From 12a1759719a4f810183f49d8918c1fe432715bb6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:23:12 +0000 Subject: [PATCH 1/3] Print a parameter list that holds a comment as it is written In a module that the runtime loads, the parser keeps the source text of a parameter list that holds a comment and only plain names. The printer writes that text in place of the list. Function.prototype.toString() then returns `function (/* config.proxies */ proxies)`, which is where the injector of karma reads its tokens. A list with a default value, a pattern or a type, the list of a setter, and what `bun build` prints are printed as before. The transpiler cache version goes to 52. Part of #13451 --- src/aot/lower/transpiled.rs | 1 + src/ast/ast_result.rs | 12 +- src/ast/lib.rs | 2 + src/ast/parameter_list_as_written.rs | 251 ++++++++ src/bundler/aot/services.rs | 1 + src/bundler/transpiler.rs | 3 + src/js_parser/lexer.rs | 12 + src/js_parser/p.rs | 4 + src/js_parser/parse/mod.rs | 10 +- src/js_parser/parse/parse_entry.rs | 6 + src/js_parser/parse/parse_fn.rs | 25 + src/js_parser/parse/parse_prefix.rs | 5 + src/js_parser/parse/parse_property.rs | 4 + src/js_printer/lib.rs | 69 +++ src/jsc/RuntimeTranspilerCache.rs | 3 +- src/runtime/test_runner/expect.rs | 25 +- ...ds-a-comment-is-quoted-as-it-is-written.js | 25 + .../transpiler/runtime-transpiler.test.ts | 545 ++++++++++++++++++ test/cli/run/transpiler-cache.test.ts | 13 + .../bun/sourcemap/source-positions-corpus.ts | 72 +++ test/js/bun/test/expect-extend.test.js | 42 ++ test/js/bun/test/mock/mock-module.test.ts | 39 ++ test/js/bun/test/vitest-flavor.test.ts | 29 + 23 files changed, 1192 insertions(+), 6 deletions(-) create mode 100644 src/ast/parameter_list_as_written.rs create mode 100644 test/aot/parallel/errors/a-parameter-list-that-holds-a-comment-is-quoted-as-it-is-written.js diff --git a/src/aot/lower/transpiled.rs b/src/aot/lower/transpiled.rs index bc3262772b2a..3be0c209118d 100644 --- a/src/aot/lower/transpiled.rs +++ b/src/aot/lower/transpiled.rs @@ -146,6 +146,7 @@ impl RuntimeTranspiler<'_> { options.tree_shaking = true; options.warn_about_unbundled_modules = false; options.use_define_for_class_fields = !request.assigns_class_fields; + options.keep_parameter_lists_as_written = true; let features = &mut options.features; features.standard_decorators = !request.has_legacy_decorators; features.allow_runtime = true; diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index 1ab68574a4fd..10701d94071b 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -10,8 +10,8 @@ use bun_collections::{ArrayHashMap, StringArrayHashMap, StringHashMap}; use crate::runtime; use crate::{ - CharFreq, ExportsKind, Expr, InlinedEnumValue, LocRef, NamedExport, NamedImport, Part, Range, - Ref, Scope, SlotCounts, StoreStr, Target, + CharFreq, ExportsKind, Expr, InlinedEnumValue, LocRef, NamedExport, NamedImport, + ParameterListsAsWritten, Part, Range, Ref, Scope, SlotCounts, StoreStr, Target, }; use crate::part::List as PartList; @@ -104,6 +104,8 @@ pub struct Ast<'a> { pub import_meta_ref: Ref, /// Output depends on more than the source text (macros, `import.meta.glob`), so don't cache it. pub depends_on_more_than_source: bool, + /// Filled for a module the runtime loads. + pub parameter_lists_as_written: ParameterListsAsWritten, } // `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator, @@ -150,8 +152,14 @@ impl<'a> Ast<'a> { has_import_meta: false, import_meta_ref: Ref::NONE, depends_on_more_than_source: false, + parameter_lists_as_written: Default::default(), } } + + /// What the printer takes: `None` when no list is kept, so that it tests one pointer for each function. + pub fn kept_parameter_lists(&self) -> Option<&ParameterListsAsWritten> { + (!self.parameter_lists_as_written.is_empty()).then_some(&self.parameter_lists_as_written) + } } pub struct CommonJSNamedExport { diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 586657d54ac8..3b826b688deb 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -2623,6 +2623,7 @@ pub mod runtime; pub mod ast_result; pub mod import_record; pub mod loader; +pub mod parameter_list_as_written; pub mod target; pub use ast_result::{ @@ -2631,6 +2632,7 @@ pub use ast_result::{ }; pub use import_record::{Flags as ImportRecordFlags, ImportRecord, Tag as ImportRecordTag}; pub use loader::{Loader, LoaderHashTable, SideEffects}; +pub use parameter_list_as_written::ParameterListsAsWritten; pub use target::Target; pub mod transpiler_cache; // Glob re-export: `link_interface!` emits `#[doc(hidden)]` type aliases that diff --git a/src/ast/parameter_list_as_written.rs b/src/ast/parameter_list_as_written.rs new file mode 100644 index 000000000000..b93af6d2cd0a --- /dev/null +++ b/src/ast/parameter_list_as_written.rs @@ -0,0 +1,251 @@ +//! The runtime prints a parameter list that holds a comment as it is written: an injector reads `/* token */ name` from `fn.toString()`. + +use bun_alloc::AstAlloc; +use bun_collections::ArrayHashMap; +use bun_collections::array_hash_map::AutoContext; +use bun_core::strings; + +use crate::StoreStr; + +/// The key is where the printer finds the list again: the `(` of a function, the start of an arrow function. +pub type ParameterListsAsWritten = ArrayHashMap; + +/// `mock.module` takes each of these, anywhere in the text of a factory, as a mention of the original module. +const READ_BY_MOCK_MODULE: [&[u8]; 3] = [b"importOriginal", b"importActual", b"requireActual"]; + +/// The text up to the `)` of the list that starts at `after_open_paren`, if the printer is to write it: plain names and a comment. +pub fn text_to_keep(after_open_paren: &[u8]) -> Option<&[u8]> { + let mut names = Names::new(after_open_paren); + while let Ok(Some(_)) = names.next_name() {} + let (text, rest) = after_open_paren.split_at(names.position); + let is_kept = names.has_comment + && rest.first() == Some(&b')') + && !READ_BY_MOCK_MODULE + .iter() + .any(|word| strings::contains(text, word)); + is_kept.then_some(text) +} + +pub struct Name { + pub start: usize, + pub end: usize, + /// `...` stands before it. + pub is_rest: bool, +} + +/// The text holds something other than names, commas, `...`, ASCII whitespace and ASCII comments. +#[derive(Debug)] +pub struct NotPlainNames; + +/// The names in the text between the parentheses of a parameter list. +pub struct Names<'a> { + text: &'a [u8], + /// After an error, where what is not part of a list of plain names starts. + position: usize, + has_comment: bool, +} + +impl<'a> Names<'a> { + pub fn new(text: &'a [u8]) -> Self { + Names { + text, + position: 0, + has_comment: false, + } + } + + /// `Ok(None)` at the end of the text. + pub fn next_name(&mut self) -> Result, NotPlainNames> { + let text = self.text; + let mut at = self.position; + let mut is_rest = false; + let found = loop { + let Some(&byte) = text.get(at) else { + break if is_rest { + Err(NotPlainNames) + } else { + Ok(None) + }; + }; + match byte { + b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C => at += 1, + b',' if !is_rest => at += 1, + b'.' if !is_rest && text[at..].starts_with(b"...") => { + is_rest = true; + at += 3; + } + b'/' => match end_of_comment(text, at) { + Some(end) => { + at = end; + self.has_comment = true; + } + None => break Err(NotPlainNames), + }, + b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$' => { + let start = at; + while text.get(at).is_some_and(|&byte| is_name_byte(byte)) { + at += 1; + } + break Ok(Some(Name { + start, + end: at, + is_rest, + })); + } + _ => break Err(NotPlainNames), + } + }; + self.position = at; + found + } +} + +fn is_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' +} + +/// `text[at]` is `/`. The end of the ASCII comment that starts there, if it ends inside the text: a line comment ends before its line terminator. +fn end_of_comment(text: &[u8], at: usize) -> Option { + let body = text.get(at + 2..)?; + let length = match text[at + 1] { + b'*' => strings::index_of(body, b"*/")? + 2, + // JavaScriptCore reads `sourceURL=` and `sourceMappingURL=` from a comment that starts with `//#` or `//@`. + b'/' if !matches!(body.first(), Some(b'#' | b'@')) => strings::index_of_any(body, b"\r\n")?, + _ => return None, + }; + body[..length].is_ascii().then_some(at + 2 + length) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `None`: not a list of plain names. + fn names_of(text: &str) -> Option<(Vec, bool)> { + let mut names = Names::new(text.as_bytes()); + let mut found = Vec::new(); + while let Some(name) = names.next_name().ok()? { + let spelling = &text[name.start..name.end]; + found.push(if name.is_rest { + format!("...{spelling}") + } else { + spelling.to_owned() + }); + } + Some((found, names.has_comment)) + } + + fn commented(names: &[&str]) -> Option<(Vec, bool)> { + Some((names.iter().map(|&name| name.to_owned()).collect(), true)) + } + + fn kept(after_open_paren: &str) -> Option<&str> { + text_to_keep(after_open_paren.as_bytes()).map(|text| core::str::from_utf8(text).unwrap()) + } + + #[test] + fn names_and_comments() { + assert_eq!( + names_of("/* config */config, /* config.proxies */proxies"), + commented(&["config", "proxies"]) + ); + assert_eq!( + names_of("a /* after */, $b_1 // line\n"), + commented(&["a", "$b_1"]) + ); + assert_eq!(names_of("/* a, b) */"), commented(&[])); + assert_eq!( + names_of(" a,\r\n\tb, "), + Some((vec!["a".into(), "b".into()], false)) + ); + assert_eq!(names_of(""), Some((vec![], false))); + } + + #[test] + fn rest_parameter() { + assert_eq!( + names_of("a, /* all */ ...rest"), + commented(&["a", "...rest"]) + ); + assert_eq!(names_of("... /* all */ rest"), commented(&["...rest"])); + assert_eq!(names_of("a, ..."), None); + assert_eq!(names_of("......a"), None); + assert_eq!(names_of("..a"), None); + assert_eq!(names_of("...,a"), None); + } + + #[test] + fn what_is_not_a_plain_name() { + for text in [ + "a = 1", + "{ a }", + "[a]", + "a: number", + "a?", + "@inject a", + "a /* \u{e9} */", + "a // \u{e9}\n", + "\u{e9}", + "a\\u0062", + "a