diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 40aea05..21fa244 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -36,6 +36,9 @@ import { URL, DIMENSION, FEATURE_RANGE, + FUNCTION, + OPERATOR, + NUMBER, } from './arena' describe('At-Rule Prelude Nodes', () => { @@ -562,6 +565,41 @@ describe('At-Rule Prelude Nodes', () => { expect(feature?.value?.text).toBe('portrait') }) + test('should parse calc() function value', () => { + const css = '@media (min-width: calc(1px * 1)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + expect(feature?.value?.type).toBe(FUNCTION) + expect(feature?.value?.text).toBe('calc(1px * 1)') + + // calc()'s arguments should be parsed into structured children, not dropped + const args = (feature?.value as Function | undefined)?.children || [] + expect(args.map((n) => n.type)).toEqual([DIMENSION, OPERATOR, NUMBER]) + expect(args.map((n) => n.text)).toEqual(['1px', '*', '1']) + }) + + test('should parse env() function value', () => { + const css = '@media (min-width: env(safe-area-inset-top)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const queryChildren = + ((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined) + ?.children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as + | MediaFeature + | undefined + + expect(feature?.value?.type).toBe(FUNCTION) + expect(feature?.value?.text).toBe('env(safe-area-inset-top)') + }) + test('should have null value for boolean features', () => { const css = '@media (hover) { }' const ast = parse(css) @@ -869,6 +907,17 @@ describe('At-Rule Prelude Nodes', () => { expect(query.children[0].type).toBe(SUPPORTS_DECLARATION) }) + test('should not truncate the query at a nested function paren, e.g. calc()', () => { + const css = '@supports (width: calc(1px + 2px)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children || [] + const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery | undefined + + // The query's own closing paren must be found, not calc()'s + expect(query?.value).toBe('width: calc(1px + 2px)') + }) + test('should have a Declaration with property inside SupportsDeclaration', () => { const css = '@supports (display: flex) { }' const ast = parse(css) @@ -1298,6 +1347,18 @@ describe('At-Rule Prelude Nodes', () => { expect((children[2] as PreludeSelectorList).value).toBe('.dark') }) + test('should not truncate the scope selector at a nested function paren, e.g. :not()', () => { + const root = parse('@scope (:not(.a)) to (.b) { }') + const atRule = root.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude | null)?.children ?? [] + + expect(children.length).toBe(3) + // The scope-start's own closing paren must be found, not :not()'s + expect((children[0] as PreludeSelectorList).value).toBe(':not(.a)') + expect(children[1].type).toBe(PRELUDE_OPERATOR) + expect((children[2] as PreludeSelectorList).value).toBe('.b') + }) + test('should have no prelude children for bare @scope', () => { const root = parse('@scope { p { color: black; } }') const atRule = root.first_child! as Atrule diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 2f437a2..9fd9942 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -16,8 +16,6 @@ import { PRELUDE_SELECTORLIST, URL, FUNCTION, - NUMBER, - DIMENSION, STRING, FEATURE_RANGE, } from './arena' @@ -31,15 +29,11 @@ import { TOKEN_STRING, TOKEN_URL, TOKEN_FUNCTION, - TOKEN_NUMBER, - TOKEN_PERCENTAGE, - TOKEN_DIMENSION, TOKEN_DELIM, type TokenType, } from './token-types' import { str_equals, - is_whitespace, strip_vendor_prefix, CHAR_COLON, CHAR_LESS_THAN, @@ -50,6 +44,7 @@ import { import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' import type { AnyNode } from './node-types' +import { ValueNodeParser } from './value-node-parser' /** @internal */ export class AtRulePreludeParser { @@ -57,6 +52,10 @@ export class AtRulePreludeParser { private arena: CSSDataArena private source: string private prelude_end: number + // Shared with declaration-value parsing so feature values (calc(), env(), var(), ...) + // get the same structured Number/Operator/Function tree, not just an opaque text span. + // Runs on its own lexer instance, independent of this.lexer. + private value_node_parser: ValueNodeParser constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -64,6 +63,7 @@ export class AtRulePreludeParser { // Create a lexer instance for prelude parsing this.lexer = new Lexer(source) this.prelude_end = 0 + this.value_node_parser = new ValueNodeParser(arena, source) } // Parse an at-rule prelude into nodes (standalone use) @@ -261,7 +261,7 @@ export class AtRulePreludeParser { while (this.lexer.pos < this.prelude_end && depth > 0) { this.next_token() let token_type = this.lexer.token_type - if (token_type === TOKEN_LEFT_PAREN) { + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { depth++ } else if (token_type === TOKEN_RIGHT_PAREN) { depth-- @@ -461,7 +461,7 @@ export class AtRulePreludeParser { while (this.lexer.pos < this.prelude_end && depth > 0) { this.next_token() let inner_token_type = this.lexer.token_type - if (inner_token_type === TOKEN_LEFT_PAREN) { + if (inner_token_type === TOKEN_LEFT_PAREN || inner_token_type === TOKEN_FUNCTION) { depth++ } else if (inner_token_type === TOKEN_RIGHT_PAREN) { depth-- @@ -908,61 +908,13 @@ export class AtRulePreludeParser { return this.lexer.next_token_fast(false) } - // Helper: Parse a single value token into a node - private parse_value_token(): number | null { - switch (this.lexer.token_type) { - case TOKEN_IDENT: - return this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end) - case TOKEN_NUMBER: - return this.create_node(NUMBER, this.lexer.token_start, this.lexer.token_end) - case TOKEN_PERCENTAGE: - case TOKEN_DIMENSION: - return this.create_node(DIMENSION, this.lexer.token_start, this.lexer.token_end) - case TOKEN_STRING: - return this.create_node(STRING, this.lexer.token_start, this.lexer.token_end) - default: - return null - } - } - - // Helper: Parse feature value portion into typed nodes, chained as siblings without an - // intermediate array. Returns the first node in the chain (0 if none) — the common case is - // a single value (e.g. `min-width: 768px`), so callers get that node directly. + // Parse feature value portion into typed nodes (Number, Dimension, Function, Operator, ...), + // chained as siblings without an intermediate array. Delegates to the shared ValueNodeParser + // (also used for declaration values) so calc(), env(), var(), etc. get full structured + // children instead of being treated as opaque text. Runs on its own lexer instance, so it + // doesn't disturb this.lexer's position — no save/restore needed around the call. private parse_feature_value(start: number, end: number): number { - const saved_position = this.lexer.save_position() - this.lexer.seek(start, this.lexer.line, this.lexer.column) - - let first_node = 0 - let last_node = 0 - - while (this.lexer.pos < end) { - this.lexer.next_token_fast(false) - if (this.lexer.token_start >= end) break - - // Skip whitespace tokens - let all_whitespace = true - for (let i = this.lexer.token_start; i < this.lexer.token_end && i < end; i++) { - if (!is_whitespace(this.source.charCodeAt(i))) { - all_whitespace = false - break - } - } - if (all_whitespace) continue - - // Create node based on token type - let node = this.parse_value_token() - if (node !== null) { - if (first_node === 0) { - first_node = node - } else { - this.arena.set_next_sibling(last_node, node) - } - last_node = node - } - } - - this.lexer.restore_position(saved_position) - return first_node + return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column) } // Parse @namespace prelude: [prefix] url("...") | "..." @@ -1009,7 +961,11 @@ export class AtRulePreludeParser { while (this.lexer.pos < this.prelude_end && depth > 0) { this.next_token() - if (this.lexer.token_type === TOKEN_LEFT_PAREN) depth++ + if ( + this.lexer.token_type === TOKEN_LEFT_PAREN || + this.lexer.token_type === TOKEN_FUNCTION + ) + depth++ else if (this.lexer.token_type === TOKEN_RIGHT_PAREN) depth-- } diff --git a/src/parse-options.test.ts b/src/parse-options.test.ts index a5bbeaa..a948113 100644 --- a/src/parse-options.test.ts +++ b/src/parse-options.test.ts @@ -304,6 +304,17 @@ describe('Parser Options', () => { const atrule = root.first_child! as Atrule expect(atrule.prelude).toBeNull() }) + + test('should not deep-parse function values (e.g. calc()) inside a disabled prelude', () => { + const root = parse('@media (min-width: calc(1px * 1)) { }', { + parse_atrule_preludes: false, + }) + const atrule = root.first_child! as Atrule + const prelude = atrule.prelude + expect(prelude?.type).toBe(RAW) + expect(prelude?.text).toBe('(min-width: calc(1px * 1))') + expect(prelude?.first_child).toBeNull() + }) }) describe('on_comment callback', () => { diff --git a/src/parse-value.ts b/src/parse-value.ts index 7a25895..4640db4 100644 --- a/src/parse-value.ts +++ b/src/parse-value.ts @@ -1,108 +1,31 @@ // Value Parser - Parses CSS declaration values into structured AST nodes -import { Lexer } from './tokenize' -import { - CSSDataArena, - IDENTIFIER, - NUMBER, - DIMENSION, - STRING, - HASH, - FUNCTION, - OPERATOR, - PARENTHESIS, - URL, - UNICODE_RANGE, - VALUE, -} from './arena' -import { - TOKEN_IDENT, - TOKEN_NUMBER, - TOKEN_PERCENTAGE, - TOKEN_DIMENSION, - TOKEN_STRING, - TOKEN_HASH, - TOKEN_FUNCTION, - TOKEN_DELIM, - TOKEN_COMMA, - TOKEN_EOF, - TOKEN_LEFT_PAREN, - TOKEN_RIGHT_PAREN, - TOKEN_UNICODE_RANGE, -} from './token-types' -import { - is_whitespace, - CHAR_MINUS_HYPHEN, - CHAR_PLUS, - CHAR_ASTERISK, - CHAR_FORWARD_SLASH, - str_equals, -} from './string-utils' +import { CSSDataArena, VALUE } from './arena' import { CSSNode } from './css-node' import type { Value } from './node-types' +import { ValueNodeParser } from './value-node-parser' /** @internal */ export class ValueParser { - private lexer: Lexer + private nodes: ValueNodeParser private arena: CSSDataArena - private source: string - private value_end: number constructor(arena: CSSDataArena, source: string) { this.arena = arena - this.source = source - // Create a lexer instance for value parsing - this.lexer = new Lexer(source) - this.value_end = 0 + this.nodes = new ValueNodeParser(arena, source) } // Parse a declaration value range into a VALUE wrapper node // Returns single VALUE node index parse_value(start: number, end: number, start_line: number, start_column: number): number { - this.value_end = end + let first_node = this.nodes.parse_chain(start, end, start_line, start_column) - // Position lexer at value start with provided line/column - this.lexer.seek(start, start_line, start_column) - - // Parse individual value tokens, chaining them as siblings without an intermediate array. - // The common case is a single token (e.g. `color: red`), so this avoids allocating and - // pushing into an array for what is by far the most frequent shape of a declaration value. - let first_node = 0 - let last_node = 0 - - while (this.lexer.pos < this.value_end) { - // Get next token without skipping whitespace (whitespace matters in values) - this.lexer.next_token_fast(false) - - // Stop if we've reached the end of the value - if (this.lexer.token_start >= this.value_end) break - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - - // Skip whitespace tokens (they're separators, not value nodes) - if (this.is_whitespace_inline()) { - continue - } - - // Parse this token into a value node (token_type already cached in lexer.token_type) - let node = this.parse_value_node() - if (node !== null) { - if (first_node === 0) { - first_node = node - } else { - this.arena.set_next_sibling(last_node, node) - } - last_node = node - } - } - - // Wrap in VALUE node if (first_node === 0) { // Empty value - create VALUE node with no children - let value_node = this.arena.create_node(VALUE, start, 0, start_line, start_column) - return value_node + return this.arena.create_node(VALUE, start, 0, start_line, start_column) } + let last_node = this.nodes.last_chain_node + // Create VALUE wrapper node spanning all value tokens let first_node_start = this.arena.get_start_offset(first_node) let last_node_end = this.arena.get_start_offset(last_node) + this.arena.get_length(last_node) @@ -120,297 +43,6 @@ export class ValueParser { return value_node } - - // Helper to check if token is all whitespace (inline for hot paths) - private is_whitespace_inline(): boolean { - if (this.lexer.token_start >= this.lexer.token_end) return false - for (let i = this.lexer.token_start; i < this.lexer.token_end; i++) { - if (!is_whitespace(this.source.charCodeAt(i))) { - return false - } - } - return true - } - - private parse_value_node(): number | null { - let token_type = this.lexer.token_type - let start = this.lexer.token_start - let end = this.lexer.token_end - - switch (token_type) { - case TOKEN_IDENT: - return this.create_node(IDENTIFIER, start, end) - - case TOKEN_NUMBER: - return this.create_node(NUMBER, start, end) - - case TOKEN_PERCENTAGE: - case TOKEN_DIMENSION: - return this.create_node(DIMENSION, start, end) - - case TOKEN_STRING: - return this.create_node(STRING, start, end) - - case TOKEN_HASH: - return this.create_node(HASH, start, end) - - case TOKEN_UNICODE_RANGE: - return this.create_node(UNICODE_RANGE, start, end) - - case TOKEN_FUNCTION: - return this.parse_function_node(start, end) - - case TOKEN_DELIM: - return this.parse_operator_node(start, end) - - case TOKEN_COMMA: - return this.create_node(OPERATOR, start, end) - - case TOKEN_LEFT_PAREN: - return this.parse_parenthesis_node(start, end) - - default: - // Unknown token type, skip it - return null - } - } - - private create_node(node_type: number, start: number, end: number): number { - let node = this.arena.create_node( - node_type, - start, - end - start, - this.lexer.token_line, - this.lexer.token_column, - ) - // Skip set_content_start_delta since delta = start - start = 0 (already zero-initialized) - this.arena.set_content_length(node, end - start) - return node - } - - private create_operator_node(start: number, end: number): number { - return this.create_node(OPERATOR, start, end) - } - - private parse_operator_node(start: number, end: number): number | null { - // Only create operator nodes for specific delimiters: + - * / - let ch = this.source.charCodeAt(start) - if ( - ch === CHAR_PLUS || - ch === CHAR_MINUS_HYPHEN || - ch === CHAR_ASTERISK || - ch === CHAR_FORWARD_SLASH - ) { - return this.create_operator_node(start, end) - } - // Other delimiters are ignored for now - return null - } - - private parse_function_node(start: number, end: number): number { - // Function name is everything before the '(' - // The lexer's TOKEN_FUNCTION includes the '(' at the end - let name_end = end - 1 // Exclude the '(' - - // Get function name to check for special handling - let func_name_substr = this.source.substring(start, name_end) - - // Create URL or function node based on function name (length will be set later) - let node = this.arena.create_node( - str_equals('url', func_name_substr) ? URL : FUNCTION, - start, - 0, // length unknown yet - this.lexer.token_line, - this.lexer.token_column, - ) - this.arena.set_content_start_delta(node, 0) - this.arena.set_content_length(node, name_end - start) - - // Special handling for url() and src() functions with unquoted content: - // Don't parse contents to preserve URLs with dots, base64, inline SVGs, etc. - // Users can extract the full URL from the function's text property - // Note: Quoted urls like url("...") or url('...') parse normally - if (str_equals('url', func_name_substr) || str_equals('src', func_name_substr)) { - // 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() - this.lexer.next_token_fast(false) - - // Skip whitespace - while (this.is_whitespace_inline() && this.lexer.pos < this.value_end) { - this.lexer.next_token_fast(false) - } - - let first_token_type = this.lexer.token_type - - // Restore lexer position - this.lexer.restore_position(save_pos) - - // If the first non-whitespace token is a string, parse normally - if (first_token_type === TOKEN_STRING) { - // Fall through to normal parsing below - } else { - // Unquoted URL - don't parse children - // Note: We can't rely on value_end because URLs may contain semicolons - // that confuse the declaration parser (e.g., data:image/png;base64,...) - // So we consume tokens until we find the matching ')' regardless of value_end - let paren_depth = 1 - let func_end = end - let content_start = end // Position after 'url(' - let content_end = end - - // Just consume tokens until we find the matching ')' - // Don't create child nodes - while (paren_depth > 0) { - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - - // Track parentheses depth - if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { - paren_depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start // Position of ')' - func_end = this.lexer.token_end - break - } - } - } - - // Set function total length (includes opening and closing parens) - this.arena.set_length(node, func_end - start) - - // Set value to the content between parentheses (accessible via node.value) - this.arena.set_value_start_delta(node, content_start - start) - this.arena.set_value_length(node, content_end - content_start) - - return node - } - } - - // Parse function arguments (everything until matching ')'), chained as siblings - // without an intermediate array (single-argument calls like var(--x) are common) - let first_arg = 0 - let last_arg = 0 - let paren_depth = 1 - let func_end = end - let content_start = end // Position after function name and '(' - let content_end = end - - while (this.lexer.pos < this.value_end && paren_depth > 0) { - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - if (this.lexer.token_start >= this.value_end) break - - // Check for closing paren - // Note: We don't track paren_depth for TOKEN_LEFT_PAREN or TOKEN_FUNCTION here - // because parse_value_node() will recursively handle them - if (token_type === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start // Position of ')' - func_end = this.lexer.token_end - break - } - } - - // Skip whitespace - if (this.is_whitespace_inline()) continue - - // Parse argument node - let arg_node = this.parse_value_node() - if (arg_node !== null) { - if (first_arg === 0) { - first_arg = arg_node - } else { - this.arena.set_next_sibling(last_arg, arg_node) - } - last_arg = arg_node - } - } - - // Set function total length - this.arena.set_length(node, func_end - start) - - // Set value to the content between parentheses (accessible via node.value) - this.arena.set_value_start_delta(node, content_start - start) - this.arena.set_value_length(node, content_end - content_start) - - // Link arguments as children - if (first_arg !== 0) { - this.arena.set_first_child(node, first_arg) - } - - return node - } - - private parse_parenthesis_node(start: number, end: number): number { - // Create parenthesis node (length will be set later) - let node = this.arena.create_node( - PARENTHESIS, - start, - 0, // length unknown yet - this.lexer.token_line, - this.lexer.token_column, - ) - - // Parse parenthesized content (everything until matching ')'), chained as siblings - // without an intermediate array - let first_child = 0 - let last_child = 0 - let paren_depth = 1 - let paren_end = end - - while (this.lexer.pos < this.value_end && paren_depth > 0) { - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - if (this.lexer.token_start >= this.value_end) break - - // Check for closing paren BEFORE parsing child nodes - // This is important because child nodes (like nested parentheses or functions) - // will consume tokens including closing parens - if (token_type === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - paren_end = this.lexer.token_end - break - } - } - - // Skip whitespace - if (this.is_whitespace_inline()) continue - - // Parse child node - // Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here - // because parse_value_node() will recursively handle them - let child_node = this.parse_value_node() - if (child_node !== null) { - if (first_child === 0) { - first_child = child_node - } else { - this.arena.set_next_sibling(last_child, child_node) - } - last_child = child_node - } - } - - // Set parenthesis total length (includes opening and closing parens) - this.arena.set_length(node, paren_end - start) - - // Link children as siblings - if (first_child !== 0) { - this.arena.set_first_child(node, first_child) - } - - return node - } } /** diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts new file mode 100644 index 0000000..e1b9124 --- /dev/null +++ b/src/value-node-parser.ts @@ -0,0 +1,392 @@ +// Value Node Parser - Shared recursive parsing of value-shaped token streams. +// Used by both ValueParser (declaration values) and AtRulePreludeParser (feature +// values inside media/container/supports conditions), so calc(), env(), var(), +// and other functions get the same structured tree — Number/Operator/Dimension +// children, not just an opaque text span — regardless of where they appear. +import { Lexer } from './tokenize' +import { + CSSDataArena, + IDENTIFIER, + NUMBER, + DIMENSION, + STRING, + HASH, + FUNCTION, + OPERATOR, + PARENTHESIS, + URL, + UNICODE_RANGE, +} from './arena' +import { + TOKEN_IDENT, + TOKEN_NUMBER, + TOKEN_PERCENTAGE, + TOKEN_DIMENSION, + TOKEN_STRING, + TOKEN_HASH, + TOKEN_FUNCTION, + TOKEN_DELIM, + TOKEN_COMMA, + TOKEN_EOF, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_UNICODE_RANGE, +} from './token-types' +import { + is_whitespace, + CHAR_MINUS_HYPHEN, + CHAR_PLUS, + CHAR_ASTERISK, + CHAR_FORWARD_SLASH, + str_equals, +} from './string-utils' + +/** @internal */ +export class ValueNodeParser { + protected lexer: Lexer + protected arena: CSSDataArena + protected source: string + protected end: number = 0 + // Last node produced by parse_chain(), for callers that need the chain's end offset + // (e.g. to size a wrapper node). Avoids returning a tuple/array from the hot path. + last_chain_node: number = 0 + + constructor(arena: CSSDataArena, source: string) { + this.arena = arena + this.source = source + this.lexer = new Lexer(source) + } + + // Parse a run of value tokens in [start, end) into typed nodes, chained as siblings + // without an intermediate array. Returns the first node (0 if none); the last node + // is left in `this.last_chain_node`. Used both for a full declaration value and for + // a sub-range excursion (e.g. a media feature's value) via a shared, independent lexer. + parse_chain(start: number, end: number, start_line: number, start_column: number): number { + this.end = end + this.lexer.seek(start, start_line, start_column) + + let first_node = 0 + let last_node = 0 + + while (this.lexer.pos < this.end) { + // Get next token without skipping whitespace (whitespace matters in values) + this.lexer.next_token_fast(false) + + // Stop if we've reached the end of the range + if (this.lexer.token_start >= this.end) break + + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + + // Skip whitespace tokens (they're separators, not value nodes) + if (this.is_whitespace_inline()) { + continue + } + + // Parse this token into a value node (token_type already cached in lexer.token_type) + let node = this.parse_value_node() + if (node !== null) { + if (first_node === 0) { + first_node = node + } else { + this.arena.set_next_sibling(last_node, node) + } + last_node = node + } + } + + this.last_chain_node = last_node + return first_node + } + + // Helper to check if token is all whitespace (inline for hot paths) + private is_whitespace_inline(): boolean { + if (this.lexer.token_start >= this.lexer.token_end) return false + for (let i = this.lexer.token_start; i < this.lexer.token_end; i++) { + if (!is_whitespace(this.source.charCodeAt(i))) { + return false + } + } + return true + } + + private parse_value_node(): number | null { + let token_type = this.lexer.token_type + let start = this.lexer.token_start + let end = this.lexer.token_end + + switch (token_type) { + case TOKEN_IDENT: + return this.create_node(IDENTIFIER, start, end) + + case TOKEN_NUMBER: + return this.create_node(NUMBER, start, end) + + case TOKEN_PERCENTAGE: + case TOKEN_DIMENSION: + return this.create_node(DIMENSION, start, end) + + case TOKEN_STRING: + return this.create_node(STRING, start, end) + + case TOKEN_HASH: + return this.create_node(HASH, start, end) + + case TOKEN_UNICODE_RANGE: + return this.create_node(UNICODE_RANGE, start, end) + + case TOKEN_FUNCTION: + return this.parse_function_node(start, end) + + case TOKEN_DELIM: + return this.parse_operator_node(start, end) + + case TOKEN_COMMA: + return this.create_node(OPERATOR, start, end) + + case TOKEN_LEFT_PAREN: + return this.parse_parenthesis_node(start, end) + + default: + // Unknown token type, skip it + return null + } + } + + private create_node(node_type: number, start: number, end: number): number { + let node = this.arena.create_node( + node_type, + start, + end - start, + this.lexer.token_line, + this.lexer.token_column, + ) + // Skip set_content_start_delta since delta = start - start = 0 (already zero-initialized) + this.arena.set_content_length(node, end - start) + return node + } + + private create_operator_node(start: number, end: number): number { + return this.create_node(OPERATOR, start, end) + } + + private parse_operator_node(start: number, end: number): number | null { + // Only create operator nodes for specific delimiters: + - * / + let ch = this.source.charCodeAt(start) + if ( + ch === CHAR_PLUS || + ch === CHAR_MINUS_HYPHEN || + ch === CHAR_ASTERISK || + ch === CHAR_FORWARD_SLASH + ) { + return this.create_operator_node(start, end) + } + // Other delimiters are ignored for now + return null + } + + private parse_function_node(start: number, end: number): number { + // Function name is everything before the '(' + // The lexer's TOKEN_FUNCTION includes the '(' at the end + let name_end = end - 1 // Exclude the '(' + + // Get function name to check for special handling + let func_name_substr = this.source.substring(start, name_end) + + // Create URL or function node based on function name (length will be set later) + let node = this.arena.create_node( + str_equals('url', func_name_substr) ? URL : FUNCTION, + start, + 0, // length unknown yet + this.lexer.token_line, + this.lexer.token_column, + ) + this.arena.set_content_start_delta(node, 0) + this.arena.set_content_length(node, name_end - start) + + // Special handling for url() and src() functions with unquoted content: + // Don't parse contents to preserve URLs with dots, base64, inline SVGs, etc. + // Users can extract the full URL from the function's text property + // Note: Quoted urls like url("...") or url('...') parse normally + if (str_equals('url', func_name_substr) || str_equals('src', func_name_substr)) { + // 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() + this.lexer.next_token_fast(false) + + // Skip whitespace + while (this.is_whitespace_inline() && this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + } + + let first_token_type = this.lexer.token_type + + // Restore lexer position + this.lexer.restore_position(save_pos) + + // If the first non-whitespace token is a string, parse normally + if (first_token_type === TOKEN_STRING) { + // Fall through to normal parsing below + } else { + // Unquoted URL - don't parse children + // Note: We can't rely on `end` because URLs may contain semicolons + // that confuse the declaration parser (e.g., data:image/png;base64,...) + // So we consume tokens until we find the matching ')' regardless of `end` + let paren_depth = 1 + let func_end = end + let content_start = end // Position after 'url(' + let content_end = end + + // Just consume tokens until we find the matching ')' + // Don't create child nodes + while (paren_depth > 0) { + this.lexer.next_token_fast(false) + + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + + // Track parentheses depth + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { + paren_depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + content_end = this.lexer.token_start // Position of ')' + func_end = this.lexer.token_end + break + } + } + } + + // Set function total length (includes opening and closing parens) + this.arena.set_length(node, func_end - start) + + // Set value to the content between parentheses (accessible via node.value) + this.arena.set_value_start_delta(node, content_start - start) + this.arena.set_value_length(node, content_end - content_start) + + return node + } + } + + // Parse function arguments (everything until matching ')'), chained as siblings + // without an intermediate array (single-argument calls like var(--x) are common) + let first_arg = 0 + let last_arg = 0 + let paren_depth = 1 + let func_end = end + let content_start = end // Position after function name and '(' + let content_end = end + + while (this.lexer.pos < this.end && paren_depth > 0) { + this.lexer.next_token_fast(false) + + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + + // Check for closing paren + // Note: We don't track paren_depth for TOKEN_LEFT_PAREN or TOKEN_FUNCTION here + // because parse_value_node() will recursively handle them + if (token_type === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + content_end = this.lexer.token_start // Position of ')' + func_end = this.lexer.token_end + break + } + } + + // Skip whitespace + if (this.is_whitespace_inline()) continue + + // Parse argument node + let arg_node = this.parse_value_node() + if (arg_node !== null) { + if (first_arg === 0) { + first_arg = arg_node + } else { + this.arena.set_next_sibling(last_arg, arg_node) + } + last_arg = arg_node + } + } + + // Set function total length + this.arena.set_length(node, func_end - start) + + // Set value to the content between parentheses (accessible via node.value) + this.arena.set_value_start_delta(node, content_start - start) + this.arena.set_value_length(node, content_end - content_start) + + // Link arguments as children + if (first_arg !== 0) { + this.arena.set_first_child(node, first_arg) + } + + return node + } + + private parse_parenthesis_node(start: number, end: number): number { + // Create parenthesis node (length will be set later) + let node = this.arena.create_node( + PARENTHESIS, + start, + 0, // length unknown yet + this.lexer.token_line, + this.lexer.token_column, + ) + + // Parse parenthesized content (everything until matching ')'), chained as siblings + // without an intermediate array + let first_child = 0 + let last_child = 0 + let paren_depth = 1 + let paren_end = end + + while (this.lexer.pos < this.end && paren_depth > 0) { + this.lexer.next_token_fast(false) + + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + + // Check for closing paren BEFORE parsing child nodes + // This is important because child nodes (like nested parentheses or functions) + // will consume tokens including closing parens + if (token_type === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + paren_end = this.lexer.token_end + break + } + } + + // Skip whitespace + if (this.is_whitespace_inline()) continue + + // Parse child node + // Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here + // because parse_value_node() will recursively handle them + let child_node = this.parse_value_node() + if (child_node !== null) { + if (first_child === 0) { + first_child = child_node + } else { + this.arena.set_next_sibling(last_child, child_node) + } + last_child = child_node + } + } + + // Set parenthesis total length (includes opening and closing parens) + this.arena.set_length(node, paren_end - start) + + // Link children as siblings + if (first_child !== 0) { + this.arena.set_first_child(node, first_child) + } + + return node + } +}