diff --git a/src/css-node.ts b/src/css-node.ts index e2dac57..6544a3d 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -70,7 +70,7 @@ import { str_starts_with, str_equals, } from './string-utils' -import { parse_dimension } from './parse-dimension' +import { dimension_number_end } from './parse-dimension' // Type name lookup table - maps numeric type to CSSTree-compatible strings export const TYPE_NAMES = { @@ -401,7 +401,10 @@ export class CSSNode { } if (type === DIMENSION) { - return parse_dimension(text).value + // Slice only the numeric part — avoids parse_dimension's extra unit substring + object + let num_str = text.substring(0, dimension_number_end(text)) + // oxlint-disable-next-line prefer-number-coercion + return num_str ? Number.parseFloat(num_str) : 0 } if (type === NUMBER) { @@ -523,7 +526,8 @@ export class CSSNode { /** Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%") */ get unit(): string | undefined { if (this.type !== DIMENSION) return undefined - return parse_dimension(this.text).unit + let text = this.text + return text.substring(dimension_number_end(text)) } /** Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` */ diff --git a/src/parse-dimension.ts b/src/parse-dimension.ts index cf8f47f..059dd41 100644 --- a/src/parse-dimension.ts +++ b/src/parse-dimension.ts @@ -1,8 +1,13 @@ import { is_digit, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_PERIOD } from './string-utils' -/** Parse a dimension string into value and unit, e.g. "100px" → { value: 100, unit: "px" } or "2%" → { value: 2, unit: "%" } */ -export function parse_dimension(text: string): { value: number; unit: string } { - // Find where the numeric part ends +/** + * Find where a dimension's numeric part ends, e.g. 3 for "100px" or 2 for "2%". + * No allocation — callers that only need `value` or only `unit` can slice just + * the part they want instead of going through `parse_dimension`'s object + two + * substrings for both. + * @internal + */ +export function dimension_number_end(text: string): number { let num_end = 0 for (let i = 0; i < text.length; i++) { let ch = text.charCodeAt(i) @@ -39,6 +44,12 @@ export function parse_dimension(text: string): { value: number; unit: string } { } } + return num_end +} + +/** Parse a dimension string into value and unit, e.g. "100px" → { value: 100, unit: "px" } or "2%" → { value: 2, unit: "%" } */ +export function parse_dimension(text: string): { value: number; unit: string } { + let num_end = dimension_number_end(text) let num_str = text.substring(0, num_end) let unit = text.substring(num_end) // oxlint-disable-next-line prefer-number-coercion diff --git a/src/parse-selector.ts b/src/parse-selector.ts index a51ec23..d62419b 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -43,7 +43,7 @@ import { } from './parse-utils' import { is_whitespace, - str_equals, + str_equals_range, CHAR_PLUS, CHAR_TILDE, CHAR_GREATER_THAN, @@ -791,16 +791,15 @@ export class SelectorParser { // Parse the content inside the parentheses if (content_end > content_start) { - // Check if this is an nth-* pseudo-class - let func_name_substr = this.source.substring(func_name_start, func_name_end) - - if (this.is_nth_pseudo(func_name_substr)) { + // Check if this is an nth-* pseudo-class (offset-based — avoids allocating a + // substring for every parenthesized pseudo-class, e.g. :not(), :is(), :nth-child()) + if (this.is_nth_pseudo(func_name_start, func_name_end)) { // Parse as An+B expression let child = this.parse_nth_expression(content_start, content_end) if (child !== null) { this.arena.set_first_child(node, child) } - } else if (str_equals('lang', func_name_substr)) { + } else if (str_equals_range(this.source, func_name_start, func_name_end, 'lang')) { // Parse as :lang() - comma-separated language identifiers this.parse_lang_identifiers(content_start, content_end, node) } else { @@ -811,7 +810,7 @@ export class SelectorParser { // Recursively parse the content as a selector // Only :has() accepts relative selectors (starting with combinator) - let allow_relative = str_equals('has', func_name_substr) + let allow_relative = str_equals_range(this.source, func_name_start, func_name_end, 'has') let child_selector = this.parse_selector( content_start, content_end, @@ -834,15 +833,15 @@ export class SelectorParser { return node } - // Check if pseudo-class name is an nth-* pseudo - private is_nth_pseudo(name: string): boolean { + // Check if pseudo-class name (source[start, end)) is an nth-* pseudo + private is_nth_pseudo(start: number, end: number): boolean { return ( - str_equals('nth-child', name) || - str_equals('nth-last-child', name) || - str_equals('nth-of-type', name) || - str_equals('nth-last-of-type', name) || - str_equals('nth-col', name) || - str_equals('nth-last-col', name) + str_equals_range(this.source, start, end, 'nth-child') || + str_equals_range(this.source, start, end, 'nth-last-child') || + str_equals_range(this.source, start, end, 'nth-of-type') || + str_equals_range(this.source, start, end, 'nth-last-of-type') || + str_equals_range(this.source, start, end, 'nth-col') || + str_equals_range(this.source, start, end, 'nth-last-col') ) } diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 1588c24..d2b6912 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1013,6 +1013,29 @@ describe('Value Node Types', () => { expect((decl!.first_child! as Value).children[0].text).toBe('red') expect(decl?.is_important).toBe(true) }) + + test('should skip comments between value tokens', () => { + const root = parse('body { margin: 10px /* comment */ 20px; }') + const decl = root.first_child?.first_child?.next_sibling?.first_child + + const children = (decl!.first_child! as Value).children + expect(children).toHaveLength(2) + expect(children[0].type).toBe(DIMENSION) + expect(children[0].text).toBe('10px') + expect(children[1].type).toBe(DIMENSION) + expect(children[1].text).toBe('20px') + }) + + test('should skip comments between function arguments', () => { + const root = parse('body { color: rgb(255 /* red */, 0, /* green */ 0); }') + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = (decl!.first_child! as Value).children[0] as Function + + expect(func.type).toBe(FUNCTION) + const args = func.children + expect(args.map((n) => n.type)).toEqual([NUMBER, OPERATOR, NUMBER, OPERATOR, NUMBER]) + expect(args.map((n) => n.text)).toEqual(['255', ',', '0', ',', '0']) + }) }) }) diff --git a/src/string-utils.ts b/src/string-utils.ts index d6b04f4..616b02c 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -77,6 +77,34 @@ export function str_equals(a: string, b: string): boolean { return true } +/** + * Case-insensitive equality between `source[start, end)` and `literal`, without allocating + * a substring first. Same semantics as `str_equals(literal, source.substring(start, end))`. + * `literal` MUST be lowercase. + */ +export function str_equals_range( + source: string, + start: number, + end: number, + literal: string, +): boolean { + if (end - start !== literal.length) { + return false + } + + for (let i = 0; i < literal.length; i++) { + let ch = source.charCodeAt(start + i) + // normalize ASCII uppercase A-Z → a-z + ch |= 32 + + if (ch !== literal.charCodeAt(i)) { + return false + } + } + + return true +} + /** 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/value-node-parser.ts b/src/value-node-parser.ts index bf77f68..2cf780b 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -35,15 +35,16 @@ import { TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, TOKEN_UNICODE_RANGE, + TOKEN_WHITESPACE, type TokenType, } from './token-types' import { - is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH, str_equals, + str_equals_range, } from './string-utils' import { ConditionParser } from './parse-condition' @@ -103,7 +104,7 @@ export class ValueNodeParser { if (token_type === TOKEN_EOF) break // Skip whitespace tokens (they're separators, not value nodes) - if (this.is_whitespace_inline()) { + if (token_type === TOKEN_WHITESPACE) { continue } @@ -123,17 +124,6 @@ export class ValueNodeParser { 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 @@ -248,17 +238,20 @@ export class ValueNodeParser { // 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) + // Check function name for special handling without allocating a substring — most + // function calls (calc(), var(), rgb(), translate(), ...) aren't any of these, and + // this runs once per FUNCTION token, one of the most common tokens in a value. // Dispatch to dedicated parser for if() - if (str_equals('if', func_name_substr)) { + if (str_equals_range(this.source, start, name_end, 'if')) { return this.parse_if_function_node(start, end) } + let is_url = str_equals_range(this.source, start, name_end, 'url') + // 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, + is_url ? URL : FUNCTION, start, 0, // length unknown yet this.lexer.token_line, @@ -271,14 +264,14 @@ export class ValueNodeParser { // 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)) { + 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() this.lexer.next_token_fast(false) // Skip whitespace - while (this.is_whitespace_inline() && this.lexer.pos < this.end) { + while (this.lexer.token_type === TOKEN_WHITESPACE && this.lexer.pos < this.end) { this.lexer.next_token_fast(false) } @@ -345,7 +338,7 @@ export class ValueNodeParser { } // Skip whitespace - if (this.is_whitespace_inline()) continue + if (token_type === TOKEN_WHITESPACE) continue // Parse argument node let arg_node = this.parse_value_node() @@ -414,7 +407,7 @@ export class ValueNodeParser { } // Skip whitespace and any stray separators between branches - if (this.is_whitespace_inline() || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue + if (tt === TOKEN_WHITESPACE || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue // ── Condition ────────────────────────────────────────────────────── let branch_start = this.lexer.token_start @@ -434,7 +427,7 @@ export class ValueNodeParser { let t = this.lexer.token_type if (t === TOKEN_EOF) break if (this.lexer.token_start >= this.end) break - if (this.is_whitespace_inline()) continue + if (t === TOKEN_WHITESPACE) continue if (t === TOKEN_COLON) { colon_found = true break @@ -462,7 +455,7 @@ export class ValueNodeParser { let t = this.lexer.token_type if (t === TOKEN_EOF) break if (this.lexer.token_start >= this.end) break - if (this.is_whitespace_inline()) continue + if (t === TOKEN_WHITESPACE) continue if (t === TOKEN_SEMICOLON) break // end of this branch @@ -543,7 +536,7 @@ export class ValueNodeParser { while (this.lexer.pos < this.end) { this.lexer.next_token_fast(false) if (this.lexer.token_start >= this.end) return TOKEN_EOF - if (this.is_whitespace_inline()) continue + if (this.lexer.token_type === TOKEN_WHITESPACE) continue return this.lexer.token_type } return TOKEN_EOF @@ -763,7 +756,7 @@ export class ValueNodeParser { if (this.lexer.token_start >= this.end) break let token_type = this.lexer.token_type if (token_type === TOKEN_EOF) break - if (this.is_whitespace_inline()) continue + if (token_type === TOKEN_WHITESPACE) continue let node = this.parse_value_node() if (node !== null) nodes.push(node) } @@ -810,7 +803,7 @@ export class ValueNodeParser { } // Skip whitespace - if (this.is_whitespace_inline()) continue + if (token_type === TOKEN_WHITESPACE) continue // Parse child node // Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here