From ea6829126dca8b6cc71c0c87e7fe72598a4f9022 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:12:24 +0000 Subject: [PATCH 01/11] parser: apply ASI when declare/abstract/interface/accessor precede a newline Bun's TS parser treated declare, abstract, interface (statement level and class-body level) and accessor (class body) as modifiers even when a newline followed, silently deleting the declaration that came after. esbuild and tsc both apply ASI and treat the word as a standalone identifier/field. Also fixes class-body get/set followed by '*' (esbuild parses the word as a field and '*' as a generator; bun rejected with 'Unexpected *'). Statement keywords now gate on !has_newline_before to match esbuild: TsStmtDeclare, TsStmtInterface, TsStmtAbstract, and the export-default abstract class path. Class-body declare/abstract/accessor gate on !has_newline_before. could_be_modifier_keyword now excludes '*' after get/set (when not async), matching esbuild. --- src/js_parser/parse/parse_property.rs | 8 ++- src/js_parser/parse/parse_stmt.rs | 38 +++++++++++--- test/bundler/transpiler/transpiler.test.js | 60 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/js_parser/parse/parse_property.rs b/src/js_parser/parse/parse_property.rs index c6c141787adc..167d47f17450 100644 --- a/src/js_parser/parse/parse_property.rs +++ b/src/js_parser/parse/parse_property.rs @@ -353,9 +353,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O T::TOpenBracket | T::TNumericLiteral | T::TStringLiteral - | T::TAsterisk | T::TPrivateIdentifier - ); + ) + || (p.lexer.token == T::TAsterisk + && (opts.is_async || (raw != b"get" && raw != b"set"))); // If so, check for a modifier keyword if could_be_modifier_keyword { @@ -417,6 +418,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // https://github.com/oven-sh/bun/issues/1907 if opts.is_class && Self::IS_TYPESCRIPT_ENABLED + && !p.lexer.has_newline_before && raw == b"declare" { let scope_index = p.scopes_in_order.len(); @@ -440,6 +442,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O PropertyModifierKeyword::PAbstract => { if opts.is_class && Self::IS_TYPESCRIPT_ENABLED + && !p.lexer.has_newline_before && !opts.is_ts_abstract && raw == b"abstract" { @@ -464,6 +467,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O PropertyModifierKeyword::PAccessor => { // "accessor" keyword for auto-accessor fields (TC39 standard decorators) if opts.is_class + && !p.lexer.has_newline_before && p.options.features.standard_decorators && PropertyModifierKeyword::find(raw) == Some(PropertyModifierKeyword::PAccessor) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index dbad1da7d78d..01a749718deb 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1090,6 +1090,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && is_identifier && (p.lexer.token == T::TClass || opts.ts_decorators.is_some()) && name == b"abstract" + && !p.lexer.has_newline_before && matches!(expr.data, js_ast::ExprData::EIdentifier(_)) { let mut stmt_opts = ParseStatementOptions { @@ -1765,18 +1766,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } js_lexer::TypescriptStmtKeyword::TsStmtInterface => { // "interface Foo {}" - let mut stmt_opts = ParseStatementOptions { - is_module_scope: opts.is_module_scope, - ..Default::default() - }; + // "export default interface Foo {}" + // "export default interface \n Foo {}" + if !p.lexer.has_newline_before || opts.is_name_optional { + let mut stmt_opts = ParseStatementOptions { + is_module_scope: opts.is_module_scope, + ..Default::default() + }; - p.skip_type_script_interface_stmt(&mut stmt_opts)?; - return Ok(Some(p.s(S::TypeScript {}, loc))); + p.skip_type_script_interface_stmt(&mut stmt_opts)?; + return Ok(Some(p.s(S::TypeScript {}, loc))); + } + // "interface \n Foo {}" + // "export interface \n Foo {}" + if opts.is_export { + let r = js_lexer::range_of_identifier(p.source, loc); + p.log() + .add_range_error(Some(p.source), r, b"Unexpected \"interface\""); + return Err(crate::Error::SyntaxError); + } } js_lexer::TypescriptStmtKeyword::TsStmtAbstract => { - if p.lexer.token == T::TClass || opts.ts_decorators.is_some() { + if !p.lexer.has_newline_before + && (p.lexer.token == T::TClass || opts.ts_decorators.is_some()) + { return Ok(Some(p.parse_class_stmt(loc, opts)?)); } + if opts.ts_decorators.is_some() { + p.lexer.expected(T::TClass)?; + } } js_lexer::TypescriptStmtKeyword::TsStmtGlobal => { // "declare module 'fs' { global { namespace NodeJS {} } }" @@ -1791,6 +1809,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } js_lexer::TypescriptStmtKeyword::TsStmtDeclare => { + if p.lexer.has_newline_before { + if opts.ts_decorators.is_some() { + p.lexer.expected(T::TClass)?; + } + return Ok(None); + } opts.lexical_decl = LexicalDecl::AllowAll; opts.is_typescript_declare = true; diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index bf2b3db6485c..91fe81fb731d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -176,6 +176,66 @@ describe("Bun.Transpiler", () => { exp("declare class Foo {}", ""); }); + it("contextual keywords followed by a newline apply ASI instead of acting as modifiers", () => { + const exp = ts.expectPrinted_; + const err = ts.expectParseError; + + // Statement-level "declare": a newline splits into "declare;" + the following declaration. + exp("declare\nfunction foo() { return 1 }\nfoo()", "declare;\nfunction foo() {\n return 1;\n}\nfoo();\n"); + exp("declare\nlet x = 1\nuse(x)", "declare;\nlet x = 1;\nuse(x);\n"); + exp("declare\nclass Foo {}\nnew Foo", "declare;\n\nclass Foo {\n}\nnew Foo;\n"); + exp("declare function foo(): void", ""); + exp("declare let x: number", ""); + + // Statement-level "abstract": a newline splits into "abstract;" + "class Foo {}". + exp("abstract\nclass Foo {}\nnew Foo", "abstract;\n\nclass Foo {\n}\nnew Foo;\n"); + exp("abstract class Foo { abstract bar(): void }\nnew Foo", "class Foo {\n}\nnew Foo;\n"); + + // Statement-level "interface": a newline splits into three statements. + exp("interface\nFoo\n{ sideEffect() }", "interface;\nFoo;\n{\n sideEffect();\n}"); + exp("interface Foo { x: number }", ""); + + // "export interface \n Foo {}" is a syntax error, matching esbuild. + err("export interface\nFoo {}", 'Unexpected "interface"'); + // "export default interface \n Foo {}" is allowed (the interface name can be on the next line). + exp("export default interface\nFoo {}", ""); + exp("export default interface Foo {}", ""); + + // "export default abstract \n class A {}" exports the identifier `abstract` and declares A separately. + exp( + "export default abstract\nclass A { foo() { return 1 } }\nnew A", + "export default abstract;\n\nclass A {\n foo() {\n return 1;\n }\n}\nnew A;\n", + ); + exp("export default abstract class A {}", "export default class A {\n}"); + + // Class body "declare": a newline makes it a field named "declare" followed by a method. + exp( + "class Foo { declare\n foo() { return 1 } }\nnew Foo().foo()", + "class Foo {\n declare;\n foo() {\n return 1;\n }\n}\nnew Foo().foo();\n", + ); + exp("class Foo { declare foo: number }", "class Foo {\n}"); + + // Class body "abstract": a newline makes it a field named "abstract" followed by a method. + exp( + "abstract class A { abstract\n foo() {} }\nnew A", + "class A {\n abstract;\n foo() {}\n}\nnew A;\n", + ); + exp("abstract class A { abstract foo(): void }\nnew A", "class A {\n}\nnew A;\n"); + + // Class body "accessor": a newline makes it a field named "accessor" followed by a field. + exp("class A { accessor\n x = 1 }\nnew A", "class A {\n accessor;\n x = 1;\n}\nnew A;\n"); + + // Class body "get"/"set" followed by "*": the asterisk starts a generator; the prior word is a field. + exp("class A { get\n *x() {} }\nnew A", "class A {\n get;\n *x() {}\n}\nnew A;\n"); + exp("class A { set\n *x() {} }\nnew A", "class A {\n set;\n *x() {}\n}\nnew A;\n"); + // "get"/"set" without the generator star still bind to the next key across a newline. + exp("class A { get\n x() { return 1 } }", "class A {\n get x() {\n return 1;\n }\n}"); + + // Decorators before "declare"/"abstract" with a newline must still demand a class. + err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Expected "class" but found "class"'); + err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Expected "class" but found "class"'); + }); + it("does not crash when export default abstract is an expression followed by a class", () => { const exp = ts.expectPrinted_; const err = ts.expectParseError; From 0b57016a834e7501612fd7c5b7dd3437de9fe5aa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:14:44 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- test/bundler/transpiler/transpiler.test.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 91fe81fb731d..ec3b0084b902 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -216,10 +216,7 @@ describe("Bun.Transpiler", () => { exp("class Foo { declare foo: number }", "class Foo {\n}"); // Class body "abstract": a newline makes it a field named "abstract" followed by a method. - exp( - "abstract class A { abstract\n foo() {} }\nnew A", - "class A {\n abstract;\n foo() {}\n}\nnew A;\n", - ); + exp("abstract class A { abstract\n foo() {} }\nnew A", "class A {\n abstract;\n foo() {}\n}\nnew A;\n"); exp("abstract class A { abstract foo(): void }\nnew A", "class A {\n}\nnew A;\n"); // Class body "accessor": a newline makes it a field named "accessor" followed by a field. From dbabcc1d81699ba1f9d72e8da4f601a4fbdc0937 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:28:18 +0000 Subject: [PATCH 03/11] parser: reword decorator+newline error to avoid "Expected class but found class" --- src/js_parser/parse/parse_stmt.rs | 16 ++++++++++++++-- test/bundler/transpiler/transpiler.test.js | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 01a749718deb..f724b8bfe03d 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1793,7 +1793,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Ok(Some(p.parse_class_stmt(loc, opts)?)); } if opts.ts_decorators.is_some() { - p.lexer.expected(T::TClass)?; + let r = js_lexer::range_of_identifier(p.source, loc); + p.log().add_error( + Some(p.source), + r.end(), + b"Unexpected newline after \"abstract\"", + ); + return Err(crate::Error::SyntaxError); } } js_lexer::TypescriptStmtKeyword::TsStmtGlobal => { @@ -1811,7 +1817,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_lexer::TypescriptStmtKeyword::TsStmtDeclare => { if p.lexer.has_newline_before { if opts.ts_decorators.is_some() { - p.lexer.expected(T::TClass)?; + let r = js_lexer::range_of_identifier(p.source, loc); + p.log().add_error( + Some(p.source), + r.end(), + b"Unexpected newline after \"declare\"", + ); + return Err(crate::Error::SyntaxError); } return Ok(None); } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ec3b0084b902..d7f5aa1da825 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -229,8 +229,8 @@ describe("Bun.Transpiler", () => { exp("class A { get\n x() { return 1 } }", "class A {\n get x() {\n return 1;\n }\n}"); // Decorators before "declare"/"abstract" with a newline must still demand a class. - err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Expected "class" but found "class"'); - err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Expected "class" but found "class"'); + err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Unexpected newline after "declare"'); + err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Unexpected newline after "abstract"'); }); it("does not crash when export default abstract is an expression followed by a class", () => { From 534bc365b694520cf83be7bd89169d11d1ed9e5c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:16:01 +0000 Subject: [PATCH 04/11] parser: reject "declare interface\n" / "declare abstract\n" instead of emitting live code When the newline falls after interface/abstract inside an ambient "declare" context, falling through to SExpr leaves the remaining tokens to be parsed as live runtime statements (the declare wrapper only consumes the keyword). esbuild rejects these shapes. Extend the error checks to cover is_typescript_declare and point the error at the keyword range to match esbuild, replacing the self-contradictory "Expected class but found class" wording for the decorator variants. --- src/js_parser/parse/parse_stmt.rs | 32 +++++++++++++--------- test/bundler/transpiler/transpiler.test.js | 15 ++++++++-- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index f724b8bfe03d..1426f601ebb4 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1140,7 +1140,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // "@decorator export default abstract = 1" + // "@decorator export default abstract \n class Foo {}" if opts.ts_decorators.is_some() { + if is_identifier + && name == b"abstract" + && p.lexer.has_newline_before + && matches!(expr.data, js_ast::ExprData::EIdentifier(_)) + { + let r = js_lexer::range_of_identifier(p.source, expr.loc); + p.log() + .add_range_error(Some(p.source), r, b"Unexpected \"abstract\""); + return Err(crate::Error::SyntaxError); + } p.lexer.expected(T::TClass)?; } @@ -1779,7 +1790,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // "interface \n Foo {}" // "export interface \n Foo {}" - if opts.is_export { + // "declare interface \n Foo {}" + if opts.is_export || opts.is_typescript_declare { let r = js_lexer::range_of_identifier(p.source, loc); p.log() .add_range_error(Some(p.source), r, b"Unexpected \"interface\""); @@ -1792,13 +1804,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { return Ok(Some(p.parse_class_stmt(loc, opts)?)); } - if opts.ts_decorators.is_some() { + if opts.ts_decorators.is_some() || opts.is_typescript_declare { let r = js_lexer::range_of_identifier(p.source, loc); - p.log().add_error( - Some(p.source), - r.end(), - b"Unexpected newline after \"abstract\"", - ); + p.log() + .add_range_error(Some(p.source), r, b"Unexpected \"abstract\""); return Err(crate::Error::SyntaxError); } } @@ -1816,13 +1825,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } js_lexer::TypescriptStmtKeyword::TsStmtDeclare => { if p.lexer.has_newline_before { - if opts.ts_decorators.is_some() { + if opts.ts_decorators.is_some() || opts.is_typescript_declare { let r = js_lexer::range_of_identifier(p.source, loc); - p.log().add_error( - Some(p.source), - r.end(), - b"Unexpected newline after \"declare\"", - ); + p.log() + .add_range_error(Some(p.source), r, b"Unexpected \"declare\""); return Err(crate::Error::SyntaxError); } return Ok(None); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index d7f5aa1da825..850b6eabf646 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -228,9 +228,20 @@ describe("Bun.Transpiler", () => { // "get"/"set" without the generator star still bind to the next key across a newline. exp("class A { get\n x() { return 1 } }", "class A {\n get x() {\n return 1;\n }\n}"); + // "declare " with a newline cannot fall through to SExpr because that would + // leave the remainder as live runtime code. Match esbuild and reject instead. + err("declare interface\nFoo\n{ sideEffect() }", 'Unexpected "interface"'); + err("declare abstract\nclass Foo {}", 'Unexpected "abstract"'); + err("export declare interface\nFoo {}", 'Unexpected "interface"'); + err("export declare abstract\nclass Foo {}", 'Unexpected "abstract"'); + err("declare declare\nlet x = 1", 'Unexpected "declare"'); + // "export abstract \n class" falls through silently like esbuild (export is discarded). + exp("export abstract\nclass Foo {}\nnew Foo", "abstract;\n\nclass Foo {\n}\nnew Foo;\n"); + // Decorators before "declare"/"abstract" with a newline must still demand a class. - err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Unexpected newline after "declare"'); - err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Unexpected newline after "abstract"'); + err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Unexpected "declare"'); + err("function dec(c){return c}\n@dec abstract\nclass Foo {}", 'Unexpected "abstract"'); + err("function dec(c){return c}\n@dec export default abstract\nclass Foo {}", 'Unexpected "abstract"'); }); it("does not crash when export default abstract is an expression followed by a class", () => { From 0017d64c2501fb8e57f978385c000d9602d53887 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:51:50 +0000 Subject: [PATCH 05/11] parser: validate "declare X" body via post-check instead of per-arm flag checks The per-arm is_typescript_declare checks over-rejected inside ambient namespace/global bodies (where the flag is propagated for body semantics) and for "export declare\n" (where t_export pre-set the flag redundantly), and under-covered the type/namespace/module arms. Replace with esbuild's approach: after the recursive parse inside TsStmtDeclare, reject any result that is not STypeScript/SLocal/SEmpty, pointing at the token immediately after "declare". This uniformly catches "declare interface\n", "declare abstract\n", "declare type\n", "declare namespace\n", "declare module\n", "declare declare\n", and "declare foo" while accepting all valid ambient forms and all shapes inside ambient bodies. Also drop the redundant is_typescript_declare pre-set in t_export's SDeclare arm (TsStmtDeclare sets it itself on the no-newline path). --- src/js_parser/parse/parse_stmt.rs | 29 ++++++++++++++++++---- test/bundler/transpiler/transpiler.test.js | 26 ++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 1426f601ebb4..b44f30005b03 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -924,7 +924,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "export declare class Foo {}" opts.is_export = true; opts.lexical_decl = LexicalDecl::AllowAll; - opts.is_typescript_declare = true; return p.parse_stmt(opts); } } @@ -1790,8 +1789,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // "interface \n Foo {}" // "export interface \n Foo {}" - // "declare interface \n Foo {}" - if opts.is_export || opts.is_typescript_declare { + if opts.is_export { let r = js_lexer::range_of_identifier(p.source, loc); p.log() .add_range_error(Some(p.source), r, b"Unexpected \"interface\""); @@ -1804,7 +1802,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { return Ok(Some(p.parse_class_stmt(loc, opts)?)); } - if opts.ts_decorators.is_some() || opts.is_typescript_declare { + if opts.ts_decorators.is_some() { let r = js_lexer::range_of_identifier(p.source, loc); p.log() .add_range_error(Some(p.source), r, b"Unexpected \"abstract\""); @@ -1825,7 +1823,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } js_lexer::TypescriptStmtKeyword::TsStmtDeclare => { if p.lexer.has_newline_before { - if opts.ts_decorators.is_some() || opts.is_typescript_declare { + if opts.ts_decorators.is_some() { let r = js_lexer::range_of_identifier(p.source, loc); p.log() .add_range_error(Some(p.source), r, b"Unexpected \"declare\""); @@ -1859,8 +1857,29 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // "declare const x: any" + let after_declare_range = p.lexer.range(); let scope_index = p.scopes_in_order.len(); let stmt = p.parse_stmt(opts)?; + // Anything that we don't expect is a syntax error ("declare foo", + // "declare interface \n Foo {}", "declare type \n Foo = number"). + // esbuild rewinds its lexer and calls `Unexpected()` here; we + // point at the token range captured before recursing instead. + if !matches!( + &stmt.data, + js_ast::StmtData::STypeScript(_) + | js_ast::StmtData::SLocal(_) + | js_ast::StmtData::SEmpty(_) + ) { + p.log().add_range_error_fmt( + Some(p.source), + after_declare_range, + format_args!( + "Unexpected {}", + bun_core::fmt::quote(p.source.text_for_range(after_declare_range)) + ), + ); + return Err(crate::Error::SyntaxError); + } if let Some(decs) = &opts.ts_decorators { p.discard_scopes_up_to(decs.scope_index); } else { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 850b6eabf646..8c8b96841202 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -228,15 +228,33 @@ describe("Bun.Transpiler", () => { // "get"/"set" without the generator star still bind to the next key across a newline. exp("class A { get\n x() { return 1 } }", "class A {\n get x() {\n return 1;\n }\n}"); - // "declare " with a newline cannot fall through to SExpr because that would - // leave the remainder as live runtime code. Match esbuild and reject instead. + // "declare X" where X is not a valid ambient declaration is rejected, so a + // newline-split keyword cannot leave the remainder as live runtime code. err("declare interface\nFoo\n{ sideEffect() }", 'Unexpected "interface"'); err("declare abstract\nclass Foo {}", 'Unexpected "abstract"'); + err("declare type\nFoo = number", 'Unexpected "type"'); + err("declare namespace\nFoo { sideEffect() }", 'Unexpected "namespace"'); + err("declare module\nFoo { sideEffect() }", 'Unexpected "module"'); + err("declare declare\nlet x = 1", 'Unexpected "declare"'); + err("declare foo", 'Unexpected "foo"'); err("export declare interface\nFoo {}", 'Unexpected "interface"'); err("export declare abstract\nclass Foo {}", 'Unexpected "abstract"'); - err("declare declare\nlet x = 1", 'Unexpected "declare"'); - // "export abstract \n class" falls through silently like esbuild (export is discarded). + // All valid "declare X" forms still emit nothing. + exp("declare function f(): void", ""); + exp("declare class C {}", ""); + exp("declare enum E { A }", ""); + exp("declare namespace N { let x: number }", ""); + exp("declare abstract class C {}", ""); + exp("export declare function f(): void", ""); + exp("export declare const x: number", ""); + // "export abstract \n class" and "export declare \n class" fall through silently like esbuild. exp("export abstract\nclass Foo {}\nnew Foo", "abstract;\n\nclass Foo {\n}\nnew Foo;\n"); + exp("export declare\nclass Foo {}\nnew Foo", "declare;\n\nclass Foo {\n}\nnew Foo;\n"); + // Inside an ambient body the flag is propagated for body semantics, but the whole + // block is erased regardless, so newline-split keywords in the body are harmless. + exp("declare namespace N { abstract\nclass Foo {} }", ""); + exp("declare namespace N { declare\nlet x: number }", ""); + exp("declare global { abstract\nclass Foo {} }\nexport {}", "export {};\n"); // Decorators before "declare"/"abstract" with a newline must still demand a class. err("function dec(c){return c}\n@dec declare\nclass Foo {}", 'Unexpected "declare"'); From 850393fa0e3d1e274ec90308a439b612774d8203 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:54:25 +0000 Subject: [PATCH 06/11] test: add export-declare-let and string-literal-module ambient body cases --- test/bundler/transpiler/transpiler.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 8c8b96841202..8c426eb90f1c 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -250,10 +250,12 @@ describe("Bun.Transpiler", () => { // "export abstract \n class" and "export declare \n class" fall through silently like esbuild. exp("export abstract\nclass Foo {}\nnew Foo", "abstract;\n\nclass Foo {\n}\nnew Foo;\n"); exp("export declare\nclass Foo {}\nnew Foo", "declare;\n\nclass Foo {\n}\nnew Foo;\n"); + exp("export declare\nlet x = 1\nuse(x)", "declare;\nlet x = 1;\nuse(x);\n"); // Inside an ambient body the flag is propagated for body semantics, but the whole // block is erased regardless, so newline-split keywords in the body are harmless. exp("declare namespace N { abstract\nclass Foo {} }", ""); exp("declare namespace N { declare\nlet x: number }", ""); + exp('declare module "m" { abstract\n class Foo {} }', ""); exp("declare global { abstract\nclass Foo {} }\nexport {}", "export {};\n"); // Decorators before "declare"/"abstract" with a newline must still demand a class. From 99a65659fd5805d2876a03ff4d0ff35797fb2f7d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:16:40 +0000 Subject: [PATCH 07/11] test: update scope-mismatch-panic cases that are now parse errors "declare foo: bar" and "declare module : es2015" are now rejected with Unexpected "foo"/"module" (matching esbuild) instead of being silently dropped, so they no longer exercise the scope-discard codepath. Move the error assertions to transpiler.test.js alongside the other declare-body validation cases. --- test/bundler/transpiler/scope-mismatch-panic.test.ts | 8 ++------ test/bundler/transpiler/transpiler.test.js | 2 ++ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/test/bundler/transpiler/scope-mismatch-panic.test.ts b/test/bundler/transpiler/scope-mismatch-panic.test.ts index a7712c98037f..6aed9afa410a 100644 --- a/test/bundler/transpiler/scope-mismatch-panic.test.ts +++ b/test/bundler/transpiler/scope-mismatch-panic.test.ts @@ -101,13 +101,9 @@ describe("TypeScript 'declare' statements discard scopes of dropped statements", // Each of these parses a statement after "declare" that records scopes during the // parse pass and is then dropped. The recorded scopes used to be left behind, so // visiting the following class statement hit "Scope mismatch while visiting". + // Two earlier cases ("declare foo: bar" and "declare module : es2015") have since + // become parse errors (matching esbuild) and are covered in transpiler.test.js. const cases: [name: string, source: string, expected: string[]][] = [ - [ - "declare module with an invalid name followed by a class", - "declare module : es2015\nclass Foo {}\n", - ["class Foo"], - ], - ["declare followed by a labeled statement and a class", "declare foo: bar\nclass Foo {}\n", ["class Foo"]], [ "declare const with an arrow function initializer followed by a class", "declare const x = () => {};\nclass Foo {}\n", diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 8c426eb90f1c..4f0c0983e7f6 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -237,6 +237,8 @@ describe("Bun.Transpiler", () => { err("declare module\nFoo { sideEffect() }", 'Unexpected "module"'); err("declare declare\nlet x = 1", 'Unexpected "declare"'); err("declare foo", 'Unexpected "foo"'); + err("declare foo: bar", 'Unexpected "foo"'); + err("declare module : es2015", 'Unexpected "module"'); err("export declare interface\nFoo {}", 'Unexpected "interface"'); err("export declare abstract\nclass Foo {}", 'Unexpected "abstract"'); // All valid "declare X" forms still emit nothing. From bb9014b4a4650d830360b60bd77662b207c29c2f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:27:37 +0000 Subject: [PATCH 08/11] test(scope-mismatch-panic): resolve react from test/node_modules explicitly The JSX fixture runs from a tempDir with no node_modules of its own, so react/jsx-dev-runtime resolution depends on NODE_PATH being set in the ambient environment. Set it explicitly so the test passes in environments that do not pre-seed NODE_PATH. --- test/bundler/transpiler/scope-mismatch-panic.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/bundler/transpiler/scope-mismatch-panic.test.ts b/test/bundler/transpiler/scope-mismatch-panic.test.ts index 6aed9afa410a..9d6d5b6f460f 100644 --- a/test/bundler/transpiler/scope-mismatch-panic.test.ts +++ b/test/bundler/transpiler/scope-mismatch-panic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; describe("scope mismatch panic regression test", () => { test("should not panic with scope mismatch when arrow function is followed by array literal", async () => { @@ -28,7 +29,7 @@ const Layout = () => { // With the fix, it should fail with a normal ReferenceError for 'app' await using proc = Bun.spawn({ cmd: [bunExe(), "index.tsx"], - env: bunEnv, + env: { ...bunEnv, NODE_PATH: join(import.meta.dir, "..", "..", "node_modules") }, cwd: String(dir), stderr: "pipe", stdout: "pipe", From 92025ba548dbb44131ecc5d187ea601c827130f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:43:15 +0000 Subject: [PATCH 09/11] style: tighten declare post-check comment to 2 lines --- src/js_parser/parse/parse_stmt.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index b44f30005b03..686d1cdbb691 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1860,10 +1860,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let after_declare_range = p.lexer.range(); let scope_index = p.scopes_in_order.len(); let stmt = p.parse_stmt(opts)?; - // Anything that we don't expect is a syntax error ("declare foo", - // "declare interface \n Foo {}", "declare type \n Foo = number"). - // esbuild rewinds its lexer and calls `Unexpected()` here; we - // point at the token range captured before recursing instead. + // Anything unexpected is a syntax error ("declare foo", "declare type \n Foo"). + // esbuild rewinds its lexer here; we point at the range captured above instead. if !matches!( &stmt.data, js_ast::StmtData::STypeScript(_) From 21df53534222fb848844a30e04619a0b13996951 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:17:41 +0000 Subject: [PATCH 10/11] ci: retrigger From 8e0d9d11c178dac002e4cb6da49aef8727bd1976 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:35:08 +0000 Subject: [PATCH 11/11] style: drop bug-history comment in scope-mismatch-panic test --- test/bundler/transpiler/scope-mismatch-panic.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/bundler/transpiler/scope-mismatch-panic.test.ts b/test/bundler/transpiler/scope-mismatch-panic.test.ts index 9d6d5b6f460f..f97f12d6c32d 100644 --- a/test/bundler/transpiler/scope-mismatch-panic.test.ts +++ b/test/bundler/transpiler/scope-mismatch-panic.test.ts @@ -102,8 +102,6 @@ describe("TypeScript 'declare' statements discard scopes of dropped statements", // Each of these parses a statement after "declare" that records scopes during the // parse pass and is then dropped. The recorded scopes used to be left behind, so // visiting the following class statement hit "Scope mismatch while visiting". - // Two earlier cases ("declare foo: bar" and "declare module : es2015") have since - // become parse errors (matching esbuild) and are covered in transpiler.test.js. const cases: [name: string, source: string, expected: string[]][] = [ [ "declare const with an arrow function initializer followed by a class",