diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index f160a0b..2120d2a 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -4,7 +4,7 @@ // Much inspiration taken from CSSTree (Roman Dvornov) - MIT License // https://github.com/csstree/csstree/blob/56afb6dd761149099cd3cdfb0a38e15e8cc0a71a/lib/syntax/node/AnPlusB.js#L106-L271 -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { NTH_SELECTOR, CSSDataArena } from './arena' import { TOKEN_IDENT, @@ -23,6 +23,8 @@ export class ANplusBParser { private arena: CSSDataArena private source: string private expr_end: number + // Reused "+n" lookahead snapshot (single use per parse_anplusb call, never nested) + private plus_n_lookahead_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -152,7 +154,7 @@ export class ANplusBParser { this.source.charCodeAt(this.lexer.token_start) === CHAR_PLUS ) { // Look ahead for 'n' - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.plus_n_lookahead_position) this.lexer.next_token_fast(true) if ((this.lexer.token_type as TokenType) === TOKEN_IDENT) { diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 194a6ad..a986de0 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -1,5 +1,5 @@ // At-Rule Prelude Parser - Parses at-rule preludes into structured AST nodes -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { CSSDataArena, MEDIA_QUERY, @@ -50,6 +50,11 @@ export class AtRulePreludeParser { // Shared media-feature/supports-condition/style()-selector() parsing, also used by ValueNodeParser // for if()'s condition functions — see parse-condition.ts for why value_node_parser is injected private condition_parser: ConditionParser + // Reused lookahead snapshot shared by all save/restore sites in this class — none of + // them stay live across a call that could itself need this slot (verified: none here + // recurse back into this class, and the two @import helpers resolve their save before + // delegating further parsing elsewhere). + private lookahead_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -132,7 +137,7 @@ export class AtRulePreludeParser { // Skip comma separator this.skip_whitespace() - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.lookahead_position) this.next_token() if (this.lexer.token_type !== TOKEN_COMMA) { // Not a comma, restore position @@ -203,7 +208,7 @@ export class AtRulePreludeParser { let last_component = 0 // Leading modifier (only/not) — emit as PRELUDE_OPERATOR like the and/or/not combinators below - const saved_token_start = this.lexer.save_position() + const saved_token_start = this.lexer.save_position_into(this.lookahead_position) this.next_token() if (this.lexer.token_type === TOKEN_IDENT) { @@ -390,7 +395,7 @@ export class AtRulePreludeParser { // Glue on '.' ident segments immediately following, with no gaps. while (this.lexer.pos < this.prelude_end) { - let saved = this.lexer.save_position() + let saved = this.lexer.save_position_into(this.lookahead_position) let dot_token_type = this.next_token() if ( @@ -556,7 +561,7 @@ export class AtRulePreludeParser { // Parse import layer: layer or layer(name) private parse_import_layer(): number | null { // Peek at next token - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.lookahead_position) this.next_token() @@ -609,7 +614,7 @@ export class AtRulePreludeParser { // Parse import supports: supports(condition) private parse_import_supports(): number | null { // Peek at next token - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.lookahead_position) this.next_token() @@ -662,7 +667,7 @@ export class AtRulePreludeParser { // Helper: Peek at next token type without consuming private peek_token_type(): number { - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.lookahead_position) this.next_token() let type = this.lexer.token_type @@ -690,7 +695,7 @@ export class AtRulePreludeParser { if (this.lexer.pos >= this.prelude_end) return [] // Peek: if it's an IDENT it's an optional prefix, otherwise let parse_import_url() take it - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.lookahead_position) this.next_token() if (this.lexer.token_type === TOKEN_IDENT) { diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 3f1eda6..668bafe 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -8,7 +8,7 @@ // own (a plain `import { ValueNodeParser } from './value-node-parser'` here would be // circular, since value-node-parser.ts imports this file). The type-only import below is // erased at build time and creates no runtime dependency in either direction. -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { CSSDataArena, MEDIA_FEATURE, @@ -61,6 +61,9 @@ export class ConditionParser { // Deep-parses selector()'s argument into a real SelectorList. No cycle risk (SelectorParser // doesn't depend on ValueNodeParser/AtRulePreludeParser), so this is safely owned here. private selector_parser: SelectorParser + // Reused peek snapshot for parse_feature_range's token-type probe (never nested — resolved + // within the same loop iteration, before the next save). + private feature_range_peek_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string, value_node_parser: ValueNodeParser) { this.arena = arena @@ -259,7 +262,7 @@ export class ConditionParser { last_child = op } else { // Value or feature name - let saved = this.lexer.save_position() + let saved = this.lexer.save_position_into(this.feature_range_peek_position) this.lexer.pos = pos this.next_token() diff --git a/src/parse-declaration.ts b/src/parse-declaration.ts index a8eee30..aec4306 100644 --- a/src/parse-declaration.ts +++ b/src/parse-declaration.ts @@ -39,6 +39,9 @@ export class DeclarationParser { private value_parser: ValueParser | null // Reused lookahead snapshot (never nested) to avoid an allocation per declaration private lookahead_position: LexerPosition = new Lexer('').save_position() + // Separate scratch slot: the browser-hack delimiter-prefix snapshot (below) stays live + // at the same time as lookahead_position, so it can't reuse that field. + private delim_prefix_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string, parse_values: boolean = true) { this.arena = arena @@ -111,7 +114,7 @@ export class DeclarationParser { lexer.token_type === TOKEN_COLON ) { // Browser hacks can use various token types as prefixes (e.g., * hack) - const delim_saved = lexer.save_position() + const delim_saved = lexer.save_position_into(this.delim_prefix_position) browser_hack_start = lexer.token_start browser_hack_line = lexer.token_line browser_hack_column = lexer.token_column diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 6fcd62b..97186fd 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -1,5 +1,5 @@ // Selector Parser - Parses CSS selectors into structured AST nodes -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { CSSDataArena } from './arena' import { SELECTOR, @@ -71,6 +71,30 @@ export class SelectorParser { private source: string private selector_end: number private anplusb_parser: ANplusBParser + // Reused lookahead snapshots for the save/restore sites that are never live across a + // recursive re-entry into the selector grammar (unlike parse_compound_selector's, + // parse_pseudo_function_after_colon's, and parse_nth_expression's own saves, which stay + // allocating for now — see the recursive :is()/:has()/:nth-child(of) call chain). + // Resolved entirely before parse_complex_selector's compound-selector loop runs. + private complex_selector_lookahead_position: LexerPosition = new Lexer('').save_position() + // Live only inside parse_namespace_local_part itself, never during its own call into + // anything else — but concurrently live with whichever of the two fields below called it, + // so it must stay distinct from both. + private namespace_local_part_lookahead_position: LexerPosition = new Lexer('').save_position() + // Shared by parse_type_or_namespace_selector and parse_universal_or_namespace_selector: + // alternative call paths (type selector vs universal selector) that are never both active, + // each held live across its own call into parse_namespace_local_part. + private namespace_selector_lookahead_position: LexerPosition = new Lexer('').save_position() + // try_parse_combinator: fully resolved within one call, no nested saves. + private combinator_lookahead_position: LexerPosition = new Lexer('').save_position() + // parse_class_selector: fully resolved within one call, no nested saves. + private class_selector_lookahead_position: LexerPosition = new Lexer('').save_position() + // parse_pseudo's two concurrent saves (`saved` persists longer, discarded rather than + // re-read on the path that recurses into parse_pseudo_function_after_colon). + private pseudo_lookahead_position: LexerPosition = new Lexer('').save_position() + private pseudo_double_colon_lookahead_position: LexerPosition = new Lexer('').save_position() + // parse_lang_identifiers: fully resolved within one call, no nested saves. + private lang_identifiers_lookahead_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -192,7 +216,7 @@ export class SelectorParser { // Check for leading combinator (relative selector) if allowed if (allow_relative && this.lexer.pos < this.selector_end) { - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.complex_selector_lookahead_position) this.lexer.next_token_fast(false) let token_type = this.lexer.token_type @@ -357,7 +381,7 @@ export class SelectorParser { namespace_start: number, namespace_prefix_length: number, ): number | null { - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.namespace_local_part_lookahead_position) this.lexer.next_token_fast(false) let node_type: number @@ -404,7 +428,7 @@ export class SelectorParser { } // Save position before skipping whitespace/comments - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.namespace_selector_lookahead_position) // Skip whitespace and comments before checking for | this.skip_whitespace() @@ -429,7 +453,7 @@ export class SelectorParser { // Called when we've seen a * DELIM token private parse_universal_or_namespace_selector(start: number, end: number): number | null { // Save position before skipping whitespace/comments - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.namespace_selector_lookahead_position) // Skip whitespace and comments before checking for | this.skip_whitespace() @@ -459,7 +483,7 @@ export class SelectorParser { // Parse combinator (>, +, ~, or descendant space) private try_parse_combinator(): number | null { - const saved_whitespace_start = this.lexer.save_position() + const saved_whitespace_start = this.lexer.save_position_into(this.combinator_lookahead_position) let has_whitespace = this.lexer.pos < this.selector_end // Skip whitespace and comments @@ -513,7 +537,7 @@ export class SelectorParser { // Parse class selector (.classname) private parse_class_selector(dot_pos: number): number | null { // Save lexer state for potential restoration - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.class_selector_lookahead_position) // Next token should be identifier this.lexer.next_token_fast(false) @@ -685,10 +709,10 @@ export class SelectorParser { // Parse pseudo-class or pseudo-element (:hover, ::before) private parse_pseudo(start: number): number | null { // Save lexer state for potential restoration - const saved = this.lexer.save_position() + const saved = this.lexer.save_position_into(this.pseudo_lookahead_position) // Save position before skipping whitespace/comments - const saved_ws = this.lexer.save_position() + const saved_ws = this.lexer.save_position_into(this.pseudo_double_colon_lookahead_position) // Skip whitespace and comments before checking for second colon this.skip_whitespace() @@ -840,7 +864,7 @@ export class SelectorParser { // Parse :lang() content - comma-separated language identifiers // Accepts both quoted strings: :lang("en", "fr") and unquoted: :lang(en, fr) private parse_lang_identifiers(start: number, end: number, parent_node: number): void { - const saved_position = this.lexer.save_position() + const saved_position = this.lexer.save_position_into(this.lang_identifiers_lookahead_position) this.lexer.seek(start, this.lexer.line, this.lexer.column) let saved_selector_end = this.selector_end diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 4e72eb7..64fa177 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -1,7 +1,7 @@ // Value Node Parser - shared recursive value-token parsing, used by both ValueParser // (declaration values) and AtRulePreludeParser (feature values), so calc()/env()/var() // get the same structured tree everywhere instead of an opaque text span. -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { CSSDataArena, IDENTIFIER, @@ -66,6 +66,13 @@ export class ValueNodeParser { // try to build another ValueNodeParser to inject, forever. The helper's own condition_parser is // simply never accessed (it's only ever used via parse_chain), so the recursion stops there. private _condition_parser: ConditionParser | null = null + // Reused peek snapshots. Two distinct fields: parse_function_node's url()/src() peek and + // parse_if_condition's and/or/not continuation peek are each resolved before anything else + // in this class could save again, so neither is ever live at the same time as the other — + // but parse_value_nodes_in_range's own save (elsewhere) CAN be live across a recursive + // descent into this class, so it deliberately does not share either of these. + private url_peek_position: LexerPosition = new Lexer('').save_position() + private if_condition_peek_position: LexerPosition = new Lexer('').save_position() private get condition_parser(): ConditionParser { if (this._condition_parser === null) { @@ -266,7 +273,7 @@ export class ValueNodeParser { if (is_url || str_equals_range(this.source, start, name_end, 'src')) { // Peek at the next token to see if it's a string // If it's a string, parse normally. Otherwise, skip parsing children. - let save_pos = this.lexer.save_position() + let save_pos = this.lexer.save_position_into(this.url_peek_position) this.lexer.next_token_fast(false) // Skip whitespace @@ -593,7 +600,7 @@ export class ValueNodeParser { // condition; anything else ends the condition here. if (tt !== TOKEN_FUNCTION && !is_operator) break - let saved = this.lexer.save_position() + let saved = this.lexer.save_position_into(this.if_condition_peek_position) let next_tt = this.next_significant_token() if (next_tt === TOKEN_FUNCTION) { compound = true