From 4f6c6fe36a300d6635119741d2c4f936bbf73239 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:17:05 +0000 Subject: [PATCH 1/5] perf: reduce temporary allocations during parsing Avoid substrings for keyword/function-name checks (compare source ranges instead), replace tuple-returning helpers with fields, drop the at-rule name string and trim_boundaries tuple on the hot path, and reuse lexer snapshots in the declaration parser. Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 182 ++++++++++++++++++++++-------------- src/parse-condition.ts | 46 +++++---- src/parse-declaration.ts | 45 ++++++--- src/parse.ts | 51 ++++++---- src/string-utils.ts | 11 +++ src/tokenize.ts | 17 ++++ src/value-node-parser.ts | 41 ++++---- 7 files changed, 249 insertions(+), 144 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 4229975..cf070b9 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -27,7 +27,13 @@ import { TOKEN_DELIM, type TokenType, } from './token-types' -import { str_equals, strip_vendor_prefix, CHAR_PERIOD } from './string-utils' +import { + str_equals_range, + is_and_or_not_range, + is_vendor_prefixed, + CHAR_PERIOD, + CHAR_MINUS_HYPHEN, +} from './string-utils' import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' import type { AnyNode } from './node-types' @@ -63,50 +69,81 @@ export class AtRulePreludeParser { end: number, line: number = 1, column: number = 1, + ): number[] { + return this.parse_prelude_dispatch( + at_rule_name, + 0, + at_rule_name.length, + start, + end, + line, + column, + ) + } + + // Same as parse_prelude, but the at-rule name is the range [name_start, name_end) of this + // parser's own source — avoids allocating a substring for the name. + parse_prelude_named_in_source( + name_start: number, + name_end: number, + start: number, + end: number, + line: number = 1, + column: number = 1, + ): number[] { + return this.parse_prelude_dispatch(this.source, name_start, name_end, start, end, line, column) + } + + // Dispatch to appropriate parser based on at-rule type + private parse_prelude_dispatch( + name_source: string, + name_start: number, + name_end: number, + start: number, + end: number, + line: number, + column: number, ): number[] { this.prelude_end = end // Position lexer at prelude start this.lexer.seek(start, line, column) - return this.parse_prelude_dispatch(at_rule_name) - } - - // Dispatch to appropriate parser based on at-rule type - private parse_prelude_dispatch(at_rule_name: string): number[] { // Strip vendor prefix to treat @-webkit-keyframes same as @keyframes - let normalized_name = strip_vendor_prefix(at_rule_name).toLowerCase() - - switch (normalized_name) { - case 'media': - return this.parse_media_query_list() - case 'container': - return this.parse_container_query() - case 'supports': - return this.parse_supports_query() - case 'layer': - return this.parse_layer_names() - case 'keyframes': - case 'property': - case 'counter-style': - case 'color-profile': - case 'font-palette-values': - case 'position-try': - case 'font-feature-values': - case 'page': - return this.parse_identifier() - case 'function': - return this.parse_function_prelude() - case 'import': - return this.parse_import_prelude() - case 'charset': - return this.parse_charset_prelude() - case 'namespace': - return this.parse_namespace_prelude() - case 'scope': - return this.parse_scope_prelude() - case 'custom-media': - return this.parse_custom_media_prelude() + if (is_vendor_prefixed(name_source, name_start, name_end)) { + for (let i = name_start + 2; i < name_end; i++) { + if (name_source.charCodeAt(i) === CHAR_MINUS_HYPHEN) { + name_start = i + 1 + break + } + } + } + + // Each comparison rejects on length first, so a flat chain is cheap + let is = str_equals_range + if (is(name_source, name_start, name_end, 'media')) return this.parse_media_query_list() + if (is(name_source, name_start, name_end, 'container')) return this.parse_container_query() + if (is(name_source, name_start, name_end, 'supports')) return this.parse_supports_query() + if (is(name_source, name_start, name_end, 'layer')) return this.parse_layer_names() + if ( + is(name_source, name_start, name_end, 'keyframes') || + is(name_source, name_start, name_end, 'property') || + is(name_source, name_start, name_end, 'counter-style') || + is(name_source, name_start, name_end, 'color-profile') || + is(name_source, name_start, name_end, 'font-palette-values') || + is(name_source, name_start, name_end, 'position-try') || + is(name_source, name_start, name_end, 'font-feature-values') || + is(name_source, name_start, name_end, 'page') + ) { + return this.parse_identifier() + } + if (is(name_source, name_start, name_end, 'function')) return this.parse_function_prelude() + if (is(name_source, name_start, name_end, 'import')) return this.parse_import_prelude() + if (is(name_source, name_start, name_end, 'charset')) return this.parse_charset_prelude() + if (is(name_source, name_start, name_end, 'namespace')) return this.parse_namespace_prelude() + if (is(name_source, name_start, name_end, 'scope')) return this.parse_scope_prelude() + if (is(name_source, name_start, name_end, 'custom-media')) { + return this.parse_custom_media_prelude() } return [] @@ -148,12 +185,6 @@ export class AtRulePreludeParser { ) } - private is_and_or_not(str: string): boolean { - // All logical operators are 2-3 chars: "and" (3), "or" (2), "not" (3) - // The str_equals calls will quickly reject strings of other lengths - return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) - } - // Scan tokens from just after an already-open '(' or function-call (depth 1) to its // matching ')'. Must be called right after consuming the opening token. Returns // [content_end, close_end, matched]: content_end/close_end are the positions right @@ -161,7 +192,10 @@ export class AtRulePreludeParser { // content_end/close_end are left at the position scanning started from (mirroring the // caller's own pre-loop defaults, so callers that don't check `matched` still get sane // fallback spans). - private scan_matching_paren(): [content_end: number, close_end: number, matched: boolean] { + private paren_content_end = 0 + private paren_close_end = 0 + + private scan_matching_paren(): boolean { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end @@ -181,7 +215,9 @@ export class AtRulePreludeParser { } } - return [content_end, close_end, depth === 0] + this.paren_content_end = content_end + this.paren_close_end = close_end + return depth === 0 } // Parse a single media query: screen and (min-width: 768px) @@ -203,8 +239,10 @@ export class AtRulePreludeParser { this.next_token() if (this.lexer.token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (str_equals('only', text) || str_equals('not', text)) { + if ( + str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'only') || + str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'not') + ) { let modifier = this.create_node( PRELUDE_OPERATOR, this.lexer.token_start, @@ -242,14 +280,12 @@ export class AtRulePreludeParser { ) if (component !== null) { // condition_parser scanned with its own lexer — resync ours past what it consumed - this.lexer.seek(...this.condition_parser.end_position) + this.condition_parser.seek_to_end(this.lexer) } } // Identifier: media type or operator (and, or, not) else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { + if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { // Logical operator component = this.create_node( PRELUDE_OPERATOR, @@ -313,7 +349,7 @@ export class AtRulePreludeParser { this.lexer.token_column, ) if (component !== null) { - this.lexer.seek(...this.condition_parser.end_position) + this.condition_parser.seek_to_end(this.lexer) } } // Function: style(--custom: 1) @@ -324,13 +360,11 @@ export class AtRulePreludeParser { this.lexer.token_line, this.lexer.token_column, ) - this.lexer.seek(...this.condition_parser.end_position) + this.condition_parser.seek_to_end(this.lexer) } // Identifier: operator (and, or, not) or container name else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { + if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { // Logical operator component = this.create_node( PRELUDE_OPERATOR, @@ -546,7 +580,8 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // It's url( ... we need to find the matching ) - let [, close_end] = this.scan_matching_paren() + this.scan_matching_paren() + let close_end = this.paren_close_end url_end = close_end } @@ -564,13 +599,11 @@ export class AtRulePreludeParser { // Check for 'layer' keyword or 'layer(' function if (this.lexer.token_type === TOKEN_IDENT || this.lexer.token_type === TOKEN_FUNCTION) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - // For function tokens, remove the trailing '(' - if (this.lexer.token_type === TOKEN_FUNCTION && text.endsWith('(')) { - text = text.slice(0, -1) - } + // For function tokens, exclude the trailing '(' + let name_end = + this.lexer.token_type === TOKEN_FUNCTION ? this.lexer.token_end - 1 : this.lexer.token_end - if (str_equals('layer', text)) { + if (str_equals_range(this.source, this.lexer.token_start, name_end, 'layer')) { let layer_start = this.lexer.token_start let layer_end = this.lexer.token_end let content_start = 0 @@ -580,7 +613,9 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // Track the content inside the parentheses content_start = this.lexer.pos - let [content_end, close_end, matched] = this.scan_matching_paren() + let matched = this.scan_matching_paren() + let content_end = this.paren_content_end + let close_end = this.paren_close_end if (matched) { content_length = content_end - content_start layer_end = close_end @@ -617,13 +652,17 @@ export class AtRulePreludeParser { // Check for 'supports(' function if (this.lexer.token_type === TOKEN_FUNCTION) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' - if (str_equals('supports', text)) { + // -1 to exclude '(' + if ( + str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end - 1, 'supports') + ) { let supports_start = this.lexer.token_start let content_start = this.lexer.token_end // After the opening '(' // Find matching closing parenthesis - let [content_end, supports_end] = this.scan_matching_paren() + this.scan_matching_paren() + let content_end = this.paren_content_end + let supports_end = this.paren_close_end // Create supports node let supports_node = this.create_node(SUPPORTS_QUERY, supports_start, supports_end) @@ -720,7 +759,9 @@ export class AtRulePreludeParser { let paren_start = this.lexer.token_start let content_start = this.lexer.pos - let [content_end, paren_end] = this.scan_matching_paren() + this.scan_matching_paren() + let content_end = this.paren_content_end + let paren_end = this.paren_close_end let scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end) let trimmed = trim_boundaries(this.source, content_start, content_end) @@ -731,8 +772,7 @@ export class AtRulePreludeParser { nodes.push(scope_node) } else if (token_type === TOKEN_IDENT) { this.next_token() - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (str_equals('to', text)) { + if (str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'to')) { nodes.push( this.create_node(PRELUDE_OPERATOR, this.lexer.token_start, this.lexer.token_end), ) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 5fed5ed..eda9e23 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -38,6 +38,8 @@ import { CHAR_GREATER_THAN, CHAR_EQUALS, CHAR_FORWARD_SLASH, + str_equals_range, + is_and_or_not_range, } from './string-utils' import { trim_boundaries, @@ -70,8 +72,8 @@ export class ConditionParser { // Where this instance's own lexer landed after the last parse_media_feature()/ // parse_function_condition() call — callers scanning the same range on their own lexer must reseek here after delegating. - get end_position(): [pos: number, line: number, column: number] { - return [this.lexer.pos, this.lexer.line, this.lexer.column] + seek_to_end(target: Lexer): void { + target.seek(this.lexer.pos, this.lexer.line, this.lexer.column) } private create_node(type: number, start: number, end: number): number { @@ -92,12 +94,6 @@ export class ConditionParser { return this.lexer.next_token_fast(false) } - private is_and_or_not(str: string): boolean { - // All logical operators are 2-3 chars: "and" (3), "or" (2), "not" (3) - // The str_equals calls will quickly reject strings of other lengths - return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) - } - // Scan tokens from just after an already-open '(' or function-call (depth 1) to its // matching ')'. Must be called right after consuming the opening token. Returns // [content_end, close_end, matched]: content_end/close_end are the positions right @@ -105,7 +101,10 @@ export class ConditionParser { // content_end/close_end are left at the position scanning started from (mirroring the // caller's own pre-loop defaults, so callers that don't check `matched` still get sane // fallback spans). - private scan_matching_paren(): [content_end: number, close_end: number, matched: boolean] { + private paren_content_end = 0 + private paren_close_end = 0 + + private scan_matching_paren(): boolean { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end @@ -125,7 +124,9 @@ export class ConditionParser { } } - return [content_end, close_end, depth === 0] + this.paren_content_end = content_end + this.paren_close_end = close_end + return depth === 0 } // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children. @@ -397,7 +398,9 @@ export class ConditionParser { let feature_start = this.lexer.token_start let content_start = this.lexer.pos - let [content_end, feature_end, matched] = this.scan_matching_paren() + let matched = this.scan_matching_paren() + let content_end = this.paren_content_end + let feature_end = this.paren_close_end if (!matched) return null return this.parse_media_feature_content(feature_start, feature_end, content_start, content_end) @@ -411,23 +414,26 @@ export class ConditionParser { this.lexer.seek(func_start, line, column) this.next_token() // consume the function token, up to and including '(' - let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' + let func_name_start = this.lexer.token_start + let func_name_end = this.lexer.token_end - 1 // -1 to exclude '(' let content_start = this.lexer.token_end // After '(' // Find matching closing paren - let [content_end, func_end] = this.scan_matching_paren() + this.scan_matching_paren() + let content_end = this.paren_content_end + let func_end = this.paren_close_end // Create function node let func_node = this.create_node(FUNCTION, func_start, func_end) // Set content fields to function name this.arena.set_content_start_delta(func_node, 0) - this.arena.set_content_length(func_node, func_name.length) + this.arena.set_content_length(func_node, func_name_end - func_name_start) // Set value fields to content inside parentheses this.arena.set_value_start_delta(func_node, content_start - func_start) this.arena.set_value_length(func_node, content_end - content_start) // selector()'s argument is a — parse it into a real SelectorList - if (str_equals('selector', func_name)) { + if (str_equals_range(this.source, func_name_start, func_name_end, 'selector')) { let selector_list = this.selector_parser.parse_selector( content_start, content_end, @@ -439,7 +445,7 @@ export class ConditionParser { } } // style()'s argument is a — parse it into the same tree as (property: value) - else if (str_equals('style', func_name)) { + else if (str_equals_range(this.source, func_name_start, func_name_end, 'style')) { let decl_child = this.parse_supports_declaration_content(content_start, content_end) if (decl_child !== null) { this.arena.set_first_child(func_node, decl_child) @@ -483,7 +489,9 @@ export class ConditionParser { let feature_start = this.lexer.token_start let content_start = this.lexer.pos - let [content_end, feature_end, matched] = this.scan_matching_paren() + let matched = this.scan_matching_paren() + let content_end = this.paren_content_end + let feature_end = this.paren_close_end if (matched) { let query = this.create_node(SUPPORTS_QUERY, feature_start, feature_end) @@ -504,9 +512,7 @@ export class ConditionParser { } // Identifier: operator (and, or, not) else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { + if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { let op = this.create_node(PRELUDE_OPERATOR, this.lexer.token_start, this.lexer.token_end) nodes.push(op) } diff --git a/src/parse-declaration.ts b/src/parse-declaration.ts index 245a9f5..d197a9d 100644 --- a/src/parse-declaration.ts +++ b/src/parse-declaration.ts @@ -1,5 +1,5 @@ // Declaration Parser - Parses CSS declarations into structured AST nodes -import { Lexer } from './tokenize' +import { Lexer, type LexerPosition } from './tokenize' import { CSSDataArena, DECLARATION, RAW, FLAG_IMPORTANT, FLAG_BROWSERHACK } from './arena' import { ValueParser } from './parse-value' import { @@ -25,7 +25,10 @@ import { TOKEN_AT_KEYWORD, type TokenType, } from './token-types' -import { trim_boundaries, skip_whitespace_and_comments_backward } from './parse-utils' +import { + skip_whitespace_and_comments_forward, + skip_whitespace_and_comments_backward, +} from './parse-utils' import { CSSNode } from './css-node' import type { Declaration } from './node-types' @@ -34,6 +37,19 @@ export class DeclarationParser { private arena: CSSDataArena private source: string private value_parser: ValueParser | null + // Reused for the property-name lookahead snapshot (never nested), so each declaration + // attempt doesn't allocate a fresh LexerPosition + private lookahead_position: LexerPosition = { + pos: 0, + line: 0, + column: 0, + _line_offset: 0, + token_type: 0 as TokenType, + token_start: 0, + token_end: 0, + token_line: 0, + token_column: 0, + } constructor(arena: CSSDataArena, source: string, parse_values: boolean = true) { this.arena = arena @@ -58,8 +74,9 @@ export class DeclarationParser { // Parse a declaration using a provided lexer (used by Parser to avoid re-tokenization) parse_declaration_with_lexer(lexer: Lexer, end: number): number | null { - // Save initial position so we can fully restore if parsing fails after consuming a delimiter prefix - const initial_saved = lexer.save_position() + // Only saved if a delimiter prefix is found (rare), so we can fully restore if parsing + // fails after consuming it + let initial_saved: LexerPosition | null = null // Check for browser hack prefix (single delimiter/special character before identifier) let has_browser_hack = false @@ -116,6 +133,7 @@ export class DeclarationParser { if ((lexer.token_type as TokenType) === TOKEN_IDENT) { has_browser_hack = true has_delimiter_prefix = true + initial_saved = delim_saved } else { lexer.restore_position(delim_saved) } @@ -137,7 +155,7 @@ export class DeclarationParser { let decl_column = has_browser_hack ? browser_hack_column : lexer.token_column // Lookahead: save lexer state before consuming - const saved = lexer.save_position() + const saved = lexer.save_position_into(this.lookahead_position) lexer.next_token_fast(true) // consume property name, skip whitespace @@ -146,7 +164,7 @@ export class DeclarationParser { // Restore lexer state and return null. // If we consumed a delimiter prefix (e.g. ':' before the property name), we must restore // all the way back to before that prefix so the caller's lexer position is correct. - lexer.restore_position(has_delimiter_prefix ? initial_saved : saved) + lexer.restore_position(initial_saved ?? saved) return null } // Skip whitespace after ':' without tokenizing the value's first token - the raw scan @@ -261,18 +279,19 @@ export class DeclarationParser { } // Store value position (trimmed) and parse value nodes - let trimmed = trim_boundaries(this.source, value_start, value_end) - if (trimmed) { + let trimmed_start = skip_whitespace_and_comments_forward(this.source, value_start, value_end) + let trimmed_end = skip_whitespace_and_comments_backward(this.source, value_end, trimmed_start) + if (trimmed_start < trimmed_end) { // Store raw value string offsets (for fast string access) - this.arena.set_value_start_delta(declaration, trimmed[0] - prop_start) - this.arena.set_value_length(declaration, trimmed[1] - trimmed[0]) + this.arena.set_value_start_delta(declaration, trimmed_start - prop_start) + this.arena.set_value_length(declaration, trimmed_end - trimmed_start) // Parse value into structured nodes (only if enabled) if (this.value_parser) { // CRITICAL: Pass value_start_line and value_start_column to value parser let valueNode = this.value_parser.parse_value( value_start, - trimmed[1], + trimmed_end, value_start_line, value_start_column, ) @@ -283,8 +302,8 @@ export class DeclarationParser { // Create RAW node for unparsed value text let rawNode = this.arena.create_node( RAW, - trimmed[0], - trimmed[1] - trimmed[0], + trimmed_start, + trimmed_end - trimmed_start, value_start_line, value_start_column, ) diff --git a/src/parse.ts b/src/parse.ts index d29627c..44aad99 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -34,7 +34,10 @@ import { TOKEN_COLON, TOKEN_FUNCTION, } from './token-types' -import { trim_boundaries, skip_whitespace_and_comments_backward } from './parse-utils' +import { + skip_whitespace_and_comments_forward, + skip_whitespace_and_comments_backward, +} from './parse-utils' import { CHAR_PERIOD, CHAR_GREATER_THAN, @@ -42,6 +45,7 @@ import { CHAR_TILDE, CHAR_AMPERSAND, CHAR_LEFT_BRACE, + str_equals_range, } from './string-utils' export interface ParserOptions { @@ -52,7 +56,7 @@ export interface ParserOptions { } // Static at-rule lookup sets for fast classification -let DECLARATION_AT_RULES = new Set([ +let DECLARATION_AT_RULES = [ 'font-face', 'font-feature-values', 'page', @@ -62,7 +66,7 @@ let DECLARATION_AT_RULES = new Set([ 'font-palette-values', 'position-try', 'view-transition', -]) +] /** @internal */ export class Parser { @@ -393,7 +397,7 @@ export class Parser { // Extract at-rule name (skip the '@') let name_start = this.lexer.token_start + 1 let name_length = this.lexer.token_end - name_start - let at_rule_name = this.source.substring(name_start, this.lexer.token_end) + let name_end = this.lexer.token_end this.next_token() // consume @keyword @@ -436,24 +440,30 @@ export class Parser { } // Store prelude position (trimmed) - let trimmed = trim_boundaries(this.source, prelude_start, prelude_end) + let trimmed_start = skip_whitespace_and_comments_forward( + this.source, + prelude_start, + prelude_end, + ) + let trimmed_end = skip_whitespace_and_comments_backward(this.source, prelude_end, trimmed_start) let prelude_wrapper: number | null = null - if (trimmed) { + if (trimmed_start < trimmed_end) { // Create AT_RULE_PRELUDE wrapper if prelude parsing is enabled if (this.prelude_parser) { // Parse prelude and add structured nodes as children - let prelude_nodes = this.prelude_parser.parse_prelude( - at_rule_name, - trimmed[0], - trimmed[1], + let prelude_nodes = this.prelude_parser.parse_prelude_named_in_source( + name_start, + name_end, + trimmed_start, + trimmed_end, at_rule_line, at_rule_column, ) if (prelude_nodes.length > 0) { prelude_wrapper = this.arena.create_node( AT_RULE_PRELUDE, - trimmed[0], - trimmed[1] - trimmed[0], + trimmed_start, + trimmed_end - trimmed_start, at_rule_line, at_rule_column, ) @@ -461,8 +471,8 @@ export class Parser { } else { prelude_wrapper = this.arena.create_node( RAW, - trimmed[0], - trimmed[1] - trimmed[0], + trimmed_start, + trimmed_end - trimmed_start, at_rule_line, at_rule_column, ) @@ -470,8 +480,8 @@ export class Parser { } else { prelude_wrapper = this.arena.create_node( RAW, - trimmed[0], - trimmed[1] - trimmed[0], + trimmed_start, + trimmed_end - trimmed_start, at_rule_line, at_rule_column, ) @@ -501,7 +511,7 @@ export class Parser { // Determine what to parse inside the block based on the at-rule name: // declarations only (like @font-face, @page), or declarations + rules + at-rules // (like @media, @keyframes, unknown at-rules, and nested conditional groups) - let declarations_only = this.atrule_has_declarations(at_rule_name) + let declarations_only = this.atrule_has_declarations(name_start, name_end) let first_child = this.parse_block_children(at_rule, declarations_only) // Consume '}' (block excludes closing brace, but at-rule includes it) @@ -547,8 +557,11 @@ export class Parser { } // Determine if an at-rule contains declarations or nested rules - private atrule_has_declarations(name: string): boolean { - return DECLARATION_AT_RULES.has(name.toLowerCase()) + private atrule_has_declarations(name_start: number, name_end: number): boolean { + for (let i = 0; i < DECLARATION_AT_RULES.length; i++) { + if (str_equals_range(this.source, name_start, name_end, DECLARATION_AT_RULES[i])) return true + } + return false } } diff --git a/src/string-utils.ts b/src/string-utils.ts index 0e272e6..2df2550 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -99,6 +99,17 @@ export function str_equals_range( return true } +/** True if `source[start, end)` is `and`, `or` or `not` (case-insensitive), without allocating a substring. */ +export function is_and_or_not_range(source: string, start: number, end: number): boolean { + let length = end - start + if (length === 3) { + return ( + str_equals_range(source, start, end, 'and') || str_equals_range(source, start, end, 'not') + ) + } + return length === 2 && str_equals_range(source, start, end, 'or') +} + /** Case-insensitive ASCII prefix check without allocations. `prefix` MUST be lowercase. */ export function str_starts_with(str: string, prefix: string): boolean { if (str.length < prefix.length) { diff --git a/src/tokenize.ts b/src/tokenize.ts index 559c0b2..f9352a5 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -997,6 +997,23 @@ export class Lexer { } } + /** + * Like `save_position()`, but overwrites an existing object instead of allocating a new one. + * For hot paths that backtrack often and can own a long-lived, non-reentrant snapshot. + */ + save_position_into(target: LexerPosition): LexerPosition { + target.pos = this.pos + target.line = this._line + target.column = this.pos - this._line_offset + 1 + target._line_offset = this._line_offset + target.token_type = this.token_type + target.token_start = this.token_start + target.token_end = this.token_end + target.token_line = this.token_line + target.token_column = this.token_column + return target + } + /** * Restore lexer state from saved position * @param saved The saved position to restore diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 19eeb36..c7bb4cc 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -45,6 +45,7 @@ import { CHAR_FORWARD_SLASH, str_equals, str_equals_range, + is_and_or_not_range, } from './string-utils' import { ConditionParser } from './parse-condition' @@ -205,9 +206,10 @@ export class ValueNodeParser { // see the caller for why). // Returns [content_end, close_end, matched]; matched is false if EOF was hit first, in // which case content_end/close_end are left at their initial (scan-start) values. - private scan_matching_paren( - bounded: boolean, - ): [content_end: number, close_end: number, matched: boolean] { + private paren_content_end = 0 + private paren_close_end = 0 + + private scan_matching_paren(bounded: boolean): boolean { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end @@ -229,7 +231,9 @@ export class ValueNodeParser { } } - return [content_end, close_end, depth === 0] + this.paren_content_end = content_end + this.paren_close_end = close_end + return depth === 0 } private parse_function_node(start: number, end: number): number { @@ -291,7 +295,9 @@ export class ValueNodeParser { let content_start = end // Position after 'url(' let content_end = end - let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(false) + let matched = this.scan_matching_paren(false) + let scanned_content_end = this.paren_content_end + let scanned_func_end = this.paren_close_end if (matched) { content_end = scanned_content_end func_end = scanned_func_end @@ -521,11 +527,6 @@ export class ValueNodeParser { return node } - private is_and_or_not(str: string): boolean { - // All logical operators are 2-3 chars: "and" (3), "or" (2), "not" (3) - return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) - } - // Advance past whitespace to the next real token, returning its type (TOKEN_EOF once // `this.end` is reached). Used to look ahead for a not/and/or continuation without // committing to consuming it — callers restore to a saved position when it doesn't. @@ -552,8 +553,7 @@ export class ValueNodeParser { private parse_if_condition(): number | null { // "else" never combines with not/and/or — always a bare identifier. if (this.lexer.token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (str_equals('else', text)) { + if (str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'else')) { return this.parse_value_node() } } @@ -571,8 +571,7 @@ export class ValueNodeParser { if (tt === TOKEN_FUNCTION) { component = this.parse_if_condition_function(this.lexer.token_start, this.lexer.token_end) } else if (tt === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (this.is_and_or_not(text)) { + if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { is_operator = true component = this.arena.create_node( PRELUDE_OPERATOR, @@ -606,8 +605,7 @@ export class ValueNodeParser { continue } if (next_tt === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (this.is_and_or_not(text)) { + if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { compound = true continue } @@ -648,7 +646,6 @@ export class ValueNodeParser { // @param token_end Offset right after '(' (== lexer.token_end here). private parse_if_condition_function(func_start: number, token_end: number): number { let func_name_end = token_end - 1 // before '(' - let func_name = this.source.substring(func_start, func_name_end) let func_line = this.lexer.token_line let func_col = this.lexer.token_column @@ -657,7 +654,9 @@ export class ValueNodeParser { let func_end = content_start // Scan for matching ')' to find the full function extent - let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(true) + let matched = this.scan_matching_paren(true) + let scanned_content_end = this.paren_content_end + let scanned_func_end = this.paren_close_end if (matched) { content_end = scanned_content_end func_end = scanned_func_end @@ -679,7 +678,7 @@ export class ValueNodeParser { // Parse content based on function name let child_nodes: number[] = [] - if (str_equals('style', func_name)) { + if (str_equals_range(this.source, func_start, func_name_end, 'style')) { // style(): a bare single declaration when the content has a top-level // ':' (style(--x: 1)); otherwise the full compound and/or/not grammar over // parenthesized declarations, same as `@supports` — style((--a: 1) or (--a: 2)). @@ -697,7 +696,7 @@ export class ValueNodeParser { } else { child_nodes = [decl] } - } else if (str_equals('supports', func_name)) { + } else if (str_equals_range(this.source, func_start, func_name_end, 'supports')) { // supports(): a bare single declaration when the content has a // top-level ':' (matching style()'s shorthand — supports(display: grid), no extra // parens needed); otherwise the full compound grammar, same as `@supports`'s own @@ -716,7 +715,7 @@ export class ValueNodeParser { } else { child_nodes = [decl] } - } else if (str_equals('media', func_name)) { + } else if (str_equals_range(this.source, func_start, func_name_end, 'media')) { // media()'s own parens delimit the feature; there's no separate inner paren pair, // so the feature span equals the content span (see parse_media_feature_content's docs) let feature = this.condition_parser.parse_media_feature_content( From 6c3021ecd39ceba07bc302e58e8fb2c865b32506 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:45:15 +0000 Subject: [PATCH 2/5] perf: keep at-rule name dispatch string-based to limit bundle growth At-rule names are rare compared to tokens, so the range-based dispatch wasn't worth ~1.4 kB of extra code. Also build the reusable lexer snapshot without a long literal and shorten new comments. Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 108 +++++++++++++----------------------- src/parse-declaration.ts | 18 +----- src/parse.ts | 21 +++---- src/string-utils.ts | 2 +- src/tokenize.ts | 3 +- 5 files changed, 51 insertions(+), 101 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index cf070b9..2a240b5 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -30,9 +30,8 @@ import { import { str_equals_range, is_and_or_not_range, - is_vendor_prefixed, + strip_vendor_prefix, CHAR_PERIOD, - CHAR_MINUS_HYPHEN, } from './string-utils' import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' @@ -69,81 +68,50 @@ export class AtRulePreludeParser { end: number, line: number = 1, column: number = 1, - ): number[] { - return this.parse_prelude_dispatch( - at_rule_name, - 0, - at_rule_name.length, - start, - end, - line, - column, - ) - } - - // Same as parse_prelude, but the at-rule name is the range [name_start, name_end) of this - // parser's own source — avoids allocating a substring for the name. - parse_prelude_named_in_source( - name_start: number, - name_end: number, - start: number, - end: number, - line: number = 1, - column: number = 1, - ): number[] { - return this.parse_prelude_dispatch(this.source, name_start, name_end, start, end, line, column) - } - - // Dispatch to appropriate parser based on at-rule type - private parse_prelude_dispatch( - name_source: string, - name_start: number, - name_end: number, - start: number, - end: number, - line: number, - column: number, ): number[] { this.prelude_end = end // Position lexer at prelude start this.lexer.seek(start, line, column) - // Strip vendor prefix to treat @-webkit-keyframes same as @keyframes - if (is_vendor_prefixed(name_source, name_start, name_end)) { - for (let i = name_start + 2; i < name_end; i++) { - if (name_source.charCodeAt(i) === CHAR_MINUS_HYPHEN) { - name_start = i + 1 - break - } - } - } + return this.parse_prelude_dispatch(at_rule_name) + } - // Each comparison rejects on length first, so a flat chain is cheap - let is = str_equals_range - if (is(name_source, name_start, name_end, 'media')) return this.parse_media_query_list() - if (is(name_source, name_start, name_end, 'container')) return this.parse_container_query() - if (is(name_source, name_start, name_end, 'supports')) return this.parse_supports_query() - if (is(name_source, name_start, name_end, 'layer')) return this.parse_layer_names() - if ( - is(name_source, name_start, name_end, 'keyframes') || - is(name_source, name_start, name_end, 'property') || - is(name_source, name_start, name_end, 'counter-style') || - is(name_source, name_start, name_end, 'color-profile') || - is(name_source, name_start, name_end, 'font-palette-values') || - is(name_source, name_start, name_end, 'position-try') || - is(name_source, name_start, name_end, 'font-feature-values') || - is(name_source, name_start, name_end, 'page') - ) { - return this.parse_identifier() - } - if (is(name_source, name_start, name_end, 'function')) return this.parse_function_prelude() - if (is(name_source, name_start, name_end, 'import')) return this.parse_import_prelude() - if (is(name_source, name_start, name_end, 'charset')) return this.parse_charset_prelude() - if (is(name_source, name_start, name_end, 'namespace')) return this.parse_namespace_prelude() - if (is(name_source, name_start, name_end, 'scope')) return this.parse_scope_prelude() - if (is(name_source, name_start, name_end, 'custom-media')) { - return this.parse_custom_media_prelude() + // Dispatch to appropriate parser based on at-rule type + private parse_prelude_dispatch(at_rule_name: string): number[] { + // Strip vendor prefix to treat @-webkit-keyframes same as @keyframes + let normalized_name = strip_vendor_prefix(at_rule_name).toLowerCase() + + switch (normalized_name) { + case 'media': + return this.parse_media_query_list() + case 'container': + return this.parse_container_query() + case 'supports': + return this.parse_supports_query() + case 'layer': + return this.parse_layer_names() + case 'keyframes': + case 'property': + case 'counter-style': + case 'color-profile': + case 'font-palette-values': + case 'position-try': + case 'font-feature-values': + case 'page': + return this.parse_identifier() + case 'function': + return this.parse_function_prelude() + case 'import': + return this.parse_import_prelude() + case 'charset': + return this.parse_charset_prelude() + case 'namespace': + return this.parse_namespace_prelude() + case 'scope': + return this.parse_scope_prelude() + case 'custom-media': + return this.parse_custom_media_prelude() } return [] diff --git a/src/parse-declaration.ts b/src/parse-declaration.ts index d197a9d..a8eee30 100644 --- a/src/parse-declaration.ts +++ b/src/parse-declaration.ts @@ -37,19 +37,8 @@ export class DeclarationParser { private arena: CSSDataArena private source: string private value_parser: ValueParser | null - // Reused for the property-name lookahead snapshot (never nested), so each declaration - // attempt doesn't allocate a fresh LexerPosition - private lookahead_position: LexerPosition = { - pos: 0, - line: 0, - column: 0, - _line_offset: 0, - token_type: 0 as TokenType, - token_start: 0, - token_end: 0, - token_line: 0, - token_column: 0, - } + // Reused lookahead snapshot (never nested) to avoid an allocation per declaration + private lookahead_position: LexerPosition = new Lexer('').save_position() constructor(arena: CSSDataArena, source: string, parse_values: boolean = true) { this.arena = arena @@ -74,8 +63,7 @@ export class DeclarationParser { // Parse a declaration using a provided lexer (used by Parser to avoid re-tokenization) parse_declaration_with_lexer(lexer: Lexer, end: number): number | null { - // Only saved if a delimiter prefix is found (rare), so we can fully restore if parsing - // fails after consuming it + // Only set when a delimiter prefix is consumed, to fully restore on failure let initial_saved: LexerPosition | null = null // Check for browser hack prefix (single delimiter/special character before identifier) diff --git a/src/parse.ts b/src/parse.ts index 44aad99..27c57de 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -45,7 +45,6 @@ import { CHAR_TILDE, CHAR_AMPERSAND, CHAR_LEFT_BRACE, - str_equals_range, } from './string-utils' export interface ParserOptions { @@ -56,7 +55,7 @@ export interface ParserOptions { } // Static at-rule lookup sets for fast classification -let DECLARATION_AT_RULES = [ +let DECLARATION_AT_RULES = new Set([ 'font-face', 'font-feature-values', 'page', @@ -66,7 +65,7 @@ let DECLARATION_AT_RULES = [ 'font-palette-values', 'position-try', 'view-transition', -] +]) /** @internal */ export class Parser { @@ -397,7 +396,7 @@ export class Parser { // Extract at-rule name (skip the '@') let name_start = this.lexer.token_start + 1 let name_length = this.lexer.token_end - name_start - let name_end = this.lexer.token_end + let at_rule_name = this.source.substring(name_start, this.lexer.token_end) this.next_token() // consume @keyword @@ -451,9 +450,8 @@ export class Parser { // Create AT_RULE_PRELUDE wrapper if prelude parsing is enabled if (this.prelude_parser) { // Parse prelude and add structured nodes as children - let prelude_nodes = this.prelude_parser.parse_prelude_named_in_source( - name_start, - name_end, + let prelude_nodes = this.prelude_parser.parse_prelude( + at_rule_name, trimmed_start, trimmed_end, at_rule_line, @@ -511,7 +509,7 @@ export class Parser { // Determine what to parse inside the block based on the at-rule name: // declarations only (like @font-face, @page), or declarations + rules + at-rules // (like @media, @keyframes, unknown at-rules, and nested conditional groups) - let declarations_only = this.atrule_has_declarations(name_start, name_end) + let declarations_only = this.atrule_has_declarations(at_rule_name) let first_child = this.parse_block_children(at_rule, declarations_only) // Consume '}' (block excludes closing brace, but at-rule includes it) @@ -557,11 +555,8 @@ export class Parser { } // Determine if an at-rule contains declarations or nested rules - private atrule_has_declarations(name_start: number, name_end: number): boolean { - for (let i = 0; i < DECLARATION_AT_RULES.length; i++) { - if (str_equals_range(this.source, name_start, name_end, DECLARATION_AT_RULES[i])) return true - } - return false + private atrule_has_declarations(name: string): boolean { + return DECLARATION_AT_RULES.has(name.toLowerCase()) } } diff --git a/src/string-utils.ts b/src/string-utils.ts index 2df2550..08b7b18 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -99,7 +99,7 @@ export function str_equals_range( return true } -/** True if `source[start, end)` is `and`, `or` or `not` (case-insensitive), without allocating a substring. */ +/** Case-insensitive check that `source[start, end)` is `and`, `or` or `not`, without allocating. */ export function is_and_or_not_range(source: string, start: number, end: number): boolean { let length = end - start if (length === 3) { diff --git a/src/tokenize.ts b/src/tokenize.ts index f9352a5..7cb8a40 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -998,8 +998,7 @@ export class Lexer { } /** - * Like `save_position()`, but overwrites an existing object instead of allocating a new one. - * For hot paths that backtrack often and can own a long-lived, non-reentrant snapshot. + * Like `save_position()`, but reuses `target` instead of allocating. */ save_position_into(target: LexerPosition): LexerPosition { target.pos = this.pos From 9f53d3ad75e4a6871da6ffbe638e328d1ae02de9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:49:57 +0000 Subject: [PATCH 3/5] docs: shorten new JSDoc comments Co-Authored-By: Claude Sonnet 5.5 --- src/string-utils.ts | 2 +- src/tokenize.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/string-utils.ts b/src/string-utils.ts index 08b7b18..f5bf3ff 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -99,7 +99,7 @@ export function str_equals_range( return true } -/** Case-insensitive check that `source[start, end)` is `and`, `or` or `not`, without allocating. */ +/** True if `source[start, end)` is and/or/not (case-insensitive). */ export function is_and_or_not_range(source: string, start: number, end: number): boolean { let length = end - start if (length === 3) { diff --git a/src/tokenize.ts b/src/tokenize.ts index 7cb8a40..bb146b7 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -997,9 +997,7 @@ export class Lexer { } } - /** - * Like `save_position()`, but reuses `target` instead of allocating. - */ + /** Like `save_position()`, but fills `target` instead of allocating. */ save_position_into(target: LexerPosition): LexerPosition { target.pos = this.pos target.line = this._line From 22e268fdfef2f62ccb8ca594c506bc864ed17428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:14:20 +0000 Subject: [PATCH 4/5] docs: use line comments for new internal helpers Co-Authored-By: Claude Sonnet 5.5 --- src/string-utils.ts | 2 +- src/tokenize.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/string-utils.ts b/src/string-utils.ts index f5bf3ff..dcc61ef 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -99,7 +99,7 @@ export function str_equals_range( return true } -/** True if `source[start, end)` is and/or/not (case-insensitive). */ +// True if `source[start, end)` is and/or/not (case-insensitive) export function is_and_or_not_range(source: string, start: number, end: number): boolean { let length = end - start if (length === 3) { diff --git a/src/tokenize.ts b/src/tokenize.ts index bb146b7..8694816 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -997,7 +997,7 @@ export class Lexer { } } - /** Like `save_position()`, but fills `target` instead of allocating. */ + // Like `save_position()`, but fills `target` instead of allocating save_position_into(target: LexerPosition): LexerPosition { target.pos = this.pos target.line = this._line From 028a8ae28e2083fbf0b1000957bdf383b6953860 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:18:29 +0000 Subject: [PATCH 5/5] perf: cache source and token bounds in and/or/not checks Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 37 ++++++++++++++++--------------------- src/parse-condition.ts | 6 ++++-- src/value-node-parser.ts | 8 +++++--- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 2a240b5..194a6ad 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -207,15 +207,14 @@ export class AtRulePreludeParser { this.next_token() if (this.lexer.token_type === TOKEN_IDENT) { + let source = this.source + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end if ( - str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'only') || - str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end, 'not') + str_equals_range(source, token_start, token_end, 'only') || + str_equals_range(source, token_start, token_end, 'not') ) { - let modifier = this.create_node( - PRELUDE_OPERATOR, - this.lexer.token_start, - this.lexer.token_end, - ) + let modifier = this.create_node(PRELUDE_OPERATOR, token_start, token_end) first_component = modifier last_component = modifier } else { @@ -253,16 +252,14 @@ export class AtRulePreludeParser { } // Identifier: media type or operator (and, or, not) else if (token_type === TOKEN_IDENT) { - if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end + if (is_and_or_not_range(this.source, token_start, token_end)) { // Logical operator - component = this.create_node( - PRELUDE_OPERATOR, - this.lexer.token_start, - this.lexer.token_end, - ) + component = this.create_node(PRELUDE_OPERATOR, token_start, token_end) } else { // Media type: screen, print, all - component = this.create_node(MEDIA_TYPE, this.lexer.token_start, this.lexer.token_end) + component = this.create_node(MEDIA_TYPE, token_start, token_end) } } else { // Unknown token, skip @@ -332,16 +329,14 @@ export class AtRulePreludeParser { } // Identifier: operator (and, or, not) or container name else if (token_type === TOKEN_IDENT) { - if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end + if (is_and_or_not_range(this.source, token_start, token_end)) { // Logical operator - component = this.create_node( - PRELUDE_OPERATOR, - this.lexer.token_start, - this.lexer.token_end, - ) + component = this.create_node(PRELUDE_OPERATOR, token_start, token_end) } else { // Container name or other identifier - component = this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end) + component = this.create_node(IDENTIFIER, token_start, token_end) } } diff --git a/src/parse-condition.ts b/src/parse-condition.ts index eda9e23..3f1eda6 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -512,8 +512,10 @@ export class ConditionParser { } // Identifier: operator (and, or, not) else if (token_type === TOKEN_IDENT) { - if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { - let op = this.create_node(PRELUDE_OPERATOR, this.lexer.token_start, this.lexer.token_end) + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end + if (is_and_or_not_range(this.source, token_start, token_end)) { + let op = this.create_node(PRELUDE_OPERATOR, token_start, token_end) nodes.push(op) } } diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index c7bb4cc..93f61e0 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -571,12 +571,14 @@ export class ValueNodeParser { if (tt === TOKEN_FUNCTION) { component = this.parse_if_condition_function(this.lexer.token_start, this.lexer.token_end) } else if (tt === TOKEN_IDENT) { - if (is_and_or_not_range(this.source, this.lexer.token_start, this.lexer.token_end)) { + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end + if (is_and_or_not_range(this.source, token_start, token_end)) { is_operator = true component = this.arena.create_node( PRELUDE_OPERATOR, - this.lexer.token_start, - this.lexer.token_end - this.lexer.token_start, + token_start, + token_end - token_start, this.lexer.token_line, this.lexer.token_column, )