From 5682bb94e1ba462e5d24cd36eb4076a822963953 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:12:47 +0000 Subject: [PATCH] Fix newline and has_declarations bugs found during simplification pass, dedupe parser internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs surfaced while reviewing the parser for duplication: - Lexer.skip_whitespace_in_range() used char-types.ts's narrower is_whitespace() (space/tab only, no newlines), while tokenize.ts's own hot-path whitespace skip correctly includes newlines. Any at-rule prelude or nth-expression relying on skip_whitespace_in_range() to jump straight to the next real token (AtRulePreludeParser, ANplusBParser) could silently split a media/supports query in two when a newline fell between components, e.g. "@media screen\nand (min-width: 768px)" produced two MediaQuery nodes instead of one. Fixed by checking CHAR_WHITESPACE | CHAR_NEWLINE directly, matching the rest of the file; removed the now-dead char-types.ts is_whitespace. - FLAG_HAS_DECLARATIONS was only ever set on STYLE_RULE nodes, even though the public Atrule type promises `has_declarations: boolean`. At-rules with direct declarations (@font-face, @page, or a nested @media under CSS Nesting) always reported has_declarations: false. Also removed duplication that had built up across the parser: - parse.ts: style rules and at-rule bodies each had their own ~35-line copy of the "declaration, nested rule, or nested at-rule" loop; extracted into parse_block_children() (which also carries the has_declarations fix to at-rules). - parse-atrule-prelude.ts: the "scan to matching closing paren" loop was copy-pasted across 7 call sites (function conditions, media/supports features, @import's url()/layer()/supports(), @scope); extracted into scan_matching_paren(). - parse-selector.ts: skip_whitespace() had its own from-scratch copy of the whitespace/comment skip that Lexer.skip_whitespace_in_range() already implements (correctly) elsewhere; now delegates to it. Merged create_node/create_node_at into one method with defaulted line/column. - walk.ts: walk()/traverse() each duplicated their recursive helper's body just to avoid one extra node-wrapper allocation for the root node; now delegate directly. - value-node-parser.ts: dropped a one-line-only wrapper. None of tokenize.ts's character-level hot loops (next_token_fast, consume_number, consume_ident_or_function, etc.) were touched — those are deliberately inlined for performance. Verified no regression with a before/after timing comparison parsing bootstrap.css (100 iterations): ~57-58ms/parse on both sides. All 1382 existing tests plus 4 new regression tests pass; typecheck, lint, and knip are clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017XjfDK7or6VrnJ41vBhxrK --- src/api.test.ts | 21 +++- src/arena.ts | 2 +- src/char-types.ts | 4 - src/parse-atrule-prelude.test.ts | 33 ++++++ src/parse-atrule-prelude.ts | 156 ++++++++----------------- src/parse-selector.ts | 71 ++---------- src/parse.ts | 188 ++++++++++++------------------- src/tokenize.ts | 6 +- src/value-node-parser.ts | 6 +- src/walk.ts | 43 +------ 10 files changed, 185 insertions(+), 345 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index b2ae8be..ac17f59 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -427,7 +427,7 @@ describe('CSSNode', () => { expect(rule.has_declarations).toBe(true) }) - test('should return false for at-rules', () => { + test('should return false for at-rules with only nested rules', () => { const source = '@media screen { body { color: red; } }' const root = parse(source) const media = root.first_child! as Atrule @@ -435,6 +435,25 @@ describe('CSSNode', () => { expect(media.type).toBe(AT_RULE) expect(media.has_declarations).toBe(false) }) + + test('should return true for at-rules with direct declarations', () => { + const source = '@font-face { font-family: "Arial"; src: url(a.woff); }' + const root = parse(source) + const fontFace = root.first_child! as Atrule + + expect(fontFace.type).toBe(AT_RULE) + expect(fontFace.has_declarations).toBe(true) + }) + + test('should return true for nested conditional at-rules with a direct declaration (CSS Nesting)', () => { + const source = '.foo { color: red; @media (min-width: 800px) { color: blue; } }' + const root = parse(source) + const rule = root.first_child! as Rule + const media = (rule.block!.children.find((c) => c.type === AT_RULE) as Atrule) ?? null + + expect(media).not.toBeNull() + expect(media!.has_declarations).toBe(true) + }) }) describe('type_name property', () => { diff --git a/src/arena.ts b/src/arena.ts index 81092b4..849eed2 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -100,7 +100,7 @@ export const FLAG_LENGTH_OVERFLOW = 1 << 2 // Node > 65k chars export const FLAG_HAS_BLOCK = 1 << 3 // Has { } block (for style rules and at-rules) // export const FLAG_VENDOR_PREFIXED = 1 << 4 // Has vendor prefix (-webkit-, -moz-, -ms-, -o-) export const FLAG_HAS_NAMESPACE = 1 << 4 // Has namespace qualifier (for type/universal selectors) -export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has declarations (for style rules) +export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has direct declaration children (style rules and at-rules) export const FLAG_HAS_PARENS = 1 << 6 // Has parentheses syntax (for pseudo-class/pseudo-element functions) export const FLAG_BROWSERHACK = 1 << 7 // Has browser hack prefix (*property, _property, etc.) diff --git a/src/char-types.ts b/src/char-types.ts index 0099b0e..60983de 100644 --- a/src/char-types.ts +++ b/src/char-types.ts @@ -82,10 +82,6 @@ function is_alpha(ch: number): boolean { return ch < 128 && (char_types[ch] & CHAR_ALPHA) !== 0 } -export function is_whitespace(ch: number): boolean { - return ch < 128 && (char_types[ch] & CHAR_WHITESPACE) !== 0 -} - // CSS ident start: letter, underscore, or non-ASCII (>= 0x80) export function is_ident_start(ch: number): boolean { if (ch >= 0x80) return true // Non-ASCII diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index c3a75d1..3bdde04 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -2047,6 +2047,39 @@ describe('At-Rule Prelude Nodes', () => { expect(atRule?.prelude?.length).toBe(33) }) + test('@media query stays a single MediaQuery when a newline separates its components', () => { + // Regression test: a newline between tokens inside an at-rule prelude used to + // desync the internal whitespace-skip helper from the tokenizer's own newline + // handling, splitting one media query into two at the newline. + const css = '@media screen\nand (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const prelude = atRule.prelude as AtrulePrelude + + expect(prelude.child_count).toBe(1) + + const query = prelude.first_child as MediaQuery + expect(query.type).toBe(MEDIA_QUERY) + expect(query.children.map((c) => c.type_name)).toEqual([ + 'MediaType', + 'Operator', + 'Feature', + ]) + }) + + test('@supports condition stays intact when a newline separates and/or operators', () => { + const css = '@supports (display: grid)\nand (gap: 1rem) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const prelude = atRule.prelude as AtrulePrelude + + expect(prelude.children.map((c) => c.type_name)).toEqual([ + 'SupportsQuery', + 'Operator', + 'SupportsQuery', + ]) + }) + test('@layer with whitespace around commas', () => { const css = '@layer base , components , utilities { }' const ast = parse(css) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 47d64a3..5996ab6 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -166,6 +166,36 @@ export class AtRulePreludeParser { 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 + // before/after the closing ')'; matched is false if EOF was hit first, in which case + // 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] { + let depth = 1 + let content_end = this.lexer.pos + let close_end = this.lexer.token_end + + while (this.lexer.pos < this.prelude_end && depth > 0) { + let token_type = this.next_token() + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { + depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + depth-- + if (depth === 0) { + content_end = this.lexer.token_start + close_end = this.lexer.token_end + } + } else if (token_type === TOKEN_EOF) { + break + } + } + + return [content_end, close_end, depth === 0] + } + // Parse a bare function condition: style(...), selector(...), font-tech(...). Current token must be TOKEN_FUNCTION. private parse_function_condition(): number { let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' @@ -173,25 +203,7 @@ export class AtRulePreludeParser { let content_start = this.lexer.token_end // After '(' // Find matching closing paren - let paren_depth = 1 - let func_end = this.lexer.token_end - let content_end = content_start - - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - this.next_token() - let inner_token = this.lexer.token_type - if (inner_token === TOKEN_LEFT_PAREN || inner_token === TOKEN_FUNCTION) { - paren_depth++ - } else if (inner_token === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start - func_end = this.lexer.token_end - } - } else if (inner_token === TOKEN_EOF) { - break - } - } + let [content_end, func_end] = this.scan_matching_paren() // Create function node let func_node = this.create_node(FUNCTION, func_start, func_end) @@ -322,25 +334,11 @@ export class AtRulePreludeParser { // Parse media feature: (min-width: 768px) or range: (50px <= width <= 100px) private parse_media_feature(): number | null { let feature_start = this.lexer.token_start // '(' position - - // Find matching right paren - let depth = 1 let content_start = this.lexer.pos - 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 || token_type === TOKEN_FUNCTION) { - depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - depth-- - } - } - - if (depth !== 0) return null // Unmatched parentheses - - let content_end = this.lexer.token_start // Before ')' - let feature_end = this.lexer.token_end // After ')' + // Find matching right paren + let [content_end, feature_end, matched] = this.scan_matching_paren() + if (!matched) return null // Unmatched parentheses // Check for range syntax (has comparison operators) let has_comparison = false @@ -486,25 +484,12 @@ export class AtRulePreludeParser { // Feature query: (property: value) if (token_type === TOKEN_LEFT_PAREN) { let feature_start = this.lexer.token_start - - // Find matching right paren - let depth = 1 let content_start = this.lexer.pos - 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 || inner_token_type === TOKEN_FUNCTION) { - depth++ - } else if (inner_token_type === TOKEN_RIGHT_PAREN) { - depth-- - } - } - - if (depth === 0) { - let content_end = this.lexer.token_start - let feature_end = this.lexer.token_end + // Find matching right paren + let [content_end, feature_end, matched] = this.scan_matching_paren() + if (matched) { // Create supports query node let query = this.create_node(SUPPORTS_QUERY, feature_start, feature_end) @@ -785,20 +770,8 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // It's url( ... we need to find the matching ) - let paren_depth = 1 - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - url_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } - } + let [, close_end] = this.scan_matching_paren() + url_end = close_end } // Create URL node @@ -831,20 +804,10 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // Track the content inside the parentheses content_start = this.lexer.pos - let paren_depth = 1 - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_length = this.lexer.token_start - content_start - layer_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } + let [content_end, close_end, matched] = this.scan_matching_paren() + if (matched) { + content_length = content_end - content_start + layer_end = close_end } } @@ -884,24 +847,7 @@ export class AtRulePreludeParser { let content_start = this.lexer.token_end // After the opening '(' // Find matching closing parenthesis - let paren_depth = 1 - let supports_end = this.lexer.token_end - let content_end = content_start - - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start // Before the closing ')' - supports_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } - } + let [content_end, supports_end] = this.scan_matching_paren() // Create supports node let supports_node = this.create_node(SUPPORTS_QUERY, supports_start, supports_end) @@ -1001,20 +947,8 @@ export class AtRulePreludeParser { this.next_token() // consume '(' let paren_start = this.lexer.token_start let content_start = this.lexer.pos - let depth = 1 - - while (this.lexer.pos < this.prelude_end && depth > 0) { - this.next_token() - 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-- - } - let content_end = this.lexer.token_start - let paren_end = this.lexer.token_end + let [content_end, paren_end] = this.scan_matching_paren() let scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end) let trimmed = trim_boundaries(this.source, content_start, content_end) diff --git a/src/parse-selector.ts b/src/parse-selector.ts index c3b946e..a51ec23 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -49,7 +49,6 @@ import { CHAR_GREATER_THAN, CHAR_PERIOD, CHAR_ASTERISK, - CHAR_FORWARD_SLASH, CHAR_AMPERSAND, is_combinator, CHAR_EQUALS, @@ -203,12 +202,10 @@ export class SelectorParser { let ch = this.source.charCodeAt(this.lexer.token_start) if (ch === CHAR_GREATER_THAN || ch === CHAR_PLUS || ch === CHAR_TILDE) { // Found leading combinator (>, +, ~) - this is a relative selector - let combinator = this.create_node_at( + let combinator = this.create_node( COMBINATOR, this.lexer.token_start, this.lexer.token_end, - this.lexer.token_line, - this.lexer.token_column, ) first_component = combinator chain_tail = combinator @@ -497,13 +494,7 @@ export class SelectorParser { if (is_combinator(ch)) { // > + ~ (combinator text excludes leading whitespace) // Use token's line and column for the combinator position - return this.create_node_at( - COMBINATOR, - this.lexer.token_start, - this.lexer.token_end, - this.lexer.token_line, - this.lexer.token_column, - ) + return this.create_node(COMBINATOR, this.lexer.token_start, this.lexer.token_end) } } @@ -513,7 +504,7 @@ export class SelectorParser { this.lexer.restore_position(saved_whitespace_start) this.skip_whitespace() // Use the position at the start of the whitespace - return this.create_node_at( + return this.create_node( COMBINATOR, saved_whitespace_start.pos, this.lexer.pos, @@ -986,26 +977,15 @@ export class SelectorParser { return -1 } - private create_node(type: number, start: number, end: number): number { - // Use token's line/column since most nodes are created from token positions - let node = this.arena.create_node( - type, - start, - end - start, - this.lexer.token_line, - this.lexer.token_column, - ) - this.arena.set_content_start_delta(node, 0) - this.arena.set_content_length(node, end - start) - return node - } - - private create_node_at( + // Line/column default to the current token's position, since most nodes are created + // from token positions; combinators pass explicit ones (e.g. a descendant combinator's + // position is the start of the whitespace run, not the current token). + private create_node( type: number, start: number, end: number, - line: number, - column: number, + line: number = this.lexer.token_line, + column: number = this.lexer.token_column, ): number { let node = this.arena.create_node(type, start, end - start, line, column) this.arena.set_content_start_delta(node, 0) @@ -1015,38 +995,7 @@ export class SelectorParser { // Helper to skip whitespace and comments, updating line/column private skip_whitespace(): void { - while (this.lexer.pos < this.selector_end) { - let ch = this.source.charCodeAt(this.lexer.pos) - - // Skip whitespace - if (is_whitespace(ch)) { - this.lexer.advance() - continue - } - - // Skip comments /*...*/ - if ( - ch === CHAR_FORWARD_SLASH && - this.lexer.pos + 1 < this.selector_end && - this.source.charCodeAt(this.lexer.pos + 1) === CHAR_ASTERISK - ) { - this.lexer.advance(2) // Skip /* - while (this.lexer.pos < this.selector_end) { - if ( - this.source.charCodeAt(this.lexer.pos) === CHAR_ASTERISK && - this.lexer.pos + 1 < this.selector_end && - this.source.charCodeAt(this.lexer.pos + 1) === CHAR_FORWARD_SLASH - ) { - this.lexer.advance(2) // Skip */ - break - } - this.lexer.advance() - } - continue - } - - break - } + this.lexer.skip_whitespace_in_range(this.selector_end) } } diff --git a/src/parse.ts b/src/parse.ts index 3b4b0dc..aea38fc 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -177,6 +177,54 @@ export class Parser { return this.parse_style_rule() } + // Parse the children of a block ({ ... }): declarations, and — unless declarations_only — + // nested at-rules and style rules too. Chains children as siblings without an intermediate + // array and returns the first child (0 if none). Sets FLAG_HAS_DECLARATIONS on `owner` + // whenever a direct declaration child is produced (used for both style rules and at-rules, + // e.g. `@font-face { ... }` or a nested `@media { color: red }` under CSS Nesting). + private parse_block_children(owner: number, declarations_only: boolean): number { + let first_child = 0 + let last_child = 0 + + while (!this.is_eof()) { + let token_type = this.peek_type() + if (token_type === TOKEN_RIGHT_BRACE) break + + let child: number | null = null + + if (!declarations_only && token_type === TOKEN_AT_KEYWORD) { + child = this.parse_atrule() + if (child === null) { + this.next_token() + } + } else { + child = this.parse_declaration() + if (child !== null) { + this.arena.set_flag(owner, FLAG_HAS_DECLARATIONS) + } else if (declarations_only) { + this.next_token() + } else { + // Not a declaration - try parsing as nested style rule + child = this.parse_style_rule() + if (child === null) { + this.next_token() + } + } + } + + if (child !== null) { + if (first_child === 0) { + first_child = child + } else { + this.arena.set_next_sibling(last_child, child) + } + last_child = child + } + } + + return first_child + } + // Parse a style rule: selector { declarations } private parse_style_rule(): number | null { if (this.is_eof()) return null @@ -218,46 +266,8 @@ export class Parser { block_column, ) - // Parse declarations block (and nested rules for CSS Nesting), chained as siblings - // without an intermediate array - let first_child = 0 - let last_child = 0 - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let child: number | null = null - - // Check for nested at-rule - if (token_type === TOKEN_AT_KEYWORD) { - child = this.parse_atrule() - if (child === null) { - this.next_token() - } - } else { - // Try to parse as declaration first - child = this.parse_declaration() - if (child === null) { - // If not a declaration, try parsing as nested style rule - child = this.parse_style_rule() - if (child === null) { - // Skip unknown tokens - this.next_token() - } - } else { - this.arena.set_flag(style_rule, FLAG_HAS_DECLARATIONS) - } - } - - if (child !== null) { - if (first_child === 0) { - first_child = child - } else { - this.arena.set_next_sibling(last_child, child) - } - last_child = child - } - } + // Parse declarations block (and nested rules for CSS Nesting) + let first_child = this.parse_block_children(style_rule, false) // Expect '}' and calculate lengths (block excludes brace, rule includes it) let block_end = this.lexer.token_start @@ -331,18 +341,11 @@ export class Parser { // Check if this could be a declaration (identifier or browser hack prefix) const token_type = this.peek_type() - // Accept identifiers, at-keywords, and hash tokens - if ( - token_type === TOKEN_IDENT || - token_type === TOKEN_AT_KEYWORD || - token_type === TOKEN_HASH - ) { - return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length) - } - - // For delimiters and special tokens, check if they could be browser hack prefixes - // Only accept single-character prefixes that are not CSS selector syntax - if ( + // Accept identifiers, at-keywords, and hash tokens outright. Delimiters and special + // tokens are only accepted as single-character browser hack prefixes (e.g. `*zoom: 1`), + // excluding selector syntax that happens to share those tokens: . (class), > (child), + // + (adjacent), ~ (general), & (nesting). + const could_be_hack = token_type === TOKEN_DELIM || token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_RIGHT_PAREN || @@ -350,10 +353,8 @@ export class Parser { token_type === TOKEN_RIGHT_BRACKET || token_type === TOKEN_COMMA || token_type === TOKEN_COLON - ) { - // Check if this delimiter could be a browser hack (not a selector combinator) + if (could_be_hack) { const char_code = this.source.charCodeAt(this.lexer.token_start) - // Exclude selector-specific delimiters: . (class), > (child), + (adjacent), ~ (general), & (nesting) if ( char_code === CHAR_PERIOD || char_code === CHAR_GREATER_THAN || @@ -363,11 +364,16 @@ export class Parser { ) { return null } - // Let DeclarationParser try to parse it and return null if it's not a valid declaration - return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length) + } else if ( + token_type !== TOKEN_IDENT && + token_type !== TOKEN_AT_KEYWORD && + token_type !== TOKEN_HASH + ) { + return null } - return null + // Let DeclarationParser try to parse it and return null if it's not a valid declaration + return this.declaration_parser.parse_declaration_with_lexer(this.lexer, this.source.length) } // Parse an at-rule: @media, @import, @font-face, etc. @@ -488,67 +494,11 @@ export class Parser { block_column, ) - // Determine what to parse inside the block based on the at-rule name - let has_declarations = this.atrule_has_declarations(at_rule_name) - // Chain block children as siblings without an intermediate array - let first_child = 0 - let last_child = 0 - - if (has_declarations) { - // Parse declarations only (like @font-face, @page) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let declaration = this.parse_declaration() - if (declaration === null) { - this.next_token() - } else { - if (first_child === 0) { - first_child = declaration - } else { - this.arena.set_next_sibling(last_child, declaration) - } - last_child = declaration - } - } - } else { - // Parse declarations + rules + at-rules (like @media, @keyframes, unknown at-rules) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let child: number | null = null - - // Check for nested at-rule - if (token_type === TOKEN_AT_KEYWORD) { - child = this.parse_atrule() - if (child === null) { - this.next_token() - } - } else { - // Try to parse as declaration first - child = this.parse_declaration() - if (child === null) { - // If not a declaration, try parsing as nested style rule - child = this.parse_style_rule() - if (child === null) { - // Skip unknown tokens - this.next_token() - } - } - } - - if (child !== null) { - if (first_child === 0) { - first_child = child - } else { - this.arena.set_next_sibling(last_child, child) - } - last_child = child - } - } - } + // 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 first_child = this.parse_block_children(at_rule, declarations_only) // Consume '}' (block excludes closing brace, but at-rule includes it) if (this.peek_type() === TOKEN_RIGHT_BRACE) { diff --git a/src/tokenize.ts b/src/tokenize.ts index ddf06a4..5b24579 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -1,7 +1,6 @@ import { is_hex_digit, is_ident_start, - is_whitespace, char_types, CHAR_DIGIT, CHAR_WHITESPACE, @@ -831,8 +830,9 @@ export class Lexer { while (this.pos < end) { let ch = this.source.charCodeAt(this.pos) - // Skip whitespace - if (is_whitespace(ch)) { + // Skip whitespace (space, tab, and newlines — is_whitespace() from string-utils + // covers newlines too, but here we go straight to char_types to avoid the import) + if (ch < 128 && (char_types[ch] & (CHAR_WHITESPACE | CHAR_NEWLINE)) !== 0) { this.advance() continue } diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 58473d9..3465d60 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -161,10 +161,6 @@ export class ValueNodeParser { 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) @@ -174,7 +170,7 @@ export class ValueNodeParser { ch === CHAR_ASTERISK || ch === CHAR_FORWARD_SLASH ) { - return this.create_operator_node(start, end) + return this.create_node(OPERATOR, start, end) } // Other delimiters are ignored for now return null diff --git a/src/walk.ts b/src/walk.ts index a8ac905..b37373b 100644 --- a/src/walk.ts +++ b/src/walk.ts @@ -16,26 +16,8 @@ type WalkCallback = (node: AnyNode, depth: number) => void | typeof SKIP | typeo * not tree depth. */ export function walk(node: CSSNode, callback: WalkCallback, depth = 0): boolean { - const result = callback(node as AnyNode, depth) - - if (result === BREAK) return false - if (result === SKIP) return true - const impl = node as unknown as CSSNodeImpl - const arena = impl.__get_arena() - const source = impl.__get_source() - const index = impl.__get_index() - - const type = arena.get_type(index) - const child_depth = type === STYLE_RULE || type === AT_RULE ? depth + 1 : depth - - let child = arena.get_first_child(index) - while (child !== 0) { - if (!_walk(arena, source, child, callback, child_depth)) return false - child = arena.get_next_sibling(child) - } - - return true + return _walk(impl.__get_arena(), impl.__get_source(), impl.__get_index(), callback, depth) } function _walk( @@ -80,27 +62,8 @@ export function traverse( node: CSSNode, { enter = NOOP, leave = NOOP }: WalkEnterLeaveOptions = {}, ): boolean { - const enter_result = enter(node as AnyNode) - - if (enter_result === BREAK) return false - - if (enter_result !== SKIP) { - const impl = node as unknown as CSSNodeImpl - const arena = impl.__get_arena() - const source = impl.__get_source() - const index = impl.__get_index() - - let child = arena.get_first_child(index) - while (child !== 0) { - if (!_traverse(arena, source, child, enter, leave)) return false - child = arena.get_next_sibling(child) - } - } - - const leave_result = leave(node as AnyNode) - if (leave_result === BREAK) return false - - return true + const impl = node as unknown as CSSNodeImpl + return _traverse(impl.__get_arena(), impl.__get_source(), impl.__get_index(), enter, leave) } function _traverse(