diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d981eb71862d..a2871c8c62b2 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8297,6 +8297,27 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.import_records.truncate(snapshot.import_records_len); } + /// Runs `attempt` with the log off, then undoes everything it did to the parser. `None` means the attempt failed. Stack and memory exhaustion are not properties of the attempt and pass through. + #[inline] + pub(crate) fn speculate( + &mut self, + attempt: impl FnOnce(&mut Self) -> Result, + ) -> Result, crate::Error> { + let snapshot = self.parser_snapshot(); + self.lexer.is_log_disabled = true; + // Metadata looks up type names, which adds unbound symbols to the module scope. + let emit_decorator_metadata = + core::mem::take(&mut self.options.features.emit_decorator_metadata); + let result = attempt(self); + self.restore_parser_snapshot(snapshot); + self.options.features.emit_decorator_metadata = emit_decorator_metadata; + match result { + Ok(value) => Ok(Some(value)), + Err(err @ (crate::Error::StackOverflow | crate::Error::Alloc(_))) => Err(err), + Err(_) => Ok(None), + } + } + /// When not transpiling we dont use the renamer, so our solution is to generate really /// hard to collide with variables, instead of actually making things collision free pub(crate) fn generate_temp_ref(&mut self, default_name: Option<&'a [u8]>) -> Ref { diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index ff7f7d82305b..627716853f2b 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -44,12 +44,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O errors: Option<&mut DeferredErrors>, expr: &mut Expr, ) -> Result<(), Error> { - self.parse_expr_common(level, errors, EFlags::None, expr) + self.parse_expr_common::(level, errors, EFlags::None, expr) } #[inline] pub fn parse_expr(&mut self, level: Level) -> Result { let mut expr = Expr::EMPTY; - self.parse_expr_common(level, None, EFlags::None, &mut expr)?; + self.parse_expr_common::(level, None, EFlags::None, &mut expr)?; Ok(expr) } #[inline] @@ -59,9 +59,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O flags: EFlags, expr: &mut Expr, ) -> Result<(), Error> { - self.parse_expr_common(level, None, flags, expr) + self.parse_expr_common::(level, None, flags, expr) } - pub(crate) fn parse_expr_common( + /// With `STOP_BEFORE_NAME`, an identifier after the prefix ends the expression, so the caller can read it as a name. + pub(crate) fn parse_expr_common( &mut self, level: Level, mut errors: Option<&mut DeferredErrors>, @@ -75,6 +76,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let had_pure_comment_before = self.lexer.has_pure_comment_before && !self.options.ignore_dce_annotations; *expr = self.parse_prefix(level, errors.as_deref_mut(), flags)?; + if STOP_BEFORE_NAME && self.lexer.token == T::TIdentifier { + return Ok(()); + } // `errors` is reborrowed via as_deref_mut for each call site. // There is no formal spec for "__PURE__" comments but from reverse- diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index ef1b8d8d917e..66814ea93e84 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -1612,30 +1612,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Ok(self.ts_conditional_arrow_attempts[i] & 1 == 1); } - let snapshot = self.parser_snapshot(); - self.lexer.is_log_disabled = true; - // Metadata looks up type names, which adds unbound symbols to the module scope. - let emit_decorator_metadata = - core::mem::take(&mut self.options.features.emit_decorator_metadata); - let mut data = arrow_data.clone(); - let result: Result<(), Error> = (|| { - self.lexer.expect(T::TColon)?; - self.skip_typescript_return_type()?; - self.parse_arrow_body(&mut [], &mut data)?; - // The ":" that pairs with the "?" - self.lexer.expect(T::TColon)?; - Ok(()) - })(); - - self.restore_parser_snapshot(snapshot); - self.options.features.emit_decorator_metadata = emit_decorator_metadata; - let is_arrow_fn = match result { - Ok(()) => true, - // Stack and memory exhaustion are not properties of the attempt - Err(err @ (Error::StackOverflow | Error::Alloc(_))) => return Err(err), - Err(_) => false, - }; + let is_arrow_fn = self + .speculate(|p| { + p.lexer.expect(T::TColon)?; + p.skip_typescript_return_type()?; + p.parse_arrow_body(&mut [], &mut data)?; + // The ":" that pairs with the "?" + p.lexer.expect(T::TColon)?; + Ok(()) + })? + .is_some(); // Re-search for the insertion point: attempts nested inside this one may // have added entries of their own. diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 856983870baf..16e83d47eccb 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -14,7 +14,7 @@ use js_lexer::T; use crate::parser::fs; use crate::parser::{ AwaitOrYield, DeferredTsDecorators, LexicalDecl, ParseStatementOptions, ParsedPath, Ref, - StatementScope, StmtList, + StatementScope, StmtList, TypeParameterFlag, }; use crate::typescript; use bun_ast::{ImportKind, ImportRecordFlags, ImportRecordTag}; @@ -1738,6 +1738,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O expr = p.parse_async_prefix_expr(async_range, Level::Lowest, EFlags::None)?; p.parse_suffix(&mut expr, Level::Lowest, None, EFlags::None)?; + } else if Self::IS_TYPESCRIPT_ENABLED + && is_identifier + && matches!(name, b"type" | b"interface" | b"namespace" | b"module") + { + expr = Self::parse_ts_decl_keyword_expr(p, name, opts.scope)?; } else { let expr_or_let = p.parse_expr_or_let_stmt(opts)?; match expr_or_let.stmt_or_expr { @@ -1781,6 +1786,49 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O )) } + /// The expression that starts a statement led by `type`, `interface`, `namespace` or `module`. It ends before the name of a declaration. + #[cold] + #[inline(never)] + fn parse_ts_decl_keyword_expr( + p: &mut Self, + keyword: &[u8], + scope: StatementScope, + ) -> Result { + let mut expr = Expr::EMPTY; + p.parse_expr_common::(Level::Lowest, None, EFlags::None, &mut expr)?; + if (p.lexer.is_contextual_keyword(b"as") || p.lexer.is_contextual_keyword(b"satisfies")) + && !Self::ts_decl_follows_name(p, keyword, scope)? + { + // "type as any;" casts a variable named `type`, as in esbuild. tsc reads a declaration and rejects it (microsoft/TypeScript#58248). + p.parse_suffix(&mut expr, Level::Lowest, None, EFlags::None)?; + } + Ok(expr) + } + + /// Looks past the name after `keyword`. Returns whether the rest of a declaration with that name follows ("type as = any"). + #[cold] + #[inline(never)] + fn ts_decl_follows_name(p: &mut Self, keyword: &[u8], scope: StatementScope) -> Result { + let follows = p.speculate(|p| { + p.lexer.next()?; + if matches!(keyword, b"type" | b"interface") { + let _ = p.skip_type_script_type_parameters( + TypeParameterFlag::ALLOW_IN_OUT_VARIANCE_ANNOTATIONS + | TypeParameterFlag::ALLOW_EMPTY_TYPE_PARAMETERS, + )?; + } + Ok(match keyword { + b"type" => p.lexer.token == T::TEquals, + b"interface" => matches!(p.lexer.token, T::TOpenBrace | T::TExtends), + _ => { + scope != StatementScope::Nested + && matches!(p.lexer.token, T::TOpenBrace | T::TDot) + } + }) + })?; + Ok(follows == Some(true)) + } + /// Cold TS-only statement keywords reached from `parse_stmt_fallthrough` once the /// leading identifier has been recognised as one of the contextual statement keywords. /// Returns `Some(stmt)` when the keyword form was consumed; `None` means the caller diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 8d40a1bcfd28..c4ab84edf108 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -77,7 +77,8 @@ bun_core::declare_scope!(cache, visible); /// Version 42: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 43: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 43; +/// Version 44: A TypeScript `interface` or `namespace` named `as` or `satisfies` is a declaration, not the statement `interface;`. +const EXPECTED_VERSION: u32 = 44; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/esbuild/dce.test.ts b/test/bundler/esbuild/dce.test.ts index f3f159d484c3..34a73b725055 100644 --- a/test/bundler/esbuild/dce.test.ts +++ b/test/bundler/esbuild/dce.test.ts @@ -3510,6 +3510,27 @@ describe("bundler", () => { stdout: "PASS", }, }); + itBundled("dce/IgnoreAnnotationsCallOfTypeScriptDeclarationKeyword", { + files: { + "/entry.ts": /* ts */ ` + import { as, satisfies } from "./names"; + function type(n: number) { console.log("type", n); } + /* @__PURE__ */ type(as.one); + /* @__PURE__ */ type(satisfies.two) as any; + console.log("done"); + `, + "/names.ts": /* ts */ ` + export type as = number; + export namespace as { export const one: as = 1; } + namespace satisfies { export const two = 2; } + export { satisfies }; + `, + }, + ignoreDCEAnnotations: true, + run: { + stdout: "type 1\ntype 2\ndone", + }, + }); itBundled("dce/IgnoreAnnotationsDoesNotApplyToRuntime", { files: { "/entry.js": /* js */ ` diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2d68d35449d2..c93f1d00ce58 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -598,6 +598,225 @@ describe("Bun.Transpiler", () => { exp("declare class Foo {}", ""); }); + describe("a declaration named `as` or `satisfies`", () => { + // After `type`, `interface`, `namespace` or `module`, `as` or `satisfies` is the name + // when a declaration follows it. If not, it is the cast operator. + const names = ["as", "satisfies"]; + + const declarations = [ + "type NAME = number;", + "type NAME = T[];", + "type NAME<> = number;", + "type NAME\n= number;", + "type /* comment */ NAME = number;", + "/* @__PURE__ */ type NAME = number;", + "declare type NAME = number;", + "export declare type NAME = number;", + "interface NAME { a: number }", + "interface NAME { a: T }", + "interface NAME extends Bar { a: number }", + "interface NAME\n{ a: number }", + "declare interface NAME {}", + "export interface NAME {}", + "export declare interface NAME {}", + "export default interface NAME {}", + "namespace NAME { export const x = 1 }", + "namespace NAME\n{ export const x = 1 }", + "namespace NAME.b { export const x = 1 }", + "module NAME { export const x = 1 }", + "module NAME.b { export const x = 1 }", + "export namespace NAME { export const x = 1 }", + "declare namespace NAME { const x: number }", + "declare module NAME { const x: number }", + "export declare namespace NAME { const x: number }", + "namespace N { type NAME = number; export interface NAME {} }", + "function f() { type NAME = number; interface NAME {} }", + "{ type NAME = number }", + "if (x) type NAME = number;", + "label: type NAME = number;", + "class C { static { type NAME = number; } }", + "declare global { type NAME = number; interface NAME {} }", + 'declare module "m" { type NAME = number; namespace NAME {} }', + ]; + + it.each(declarations)("%j gives the output of the same declaration named Foo", template => { + const run = (name, loader) => { + const code = template.replaceAll("NAME", name) + "\nkeep();"; + return { + transformSync: transpiler.transformSync(code, loader), + scan: transpiler.scan(code, loader), + scanImports: transpiler.scanImports(code, loader), + }; + }; + for (const loader of ["ts", "tsx"]) { + const control = run("Foo", loader); + expect(control.transformSync).toEndWith("keep();\n"); + for (const name of names) { + expect(run(name, loader)).toEqual(JSON.parse(JSON.stringify(control).replaceAll("Foo", name))); + } + } + }); + + it("the type-only declarations are removed from the output", async () => { + const cases = [ + ["type as = number;\nconst n: as = 1;\nexport { n };", "const n = 1;\n\nexport { n };\n", ["n"]], + [ + "interface as { a: number }\nconst o: as = { a: 1 };\nexport { o };", + "const o = { a: 1 };\n\nexport { o };\n", + ["o"], + ], + ]; + for (const [code, output, exports] of cases) { + expect({ + transformSync: transpiler.transformSync(code, "ts"), + transform: await transpiler.transform(code, "ts"), + scan: transpiler.scan(code, "ts"), + scanImports: transpiler.scanImports(code, "ts"), + }).toEqual({ + transformSync: output, + transform: output, + scan: { exports, imports: [] }, + scanImports: [], + }); + } + }); + + it("the keyword is an identifier when no name follows it on the line", () => { + const exp = ts.expectPrinted_; + + // A line break after the keyword ends the statement. + exp("type\nas;", "type;\nas;\n"); + exp("type\nas = 3;", "type;\nas = 3;\n"); + exp("namespace\nsatisfies;", "namespace;\nsatisfies;\n"); + exp("var interface, as; interface\nas\n{}", "var interface, as;\n{}"); + + // A token that is not an identifier after the keyword continues the expression. + exp("type! as number;", "type;\n"); + exp("type.x as number;", "type.x;\n"); + exp("type++ as any;", "type++;\n"); + exp("module.exports = x as any;", "module.exports = x;\n"); + exp("type => 1\nas;", "as;\n"); + exp("type();", "type();\n"); + exp("module.foo();", "module.foo();\n"); + exp("/* @__PURE__ */ type();", ""); + exp("/* @__PURE__ */ module.foo() as any;", ""); + + // The keyword does not start the statement. + exp("var type = 1; var y = type as number;", "var type = 1;\nvar y = type;\n"); + exp("(type as number);", "type;\n"); + exp("x = interface satisfies any;", "x = interface;\n"); + exp("x(type => type as any);", "x((type) => type);\n"); + exp("for (type as any;;) {}", "for (type;; ) {}"); + exp("for (module satisfies any of []) {}", "for (module of []) {}"); + }); + + it("a cast of a variable that has the name of the keyword stays a cast", () => { + const exp = ts.expectPrinted_; + const err = ts.expectParseError; + + // tsc reads each of these as a declaration and rejects it (microsoft/TypeScript#58248). + exp("type as number;", "type;\n"); + exp("type satisfies never;", "type;\n"); + exp("type as any as number;", "type;\n"); + exp("type as\nnumber;", "type;\n"); + exp("type as { a: number };", "type;\n"); + exp("switch (type) { default: type satisfies never; }", "switch (type) {\n default:\n type;\n}"); + exp("function f(module: unknown) { module satisfies object; }", "function f(module) {}"); + exp("namespace as number;", "namespace;\n"); + exp("namespace satisfies object;", "namespace;\n"); + + // Type parameters start a type alias only when "=" follows them. + exp("type as () => T;", "type;\n"); + exp("type satisfies (x: T) => T;", "type;\n"); + + // A namespace is not a declaration in a nested scope. + exp("{ namespace as {} }", "{\n namespace;\n}"); + exp("function f(namespace: any) { namespace as {}; return 1 }", "function f(namespace) {\n return 1;\n}"); + exp("{ module as {} }", "{}"); + exp("function f(module: any) { module as {}; return 1 }", "function f(module) {\n return 1;\n}"); + + // `interface` followed by a token on the same line is always read as a declaration. + exp("var interface: any; interface as number\nfoo();", "var interface;\nfoo();\n"); + err("interface as number;", 'Expected identifier but found ";"'); + }); + + it("`{` after the name starts a declaration, also where main read a cast to an object type", () => { + const exp = ts.expectPrinted_; + const err = ts.expectParseError; + + exp("interface as { a: number }\nkeep();", "keep();\n"); + exp("namespace as {}\nkeep();", "keep();\n"); + // As in tsc, the body is a labeled statement. A type checker reports TS2693 for it. + exp("namespace as { a: number };", "var as;\n((as) => {\n a:\n number;\n})(as ||= {});\n"); + + // tsc rejects these. They are casts on main and in esbuild. + err("var namespace: any = {}; namespace as { a?: number };", "Unexpected :"); + err("namespace as {} | null;", "Unexpected |"); + err("module satisfies { exports: unknown } | undefined;", "Unexpected |"); + err("var interface: any;\ninterface as { a: number } | null\nfoo();", "Unexpected |"); + }); + + it("only the first token of a statement is tested for the keyword", () => { + const exp = ts.expectPrinted_; + + exp("y = namespace;\n(z) as { a: number };", "y = namespace;\nz;\n"); + exp("f(module);\n[z()] satisfies { length: number };", "f(module);\nz();\n"); + exp("a.interface = 1;\n(b) as { a: number };", "a.interface = 1;\nb;\n"); + exp("a.type = 1;\n-b satisfies number;", "a.type = 1;\n-b;\n"); + }); + + it("the js and jsx loaders have no such declarations", () => { + for (const loader of ["js", "jsx"]) { + for (const [code, message] of [ + ["type as = 1;", 'Expected ";" but found "as"'], + ["type satisfies = T;", 'Expected ";" but found "satisfies"'], + ["interface as {}", 'Expected ";" but found "as"'], + ["interface satisfies extends Bar {}", 'Expected ";" but found "satisfies"'], + ]) { + const actual = {}; + for (const api of ["transformSync", "scan", "scanImports"]) { + try { + transpiler[api](code, loader); + actual[api] = "did not throw"; + } catch (e) { + actual[api] = (e instanceof AggregateError ? e.errors[0] : e).message; + } + } + expect(actual).toEqual({ transformSync: message, scan: message, scanImports: message }); + } + expect(transpiler.transformSync("type\nas = 3;", loader)).toBe("type;\nas = 3;\n"); + } + }); + + it("bun runs a file that declares and uses them", async () => { + using dir = tempDir("ts-declaration-named-as", { + "index.ts": ` + type as = number; + interface satisfies { + a: as; + } + namespace as { + export const one: as = 1; + } + export namespace satisfies { + export const two: as = as.one + 1; + } + const n: as = as.one; + const o: satisfies = { a: satisfies.two }; + console.log(JSON.stringify([n, o])); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: '[1,{"a":2}]\n', stderr: "", exitCode: 0 }); + }); + }); + it("contextual keywords followed by a newline apply ASI instead of acting as modifiers", () => { const exp = ts.expectPrinted_; const err = ts.expectParseError; @@ -5802,6 +6021,8 @@ it("deeply nested TypeScript types error instead of crashing the process", () => }, // deeply nested tuple type in a type alias { loader: "ts", code: n => "type A = " + repeat("[", n) + "0" + repeat("]", n) + ";" }, + // the same in the type parameters of an alias named "as", which the parser first looks past + { loader: "ts", code: n => "type as = T;" }, // deeply nested destructuring pattern in a function type's arguments { loader: "ts", code: n => "type A = (" + repeat("[", n) + "a" + repeat("]", n) + ": any) => void;" }, ];