From 3c334ef53b22ae56b57b07bb843df71de1fe4e86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:43:11 +0000 Subject: [PATCH 1/9] parser: keep the `a ? (b) : c => d : e` attempt when it finds an arrow function Between the "?" and ":" of a conditional, TypeScript `(b) : c => d` is an arrow function only when another ":" follows its body. The attempt that decides this parsed the arrow function, always restored the parser, and the caller parsed a kept body again. A chain of depth n took n * n / 2 parses of a level, and the parser frees nothing before the file is done. The attempt is now the parse. It declares the real arguments and is kept when a ":" follows the body. Only a failed attempt restores the snapshot. - `Lexer::backtrack_on_mismatch` keeps the attempt strict while the log stays on, so a kept parse reports its diagnostics. - The legal comments that wait for the next statement stay in the lexer buffer with a count of the taken ones, so a restore gives them back without a copy. - The memo records only failed attempts. - The restore scans `ref_to_ts_namespace_member` only when the attempt added a key. The shape of the helper follows `parse_possible_arrow_fn` from #44685. Co-authored-by: Jarred Sumner --- src/js_parser/lexer.rs | 13 ++- src/js_parser/p.rs | 41 ++++--- src/js_parser/parse/mod.rs | 61 +++++++---- src/js_parser/parse/parse_import_export.rs | 2 +- src/js_parser/parse/parse_skip_typescript.rs | 97 +++++++++-------- src/jsc/RuntimeTranspilerCache.rs | 4 +- test/bundler/transpiler/transpiler.test.js | 106 +++++++++++++++++-- 7 files changed, 223 insertions(+), 101 deletions(-) diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 26f624f19c00..d75364805a76 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -223,7 +223,16 @@ pub struct Lexer<'a> { pub(crate) preserve_all_comments_before: bool, pub(crate) is_legacy_octal_literal: bool, pub(crate) is_log_disabled: bool, + /// A token mismatch backtracks, as it does while the log is disabled, but + /// the log stays on. Set for a speculative parse that is kept when it + /// succeeds, so that its diagnostics count + /// (`P::parse_possible_arrow_fn_after_question_and_before_colon`). + pub(crate) backtrack_on_mismatch: bool, pub(crate) comments_to_preserve_before: Vec, + /// How many of `comments_to_preserve_before` a statement or `import()` has + /// taken. They stay in the buffer, so a speculative parse that took them + /// gives them back by resetting this (`P::restore_parser_snapshot`). + pub(crate) comments_to_preserve_before_taken: usize, pub(crate) code_point: CodePoint, pub(crate) identifier: &'a [u8], pub(crate) jsx_pragma: JSXPragma, @@ -1827,7 +1836,7 @@ impl<'a> Lexer<'a> { #[cold] #[inline(never)] pub(crate) fn expected(&mut self, token: T) -> Result<(), Error> { - if self.is_log_disabled { + if self.is_log_disabled || self.backtrack_on_mismatch { return Err(Error::Backtrack); } else if !tokenToString_get(token).is_empty() { self.expected_string(tokenToString_get(token)) @@ -2268,7 +2277,9 @@ impl<'a> Lexer<'a> { preserve_all_comments_before: false, is_legacy_octal_literal: false, is_log_disabled: false, + backtrack_on_mismatch: false, comments_to_preserve_before: Vec::new(), + comments_to_preserve_before_taken: 0, code_point: -1, identifier: b"", jsx_pragma: JSXPragma::default(), diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index bd0f56678462..236dadc46c66 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -167,7 +167,7 @@ impl<'a> core::ops::DerefMut for NamedImportsType<'a> { /// See [`P::parser_snapshot`]. pub(crate) struct ParserSnapshot<'a> { lexer: js_lexer::LexerSnapshot<'a>, - comments_to_preserve_before: Vec, + comments_to_preserve_before_taken: usize, log_msgs_len: usize, log_errors: u32, log_warnings: u32, @@ -188,6 +188,7 @@ pub(crate) struct ParserSnapshot<'a> { scopes_in_order_len: usize, scopes_in_order_for_enum_len: usize, symbols_len: usize, + ref_to_ts_namespace_member_len: usize, allocated_names_len: usize, import_records_len: usize, } @@ -384,14 +385,12 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// until a constraint attempt actually backtracks, which is rare in real code. pub(crate) ts_infer_constraint_backtracks: Vec, - /// Outcomes of `is_type_script_arrow_return_type_after_question_and_before_colon`, - /// keyed by the byte offset of the `:` shifted left by one, with the low bit set - /// when the attempt parsed an arrow function. The outcome depends only on the - /// source from that offset on, so the real parse that follows a discarded - /// attempt can reuse it. Without this memo every nested - /// `a ? (b) : c => : e` inside the body is parsed once by the attempt and - /// once for real, which is exponential in the nesting depth. Kept sorted for - /// binary search. + /// Byte offset of each `:` where `parse_possible_arrow_fn_after_question_and_before_colon` + /// found no arrow function. The outcome depends only on the source from that + /// offset on, so the attempt is not repeated when an attempt around it fails too + /// and the same text is parsed again. Without this memo every failed attempt + /// would run the failed attempts nested in its body twice, which is exponential + /// in the nesting depth. Kept sorted for binary search. pub(crate) ts_conditional_arrow_attempts: Vec, /// When this flag is enabled, we attempt to fold all expressions that @@ -8200,19 +8199,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// expression can be undone with [`Self::restore_parser_snapshot`]. The /// lexer-only backtracking in `parse_skip_typescript.rs` only covers /// types: an expression also declares symbols, pushes scopes, records - /// dynamic imports and logs through `P::log()`, which ignores - /// `lexer.is_log_disabled`. Lists are captured as lengths and truncated + /// dynamic imports and logs. Lists are captured as lengths and truncated /// on restore; the attempt's arena allocations just become unreachable. - /// The legal comments waiting for the next statement are moved out - /// instead: a block body or `import()` drains them, so a length cannot - /// restore them. - pub(crate) fn parser_snapshot(&mut self) -> ParserSnapshot<'a> { - let comments_to_preserve_before = - core::mem::take(&mut self.lexer.comments_to_preserve_before); + /// A block body or `import()` takes the legal comments that wait for the + /// next statement, so the count of taken ones is captured too. + pub(crate) fn parser_snapshot(&self) -> ParserSnapshot<'a> { let log = self.log(); ParserSnapshot { lexer: self.lexer.snapshot(), - comments_to_preserve_before, + comments_to_preserve_before_taken: self.lexer.comments_to_preserve_before_taken, log_msgs_len: log.msgs.len(), log_errors: log.errors, log_warnings: log.warnings, @@ -8235,6 +8230,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O scopes_in_order_len: self.scopes_in_order.len(), scopes_in_order_for_enum_len: self.scopes_in_order_for_enum.len(), symbols_len: self.symbols.len(), + ref_to_ts_namespace_member_len: self.ref_to_ts_namespace_member.len(), allocated_names_len: self.allocated_names.len(), import_records_len: self.import_records.len(), } @@ -8243,7 +8239,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// Undo every parse-pass mutation made since [`Self::parser_snapshot`]. pub(crate) fn restore_parser_snapshot(&mut self, snapshot: ParserSnapshot<'a>) { self.lexer.restore(&snapshot.lexer); - self.lexer.comments_to_preserve_before = snapshot.comments_to_preserve_before; + self.lexer.comments_to_preserve_before_taken = snapshot.comments_to_preserve_before_taken; let log = self.log(); log.msgs.truncate(snapshot.log_msgs_len); @@ -8275,13 +8271,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.scopes_in_order_for_enum.pop(); } - // Enum and namespace symbols also key `ref_to_ts_namespace_member`; a symbol - // that later reuses the index must not inherit their namespace data. if self.symbols.len() > snapshot.symbols_len { self.symbols.truncate(snapshot.symbols_len); if TYPESCRIPT { self.ts_use_counts.truncate(snapshot.symbols_len); } + } + // Enum and namespace symbols also key `ref_to_ts_namespace_member`; a symbol + // that later reuses the index must not inherit their namespace data. Only + // this removes from the map, so a new key shows as a longer map. + if self.ref_to_ts_namespace_member.len() > snapshot.ref_to_ts_namespace_member_len { let stale: Vec = self .ref_to_ts_namespace_member .keys() diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index e1531d7d0803..060472aca439 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -571,25 +571,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // attempt to convert the expressions to bindings first before deciding // whether this is an arrow function, and only pick an arrow function if // there were no conversion errors. - if Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty() { - if opts.is_after_question_and_before_colon { - // Only do this very expensive check if we must - is_arrow_fn = p - .is_type_script_arrow_return_type_after_question_and_before_colon( - &arrow_data, - )?; - if is_arrow_fn { - // We know this will succeed because we've already done it once above - p.lexer.next()?; - p.skip_typescript_return_type()?; - } - } else { - // Otherwise, do the less expensive check - is_arrow_fn = p.try_skip_type_script_arrow_return_type_with_backtracking()?; - } - } + let maybe_return_type = + Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty(); - if is_arrow_fn || opts.force_arrow_fn { + let parse_arrow_fn = move |p: &mut Self| -> Result { p.maybe_comma_spread_error(comma_after_spread); p.log_arrow_arg_errors(&mut arrow_arg_errors); @@ -605,7 +590,26 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O arrow.is_async = opts.is_async; arrow.has_rest_arg = spread_range.len > 0; p.pop_scope(); - return Ok(p.new_expr(arrow, loc)); + Ok(p.new_expr(arrow, loc)) + }; + + if maybe_return_type && opts.is_after_question_and_before_colon { + // Only do this very expensive check if we must. It parses the arrow + // function, and undoes that when the ":" pairs with the "?". + if let Some(arrow) = + p.parse_possible_arrow_fn_after_question_and_before_colon(parse_arrow_fn)? + { + return Ok(arrow); + } + } else { + if maybe_return_type { + // Otherwise, do the less expensive check + is_arrow_fn = p.try_skip_type_script_arrow_return_type_with_backtracking()?; + } + + if is_arrow_fn || opts.force_arrow_fn { + return parse_arrow_fn(p); + } } } @@ -1481,11 +1485,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut is_directive_prologue = true; loop { - for comment in p.lexer.comments_to_preserve_before.iter() { - let loc = p.lexer.loc(); - stmts.push(p.s(S::Comment { text: comment.text }, loc)); + if p.lexer.comments_to_preserve_before_taken + != p.lexer.comments_to_preserve_before.len() + { + p.take_comments_to_preserve_before(&mut stmts); } - p.lexer.comments_to_preserve_before.clear(); if p.lexer.token == eend { break; @@ -1599,6 +1603,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(stmts) } + /// Puts the legal comments that wait for the next statement before it. + #[cold] + fn take_comments_to_preserve_before(&mut self, stmts: &mut StmtList<'a>) { + let loc = self.lexer.loc(); + let taken = self.lexer.comments_to_preserve_before_taken; + for comment in &self.lexer.comments_to_preserve_before[taken..] { + stmts.push(self.s(S::Comment { text: comment.text }, loc)); + } + self.lexer.comments_to_preserve_before_taken = self.lexer.comments_to_preserve_before.len(); + } + /// One-token lookahead: advance past the current token, evaluate `pred`, /// then unconditionally restore the lexer (including `is_log_disabled`). #[inline] diff --git a/src/js_parser/parse/parse_import_export.rs b/src/js_parser/parse/parse_import_export.rs index f862d5adde3e..c38ac85b7b55 100644 --- a/src/js_parser/parse/parse_import_export.rs +++ b/src/js_parser/parse/parse_import_export.rs @@ -46,7 +46,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TOpenParen)?; // const comments = try p.lexer.comments_to_preserve_before.toOwnedSlice(); - p.lexer.comments_to_preserve_before.clear(); + p.lexer.comments_to_preserve_before_taken = p.lexer.comments_to_preserve_before.len(); p.lexer.preserve_all_comments_before = false; diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index 32b1d9ab4d3c..421fd0c49e93 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -2,12 +2,11 @@ use crate::Error; use crate::lexer::T; use crate::p::P; -use crate::parser::{ - FnOrArrowDataParse, ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, -}; +use crate::parser::{ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag}; use crate::typescript; use crate::typescript::SkipTypeOptions; use crate::typescript::identifier::{Kind as TsIdentKind, kind_for_identifier}; +use bun_ast::Expr; use bun_ast::op::Level; use bun_ast::ts::Metadata; @@ -1583,66 +1582,70 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } - /// Whether the ":" after a parenthesized expression that sits between the - /// "?" and ":" of a conditional starts an arrow function return type. The - /// TypeScript compiler parses the whole arrow function and keeps it only - /// when another ":" follows its body: + /// The arrow function whose return type starts at the ":" after a + /// parenthesized expression that sits between the "?" and ":" of a + /// conditional, or `None`. The TypeScript compiler parses the whole arrow + /// function and keeps it only when another ":" follows its body: /// /// x = a ? (b) : c => d; // "(b)" is an expression, ":" pairs with "?" /// y = a ? (b) : c => d : e; // "(b) : c => d" is an arrow function /// - /// The body is parsed here and thrown away, then parsed again by the - /// caller when this returns true. Parsing an expression mutates parser - /// state, so the whole parser is restored from a snapshot. The current - /// scope is the arrow's `FunctionArgs` scope; no arguments are declared so - /// that its members stay untouched. The outcome is memoized by the offset - /// of the ":" in `ts_conditional_arrow_attempts`, so the real parse does not - /// repeat the attempts nested inside the body. - pub(crate) fn is_type_script_arrow_return_type_after_question_and_before_colon( + /// `parse_arrow_fn` parses from the "=>" on, and that parse is kept when it + /// ends before a ":". Otherwise the whole parser is restored from a + /// snapshot, because parsing an expression mutates parser state, and the + /// offset of the ":" goes into `ts_conditional_arrow_attempts`. The caller + /// then drops the `FunctionArgs` scope, and with it the arguments that + /// `parse_arrow_fn` declared there. + #[cold] + #[inline(never)] + pub(crate) fn parse_possible_arrow_fn_after_question_and_before_colon( &mut self, - arrow_data: &FnOrArrowDataParse, - ) -> Result { + parse_arrow_fn: impl FnOnce(&mut Self) -> Result, + ) -> Result, Error> { self.mark_type_script_only(); - debug_assert!(self.lexer.start <= (u32::MAX >> 1) as usize); + debug_assert!(self.lexer.start <= u32::MAX as usize); let memo_key = self.lexer.start as u32; - if let Ok(i) = self + if self .ts_conditional_arrow_attempts - .binary_search_by_key(&memo_key, |&packed| packed >> 1) + .binary_search(&memo_key) + .is_ok() { - return Ok(self.ts_conditional_arrow_attempts[i] & 1 == 1); + return Ok(None); } let snapshot = self.parser_snapshot(); - self.lexer.is_log_disabled = true; - - let mut data = arrow_data.clone(); - let result: Result<(), Error> = (|| { - self.lexer.expect(T::TColon)?; + let backtracked_on_mismatch = + core::mem::replace(&mut self.lexer.backtrack_on_mismatch, true); + let result: Result = (|| { + self.lexer.next()?; self.skip_typescript_return_type()?; - self.parse_arrow_body(&mut [], &mut data)?; - // The ":" that pairs with the "?" - self.lexer.expect(T::TColon)?; - Ok(()) + let arrow = parse_arrow_fn(self)?; + // The ":" that pairs with the "?", and a token after it + if self.lexer.token != T::TColon || !self.next_token_matches(|_| true) { + return Err(Error::Backtrack); + } + Ok(arrow) })(); + self.lexer.backtrack_on_mismatch = backtracked_on_mismatch; - self.restore_parser_snapshot(snapshot); - 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, - }; - - // Re-search for the insertion point: attempts nested inside this one may - // have added entries of their own. - if let Err(insert_at) = self - .ts_conditional_arrow_attempts - .binary_search_by_key(&memo_key, |&packed| packed >> 1) - { - self.ts_conditional_arrow_attempts - .insert(insert_at, (memo_key << 1) | is_arrow_fn as u32); + match result { + Ok(arrow) => Ok(Some(arrow)), + Err(err) => { + self.restore_parser_snapshot(snapshot); + // Stack and memory exhaustion are not properties of the attempt + if matches!(err, Error::StackOverflow | Error::Alloc(_)) { + return Err(err); + } + // Attempts nested inside this one may have added entries of their own, + // so the insertion point is searched for only now. + if let Err(insert_at) = self.ts_conditional_arrow_attempts.binary_search(&memo_key) + { + self.ts_conditional_arrow_attempts + .insert(insert_at, memo_key); + } + Ok(None) + } } - Ok(is_arrow_fn) } // ─────────────────────── try_* wrappers ─────────────────────── diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index c71a871474fc..c81399472897 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -68,7 +68,9 @@ bun_core::declare_scope!(cache, visible); /// Version 36: `export`, `require()`, `import()`, a JSX element, an exported async function and /// an abstract class are mapped elsewhere. /// Version 37: A JSX spread no longer empties an object `--define`. Older entries can print it as `{}`. -const EXPECTED_VERSION: u32 = 37; +/// Version 38: The arrow function in TypeScript `a ? (b): T => c : d` is parsed once, so +/// decorator metadata in its body is bound to the right symbols. +const EXPECTED_VERSION: u32 = 38; /// 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/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 1ce0ddc465d4..e99ef13bd5f7 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -1,5 +1,16 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, bunRun, hideFromStackTrace, integersThatNeedRounding, isDebug, tempDir } from "harness"; +import { + bunEnv, + bunExe, + bunRun, + emptyProcessMaxRSS, + hideFromStackTrace, + integersThatNeedRounding, + isASAN, + isDebug, + runFixtureMaxRSS, + tempDir, +} from "harness"; import { join } from "path"; describe("Bun.Transpiler", () => { @@ -424,8 +435,8 @@ describe("Bun.Transpiler", () => { // Newlines are important (they trigger backtracking) exp("x = (\n a ? (b = c) : { d: e }\n)", "x = a ? b = c : { d: e };\n"); - // The attempt to parse the arrow body must see the same parser state as - // the real parse: private names and the "in" operator. + // The attempt parses the arrow body in the parser state of its place: + // private names and the "in" operator. exp( "x = class { #y; y = a ? (b : T) : T => this.#y : c }", "x = class {\n #y;\n y = a ? (b) => this.#y : c;\n};\n", @@ -457,26 +468,107 @@ describe("Bun.Transpiler", () => { ); // A legal comment scanned between "?" and "(" waits in the lexer for the - // next statement. The attempt's block body or import() takes it. The real - // parse must still get it. + // next statement. The attempt's block body or import() takes it. The + // parse after a discarded attempt must still get it. exp("x = a ? /*! L */ (b) : T => { let y = 1 } : c", "x = a ? (b) => {\n /*! L */\n let y = 1;\n} : c;\n"); exp("x = a ? /*! L */ (b) : c => { return d }", "x = a ? b : (c) => {\n /*! L */\n return d;\n};\n"); exp('x = a ? /*! L */ (b) : T => import("m") : c', 'x = a ? (b) => import("m") : c;\n'); + exp( + "x = a ? /*! L1 */ (b) : c => { return a ? /*! L2 */ (b) : T => { let y = 1 } : c }", + "x = a ? b : (c) => {\n /*! L1 */\n return a ? (b) => {\n /*! L2 */\n let y = 1;\n } : c;\n};\n", + ); + exp( + "x = a ? /*! L1 */ (b) : T => a ? /*! L2 */ (b) : T => d : e : e; y", + "x = a ? (b) => a ? (b) => d : e : e;\n/*! L1 */\n/*! L2 */\ny;\n", + ); + exp( + "/*! A */ x = a ? /*! L1 */ (b) : c => { /*! L2 */ z; /*! L3 */ }; /*! B */ y", + "/*! A */\nx = a ? b : (c) => {\n /*! L1 */\n /*! L2 */\n z;\n /*! L3 */\n};\n/*! B */\ny;\n", + ); + + // A kept attempt is the only parse of its arrow function: it reports the + // errors of that parse and declares the parameters. A discarded attempt + // leaves neither behind. + err("x = a ? (b) : T => /x/gg : e", 'Duplicate flag "g" in regular expression'); + err("x = a ? (b) : T => { let b } : e", '"b" has already been declared'); + err("x = a ? (b, b) : T => 1 : e", '"b" cannot be bound multiple times in the same parameter list'); + err("x = a ? (...b, c) : T => b : e", 'Unexpected "," after rest pattern'); + exp("x = a ? (b) : c => { let b }", "x = a ? b : (c) => {\n let b;\n};\n"); + exp("x = a ? (b, b) : c => 1", "x = a ? (b, b) : (c) => 1;\n"); + + // A token that does not fit ends the attempt. So does a token after the + // second ":" that the lexer cannot read. + err("x = a ? (b) : c => (d e) : f", 'Expected ")" but found "e"'); + err("x = a ? (b) : T => d : 'abc", 'Expected ";" but found ":"'); + + // An enum from before a discarded attempt is still inlined + exp( + "enum E { A = 1 }\nx = a ? (b) : c => E.A", + 'var E;\n((E) => {\n E[E["A"] = 1] = "A";\n})(E ||= {});\nx = a ? b : (c) => 1 /* A */;\n', + ); + + // A kept attempt in a discarded one, and a discarded attempt in a kept one + exp("x = a ? (b) : c => [a ? (b) : T => d : e]", "x = a ? b : (c) => [a ? (b) => d : e];\n"); + exp("x = a ? (b) : T => [a ? (b) : c => d] : e", "x = a ? (b) => [a ? b : (c) => d] : e;\n"); + exp( + "x = a ? async (b) : c => a ? async (b) : c => await d : e : e", + "x = a ? async (b) => a ? async (b) => await d : e : e;\n", + ); - // The attempt parses the body, so an attempt nested in the body must not - // run again when the body is parsed for real: 2^40 parses would hang. + // The body of a discarded attempt is parsed again, so a discarded attempt + // nested in that body must not run again: 2^40 parses would hang. let kept = "d"; let discarded = "d"; + let keptInDiscarded = "d"; let keptOut = "d"; let discardedOut = "d"; + let keptInDiscardedOut = "d"; for (let i = 0; i < 40; i++) { kept = `a ? (b) : c => ${kept} : e`; keptOut = `a ? (b) => ${keptOut} : e`; discarded = `a ? (b) : c => ${discarded}`; discardedOut = `a ? b : (c) => ${discardedOut}`; + keptInDiscarded = i < 20 ? `a ? (b) : c => ${keptInDiscarded} : e` : `a ? (b) : c => ${keptInDiscarded}`; + keptInDiscardedOut = i < 20 ? `a ? (b) => ${keptInDiscardedOut} : e` : `a ? b : (c) => ${keptInDiscardedOut}`; } exp(`x = ${kept}`, `x = ${keptOut};\n`); exp(`x = ${discarded}`, `x = ${discardedOut};\n`); + exp(`x = ${keptInDiscarded}`, `x = ${keptInDiscardedOut};\n`); + }); + + it("parses an arrow function with a return type between the ? and : of a conditional once", () => { + // The type of a decorated property is bound to a symbol as it is parsed. + // When the arrow function was parsed a second time, `Foo` was bound to + // the symbol that had taken its index: `K`. + const withMetadata = new Bun.Transpiler({ + loader: "ts", + tsconfig: { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }, + }); + expect( + withMetadata.transformSync("x = a ? (b) : T => { class K { @dec p: Foo } return K } : e"), + ).toContain('("design:type", typeof Foo === "undefined" ? Object : Foo)'); + }); + + it("nested arrow functions with return types between ? and : take memory in proportion to their depth", async () => { + // Each arrow function here has the next one in its body. Every body was + // parsed twice, so depth n took n * n / 2 parses of a level, and what a + // parse allocates stays until the file is done. + const depth = isDebug || isASAN ? 256 : 640; + const fixture = /* js */ ` + let input = "d"; + let output = "d"; + for (let i = 0; i < ${depth}; i++) { + input = "a ? (b) : c => " + input + " : e"; + output = "a ? (b) => " + output + " : e"; + } + const printed = new Bun.Transpiler({ loader: "ts" }).transformSync("x = " + input); + console.log(JSON.stringify({ printed: printed === "x = " + output + ";\\n" })); + `; + const [fixtureMaxRSS, baselineMaxRSS] = await Promise.all([ + runFixtureMaxRSS(fixture, { printed: true }), + emptyProcessMaxRSS(), + ]); + expect((fixtureMaxRSS - baselineMaxRSS) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 25 : 64); }); it("type-only import syntax", () => { From e38bd5abb8fa90f43826dd97c4d480058ec192ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:43:08 +0000 Subject: [PATCH 2/9] parser: undo the decorator metadata bindings of a failed arrow function attempt The type names of decorator metadata are bound as they are parsed. A name that nothing declares becomes a member of the module scope. A failed attempt left that member behind with the index of a symbol that the restore removed, so the parse after it bound `Date` to another symbol or read past the end of the symbol table. The restore now removes the module scope members and the scope uses that the attempt added. --- src/ast/scope.rs | 25 ++++++++++++++++++++++ src/js_parser/p.rs | 13 +++++++++++ test/bundler/transpiler/transpiler.test.js | 14 +++++++++--- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/ast/scope.rs b/src/ast/scope.rs index d6ec7791f77a..d7876bdc42d9 100644 --- a/src/ast/scope.rs +++ b/src/ast/scope.rs @@ -196,6 +196,31 @@ impl Members { } } + /// Removes the members that `keep` rejects. + pub fn retain(&mut self, mut keep: impl FnMut(&Member) -> bool) { + fn retain_inline( + inline: &mut InlineMembers, + mut keep: impl FnMut(&Member) -> bool, + ) { + let mut kept = 0; + for i in 0..inline.len as usize { + if keep(&inline.members[i]) { + inline.hashes[kept] = inline.hashes[i]; + inline.members[kept] = inline.members[i]; + inline.names[kept] = inline.names[i]; + kept += 1; + } + } + inline.len = kept as u32; + } + match self { + Members::Empty => {} + Members::Small(inline) => retain_inline(inline, keep), + Members::Inline(inline) => retain_inline(inline, keep), + Members::Table(table) => table.retain(|entry| keep(&entry.member)), + } + } + /// Room for about `n` members (the module scope, sized from the source). pub fn reserve(&mut self, n: usize) { if n > Self::INLINE_MAX { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 236dadc46c66..1b2d3c15b03e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -189,6 +189,8 @@ pub(crate) struct ParserSnapshot<'a> { scopes_in_order_for_enum_len: usize, symbols_len: usize, ref_to_ts_namespace_member_len: usize, + module_scope_members_len: usize, + span_uses_len: usize, allocated_names_len: usize, import_records_len: usize, } @@ -8231,6 +8233,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O scopes_in_order_for_enum_len: self.scopes_in_order_for_enum.len(), symbols_len: self.symbols.len(), ref_to_ts_namespace_member_len: self.ref_to_ts_namespace_member.len(), + module_scope_members_len: self.module_scope.members.len(), + span_uses_len: self.span_uses.len(), allocated_names_len: self.allocated_names.len(), import_records_len: self.import_records.len(), } @@ -8291,6 +8295,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.ref_to_ts_namespace_member.remove(&ref_); } } + // The type names of decorator metadata are bound as they are parsed + // (`find_symbol`): a name that nothing declares becomes a member of the + // module scope, and a use of a declared one is recorded. + if self.module_scope.members.len() > snapshot.module_scope_members_len { + self.module_scope_mut() + .members + .retain(|member| (member.ref_.inner_index() as usize) < snapshot.symbols_len); + } + self.span_uses.truncate(snapshot.span_uses_len); self.allocated_names.truncate(snapshot.allocated_names_len); self.import_records.truncate(snapshot.import_records_len); } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index e99ef13bd5f7..102b6e9e83b4 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -539,14 +539,22 @@ describe("Bun.Transpiler", () => { it("parses an arrow function with a return type between the ? and : of a conditional once", () => { // The type of a decorated property is bound to a symbol as it is parsed. // When the arrow function was parsed a second time, `Foo` was bound to - // the symbol that had taken its index: `K`. + // the symbol that had taken its index: `K`. A discarded attempt left + // the same binding to the parse after it. const withMetadata = new Bun.Transpiler({ loader: "ts", tsconfig: { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }, }); + const typeIsFoo = '("design:type", typeof Foo === "undefined" ? Object : Foo)'; + expect(withMetadata.transformSync("x = a ? (b) : T => { class K { @dec p: Foo } return K } : e")).toContain( + typeIsFoo, + ); + expect(withMetadata.transformSync("x = a ? (b) : c => { class K { @dec p: Foo } return K }")).toContain( + typeIsFoo, + ); expect( - withMetadata.transformSync("x = a ? (b) : T => { class K { @dec p: Foo } return K } : e"), - ).toContain('("design:type", typeof Foo === "undefined" ? Object : Foo)'); + withMetadata.transformSync("x = a ? (b, c) : d => { class K { @dec p: Foo } return new Foo() }"), + ).toContain("return new Foo"); }); it("nested arrow functions with return types between ? and : take memory in proportion to their depth", async () => { From 7f1b8c4b4ac0a460ab3ee8f157bcfdb41fbc583c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:43:30 +0000 Subject: [PATCH 3/9] parser: a failed arrow function attempt drops its namespace property accesses A decorator metadata type that a sibling namespace block exports is bound to a new symbol in the `property_accesses` of that namespace. The entry stayed after a failed attempt, with the index of a removed symbol. --- src/js_parser/p.rs | 19 +++++++++++++++++-- test/bundler/transpiler/transpiler.test.js | 5 +++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 1b2d3c15b03e..34c68ca4bf19 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8296,13 +8296,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } // The type names of decorator metadata are bound as they are parsed - // (`find_symbol`): a name that nothing declares becomes a member of the - // module scope, and a use of a declared one is recorded. + // (`find_symbol`). A name that nothing declares becomes a member of the + // module scope. A name that a sibling namespace block exports becomes a + // property access of that namespace, which is around the attempt. if self.module_scope.members.len() > snapshot.module_scope_members_len { self.module_scope_mut() .members .retain(|member| (member.ref_.inner_index() as usize) < snapshot.symbols_len); } + let mut enclosing = Some(snapshot.current_scope); + while let Some(scope) = enclosing { + if let Some(mut ts_namespace) = scope.ts_namespace { + let accesses = &mut ts_namespace.property_accesses; + while accesses + .values() + .last() + .is_some_and(|ref_| ref_.inner_index() as usize >= snapshot.symbols_len) + { + accesses.pop(); + } + } + enclosing = scope.parent; + } self.span_uses.truncate(snapshot.span_uses_len); self.allocated_names.truncate(snapshot.allocated_names_len); self.import_records.truncate(snapshot.import_records_len); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 102b6e9e83b4..c2f186d0f5b5 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -555,6 +555,11 @@ describe("Bun.Transpiler", () => { expect( withMetadata.transformSync("x = a ? (b, c) : d => { class K { @dec p: Foo } return new Foo() }"), ).toContain("return new Foo"); + expect( + withMetadata.transformSync( + "namespace N { export class A {} } namespace N { x = a ? (b) : c => { class K { @dec p: A } return K } }", + ), + ).toContain('("design:type", typeof A === "undefined" ? Object : A)'); }); it("nested arrow functions with return types between ? and : take memory in proportion to their depth", async () => { From 24d0add1bb36c51cfe0a02a1a7195999678ebfe1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:31:38 +0000 Subject: [PATCH 4/9] test: compare the nested arrow function chain with the same chain without return types --- test/bundler/transpiler/transpiler.test.js | 23 +++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index c2f186d0f5b5..2a0fae5b37aa 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3,7 +3,6 @@ import { bunEnv, bunExe, bunRun, - emptyProcessMaxRSS, hideFromStackTrace, integersThatNeedRounding, isASAN, @@ -562,26 +561,28 @@ describe("Bun.Transpiler", () => { ).toContain('("design:type", typeof A === "undefined" ? Object : A)'); }); - it("nested arrow functions with return types between ? and : take memory in proportion to their depth", async () => { - // Each arrow function here has the next one in its body. Every body was - // parsed twice, so depth n took n * n / 2 parses of a level, and what a - // parse allocates stays until the file is done. + it("nested arrow functions between ? and : cost the same with return types as without", async () => { + // Each arrow function here has the next one in its body. With a return + // type, every body was parsed twice, so depth n took n * n / 2 parses of + // a level, and what a parse allocates stays until the file is done. const depth = isDebug || isASAN ? 256 : 640; - const fixture = /* js */ ` + const fixture = returnType => /* js */ ` let input = "d"; let output = "d"; for (let i = 0; i < ${depth}; i++) { - input = "a ? (b) : c => " + input + " : e"; + input = "a ? (b)${returnType} => " + input + " : e"; output = "a ? (b) => " + output + " : e"; } const printed = new Bun.Transpiler({ loader: "ts" }).transformSync("x = " + input); console.log(JSON.stringify({ printed: printed === "x = " + output + ";\\n" })); `; - const [fixtureMaxRSS, baselineMaxRSS] = await Promise.all([ - runFixtureMaxRSS(fixture, { printed: true }), - emptyProcessMaxRSS(), + const [withReturnTypes, without] = await Promise.all([ + runFixtureMaxRSS(fixture(" : c"), { printed: true }), + runFixtureMaxRSS(fixture(""), { printed: true }), ]); - expect((fixtureMaxRSS - baselineMaxRSS) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 25 : 64); + // Peak RSS over the same input without return types. Before: 225 MB in + // release, 38 MB in a debug ASAN build. After: under 1 MB in both. + expect((withReturnTypes - without) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 10 : 32); }); it("type-only import syntax", () => { From e3be8f17ce7c555707ef6e4ee9687c6faa92a45c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:33:06 +0000 Subject: [PATCH 5/9] parser: keep the arrow function tail of `parse_paren_expr` inline The closure for the tail was built for every parenthesized arrow function and was a call in the TypeScript parsers. The tail is now an `#[inline(always)]` method, and only the attempt builds a closure. The JavaScript copies of `parse_paren_expr` compile to the size they had. The lookahead after the second ":" is out of line, so its lexer snapshot is not in the frame of the attempt while the body is parsed. --- src/js_parser/parse/mod.rs | 88 ++++++++++++++------ src/js_parser/parse/parse_skip_typescript.rs | 2 +- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 060472aca439..258788d85466 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -417,6 +417,39 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// The end of `parse_paren_expr` when the parentheses hold the arguments of an + /// arrow function. The lexer is at the "=>". + #[inline(always)] + fn parse_paren_arrow_fn( + &mut self, + loc: bun_ast::Loc, + opts: ParenExprOpts, + spread_range: bun_ast::Range, + comma_after_spread: bun_ast::Loc, + arrow_arg_errors: &mut DeferredArrowArgErrors, + invalid_log: &LocList<'a>, + args: BumpVec<'a, G::Arg>, + arrow_data: &mut FnOrArrowDataParse, + ) -> Result { + let p = self; + p.maybe_comma_spread_error(comma_after_spread); + p.log_arrow_arg_errors(arrow_arg_errors); + + // Now that we've decided we're an arrow function, report binding pattern + // conversion errors + if !invalid_log.is_empty() { + for loc_ in invalid_log.iter() { + loc_.add_error(p.log(), p.source); + } + } + let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); + let mut arrow = p.parse_arrow_body(args_slice, arrow_data)?; + arrow.is_async = opts.is_async; + arrow.has_rest_arg = spread_range.len > 0; + p.pop_scope(); + Ok(p.new_expr(arrow, loc)) + } + /// This assumes that the open parenthesis has already been parsed by the caller pub(crate) fn parse_paren_expr( &mut self, @@ -573,32 +606,23 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // there were no conversion errors. let maybe_return_type = Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty(); - - let parse_arrow_fn = move |p: &mut Self| -> Result { - p.maybe_comma_spread_error(comma_after_spread); - p.log_arrow_arg_errors(&mut arrow_arg_errors); - - // Now that we've decided we're an arrow function, report binding pattern - // conversion errors - if !invalid_log.is_empty() { - for loc_ in invalid_log.iter() { - loc_.add_error(p.log(), p.source); - } - } - let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); - let mut arrow = p.parse_arrow_body(args_slice, &mut arrow_data)?; - arrow.is_async = opts.is_async; - arrow.has_rest_arg = spread_range.len > 0; - p.pop_scope(); - Ok(p.new_expr(arrow, loc)) - }; - if maybe_return_type && opts.is_after_question_and_before_colon { // Only do this very expensive check if we must. It parses the arrow // function, and undoes that when the ":" pairs with the "?". - if let Some(arrow) = - p.parse_possible_arrow_fn_after_question_and_before_colon(parse_arrow_fn)? - { + let arrow = + p.parse_possible_arrow_fn_after_question_and_before_colon(move |p| { + p.parse_paren_arrow_fn( + loc, + opts, + spread_range, + comma_after_spread, + &mut arrow_arg_errors, + &invalid_log, + args, + &mut arrow_data, + ) + })?; + if let Some(arrow) = arrow { return Ok(arrow); } } else { @@ -608,7 +632,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } if is_arrow_fn || opts.force_arrow_fn { - return parse_arrow_fn(p); + return p.parse_paren_arrow_fn( + loc, + opts, + spread_range, + comma_after_spread, + &mut arrow_arg_errors, + &invalid_log, + args, + &mut arrow_data, + ); } } } @@ -1625,6 +1658,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O result } + /// Whether the lexer can read the token after the current one. Not inlined, + /// so that the lexer snapshot is not in the frame of a caller that recurses. + #[inline(never)] + fn can_read_next_token(&mut self) -> bool { + self.next_token_matches(|_| true) + } + #[inline] fn check_for_arrow_after_the_current_token(&mut self) -> bool { self.next_token_matches(|p| p.lexer.token == T::TEqualsGreaterThan) diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index 421fd0c49e93..cd5995bcf832 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -1621,7 +1621,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.skip_typescript_return_type()?; let arrow = parse_arrow_fn(self)?; // The ":" that pairs with the "?", and a token after it - if self.lexer.token != T::TColon || !self.next_token_matches(|_| true) { + if self.lexer.token != T::TColon || !self.can_read_next_token() { return Err(Error::Backtrack); } Ok(arrow) From c4484b79ec723f3cbc7094011fda18ae977e517e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:09:47 +0000 Subject: [PATCH 6/9] ci: retrigger From eca82ed43d7b2e96d8328cca71dff3548f579ab7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:39:54 +0000 Subject: [PATCH 7/9] parser: an attempt inside another attempt keeps its arrow function The attempt that decides whether `(b) : c => d` between the `?` and `:` of a conditional is an arrow function parses the body, restores the parser, and the caller parses the body again. An attempt in the body did the same, so a chain of these arrow functions parsed the body at depth n about n times, and the arena kept every parse. An attempt that runs inside another attempt is thrown away with it. When it finds an arrow function it now skips its own restore and hands the arrow function to the caller. The outermost attempt still restores the parser, and the parse after it still parses each body for real, so each body is parsed two times. The attempt is no longer inlined into `parse_paren_expr`, which keeps the parser snapshot out of that frame. --- src/ast/scope.rs | 25 ---- src/js_parser/lexer.rs | 13 +- src/js_parser/p.rs | 80 +++++------- src/js_parser/parse/mod.rs | 125 ++++++------------- src/js_parser/parse/parse_import_export.rs | 2 +- src/js_parser/parse/parse_skip_typescript.rs | 111 +++++++++------- test/bundler/transpiler/transpiler.test.js | 105 ++++++---------- 7 files changed, 174 insertions(+), 287 deletions(-) diff --git a/src/ast/scope.rs b/src/ast/scope.rs index d7876bdc42d9..d6ec7791f77a 100644 --- a/src/ast/scope.rs +++ b/src/ast/scope.rs @@ -196,31 +196,6 @@ impl Members { } } - /// Removes the members that `keep` rejects. - pub fn retain(&mut self, mut keep: impl FnMut(&Member) -> bool) { - fn retain_inline( - inline: &mut InlineMembers, - mut keep: impl FnMut(&Member) -> bool, - ) { - let mut kept = 0; - for i in 0..inline.len as usize { - if keep(&inline.members[i]) { - inline.hashes[kept] = inline.hashes[i]; - inline.members[kept] = inline.members[i]; - inline.names[kept] = inline.names[i]; - kept += 1; - } - } - inline.len = kept as u32; - } - match self { - Members::Empty => {} - Members::Small(inline) => retain_inline(inline, keep), - Members::Inline(inline) => retain_inline(inline, keep), - Members::Table(table) => table.retain(|entry| keep(&entry.member)), - } - } - /// Room for about `n` members (the module scope, sized from the source). pub fn reserve(&mut self, n: usize) { if n > Self::INLINE_MAX { diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index d75364805a76..26f624f19c00 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -223,16 +223,7 @@ pub struct Lexer<'a> { pub(crate) preserve_all_comments_before: bool, pub(crate) is_legacy_octal_literal: bool, pub(crate) is_log_disabled: bool, - /// A token mismatch backtracks, as it does while the log is disabled, but - /// the log stays on. Set for a speculative parse that is kept when it - /// succeeds, so that its diagnostics count - /// (`P::parse_possible_arrow_fn_after_question_and_before_colon`). - pub(crate) backtrack_on_mismatch: bool, pub(crate) comments_to_preserve_before: Vec, - /// How many of `comments_to_preserve_before` a statement or `import()` has - /// taken. They stay in the buffer, so a speculative parse that took them - /// gives them back by resetting this (`P::restore_parser_snapshot`). - pub(crate) comments_to_preserve_before_taken: usize, pub(crate) code_point: CodePoint, pub(crate) identifier: &'a [u8], pub(crate) jsx_pragma: JSXPragma, @@ -1836,7 +1827,7 @@ impl<'a> Lexer<'a> { #[cold] #[inline(never)] pub(crate) fn expected(&mut self, token: T) -> Result<(), Error> { - if self.is_log_disabled || self.backtrack_on_mismatch { + if self.is_log_disabled { return Err(Error::Backtrack); } else if !tokenToString_get(token).is_empty() { self.expected_string(tokenToString_get(token)) @@ -2277,9 +2268,7 @@ impl<'a> Lexer<'a> { preserve_all_comments_before: false, is_legacy_octal_literal: false, is_log_disabled: false, - backtrack_on_mismatch: false, comments_to_preserve_before: Vec::new(), - comments_to_preserve_before_taken: 0, code_point: -1, identifier: b"", jsx_pragma: JSXPragma::default(), diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 167cd5d5f2c8..08a89c67b74c 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -167,7 +167,7 @@ impl<'a> core::ops::DerefMut for NamedImportsType<'a> { /// See [`P::parser_snapshot`]. pub(crate) struct ParserSnapshot<'a> { lexer: js_lexer::LexerSnapshot<'a>, - comments_to_preserve_before_taken: usize, + comments_to_preserve_before: Vec, log_msgs_len: usize, log_errors: u32, log_warnings: u32, @@ -188,9 +188,6 @@ pub(crate) struct ParserSnapshot<'a> { scopes_in_order_len: usize, scopes_in_order_for_enum_len: usize, symbols_len: usize, - ref_to_ts_namespace_member_len: usize, - module_scope_members_len: usize, - span_uses_len: usize, allocated_names_len: usize, import_records_len: usize, } @@ -387,14 +384,21 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// until a constraint attempt actually backtracks, which is rare in real code. pub(crate) ts_infer_constraint_backtracks: Vec, - /// Byte offset of each `:` where `parse_possible_arrow_fn_after_question_and_before_colon` - /// found no arrow function. The outcome depends only on the source from that - /// offset on, so the attempt is not repeated when an attempt around it fails too - /// and the same text is parsed again. Without this memo every failed attempt - /// would run the failed attempts nested in its body twice, which is exponential - /// in the nesting depth. Kept sorted for binary search. + /// Outcomes of `is_type_script_arrow_return_type_after_question_and_before_colon`, + /// keyed by the byte offset of the `:` shifted left by one, with the low bit set + /// when the attempt parsed an arrow function. The outcome depends only on the + /// source from that offset on, so the real parse that follows a discarded + /// attempt can reuse it. Without this memo every nested + /// `a ? (b) : c => : e` inside the body is parsed once by the attempt and + /// once for real, which is exponential in the nesting depth. Kept sorted for + /// binary search. pub(crate) ts_conditional_arrow_attempts: Vec, + /// Set while `is_type_script_arrow_return_type_after_question_and_before_colon` + /// parses an arrow function. The outermost attempt always restores the + /// parser, so nothing that is parsed while this is set stays. + pub(crate) in_ts_conditional_arrow_attempt: bool, + /// When this flag is enabled, we attempt to fold all expressions that /// TypeScript would consider to be "constant expressions". This flag is /// enabled inside each enum body block since TypeScript requires numeric @@ -8201,15 +8205,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// expression can be undone with [`Self::restore_parser_snapshot`]. The /// lexer-only backtracking in `parse_skip_typescript.rs` only covers /// types: an expression also declares symbols, pushes scopes, records - /// dynamic imports and logs. Lists are captured as lengths and truncated + /// dynamic imports and logs through `P::log()`, which ignores + /// `lexer.is_log_disabled`. Lists are captured as lengths and truncated /// on restore; the attempt's arena allocations just become unreachable. - /// A block body or `import()` takes the legal comments that wait for the - /// next statement, so the count of taken ones is captured too. - pub(crate) fn parser_snapshot(&self) -> ParserSnapshot<'a> { + /// The legal comments waiting for the next statement are moved out + /// instead: a block body or `import()` drains them, so a length cannot + /// restore them. + pub(crate) fn parser_snapshot(&mut self) -> ParserSnapshot<'a> { + let comments_to_preserve_before = + core::mem::take(&mut self.lexer.comments_to_preserve_before); let log = self.log(); ParserSnapshot { lexer: self.lexer.snapshot(), - comments_to_preserve_before_taken: self.lexer.comments_to_preserve_before_taken, + comments_to_preserve_before, log_msgs_len: log.msgs.len(), log_errors: log.errors, log_warnings: log.warnings, @@ -8232,18 +8240,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O scopes_in_order_len: self.scopes_in_order.len(), scopes_in_order_for_enum_len: self.scopes_in_order_for_enum.len(), symbols_len: self.symbols.len(), - ref_to_ts_namespace_member_len: self.ref_to_ts_namespace_member.len(), - module_scope_members_len: self.module_scope.members.len(), - span_uses_len: self.span_uses.len(), allocated_names_len: self.allocated_names.len(), import_records_len: self.import_records.len(), } } /// Undo every parse-pass mutation made since [`Self::parser_snapshot`]. - pub(crate) fn restore_parser_snapshot(&mut self, snapshot: ParserSnapshot<'a>) { + pub(crate) fn restore_parser_snapshot(&mut self, snapshot: &mut ParserSnapshot<'a>) { self.lexer.restore(&snapshot.lexer); - self.lexer.comments_to_preserve_before_taken = snapshot.comments_to_preserve_before_taken; + self.lexer.comments_to_preserve_before = + core::mem::take(&mut snapshot.comments_to_preserve_before); let log = self.log(); log.msgs.truncate(snapshot.log_msgs_len); @@ -8257,7 +8263,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O snapshot.has_non_local_export_declare_inside_namespace; self.should_fold_typescript_constant_expressions = snapshot.should_fold_typescript_constant_expressions; - self.fn_or_arrow_data_parse = snapshot.fn_or_arrow_data_parse; + self.fn_or_arrow_data_parse = snapshot.fn_or_arrow_data_parse.clone(); self.latest_arrow_arg_loc = snapshot.latest_arrow_arg_loc; self.forbid_suffix_after_as_loc = snapshot.forbid_suffix_after_as_loc; self.after_arrow_body_loc = snapshot.after_arrow_body_loc; @@ -8275,16 +8281,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.scopes_in_order_for_enum.pop(); } + // Enum and namespace symbols also key `ref_to_ts_namespace_member`; a symbol + // that later reuses the index must not inherit their namespace data. if self.symbols.len() > snapshot.symbols_len { self.symbols.truncate(snapshot.symbols_len); if TYPESCRIPT { self.ts_use_counts.truncate(snapshot.symbols_len); } - } - // Enum and namespace symbols also key `ref_to_ts_namespace_member`; a symbol - // that later reuses the index must not inherit their namespace data. Only - // this removes from the map, so a new key shows as a longer map. - if self.ref_to_ts_namespace_member.len() > snapshot.ref_to_ts_namespace_member_len { let stale: Vec = self .ref_to_ts_namespace_member .keys() @@ -8295,30 +8298,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.ref_to_ts_namespace_member.remove(&ref_); } } - // The type names of decorator metadata are bound as they are parsed - // (`find_symbol`). A name that nothing declares becomes a member of the - // module scope. A name that a sibling namespace block exports becomes a - // property access of that namespace, which is around the attempt. - if self.module_scope.members.len() > snapshot.module_scope_members_len { - self.module_scope_mut() - .members - .retain(|member| (member.ref_.inner_index() as usize) < snapshot.symbols_len); - } - let mut enclosing = Some(snapshot.current_scope); - while let Some(scope) = enclosing { - if let Some(mut ts_namespace) = scope.ts_namespace { - let accesses = &mut ts_namespace.property_accesses; - while accesses - .values() - .last() - .is_some_and(|ref_| ref_.inner_index() as usize >= snapshot.symbols_len) - { - accesses.pop(); - } - } - enclosing = scope.parent; - } - self.span_uses.truncate(snapshot.span_uses_len); self.allocated_names.truncate(snapshot.allocated_names_len); self.import_records.truncate(snapshot.import_records_len); } @@ -9709,6 +9688,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O reported_stack_overflow: core::cell::Cell::new(false), ts_infer_constraint_backtracks: Vec::new(), ts_conditional_arrow_attempts: Vec::new(), + in_ts_conditional_arrow_attempt: false, arena, then_catch_chain: ThenCatchChain { next_target: null_expr_data(), diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 258788d85466..132e7218787c 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -417,39 +417,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// The end of `parse_paren_expr` when the parentheses hold the arguments of an - /// arrow function. The lexer is at the "=>". - #[inline(always)] - fn parse_paren_arrow_fn( - &mut self, - loc: bun_ast::Loc, - opts: ParenExprOpts, - spread_range: bun_ast::Range, - comma_after_spread: bun_ast::Loc, - arrow_arg_errors: &mut DeferredArrowArgErrors, - invalid_log: &LocList<'a>, - args: BumpVec<'a, G::Arg>, - arrow_data: &mut FnOrArrowDataParse, - ) -> Result { - let p = self; - p.maybe_comma_spread_error(comma_after_spread); - p.log_arrow_arg_errors(arrow_arg_errors); - - // Now that we've decided we're an arrow function, report binding pattern - // conversion errors - if !invalid_log.is_empty() { - for loc_ in invalid_log.iter() { - loc_.add_error(p.log(), p.source); - } - } - let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); - let mut arrow = p.parse_arrow_body(args_slice, arrow_data)?; - arrow.is_async = opts.is_async; - arrow.has_rest_arg = spread_range.len > 0; - p.pop_scope(); - Ok(p.new_expr(arrow, loc)) - } - /// This assumes that the open parenthesis has already been parsed by the caller pub(crate) fn parse_paren_expr( &mut self, @@ -604,45 +571,46 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // attempt to convert the expressions to bindings first before deciding // whether this is an arrow function, and only pick an arrow function if // there were no conversion errors. - let maybe_return_type = - Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty(); - if maybe_return_type && opts.is_after_question_and_before_colon { - // Only do this very expensive check if we must. It parses the arrow - // function, and undoes that when the ":" pairs with the "?". - let arrow = - p.parse_possible_arrow_fn_after_question_and_before_colon(move |p| { - p.parse_paren_arrow_fn( - loc, - opts, - spread_range, - comma_after_spread, - &mut arrow_arg_errors, - &invalid_log, - args, - &mut arrow_data, - ) - })?; - if let Some(arrow) = arrow { - return Ok(arrow); - } - } else { - if maybe_return_type { + if Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty() { + if opts.is_after_question_and_before_colon { + // Only do this very expensive check if we must + let parsed; + (is_arrow_fn, parsed) = p + .is_type_script_arrow_return_type_after_question_and_before_colon( + &arrow_data, + )?; + if let Some(arrow) = parsed { + p.pop_scope(); + return Ok(p.new_expr(arrow, loc)); + } + if is_arrow_fn { + // We know this will succeed because we've already done it once above + p.lexer.next()?; + p.skip_typescript_return_type()?; + } + } else { // Otherwise, do the less expensive check is_arrow_fn = p.try_skip_type_script_arrow_return_type_with_backtracking()?; } + } - if is_arrow_fn || opts.force_arrow_fn { - return p.parse_paren_arrow_fn( - loc, - opts, - spread_range, - comma_after_spread, - &mut arrow_arg_errors, - &invalid_log, - args, - &mut arrow_data, - ); + if is_arrow_fn || opts.force_arrow_fn { + p.maybe_comma_spread_error(comma_after_spread); + p.log_arrow_arg_errors(&mut arrow_arg_errors); + + // Now that we've decided we're an arrow function, report binding pattern + // conversion errors + if !invalid_log.is_empty() { + for loc_ in invalid_log.iter() { + loc_.add_error(p.log(), p.source); + } } + let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); + let mut arrow = p.parse_arrow_body(args_slice, &mut arrow_data)?; + arrow.is_async = opts.is_async; + arrow.has_rest_arg = spread_range.len > 0; + p.pop_scope(); + return Ok(p.new_expr(arrow, loc)); } } @@ -1518,11 +1486,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut is_directive_prologue = true; loop { - if p.lexer.comments_to_preserve_before_taken - != p.lexer.comments_to_preserve_before.len() - { - p.take_comments_to_preserve_before(&mut stmts); + for comment in p.lexer.comments_to_preserve_before.iter() { + let loc = p.lexer.loc(); + stmts.push(p.s(S::Comment { text: comment.text }, loc)); } + p.lexer.comments_to_preserve_before.clear(); if p.lexer.token == eend { break; @@ -1636,17 +1604,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(stmts) } - /// Puts the legal comments that wait for the next statement before it. - #[cold] - fn take_comments_to_preserve_before(&mut self, stmts: &mut StmtList<'a>) { - let loc = self.lexer.loc(); - let taken = self.lexer.comments_to_preserve_before_taken; - for comment in &self.lexer.comments_to_preserve_before[taken..] { - stmts.push(self.s(S::Comment { text: comment.text }, loc)); - } - self.lexer.comments_to_preserve_before_taken = self.lexer.comments_to_preserve_before.len(); - } - /// One-token lookahead: advance past the current token, evaluate `pred`, /// then unconditionally restore the lexer (including `is_log_disabled`). #[inline] @@ -1659,9 +1616,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } /// Whether the lexer can read the token after the current one. Not inlined, - /// so that the lexer snapshot is not in the frame of a caller that recurses. + /// so that the lexer snapshot stays out of the frame of a caller that recurses. #[inline(never)] - fn can_read_next_token(&mut self) -> bool { + pub(crate) fn can_read_next_token(&mut self) -> bool { self.next_token_matches(|_| true) } diff --git a/src/js_parser/parse/parse_import_export.rs b/src/js_parser/parse/parse_import_export.rs index c38ac85b7b55..f862d5adde3e 100644 --- a/src/js_parser/parse/parse_import_export.rs +++ b/src/js_parser/parse/parse_import_export.rs @@ -46,7 +46,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect(T::TOpenParen)?; // const comments = try p.lexer.comments_to_preserve_before.toOwnedSlice(); - p.lexer.comments_to_preserve_before_taken = p.lexer.comments_to_preserve_before.len(); + p.lexer.comments_to_preserve_before.clear(); p.lexer.preserve_all_comments_before = false; diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index cd5995bcf832..29f18f389ee5 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -2,11 +2,13 @@ use crate::Error; use crate::lexer::T; use crate::p::P; -use crate::parser::{ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag}; +use crate::parser::{ + FnOrArrowDataParse, ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, +}; use crate::typescript; use crate::typescript::SkipTypeOptions; use crate::typescript::identifier::{Kind as TsIdentKind, kind_for_identifier}; -use bun_ast::Expr; +use bun_ast::E; use bun_ast::op::Level; use bun_ast::ts::Metadata; @@ -1582,70 +1584,83 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } - /// The arrow function whose return type starts at the ":" after a - /// parenthesized expression that sits between the "?" and ":" of a - /// conditional, or `None`. The TypeScript compiler parses the whole arrow - /// function and keeps it only when another ":" follows its body: + /// Whether the ":" after a parenthesized expression that sits between the + /// "?" and ":" of a conditional starts an arrow function return type. The + /// TypeScript compiler parses the whole arrow function and keeps it only + /// when another ":" follows its body: /// /// x = a ? (b) : c => d; // "(b)" is an expression, ":" pairs with "?" /// y = a ? (b) : c => d : e; // "(b) : c => d" is an arrow function /// - /// `parse_arrow_fn` parses from the "=>" on, and that parse is kept when it - /// ends before a ":". Otherwise the whole parser is restored from a - /// snapshot, because parsing an expression mutates parser state, and the - /// offset of the ":" goes into `ts_conditional_arrow_attempts`. The caller - /// then drops the `FunctionArgs` scope, and with it the arguments that - /// `parse_arrow_fn` declared there. + /// The body is parsed here and thrown away, then parsed again by the + /// caller when this returns true. Parsing an expression mutates parser + /// state, so the whole parser is restored from a snapshot. The current + /// scope is the arrow's `FunctionArgs` scope; no arguments are declared so + /// that its members stay untouched. The outcome is memoized by the offset + /// of the ":" in `ts_conditional_arrow_attempts`, so the real parse does not + /// repeat the attempts nested inside the body. + /// + /// An attempt that runs inside another attempt is thrown away with it. When + /// it finds an arrow function, it restores nothing and returns the arrow + /// function, and the caller uses that in place of a second parse. Without + /// this, every attempt around a body parses that body one more time. + // Not inlined: `parse_paren_expr` recurses and the snapshot is large. #[cold] #[inline(never)] - pub(crate) fn parse_possible_arrow_fn_after_question_and_before_colon( + pub(crate) fn is_type_script_arrow_return_type_after_question_and_before_colon( &mut self, - parse_arrow_fn: impl FnOnce(&mut Self) -> Result, - ) -> Result, Error> { + arrow_data: &FnOrArrowDataParse, + ) -> Result<(bool, Option), Error> { self.mark_type_script_only(); - debug_assert!(self.lexer.start <= u32::MAX as usize); + debug_assert!(self.lexer.start <= (u32::MAX >> 1) as usize); let memo_key = self.lexer.start as u32; - if self + if let Ok(i) = self .ts_conditional_arrow_attempts - .binary_search(&memo_key) - .is_ok() + .binary_search_by_key(&memo_key, |&packed| packed >> 1) { - return Ok(None); + return Ok((self.ts_conditional_arrow_attempts[i] & 1 == 1, None)); } - let snapshot = self.parser_snapshot(); - let backtracked_on_mismatch = - core::mem::replace(&mut self.lexer.backtrack_on_mismatch, true); - let result: Result = (|| { - self.lexer.next()?; + let mut snapshot = self.parser_snapshot(); + self.lexer.is_log_disabled = true; + let nested = core::mem::replace(&mut self.in_ts_conditional_arrow_attempt, true); + + let mut data = arrow_data.clone(); + let mut parsed = None; + let result: Result<(), Error> = (|| { + self.lexer.expect(T::TColon)?; self.skip_typescript_return_type()?; - let arrow = parse_arrow_fn(self)?; - // The ":" that pairs with the "?", and a token after it - if self.lexer.token != T::TColon || !self.can_read_next_token() { - return Err(Error::Backtrack); + let arrow = self.parse_arrow_body(&mut [], &mut data)?; + // The ":" that pairs with the "?". As `expect` does, read the token after it. + if nested && self.lexer.token == T::TColon && self.can_read_next_token() { + parsed = Some(arrow); + return Ok(()); } - Ok(arrow) + self.lexer.expect(T::TColon)?; + Ok(()) })(); - self.lexer.backtrack_on_mismatch = backtracked_on_mismatch; + self.in_ts_conditional_arrow_attempt = nested; - match result { - Ok(arrow) => Ok(Some(arrow)), - Err(err) => { - self.restore_parser_snapshot(snapshot); - // Stack and memory exhaustion are not properties of the attempt - if matches!(err, Error::StackOverflow | Error::Alloc(_)) { - return Err(err); - } - // Attempts nested inside this one may have added entries of their own, - // so the insertion point is searched for only now. - if let Err(insert_at) = self.ts_conditional_arrow_attempts.binary_search(&memo_key) - { - self.ts_conditional_arrow_attempts - .insert(insert_at, memo_key); - } - Ok(None) - } + if parsed.is_none() { + self.restore_parser_snapshot(&mut snapshot); + } + 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, + }; + + // Re-search for the insertion point: attempts nested inside this one may + // have added entries of their own. + if let Err(insert_at) = self + .ts_conditional_arrow_attempts + .binary_search_by_key(&memo_key, |&packed| packed >> 1) + { + self.ts_conditional_arrow_attempts + .insert(insert_at, (memo_key << 1) | is_arrow_fn as u32); } + Ok((is_arrow_fn, parsed)) } // ─────────────────────── try_* wrappers ─────────────────────── diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 5e4fa973a96c..f5e69074778e 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -434,8 +434,8 @@ describe("Bun.Transpiler", () => { // Newlines are important (they trigger backtracking) exp("x = (\n a ? (b = c) : { d: e }\n)", "x = a ? b = c : { d: e };\n"); - // The attempt parses the arrow body in the parser state of its place: - // private names and the "in" operator. + // The attempt to parse the arrow body must see the same parser state as + // the real parse: private names and the "in" operator. exp( "x = class { #y; y = a ? (b : T) : T => this.#y : c }", "x = class {\n #y;\n y = a ? (b) => this.#y : c;\n};\n", @@ -467,55 +467,50 @@ describe("Bun.Transpiler", () => { ); // A legal comment scanned between "?" and "(" waits in the lexer for the - // next statement. The attempt's block body or import() takes it. The - // parse after a discarded attempt must still get it. + // next statement. The attempt's block body or import() takes it. The real + // parse must still get it. exp("x = a ? /*! L */ (b) : T => { let y = 1 } : c", "x = a ? (b) => {\n /*! L */\n let y = 1;\n} : c;\n"); exp("x = a ? /*! L */ (b) : c => { return d }", "x = a ? b : (c) => {\n /*! L */\n return d;\n};\n"); exp('x = a ? /*! L */ (b) : T => import("m") : c', 'x = a ? (b) => import("m") : c;\n'); + + // An attempt in the body of another attempt keeps the arrow function that + // it finds, and the outer attempt throws it away. The parse after the + // outer attempt declares the parameters, reports the errors and gets the + // legal comments. + exp("x = a ? (b) : c => [a ? (b) : T => d : e]", "x = a ? b : (c) => [a ? (b) => d : e];\n"); + exp("x = a ? (b) : T => [a ? (b) : c => d] : e", "x = a ? (b) => [a ? b : (c) => d] : e;\n"); exp( - "x = a ? /*! L1 */ (b) : c => { return a ? /*! L2 */ (b) : T => { let y = 1 } : c }", - "x = a ? b : (c) => {\n /*! L1 */\n return a ? (b) => {\n /*! L2 */\n let y = 1;\n } : c;\n};\n", + "x = a ? async (b) : c => a ? async (b) : c => await d : e : e", + "x = a ? async (b) => a ? async (b) => await d : e : e;\n", ); + exp( + "x = a ? (b) : T => a ? (c, ...d) : T => [b, c, d] : e : e", + "x = a ? (b) => a ? (c, ...d) => [b, c, d] : e : e;\n", + ); + err( + "x = a ? (b) : T => a ? (c, c) : T => 1 : e : e", + '"c" cannot be bound multiple times in the same parameter list', + ); + err("x = a ? (b) : T => a ? (b) : T => { let b } : e : e", '"b" has already been declared'); exp( "x = a ? /*! L1 */ (b) : T => a ? /*! L2 */ (b) : T => d : e : e; y", "x = a ? (b) => a ? (b) => d : e : e;\n/*! L1 */\n/*! L2 */\ny;\n", ); exp( - "/*! A */ x = a ? /*! L1 */ (b) : c => { /*! L2 */ z; /*! L3 */ }; /*! B */ y", - "/*! A */\nx = a ? b : (c) => {\n /*! L1 */\n /*! L2 */\n z;\n /*! L3 */\n};\n/*! B */\ny;\n", + "x = a ? /*! L1 */ (b) : c => { return a ? /*! L2 */ (b) : T => { let y = 1 } : c }", + "x = a ? b : (c) => {\n /*! L1 */\n return a ? (b) => {\n /*! L2 */\n let y = 1;\n } : c;\n};\n", ); - - // A kept attempt is the only parse of its arrow function: it reports the - // errors of that parse and declares the parameters. A discarded attempt - // leaves neither behind. - err("x = a ? (b) : T => /x/gg : e", 'Duplicate flag "g" in regular expression'); - err("x = a ? (b) : T => { let b } : e", '"b" has already been declared'); - err("x = a ? (b, b) : T => 1 : e", '"b" cannot be bound multiple times in the same parameter list'); - err("x = a ? (...b, c) : T => b : e", 'Unexpected "," after rest pattern'); - exp("x = a ? (b) : c => { let b }", "x = a ? b : (c) => {\n let b;\n};\n"); - exp("x = a ? (b, b) : c => 1", "x = a ? (b, b) : (c) => 1;\n"); - - // A token that does not fit ends the attempt. So does a token after the - // second ":" that the lexer cannot read. - err("x = a ? (b) : c => (d e) : f", 'Expected ")" but found "e"'); - err("x = a ? (b) : T => d : 'abc", 'Expected ";" but found ":"'); - - // An enum from before a discarded attempt is still inlined + // The inner attempt finds no arrow function when the lexer cannot read + // the token after the second ":". + err("x = a ? (b) : T => a ? (b) : T => d : 'abc : e", 'Expected ";" but found ":"'); + // An attempt that ended does not make the next attempt an inner one. exp( - "enum E { A = 1 }\nx = a ? (b) : c => E.A", - 'var E;\n((E) => {\n E[E["A"] = 1] = "A";\n})(E ||= {});\nx = a ? b : (c) => 1 /* A */;\n', + "x = a ? (b) : c => d; y = a ? (b) : T => b : e; z = a ? (b) : T => b : e", + "x = a ? b : (c) => d;\ny = a ? (b) => b : e;\nz = a ? (b) => b : e;\n", ); - // A kept attempt in a discarded one, and a discarded attempt in a kept one - exp("x = a ? (b) : c => [a ? (b) : T => d : e]", "x = a ? b : (c) => [a ? (b) => d : e];\n"); - exp("x = a ? (b) : T => [a ? (b) : c => d] : e", "x = a ? (b) => [a ? b : (c) => d] : e;\n"); - exp( - "x = a ? async (b) : c => a ? async (b) : c => await d : e : e", - "x = a ? async (b) => a ? async (b) => await d : e : e;\n", - ); - - // The body of a discarded attempt is parsed again, so a discarded attempt - // nested in that body must not run again: 2^40 parses would hang. + // The attempt parses the body, so an attempt nested in the body must not + // run again when the body is parsed for real: 2^40 parses would hang. let kept = "d"; let discarded = "d"; let keptInDiscarded = "d"; @@ -535,36 +530,12 @@ describe("Bun.Transpiler", () => { exp(`x = ${keptInDiscarded}`, `x = ${keptInDiscardedOut};\n`); }); - it("parses an arrow function with a return type between the ? and : of a conditional once", () => { - // The type of a decorated property is bound to a symbol as it is parsed. - // When the arrow function was parsed a second time, `Foo` was bound to - // the symbol that had taken its index: `K`. A discarded attempt left - // the same binding to the parse after it. - const withMetadata = new Bun.Transpiler({ - loader: "ts", - tsconfig: { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }, - }); - const typeIsFoo = '("design:type", typeof Foo === "undefined" ? Object : Foo)'; - expect(withMetadata.transformSync("x = a ? (b) : T => { class K { @dec p: Foo } return K } : e")).toContain( - typeIsFoo, - ); - expect(withMetadata.transformSync("x = a ? (b) : c => { class K { @dec p: Foo } return K }")).toContain( - typeIsFoo, - ); - expect( - withMetadata.transformSync("x = a ? (b, c) : d => { class K { @dec p: Foo } return new Foo() }"), - ).toContain("return new Foo"); - expect( - withMetadata.transformSync( - "namespace N { export class A {} } namespace N { x = a ? (b) : c => { class K { @dec p: A } return K } }", - ), - ).toContain('("design:type", typeof A === "undefined" ? Object : A)'); - }); - - it("nested arrow functions between ? and : cost the same with return types as without", async () => { - // Each arrow function here has the next one in its body. With a return - // type, every body was parsed twice, so depth n took n * n / 2 parses of - // a level, and what a parse allocates stays until the file is done. + it("nested arrow functions with return types between ? and : take linear memory", async () => { + // Each arrow function here has the next one in its body. Every attempt + // parsed its body, threw it away, and the parse after it parsed the body + // again with a second parse of each arrow function in it. Depth n took + // n * n / 2 parses of a level, and what a parse allocates stays until + // the file is done. const depth = isDebug || isASAN ? 256 : 640; const fixture = returnType => /* js */ ` let input = "d"; From 28ed7ee7a1691e4fb718a66c88f58bc3c0101f26 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:59:02 +0000 Subject: [PATCH 8/9] parser: keep the arrow function that an attempt finds The attempt that decides whether `(b) : c => d` between the `?` and `:` of a conditional is an arrow function parsed the arrow function, restored the parser, and the caller parsed it again. The attempt now parses the arrow function with its arguments, with the log on and with a lexer flag that makes a token mismatch end the attempt. When another `:` follows, that parse is the result and nothing is restored. A check that only moves the lexer runs first, so an attempt with no `=>` after the type takes no parser snapshot. With emitDecoratorMetadata the attempt is thrown away as before and the caller parses again: the snapshot does not undo what metadata adds. The snapshot copies the pending legal comments, and they stay in the lexer for the parse that is kept. --- src/js_parser/lexer.rs | 7 +- src/js_parser/p.rs | 23 +-- src/js_parser/parse/mod.rs | 108 +++++++++----- src/js_parser/parse/parse_skip_typescript.rs | 145 ++++++++++++------- src/js_parser/parser.rs | 11 ++ test/bundler/transpiler/transpiler.test.js | 103 +++++++++++-- 6 files changed, 289 insertions(+), 108 deletions(-) diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 26f624f19c00..8176a3048bb2 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -223,6 +223,10 @@ pub struct Lexer<'a> { pub(crate) preserve_all_comments_before: bool, pub(crate) is_legacy_octal_literal: bool, pub(crate) is_log_disabled: bool, + /// `expected` returns `Error::Backtrack` with the log on, as it does with + /// the log off. Set for a parse that is thrown away when a token does not + /// fit, and whose messages stay when it is kept. + pub(crate) backtrack_on_mismatch: bool, pub(crate) comments_to_preserve_before: Vec, pub(crate) code_point: CodePoint, pub(crate) identifier: &'a [u8], @@ -1827,7 +1831,7 @@ impl<'a> Lexer<'a> { #[cold] #[inline(never)] pub(crate) fn expected(&mut self, token: T) -> Result<(), Error> { - if self.is_log_disabled { + if self.is_log_disabled || self.backtrack_on_mismatch { return Err(Error::Backtrack); } else if !tokenToString_get(token).is_empty() { self.expected_string(tokenToString_get(token)) @@ -2268,6 +2272,7 @@ impl<'a> Lexer<'a> { preserve_all_comments_before: false, is_legacy_octal_literal: false, is_log_disabled: false, + backtrack_on_mismatch: false, comments_to_preserve_before: Vec::new(), code_point: -1, identifier: b"", diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 931fc7cd4af9..53c0e094ef22 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -394,11 +394,6 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// binary search. pub(crate) ts_conditional_arrow_attempts: Vec, - /// Set while `is_type_script_arrow_return_type_after_question_and_before_colon` - /// parses an arrow function. The outermost attempt always restores the - /// parser, so nothing that is parsed while this is set stays. - pub(crate) in_ts_conditional_arrow_attempt: bool, - /// When this flag is enabled, we attempt to fold all expressions that /// TypeScript would consider to be "constant expressions". This flag is /// enabled inside each enum body block since TypeScript requires numeric @@ -8209,12 +8204,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// dynamic imports and logs through `P::log()`, which ignores /// `lexer.is_log_disabled`. Lists are captured as lengths and truncated /// on restore; the attempt's arena allocations just become unreachable. - /// The legal comments waiting for the next statement are moved out + /// The legal comments waiting for the next statement are copied /// instead: a block body or `import()` drains them, so a length cannot - /// restore them. + /// restore them. They stay in the lexer for a parse that is kept. pub(crate) fn parser_snapshot(&mut self) -> ParserSnapshot<'a> { - let comments_to_preserve_before = - core::mem::take(&mut self.lexer.comments_to_preserve_before); + let comments_to_preserve_before = self + .lexer + .comments_to_preserve_before + .iter() + .map(|comment| js_ast::G::Comment { + loc: comment.loc, + text: comment.text, + }) + .collect(); let log = self.log(); ParserSnapshot { lexer: self.lexer.snapshot(), @@ -8248,9 +8250,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// Undo every parse-pass mutation made since [`Self::parser_snapshot`]. pub(crate) fn restore_parser_snapshot(&mut self, snapshot: &mut ParserSnapshot<'a>) { - self.lexer.restore(&snapshot.lexer); self.lexer.comments_to_preserve_before = core::mem::take(&mut snapshot.comments_to_preserve_before); + self.lexer.restore(&snapshot.lexer); let log = self.log(); log.msgs.truncate(snapshot.log_msgs_len); @@ -9689,7 +9691,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O reported_stack_overflow: core::cell::Cell::new(false), ts_infer_constraint_backtracks: Vec::new(), ts_conditional_arrow_attempts: Vec::new(), - in_ts_conditional_arrow_attempt: false, arena, then_catch_chain: ThenCatchChain { next_target: null_expr_data(), diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 44e9f540711d..6d104acb6cb6 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -22,8 +22,8 @@ use bun_ast::LexerLog as _; use crate::lexer::T; use crate::p::P; use crate::parser::{ - AwaitOrYield, DeferredArrowArgErrors, DeferredErrors, ExprListLoc, ExprOrLetStmt, - FnOrArrowDataParse, LexicalDecl, LocList, ParenExprOpts, ParseBindingOptions, + AwaitOrYield, ConditionalArrowFn, DeferredArrowArgErrors, DeferredErrors, ExprListLoc, + ExprOrLetStmt, FnOrArrowDataParse, LexicalDecl, LocList, ParenExprOpts, ParseBindingOptions, ParseClassOptions, ParseStatementOptions, ParsedPath, PropertyOpts, SkipTypeParameterResult, StmtList, TypeParameterFlag, }; @@ -418,6 +418,39 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// The end of `parse_paren_expr` when the parentheses hold the arguments of + /// an arrow function. The lexer is at the "=>". + #[inline(always)] + pub(crate) fn parse_paren_arrow_fn( + &mut self, + loc: bun_ast::Loc, + opts: ParenExprOpts, + spread_range: bun_ast::Range, + comma_after_spread: bun_ast::Loc, + arrow_arg_errors: &mut DeferredArrowArgErrors, + invalid_log: &LocList<'a>, + args: BumpVec<'a, G::Arg>, + arrow_data: &mut FnOrArrowDataParse, + ) -> Result { + let p = self; + p.maybe_comma_spread_error(comma_after_spread); + p.log_arrow_arg_errors(arrow_arg_errors); + + // Now that we've decided we're an arrow function, report binding pattern + // conversion errors + if !invalid_log.is_empty() { + for loc_ in invalid_log.iter() { + loc_.add_error(p.log(), p.source); + } + } + let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); + let mut arrow = p.parse_arrow_body(args_slice, arrow_data)?; + arrow.is_async = opts.is_async; + arrow.has_rest_arg = spread_range.len > 0; + p.pop_scope(); + Ok(p.new_expr(arrow, loc)) + } + /// This assumes that the open parenthesis has already been parsed by the caller pub(crate) fn parse_paren_expr( &mut self, @@ -572,46 +605,55 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // attempt to convert the expressions to bindings first before deciding // whether this is an arrow function, and only pick an arrow function if // there were no conversion errors. - if Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty() { - if opts.is_after_question_and_before_colon { - // Only do this very expensive check if we must - let parsed; - (is_arrow_fn, parsed) = p - .is_type_script_arrow_return_type_after_question_and_before_colon( - &arrow_data, - )?; - if let Some(arrow) = parsed { - p.pop_scope(); - return Ok(p.new_expr(arrow, loc)); - } - if is_arrow_fn { + let maybe_return_type = + Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty(); + if maybe_return_type && opts.is_after_question_and_before_colon { + // Only do this very expensive check if we must + match p.parse_arrow_fn_after_question_and_before_colon( + loc, + opts, + spread_range, + comma_after_spread, + arrow_arg_errors, + args, + &arrow_data, + )? { + ConditionalArrowFn::Parsed(arrow) => return Ok(arrow), + ConditionalArrowFn::Found(args) => { // We know this will succeed because we've already done it once above p.lexer.next()?; p.skip_typescript_return_type()?; + return p.parse_paren_arrow_fn( + loc, + opts, + spread_range, + comma_after_spread, + &mut arrow_arg_errors, + &invalid_log, + args, + &mut arrow_data, + ); } - } else { + ConditionalArrowFn::NotFound => {} + } + } else { + if maybe_return_type { // Otherwise, do the less expensive check is_arrow_fn = p.try_skip_type_script_arrow_return_type_with_backtracking()?; } - } - - if is_arrow_fn || opts.force_arrow_fn { - p.maybe_comma_spread_error(comma_after_spread); - p.log_arrow_arg_errors(&mut arrow_arg_errors); - // Now that we've decided we're an arrow function, report binding pattern - // conversion errors - if !invalid_log.is_empty() { - for loc_ in invalid_log.iter() { - loc_.add_error(p.log(), p.source); - } + if is_arrow_fn || opts.force_arrow_fn { + return p.parse_paren_arrow_fn( + loc, + opts, + spread_range, + comma_after_spread, + &mut arrow_arg_errors, + &invalid_log, + args, + &mut arrow_data, + ); } - let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); - let mut arrow = p.parse_arrow_body(args_slice, &mut arrow_data)?; - arrow.is_async = opts.is_async; - arrow.has_rest_arg = spread_range.len > 0; - p.pop_scope(); - return Ok(p.new_expr(arrow, loc)); } } diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index a23edfd23fe7..4d947933f10d 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -3,14 +3,16 @@ use crate::Error; use crate::lexer::T; use crate::p::P; use crate::parser::{ - FnOrArrowDataParse, ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, + ConditionalArrowFn, DeferredArrowArgErrors, FnOrArrowDataParse, LocList, ParenExprOpts, + ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, }; use crate::typescript; use crate::typescript::SkipTypeOptions; use crate::typescript::identifier::{Kind as TsIdentKind, kind_for_identifier}; -use bun_ast::E; +use bun_alloc::ArenaVec as BumpVec; use bun_ast::op::Level; use bun_ast::ts::Metadata; +use bun_ast::{Expr, G}; // Re-export so the parser-side type alias used in this file matches the // canonical definition in `TypeScript.rs`. @@ -1584,33 +1586,37 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } - /// Whether the ":" after a parenthesized expression that sits between the - /// "?" and ":" of a conditional starts an arrow function return type. The - /// TypeScript compiler parses the whole arrow function and keeps it only - /// when another ":" follows its body: + /// Decides what the ":" after a parenthesized expression means when that + /// expression sits between the "?" and ":" of a conditional. It can start + /// the return type of an arrow function. The TypeScript compiler parses the + /// whole arrow function and keeps it only when another ":" follows its body: /// /// x = a ? (b) : c => d; // "(b)" is an expression, ":" pairs with "?" /// y = a ? (b) : c => d : e; // "(b) : c => d" is an arrow function /// - /// The body is parsed here and thrown away, then parsed again by the - /// caller when this returns true. Parsing an expression mutates parser - /// state, so the whole parser is restored from a snapshot. The current - /// scope is the arrow's `FunctionArgs` scope; no arguments are declared so - /// that its members stay untouched. The outcome is memoized by the offset - /// of the ":" in `ts_conditional_arrow_attempts`, so the real parse does not - /// repeat the attempts nested inside the body. + /// The arrow function is parsed here with the arguments from the caller, and + /// that parse is kept when another ":" follows. When none follows, the whole + /// parser goes back to a snapshot, because parsing an expression mutates + /// parser state. The outcome is memoized by the offset of the ":" in + /// `ts_conditional_arrow_attempts`, so the parse after a failed attempt does + /// not repeat the attempts nested inside the body. /// - /// An attempt that runs inside another attempt is thrown away with it. When - /// it finds an arrow function, it restores nothing and returns the arrow - /// function, and the caller uses that in place of a second parse. Without - /// this, every attempt around a body parses that body one more time. + /// The snapshot does not undo what decorator metadata adds. So with + /// `emitDecoratorMetadata` the arrow function is parsed with metadata off and + /// without its arguments, thrown away, and parsed again by the caller. // Not inlined: `parse_paren_expr` recurses and the snapshot is large. #[cold] #[inline(never)] - pub(crate) fn is_type_script_arrow_return_type_after_question_and_before_colon( + pub(crate) fn parse_arrow_fn_after_question_and_before_colon( &mut self, + loc: bun_ast::Loc, + opts: ParenExprOpts, + spread_range: bun_ast::Range, + comma_after_spread: bun_ast::Loc, + mut arrow_arg_errors: DeferredArrowArgErrors, + mut args: BumpVec<'a, G::Arg>, arrow_data: &FnOrArrowDataParse, - ) -> Result<(bool, Option), Error> { + ) -> Result, Error> { self.mark_type_script_only(); debug_assert!(self.lexer.start <= (u32::MAX >> 1) as usize); let memo_key = self.lexer.start as u32; @@ -1618,42 +1624,62 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .ts_conditional_arrow_attempts .binary_search_by_key(&memo_key, |&packed| packed >> 1) { - return Ok((self.ts_conditional_arrow_attempts[i] & 1 == 1, None)); + return Ok(if self.ts_conditional_arrow_attempts[i] & 1 == 1 { + ConditionalArrowFn::Found(args) + } else { + ConditionalArrowFn::NotFound + }); } - let mut 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 nested = core::mem::replace(&mut self.in_ts_conditional_arrow_attempt, true); - - let mut data = arrow_data.clone(); + // Most of the time no "=>" follows, and only the lexer has to go back. + let mut is_arrow_fn = self.is_type_script_arrow_return_type_before_arrow()?; let mut parsed = None; - let result: Result<(), Error> = (|| { - self.lexer.expect(T::TColon)?; - self.skip_typescript_return_type()?; - let arrow = self.parse_arrow_body(&mut [], &mut data)?; - // The ":" that pairs with the "?". As `expect` does, read the token after it. - if nested && self.lexer.token == T::TColon && self.can_read_next_token() { - parsed = Some(arrow); - return Ok(()); - } - self.lexer.expect(T::TColon)?; - Ok(()) - })(); - self.in_ts_conditional_arrow_attempt = nested; + if is_arrow_fn { + let mut snapshot = self.parser_snapshot(); + // 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 backtrack_on_mismatch = + core::mem::replace(&mut self.lexer.backtrack_on_mismatch, true); + + let mut data = arrow_data.clone(); + let result: Result, Error> = (|| { + self.lexer.next()?; + self.skip_typescript_return_type()?; + let arrow = if emit_decorator_metadata { + self.parse_arrow_body(&mut [], &mut data)?; + None + } else { + Some(self.parse_paren_arrow_fn( + loc, + opts, + spread_range, + comma_after_spread, + &mut arrow_arg_errors, + &LocList::new_in(self.arena), + core::mem::replace(&mut args, BumpVec::new_in(self.arena)), + &mut data, + )?) + }; + // The ":" that pairs with the "?", and a token after it + if self.lexer.token != T::TColon || !self.can_read_next_token() { + return Err(Error::Backtrack); + } + Ok(arrow) + })(); + self.lexer.backtrack_on_mismatch = backtrack_on_mismatch; - if parsed.is_none() { - self.restore_parser_snapshot(&mut snapshot); + if !matches!(result, Ok(Some(_))) { + self.restore_parser_snapshot(&mut snapshot); + } + self.options.features.emit_decorator_metadata = emit_decorator_metadata; + match result { + Ok(arrow) => parsed = arrow, + // Stack and memory exhaustion are not properties of the attempt + Err(err @ (Error::StackOverflow | Error::Alloc(_))) => return Err(err), + Err(_) => is_arrow_fn = false, + } } - 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, - }; // Re-search for the insertion point: attempts nested inside this one may // have added entries of their own. @@ -1664,7 +1690,24 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.ts_conditional_arrow_attempts .insert(insert_at, (memo_key << 1) | is_arrow_fn as u32); } - Ok((is_arrow_fn, parsed)) + Ok(match parsed { + Some(arrow) => ConditionalArrowFn::Parsed(arrow), + None if is_arrow_fn => ConditionalArrowFn::Found(args), + None => ConditionalArrowFn::NotFound, + }) + } + + /// Whether the ":" starts a type that "=>" follows. The lexer stays at the ":". + #[inline(never)] + fn is_type_script_arrow_return_type_before_arrow(&mut self) -> Result { + let mut found = false; + self.lexer_backtracker_bool(|p| { + p.skip_type_script_arrow_return_type_with_backtracking()?; + found = true; + // Go back in both cases + Err::<(), Error>(Error::Backtrack) + })?; + Ok(found) } // ─────────────────────── try_* wrappers ─────────────────────── diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index b37f40c8f55a..1095b4a1816e 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1234,6 +1234,17 @@ pub struct ParenExprOpts { pub(crate) is_after_question_and_before_colon: bool, } +/// See `P::parse_arrow_fn_after_question_and_before_colon`. +pub(crate) enum ConditionalArrowFn<'a> { + /// The ":" pairs with the "?". The lexer is at the ":". + NotFound, + /// The ":" starts the return type of an arrow function, which is not parsed + /// yet. The lexer is at the ":". These are its arguments. + Found(bun_alloc::ArenaVec<'a, G::Arg>), + /// The same, and this is the arrow function. + Parsed(Expr), +} + #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum AwaitOrYield { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index f5ea775841e3..96e07be5f2f9 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -473,10 +473,10 @@ describe("Bun.Transpiler", () => { exp("x = a ? /*! L */ (b) : c => { return d }", "x = a ? b : (c) => {\n /*! L */\n return d;\n};\n"); exp('x = a ? /*! L */ (b) : T => import("m") : c', 'x = a ? (b) => import("m") : c;\n'); - // An attempt in the body of another attempt keeps the arrow function that - // it finds, and the outer attempt throws it away. The parse after the - // outer attempt declares the parameters, reports the errors and gets the - // legal comments. + // The arrow function that an attempt finds is kept, also when the attempt + // runs in the body of another one. It declares the parameters, reports the + // errors and gets the legal comments, as the parse after a failed attempt + // does. exp("x = a ? (b) : c => [a ? (b) : T => d : e]", "x = a ? b : (c) => [a ? (b) => d : e];\n"); exp("x = a ? (b) : T => [a ? (b) : c => d] : e", "x = a ? (b) => [a ? b : (c) => d] : e;\n"); exp( @@ -500,15 +500,45 @@ describe("Bun.Transpiler", () => { "x = a ? /*! L1 */ (b) : c => { return a ? /*! L2 */ (b) : T => { let y = 1 } : c }", "x = a ? b : (c) => {\n /*! L1 */\n return a ? (b) => {\n /*! L2 */\n let y = 1;\n } : c;\n};\n", ); - // The inner attempt finds no arrow function when the lexer cannot read - // the token after the second ":". + exp("x = a ? (b) : /*! L */ T => { let y = 1 } : c", "x = a ? (b) => {\n /*! L */\n let y = 1;\n} : c;\n"); + exp("x = a ? (b) : c => d : e", "x = a ? (b) => d : e;\n"); + exp("x = a ? (b) : c => d", "x = a ? b : (c) => d;\n"); + // An attempt finds no arrow function when the lexer cannot read the token + // after the second ":". err("x = a ? (b) : T => a ? (b) : T => d : 'abc : e", 'Expected ";" but found ":"'); - // An attempt that ended does not make the next attempt an inner one. + // What one attempt finds does not change the next one. exp( "x = a ? (b) : c => d; y = a ? (b) : T => b : e; z = a ? (b) : T => b : e", "x = a ? b : (c) => d;\ny = a ? (b) => b : e;\nz = a ? (b) => b : e;\n", ); + // An attempt reports each error of its arrow function one time, and a + // failed attempt leaves none. + const errors = code => { + try { + ts.parsed(code, false, false); + } catch (er) { + return (er instanceof AggregateError ? er.errors : [er]).map(e => e.message); + } + return []; + }; + expect(errors("x = a ? (b) : T => { let b } : e")).toEqual(['"b" has already been declared']); + expect(errors("x = a ? (b, b) : T => 1 : e")).toEqual([ + '"b" cannot be bound multiple times in the same parameter list', + ]); + expect(errors("x = a ? (b) : T => a ? (c, c) : T => 1 : e : e")).toEqual([ + '"c" cannot be bound multiple times in the same parameter list', + ]); + expect(errors("x = a ? (b) : T => /x/gg : e")).toEqual(['Duplicate flag "g" in regular expression']); + expect(errors("x = a ? (b) : T => d : 'abc")).toEqual([ + 'Expected ";" but found ":"', + "Unterminated string literal", + ]); + expect(errors("x = a ? (b, b) : c => 1")).toEqual([]); + expect(new Bun.Transpiler({ loader: "ts" }).scanImports('x = a ? (b) : T => import("m") : c')).toEqual([ + { kind: "dynamic-import", path: "m" }, + ]); + // The attempt parses the body, so an attempt nested in the body must not // run again when the body is parsed for real: 2^40 parses would hang. let kept = "d"; @@ -530,12 +560,29 @@ describe("Bun.Transpiler", () => { exp(`x = ${keptInDiscarded}`, `x = ${keptInDiscardedOut};\n`); }); + it("parses an arrow function with a return type between ? and : one time", async () => { + // The attempt parsed the arrow function, threw that away, and the arrow + // function was parsed again. What a parse allocates stays until the file + // is done, so a large body shows the second parse. + const arrows = isDebug || isASAN ? 20_000 : 40_000; + const fixture = returnType => /* js */ ` + const body = "[" + "(y) => y, ".repeat(${arrows}) + "0]"; + const printed = new Bun.Transpiler({ loader: "ts" }).transformSync("x = a ? (b)${returnType} => " + body + " : e"); + console.log(JSON.stringify({ printed: printed.length > body.length })); + `; + const [withReturnType, without] = await Promise.all([ + runFixtureMaxRSS(fixture(" : c"), { printed: true }), + runFixtureMaxRSS(fixture(""), { printed: true }), + ]); + // Peak RSS over the same input without the return type. Before: 43 MB in + // release. After: under 1 MB. + expect((withReturnType - without) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 8 : 16); + }); + it("nested arrow functions with return types between ? and : take linear memory", async () => { - // Each arrow function here has the next one in its body. Every attempt - // parsed its body, threw it away, and the parse after it parsed the body - // again with a second parse of each arrow function in it. Depth n took - // n * n / 2 parses of a level, and what a parse allocates stays until - // the file is done. + // Each arrow function here has the next one in its body, and every body + // was parsed again for each arrow function around it. Depth n took + // n * n / 2 parses of a level. const depth = isDebug || isASAN ? 256 : 640; const fixture = returnType => /* js */ ` let input = "d"; @@ -556,6 +603,38 @@ describe("Bun.Transpiler", () => { expect((withReturnTypes - without) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 10 : 32); }); + it("emitDecoratorMetadata does not change an arrow function with a return type between ? and :", () => { + const tsconfig = { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }; + const plain = new Bun.Transpiler({ loader: "ts" }); + const withMetadata = new Bun.Transpiler({ loader: "ts", tsconfig }); + for (const code of [ + "x = a ? (b) : T => c : d", + "x = a ? (b) : c => d", + "x = a ? async (b) : c => a ? async (b) : c => await d : e : e", + "x = a ? (b) : T => a ? (c, ...d) : T => [b, c, d] : e : e", + "x = a ? (b) : c => [a ? (b) : T => d : e]", + "x = a ? (b) : T => [a ? (b) : c => d] : e", + "x = a ? /*! L1 */ (b) : T => a ? /*! L2 */ (b) : T => { let y = 1 } : e : e; y", + "x = a ? (b) : T => { enum E { A = 1 } return E.A } : e", + 'x = a ? (b) : T => import("m") : c', + ]) { + expect(withMetadata.transformSync(code)).toBe(plain.transformSync(code)); + } + + // A class with a decorator in an arrow function in an arrow function + expect( + withMetadata.transformSync("x = a ? (b) : T => a ? (c) : T => { class A { @d() p: Date } return A } : e : e"), + ).toContain('("design:type", typeof Date === "undefined" ? Object : Date)'); + + // After a failed attempt "c" is a parameter, and the import has no use. + const trim = new Bun.Transpiler({ loader: "ts", tsconfig, trimUnusedImports: true }); + const decorated = "{ class K { @dec p: c } return K }"; + expect(trim.transformSync(`import { c } from "./m"; x = a ? (b) : c => ${decorated};`)).not.toContain("./m"); + expect(trim.transformSync(`import { c } from "./m"; x = a ? (b) : T => ${decorated} : e;`)).toContain( + 'import { c } from "./m";', + ); + }); + it("emitDecoratorMetadata in an arrow function with a return type in a true branch", async () => { const script = ` const tsconfig = { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }; From 2c8158346c4c6b40e48a2e1b0d57e08bfccac80e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:42:29 +0000 Subject: [PATCH 9/9] parser: keep only the arrow function of an attempt inside another attempt Go back to the smaller shape. The attempt for `(b) : c => d` between the `?` and `:` of a conditional is the one from main again: it parses with the log off and without arguments, and the outermost attempt always restores the parser. An attempt that starts with the log off runs inside another attempt, which throws everything away. When such an attempt finds an arrow function, it skips its restore and returns the arrow function. The outer attempt then parses each nested body one time, not one more time for every attempt around it. This takes out the parse that was kept for real: the lexer flag, the arrow tail function, the copy of the legal comments in the snapshot, and the check before the snapshot, which could log outside of it. --- src/js_parser/lexer.rs | 7 +- src/js_parser/p.rs | 17 +-- src/js_parser/parse/mod.rs | 110 +++++--------- src/js_parser/parse/parse_skip_typescript.rs | 146 +++++++------------ src/js_parser/parser.rs | 11 -- test/bundler/transpiler/transpiler.test.js | 101 ++----------- 6 files changed, 105 insertions(+), 287 deletions(-) diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 3badf33d33e1..b7620f767699 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -225,10 +225,6 @@ pub struct Lexer<'a> { pub(crate) preserve_all_comments_before: bool, pub(crate) is_legacy_octal_literal: bool, pub(crate) is_log_disabled: bool, - /// `expected` returns `Error::Backtrack` with the log on, as it does with - /// the log off. Set for a parse that is thrown away when a token does not - /// fit, and whose messages stay when it is kept. - pub(crate) backtrack_on_mismatch: bool, pub(crate) comments_to_preserve_before: Vec, pub(crate) code_point: CodePoint, pub(crate) identifier: &'a [u8], @@ -1830,7 +1826,7 @@ impl<'a> Lexer<'a> { #[cold] #[inline(never)] pub(crate) fn expected(&mut self, token: T) -> Result<(), Error> { - if self.is_log_disabled || self.backtrack_on_mismatch { + if self.is_log_disabled { return Err(Error::Backtrack); } else if !tokenToString_get(token).is_empty() { self.expected_string(tokenToString_get(token)) @@ -2271,7 +2267,6 @@ impl<'a> Lexer<'a> { preserve_all_comments_before: false, is_legacy_octal_literal: false, is_log_disabled: false, - backtrack_on_mismatch: false, comments_to_preserve_before: Vec::new(), code_point: -1, identifier: b"", diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 863fb1c43a7f..8dc377672d7b 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8193,19 +8193,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// dynamic imports and logs through `P::log()`, which ignores /// `lexer.is_log_disabled`. Lists are captured as lengths and truncated /// on restore; the attempt's arena allocations just become unreachable. - /// The legal comments waiting for the next statement are copied + /// The legal comments waiting for the next statement are moved out /// instead: a block body or `import()` drains them, so a length cannot - /// restore them. They stay in the lexer for a parse that is kept. + /// restore them. pub(crate) fn parser_snapshot(&mut self) -> ParserSnapshot<'a> { - let comments_to_preserve_before = self - .lexer - .comments_to_preserve_before - .iter() - .map(|comment| js_ast::G::Comment { - loc: comment.loc, - text: comment.text, - }) - .collect(); + let comments_to_preserve_before = + core::mem::take(&mut self.lexer.comments_to_preserve_before); let log = self.log(); ParserSnapshot { lexer: self.lexer.snapshot(), @@ -8239,9 +8232,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O /// Undo every parse-pass mutation made since [`Self::parser_snapshot`]. pub(crate) fn restore_parser_snapshot(&mut self, snapshot: &mut ParserSnapshot<'a>) { + self.lexer.restore(&snapshot.lexer); self.lexer.comments_to_preserve_before = core::mem::take(&mut snapshot.comments_to_preserve_before); - self.lexer.restore(&snapshot.lexer); let log = self.log(); log.msgs.truncate(snapshot.log_msgs_len); diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index ded07ea2c50c..a1e4f77623c5 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -22,8 +22,8 @@ use bun_ast::LexerLog as _; use crate::lexer::T; use crate::p::P; use crate::parser::{ - AwaitOrYield, ConditionalArrowFn, DeferredArrowArgErrors, DeferredErrors, ExprListLoc, - ExprOrLetStmt, FnOrArrowDataParse, LexicalDecl, LocList, ParenExprOpts, ParseBindingOptions, + AwaitOrYield, DeferredArrowArgErrors, DeferredErrors, ExprListLoc, ExprOrLetStmt, + FnOrArrowDataParse, LexicalDecl, LocList, ParenExprOpts, ParseBindingOptions, ParseClassOptions, ParseStatementOptions, ParsedPath, PropertyOpts, SkipTypeParameterResult, StmtList, TypeParameterFlag, }; @@ -418,39 +418,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// The end of `parse_paren_expr` when the parentheses hold the arguments of - /// an arrow function. The lexer is at the "=>". - #[inline(always)] - pub(crate) fn parse_paren_arrow_fn( - &mut self, - loc: bun_ast::Loc, - opts: ParenExprOpts, - spread_range: bun_ast::Range, - comma_after_spread: bun_ast::Loc, - arrow_arg_errors: &mut DeferredArrowArgErrors, - invalid_log: &LocList<'a>, - args: BumpVec<'a, G::Arg>, - arrow_data: &mut FnOrArrowDataParse, - ) -> Result { - let p = self; - p.maybe_comma_spread_error(comma_after_spread); - p.log_arrow_arg_errors(arrow_arg_errors); - - // Now that we've decided we're an arrow function, report binding pattern - // conversion errors - if !invalid_log.is_empty() { - for loc_ in invalid_log.iter() { - loc_.add_error(p.log(), p.source); - } - } - let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); - let mut arrow = p.parse_arrow_body(args_slice, arrow_data)?; - arrow.is_async = opts.is_async; - arrow.has_rest_arg = spread_range.len > 0; - p.pop_scope(); - Ok(p.new_expr(arrow, loc)) - } - /// This assumes that the open parenthesis has already been parsed by the caller pub(crate) fn parse_paren_expr( &mut self, @@ -605,55 +572,48 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // attempt to convert the expressions to bindings first before deciding // whether this is an arrow function, and only pick an arrow function if // there were no conversion errors. - let maybe_return_type = - Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty(); - if maybe_return_type && opts.is_after_question_and_before_colon { - // Only do this very expensive check if we must - match p.parse_arrow_fn_after_question_and_before_colon( - loc, - opts, - spread_range, - comma_after_spread, - arrow_arg_errors, - args, - &arrow_data, - )? { - ConditionalArrowFn::Parsed(arrow) => return Ok(arrow), - ConditionalArrowFn::Found(args) => { + if Self::IS_TYPESCRIPT_ENABLED && p.lexer.token == T::TColon && invalid_log.is_empty() { + if opts.is_after_question_and_before_colon { + // Only do this very expensive check if we must + let parsed; + (is_arrow_fn, parsed) = p + .is_type_script_arrow_return_type_after_question_and_before_colon( + &arrow_data, + )?; + if let Some(arrow) = parsed { + // Parsed without its arguments, for an outer attempt that restores + debug_assert!(p.lexer.is_log_disabled); + p.pop_scope(); + return Ok(p.new_expr(arrow, loc)); + } + if is_arrow_fn { // We know this will succeed because we've already done it once above p.lexer.next()?; p.skip_typescript_return_type()?; - return p.parse_paren_arrow_fn( - loc, - opts, - spread_range, - comma_after_spread, - &mut arrow_arg_errors, - &invalid_log, - args, - &mut arrow_data, - ); } - ConditionalArrowFn::NotFound => {} - } - } else { - if maybe_return_type { + } else { // Otherwise, do the less expensive check is_arrow_fn = p.try_skip_type_script_arrow_return_type_with_backtracking()?; } + } - if is_arrow_fn || opts.force_arrow_fn { - return p.parse_paren_arrow_fn( - loc, - opts, - spread_range, - comma_after_spread, - &mut arrow_arg_errors, - &invalid_log, - args, - &mut arrow_data, - ); + if is_arrow_fn || opts.force_arrow_fn { + p.maybe_comma_spread_error(comma_after_spread); + p.log_arrow_arg_errors(&mut arrow_arg_errors); + + // Now that we've decided we're an arrow function, report binding pattern + // conversion errors + if !invalid_log.is_empty() { + for loc_ in invalid_log.iter() { + loc_.add_error(p.log(), p.source); + } } + let args_slice: &'a mut [G::Arg] = args.into_bump_slice_mut(); + let mut arrow = p.parse_arrow_body(args_slice, &mut arrow_data)?; + arrow.is_async = opts.is_async; + arrow.has_rest_arg = spread_range.len > 0; + p.pop_scope(); + return Ok(p.new_expr(arrow, loc)); } } diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index 4d947933f10d..39209dcc0af2 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -3,16 +3,14 @@ use crate::Error; use crate::lexer::T; use crate::p::P; use crate::parser::{ - ConditionalArrowFn, DeferredArrowArgErrors, FnOrArrowDataParse, LocList, ParenExprOpts, - ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, + FnOrArrowDataParse, ParseStatementOptions, Ref, SkipTypeParameterResult, TypeParameterFlag, }; use crate::typescript; use crate::typescript::SkipTypeOptions; use crate::typescript::identifier::{Kind as TsIdentKind, kind_for_identifier}; -use bun_alloc::ArenaVec as BumpVec; +use bun_ast::E; use bun_ast::op::Level; use bun_ast::ts::Metadata; -use bun_ast::{Expr, G}; // Re-export so the parser-side type alias used in this file matches the // canonical definition in `TypeScript.rs`. @@ -1586,37 +1584,35 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } - /// Decides what the ":" after a parenthesized expression means when that - /// expression sits between the "?" and ":" of a conditional. It can start - /// the return type of an arrow function. The TypeScript compiler parses the - /// whole arrow function and keeps it only when another ":" follows its body: + /// Whether the ":" after a parenthesized expression that sits between the + /// "?" and ":" of a conditional starts an arrow function return type. The + /// TypeScript compiler parses the whole arrow function and keeps it only + /// when another ":" follows its body: /// /// x = a ? (b) : c => d; // "(b)" is an expression, ":" pairs with "?" /// y = a ? (b) : c => d : e; // "(b) : c => d" is an arrow function /// - /// The arrow function is parsed here with the arguments from the caller, and - /// that parse is kept when another ":" follows. When none follows, the whole - /// parser goes back to a snapshot, because parsing an expression mutates - /// parser state. The outcome is memoized by the offset of the ":" in - /// `ts_conditional_arrow_attempts`, so the parse after a failed attempt does - /// not repeat the attempts nested inside the body. + /// The body is parsed here and thrown away, then parsed again by the + /// caller when this returns true. Parsing an expression mutates parser + /// state, so the whole parser is restored from a snapshot. The current + /// scope is the arrow's `FunctionArgs` scope; no arguments are declared so + /// that its members stay untouched. The outcome is memoized by the offset + /// of the ":" in `ts_conditional_arrow_attempts`, so the real parse does not + /// repeat the attempts nested inside the body. /// - /// The snapshot does not undo what decorator metadata adds. So with - /// `emitDecoratorMetadata` the arrow function is parsed with metadata off and - /// without its arguments, thrown away, and parsed again by the caller. + /// An attempt that runs inside another attempt is thrown away with it. When + /// it finds an arrow function, it restores nothing and returns the arrow + /// function, and the caller uses that in place of a second parse. Without + /// this, every attempt around a body parses that body one more time. The + /// log is off on entry only inside another attempt: nothing else that turns + /// it off parses an expression. // Not inlined: `parse_paren_expr` recurses and the snapshot is large. #[cold] #[inline(never)] - pub(crate) fn parse_arrow_fn_after_question_and_before_colon( + pub(crate) fn is_type_script_arrow_return_type_after_question_and_before_colon( &mut self, - loc: bun_ast::Loc, - opts: ParenExprOpts, - spread_range: bun_ast::Range, - comma_after_spread: bun_ast::Loc, - mut arrow_arg_errors: DeferredArrowArgErrors, - mut args: BumpVec<'a, G::Arg>, arrow_data: &FnOrArrowDataParse, - ) -> Result, Error> { + ) -> Result<(bool, Option), Error> { self.mark_type_script_only(); debug_assert!(self.lexer.start <= (u32::MAX >> 1) as usize); let memo_key = self.lexer.start as u32; @@ -1624,62 +1620,41 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .ts_conditional_arrow_attempts .binary_search_by_key(&memo_key, |&packed| packed >> 1) { - return Ok(if self.ts_conditional_arrow_attempts[i] & 1 == 1 { - ConditionalArrowFn::Found(args) - } else { - ConditionalArrowFn::NotFound - }); + return Ok((self.ts_conditional_arrow_attempts[i] & 1 == 1, None)); } - // Most of the time no "=>" follows, and only the lexer has to go back. - let mut is_arrow_fn = self.is_type_script_arrow_return_type_before_arrow()?; - let mut parsed = None; - if is_arrow_fn { - let mut snapshot = self.parser_snapshot(); - // 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 backtrack_on_mismatch = - core::mem::replace(&mut self.lexer.backtrack_on_mismatch, true); - - let mut data = arrow_data.clone(); - let result: Result, Error> = (|| { - self.lexer.next()?; - self.skip_typescript_return_type()?; - let arrow = if emit_decorator_metadata { - self.parse_arrow_body(&mut [], &mut data)?; - None - } else { - Some(self.parse_paren_arrow_fn( - loc, - opts, - spread_range, - comma_after_spread, - &mut arrow_arg_errors, - &LocList::new_in(self.arena), - core::mem::replace(&mut args, BumpVec::new_in(self.arena)), - &mut data, - )?) - }; - // The ":" that pairs with the "?", and a token after it - if self.lexer.token != T::TColon || !self.can_read_next_token() { - return Err(Error::Backtrack); - } - Ok(arrow) - })(); - self.lexer.backtrack_on_mismatch = backtrack_on_mismatch; + let mut snapshot = self.parser_snapshot(); + let nested = self.lexer.is_log_disabled; + 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); - if !matches!(result, Ok(Some(_))) { - self.restore_parser_snapshot(&mut snapshot); - } - self.options.features.emit_decorator_metadata = emit_decorator_metadata; - match result { - Ok(arrow) => parsed = arrow, - // Stack and memory exhaustion are not properties of the attempt - Err(err @ (Error::StackOverflow | Error::Alloc(_))) => return Err(err), - Err(_) => is_arrow_fn = false, + let mut data = arrow_data.clone(); + let mut parsed = None; + let result: Result<(), Error> = (|| { + self.lexer.expect(T::TColon)?; + self.skip_typescript_return_type()?; + let arrow = self.parse_arrow_body(&mut [], &mut data)?; + // The ":" that pairs with the "?". As `expect` does, read the token after it. + if nested && self.lexer.token == T::TColon && self.can_read_next_token() { + parsed = Some(arrow); + return Ok(()); } + self.lexer.expect(T::TColon)?; + Ok(()) + })(); + + if parsed.is_none() { + self.restore_parser_snapshot(&mut 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, + }; // Re-search for the insertion point: attempts nested inside this one may // have added entries of their own. @@ -1690,24 +1665,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.ts_conditional_arrow_attempts .insert(insert_at, (memo_key << 1) | is_arrow_fn as u32); } - Ok(match parsed { - Some(arrow) => ConditionalArrowFn::Parsed(arrow), - None if is_arrow_fn => ConditionalArrowFn::Found(args), - None => ConditionalArrowFn::NotFound, - }) - } - - /// Whether the ":" starts a type that "=>" follows. The lexer stays at the ":". - #[inline(never)] - fn is_type_script_arrow_return_type_before_arrow(&mut self) -> Result { - let mut found = false; - self.lexer_backtracker_bool(|p| { - p.skip_type_script_arrow_return_type_with_backtracking()?; - found = true; - // Go back in both cases - Err::<(), Error>(Error::Backtrack) - })?; - Ok(found) + Ok((is_arrow_fn, parsed)) } // ─────────────────────── try_* wrappers ─────────────────────── diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 69f0df7b60d6..084dbb24aa23 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -1236,17 +1236,6 @@ pub struct ParenExprOpts { pub(crate) is_after_question_and_before_colon: bool, } -/// See `P::parse_arrow_fn_after_question_and_before_colon`. -pub(crate) enum ConditionalArrowFn<'a> { - /// The ":" pairs with the "?". The lexer is at the ":". - NotFound, - /// The ":" starts the return type of an arrow function, which is not parsed - /// yet. The lexer is at the ":". These are its arguments. - Found(bun_alloc::ArenaVec<'a, G::Arg>), - /// The same, and this is the arrow function. - Parsed(Expr), -} - #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum AwaitOrYield { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 269a44aabb61..e9cf3572a4fe 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -473,10 +473,10 @@ describe("Bun.Transpiler", () => { exp("x = a ? /*! L */ (b) : c => { return d }", "x = a ? b : (c) => {\n /*! L */\n return d;\n};\n"); exp('x = a ? /*! L */ (b) : T => import("m") : c', 'x = a ? (b) => import("m") : c;\n'); - // The arrow function that an attempt finds is kept, also when the attempt - // runs in the body of another one. It declares the parameters, reports the - // errors and gets the legal comments, as the parse after a failed attempt - // does. + // An attempt in the body of another attempt keeps the arrow function that + // it finds, and the outer attempt throws it away. The parse after the + // outer attempt declares the parameters, reports the errors and gets the + // legal comments. exp("x = a ? (b) : c => [a ? (b) : T => d : e]", "x = a ? b : (c) => [a ? (b) => d : e];\n"); exp("x = a ? (b) : T => [a ? (b) : c => d] : e", "x = a ? (b) => [a ? b : (c) => d] : e;\n"); exp( @@ -500,45 +500,18 @@ describe("Bun.Transpiler", () => { "x = a ? /*! L1 */ (b) : c => { return a ? /*! L2 */ (b) : T => { let y = 1 } : c }", "x = a ? b : (c) => {\n /*! L1 */\n return a ? (b) => {\n /*! L2 */\n let y = 1;\n } : c;\n};\n", ); - exp("x = a ? (b) : /*! L */ T => { let y = 1 } : c", "x = a ? (b) => {\n /*! L */\n let y = 1;\n} : c;\n"); - exp("x = a ? (b) : c => d : e", "x = a ? (b) => d : e;\n"); - exp("x = a ? (b) : c => d", "x = a ? b : (c) => d;\n"); - // An attempt finds no arrow function when the lexer cannot read the token - // after the second ":". + // As an outermost attempt does, the inner attempt finds no arrow function + // when the lexer cannot read the token after the second ":". err("x = a ? (b) : T => a ? (b) : T => d : 'abc : e", 'Expected ";" but found ":"'); - // What one attempt finds does not change the next one. + // A failed attempt leaves no declaration of its body behind. + exp("x = a ? (b) : c => { let b }", "x = a ? b : (c) => {\n let b;\n};\n"); + exp("x = a ? (b) : c => { class b {} }", "x = a ? b : (c) => {\n class b {\n }\n};\n"); + // An attempt that ended does not make the next attempt an inner one. exp( "x = a ? (b) : c => d; y = a ? (b) : T => b : e; z = a ? (b) : T => b : e", "x = a ? b : (c) => d;\ny = a ? (b) => b : e;\nz = a ? (b) => b : e;\n", ); - // An attempt reports each error of its arrow function one time, and a - // failed attempt leaves none. - const errors = code => { - try { - ts.parsed(code, false, false); - } catch (er) { - return (er instanceof AggregateError ? er.errors : [er]).map(e => e.message); - } - return []; - }; - expect(errors("x = a ? (b) : T => { let b } : e")).toEqual(['"b" has already been declared']); - expect(errors("x = a ? (b, b) : T => 1 : e")).toEqual([ - '"b" cannot be bound multiple times in the same parameter list', - ]); - expect(errors("x = a ? (b) : T => a ? (c, c) : T => 1 : e : e")).toEqual([ - '"c" cannot be bound multiple times in the same parameter list', - ]); - expect(errors("x = a ? (b) : T => /x/gg : e")).toEqual(['Duplicate flag "g" in regular expression']); - expect(errors("x = a ? (b) : T => d : 'abc")).toEqual([ - 'Expected ";" but found ":"', - "Unterminated string literal", - ]); - expect(errors("x = a ? (b, b) : c => 1")).toEqual([]); - expect(new Bun.Transpiler({ loader: "ts" }).scanImports('x = a ? (b) : T => import("m") : c')).toEqual([ - { kind: "dynamic-import", path: "m" }, - ]); - // The attempt parses the body, so an attempt nested in the body must not // run again when the body is parsed for real: 2^40 parses would hang. let kept = "d"; @@ -560,29 +533,11 @@ describe("Bun.Transpiler", () => { exp(`x = ${keptInDiscarded}`, `x = ${keptInDiscardedOut};\n`); }); - it("parses an arrow function with a return type between ? and : one time", async () => { - // The attempt parsed the arrow function, threw that away, and the arrow - // function was parsed again. What a parse allocates stays until the file - // is done, so a large body shows the second parse. - const arrows = isDebug || isASAN ? 20_000 : 40_000; - const fixture = returnType => /* js */ ` - const body = "[" + "(y) => y, ".repeat(${arrows}) + "0]"; - const printed = new Bun.Transpiler({ loader: "ts" }).transformSync("x = a ? (b)${returnType} => " + body + " : e"); - console.log(JSON.stringify({ printed: printed.length > body.length })); - `; - const [withReturnType, without] = await Promise.all([ - runFixtureMaxRSS(fixture(" : c"), { printed: true }), - runFixtureMaxRSS(fixture(""), { printed: true }), - ]); - // Peak RSS over the same input without the return type. Before: 43 MB in - // release. After: under 1 MB. - expect((withReturnType - without) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 8 : 16); - }); - it("nested arrow functions with return types between ? and : take linear memory", async () => { // Each arrow function here has the next one in its body, and every body // was parsed again for each arrow function around it. Depth n took - // n * n / 2 parses of a level. + // n * n / 2 parses of a level, and what a parse allocates stays until + // the file is done. const depth = isDebug || isASAN ? 256 : 640; const fixture = returnType => /* js */ ` let input = "d"; @@ -603,38 +558,6 @@ describe("Bun.Transpiler", () => { expect((withReturnTypes - without) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 10 : 32); }); - it("emitDecoratorMetadata does not change an arrow function with a return type between ? and :", () => { - const tsconfig = { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }; - const plain = new Bun.Transpiler({ loader: "ts" }); - const withMetadata = new Bun.Transpiler({ loader: "ts", tsconfig }); - for (const code of [ - "x = a ? (b) : T => c : d", - "x = a ? (b) : c => d", - "x = a ? async (b) : c => a ? async (b) : c => await d : e : e", - "x = a ? (b) : T => a ? (c, ...d) : T => [b, c, d] : e : e", - "x = a ? (b) : c => [a ? (b) : T => d : e]", - "x = a ? (b) : T => [a ? (b) : c => d] : e", - "x = a ? /*! L1 */ (b) : T => a ? /*! L2 */ (b) : T => { let y = 1 } : e : e; y", - "x = a ? (b) : T => { enum E { A = 1 } return E.A } : e", - 'x = a ? (b) : T => import("m") : c', - ]) { - expect(withMetadata.transformSync(code)).toBe(plain.transformSync(code)); - } - - // A class with a decorator in an arrow function in an arrow function - expect( - withMetadata.transformSync("x = a ? (b) : T => a ? (c) : T => { class A { @d() p: Date } return A } : e : e"), - ).toContain('("design:type", typeof Date === "undefined" ? Object : Date)'); - - // After a failed attempt "c" is a parameter, and the import has no use. - const trim = new Bun.Transpiler({ loader: "ts", tsconfig, trimUnusedImports: true }); - const decorated = "{ class K { @dec p: c } return K }"; - expect(trim.transformSync(`import { c } from "./m"; x = a ? (b) : c => ${decorated};`)).not.toContain("./m"); - expect(trim.transformSync(`import { c } from "./m"; x = a ? (b) : T => ${decorated} : e;`)).toContain( - 'import { c } from "./m";', - ); - }); - it("emitDecoratorMetadata in an arrow function with a return type in a true branch", async () => { const script = ` const tsconfig = { compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } };