From ae6c06df33f672d42d28dc9570dbe01220040ac5 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 9 Dec 2025 23:16:00 +0100 Subject: [PATCH 1/6] perf: selector parse improvements --- src/parse-selector.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 26a0eff..6bbc900 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -61,6 +61,10 @@ import { CHAR_SINGLE_QUOTE, CHAR_DOUBLE_QUOTE, CHAR_COLON, + CHAR_CARRIAGE_RETURN, + CHAR_FORM_FEED, + CHAR_NEWLINE, + CHAR_SPACE, } from './string-utils' import { ANplusBParser } from './parse-anplusb' import { CSSNode } from './css-node' @@ -113,8 +117,10 @@ export class SelectorParser { // Find the last component in the chain let last_component = complex_selector - while (this.arena.get_next_sibling(last_component) !== 0) { - last_component = this.arena.get_next_sibling(last_component) + let next_sibling = this.arena.get_next_sibling(last_component) + while (next_sibling !== 0) { + last_component = next_sibling + next_sibling = this.arena.get_next_sibling(last_component) } // Set the complex selector chain as children @@ -199,8 +205,6 @@ export class SelectorParser { } while (this.lexer.pos < this.selector_end) { - if (this.lexer.pos >= this.selector_end) break - // Parse compound selector first let compound = this.parse_compound_selector() if (compound !== null) { @@ -347,11 +351,7 @@ export class SelectorParser { // Parse the local part after | in a namespace selector (E or *) // Returns the node type (TYPE or UNIVERSAL) or null if invalid - private parse_namespace_local_part( - selector_start: number, - namespace_start: number, - namespace_length: number, - ): number | null { + private parse_namespace_local_part(selector_start: number, namespace_start: number, namespace_length: number): number | null { const saved = this.lexer.save_position() this.lexer.next_token_fast(false) @@ -359,10 +359,7 @@ export class SelectorParser { if (this.lexer.token_type === TOKEN_IDENT) { // ns|type node_type = NODE_SELECTOR_TYPE - } else if ( - this.lexer.token_type === TOKEN_DELIM && - this.source.charCodeAt(this.lexer.token_start) === CHAR_ASTERISK - ) { + } else if (this.lexer.token_type === TOKEN_DELIM && this.source.charCodeAt(this.lexer.token_start) === CHAR_ASTERISK) { // ns|* node_type = NODE_SELECTOR_UNIVERSAL } else { @@ -425,7 +422,8 @@ export class SelectorParser { // Skip whitespace and check for combinator while (this.lexer.pos < this.selector_end) { let ch = this.source.charCodeAt(this.lexer.pos) - if (is_whitespace(ch)) { + // no calling is_whitespace() because of function call overhead + if (ch === CHAR_SPACE || ch === CHAR_NEWLINE || ch === CHAR_CARRIAGE_RETURN || ch === CHAR_FORM_FEED) { has_whitespace = true this.lexer.pos++ } else { @@ -452,7 +450,8 @@ export class SelectorParser { this.lexer.pos = whitespace_start while (this.lexer.pos < this.selector_end) { let ch = this.source.charCodeAt(this.lexer.pos) - if (is_whitespace(ch)) { + // no calling is_whitespace() because of function call overhead + if (ch === CHAR_SPACE || ch === CHAR_NEWLINE || ch === CHAR_CARRIAGE_RETURN || ch === CHAR_FORM_FEED) { this.lexer.pos++ } else { break @@ -565,28 +564,29 @@ export class SelectorParser { // Parse operator let ch1 = this.source.charCodeAt(pos) + let ch2 = pos + 1 < end ? this.source.charCodeAt(pos + 1) : 0 // Cache second character to avoid repeated calls if (ch1 === CHAR_EQUALS) { // = operator_end = pos + 1 this.arena.set_attr_operator(node, ATTR_OPERATOR_EQUAL) - } else if (ch1 === CHAR_TILDE && pos + 1 < end && this.source.charCodeAt(pos + 1) === CHAR_EQUALS) { + } else if (ch1 === CHAR_TILDE && ch2 === CHAR_EQUALS) { // ~= operator_end = pos + 2 this.arena.set_attr_operator(node, ATTR_OPERATOR_TILDE_EQUAL) - } else if (ch1 === CHAR_PIPE && pos + 1 < end && this.source.charCodeAt(pos + 1) === CHAR_EQUALS) { + } else if (ch1 === CHAR_PIPE && ch2 === CHAR_EQUALS) { // |= operator_end = pos + 2 this.arena.set_attr_operator(node, ATTR_OPERATOR_PIPE_EQUAL) - } else if (ch1 === CHAR_CARET && pos + 1 < end && this.source.charCodeAt(pos + 1) === CHAR_EQUALS) { + } else if (ch1 === CHAR_CARET && ch2 === CHAR_EQUALS) { // ^= operator_end = pos + 2 this.arena.set_attr_operator(node, ATTR_OPERATOR_CARET_EQUAL) - } else if (ch1 === CHAR_DOLLAR && pos + 1 < end && this.source.charCodeAt(pos + 1) === CHAR_EQUALS) { + } else if (ch1 === CHAR_DOLLAR && ch2 === CHAR_EQUALS) { // $= operator_end = pos + 2 this.arena.set_attr_operator(node, ATTR_OPERATOR_DOLLAR_EQUAL) - } else if (ch1 === CHAR_ASTERISK && pos + 1 < end && this.source.charCodeAt(pos + 1) === CHAR_EQUALS) { + } else if (ch1 === CHAR_ASTERISK && ch2 === CHAR_EQUALS) { // *= operator_end = pos + 2 this.arena.set_attr_operator(node, ATTR_OPERATOR_STAR_EQUAL) From 26b4962bcd75fad05f90d66ad3c2800d2a0030fb Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 9 Dec 2025 23:23:46 +0100 Subject: [PATCH 2/6] perf: reduce lexer calls in parse.ts --- src/parse.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index bd8fa51..6756972 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -400,9 +400,9 @@ export class Parser { let at_rule_column = this.lexer.token_column // Extract at-rule name (skip the '@') - let at_rule_name = this.source.substring(this.lexer.token_start + 1, this.lexer.token_end) let name_start = this.lexer.token_start + 1 - let name_length = at_rule_name.length + let name_length = this.lexer.token_end - name_start + let at_rule_name = this.source.substring(name_start, this.lexer.token_end) this.next_token() // consume @keyword @@ -422,7 +422,9 @@ export class Parser { let prelude_end = prelude_start // Parse prelude (everything before '{' or ';') - while (!this.is_eof() && this.peek_type() !== TOKEN_LEFT_BRACE && this.peek_type() !== TOKEN_SEMICOLON) { + while (!this.is_eof()) { + let token_type = this.peek_type() + if (token_type === TOKEN_LEFT_BRACE || token_type === TOKEN_SEMICOLON) break prelude_end = this.lexer.token_end this.next_token() } From 76e1890672f6101578a7bcc803962fdaa11767c5 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 9 Dec 2025 23:33:12 +0100 Subject: [PATCH 3/6] perf: improvements in parse-value.ts --- src/parse-value.ts | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/parse-value.ts b/src/parse-value.ts index 1d3a431..d94aca7 100644 --- a/src/parse-value.ts +++ b/src/parse-value.ts @@ -65,7 +65,7 @@ export class ValueParser { if (token_type === TOKEN_EOF) break // Skip whitespace tokens (they're separators, not value nodes) - if (this.is_whitespace_token()) { + if (this.is_whitespace_inline()) { continue } @@ -79,16 +79,11 @@ export class ValueParser { return nodes } - private is_whitespace_token(): boolean { - // Whitespace is implicit between tokens, we don't create nodes for it - let start = this.lexer.token_start - let end = this.lexer.token_end - if (start >= end) return false - - // Check if all characters are whitespace - for (let i = start; i < end; i++) { - let ch = this.source.charCodeAt(i) - if (!is_whitespace(ch)) { + // 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 } } @@ -139,9 +134,10 @@ export class ValueParser { let node = this.arena.create_node() this.arena.set_type(node, node_type) this.arena.set_start_offset(node, start) - this.arena.set_length(node, end - start) - this.arena.set_content_start(node, start) - this.arena.set_content_length(node, end - start) + let length = end - start + this.arena.set_length(node, length) + // Skip set_content_start since it would compute delta = start - start = 0 (already zero-initialized) + this.arena.set_content_length(node, length) return node } @@ -185,7 +181,7 @@ export class ValueParser { this.lexer.next_token_fast(false) // Skip whitespace - while (this.is_whitespace_token() && this.lexer.pos < this.value_end) { + while (this.is_whitespace_inline() && this.lexer.pos < this.value_end) { this.lexer.next_token_fast(false) } @@ -266,7 +262,7 @@ export class ValueParser { } // Skip whitespace - if (this.is_whitespace_token()) continue + if (this.is_whitespace_inline()) continue // Parse argument node let arg_node = this.parse_value_node() @@ -326,7 +322,7 @@ export class ValueParser { } // Skip whitespace - if (this.is_whitespace_token()) continue + if (this.is_whitespace_inline()) continue // Parse child node // Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here From 11867a98f2e20c80ec491c9fff693094eaebef3c Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 9 Dec 2025 23:39:01 +0100 Subject: [PATCH 4/6] perf: rm dead code from parse-atrule-prelude --- src/parse-atrule-prelude.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 31488b8..4c6c0e9 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -89,8 +89,11 @@ export class AtRulePreludeParser { // Skip comma separator this.skip_whitespace() - if (this.peek_token_type() === TOKEN_COMMA) { - this.next_token() // consume comma + const saved = this.lexer.save_position() + this.next_token() + if (this.lexer.token_type !== TOKEN_COMMA) { + // Not a comma, restore position + this.lexer.restore_position(saved) } } @@ -108,14 +111,14 @@ export class AtRulePreludeParser { } private is_and_or_not(str: string): boolean { - if (str.length > 3 || str.length < 2) return false + // 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) } // Parse a single media query: screen and (min-width: 768px) private parse_single_media_query(): number | null { let query_start = this.lexer.pos - let query_line = this.lexer.line // Skip whitespace this.skip_whitespace() @@ -191,7 +194,6 @@ export class AtRulePreludeParser { // Parse media feature: (min-width: 768px) private parse_media_feature(): number | null { let feature_start = this.lexer.token_start // '(' position - let feature_line = this.lexer.token_line // Find matching right paren let depth = 1 @@ -229,7 +231,6 @@ export class AtRulePreludeParser { private parse_container_query(): number[] { let nodes: number[] = [] let query_start = this.lexer.pos - let query_line = this.lexer.line // Parse components (identifiers, operators, features) let components: number[] = [] @@ -292,7 +293,6 @@ export class AtRulePreludeParser { // Feature query: (property: value) if (token_type === TOKEN_LEFT_PAREN) { let feature_start = this.lexer.token_start - let feature_line = this.lexer.token_line // Find matching right paren let depth = 1 @@ -438,7 +438,6 @@ export class AtRulePreludeParser { // For url() function, we need to consume all tokens until the closing paren let url_start = this.lexer.token_start let url_end = this.lexer.token_end - let url_line = this.lexer.token_line if (this.lexer.token_type === TOKEN_FUNCTION) { // It's url( ... we need to find the matching ) @@ -481,7 +480,6 @@ export class AtRulePreludeParser { if (str_equals('layer', text)) { let layer_start = this.lexer.token_start let layer_end = this.lexer.token_end - let layer_line = this.lexer.token_line let content_start = 0 let content_length = 0 @@ -539,7 +537,6 @@ export class AtRulePreludeParser { let text = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' if (str_equals('supports', text)) { let supports_start = this.lexer.token_start - let supports_line = this.lexer.token_line // Find matching closing parenthesis let paren_depth = 1 From cb3637f8231a4de9f0a69b41babc97236d72414d Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 9 Dec 2025 23:44:44 +0100 Subject: [PATCH 5/6] chore: small consistency issues --- src/parse-utils.ts | 20 ++++++++++---------- src/string-utils.ts | 11 ++++++++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/parse-utils.ts b/src/parse-utils.ts index b5adcfd..b8a6b2a 100644 --- a/src/parse-utils.ts +++ b/src/parse-utils.ts @@ -14,7 +14,7 @@ import { CHAR_ASTERISK, CHAR_FORWARD_SLASH, is_whitespace, is_digit, CHAR_MINUS_ */ export function parse_dimension(text: string): { value: number; unit: string } { // Find where the numeric part ends - let numEnd = 0 + let num_end = 0 for (let i = 0; i < text.length; i++) { let ch = text.charCodeAt(i) @@ -23,17 +23,17 @@ export function parse_dimension(text: string): { value: number; unit: string } { // e or E // Only allow e/E if followed by digit or sign+digit if (i + 1 < text.length) { - let nextCh = text.charCodeAt(i + 1) + let next_ch = text.charCodeAt(i + 1) // Check if next is digit - if (is_digit(nextCh)) { - numEnd = i + 1 + if (is_digit(next_ch)) { + num_end = i + 1 continue } // Check if next is sign followed by digit - if ((nextCh === 0x2b || nextCh === 0x2d) && i + 2 < text.length) { + if ((next_ch === 0x2b || next_ch === 0x2d) && i + 2 < text.length) { let afterSign = text.charCodeAt(i + 2) if (is_digit(afterSign)) { - numEnd = i + 1 + num_end = i + 1 continue } } @@ -44,15 +44,15 @@ export function parse_dimension(text: string): { value: number; unit: string } { // Allow digits, dot, minus, plus if (is_digit(ch) || ch === CHAR_PERIOD || ch === CHAR_MINUS_HYPHEN || ch === CHAR_PLUS) { - numEnd = i + 1 + num_end = i + 1 } else { break } } - let numStr = text.substring(0, numEnd) - let unit = text.substring(numEnd) - let value = numStr ? parseFloat(numStr) : 0 + let num_str = text.substring(0, num_end) + let unit = text.substring(num_end) + let value = num_str ? parseFloat(num_str) : 0 return { value, unit } } diff --git a/src/string-utils.ts b/src/string-utils.ts index c3a1203..e156cc4 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -30,7 +30,7 @@ export function is_whitespace(ch: number): boolean { } export function is_combinator(ch: number): boolean { - return ch === CHAR_GREATER_THAN || ch === CHAR_PLUS || ch == CHAR_TILDE + return ch === CHAR_GREATER_THAN || ch === CHAR_PLUS || ch === CHAR_TILDE } export function is_digit(ch: number): boolean { @@ -101,6 +101,11 @@ export function is_vendor_prefixed(source: string, start: number, end: number): // Must have another hyphen after the vendor name // This identifies: -webkit-, -moz-, -ms-, -o- - let secondHyphenPos = source.indexOf('-', start + 2) - return secondHyphenPos !== -1 && secondHyphenPos < end + // Use bounded loop instead of unbounded indexOf() to only search within the range + for (let i = start + 2; i < end; i++) { + if (source.charCodeAt(i) === CHAR_MINUS_HYPHEN) { + return true + } + } + return false } From 997bed66e5d66affe58f48f5b60d372aea055e32 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 10 Dec 2025 00:04:24 +0100 Subject: [PATCH 6/6] perf: avoid string allocations in nth parser --- src/parse-anplusb.ts | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index eb26b0d..7f690eb 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -33,7 +33,6 @@ export class ANplusBParser { this.lexer.pos = start this.lexer.line = line - let a: string | null = null let b: string | null = null let a_start = start let a_end = start @@ -56,11 +55,9 @@ export class ANplusBParser { const text = this.source.substring(this.lexer.token_start, this.lexer.token_end).toLowerCase() if (text === 'odd' || text === 'even') { - // Store the keyword as authored - a = this.source.substring(this.lexer.token_start, this.lexer.token_end) a_start = this.lexer.token_start a_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, null, a_start, a_end, 0, 0) + return this.create_anplusb_node(node_start, a_start, a_end, 0, 0) } // Check if it's 'n', '-n', or starts with 'n' @@ -74,18 +71,15 @@ export class ANplusBParser { const third_char = this.source.charCodeAt(this.lexer.token_start + 2) if (third_char === CHAR_MINUS_HYPHEN /* - */) { // -n-5 pattern - a = '-n' a_start = this.lexer.token_start a_end = this.lexer.token_start + 2 b = this.source.substring(this.lexer.token_start + 2, this.lexer.token_end) b_start = this.lexer.token_start + 2 b_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b_start, b_end) } } - // Store -n as authored - a = '-n' a_start = this.lexer.token_start a_end = this.lexer.token_start + 2 @@ -95,7 +89,7 @@ export class ANplusBParser { b_start = this.lexer.token_start b_end = this.lexer.token_end } - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b !== null ? b_start : 0, b !== null ? b_end : 0) } // n, n+3, n-5 @@ -105,18 +99,16 @@ export class ANplusBParser { const second_char = this.source.charCodeAt(this.lexer.token_start + 1) if (second_char === CHAR_MINUS_HYPHEN /* - */) { // n-5 pattern - a = 'n' + // a = 'n' a_start = this.lexer.token_start a_end = this.lexer.token_start + 1 b = this.source.substring(this.lexer.token_start + 1, this.lexer.token_end) b_start = this.lexer.token_start + 1 b_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b_start, b_end) } } - // Store n as authored - a = 'n' a_start = this.lexer.token_start a_end = this.lexer.token_start + 1 @@ -126,7 +118,7 @@ export class ANplusBParser { b_start = this.lexer.token_start b_end = this.lexer.token_end } - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b !== null ? b_start : 0, b !== null ? b_end : 0) } // Not a valid An+B pattern @@ -144,8 +136,6 @@ export class ANplusBParser { const first_char = text.charCodeAt(0) if (first_char === 0x6e /* n */) { - // Store +n as authored (including the +) - a = '+n' a_start = saved.pos - 1 // Position of the + delim a_end = this.lexer.token_start + 1 @@ -157,7 +147,7 @@ export class ANplusBParser { b = this.source.substring(this.lexer.token_start + 1, this.lexer.token_end) b_start = this.lexer.token_start + 1 b_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b_start, b_end) } } @@ -167,7 +157,7 @@ export class ANplusBParser { b_start = this.lexer.token_start b_end = this.lexer.token_end } - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b !== null ? b_start : 0, b !== null ? b_end : 0) } } @@ -180,8 +170,6 @@ export class ANplusBParser { const n_index = token_text.toLowerCase().indexOf('n') if (n_index !== -1) { - // Store 'a' coefficient including the 'n' - a = token_text.substring(0, n_index + 1) a_start = this.lexer.token_start a_end = this.lexer.token_start + n_index + 1 @@ -194,7 +182,7 @@ export class ANplusBParser { b = remainder b_start = this.lexer.token_start + n_index + 1 b_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b_start, b_end) } } @@ -204,7 +192,7 @@ export class ANplusBParser { b_start = this.lexer.token_start b_end = this.lexer.token_end } - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, a_start, a_end, b_start, b_end) } } @@ -214,7 +202,7 @@ export class ANplusBParser { b = num_text b_start = this.lexer.token_start b_end = this.lexer.token_end - return this.create_anplusb_node(node_start, a, b, a_start, a_end, b_start, b_end) + return this.create_anplusb_node(node_start, 0, 0, b_start, b_end) } return null @@ -278,8 +266,6 @@ export class ANplusBParser { private create_anplusb_node( start: number, - a: string | null, - b: string | null, a_start: number, a_end: number, b_start: number, @@ -291,14 +277,14 @@ export class ANplusBParser { this.arena.set_length(node, this.lexer.pos - start) this.arena.set_start_line(node, this.lexer.line) - // Store 'a' coefficient in content fields - if (a !== null) { + // Store 'a' coefficient in content fields if it exists (length > 0) + if (a_end > a_start) { this.arena.set_content_start(node, a_start) this.arena.set_content_length(node, a_end - a_start) } - // Store 'b' coefficient in value fields - if (b !== null) { + // Store 'b' coefficient in value fields if it exists (length > 0) + if (b_end > b_start) { this.arena.set_value_start(node, b_start) this.arena.set_value_length(node, b_end - b_start) }