From d9bda2457a1c3fd46f7deaab52efd460a720c5cb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:34:56 +0000 Subject: [PATCH 1/9] parser: preserve comments inside function parameter lists Karma's `di` package and AngularJS's $injector parse `Function.prototype.toString()` to discover DI tokens from comment-annotated parameters: exports.create = function (/* config */ c, /* config.proxies */ p) {} Bun's runtime transpiler was discarding these comments entirely, so `di` saw bare parameter names and failed with "No provider for \"proxies\"!" when starting Karma. This change makes the lexer capture comments while scanning a function parameter list, stores them on `G::Arg`, and re-emits them in `print_fn_args` (skipped under `--minify`). Line comments are rewritten as block comments so they don't swallow the rest of the argument list. Fixes #13451 --- src/ast/g.rs | 8 ++ src/js_parser/parse/parse_fn.rs | 28 ++++++ src/js_printer/lib.rs | 21 +++++ test/regression/issue/13451/13451.test.ts | 108 ++++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 test/regression/issue/13451/13451.test.ts diff --git a/src/ast/g.rs b/src/ast/g.rs index 92cd31a4712b..9636703e7b16 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -337,6 +337,12 @@ pub struct Arg { pub is_typescript_ctor_field: bool, pub ts_metadata: TypeScript::Metadata, + + // Comments that appeared immediately before this argument's binding in the + // source, e.g. `function(/* config.proxies */ proxies) {}`. Preserved so + // that `Function.prototype.toString()` round-trips the DI annotation style + // used by AngularJS / karma's `di` package. + pub leading_comments: StoreSlice, } impl Default for Arg { @@ -347,6 +353,7 @@ impl Default for Arg { default: None, is_typescript_ctor_field: false, ts_metadata: TypeScript::Metadata::MNone, + leading_comments: StoreSlice::EMPTY, } } } @@ -368,6 +375,7 @@ impl Arg { }, is_typescript_ctor_field: self.is_typescript_ctor_field, ts_metadata: self.ts_metadata.clone(), + leading_comments: self.leading_comments, }) } } diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 6a58ff9ae02c..2a36882f8704 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -160,6 +160,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(p.s(S::Function { func }, loc)) } + fn take_arg_leading_comments( + &mut self, + base: usize, + ) -> bun_ast::StoreSlice { + let buf = &mut self.lexer.comments_to_preserve_before; + if buf.len() <= base { + return bun_ast::StoreSlice::EMPTY; + } + let slice = self.arena.alloc_slice_fill_iter(buf.drain(base..)); + bun_ast::StoreSlice::new_mut(slice) + } + pub(crate) fn parse_fn( &mut self, name: Option, @@ -185,6 +197,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O open_parens_loc: p.lexer.loc(), ..Default::default() }; + + // Capture `/* ... */` comments inside the parameter list so they + // survive into `Function.prototype.toString()`. Only entries pushed + // past `comments_base` are ours; anything already in the buffer (legal + // comments from earlier in the statement) is left in place. + let old_preserve_comments = p.lexer.preserve_all_comments_before; + let comments_base = p.lexer.comments_to_preserve_before.len(); + p.lexer.preserve_all_comments_before = true; p.lexer.expect(T::TOpenParen)?; // Await and yield are not allowed in function arguments @@ -244,6 +264,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O func.flags.insert(Flags::Function::HasRestArg); } + let leading_comments = p.take_arg_leading_comments(comments_base); + let mut is_typescript_ctor_field = false; let is_identifier = p.lexer.token == T::TIdentifier; let mut text = p.lexer.identifier; @@ -325,6 +347,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // We need to track this because it affects code generation is_typescript_ctor_field, ts_metadata, + leading_comments, }); if p.lexer.token != T::TComma { @@ -346,6 +369,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.next()?; rest_arg = false; } + // Drop any comments captured between the last arg and `)`; they would + // otherwise surface as top-level `SComment` statements in the body. + p.lexer.comments_to_preserve_before.truncate(comments_base); + p.lexer.preserve_all_comments_before = old_preserve_comments; + if !args.is_empty() { func.args = bun_ast::StoreSlice::new_mut(args.into_bump_slice_mut()); } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index ea6bc77a3ae1..89ca380740ac 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2108,6 +2108,27 @@ pub(crate) mod __gated_printer { self.print_space(); } + if !self.options.minify_whitespace { + for comment in arg.leading_comments.slice() { + let text = comment.text.slice(); + if text.starts_with(b"//") { + // A line comment here would swallow the rest of the + // parameter list on one line; re-emit as a block + // comment. Skip if the body contains "*/" since + // that would terminate the block early. + let body = &text[2..]; + if !strings::contains(body, b"*/") { + self.print(b"/*"); + self.print(body); + self.print(b" */ "); + } + } else { + self.print(text); + self.print(b" "); + } + } + } + if has_rest_arg && i + 1 == args.len() { self.print(b"..."); } diff --git a/test/regression/issue/13451/13451.test.ts b/test/regression/issue/13451/13451.test.ts new file mode 100644 index 000000000000..c2040bd1165c --- /dev/null +++ b/test/regression/issue/13451/13451.test.ts @@ -0,0 +1,108 @@ +import { test, expect } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/13451 +// +// Karma's `di` package (and AngularJS $injector) parse Function.prototype.toString() +// to extract DI tokens from /* comment */-annotated parameter names, e.g. +// +// exports.create = function (/* config */ config, /* config.proxies */ proxies) { ... } +// +// Bun's runtime transpiler was discarding those comments, so `di` saw `proxies` +// instead of `config.proxies` and failed with "No provider for "proxies"!". + +const FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; +const FN_ARG = /\/\*([^\*]*)\*\//m; + +function diParse(src: string): string[] { + const match = src.match(FN_ARGS); + if (!match || !match[1]) return []; + return match[1].split(",").map(arg => { + const m = arg.match(FN_ARG); + return m ? m[1].trim() : arg.trim(); + }); +} + +test("inline /* */ comments in function parameter lists survive the runtime transpiler", async () => { + using dir = tempDir("issue-13451", { + "mod.cjs": ` +exports.anonExpr = function (/* a */ x, /* b.c */ y, /* d */ z) { + return [x, y, z]; +}; + +function decl(/* one */ p, /* two */ q) { + return [p, q]; +} +exports.decl = decl; + +exports.obj = { + method(/* inner */ v) { + return v; + }, +}; + +exports.karma = function (/* config */ config, /* config.proxies */ proxies, /* emitter */ emitter) { + return [config, proxies, emitter]; +}; + +exports.lineComment = function ( + // token + value +) { + return value; +}; +`, + "main.cjs": ` +const m = require("./mod.cjs"); +console.log(JSON.stringify({ + anonExpr: m.anonExpr.toString(), + decl: m.decl.toString(), + method: m.obj.method.toString(), + karma: m.karma.toString(), + lineComment: m.lineComment.toString(), +})); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.cjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).toBe(""); + + const results = JSON.parse(stdout); + + // anonymous function expression + expect(results.anonExpr).toContain("/* a */"); + expect(results.anonExpr).toContain("/* b.c */"); + expect(results.anonExpr).toContain("/* d */"); + expect(diParse(results.anonExpr)).toEqual(["a", "b.c", "d"]); + + // named function declaration + expect(results.decl).toContain("/* one */"); + expect(results.decl).toContain("/* two */"); + expect(diParse(results.decl)).toEqual(["one", "two"]); + + // object method shorthand + expect(results.method).toContain("/* inner */"); + + // karma's exact pattern from middleware/proxy.js + expect(results.karma).toContain("/* config */"); + expect(results.karma).toContain("/* config.proxies */"); + expect(results.karma).toContain("/* emitter */"); + expect(diParse(results.karma)).toEqual(["config", "config.proxies", "emitter"]); + + // line comment converted to block (otherwise it would swallow the arg list) + expect(results.lineComment).not.toMatch(/\/\/[^\n]*\)/); + expect(results.lineComment).toContain("token"); + + expect(exitCode).toBe(0); +}); From 421a7bf056da9ed669eeb63e209538bc79eedcfa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:37:40 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- src/js_parser/parse/parse_fn.rs | 5 +---- test/regression/issue/13451/13451.test.ts | 8 ++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 2a36882f8704..8d9d89d7a9df 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -160,10 +160,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(p.s(S::Function { func }, loc)) } - fn take_arg_leading_comments( - &mut self, - base: usize, - ) -> bun_ast::StoreSlice { + fn take_arg_leading_comments(&mut self, base: usize) -> bun_ast::StoreSlice { let buf = &mut self.lexer.comments_to_preserve_before; if buf.len() <= base { return bun_ast::StoreSlice::EMPTY; diff --git a/test/regression/issue/13451/13451.test.ts b/test/regression/issue/13451/13451.test.ts index c2040bd1165c..adcbe11eb6a4 100644 --- a/test/regression/issue/13451/13451.test.ts +++ b/test/regression/issue/13451/13451.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test"; +import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; // https://github.com/oven-sh/bun/issues/13451 @@ -71,11 +71,7 @@ console.log(JSON.stringify({ stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const results = JSON.parse(stdout); From 8e58894eb097b62cdeaedad879732884815ff7ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:47:18 +0000 Subject: [PATCH 3/9] trim comments per comment-cop --- src/ast/g.rs | 6 ++---- src/js_parser/parse/parse_fn.rs | 9 +++------ src/js_printer/lib.rs | 6 ++---- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/ast/g.rs b/src/ast/g.rs index 9636703e7b16..9a046a09e175 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -338,10 +338,8 @@ pub struct Arg { pub ts_metadata: TypeScript::Metadata, - // Comments that appeared immediately before this argument's binding in the - // source, e.g. `function(/* config.proxies */ proxies) {}`. Preserved so - // that `Function.prototype.toString()` round-trips the DI annotation style - // used by AngularJS / karma's `di` package. + // `function(/* token */ arg)` — kept so Function.prototype.toString() + // preserves DI-style annotations (AngularJS / karma `di`). pub leading_comments: StoreSlice, } diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 8d9d89d7a9df..2191412876a6 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -195,10 +195,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ..Default::default() }; - // Capture `/* ... */` comments inside the parameter list so they - // survive into `Function.prototype.toString()`. Only entries pushed - // past `comments_base` are ours; anything already in the buffer (legal - // comments from earlier in the statement) is left in place. + // Keep comments inside `(...)` for Function.prototype.toString(). + // Only entries past `comments_base` belong to this parameter list. let old_preserve_comments = p.lexer.preserve_all_comments_before; let comments_base = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = true; @@ -366,8 +364,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.next()?; rest_arg = false; } - // Drop any comments captured between the last arg and `)`; they would - // otherwise surface as top-level `SComment` statements in the body. + // Drop any trailing captured comments so they don't leak into the body. p.lexer.comments_to_preserve_before.truncate(comments_base); p.lexer.preserve_all_comments_before = old_preserve_comments; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 89ca380740ac..7f0f6645c7e5 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2112,10 +2112,8 @@ pub(crate) mod __gated_printer { for comment in arg.leading_comments.slice() { let text = comment.text.slice(); if text.starts_with(b"//") { - // A line comment here would swallow the rest of the - // parameter list on one line; re-emit as a block - // comment. Skip if the body contains "*/" since - // that would terminate the block early. + // Re-emit as a block comment so it doesn't swallow + // the rest of the line; drop if unrepresentable. let body = &text[2..]; if !strings::contains(body, b"*/") { self.print(b"/*"); From 3afb3d4a781503775fc9ac942bb060907ff72826 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:53:55 +0000 Subject: [PATCH 4/9] single-line comments --- src/ast/g.rs | 3 +-- src/js_parser/parse/parse_fn.rs | 3 +-- src/js_printer/lib.rs | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ast/g.rs b/src/ast/g.rs index 9a046a09e175..b4e5907308fb 100644 --- a/src/ast/g.rs +++ b/src/ast/g.rs @@ -338,8 +338,7 @@ pub struct Arg { pub ts_metadata: TypeScript::Metadata, - // `function(/* token */ arg)` — kept so Function.prototype.toString() - // preserves DI-style annotations (AngularJS / karma `di`). + // `function(/* token */ arg)` — kept for Function.prototype.toString() pub leading_comments: StoreSlice, } diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 2191412876a6..3a4421959622 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -195,8 +195,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ..Default::default() }; - // Keep comments inside `(...)` for Function.prototype.toString(). - // Only entries past `comments_base` belong to this parameter list. + // Keep `/* ... */` inside `(...)` for Function.prototype.toString(). let old_preserve_comments = p.lexer.preserve_all_comments_before; let comments_base = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = true; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 7f0f6645c7e5..b1aa34d5434c 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -2112,8 +2112,6 @@ pub(crate) mod __gated_printer { for comment in arg.leading_comments.slice() { let text = comment.text.slice(); if text.starts_with(b"//") { - // Re-emit as a block comment so it doesn't swallow - // the rest of the line; drop if unrepresentable. let body = &text[2..]; if !strings::contains(body, b"*/") { self.print(b"/*"); From 5fb70a2c2ab9f58afe4525f730fc9e673d6bbd00 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:16:53 +0000 Subject: [PATCH 5/9] scope preserve_all_comments_before to arg gaps only Comments inside a binding pattern, TS type, or default value were being captured and migrated to the next argument, and legal comments drained into leading_comments were dropped under --minify. Restrict capture to the whitespace between `(`/`,` and the next binding, and leave legal (`/*!` / `//!`) comments on the existing S::Comment path. --- src/js_parser/parse/parse_fn.rs | 37 +++++++++++++++++-- test/regression/issue/13451/13451.test.ts | 45 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 3a4421959622..a9a822f72284 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -14,6 +14,11 @@ use bun_ast::{E, Expr, Flags, G, S, Stmt}; type Error = crate::Error; +#[inline] +fn is_legal_comment(text: &[u8]) -> bool { + text.len() > 2 && text[2] == b'!' +} + impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { /// This assumes the "function" token has already been parsed pub(crate) fn parse_fn_stmt( @@ -165,8 +170,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if buf.len() <= base { return bun_ast::StoreSlice::EMPTY; } - let slice = self.arena.alloc_slice_fill_iter(buf.drain(base..)); - bun_ast::StoreSlice::new_mut(slice) + let mut taken = bun_alloc::ArenaVec::::new_in(self.arena); + let mut i = base; + while i < buf.len() { + if is_legal_comment(buf[i].text.slice()) { + i += 1; + } else { + taken.push(buf.remove(i)); + } + } + if taken.is_empty() { + return bun_ast::StoreSlice::EMPTY; + } + bun_ast::StoreSlice::from_bump(taken) + } + + fn discard_non_legal_comments_from(&mut self, base: usize) { + let buf = &mut self.lexer.comments_to_preserve_before; + let mut i = base; + while i < buf.len() { + if is_legal_comment(buf[i].text.slice()) { + i += 1; + } else { + buf.remove(i); + } + } } pub(crate) fn parse_fn( @@ -259,6 +287,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } let leading_comments = p.take_arg_leading_comments(comments_base); + p.lexer.preserve_all_comments_before = old_preserve_comments; let mut is_typescript_ctor_field = false; let is_identifier = p.lexer.token == T::TIdentifier; @@ -360,12 +389,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break; } + p.lexer.preserve_all_comments_before = true; p.lexer.next()?; rest_arg = false; } - // Drop any trailing captured comments so they don't leak into the body. - p.lexer.comments_to_preserve_before.truncate(comments_base); p.lexer.preserve_all_comments_before = old_preserve_comments; + p.discard_non_legal_comments_from(comments_base); if !args.is_empty() { func.args = bun_ast::StoreSlice::new_mut(args.into_bump_slice_mut()); diff --git a/test/regression/issue/13451/13451.test.ts b/test/regression/issue/13451/13451.test.ts index adcbe11eb6a4..f7b7d58ea7f4 100644 --- a/test/regression/issue/13451/13451.test.ts +++ b/test/regression/issue/13451/13451.test.ts @@ -51,6 +51,18 @@ exports.lineComment = function ( ) { return value; }; + +exports.noMigrateDefault = function (a = /* x */ 1, b) { + return [a, b]; +}; + +exports.noMigrateTrailing = function (a /* trail */, b) { + return [a, b]; +}; + +exports.noLeakIntoBody = function (a = () => { return 1 /* mid */ + 2; }, b) { + return [a, b]; +}; `, "main.cjs": ` const m = require("./mod.cjs"); @@ -60,6 +72,9 @@ console.log(JSON.stringify({ method: m.obj.method.toString(), karma: m.karma.toString(), lineComment: m.lineComment.toString(), + noMigrateDefault: m.noMigrateDefault.toString(), + noMigrateTrailing: m.noMigrateTrailing.toString(), + noLeakIntoBody: m.noLeakIntoBody.toString(), })); `, }); @@ -100,5 +115,35 @@ console.log(JSON.stringify({ expect(results.lineComment).not.toMatch(/\/\/[^\n]*\)/); expect(results.lineComment).toContain("token"); + // Comments inside a default value / after a binding must not migrate to the + // next argument (that would change the DI token), nor surface as statements + // inside a nested body. + expect(results.noMigrateDefault).not.toContain("/* x */"); + expect(diParse(results.noMigrateDefault)).toEqual(["a = 1", "b"]); + expect(results.noMigrateTrailing).not.toContain("/* trail */"); + expect(diParse(results.noMigrateTrailing)).toEqual(["a", "b"]); + expect(results.noLeakIntoBody).not.toContain("/* mid */"); + + expect(exitCode).toBe(0); +}); + +test("legal comments in parameter lists survive minification", async () => { + using dir = tempDir("issue-13451-legal", { + "in.js": ` +export const f = function(/*! @license MIT */ x) { return x; }; +export const g = function(/*! @preserve */) { return 1; }; +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--minify", "in.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("/*! @license MIT */"); + expect(stdout).toContain("/*! @preserve */"); expect(exitCode).toBe(0); }); From 402da9ca78d621f671f1898289de6ac3f5d7fe22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:25:08 +0000 Subject: [PATCH 6/9] don't capture comments inside TS this-param/decorators; re-snapshot base before each gap Comments inside `this: /* c */ T` or `@dec(/* c */)` were captured with the flag still raised and relocated onto the next real argument. Lower the flag (and discard any non-legal buffer entries) before entering the TThis branch, and around parse_type_script_decorators. Also re-snapshot comments_base before each comma scan so a nested .clear() during default-value parsing can't cause a later arg's leading comment to be mis-indexed. --- src/js_parser/parse/parse_fn.rs | 12 +++++++++-- test/regression/issue/13451/13451.test.ts | 26 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index a9a822f72284..9742f5577b66 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -167,6 +167,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O fn take_arg_leading_comments(&mut self, base: usize) -> bun_ast::StoreSlice { let buf = &mut self.lexer.comments_to_preserve_before; + let base = base.min(buf.len()); if buf.len() <= base { return bun_ast::StoreSlice::EMPTY; } @@ -187,7 +188,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O fn discard_non_legal_comments_from(&mut self, base: usize) { let buf = &mut self.lexer.comments_to_preserve_before; - let mut i = base; + let mut i = base.min(buf.len()); while i < buf.len() { if is_legal_comment(buf[i].text.slice()) { i += 1; @@ -225,7 +226,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Keep `/* ... */` inside `(...)` for Function.prototype.toString(). let old_preserve_comments = p.lexer.preserve_all_comments_before; - let comments_base = p.lexer.comments_to_preserve_before.len(); + let mut comments_base = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = true; p.lexer.expect(T::TOpenParen)?; @@ -258,6 +259,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O while p.lexer.token != T::TCloseParen { // Skip over "this" type annotations if Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TThis { + p.discard_non_legal_comments_from(comments_base); + p.lexer.preserve_all_comments_before = old_preserve_comments; p.lexer.next()?; if p.lexer.token == T::TColon { p.lexer.next()?; @@ -267,13 +270,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break; } + comments_base = p.lexer.comments_to_preserve_before.len(); + p.lexer.preserve_all_comments_before = true; p.lexer.next()?; continue; } let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { + p.lexer.preserve_all_comments_before = old_preserve_comments; ts_decorators = p.parse_type_script_decorators()?; + p.lexer.preserve_all_comments_before = true; if ts_decorators.len_u32() > 0 { arg_has_decorators = true; } @@ -389,6 +396,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break; } + comments_base = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = true; p.lexer.next()?; rest_arg = false; diff --git a/test/regression/issue/13451/13451.test.ts b/test/regression/issue/13451/13451.test.ts index f7b7d58ea7f4..57b32904c4b5 100644 --- a/test/regression/issue/13451/13451.test.ts +++ b/test/regression/issue/13451/13451.test.ts @@ -127,6 +127,32 @@ console.log(JSON.stringify({ expect(exitCode).toBe(0); }); +test("comments inside erased TS this-params/decorators are not relocated onto real args", async () => { + using dir = tempDir("issue-13451-ts", { + "in.ts": ` +export function f1(this: /* Self */ number, x: number) { return x; } +export function f2(/* ctx */ this: number, x: number) { return x; } +declare const dec: any; +export class C { + constructor(@dec(/* opts */) x: number) {} +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--target=bun", "in.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).not.toContain("/* Self */"); + expect(stdout).not.toContain("/* ctx */"); + expect(stdout).not.toContain("/* opts */"); + expect(exitCode).toBe(0); +}); + test("legal comments in parameter lists survive minification", async () => { using dir = tempDir("issue-13451-legal", { "in.js": ` From 3a58b80d35ba4f038d76e001fa28ff428e290ebd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:53:56 +0000 Subject: [PATCH 7/9] drain leading comments before parse_type_script_decorators A block-bodied arrow inside a decorator argument reaches parse_stmts_up_to which .clear()s comments_to_preserve_before, so a comment captured before @dec was being relocated into the nested body. Stash it into the leading-comment accumulator before decorator parsing (and re-snapshot comments_base after), then drain again after `...` for anything captured in the remaining gap. --- src/js_parser/parse/parse_fn.rs | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 9742f5577b66..1b7bfec6be30 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -165,25 +165,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(p.s(S::Function { func }, loc)) } - fn take_arg_leading_comments(&mut self, base: usize) -> bun_ast::StoreSlice { + fn drain_arg_leading_comments_into( + &mut self, + out: &mut bun_alloc::ArenaVec<'a, G::Comment>, + base: usize, + ) { let buf = &mut self.lexer.comments_to_preserve_before; - let base = base.min(buf.len()); - if buf.len() <= base { - return bun_ast::StoreSlice::EMPTY; - } - let mut taken = bun_alloc::ArenaVec::::new_in(self.arena); - let mut i = base; + let mut i = base.min(buf.len()); while i < buf.len() { if is_legal_comment(buf[i].text.slice()) { i += 1; } else { - taken.push(buf.remove(i)); + out.push(buf.remove(i)); } } - if taken.is_empty() { - return bun_ast::StoreSlice::EMPTY; - } - bun_ast::StoreSlice::from_bump(taken) } fn discard_non_legal_comments_from(&mut self, base: usize) { @@ -276,10 +271,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O continue; } + let mut leading = bun_alloc::ArenaVec::::new_in(p.arena); + let mut ts_decorators = bun_alloc::AstAlloc::vec(); if opts.allow_ts_decorators { + p.drain_arg_leading_comments_into(&mut leading, comments_base); p.lexer.preserve_all_comments_before = old_preserve_comments; ts_decorators = p.parse_type_script_decorators()?; + comments_base = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = true; if ts_decorators.len_u32() > 0 { arg_has_decorators = true; @@ -293,7 +292,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O func.flags.insert(Flags::Function::HasRestArg); } - let leading_comments = p.take_arg_leading_comments(comments_base); + p.drain_arg_leading_comments_into(&mut leading, comments_base); + let leading_comments = if leading.is_empty() { + bun_ast::StoreSlice::EMPTY + } else { + bun_ast::StoreSlice::from_bump(leading) + }; p.lexer.preserve_all_comments_before = old_preserve_comments; let mut is_typescript_ctor_field = false; From 3dc1be7db87d70f9851a50a1e5926914127181ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:30:32 +0000 Subject: [PATCH 8/9] ci: retrigger From 3ff12cde693c70b9f86de7a23b6dee389ed52b11 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:50:23 +0000 Subject: [PATCH 9/9] test: use test.concurrent for independent subprocess tests --- test/regression/issue/13451/13451.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/regression/issue/13451/13451.test.ts b/test/regression/issue/13451/13451.test.ts index 57b32904c4b5..7b2decf5df3c 100644 --- a/test/regression/issue/13451/13451.test.ts +++ b/test/regression/issue/13451/13451.test.ts @@ -23,7 +23,7 @@ function diParse(src: string): string[] { }); } -test("inline /* */ comments in function parameter lists survive the runtime transpiler", async () => { +test.concurrent("inline /* */ comments in function parameter lists survive the runtime transpiler", async () => { using dir = tempDir("issue-13451", { "mod.cjs": ` exports.anonExpr = function (/* a */ x, /* b.c */ y, /* d */ z) { @@ -127,7 +127,7 @@ console.log(JSON.stringify({ expect(exitCode).toBe(0); }); -test("comments inside erased TS this-params/decorators are not relocated onto real args", async () => { +test.concurrent("comments inside erased TS this-params/decorators are not relocated onto real args", async () => { using dir = tempDir("issue-13451-ts", { "in.ts": ` export function f1(this: /* Self */ number, x: number) { return x; } @@ -153,7 +153,7 @@ export class C { expect(exitCode).toBe(0); }); -test("legal comments in parameter lists survive minification", async () => { +test.concurrent("legal comments in parameter lists survive minification", async () => { using dir = tempDir("issue-13451-legal", { "in.js": ` export const f = function(/*! @license MIT */ x) { return x; };