diff --git a/API.md b/API.md index da0fa6a..3f779ea 100644 --- a/API.md +++ b/API.md @@ -46,6 +46,8 @@ function parse(source: string, options?: ParserOptions): CSSNode - `has_prelude` - Whether at-rule has a prelude - `has_block` - Whether rule has a `{ }` block - `has_children` - Whether node has child nodes +- `block` - Block node containing declarations/nested rules (for style rules and at-rules with blocks) +- `is_empty` - Whether block has no declarations or rules (only comments allowed) - `first_child` - First child node or `null` - `next_sibling` - Next sibling node or `null` - `children` - Array of all child nodes @@ -69,7 +71,12 @@ console.log(rule.has_block) // true const selector = rule.first_child console.log(selector.text) // "body" -const declaration = selector.next_sibling +// Access block, then declaration inside it +const block = rule.block +console.log(block.type) // 7 (NODE_BLOCK) +console.log(block.is_empty) // false + +const declaration = block.first_child console.log(declaration.property) // "color" console.log(declaration.value) // "red" ``` @@ -81,8 +88,9 @@ Stylesheet (NODE_STYLESHEET) └─ StyleRule (NODE_STYLE_RULE) ├─ SelectorList (NODE_SELECTOR_LIST) "body" │ └─ Type (NODE_SELECTOR_TYPE) "body" - └─ Declaration (NODE_DECLARATION) "color: red" - └─ Keyword (NODE_VALUE_KEYWORD) "red" + └─ Block (NODE_BLOCK) + └─ Declaration (NODE_DECLARATION) "color: red" + └─ Keyword (NODE_VALUE_KEYWORD) "red" ``` ### Example 2: Parsing with Options @@ -97,8 +105,8 @@ const ast = parse('div { margin: 10px 20px; }', { }) const rule = ast.first_child -const selector = rule.first_child -const declaration = selector.next_sibling +const block = rule.block +const declaration = block.first_child console.log(declaration.property) // "margin" console.log(declaration.value) // "10px 20px" @@ -120,13 +128,15 @@ console.log(mediaRule.has_block) // true console.log(mediaRule.has_children) // true // Access prelude nodes when parse_atrule_preludes is true +// (Prelude nodes are first children, before the block) const mediaQuery = mediaRule.first_child console.log(mediaQuery.type) // NODE_PRELUDE_MEDIA_QUERY console.log(mediaQuery.text) // "(min-width: 768px)" console.log(mediaQuery.value) // "min-width: 768px" (without parentheses) -// Access block content -for (const child of mediaRule) { +// Access block content (nested rules/declarations) +const block = mediaRule.block +for (const child of block) { if (child.type === 2) { // NODE_STYLE_RULE console.log('Found style rule in media query') @@ -218,6 +228,37 @@ const firstNode = ast2.first_child console.log(firstNode.type) // NODE_STYLE_RULE (comment skipped) ``` +### Example 7: Block Nodes and Empty Rules + +```typescript +import { parse } from '@projectwallace/css-parser' + +// Empty rule +const ast1 = parse('.empty { }') +const rule1 = ast1.first_child +console.log(rule1.has_block) // true +console.log(rule1.block.is_empty) // true + +// Rule with only comments +const ast2 = parse('.comments { /* todo */ }', { skip_comments: false }) +const rule2 = ast2.first_child +console.log(rule2.block.is_empty) // true (only comments) + +// Rule with declarations +const ast3 = parse('.filled { color: red; }') +const rule3 = ast3.first_child +console.log(rule3.block.is_empty) // false + +// Nested rules inside blocks +const ast4 = parse('.parent { .child { color: blue; } }') +const parent = ast4.first_child +const parentBlock = parent.block +const nestedRule = parentBlock.first_child + +console.log(nestedRule.type) // NODE_STYLE_RULE +console.log(nestedRule.block.is_empty) // false +``` + --- ## `parse_selector(source)` @@ -294,8 +335,9 @@ walk(ast, (node, depth) => { // NODE_STYLE_RULE // NODE_SELECTOR_LIST // NODE_SELECTOR_TYPE -// NODE_DECLARATION -// NODE_VALUE_KEYWORD +// NODE_BLOCK +// NODE_DECLARATION +// NODE_VALUE_KEYWORD ``` --- @@ -328,3 +370,69 @@ for (const token of tokenize('body { color: red; }')) { // TOKEN_WHITESPACE " " // TOKEN_RIGHT_BRACE "}" ``` + +--- + +## Node Type Constants + +The parser uses numeric constants for node types. Import them from the parser: + +```typescript +import { + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_AT_RULE, + NODE_DECLARATION, + NODE_SELECTOR, + NODE_COMMENT, + NODE_BLOCK, + // ... and more +} from '@projectwallace/css-parser' +``` + +### Core Node Types + +- `NODE_STYLESHEET` (1) - Root stylesheet node +- `NODE_STYLE_RULE` (2) - Style rule (e.g., `body { }`) +- `NODE_AT_RULE` (3) - At-rule (e.g., `@media`, `@keyframes`) +- `NODE_DECLARATION` (4) - Property declaration (e.g., `color: red`) +- `NODE_SELECTOR` (5) - Selector wrapper (deprecated, use NODE_SELECTOR_LIST) +- `NODE_COMMENT` (6) - CSS comment +- `NODE_BLOCK` (7) - Block container for declarations and nested rules + +### Value Node Types (10-16) + +- `NODE_VALUE_KEYWORD` (10) - Keyword value (e.g., `red`, `auto`) +- `NODE_VALUE_NUMBER` (11) - Number value (e.g., `42`, `3.14`) +- `NODE_VALUE_DIMENSION` (12) - Dimension value (e.g., `10px`, `2em`, `50%`) +- `NODE_VALUE_STRING` (13) - String value (e.g., `"hello"`) +- `NODE_VALUE_COLOR` (14) - Hex color (e.g., `#fff`, `#ff0000`) +- `NODE_VALUE_FUNCTION` (15) - Function (e.g., `calc()`, `var()`) +- `NODE_VALUE_OPERATOR` (16) - Operator (e.g., `+`, `,`) + +### Selector Node Types (20-29) + +- `NODE_SELECTOR_LIST` (20) - Selector list container +- `NODE_SELECTOR_TYPE` (21) - Type selector (e.g., `div`, `span`) +- `NODE_SELECTOR_CLASS` (22) - Class selector (e.g., `.classname`) +- `NODE_SELECTOR_ID` (23) - ID selector (e.g., `#identifier`) +- `NODE_SELECTOR_ATTRIBUTE` (24) - Attribute selector (e.g., `[attr=value]`) +- `NODE_SELECTOR_PSEUDO_CLASS` (25) - Pseudo-class (e.g., `:hover`) +- `NODE_SELECTOR_PSEUDO_ELEMENT` (26) - Pseudo-element (e.g., `::before`) +- `NODE_SELECTOR_COMBINATOR` (27) - Combinator (e.g., `>`, `+`, `~`, or ` `) +- `NODE_SELECTOR_UNIVERSAL` (28) - Universal selector (`*`) +- `NODE_SELECTOR_NESTING` (29) - Nesting selector (`&`) + +### At-Rule Prelude Node Types (30-40) + +- `NODE_PRELUDE_MEDIA_QUERY` (30) - Media query +- `NODE_PRELUDE_MEDIA_FEATURE` (31) - Media feature +- `NODE_PRELUDE_MEDIA_TYPE` (32) - Media type (e.g., `screen`, `print`) +- `NODE_PRELUDE_CONTAINER_QUERY` (33) - Container query +- `NODE_PRELUDE_SUPPORTS_QUERY` (34) - Supports query +- `NODE_PRELUDE_LAYER_NAME` (35) - Layer name +- `NODE_PRELUDE_IDENTIFIER` (36) - Generic identifier +- `NODE_PRELUDE_OPERATOR` (37) - Logical operator (e.g., `and`, `or`) +- `NODE_PRELUDE_IMPORT_URL` (38) - Import URL +- `NODE_PRELUDE_IMPORT_LAYER` (39) - Import layer +- `NODE_PRELUDE_IMPORT_SUPPORTS` (40) - Import supports condition diff --git a/package.json b/package.json index 642c281..5e4b87d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "./parse-atrule-prelude": { "types": "./dist/parse-atrule-prelude.d.ts", "import": "./dist/parse-atrule-prelude.js" + }, + "./parse-anplusb": { + "types": "./dist/parse-anplusb.d.ts", + "import": "./dist/parse-anplusb.js" } }, "files": [ diff --git a/src/anplusb-parser.test.ts b/src/anplusb-parser.test.ts new file mode 100644 index 0000000..0150390 --- /dev/null +++ b/src/anplusb-parser.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest' +import { ANplusBParser } from './anplusb-parser' +import { CSSDataArena, NODE_SELECTOR_NTH } from './arena' +import { CSSNode } from './css-node' + +// Helper to parse An+B expression +function parse_anplusb(expr: string): CSSNode | null { + const arena = new CSSDataArena(64) + const parser = new ANplusBParser(arena, expr) + const nodeIndex = parser.parse_anplusb(0, expr.length) + + if (nodeIndex === null) return null + return new CSSNode(arena, expr, nodeIndex) +} + +describe('ANplusBParser', () => { + describe('Simple integers (b only)', () => { + it('should parse positive integer', () => { + const node = parse_anplusb('3')! + expect(node).not.toBeNull() + expect(node.type).toBe(NODE_SELECTOR_NTH) + expect(node.nth_a).toBe(null) + expect(node.nth_b).toBe('3') + expect(node.text).toBe('3') + }) + + it('should parse negative integer', () => { + const node = parse_anplusb('-5')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe(null) + expect(node.nth_b).toBe('-5') + }) + + it('should parse zero', () => { + const node = parse_anplusb('0')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe(null) + expect(node.nth_b).toBe('0') + }) + }) + + describe('Keywords', () => { + it('should parse odd keyword', () => { + const node = parse_anplusb('odd')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('odd') + expect(node.nth_b).toBe(null) + }) + + it('should parse even keyword', () => { + const node = parse_anplusb('even')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('even') + expect(node.nth_b).toBe(null) + }) + + it('should parse ODD (case-insensitive)', () => { + const node = parse_anplusb('ODD')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('ODD') + expect(node.nth_b).toBe(null) + }) + + it('should parse EVEN (case-insensitive)', () => { + const node = parse_anplusb('EVEN')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('EVEN') + expect(node.nth_b).toBe(null) + }) + }) + + describe('Just n (a only)', () => { + it('should parse n', () => { + const node = parse_anplusb('n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('n') + expect(node.nth_b).toBe(null) + }) + + it('should parse +n', () => { + const node = parse_anplusb('+n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('+n') + expect(node.nth_b).toBe(null) + }) + + it('should parse -n', () => { + const node = parse_anplusb('-n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('-n') + expect(node.nth_b).toBe(null) + }) + }) + + describe('Dimension tokens (An)', () => { + it('should parse 2n', () => { + const node = parse_anplusb('2n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe(null) + }) + + it('should parse -3n', () => { + const node = parse_anplusb('-3n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('-3n') + expect(node.nth_b).toBe(null) + }) + + it('should parse +5n', () => { + const node = parse_anplusb('+5n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('+5n') + expect(node.nth_b).toBe(null) + }) + + it('should parse 10n', () => { + const node = parse_anplusb('10n')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('10n') + expect(node.nth_b).toBe(null) + }) + }) + + describe('An+B expressions', () => { + it('should parse 2n+1', () => { + const node = parse_anplusb('2n+1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('1') + }) + + it('should parse 3n+5', () => { + const node = parse_anplusb('3n+5')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('3n') + expect(node.nth_b).toBe('5') + }) + + it('should parse n+0', () => { + const node = parse_anplusb('n+0')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('n') + expect(node.nth_b).toBe('0') + }) + + it('should parse -n+3', () => { + const node = parse_anplusb('-n+3')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('-n') + expect(node.nth_b).toBe('3') + }) + }) + + describe('An-B expressions', () => { + it('should parse 2n-1', () => { + const node = parse_anplusb('2n-1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('-1') + }) + + it('should parse 3n-5', () => { + const node = parse_anplusb('3n-5')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('3n') + expect(node.nth_b).toBe('-5') + }) + + it('should parse n-2', () => { + const node = parse_anplusb('n-2')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('n') + expect(node.nth_b).toBe('-2') + }) + + it('should parse -n-1', () => { + const node = parse_anplusb('-n-1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('-n') + expect(node.nth_b).toBe('-1') + }) + + it('should parse -2n-3', () => { + const node = parse_anplusb('-2n-3')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('-2n') + expect(node.nth_b).toBe('-3') + }) + }) + + describe('Whitespace handling', () => { + it('should parse 2n + 1 with spaces', () => { + const node = parse_anplusb('2n + 1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('1') + }) + + it('should parse 2n - 1 with spaces', () => { + const node = parse_anplusb('2n - 1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('-1') + }) + + it('should parse n + 5 with spaces', () => { + const node = parse_anplusb('n + 5')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('n') + expect(node.nth_b).toBe('5') + }) + + it('should handle leading whitespace', () => { + const node = parse_anplusb(' 2n+1')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('1') + }) + + it('should handle trailing whitespace', () => { + const node = parse_anplusb('2n+1 ')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('2n') + expect(node.nth_b).toBe('1') + }) + }) + + describe('Edge cases', () => { + it('should parse +0n+0', () => { + const node = parse_anplusb('+0n+0')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('+0n') + expect(node.nth_b).toBe('0') + }) + + it('should parse large coefficients', () => { + const node = parse_anplusb('100n+50')! + expect(node).not.toBeNull() + expect(node.nth_a).toBe('100n') + expect(node.nth_b).toBe('50') + }) + }) +}) diff --git a/src/anplusb-parser.ts b/src/anplusb-parser.ts new file mode 100644 index 0000000..e375e6a --- /dev/null +++ b/src/anplusb-parser.ts @@ -0,0 +1,314 @@ +// ANplusB Parser - Parses An+B microsyntax for nth-* pseudo-classes +// Spec: https://www.w3.org/TR/css-syntax-3/#anb + +// Much inspiration taken from CSSTree (Roman Dvornov) - MIT License +// https://github.com/csstree/csstree/blob/56afb6dd761149099cd3cdfb0a38e15e8cc0a71a/lib/syntax/node/AnPlusB.js#L106-L271 + +import { Lexer } from './lexer' +import type { CSSDataArena } from './arena' +import { NODE_SELECTOR_NTH } from './arena' +import { TOKEN_IDENT, TOKEN_NUMBER, TOKEN_DIMENSION, TOKEN_DELIM, type TokenType } from './token-types' +import { is_whitespace as is_whitespace_char } from './string-utils' + +export class ANplusBParser { + private lexer: Lexer + private arena: CSSDataArena + private source: string + private expr_end: number + + constructor(arena: CSSDataArena, source: string) { + this.arena = arena + this.source = source + this.lexer = new Lexer(source, true) // skip comments + this.expr_end = 0 + } + + /** + * Parse An+B expression + * Examples: odd, even, 3, n, -n, 2n, 2n+1, -3n-5 + */ + parse_anplusb(start: number, end: number, line: number = 1): number | null { + this.expr_end = end + 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 + let b_start = start + let b_end = start + const node_start = start + + // Skip leading whitespace + this.skip_whitespace() + + if (this.lexer.pos >= this.expr_end) { + return null + } + + // Get first token + this.lexer.next_token_fast(true) + + // Handle special keywords: odd, even + if (this.lexer.token_type === TOKEN_IDENT) { + 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 = text + 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) + } + + // Check if it's 'n', '-n', or starts with 'n' + const first_char = this.source.charCodeAt(this.lexer.token_start) + const second_char = this.lexer.token_end > this.lexer.token_start + 1 ? this.source.charCodeAt(this.lexer.token_start + 1) : 0 + + // -n, -n+3, -n-5 + if (first_char === 0x2d /* - */ && second_char === 0x6e /* n */) { + // Check for attached -n-digit pattern + if (this.lexer.token_end > this.lexer.token_start + 2) { + const third_char = this.source.charCodeAt(this.lexer.token_start + 2) + if (third_char === 0x2d /* - */) { + // -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) + } + } + + // Store -n as authored + a = '-n' + a_start = this.lexer.token_start + a_end = this.lexer.token_start + 2 + + // Check for separate b part after whitespace + b = this.parse_b_part() + if (b !== null) { + 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) + } + + // n, n+3, n-5 + if (first_char === 0x6e /* n */) { + // Check for attached n-digit pattern + if (this.lexer.token_end > this.lexer.token_start + 1) { + const second_char = this.source.charCodeAt(this.lexer.token_start + 1) + if (second_char === 0x2d /* - */) { + // n-5 pattern + 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) + } + } + + // Store n as authored + a = 'n' + a_start = this.lexer.token_start + a_end = this.lexer.token_start + 1 + + // Check for separate b part + b = this.parse_b_part() + if (b !== null) { + 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) + } + + // Not a valid An+B pattern + return null + } + + // Handle +n pattern + if (this.lexer.token_type === TOKEN_DELIM && this.source.charCodeAt(this.lexer.token_start) === 0x2b /* + */) { + // Look ahead for 'n' + const saved_pos = this.lexer.pos + this.lexer.next_token_fast(true) + + if ((this.lexer.token_type as TokenType) === TOKEN_IDENT) { + const text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + 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 + + // Check for attached n-digit pattern + if (this.lexer.token_end > this.lexer.token_start + 1) { + const second_char = this.source.charCodeAt(this.lexer.token_start + 1) + if (second_char === 0x2d /* - */) { + // +n-5 pattern + 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) + } + } + + // Check for separate b part + b = this.parse_b_part() + if (b !== null) { + 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) + } + } + + this.lexer.pos = saved_pos + } + + // Handle dimension tokens: 2n, 3n+1, -5n-2 + if (this.lexer.token_type === TOKEN_DIMENSION) { + const token_text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + 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 + + // Check for b part after 'n' + if (n_index + 1 < token_text.length) { + const remainder = token_text.substring(n_index + 1) + + // n-5 or n+5 pattern in dimension + if (remainder.charCodeAt(0) === 0x2d /* - */) { + 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) + } + } + + // Check for separate b part after dimension + b = this.parse_b_part() + if (b !== null) { + 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) + } + } + + // Handle simple integer (b only, no 'a') + if (this.lexer.token_type === TOKEN_NUMBER) { + let num_text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + 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 null + } + + /** + * Parse the b part after 'n' + * Handles: +5, -3, whitespace variations + */ + private parse_b_part(): string | null { + this.skip_whitespace() + + if (this.lexer.pos >= this.expr_end) { + return null + } + + this.lexer.next_token_fast(true) + + // Check for + or - delim + if (this.lexer.token_type === TOKEN_DELIM) { + const ch = this.source.charCodeAt(this.lexer.token_start) + + if (ch === 0x2b /* + */ || ch === 0x2d /* - */) { + const sign = ch === 0x2d ? '-' : '' + this.skip_whitespace() + + this.lexer.next_token_fast(true) + + if ((this.lexer.token_type as TokenType) === TOKEN_NUMBER) { + let num_text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + // Remove leading + if present + if (num_text.charCodeAt(0) === 0x2b /* + */) { + num_text = num_text.substring(1) + } + return sign === '-' ? sign + num_text : num_text + } + } + } + + // Check for signed number + if (this.lexer.token_type === TOKEN_NUMBER) { + let num_text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + const first_char = num_text.charCodeAt(0) + + // If it starts with + or -, it's a signed number + if (first_char === 0x2b /* + */ || first_char === 0x2d /* - */) { + // Remove leading + if present + if (first_char === 0x2b) { + num_text = num_text.substring(1) + } + return num_text + } + } + + return null + } + + private skip_whitespace(): void { + while (this.lexer.pos < this.expr_end) { + const ch = this.source.charCodeAt(this.lexer.pos) + if (is_whitespace_char(ch)) { + this.lexer.pos++ + } else { + break + } + } + } + + private create_anplusb_node( + start: number, + a: string | null, + b: string | null, + a_start: number, + a_end: number, + b_start: number, + b_end: number, + ): number { + const node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_NTH) + this.arena.set_start_offset(node, start) + 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) { + 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) { + this.arena.set_value_start(node, b_start) + this.arena.set_value_length(node, b_end - b_start) + } + + return node + } +} diff --git a/src/arena.ts b/src/arena.ts index e47f1c6..96e1ec2 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -29,6 +29,7 @@ export const NODE_AT_RULE = 3 export const NODE_DECLARATION = 4 export const NODE_SELECTOR = 5 export const NODE_COMMENT = 6 +export const NODE_BLOCK = 7 // Block container for declarations and nested rules // Value node type constants (for declaration values) export const NODE_VALUE_KEYWORD = 10 // identifier: red, auto, inherit @@ -50,19 +51,22 @@ export const NODE_SELECTOR_PSEUDO_ELEMENT = 26 // pseudo-element: ::before, ::af export const NODE_SELECTOR_COMBINATOR = 27 // combinator: >, +, ~, space export const NODE_SELECTOR_UNIVERSAL = 28 // universal selector: * export const NODE_SELECTOR_NESTING = 29 // nesting selector: & +export const NODE_SELECTOR_NTH = 30 // An+B expression: 2n+1, odd, even +export const NODE_SELECTOR_NTH_OF = 31 // An+B with "of " syntax +export const NODE_SELECTOR_LANG = 56 // language identifier for :lang() pseudo-class // At-rule prelude node type constants (for at-rule prelude parsing) -export const NODE_PRELUDE_MEDIA_QUERY = 30 // media query: screen, (min-width: 768px) -export const NODE_PRELUDE_MEDIA_FEATURE = 31 // media feature: (min-width: 768px) -export const NODE_PRELUDE_MEDIA_TYPE = 32 // media type: screen, print, all -export const NODE_PRELUDE_CONTAINER_QUERY = 33 // container query: sidebar (min-width: 400px) -export const NODE_PRELUDE_SUPPORTS_QUERY = 34 // supports query: (display: flex) -export const NODE_PRELUDE_LAYER_NAME = 35 // layer name: base, components -export const NODE_PRELUDE_IDENTIFIER = 36 // generic identifier: keyframe name, property name -export const NODE_PRELUDE_OPERATOR = 37 // logical operator: and, or, not -export const NODE_PRELUDE_IMPORT_URL = 38 // import URL: url("file.css") or "file.css" -export const NODE_PRELUDE_IMPORT_LAYER = 39 // import layer: layer or layer(name) -export const NODE_PRELUDE_IMPORT_SUPPORTS = 40 // import supports: supports(condition) +export const NODE_PRELUDE_MEDIA_QUERY = 32 // media query: screen, (min-width: 768px) +export const NODE_PRELUDE_MEDIA_FEATURE = 33 // media feature: (min-width: 768px) +export const NODE_PRELUDE_MEDIA_TYPE = 34 // media type: screen, print, all +export const NODE_PRELUDE_CONTAINER_QUERY = 35 // container query: sidebar (min-width: 400px) +export const NODE_PRELUDE_SUPPORTS_QUERY = 36 // supports query: (display: flex) +export const NODE_PRELUDE_LAYER_NAME = 37 // layer name: base, components +export const NODE_PRELUDE_IDENTIFIER = 38 // generic identifier: keyframe name, property name +export const NODE_PRELUDE_OPERATOR = 39 // logical operator: and, or, not +export const NODE_PRELUDE_IMPORT_URL = 40 // import URL: url("file.css") or "file.css" +export const NODE_PRELUDE_IMPORT_LAYER = 41 // import layer: layer or layer(name) +export const NODE_PRELUDE_IMPORT_SUPPORTS = 42 // import supports: supports(condition) // Flag constants (bit-packed in 1 byte) export const FLAG_IMPORTANT = 1 << 0 // Has !important @@ -72,6 +76,15 @@ export const FLAG_HAS_BLOCK = 1 << 3 // Has { } block (for style rules and at-ru export const FLAG_VENDOR_PREFIXED = 1 << 4 // Has vendor prefix (-webkit-, -moz-, -ms-, -o-) export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has declarations (for style rules) +// Attribute selector operator constants (stored in 1 byte at offset 2) +export const ATTR_OPERATOR_NONE = 0 // [attr] +export const ATTR_OPERATOR_EQUAL = 1 // [attr=value] +export const ATTR_OPERATOR_TILDE_EQUAL = 2 // [attr~=value] +export const ATTR_OPERATOR_PIPE_EQUAL = 3 // [attr|=value] +export const ATTR_OPERATOR_CARET_EQUAL = 4 // [attr^=value] +export const ATTR_OPERATOR_DOLLAR_EQUAL = 5 // [attr$=value] +export const ATTR_OPERATOR_STAR_EQUAL = 6 // [attr*=value] + export class CSSDataArena { private buffer: ArrayBuffer private view: DataView @@ -150,6 +163,11 @@ export class CSSDataArena { return this.view.getUint16(this.node_offset(node_index) + 16, true) } + // Read attribute operator (for NODE_SELECTOR_ATTRIBUTE) + get_attr_operator(node_index: number): number { + return this.view.getUint8(this.node_offset(node_index) + 2) + } + // Read first child index (0 = no children) get_first_child(node_index: number): number { return this.view.getUint32(this.node_offset(node_index) + 20, true) @@ -217,6 +235,11 @@ export class CSSDataArena { this.view.setUint16(this.node_offset(node_index) + 16, length, true) } + // Write attribute operator (for NODE_SELECTOR_ATTRIBUTE) + set_attr_operator(node_index: number, operator: number): void { + this.view.setUint8(this.node_offset(node_index) + 2, operator) + } + // Write first child index set_first_child(node_index: number, childIndex: number): void { this.view.setUint32(this.node_offset(node_index) + 20, childIndex, true) diff --git a/src/at-rule-prelude-parser.test.ts b/src/at-rule-prelude-parser.test.ts index d1be477..4ae9156 100644 --- a/src/at-rule-prelude-parser.test.ts +++ b/src/at-rule-prelude-parser.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { parse } from './parse' import { NODE_AT_RULE, + NODE_BLOCK, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_TYPE, @@ -183,7 +184,8 @@ describe('At-Rule Prelude Parser', () => { expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('layer') - const children = atRule?.children || [] + // Filter out block node to get only prelude children + const children = atRule?.children.filter(c => c.type !== NODE_BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(NODE_PRELUDE_LAYER_NAME) expect(children[0].text).toBe('base') @@ -217,7 +219,8 @@ describe('At-Rule Prelude Parser', () => { expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('keyframes') - const children = atRule?.children || [] + // Filter out block node to get only prelude children + const children = atRule?.children.filter(c => c.type !== NODE_BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(NODE_PRELUDE_IDENTIFIER) expect(children[0].text).toBe('slidein') @@ -233,7 +236,8 @@ describe('At-Rule Prelude Parser', () => { expect(atRule?.type).toBe(NODE_AT_RULE) expect(atRule?.name).toBe('property') - const children = atRule?.children || [] + // Filter out block node to get only prelude children + const children = atRule?.children.filter(c => c.type !== NODE_BLOCK) || [] expect(children.length).toBe(1) expect(children[0].type).toBe(NODE_PRELUDE_IDENTIFIER) expect(children[0].text).toBe('--my-color') diff --git a/src/at-rule-prelude-parser.ts b/src/at-rule-prelude-parser.ts index 09d9f38..48ef263 100644 --- a/src/at-rule-prelude-parser.ts +++ b/src/at-rule-prelude-parser.ts @@ -104,7 +104,6 @@ export class AtRulePreludeParser { private parse_single_media_query(): number | null { let query_start = this.lexer.pos let query_line = this.lexer.line - let query_column = this.lexer.column // Skip whitespace this.skip_whitespace() @@ -479,7 +478,7 @@ export class AtRulePreludeParser { let paren_depth = 1 while (this.lexer.pos < this.prelude_end && paren_depth > 0) { let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN) { + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { paren_depth++ } else if (tokenType === TOKEN_RIGHT_PAREN) { paren_depth-- @@ -507,6 +506,7 @@ export class AtRulePreludeParser { // Peek at next token let saved_pos = this.lexer.pos let saved_line = this.lexer.line + let saved_column = this.lexer.column this.next_token() @@ -532,7 +532,7 @@ export class AtRulePreludeParser { let paren_depth = 1 while (this.lexer.pos < this.prelude_end && paren_depth > 0) { let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN) { + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { paren_depth++ } else if (tokenType === TOKEN_RIGHT_PAREN) { paren_depth-- @@ -569,6 +569,7 @@ export class AtRulePreludeParser { // Not a layer, restore position this.lexer.pos = saved_pos this.lexer.line = saved_line + this.lexer.column = saved_column return null } @@ -577,6 +578,7 @@ export class AtRulePreludeParser { // Peek at next token let saved_pos = this.lexer.pos let saved_line = this.lexer.line + let saved_column = this.lexer.column this.next_token() @@ -593,7 +595,7 @@ export class AtRulePreludeParser { while (this.lexer.pos < this.prelude_end && paren_depth > 0) { let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN) { + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { paren_depth++ } else if (tokenType === TOKEN_RIGHT_PAREN) { paren_depth-- @@ -619,6 +621,7 @@ export class AtRulePreludeParser { // Not supports(), restore position this.lexer.pos = saved_pos this.lexer.line = saved_line + this.lexer.column = saved_column return null } @@ -637,12 +640,14 @@ export class AtRulePreludeParser { private peek_token_type(): number { let saved_pos = this.lexer.pos let saved_line = this.lexer.line + let saved_column = this.lexer.column this.next_token() let type = this.lexer.token_type this.lexer.pos = saved_pos this.lexer.line = saved_line + this.lexer.column = saved_column return type } diff --git a/src/column-tracking.test.ts b/src/column-tracking.test.ts index 65e64ce..4b3f5f9 100644 --- a/src/column-tracking.test.ts +++ b/src/column-tracking.test.ts @@ -26,7 +26,8 @@ describe('Column Tracking', () => { expect(selector!.column).toBe(1) // Declaration (color: red) - const decl = selector!.next_sibling + const block = selector!.next_sibling + const decl = block!.first_child expect(decl).not.toBeNull() expect(decl!.type).toBe(NODE_DECLARATION) expect(decl!.line).toBe(1) @@ -42,9 +43,10 @@ describe('Column Tracking', () => { const ast = parse(css) const rule = ast.first_child! const selector = rule.first_child! + const block = selector.next_sibling! // First declaration (color: red) at line 2, column 3 - const decl1 = selector.next_sibling! + const decl1 = block.first_child! expect(decl1.type).toBe(NODE_DECLARATION) expect(decl1.line).toBe(2) expect(decl1.column).toBe(3) @@ -66,8 +68,9 @@ describe('Column Tracking', () => { expect(atRule.line).toBe(1) expect(atRule.column).toBe(1) - // Find the nested style rule (skip prelude nodes) - let nestedRule = atRule.first_child + // Get the block, then find the nested style rule + const block = atRule.block! + let nestedRule = block.first_child while (nestedRule && nestedRule.type !== NODE_STYLE_RULE) { nestedRule = nestedRule.next_sibling } @@ -75,9 +78,8 @@ describe('Column Tracking', () => { expect(nestedRule).not.toBeNull() expect(nestedRule!.type).toBe(NODE_STYLE_RULE) expect(nestedRule!.line).toBe(1) - // Column 17 is where 'body' starts, but parser captures at column 22 (the '{' after body) - // This is the current behavior - column tracking works, just captures at a different point - expect(nestedRule!.column).toBe(22) + // Column 17 is where 'body' starts (beginning of selector) + expect(nestedRule!.column).toBe(17) }) test('should track column for multiple rules on same line', () => { diff --git a/src/css-node.test.ts b/src/css-node.test.ts index 2a0ec05..b507dc7 100644 --- a/src/css-node.test.ts +++ b/src/css-node.test.ts @@ -10,13 +10,14 @@ describe('CSSNode', () => { const root = parser.parse() const rule = root.first_child! + const block = rule.block! const types: number[] = [] - for (const child of rule) { + for (const child of block) { types.push(child.type) } - expect(types).toEqual([NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_DECLARATION, NODE_DECLARATION]) + expect(types).toEqual([NODE_DECLARATION, NODE_DECLARATION, NODE_DECLARATION]) }) test('should work with spread operator', () => { @@ -36,7 +37,8 @@ describe('CSSNode', () => { const root = parser.parse() const media = root.first_child! - const children = Array.from(media) + const block = media.block! + const children = Array.from(block) expect(children).toHaveLength(1) expect(children[0].type).toBe(NODE_STYLE_RULE) @@ -165,7 +167,8 @@ describe('CSSNode', () => { const root = parser.parse() const rule = root.first_child! const selector = rule.first_child! - const declaration = selector.next_sibling! + const block = selector.next_sibling! + const declaration = block.first_child! // Rules and selectors don't use value field expect(rule.has_prelude).toBe(false) diff --git a/src/css-node.ts b/src/css-node.ts index 17f92ce..0cb04fa 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -7,6 +7,7 @@ import { NODE_DECLARATION, NODE_SELECTOR, NODE_COMMENT, + NODE_BLOCK, NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, NODE_VALUE_DIMENSION, @@ -24,6 +25,9 @@ import { NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_TYPE, @@ -42,6 +46,8 @@ import { FLAG_HAS_DECLARATIONS, } from './arena' +import { parse_dimension } from './string-utils' + // Node type constants (numeric for performance) export type CSSNodeType = | typeof NODE_STYLESHEET @@ -50,6 +56,7 @@ export type CSSNodeType = | typeof NODE_DECLARATION | typeof NODE_SELECTOR | typeof NODE_COMMENT + | typeof NODE_BLOCK | typeof NODE_VALUE_KEYWORD | typeof NODE_VALUE_NUMBER | typeof NODE_VALUE_DIMENSION @@ -67,6 +74,9 @@ export type CSSNodeType = | typeof NODE_SELECTOR_COMBINATOR | typeof NODE_SELECTOR_UNIVERSAL | typeof NODE_SELECTOR_NESTING + | typeof NODE_SELECTOR_NTH + | typeof NODE_SELECTOR_NTH_OF + | typeof NODE_SELECTOR_LANG | typeof NODE_PRELUDE_MEDIA_QUERY | typeof NODE_PRELUDE_MEDIA_FEATURE | typeof NODE_PRELUDE_MEDIA_TYPE @@ -122,7 +132,15 @@ export class CSSNode { } // Get the value text (for declarations: "blue" in "color: blue") - get value(): string | null { + // For dimension/number nodes: returns the numeric value as a number + // For string nodes: returns the string content without quotes + get value(): string | number | null { + // For dimension and number nodes, parse and return as number + if (this.type === NODE_VALUE_DIMENSION || this.type === NODE_VALUE_NUMBER) { + return parse_dimension(this.text).value + } + + // For other nodes, return as string let start = this.arena.get_value_start(this.index) let length = this.arena.get_value_length(this.index) if (length === 0) return null @@ -132,7 +150,20 @@ export class CSSNode { // Get the prelude text (for at-rules: "(min-width: 768px)" in "@media (min-width: 768px)") // This is an alias for `value` to make at-rule usage more semantic get prelude(): string | null { - return this.value + let val = this.value + return typeof val === 'string' ? val : null + } + + // Get the attribute operator (for attribute selectors: =, ~=, |=, ^=, $=, *=) + // Returns one of the ATTR_OPERATOR_* constants + get attr_operator(): number { + return this.arena.get_attr_operator(this.index) + } + + // Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%") + get unit(): string | null { + if (this.type !== NODE_VALUE_DIMENSION) return null + return parse_dimension(this.text).unit } // Check if this declaration has !important @@ -165,6 +196,54 @@ export class CSSNode { return this.arena.has_flag(this.index, FLAG_HAS_DECLARATIONS) } + // Get the block node (for style rules and at-rules with blocks) + get block(): CSSNode | null { + // For StyleRule: block is sibling after selector list + if (this.type === NODE_STYLE_RULE) { + let first = this.first_child + if (!first) return null + // Block is the sibling after selector list + let blockNode = first.next_sibling + if (blockNode && blockNode.type === NODE_BLOCK) { + return blockNode + } + return null + } + + // For AtRule: block is last child (after prelude nodes) + if (this.type === NODE_AT_RULE) { + // Find last child that is a block + let child = this.first_child + while (child) { + if (child.type === NODE_BLOCK && !child.next_sibling) { + return child + } + child = child.next_sibling + } + return null + } + + return null + } + + // Check if this block is empty (no declarations or rules, only comments allowed) + get is_empty(): boolean { + // Only valid on block nodes + if (this.type !== NODE_BLOCK) { + return false + } + + // Empty if no children, or all children are comments + let child = this.first_child + while (child) { + if (child.type !== NODE_COMMENT) { + return false + } + child = child.next_sibling + } + return true + } + // --- Value Node Access (for declarations) --- // Get array of parsed value nodes (for declarations only) @@ -225,6 +304,11 @@ export class CSSNode { return new CSSNode(this.arena, this.source, sibling_index) } + get has_next(): boolean { + let sibling_index = this.arena.get_next_sibling(this.index) + return sibling_index !== 0 + } + // Check if this node has children get has_children(): boolean { return this.arena.has_children(this.index) @@ -249,4 +333,50 @@ export class CSSNode { child = child.next_sibling } } + + // --- An+B Expression Helpers (for NODE_SELECTOR_NTH) --- + + // Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") + get nth_a(): string | null { + if (this.type !== NODE_SELECTOR_NTH) return null + + let len = this.arena.get_content_length(this.index) + if (len === 0) return null + let start = this.arena.get_content_start(this.index) + return this.source.substring(start, start + len) + } + + // Get the 'b' coefficient from An+B expression (e.g., "1" from "2n+1") + get nth_b(): string | null { + if (this.type !== NODE_SELECTOR_NTH) return null + + let len = this.arena.get_value_length(this.index) + if (len === 0) return null + let start = this.arena.get_value_start(this.index) + let value = this.source.substring(start, start + len) + + // Check if there's a - sign before this position (handling "2n - 1" with spaces) + // Look backwards for a - or + sign, skipping whitespace + let check_pos = start - 1 + while (check_pos >= 0) { + let ch = this.source.charCodeAt(check_pos) + if (ch === 0x20 /* space */ || ch === 0x09 /* tab */ || ch === 0x0a /* \n */ || ch === 0x0d /* \r */) { + check_pos-- + continue + } + // Found non-whitespace + if (ch === 0x2d /* - */) { + // Prepend - to value + value = '-' + value + } + // Note: + signs are implicit, so we don't prepend them + break + } + + // Strip leading + if present in the token itself + if (value.charCodeAt(0) === 0x2b /* + */) { + return value.substring(1) + } + return value + } } diff --git a/src/index.ts b/src/index.ts index f833cc2..7d8e365 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,16 @@ export { type ParserOptions } from './parser' // Types export { CSSNode, type CSSNodeType } from './css-node' +export { + ATTR_OPERATOR_NONE, + ATTR_OPERATOR_EQUAL, + ATTR_OPERATOR_TILDE_EQUAL, + ATTR_OPERATOR_PIPE_EQUAL, + ATTR_OPERATOR_CARET_EQUAL, + ATTR_OPERATOR_DOLLAR_EQUAL, + ATTR_OPERATOR_STAR_EQUAL, +} from './arena' + // Constants export { NODE_STYLE_RULE, @@ -38,6 +48,8 @@ export { NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_TYPE, diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts new file mode 100644 index 0000000..4ee3712 --- /dev/null +++ b/src/parse-anplusb.ts @@ -0,0 +1,12 @@ +import { CSSNode } from './css-node' +import { CSSDataArena } from './arena' +import { ANplusBParser } from './anplusb-parser' + +export function parse_anplusb(expr: string): CSSNode | null { + const arena = new CSSDataArena(64) + const parser = new ANplusBParser(arena, expr) + const nodeIndex = parser.parse_anplusb(0, expr.length) + + if (nodeIndex === null) return null + return new CSSNode(arena, expr, nodeIndex) +} diff --git a/src/parse.test.ts b/src/parse.test.ts index a953b10..b03be53 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -39,7 +39,8 @@ describe('parse()', () => { const result = parse('body { color: red; margin: 0; }') const rule = result.first_child! - const [_selector, decl1, decl2] = rule.children + const [_selector, block] = rule.children + const [decl1, decl2] = block.children expect(decl1.type).toBe(NODE_DECLARATION) expect(decl1.name).toBe('color') expect(decl2.type).toBe(NODE_DECLARATION) @@ -57,7 +58,8 @@ describe('parse()', () => { const result = parse('body { color: red; }', { parse_values: true }) const rule = result.first_child! - const [_selector, decl] = rule.children + const [_selector, block] = rule.children + const decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('red') // With parse_values, should have value children diff --git a/src/parser-options.test.ts b/src/parser-options.test.ts index 4d8a504..16350bf 100644 --- a/src/parser-options.test.ts +++ b/src/parser-options.test.ts @@ -18,7 +18,8 @@ describe('Parser Options', () => { expect(selector?.type).toBe(NODE_SELECTOR_LIST) // Check value is parsed with detailed structure - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration).not.toBeNull() expect(declaration?.type).toBe(NODE_DECLARATION) expect(declaration?.has_children).toBe(true) @@ -36,7 +37,8 @@ describe('Parser Options', () => { expect(selector?.type).toBe(NODE_SELECTOR_LIST) // Check value is parsed - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration?.has_children).toBe(true) expect(declaration?.first_child?.type).toBe(NODE_VALUE_KEYWORD) }) @@ -55,7 +57,8 @@ describe('Parser Options', () => { expect(selector?.type).toBe(NODE_SELECTOR_LIST) // Declaration should exist but have no value children - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration).not.toBeNull() expect(declaration?.type).toBe(NODE_DECLARATION) expect(declaration?.property).toBe('color') @@ -68,7 +71,8 @@ describe('Parser Options', () => { const root = parser.parse() const rule = root.first_child const selector = rule?.first_child - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration?.property).toBe('margin') expect(declaration?.value).toBe('10px 20px') @@ -80,7 +84,8 @@ describe('Parser Options', () => { const root = parser.parse() const rule = root.first_child const selector = rule?.first_child - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration?.property).toBe('color') expect(declaration?.value).toBe('rgb(255, 0, 0)') @@ -102,7 +107,8 @@ describe('Parser Options', () => { expect(selector?.has_children).toBe(false) // No detailed selector nodes // Values should still be parsed - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration?.has_children).toBe(true) expect(declaration?.first_child?.type).toBe(NODE_VALUE_KEYWORD) }) @@ -143,7 +149,8 @@ describe('Parser Options', () => { expect(selector?.has_children).toBe(false) // Declaration should have no value children - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child expect(declaration?.type).toBe(NODE_DECLARATION) expect(declaration?.property).toBe('color') expect(declaration?.value).toBe('red') @@ -165,7 +172,8 @@ describe('Parser Options', () => { expect(selector?.type).toBe(NODE_SELECTOR_LIST) expect(selector?.has_children).toBe(false) - const decl1 = selector?.next_sibling + const block = selector?.next_sibling + const decl1 = block?.first_child expect(decl1?.property).toBe('margin') expect(decl1?.value).toBe('10px 20px') expect(decl1?.has_children).toBe(false) @@ -192,7 +200,8 @@ describe('Parser Options', () => { const selector = rule?.first_child // Can quickly iterate through declarations without parsing complex values - let decl = selector?.next_sibling + const block = selector?.next_sibling + let decl = block?.first_child const properties: string[] = [] while (decl) { if (decl.property) { @@ -231,7 +240,8 @@ describe('Parser Options', () => { const root = parser.parse() const rule = root.first_child const selector = rule?.first_child - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child // Should use defaults (both enabled) expect(selector?.type).toBe(NODE_SELECTOR_LIST) @@ -243,7 +253,8 @@ describe('Parser Options', () => { const root = parser.parse() const rule = root.first_child const selector = rule?.first_child - const declaration = selector?.next_sibling + const block = selector?.next_sibling + const declaration = block?.first_child // Selector should still be parsed (default true) expect(selector?.type).toBe(NODE_SELECTOR_LIST) diff --git a/src/parser.test.ts b/src/parser.test.ts index ebefa89..5c8cdf9 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -1,5 +1,20 @@ import { describe, test, expect } from 'vitest' -import { Parser, NODE_STYLESHEET, NODE_STYLE_RULE, NODE_AT_RULE, NODE_DECLARATION } from './parser' +import { + Parser, + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_AT_RULE, + NODE_DECLARATION, + NODE_BLOCK, + NODE_SELECTOR_LIST, + NODE_SELECTOR, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_NESTING, +} from './parser' +import { parse } from './parse' +import { ATTR_OPERATOR_PIPE_EQUAL } from './arena' describe('Parser', () => { describe('basic parsing', () => { @@ -85,14 +100,51 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const selector = rule.first_child! + const selectorlist = rule.first_child! // With parseSelectors enabled, selector is now detailed - expect(selector.offset).toBe(0) + expect(selectorlist.offset).toBe(0) // Selector includes tokens up to but not including the '{' // Whitespace is skipped by lexer, so actual length is 16 - expect(selector.length).toBe(16) // "div.class > p#id".length - expect(selector.text).toBe('div.class > p#id') + expect(selectorlist.length).toBe(16) // "div.class > p#id".length + expect(selectorlist.text).toBe('div.class > p#id') + + const selector = selectorlist.first_child! + expect(selector.children[0].text).toBe('div') + expect(selector.children[1].text).toBe('.class') + expect(selector.children[2].text).toBe('>') + expect(selector.children[3].text).toBe('p') + expect(selector.children[4].text).toBe('#id') + }) + + test('should parse pseudo class selector', () => { + const source = 'p:has(a) {}' + const root = parse(source) + const rule = root.first_child! + const selectorlist = rule.first_child! + const selector = selectorlist.first_child! + + expect(selector.type).toBe(NODE_SELECTOR) + expect(selector.children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selector.children[1].type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(selector.children[2]).toBeUndefined() + const pseudo = selector.children[1] + expect(pseudo.text).toBe(':has(a)') + expect(pseudo.children).toHaveLength(1) + }) + + test('attribute selector should have name, value and operator', () => { + const source = '[root|="test"] {}' + const root = parse(source) + const rule = root.first_child! + const selectorlist = rule.first_child! + const selector = selectorlist.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + const s = selector.children[0] + expect(s.type).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(s.attr_operator).toEqual(ATTR_OPERATOR_PIPE_EQUAL) + expect(s.name).toBe('root') + expect(s.value).toBe('"test"') }) }) @@ -103,7 +155,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(false) @@ -115,7 +168,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! // Property name stored in the 'name' property expect(declaration.name).toBe('color') @@ -127,7 +181,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, decl1, decl2] = rule.children + const [_selector, block] = rule.children + const [decl1, decl2] = block.children expect(decl1.type).toBe(NODE_DECLARATION) expect(decl2.type).toBe(NODE_DECLARATION) @@ -140,7 +195,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -152,7 +208,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -164,7 +221,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.is_important).toBe(true) @@ -176,7 +234,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) }) @@ -187,7 +246,8 @@ describe('Parser', () => { const root = parser.parse() const rule = root.first_child! - const [_selector, declaration] = rule.children + const [_selector, block] = rule.children + const declaration = block.first_child! expect(declaration.type).toBe(NODE_DECLARATION) expect(declaration.name).toBe('background') @@ -229,7 +289,8 @@ describe('Parser', () => { expect(media.name).toBe('MEDIA') expect(media.has_children).toBe(true) // Should parse as conditional (containing rules) - const nestedRule = media.first_child! + const block = media.block! + const nestedRule = block.first_child! expect(nestedRule.type).toBe(NODE_STYLE_RULE) }) @@ -243,7 +304,8 @@ describe('Parser', () => { expect(fontFace.name).toBe('Font-Face') expect(fontFace.has_children).toBe(true) // Should parse as declaration at-rule (containing declarations) - const decl = fontFace.first_child! + const block = fontFace.block! + const decl = block.first_child! expect(decl.type).toBe(NODE_DECLARATION) }) @@ -270,7 +332,8 @@ describe('Parser', () => { expect(media.name).toBe('media') expect(media.has_children).toBe(true) - const nestedRule = media.first_child! + const block = media.block! + const nestedRule = block.first_child! expect(nestedRule.type).toBe(NODE_STYLE_RULE) }) @@ -331,7 +394,8 @@ describe('Parser', () => { expect(fontFace.has_children).toBe(true) // Should have declarations as children - const [decl1, decl2] = fontFace.children + const block = fontFace.block! + const [decl1, decl2] = block.children expect(decl1.type).toBe(NODE_DECLARATION) expect(decl2.type).toBe(NODE_DECLARATION) }) @@ -345,7 +409,8 @@ describe('Parser', () => { expect(page.type).toBe(NODE_AT_RULE) expect(page.name).toBe('page') - const decl = page.first_child! + const block = page.block! + const decl = block.first_child! expect(decl.type).toBe(NODE_DECLARATION) }) @@ -358,7 +423,8 @@ describe('Parser', () => { expect(counterStyle.type).toBe(NODE_AT_RULE) expect(counterStyle.name).toBe('counter-style') - const decl = counterStyle.first_child! + const block = counterStyle.block! + const decl = block.first_child! expect(decl.type).toBe(NODE_DECLARATION) }) }) @@ -372,11 +438,13 @@ describe('Parser', () => { const supports = root.first_child! expect(supports.name).toBe('supports') - const media = supports.first_child! + const supports_block = supports.block! + const media = supports_block.first_child! expect(media.type).toBe(NODE_AT_RULE) expect(media.name).toBe('media') - const rule = media.first_child! + const media_block = media.block! + const rule = media_block.first_child! expect(rule.type).toBe(NODE_STYLE_RULE) }) }) @@ -404,7 +472,8 @@ describe('Parser', () => { let parent = root.first_child! expect(parent.type).toBe(NODE_STYLE_RULE) - let [_selector, decl, nested_rule] = parent.children + let [_selector, block] = parent.children + let [decl, nested_rule] = block.children expect(decl.type).toBe(NODE_DECLARATION) expect(decl.name).toBe('color') @@ -420,7 +489,8 @@ describe('Parser', () => { let root = parser.parse() let parent = root.first_child! - let [_selector, _decl, nested_rule] = parent.children + let [_selector, block] = parent.children + let [_decl, nested_rule] = block.children expect(nested_rule.type).toBe(NODE_STYLE_RULE) let nested_selector = nested_rule.first_child! @@ -433,7 +503,8 @@ describe('Parser', () => { let root = parser.parse() let parent = root.first_child! - let [_selector, nested1, nested2] = parent.children + let [_selector, block] = parent.children + let [nested1, nested2] = block.children expect(nested1.type).toBe(NODE_STYLE_RULE) expect(nested2.type).toBe(NODE_STYLE_RULE) @@ -445,13 +516,16 @@ describe('Parser', () => { let root = parser.parse() let a = root.first_child! - let [_selector_a, b] = a.children + let [_selector_a, block_a] = a.children + let b = block_a.first_child! expect(b.type).toBe(NODE_STYLE_RULE) - let [_selector_b, c] = b.children + let [_selector_b, block_b] = b.children + let c = block_b.first_child! expect(c.type).toBe(NODE_STYLE_RULE) - let [_selector_c, decl] = c.children + let [_selector_c, block_c] = c.children + let decl = block_c.first_child! expect(decl.type).toBe(NODE_DECLARATION) expect(decl.name).toBe('color') }) @@ -462,13 +536,15 @@ describe('Parser', () => { let root = parser.parse() let card = root.first_child! - let [_selector, decl, media] = card.children + let [_selector, block] = card.children + let [decl, media] = block.children expect(decl.type).toBe(NODE_DECLARATION) expect(media.type).toBe(NODE_AT_RULE) expect(media.name).toBe('media') - let nested_decl = media.first_child! + let media_block = media.block! + let nested_decl = media_block.first_child! expect(nested_decl.type).toBe(NODE_DECLARATION) expect(nested_decl.name).toBe('padding') }) @@ -514,7 +590,8 @@ describe('Parser', () => { let root = parser.parse() let card = root.first_child! - let [_selector, decl1, title, decl2, body] = card.children + let [_selector, block] = card.children + let [decl1, title, decl2, body] = block.children expect(decl1.type).toBe(NODE_DECLARATION) expect(decl1.name).toBe('color') @@ -538,7 +615,8 @@ describe('Parser', () => { expect(keyframes.type).toBe(NODE_AT_RULE) expect(keyframes.name).toBe('keyframes') - let [from_rule, to_rule] = keyframes.children + let block = keyframes.block! + let [from_rule, to_rule] = block.children expect(from_rule.type).toBe(NODE_STYLE_RULE) expect(to_rule.type).toBe(NODE_STYLE_RULE) @@ -555,7 +633,8 @@ describe('Parser', () => { let root = parser.parse() let keyframes = root.first_child! - let [rule0, rule50, rule100] = keyframes.children + let block = keyframes.block! + let [rule0, rule50, rule100] = block.children expect(rule0.type).toBe(NODE_STYLE_RULE) expect(rule50.type).toBe(NODE_STYLE_RULE) @@ -571,7 +650,8 @@ describe('Parser', () => { let root = parser.parse() let keyframes = root.first_child! - let [rule1, _rule2] = keyframes.children + let block = keyframes.block! + let [rule1, _rule2] = block.children let selector1 = rule1.first_child! expect(selector1.text).toBe('0%, 100%') @@ -585,13 +665,15 @@ describe('Parser', () => { let root = parser.parse() let parent = root.first_child! - let [_selector, nest] = parent.children + let [_selector, block] = parent.children + let nest = block.first_child! expect(nest.type).toBe(NODE_AT_RULE) expect(nest.name).toBe('nest') expect(nest.has_children).toBe(true) - let decl = nest.first_child! + let nest_block = nest.block! + let decl = nest_block.first_child! expect(decl.type).toBe(NODE_DECLARATION) expect(decl.name).toBe('color') }) @@ -602,7 +684,8 @@ describe('Parser', () => { let root = parser.parse() let a = root.first_child! - let [_selector, nest] = a.children + let [_selector, block] = a.children + let nest = block.first_child! expect(nest.type).toBe(NODE_AT_RULE) expect(nest.name).toBe('nest') @@ -635,8 +718,8 @@ describe('Parser', () => { let rule = root.first_child! expect(rule.type).toBe(NODE_STYLE_RULE) - // Only has selector, no declarations - expect(rule.children.length).toBe(1) + // Only has selector and empty block + expect(rule.children.length).toBe(2) }) test('should handle declaration without value', () => { @@ -645,7 +728,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.type).toBe(NODE_DECLARATION) }) @@ -655,7 +739,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - expect(rule.children.length).toBeGreaterThan(0) + // Rule has selector + block + expect(rule.children.length).toBe(2) }) test('should skip invalid tokens in declaration block', () => { @@ -664,8 +749,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - // Should have selector + valid declarations - expect(rule.children.length).toBeGreaterThan(1) + // Should have selector + block + expect(rule.children.length).toBe(2) }) test('should handle declaration without colon', () => { @@ -674,8 +759,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - // Parser tries to interpret "color red" as nested rule, still has children - expect(rule.children.length).toBeGreaterThan(0) + // Parser tries to interpret "color red" as nested rule, still has selector + block + expect(rule.children.length).toBe(2) }) test('should handle at-rule without name', () => { @@ -713,7 +798,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('-webkit-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -724,7 +810,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('-moz-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -735,7 +822,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('-ms-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -746,7 +834,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('-o-transform') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -757,7 +846,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('transform') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -768,7 +858,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('background-color') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -779,7 +870,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('--primary-color') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -790,7 +882,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, webkit, moz, standard] = rule.children + let [_selector, block] = rule.children + let [webkit, moz, standard] = block.children expect(webkit.name).toBe('-webkit-transform') expect(webkit.is_vendor_prefixed).toBe(true) @@ -808,7 +901,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('-webkit-border-top-left-radius') expect(decl.is_vendor_prefixed).toBe(true) }) @@ -820,7 +914,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('border-radius') expect(decl.is_vendor_prefixed).toBe(false) }) @@ -1052,7 +1147,8 @@ describe('Parser', () => { let card = root.first_child! expect(card.type).toBe(NODE_STYLE_RULE) - expect(card.children.length).toBeGreaterThan(4) + // Card has selector + block + expect(card.children.length).toBe(2) }) test('should parse multiple at-rules with nesting', () => { @@ -1085,7 +1181,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl1, decl2, decl3] = rule.children + let [_selector, block] = rule.children + let [decl1, decl2, decl3] = block.children expect(decl1.name).toBe('-webkit-transform') expect(decl2.name).toBe('-moz-transform') expect(decl3.name).toBe('transform') @@ -1116,8 +1213,10 @@ describe('Parser', () => { let root = parser.parse() let supports = root.first_child! - let media = supports.first_child! - let layer = media.first_child! + let supports_block = supports.block! + let media = supports_block.first_child! + let media_block = media.block! + let layer = media_block.first_child! expect(supports.name).toBe('supports') expect(media.name).toBe('media') expect(layer.name).toBe('layer') @@ -1129,7 +1228,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, width_decl, bg_decl] = rule.children + let [_selector, block] = rule.children + let [width_decl, bg_decl] = block.children expect(width_decl.name).toBe('width') expect(bg_decl.name).toBe('background') }) @@ -1173,9 +1273,10 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - expect(rule.children.length).toBeGreaterThan(1) + let block = rule.block! + expect(block.children.length).toBeGreaterThan(1) // Check at least first declaration has important flag - let declarations = rule.children.filter((c) => c.type === NODE_DECLARATION) + let declarations = block.children.filter((c) => c.type === NODE_DECLARATION) expect(declarations.length).toBeGreaterThan(0) expect(declarations[0].is_important).toBe(true) }) @@ -1201,7 +1302,8 @@ describe('Parser', () => { let rule = root.first_child! // Comments don't break parsing expect(rule.type).toBe(NODE_STYLE_RULE) - expect(rule.children.length).toBeGreaterThan(0) + // Rule has selector + block + expect(rule.children.length).toBe(2) }) test('should skip comments in selector', () => { @@ -1262,7 +1364,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl1, decl2] = rule.children + let [_selector, block] = rule.children + let [decl1, decl2] = block.children expect(decl1.name).toBe('color') expect(decl2.name).toBe('margin') }) @@ -1305,7 +1408,8 @@ describe('Parser', () => { let font_face = root.first_child! expect(font_face.name).toBe('font-face') - expect(font_face.children.length).toBeGreaterThan(3) + let block = font_face.block! + expect(block.children.length).toBeGreaterThan(3) }) test('should parse @keyframes with mixed percentages and keywords', () => { @@ -1314,7 +1418,8 @@ describe('Parser', () => { let root = parser.parse() let keyframes = root.first_child! - expect(keyframes.children.length).toBe(3) + let block = keyframes.block! + expect(block.children.length).toBe(3) }) test('should parse @counter-style', () => { @@ -1324,7 +1429,8 @@ describe('Parser', () => { let counter = root.first_child! expect(counter.name).toBe('counter-style') - expect(counter.children.length).toBeGreaterThan(1) + let block = counter.block! + expect(block.children.length).toBeGreaterThan(1) }) test('should parse @property', () => { @@ -1378,7 +1484,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl1, decl2] = rule.children + let [_selector, block] = rule.children + let [decl1, decl2] = block.children expect(decl1.offset).toBeLessThan(decl2.offset) }) @@ -1391,7 +1498,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('blue') @@ -1403,7 +1511,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('padding') expect(decl.value).toBe('1rem 2rem 3rem 4rem') @@ -1415,7 +1524,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('background') expect(decl.value).toBe('linear-gradient(to bottom, red, blue)') @@ -1427,7 +1537,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('width') expect(decl.value).toBe('calc(100% - 2rem)') @@ -1439,7 +1550,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('blue') @@ -1452,7 +1564,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('blue') @@ -1464,7 +1577,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('--brand-color') expect(decl.value).toBe('rgb(0% 10% 50% / 0.5)') @@ -1476,7 +1590,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('var(--primary-color)') @@ -1488,7 +1603,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('transform') expect(decl.value).toBe('translate(calc(50% - 1rem), 0)') @@ -1500,7 +1616,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe('blue') @@ -1512,7 +1629,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('color') expect(decl.value).toBe(null) @@ -1524,7 +1642,8 @@ describe('Parser', () => { let root = parser.parse() let rule = root.first_child! - let [_selector, decl] = rule.children + let [_selector, block] = rule.children + let decl = block.first_child! expect(decl.name).toBe('background') expect(decl.value).toBe('url("image.png")') @@ -1653,4 +1772,227 @@ describe('Parser', () => { expect(atrule.value).toBe('(min-width: 768px)') }) }) + + describe('atrule block children', () => { + let css = `@layer test { a {} }` + let sheet = parse(css) + let atrule = sheet?.first_child + let rule = atrule?.block?.first_child + + test('atrule should have block', () => { + expect(sheet.type).toBe(NODE_STYLESHEET) + expect(atrule!.type).toBe(NODE_AT_RULE) + expect(atrule?.block?.type).toBe(NODE_BLOCK) + }) + + test('block children should be stylerule', () => { + expect(atrule!.block).not.toBeNull() + expect(rule!.type).toBe(NODE_STYLE_RULE) + expect(rule!.text).toBe('a {}') + }) + + test('rule should have selectorlist + block', () => { + expect(rule!.block).not.toBeNull() + expect(rule?.has_block).toBeTruthy() + expect(rule?.has_declarations).toBeFalsy() + expect(rule?.first_child!.type).toBe(NODE_SELECTOR_LIST) + }) + + test('has correct nested selectors', () => { + let list = rule?.first_child + expect(list!.type).toBe(NODE_SELECTOR_LIST) + expect(list!.children).toHaveLength(1) + expect(list?.first_child?.type).toEqual(NODE_SELECTOR) + expect(list?.first_child?.text).toEqual('a') + }) + }) + + describe('block text excludes braces', () => { + test('empty at-rule block should have empty text', () => { + const parser = new Parser('@layer test {}') + const root = parser.parse() + const atRule = root.first_child! + + expect(atRule.has_block).toBe(true) + expect(atRule.block!.text).toBe('') + expect(atRule.text).toBe('@layer test {}') // at-rule includes braces + }) + + test('at-rule block with content should exclude braces', () => { + const parser = new Parser('@layer test { .foo { color: red; } }') + const root = parser.parse() + const atRule = root.first_child! + + expect(atRule.has_block).toBe(true) + expect(atRule.block!.text).toBe(' .foo { color: red; } ') + expect(atRule.text).toBe('@layer test { .foo { color: red; } }') // at-rule includes braces + }) + + test('empty style rule block should have empty text', () => { + const parser = new Parser('body {}') + const root = parser.parse() + const styleRule = root.first_child! + + expect(styleRule.has_block).toBe(true) + expect(styleRule.block!.text).toBe('') + expect(styleRule.text).toBe('body {}') // style rule includes braces + }) + + test('style rule block with declaration should exclude braces', () => { + const parser = new Parser('body { color: red; }') + const root = parser.parse() + const styleRule = root.first_child! + + expect(styleRule.has_block).toBe(true) + expect(styleRule.block!.text).toBe(' color: red; ') + expect(styleRule.text).toBe('body { color: red; }') // style rule includes braces + }) + + test('nested style rule blocks should exclude braces', () => { + const parser = new Parser('.parent { .child { margin: 0; } }') + const root = parser.parse() + const parent = root.first_child! + const parentBlock = parent.block! + const child = parentBlock.first_child! + const childBlock = child.block! + + expect(parentBlock.text).toBe(' .child { margin: 0; } ') + expect(childBlock.text).toBe(' margin: 0; ') + }) + + test('at-rule with multiple declarations should exclude braces', () => { + const parser = new Parser('@font-face { font-family: "Test"; src: url(test.woff); }') + const root = parser.parse() + const atRule = root.first_child! + + expect(atRule.block!.text).toBe(' font-family: "Test"; src: url(test.woff); ') + }) + + test('media query with nested rules should exclude braces', () => { + const parser = new Parser('@media screen { body { color: blue; } }') + const root = parser.parse() + const mediaRule = root.first_child! + + expect(mediaRule.block!.text).toBe(' body { color: blue; } ') + }) + + test('block with no whitespace should be empty', () => { + const parser = new Parser('div{}') + const root = parser.parse() + const styleRule = root.first_child! + + expect(styleRule.block!.text).toBe('') + }) + + test('block with only whitespace should preserve whitespace', () => { + const parser = new Parser('div{ \n\t }') + const root = parser.parse() + const styleRule = root.first_child! + + expect(styleRule.block!.text).toBe(' \n\t ') + }) + }) + + describe('deeply nested modern CSS', () => { + test('@container should parse nested style rules', () => { + let css = `@container (width > 0) { div { color: red; } }` + let ast = parse(css) + + const container = ast.first_child! + expect(container.type).toBe(NODE_AT_RULE) + expect(container.name).toBe('container') + + const containerBlock = container.block! + const rule = containerBlock.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('@container should parse rules with :has() selector', () => { + let css = `@container (width > 0) { ul:has(li) { color: red; } }` + let ast = parse(css) + + const container = ast.first_child! + const containerBlock = container.block! + const rule = containerBlock.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('modern CSS example by Vadim Makeev', () => { + let css = ` + @layer what { + @container (width > 0) { + ul:has(:nth-child(1 of li)) { + @media (height > 0) { + &:hover { + --is: this; + } + } + } + } + }` + let ast = parse(css) + + // Root should be stylesheet + expect(ast.type).toBe(NODE_STYLESHEET) + expect(ast.has_children).toBe(true) + + // First child: @layer what + const layer = ast.first_child! + expect(layer.type).toBe(NODE_AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.prelude).toBe('what') + expect(layer.has_block).toBe(true) + + // Inside @layer: @container (width > 0) + const container = layer.block!.first_child! + expect(container.type).toBe(NODE_AT_RULE) + expect(container.name).toBe('container') + expect(container.prelude).toBe('(width > 0)') + expect(container.has_block).toBe(true) + + // Inside @container: ul:has(:nth-child(1 of li)) + const ulRule = container.block!.first_child! + expect(ulRule.type).toBe(NODE_STYLE_RULE) + expect(ulRule.has_block).toBe(true) + + // Verify selector contains ul and :has(:nth-child(1 of li)) + const selectorList = ulRule.first_child! + expect(selectorList.type).toBe(NODE_SELECTOR_LIST) + const selector = selectorList.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + // The selector should have ul type selector and :has() pseudo-class + const selectorParts = selector.children + expect(selectorParts.length).toBeGreaterThan(0) + expect(selectorParts[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selectorParts[0].text).toBe('ul') + + // Inside ul rule: @media (height > 0) + const media = ulRule.block!.first_child! + expect(media.type).toBe(NODE_AT_RULE) + expect(media.name).toBe('media') + expect(media.prelude).toBe('(height > 0)') + expect(media.has_block).toBe(true) + + // Inside @media: &:hover + const nestingRule = media.block!.first_child! + expect(nestingRule.type).toBe(NODE_STYLE_RULE) + expect(nestingRule.has_block).toBe(true) + + // Verify nesting selector &:hover + const nestingSelectorList = nestingRule.first_child! + expect(nestingSelectorList.type).toBe(NODE_SELECTOR_LIST) + const nestingSelector = nestingSelectorList.first_child! + expect(nestingSelector.type).toBe(NODE_SELECTOR) + const nestingParts = nestingSelector.children + expect(nestingParts.length).toBeGreaterThan(0) + expect(nestingParts[0].type).toBe(NODE_SELECTOR_NESTING) + expect(nestingParts[0].text).toBe('&') + + // Inside &:hover: --is: this declaration + const declaration = nestingRule.block!.first_child! + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.property).toBe('--is') + expect(declaration.value).toBe('this') + }) + }) }) diff --git a/src/parser.ts b/src/parser.ts index b8b9022..e6d3819 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,6 +8,7 @@ import { NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_AT_RULE, + NODE_BLOCK, FLAG_IMPORTANT, FLAG_HAS_BLOCK, FLAG_VENDOR_PREFIXED, @@ -166,9 +167,20 @@ export class Parser { // Error recovery: skip to next rule return null } + // Capture block start position (right after '{') before consuming the token + let block_start = this.lexer.token_end this.next_token() // consume '{' this.arena.set_flag(style_rule, FLAG_HAS_BLOCK) // Style rules always have blocks + // Create block node + let block_line = this.lexer.token_line + let block_column = this.lexer.token_column + let block_node = this.arena.create_node() + this.arena.set_type(block_node, NODE_BLOCK) + this.arena.set_start_offset(block_node, block_start) + this.arena.set_start_line(block_node, block_line) + this.arena.set_start_column(block_node, block_column) + // Parse declarations block (and nested rules for CSS Nesting) while (!this.is_eof()) { let token_type = this.peek_type() @@ -178,7 +190,7 @@ export class Parser { if (token_type === TOKEN_AT_KEYWORD) { let nested_at_rule = this.parse_atrule() if (nested_at_rule !== null) { - this.arena.append_child(style_rule, nested_at_rule) + this.arena.append_child(block_node, nested_at_rule) } else { this.next_token() } @@ -189,28 +201,36 @@ export class Parser { let declaration = this.parse_declaration() if (declaration !== null) { this.arena.set_flag(style_rule, FLAG_HAS_DECLARATIONS) - this.arena.append_child(style_rule, declaration) + this.arena.append_child(block_node, declaration) continue } // If not a declaration, try parsing as nested style rule let nested_rule = this.parse_style_rule() if (nested_rule !== null) { - this.arena.append_child(style_rule, nested_rule) + this.arena.append_child(block_node, nested_rule) } else { // Skip unknown tokens this.next_token() } } - // Expect '}' + // Expect '}' and calculate lengths (block excludes brace, rule includes it) + let block_end = this.lexer.token_start + let rule_end = this.lexer.token_end if (this.peek_type() === TOKEN_RIGHT_BRACE) { + block_end = this.lexer.token_start // Position of '}' (not included in block) + rule_end = this.lexer.token_end // Position after '}' (included in rule) this.next_token() // consume '}' } + // Set block length and append to style rule + this.arena.set_length(block_node, block_end - block_start) + this.arena.append_child(style_rule, block_node) + // Set the rule's offsets this.arena.set_start_offset(style_rule, rule_start) - this.arena.set_length(style_rule, this.lexer.token_end - rule_start) + this.arena.set_length(style_rule, rule_end - rule_start) return style_rule } @@ -261,10 +281,29 @@ export class Parser { let decl_line = this.lexer.token_line let decl_column = this.lexer.token_column + // Lookahead: save lexer state before consuming + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + let saved_token_type = this.lexer.token_type + let saved_token_start = this.lexer.token_start + let saved_token_end = this.lexer.token_end + let saved_token_line = this.lexer.token_line + let saved_token_column = this.lexer.token_column + this.next_token() // consume property name // Expect ':' if (this.peek_type() !== TOKEN_COLON) { + // Restore lexer state and return null + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + this.lexer.token_type = saved_token_type + this.lexer.token_start = saved_token_start + this.lexer.token_end = saved_token_end + this.lexer.token_line = saved_token_line + this.lexer.token_column = saved_token_column return null } this.next_token() // consume ':' @@ -297,6 +336,20 @@ export class Parser { let token_type = this.peek_type() if (token_type === TOKEN_SEMICOLON || token_type === TOKEN_RIGHT_BRACE) break + // If we encounter '{', this is actually a style rule, not a declaration + if (token_type === TOKEN_LEFT_BRACE) { + // Restore lexer state and return null + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + this.lexer.token_type = saved_token_type + this.lexer.token_start = saved_token_start + this.lexer.token_end = saved_token_end + this.lexer.token_line = saved_token_line + this.lexer.token_column = saved_token_column + return null + } + // Check for ! followed by any identifier (optimized: only check when we see '!') if (token_type === TOKEN_DELIM && this.source[this.lexer.token_start] === '!') { // Mark end of value before !important @@ -306,6 +359,7 @@ export class Parser { if (next_type === TOKEN_IDENT) { has_important = true last_end = this.lexer.token_end + this.next_token() // Advance to next token after "important" break } } @@ -413,9 +467,20 @@ export class Parser { // Check if this at-rule has a block or is a statement if (this.peek_type() === TOKEN_LEFT_BRACE) { + // Capture block start position (right after '{') before consuming the token + let block_start = this.lexer.token_end this.next_token() // consume '{' this.arena.set_flag(at_rule, FLAG_HAS_BLOCK) // At-rule has a block + // Create block node + let block_line = this.lexer.token_line + let block_column = this.lexer.token_column + let block_node = this.arena.create_node() + this.arena.set_type(block_node, NODE_BLOCK) + this.arena.set_start_offset(block_node, block_start) + this.arena.set_start_line(block_node, block_line) + this.arena.set_start_column(block_node, 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) let is_conditional = this.atrule_is_conditional(at_rule_name) @@ -428,7 +493,7 @@ export class Parser { let declaration = this.parse_declaration() if (declaration !== null) { - this.arena.append_child(at_rule, declaration) + this.arena.append_child(block_node, declaration) } else { this.next_token() } @@ -443,7 +508,7 @@ export class Parser { if (token_type === TOKEN_AT_KEYWORD) { let nested_at_rule = this.parse_atrule() if (nested_at_rule !== null) { - this.arena.append_child(at_rule, nested_at_rule) + this.arena.append_child(block_node, nested_at_rule) } else { this.next_token() } @@ -453,14 +518,14 @@ export class Parser { // Try to parse as declaration first let declaration = this.parse_declaration() if (declaration !== null) { - this.arena.append_child(at_rule, declaration) + this.arena.append_child(block_node, declaration) continue } // If not a declaration, try parsing as nested style rule let nested_rule = this.parse_style_rule() if (nested_rule !== null) { - this.arena.append_child(at_rule, nested_rule) + this.arena.append_child(block_node, nested_rule) } else { // Skip unknown tokens this.next_token() @@ -474,18 +539,27 @@ export class Parser { let rule = this.parse_rule() if (rule !== null) { - this.arena.append_child(at_rule, rule) + this.arena.append_child(block_node, rule) } else { this.next_token() } } } - // Consume '}' + // Consume '}' (block excludes closing brace, but at-rule includes it) if (this.peek_type() === TOKEN_RIGHT_BRACE) { - last_end = this.lexer.token_end + let block_end = this.lexer.token_start // Position of '}' (not included in block) this.next_token() + last_end = this.lexer.token_end // Position after '}' (included in at-rule) + + // Set block length (excludes closing brace) + this.arena.set_length(block_node, block_end - block_start) + } else { + // No closing brace found (error recovery) + this.arena.set_length(block_node, last_end - block_start) } + + this.arena.append_child(at_rule, block_node) } else if (this.peek_type() === TOKEN_SEMICOLON) { // Statement at-rule (like @import, @namespace) last_end = this.lexer.token_end @@ -517,6 +591,7 @@ export { NODE_DECLARATION, NODE_SELECTOR, NODE_COMMENT, + NODE_BLOCK, NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, NODE_VALUE_DIMENSION, @@ -534,6 +609,9 @@ export { NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, NODE_PRELUDE_MEDIA_QUERY, NODE_PRELUDE_MEDIA_FEATURE, NODE_PRELUDE_MEDIA_TYPE, diff --git a/src/selector-parser.test.ts b/src/selector-parser.test.ts index 0eaaa5b..efa49cf 100644 --- a/src/selector-parser.test.ts +++ b/src/selector-parser.test.ts @@ -13,7 +13,11 @@ import { NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, } from './arena' +import { parse_selector } from './parse-selector' // Helper to create a selector parser and parse a selector function parseSelector(selector: string) { @@ -368,7 +372,8 @@ describe('SelectorParser', () => { const child = arena.get_first_child(selectorWrapper) expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) expect(getNodeText(arena, source, child)).toBe('[type="text"]') - expect(getNodeContent(arena, source, child)).toBe('type="text"') + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('type') }) it('should parse attribute with operator', () => { @@ -410,7 +415,10 @@ describe('SelectorParser', () => { const selectorWrapper = arena.get_first_child(rootNode) const child = arena.get_first_child(selectorWrapper) expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeContent(arena, source, child)).toBe('data-test="value"') + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') + // Full text still includes brackets + expect(getNodeText(arena, source, child)).toBe('[ data-test="value" ]') }) it('should trim comments from attribute selectors', () => { @@ -422,7 +430,8 @@ describe('SelectorParser', () => { const selectorWrapper = arena.get_first_child(rootNode) const child = arena.get_first_child(selectorWrapper) expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeContent(arena, source, child)).toBe('data-test="value"') + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') }) it('should trim whitespace and comments from attribute selectors', () => { @@ -434,7 +443,8 @@ describe('SelectorParser', () => { const selectorWrapper = arena.get_first_child(rootNode) const child = arena.get_first_child(selectorWrapper) expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeContent(arena, source, child)).toBe('data-test="value"') + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') }) }) @@ -627,17 +637,128 @@ describe('SelectorParser', () => { expect(getNodeContent(arena, source, child)).toBe('where') }) - it('should parse :has() pseudo-class', () => { - const { arena, rootNode, source } = parseSelector('div:has(> p)') + it('should parse :has(a) pseudo-class', () => { + const root = parse_selector('div:has(a)') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [_, has] = root.first_child!.children - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('has') + expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(has.text).toBe(':has(a)') + + // Check children of :has() - should contain selector list with > combinator and p type selector + expect(has.has_children).toBe(true) + const selectorList = has.first_child! + expect(selectorList.type).toBe(NODE_SELECTOR_LIST) + + // Selector list contains one selector + const selector = selectorList.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + + const selectorParts = selector.children + expect(selectorParts).toHaveLength(1) + expect(selectorParts[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selectorParts[0].text).toBe('a') + }) + + it('should parse :has(> p) pseudo-class', () => { + const root = parse_selector('div:has(> p)') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [div, has] = root.first_child!.children + expect(div.type).toBe(NODE_SELECTOR_TYPE) + expect(div.text).toBe('div') + + expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(has.text).toBe(':has(> p)') + + // Check children of :has() - should contain selector list with > combinator and p type selector + expect(has.has_children).toBe(true) + const selectorList = has.first_child! + expect(selectorList.type).toBe(NODE_SELECTOR_LIST) + + // Selector list contains one selector + const selector = selectorList.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + + const selectorParts = selector.children + expect(selectorParts).toHaveLength(2) + expect(selectorParts[0].type).toBe(NODE_SELECTOR_COMBINATOR) + expect(selectorParts[0].text).toBe('>') + expect(selectorParts[1].type).toBe(NODE_SELECTOR_TYPE) + expect(selectorParts[1].text).toBe('p') + }) + + it('should parse :has() with adjacent sibling combinator (+)', () => { + const root = parse_selector('div:has(+ p)') + const has = root.first_child!.children[1] + const selectorList = has.first_child! + const selector = selectorList.first_child! + const parts = selector.children + + expect(parts).toHaveLength(2) + expect(parts[0].type).toBe(NODE_SELECTOR_COMBINATOR) + expect(parts[0].text).toBe('+') + expect(parts[1].type).toBe(NODE_SELECTOR_TYPE) + expect(parts[1].text).toBe('p') + }) + + it('should parse :has() with general sibling combinator (~)', () => { + const root = parse_selector('div:has(~ p)') + const has = root.first_child!.children[1] + const selectorList = has.first_child! + const selector = selectorList.first_child! + const parts = selector.children + + expect(parts).toHaveLength(2) + expect(parts[0].type).toBe(NODE_SELECTOR_COMBINATOR) + expect(parts[0].text).toBe('~') + expect(parts[1].type).toBe(NODE_SELECTOR_TYPE) + expect(parts[1].text).toBe('p') + }) + + it('should parse :has() with descendant selector (no combinator)', () => { + const root = parse_selector('div:has(p)') + const has = root.first_child!.children[1] + const selectorList = has.first_child! + const selector = selectorList.first_child! + + expect(selector.children).toHaveLength(1) + expect(selector.children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selector.children[0].text).toBe('p') + }) + + it('should parse :has() with multiple selectors', () => { + const root = parse_selector('div:has(> p, + span)') + const has = root.first_child!.children[1] + + // Should have 2 selector children (selector list with 2 items) + expect(has.children).toHaveLength(1) // Selector list + const selectorList = has.first_child! + expect(selectorList.children).toHaveLength(2) // Two selectors in the list + + // First selector: > p + const firstSelector = selectorList.children[0] + expect(firstSelector.children).toHaveLength(2) + expect(firstSelector.children[0].text).toBe('>') + expect(firstSelector.children[1].text).toBe('p') + + // Second selector: + span + const secondSelector = selectorList.children[1] + expect(secondSelector.children).toHaveLength(2) + expect(secondSelector.children[0].text).toBe('+') + expect(secondSelector.children[1].text).toBe('span') + }) + + it('should handle empty :has()', () => { + const root = parse_selector('div:has()') + const has = root.first_child!.children[1] + + expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(has.text).toBe(':has()') + expect(has.has_children).toBe(false) }) it('should parse nesting with ampersand', () => { @@ -652,6 +773,50 @@ describe('SelectorParser', () => { expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_NESTING) expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) }) + + it('should parse nesting selector with descendant combinator as single selector', () => { + const { arena, rootNode, source } = parseSelector('& span') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + // Should have only ONE selector, not two + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) + + // The single selector should have 3 children: &, combinator (space), span + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_NESTING) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_COMBINATOR) + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[2])).toBe('span') + }) + + it('should parse nesting selector with child combinator as single selector', () => { + const { arena, rootNode, source } = parseSelector('& > div') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Should have only ONE selector, not two + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) + + // The single selector should have 3 children: &, combinator (>), div + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_NESTING) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[1]).trim()).toBe('>') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[2])).toBe('div') + }) }) describe('Edge cases', () => { @@ -773,5 +938,208 @@ describe('SelectorParser', () => { expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) expect(getNodeContent(arena, source, children[1])).toBe('nth-of-type') }) + + it('should parse ul:has(:nth-child(1 of li))', () => { + const root = parse_selector('ul:has(:nth-child(1 of li))') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [ul, has] = root.first_child!.children + expect(ul.type).toBe(NODE_SELECTOR_TYPE) + expect(ul.text).toBe('ul') + + expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(has.text).toBe(':has(:nth-child(1 of li))') + }) + + it('should parse :nth-child(1)', () => { + const root = parse_selector(':nth-child(1)') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(1) + const nth_child = root.first_child!.first_child! + expect(nth_child.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(nth_child.text).toBe(':nth-child(1)') + + // Should have An+B child node + expect(nth_child.children).toHaveLength(1) + const anplusb = nth_child.first_child! + expect(anplusb.type).toBe(NODE_SELECTOR_NTH) + expect(anplusb.nth_a).toBe(null) // No 'a' coefficient, just 'b' + expect(anplusb.nth_b).toBe('1') + }) + + it('should parse :nth-child(2n+1)', () => { + const root = parse_selector(':nth-child(2n+1)') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(1) + const nth_child = root.first_child!.first_child! + expect(nth_child.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(nth_child.text).toBe(':nth-child(2n+1)') + + // Should have An+B child node + expect(nth_child.children).toHaveLength(1) + const anplusb = nth_child.first_child! + expect(anplusb.type).toBe(NODE_SELECTOR_NTH) + expect(anplusb.nth_a).toBe('2n') + expect(anplusb.nth_b).toBe('1') + expect(anplusb.text).toBe('2n+1') + }) + + it('should parse :nth-child(2n of .selector)', () => { + const root = parse_selector(':nth-child(2n of .selector)') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(1) + const nth_child = root.first_child!.first_child! + expect(nth_child.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(nth_child.text).toBe(':nth-child(2n of .selector)') + + // Should have NTH_OF child node (An+B with selector) + expect(nth_child.children).toHaveLength(1) + const nth_of = nth_child.first_child! + expect(nth_of.type).toBe(NODE_SELECTOR_NTH_OF) + expect(nth_of.text).toBe('2n of .selector') + + // NTH_OF has two children: An+B and selector list + expect(nth_of.children).toHaveLength(2) + const anplusb = nth_of.first_child! + expect(anplusb.type).toBe(NODE_SELECTOR_NTH) + expect(anplusb.nth_a).toBe('2n') + expect(anplusb.nth_b).toBe(null) + + // Second child is the selector list + const selectorList = nth_of.children[1] + expect(selectorList.type).toBe(NODE_SELECTOR_LIST) + const selector = selectorList.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + expect(selector.first_child!.text).toBe('.selector') + }) + + test(':is(a, b)', () => { + const root = parse_selector(':is(a, b)') + + // Root is selector list + expect(root.type).toBe(NODE_SELECTOR_LIST) + + // First selector in the list + const selector = root.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + + // Selector has :is() pseudo-class + const isPseudoClass = selector.first_child! + expect(isPseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(isPseudoClass.text).toBe(':is(a, b)') + + // :is() has 1 child: a selector list + expect(isPseudoClass.children).toHaveLength(1) + const innerSelectorList = isPseudoClass.first_child! + expect(innerSelectorList.type).toBe(NODE_SELECTOR_LIST) + + // The selector list has 2 children: selector for 'a' and selector for 'b' + expect(innerSelectorList.children).toHaveLength(2) + + // First selector: 'a' + const selectorA = innerSelectorList.children[0] + expect(selectorA.type).toBe(NODE_SELECTOR) + expect(selectorA.children).toHaveLength(1) + expect(selectorA.children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selectorA.children[0].text).toBe('a') + + // Second selector: 'b' + const selectorB = innerSelectorList.children[1] + expect(selectorB.type).toBe(NODE_SELECTOR) + expect(selectorB.children).toHaveLength(1) + expect(selectorB.children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(selectorB.children[0].text).toBe('b') + }) + + test(':lang("nl", "de")', () => { + const root = parse_selector(':lang("nl", "de")') + + // Root is selector list + expect(root.type).toBe(NODE_SELECTOR_LIST) + + // First selector in the list + const selector = root.first_child! + expect(selector.type).toBe(NODE_SELECTOR) + + // Selector has :lang() pseudo-class + const langPseudoClass = selector.first_child! + expect(langPseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(langPseudoClass.text).toBe(':lang("nl", "de")') + + // :lang() has 2 children - language identifiers + expect(langPseudoClass.has_children).toBe(true) + expect(langPseudoClass.children).toHaveLength(2) + + // First language identifier: "nl" + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(NODE_SELECTOR_LANG) + expect(lang1.text).toBe('"nl"') + + // Second language identifier: "de" + const lang2 = langPseudoClass.children[1] + expect(lang2.type).toBe(NODE_SELECTOR_LANG) + expect(lang2.text).toBe('"de"') + }) + + test(':lang(en, fr) with unquoted identifiers', () => { + const root = parse_selector(':lang(en, fr)') + + const selector = root.first_child! + const langPseudoClass = selector.first_child! + + expect(langPseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(langPseudoClass.text).toBe(':lang(en, fr)') + + // :lang() has 2 children - language identifiers + expect(langPseudoClass.children).toHaveLength(2) + + // First language identifier: en + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(NODE_SELECTOR_LANG) + expect(lang1.text).toBe('en') + + // Second language identifier: fr + const lang2 = langPseudoClass.children[1] + expect(lang2.type).toBe(NODE_SELECTOR_LANG) + expect(lang2.text).toBe('fr') + }) + + test(':lang(en-US) single language with hyphen', () => { + const root = parse_selector(':lang(en-US)') + + const selector = root.first_child! + const langPseudoClass = selector.first_child! + + expect(langPseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(langPseudoClass.text).toBe(':lang(en-US)') + + // :lang() has 1 child - single language identifier + expect(langPseudoClass.children).toHaveLength(1) + + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(NODE_SELECTOR_LANG) + expect(lang1.text).toBe('en-US') + }) + + test(':lang("*-Latn") wildcard pattern', () => { + const root = parse_selector(':lang("*-Latn")') + + const selector = root.first_child! + const langPseudoClass = selector.first_child! + + expect(langPseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(langPseudoClass.text).toBe(':lang("*-Latn")') + + // :lang() has 1 child - wildcard language identifier + expect(langPseudoClass.children).toHaveLength(1) + + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(NODE_SELECTOR_LANG) + expect(lang1.text).toBe('"*-Latn"') + }) }) }) diff --git a/src/selector-parser.ts b/src/selector-parser.ts index a8fa397..af4ae4e 100644 --- a/src/selector-parser.ts +++ b/src/selector-parser.ts @@ -13,7 +13,17 @@ import { NODE_SELECTOR_COMBINATOR, NODE_SELECTOR_UNIVERSAL, NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, FLAG_VENDOR_PREFIXED, + ATTR_OPERATOR_NONE, + ATTR_OPERATOR_EQUAL, + ATTR_OPERATOR_TILDE_EQUAL, + ATTR_OPERATOR_PIPE_EQUAL, + ATTR_OPERATOR_CARET_EQUAL, + ATTR_OPERATOR_DOLLAR_EQUAL, + ATTR_OPERATOR_STAR_EQUAL, } from './arena' import { TOKEN_IDENT, @@ -28,8 +38,10 @@ import { TOKEN_RIGHT_PAREN, TOKEN_EOF, TOKEN_WHITESPACE, + TOKEN_STRING, } from './token-types' import { trim_boundaries, is_whitespace as is_whitespace_char, is_vendor_prefixed } from './string-utils' +import { ANplusBParser } from './anplusb-parser' export class SelectorParser { private lexer: Lexer @@ -47,7 +59,7 @@ export class SelectorParser { // Parse a selector range into selector nodes // Always returns a NODE_SELECTOR_LIST with selector components as children - parse_selector(start: number, end: number, line: number = 1, column: number = 1): number | null { + parse_selector(start: number, end: number, line: number = 1, column: number = 1, allow_relative: boolean = false): number | null { this.selector_end = end // Position lexer at selector start @@ -57,11 +69,11 @@ export class SelectorParser { // Parse selector list (comma-separated selectors) // Returns NODE_SELECTOR_LIST directly (no wrapper) - return this.parse_selector_list() + return this.parse_selector_list(allow_relative) } // Parse comma-separated selectors - private parse_selector_list(): number | null { + private parse_selector_list(allow_relative: boolean = false): number | null { let selectors: number[] = [] let list_start = this.lexer.pos let list_line = this.lexer.line @@ -72,7 +84,7 @@ export class SelectorParser { let selector_line = this.lexer.line let selector_column = this.lexer.column - let complex_selector = this.parse_complex_selector() + let complex_selector = this.parse_complex_selector(allow_relative) if (complex_selector !== null) { // Wrap the complex selector (chain of components) in a NODE_SELECTOR let selector_wrapper = this.arena.create_node() @@ -136,12 +148,44 @@ export class SelectorParser { // Parse a complex selector (with combinators) // e.g., "div.class > p + span" - private parse_complex_selector(): number | null { + private parse_complex_selector(allow_relative: boolean = false): number | null { let components: number[] = [] // Skip leading whitespace this.skip_whitespace() + // Check for leading combinator (relative selector) if allowed + if (allow_relative && this.lexer.pos < this.selector_end) { + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + + // Check if token is a combinator + if (token_type === TOKEN_DELIM) { + let ch = this.source.charCodeAt(this.lexer.token_start) + if (ch === 0x3e || ch === 0x2b || ch === 0x7e) { + // Found leading combinator (>, +, ~) - this is a relative selector + let combinator = this.create_combinator(this.lexer.token_start, this.lexer.token_end) + components.push(combinator) + this.skip_whitespace() + // Continue to parse the rest normally + } else { + // Not a combinator, restore position + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + } + } else { + // Not a delimiter, restore position + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + } + } + while (this.lexer.pos < this.selector_end) { if (this.lexer.pos >= this.selector_end) break @@ -163,7 +207,9 @@ export class SelectorParser { } // Peek ahead for comma or end - let pos_before = this.lexer.pos + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column this.skip_whitespace() if (this.lexer.pos >= this.selector_end) break @@ -171,19 +217,29 @@ export class SelectorParser { let token_type = this.lexer.token_type if (token_type === TOKEN_COMMA || this.lexer.pos >= this.selector_end) { // Reset position for comma handling - this.lexer.pos = pos_before + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column break } // Reset for next iteration - this.lexer.pos = pos_before + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column break } if (components.length === 0) return null - // Chain components as siblings + // Chain components as siblings (need to find last node in each compound selector chain) for (let i = 0; i < components.length - 1; i++) { - this.arena.set_next_sibling(components[i], components[i + 1]) + // Find the last node in the current component's chain + let last_node = components[i] + while (this.arena.get_next_sibling(last_node) !== 0) { + last_node = this.arena.get_next_sibling(last_node) + } + // Link the last node to the next component + this.arena.set_next_sibling(last_node, components[i + 1]) } // Return first component (others are chained as siblings) @@ -196,8 +252,10 @@ export class SelectorParser { let parts: number[] = [] while (this.lexer.pos < this.selector_end) { - // Save position before getting token - let pos_before = this.lexer.pos + // Save lexer state before getting token + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column this.lexer.next_token_fast(false) if (this.lexer.token_start >= this.selector_end) break @@ -209,8 +267,10 @@ export class SelectorParser { if (part !== null) { parts.push(part) } else { - // Not a simple selector part, reset position and break - this.lexer.pos = pos_before + // Not a simple selector part, restore lexer state and break + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column break } } @@ -281,7 +341,7 @@ export class SelectorParser { // Parse combinator (>, +, ~, or descendant space) private try_parse_combinator(): number | null { - let start = this.lexer.pos + let whitespace_start = this.lexer.pos let has_whitespace = false // Skip whitespace and check for combinator @@ -303,15 +363,15 @@ export class SelectorParser { if (this.lexer.token_type === TOKEN_DELIM) { let ch = this.source.charCodeAt(this.lexer.token_start) if (ch === 0x3e || ch === 0x2b || ch === 0x7e) { - // > + ~ - return this.create_combinator(start, this.lexer.token_end) + // > + ~ (combinator text excludes leading whitespace) + return this.create_combinator(this.lexer.token_start, this.lexer.token_end) } } // If we had whitespace but no explicit combinator, it's a descendant combinator if (has_whitespace) { // Reset lexer position - this.lexer.pos = start + this.lexer.pos = whitespace_start while (this.lexer.pos < this.selector_end) { let ch = this.source.charCodeAt(this.lexer.pos) if (is_whitespace_char(ch)) { @@ -320,19 +380,28 @@ export class SelectorParser { break } } - return this.create_combinator(start, this.lexer.pos) + return this.create_combinator(whitespace_start, this.lexer.pos) } // No combinator found, reset position - this.lexer.pos = start + this.lexer.pos = whitespace_start return null } // Parse class selector (.classname) private parse_class_selector(dot_pos: number): number | null { + // Save lexer state for potential restoration + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + // Next token should be identifier this.lexer.next_token_fast(false) if (this.lexer.token_type !== TOKEN_IDENT) { + // Restore lexer state and return null + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column return null } @@ -352,6 +421,8 @@ export class SelectorParser { private parse_attribute_selector(start: number): number | null { let bracket_depth = 1 let end = this.lexer.token_end + let content_start = start + 1 // Position after '[' + let content_end = content_start // Find matching ] while (this.lexer.pos < this.selector_end && bracket_depth > 0) { @@ -362,6 +433,7 @@ export class SelectorParser { } else if (token_type === TOKEN_RIGHT_BRACKET) { bracket_depth-- if (bracket_depth === 0) { + content_end = this.lexer.token_start // Position before ']' end = this.lexer.token_end break } @@ -374,17 +446,217 @@ export class SelectorParser { this.arena.set_length(node, end - start) this.arena.set_start_line(node, this.lexer.line) this.arena.set_start_column(node, this.lexer.column) - // Content is everything inside the brackets, trimmed - let trimmed = trim_boundaries(this.source, start + 1, end - 1) - if (trimmed) { - this.arena.set_content_start(node, trimmed[0]) - this.arena.set_content_length(node, trimmed[1] - trimmed[0]) - } + + // Parse the content inside brackets to extract name, operator, and value + this.parse_attribute_content(node, content_start, content_end) + return node } + // Parse attribute content to extract name, operator, and value + private parse_attribute_content(node: number, start: number, end: number): void { + // Skip leading whitespace and comments + while (start < end) { + let ch = this.source.charCodeAt(start) + if (is_whitespace_char(ch)) { + start++ + continue + } + // Skip comments /*...*/ + if (ch === 0x2f /* / */ && start + 1 < end && this.source.charCodeAt(start + 1) === 0x2a /* * */) { + start += 2 // Skip /* + while (start < end) { + if (this.source.charCodeAt(start) === 0x2a && start + 1 < end && this.source.charCodeAt(start + 1) === 0x2f) { + start += 2 // Skip */ + break + } + start++ + } + continue + } + break + } + + // Skip trailing whitespace and comments + while (end > start) { + let ch = this.source.charCodeAt(end - 1) + if (is_whitespace_char(ch)) { + end-- + continue + } + // Skip comments /*...*/ + if (ch === 0x2f && end >= 2 && this.source.charCodeAt(end - 2) === 0x2a) { + // Find start of comment + let pos = end - 2 + while (pos > start && !(this.source.charCodeAt(pos) === 0x2f && this.source.charCodeAt(pos + 1) === 0x2a)) { + pos-- + } + if (pos > start) { + end = pos + continue + } + } + break + } + + if (start >= end) return + + // Find attribute name (up to operator or end) + let name_start = start + let name_end = start + let operator_start = -1 + let operator_end = -1 + let value_start = -1 + let value_end = -1 + + // Scan for attribute name + while (name_end < end) { + let ch = this.source.charCodeAt(name_end) + if (is_whitespace_char(ch) || ch === 0x3d /* = */ || ch === 0x7e /* ~ */ || ch === 0x7c /* | */ || ch === 0x5e /* ^ */ || ch === 0x24 /* $ */ || ch === 0x2a /* * */) { + break + } + name_end++ + } + + // Store attribute name in content fields + if (name_end > name_start) { + this.arena.set_content_start(node, name_start) + this.arena.set_content_length(node, name_end - name_start) + } + + // Skip whitespace and comments after name + let pos = name_end + while (pos < end) { + let ch = this.source.charCodeAt(pos) + if (is_whitespace_char(ch)) { + pos++ + continue + } + // Skip comments + if (ch === 0x2f && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x2a) { + pos += 2 + while (pos < end) { + if (this.source.charCodeAt(pos) === 0x2a && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x2f) { + pos += 2 + break + } + pos++ + } + continue + } + break + } + + if (pos >= end) { + // No operator, just [attr] + this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE) + return + } + + // Parse operator + operator_start = pos + let ch1 = this.source.charCodeAt(pos) + + if (ch1 === 0x3d) { // = + operator_end = pos + 1 + this.arena.set_attr_operator(node, ATTR_OPERATOR_EQUAL) + } else if (ch1 === 0x7e && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x3d) { // ~= + operator_end = pos + 2 + this.arena.set_attr_operator(node, ATTR_OPERATOR_TILDE_EQUAL) + } else if (ch1 === 0x7c && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x3d) { // |= + operator_end = pos + 2 + this.arena.set_attr_operator(node, ATTR_OPERATOR_PIPE_EQUAL) + } else if (ch1 === 0x5e && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x3d) { // ^= + operator_end = pos + 2 + this.arena.set_attr_operator(node, ATTR_OPERATOR_CARET_EQUAL) + } else if (ch1 === 0x24 && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x3d) { // $= + operator_end = pos + 2 + this.arena.set_attr_operator(node, ATTR_OPERATOR_DOLLAR_EQUAL) + } else if (ch1 === 0x2a && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x3d) { // *= + operator_end = pos + 2 + this.arena.set_attr_operator(node, ATTR_OPERATOR_STAR_EQUAL) + } else { + // No valid operator + this.arena.set_attr_operator(node, ATTR_OPERATOR_NONE) + return + } + + // Skip whitespace and comments after operator + pos = operator_end + while (pos < end) { + let ch = this.source.charCodeAt(pos) + if (is_whitespace_char(ch)) { + pos++ + continue + } + // Skip comments + if (ch === 0x2f && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x2a) { + pos += 2 + while (pos < end) { + if (this.source.charCodeAt(pos) === 0x2a && pos + 1 < end && this.source.charCodeAt(pos + 1) === 0x2f) { + pos += 2 + break + } + pos++ + } + continue + } + break + } + + if (pos >= end) { + // No value after operator + return + } + + // Parse value (can be quoted or unquoted) + value_start = pos + let ch = this.source.charCodeAt(pos) + + if (ch === 0x22 || ch === 0x27) { // " or ' + // Quoted string - find matching quote + let quote = ch + value_start = pos // Include quotes in value + pos++ + while (pos < end) { + let c = this.source.charCodeAt(pos) + if (c === quote) { + pos++ + break + } + if (c === 0x5c) { // backslash - skip next char + pos += 2 + } else { + pos++ + } + } + value_end = pos + } else { + // Unquoted identifier + while (pos < end) { + let c = this.source.charCodeAt(pos) + if (is_whitespace_char(c)) { + break + } + pos++ + } + value_end = pos + } + + // Store value in value fields + if (value_end > value_start) { + this.arena.set_value_start(node, value_start) + this.arena.set_value_length(node, value_end - value_start) + } + } + // Parse pseudo-class or pseudo-element (:hover, ::before) private parse_pseudo(start: number): number | null { + // Save lexer state for potential restoration + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + // Check for double colon (::) let is_pseudo_element = false if (this.lexer.pos < this.selector_end && this.source.charCodeAt(this.lexer.pos) === 0x3a) { @@ -416,6 +688,10 @@ export class SelectorParser { return this.parse_pseudo_function_after_colon(start, is_pseudo_element) } + // Restore lexer state and return null + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column return null } @@ -430,6 +706,10 @@ export class SelectorParser { let func_name_start = this.lexer.token_start let func_name_end = this.lexer.token_end - 1 // Exclude the '(' + // Capture content start (right after the '(') + let content_start = this.lexer.pos + let content_end = content_start + // Find matching ) let paren_depth = 1 let end = this.lexer.token_end @@ -437,11 +717,12 @@ export class SelectorParser { while (this.lexer.pos < this.selector_end && paren_depth > 0) { this.lexer.next_token_fast(false) let token_type = this.lexer.token_type - if (token_type === TOKEN_LEFT_PAREN) { + 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 before ')' end = this.lexer.token_end break } @@ -461,9 +742,222 @@ export class SelectorParser { if (is_vendor_prefixed(this.source, func_name_start, func_name_end)) { this.arena.set_flag(node, FLAG_VENDOR_PREFIXED) } + + // Parse the content inside the parentheses + if (content_end > content_start) { + // Check if this is an nth-* pseudo-class + let func_name = this.source.substring(func_name_start, func_name_end).toLowerCase() + + if (this.is_nth_pseudo(func_name)) { + // Parse as An+B expression + let child = this.parse_nth_expression(content_start, content_end, node) + if (child !== null) { + this.arena.set_first_child(node, child) + this.arena.set_last_child(node, child) + } + } else if (func_name === 'lang') { + // Parse as :lang() - comma-separated language identifiers + this.parse_lang_identifiers(content_start, content_end, node) + } else { + // Parse as selector (for :is(), :where(), :has(), etc.) + // Save current lexer state and selector_end + let saved_selector_end = this.selector_end + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + + // Recursively parse the content as a selector + // Only :has() accepts relative selectors (starting with combinator) + let allow_relative = func_name === 'has' + let child_selector = this.parse_selector(content_start, content_end, this.lexer.line, this.lexer.column, allow_relative) + + // Restore lexer state and selector_end + this.selector_end = saved_selector_end + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + + // Add as child if parsed successfully + if (child_selector !== null) { + this.arena.set_first_child(node, child_selector) + this.arena.set_last_child(node, child_selector) + } + } + } + return node } + // Check if pseudo-class name is an nth-* pseudo + private is_nth_pseudo(name: string): boolean { + return ( + name === 'nth-child' || + name === 'nth-last-child' || + name === 'nth-of-type' || + name === 'nth-last-of-type' || + name === 'nth-col' || + name === 'nth-last-col' + ) + } + + // Parse :lang() content - comma-separated language identifiers + // Accepts both quoted strings: :lang("en", "fr") and unquoted: :lang(en, fr) + private parse_lang_identifiers(start: number, end: number, parent_node: number): void { + // Save current lexer state + let saved_selector_end = this.selector_end + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + + // Set lexer to parse this range + this.lexer.pos = start + this.selector_end = end + + let first_child: number | null = null + let last_child: number | null = null + + while (this.lexer.pos < end) { + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + let token_start = this.lexer.token_start + let token_end = this.lexer.token_end + + // Skip whitespace + if (token_type === TOKEN_WHITESPACE) { + continue + } + + // Skip commas + if (token_type === TOKEN_COMMA) { + continue + } + + // Accept both strings and identifiers + if (token_type === TOKEN_STRING || token_type === TOKEN_IDENT) { + // Create language identifier node + let lang_node = this.arena.create_node() + this.arena.set_type(lang_node, NODE_SELECTOR_LANG) + this.arena.set_start_offset(lang_node, token_start) + this.arena.set_length(lang_node, token_end - token_start) + this.arena.set_start_line(lang_node, this.lexer.line) + this.arena.set_start_column(lang_node, this.lexer.column) + + // Link as child of :lang() pseudo-class + if (first_child === null) { + first_child = lang_node + } + if (last_child !== null) { + this.arena.set_next_sibling(last_child, lang_node) + } + last_child = lang_node + } + + // Stop if we've reached the end + if (this.lexer.pos >= end) { + break + } + } + + // Set children on parent node + if (first_child !== null) { + this.arena.set_first_child(parent_node, first_child) + } + if (last_child !== null) { + this.arena.set_last_child(parent_node, last_child) + } + + // Restore lexer state + this.selector_end = saved_selector_end + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + } + + // Parse An+B expression for nth-* pseudo-classes + // Handles both simple An+B and "An+B of S" syntax + private parse_nth_expression(start: number, end: number, parent_node: number): number | null { + // Check for "of " syntax + // e.g., "2n+1 of .active, .disabled" + let of_index = this.find_of_keyword(start, end) + + if (of_index !== -1) { + // Parse An+B part before "of" + let anplusb_parser = new ANplusBParser(this.arena, this.source) + let anplusb_node = anplusb_parser.parse_anplusb(start, of_index, this.lexer.line) + + // Parse selector list after "of" + let selector_start = of_index + 2 // skip "of" + // Skip whitespace + while ( + selector_start < end && + is_whitespace_char(this.source.charCodeAt(selector_start)) + ) { + selector_start++ + } + + // Save current state + let saved_selector_end = this.selector_end + let saved_pos = this.lexer.pos + let saved_line = this.lexer.line + let saved_column = this.lexer.column + + // Parse selector list + this.selector_end = end + this.lexer.pos = selector_start + let selector_list = this.parse_selector_list() + + // Restore state + this.selector_end = saved_selector_end + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + + // Create NTH_OF wrapper + let of_node = this.arena.create_node() + this.arena.set_type(of_node, NODE_SELECTOR_NTH_OF) + this.arena.set_start_offset(of_node, start) + this.arena.set_length(of_node, end - start) + this.arena.set_start_line(of_node, this.lexer.line) + + // Link An+B and selector list + if (anplusb_node !== null && selector_list !== null) { + this.arena.set_first_child(of_node, anplusb_node) + this.arena.set_last_child(of_node, selector_list) + this.arena.set_next_sibling(anplusb_node, selector_list) + } else if (anplusb_node !== null) { + this.arena.set_first_child(of_node, anplusb_node) + this.arena.set_last_child(of_node, anplusb_node) + } + + return of_node + } else { + // Just An+B, no "of" clause + let anplusb_parser = new ANplusBParser(this.arena, this.source) + return anplusb_parser.parse_anplusb(start, end, this.lexer.line) + } + } + + // Find the position of standalone "of" keyword + private find_of_keyword(start: number, end: number): number { + for (let i = start; i < end - 1; i++) { + if ( + this.source.charCodeAt(i) === 0x6f /* o */ && + this.source.charCodeAt(i + 1) === 0x66 /* f */ + ) { + // Check it's a word boundary + let before_ok = + i === start || is_whitespace_char(this.source.charCodeAt(i - 1)) + let after_ok = + i + 2 >= end || is_whitespace_char(this.source.charCodeAt(i + 2)) + + if (before_ok && after_ok) { + return i + } + } + } + return -1 + } + // Create simple selector nodes private create_type_selector(start: number, end: number): number { let node = this.arena.create_node() diff --git a/src/string-utils.ts b/src/string-utils.ts index 3d4b3a8..530e120 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -9,6 +9,8 @@ export const CHAR_FORM_FEED = 0x0c // '\f' export const CHAR_FORWARD_SLASH = 0x2f // '/' export const CHAR_ASTERISK = 0x2a // '*' export const CHAR_MINUS_HYPHEN = 0x2d // '-' +export const CHAR_SINGLE_QUOTE = 0x27 // ''' +export const CHAR_DOUBLE_QUOTE = 0x22 // '"' /** * Check if a character code is whitespace (space, tab, newline, CR, or FF) @@ -157,3 +159,64 @@ export function is_vendor_prefixed(source: string, start: number, end: number): let secondHyphenPos = source.indexOf('-', start + 2) return secondHyphenPos !== -1 && secondHyphenPos < end } + +/** + * Parse a dimension string into numeric value and unit + * + * @param text - Dimension text like "100px", "50%", "1.5em" + * @returns Object with value (number) and unit (string) + * + * Examples: + * - "100px" → { value: 100, unit: "px" } + * - "50%" → { value: 50, unit: "%" } + * - "1.5em" → { value: 1.5, unit: "em" } + * - "-10rem" → { value: -10, unit: "rem" } + */ +export function parse_dimension(text: string): { value: number; unit: string } { + // Find where the numeric part ends + let numEnd = 0 + for (let i = 0; i < text.length; i++) { + let ch = text.charCodeAt(i) + + // Check for e/E (scientific notation) + if (ch === 0x65 || ch === 0x45) { // 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) + // Check if next is digit + if (nextCh >= 0x30 && nextCh <= 0x39) { + numEnd = i + 1 + continue + } + // Check if next is sign followed by digit + if ((nextCh === 0x2b || nextCh === 0x2d) && i + 2 < text.length) { + let afterSign = text.charCodeAt(i + 2) + if (afterSign >= 0x30 && afterSign <= 0x39) { + numEnd = i + 1 + continue + } + } + } + // e/E not followed by valid scientific notation, stop + break + } + + // Allow digits, dot, minus, plus + if ( + (ch >= 0x30 && ch <= 0x39) || // 0-9 + ch === 0x2e || // . + ch === 0x2d || // - + ch === 0x2b // + + ) { + numEnd = i + 1 + } else { + break + } + } + + let numStr = text.substring(0, numEnd) + let unit = text.substring(numEnd) + let value = numStr ? parseFloat(numStr) : 0 + + return { value, unit } +} diff --git a/src/stylerule-structure.test.ts b/src/stylerule-structure.test.ts index d82192e..a31b833 100644 --- a/src/stylerule-structure.test.ts +++ b/src/stylerule-structure.test.ts @@ -14,12 +14,15 @@ describe('StyleRule Structure', () => { const firstChild = rule.first_child! expect(firstChild.type).toBe(NODE_SELECTOR_LIST) - // Second child should be declaration - const secondChild = firstChild.next_sibling! - expect(secondChild).not.toBeNull() + // Second child should be block containing declarations + const block = firstChild.next_sibling! + expect(block).not.toBeNull() + + // Declarations should be inside the block + const secondChild = block.first_child! expect(secondChild.type).toBe(NODE_DECLARATION) - // Third child should be declaration + // Second declaration const thirdChild = secondChild.next_sibling! expect(thirdChild).not.toBeNull() expect(thirdChild.type).toBe(NODE_DECLARATION) @@ -131,15 +134,54 @@ describe('StyleRule Structure', () => { expect(outerRule.type).toBe(NODE_STYLE_RULE) expect(outerRule.first_child!.type).toBe(NODE_SELECTOR_LIST) - // Find the nested rule - const nestedRule = outerRule.first_child!.next_sibling! + // Find the nested rule (inside the block) + const block = outerRule.first_child!.next_sibling! + const nestedRule = block.first_child! expect(nestedRule.type).toBe(NODE_STYLE_RULE) // Nested rule should also have selector list as first child expect(nestedRule.first_child!.type).toBe(NODE_SELECTOR_LIST) - // Declaration comes after selector list in nested rule - expect(nestedRule.first_child!.next_sibling!.type).toBe(NODE_DECLARATION) + // Declaration comes after selector list in nested rule's block + const nestedBlock = nestedRule.first_child!.next_sibling! + expect(nestedBlock.first_child!.type).toBe(NODE_DECLARATION) + }) + + test('& span should be parsed as ONE selector with 3 components', () => { + const parser = new Parser('.parent { & span { color: red; } }') + const root = parser.parse() + const outerRule = root.first_child! + + // Find the nested rule (& span) + const block = outerRule.first_child!.next_sibling! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(NODE_STYLE_RULE) + + // Get selector list + const selectorList = nestedRule.first_child! + expect(selectorList.type).toBe(NODE_SELECTOR_LIST) + + // Count how many selectors in the list (should be 1, not 2) + const selectors = [] + let selector = selectorList.first_child + while (selector) { + selectors.push(selector) + selector = selector.next_sibling + } + + // BUG: This should be 1 selector, but might be 2 + expect(selectors.length).toBe(1) + + // The single selector should have 3 children: &, combinator (space), span + if (selectors.length === 1) { + const components = [] + let component = selectors[0].first_child + while (component) { + components.push(component) + component = component.next_sibling + } + expect(components.length).toBe(3) + } }) test('selector list with combinators should chain all components correctly', () => { @@ -186,7 +228,172 @@ describe('StyleRule Structure', () => { expect(rule.type).toBe(NODE_STYLE_RULE) expect(rule.first_child!.type).toBe(NODE_SELECTOR_LIST) - // Selector list should be the only child - expect(rule.first_child!.next_sibling).toBeNull() + // Rule should have selector list + empty block + const block = rule.first_child!.next_sibling + expect(block).not.toBeNull() + expect(block!.is_empty).toBe(true) + }) + + test('block children should be correctly linked via next_sibling with declarations only', () => { + const parser = new Parser('body { color: red; margin: 0; padding: 10px; }') + const root = parser.parse() + const rule = root.first_child! + + // Get the block + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + // Collect all children using next_sibling + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } + + // Should have 3 declarations + expect(children.length).toBe(3) + + // Verify each child is a declaration + for (let i = 0; i < children.length; i++) { + expect(children[i].type).toBe(NODE_DECLARATION) + } + + // Verify next_sibling chain + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } + + // Last child should have null next_sibling + expect(children[children.length - 1].next_sibling).toBeNull() + }) + + test('block children should be correctly linked via next_sibling with mixed content', () => { + const parser = new Parser(` + .parent { + color: red; + .nested { margin: 0; } + padding: 10px; + @media print { display: none; } + font-size: 16px; + } + `) + const root = parser.parse() + const rule = root.first_child! + + // Get the block + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + // Collect all children using next_sibling + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } + + // Should have 5 children: declaration, nested rule, declaration, at-rule, declaration + expect(children.length).toBe(5) + + // Verify types in order + expect(children[0].type).toBe(NODE_DECLARATION) // color: red + expect(children[1].type).toBe(NODE_STYLE_RULE) // .nested { margin: 0; } + expect(children[2].type).toBe(NODE_DECLARATION) // padding: 10px + expect(children[3].type).toBe(NODE_AT_RULE) // @media print { display: none; } + expect(children[4].type).toBe(NODE_DECLARATION) // font-size: 16px + + // Verify next_sibling chain + for (let i = 0; i < children.length - 1; i++) { + const nextSibling = children[i].next_sibling + expect(nextSibling).not.toBeNull() + expect(nextSibling!.get_index()).toBe(children[i + 1].get_index()) + } + + // Last child should have null next_sibling + expect(children[children.length - 1].next_sibling).toBeNull() + }) + + test('block with only nested rules should have correct next_sibling chain', () => { + const parser = new Parser(` + .parent { + .child1 { color: red; } + .child2 { margin: 0; } + .child3 { padding: 10px; } + } + `) + const root = parser.parse() + const rule = root.first_child! + + // Get the block + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + // Collect all children using next_sibling + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } + + // Should have 3 nested rules + expect(children.length).toBe(3) + + // Verify each is a style rule + for (const child of children) { + expect(child.type).toBe(NODE_STYLE_RULE) + } + + // Verify next_sibling chain + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } + + // Last child should have null next_sibling + expect(children[children.length - 1].next_sibling).toBeNull() + }) + + test('block with only at-rules should have correct next_sibling chain', () => { + const parser = new Parser(` + .parent { + @media screen { color: blue; } + @media print { display: none; } + @supports (display: flex) { display: flex; } + } + `) + const root = parser.parse() + const rule = root.first_child! + + // Get the block + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + // Collect all children using next_sibling + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } + + // Should have 3 at-rules + expect(children.length).toBe(3) + + // Verify each is an at-rule + for (const child of children) { + expect(child.type).toBe(NODE_AT_RULE) + } + + // Verify next_sibling chain + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } + + // Last child should have null next_sibling + expect(children[children.length - 1].next_sibling).toBeNull() }) }) diff --git a/src/value-parser.test.ts b/src/value-parser.test.ts index 7746d01..a324641 100644 --- a/src/value-parser.test.ts +++ b/src/value-parser.test.ts @@ -16,7 +16,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: red; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling // Skip selector + const decl = rule?.first_child?.next_sibling?.first_child // selector → block → declaration expect(decl?.value).toBe('red') expect(decl?.values).toHaveLength(1) @@ -28,7 +28,7 @@ describe('ValueParser', () => { const parser = new Parser('body { opacity: 0.5; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('0.5') expect(decl?.values).toHaveLength(1) @@ -36,23 +36,39 @@ describe('ValueParser', () => { expect(decl?.values[0].text).toBe('0.5') }) - it('should parse dimension values', () => { + it('should parse px dimension values', () => { const parser = new Parser('body { width: 100px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('100px') expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) expect(decl?.values[0].text).toBe('100px') + expect(decl?.values[0].value).toBe(100) + expect(decl?.values[0].unit).toBe('px') + }) + + it('should parse px dimension values', () => { + const parser = new Parser('body { font-size: 3em; }') + const root = parser.parse() + const rule = root.first_child + const decl = rule?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('3em') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) + expect(decl?.values[0].text).toBe('3em') + expect(decl?.values[0].value).toBe(3) + expect(decl?.values[0].unit).toBe('em') }) it('should parse percentage values', () => { const parser = new Parser('body { width: 50%; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('50%') expect(decl?.values).toHaveLength(1) @@ -64,7 +80,7 @@ describe('ValueParser', () => { const parser = new Parser('body { content: "hello"; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('"hello"') expect(decl?.values).toHaveLength(1) @@ -76,7 +92,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: #ff0000; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('#ff0000') expect(decl?.values).toHaveLength(1) @@ -90,7 +106,7 @@ describe('ValueParser', () => { const parser = new Parser('body { font-family: Arial, sans-serif; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(3) expect(decl?.values[0].type).toBe(NODE_VALUE_KEYWORD) @@ -105,7 +121,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 10px 20px 30px 40px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(4) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -122,7 +138,7 @@ describe('ValueParser', () => { const parser = new Parser('body { border: 1px solid red; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(3) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -139,7 +155,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: rgb(255, 0, 0); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -151,7 +167,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: rgb(255, 0, 0); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child const func = decl?.values[0] expect(func?.children).toHaveLength(5) @@ -171,7 +187,7 @@ describe('ValueParser', () => { const parser = new Parser('body { width: calc(100% - 20px); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -189,7 +205,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: var(--primary-color); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -203,7 +219,7 @@ describe('ValueParser', () => { const parser = new Parser('body { background: url("image.png"); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -219,7 +235,7 @@ describe('ValueParser', () => { const parser = new Parser('body { background: url("bg.png") no-repeat center center / cover; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values.length).toBeGreaterThan(1) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -232,7 +248,7 @@ describe('ValueParser', () => { const parser = new Parser('body { transform: translateX(10px) rotate(45deg); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(2) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -245,7 +261,7 @@ describe('ValueParser', () => { const parser = new Parser('body { filter: blur(5px) brightness(1.2); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(2) expect(decl?.values[0].type).toBe(NODE_VALUE_FUNCTION) @@ -262,7 +278,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: ; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBeNull() expect(decl?.values).toHaveLength(0) @@ -272,7 +288,7 @@ describe('ValueParser', () => { const parser = new Parser('body { color: red !important; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.value).toBe('red') expect(decl?.values).toHaveLength(1) @@ -285,7 +301,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: -10px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -296,7 +312,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 0px; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_DIMENSION) @@ -307,7 +323,7 @@ describe('ValueParser', () => { const parser = new Parser('body { margin: 0; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values).toHaveLength(1) expect(decl?.values[0].type).toBe(NODE_VALUE_NUMBER) @@ -320,7 +336,7 @@ describe('ValueParser', () => { const parser = new Parser('body { font-family: Arial, sans-serif; }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child expect(decl?.values[1].type).toBe(NODE_VALUE_OPERATOR) expect(decl?.values[1].text).toBe(',') @@ -330,7 +346,7 @@ describe('ValueParser', () => { const parser = new Parser('body { width: calc(100% - 20px); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child const func = decl?.values[0] expect(func?.children[1].type).toBe(NODE_VALUE_OPERATOR) @@ -341,7 +357,7 @@ describe('ValueParser', () => { const parser = new Parser('body { width: calc(1px + 2px * 3px / 4px - 5px); }') const root = parser.parse() const rule = root.first_child - const decl = rule?.first_child?.next_sibling + const decl = rule?.first_child?.next_sibling?.first_child const func = decl?.values[0] const operators = func?.children.filter((n) => n.type === NODE_VALUE_OPERATOR) diff --git a/src/value-parser.ts b/src/value-parser.ts index 3dd95d8..b819e4f 100644 --- a/src/value-parser.ts +++ b/src/value-parser.ts @@ -231,7 +231,7 @@ export class ValueParser { if (this.lexer.token_start >= this.value_end) break // Track parentheses depth - if (token_type === TOKEN_LEFT_PAREN) { + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { paren_depth++ } else if (token_type === TOKEN_RIGHT_PAREN) { paren_depth-- diff --git a/src/walk.test.ts b/src/walk.test.ts index 4a392d3..aa3bb33 100644 --- a/src/walk.test.ts +++ b/src/walk.test.ts @@ -6,6 +6,7 @@ import { NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_AT_RULE, + NODE_BLOCK, NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, NODE_VALUE_DIMENSION, @@ -38,6 +39,7 @@ describe('walk', () => { NODE_STYLESHEET, NODE_STYLE_RULE, NODE_SELECTOR_LIST, + NODE_BLOCK, NODE_DECLARATION, NODE_VALUE_KEYWORD, // red ]) @@ -56,12 +58,14 @@ describe('walk', () => { NODE_STYLESHEET, NODE_STYLE_RULE, // body rule NODE_SELECTOR_LIST, // body selector + NODE_BLOCK, // body block NODE_DECLARATION, // color: red NODE_VALUE_KEYWORD, // red NODE_DECLARATION, // margin: 0 NODE_VALUE_NUMBER, // 0 NODE_STYLE_RULE, // div rule NODE_SELECTOR_LIST, // div selector + NODE_BLOCK, // div block NODE_DECLARATION, // padding: 1rem NODE_VALUE_DIMENSION, // 1rem ]) @@ -80,9 +84,11 @@ describe('walk', () => { NODE_STYLESHEET, NODE_STYLE_RULE, // .parent NODE_SELECTOR_LIST, // .parent selector + NODE_BLOCK, // .parent block NODE_DECLARATION, // color: red - NODE_STYLE_RULE, // .child + NODE_STYLE_RULE, // .child (nested inside parent's block) NODE_SELECTOR_LIST, // .child selector + NODE_BLOCK, // .child block NODE_DECLARATION, // color: blue ]) }) @@ -103,8 +109,10 @@ describe('walk', () => { expect(visited).toEqual([ NODE_STYLESHEET, NODE_AT_RULE, // @media + NODE_BLOCK, // @media block NODE_STYLE_RULE, // body NODE_SELECTOR_LIST, // body selector + NODE_BLOCK, // body block NODE_DECLARATION, // color: red ]) }) @@ -183,8 +191,8 @@ describe('walk', () => { depths.push(depth) }) - // NODE_STYLESHEET (0), NODE_STYLE_RULE (1), NODE_SELECTOR_LIST (2), NODE_DECLARATION (2), NODE_VALUE_KEYWORD (3) - expect(depths).toEqual([0, 1, 2, 2, 3]) + // NODE_STYLESHEET (0), NODE_STYLE_RULE (1), NODE_SELECTOR_LIST (2), NODE_BLOCK (2), NODE_DECLARATION (3), NODE_VALUE_KEYWORD (4) + expect(depths).toEqual([0, 1, 2, 2, 3, 4]) }) it('should track depth in nested structures', () => { @@ -198,7 +206,7 @@ describe('walk', () => { } }) - expect(ruleDepths).toEqual([1, 2, 3]) // .a at depth 1, .b at depth 2, .c at depth 3 + expect(ruleDepths).toEqual([1, 3, 5]) // .a at depth 1, .b at depth 3 (inside .a's block), .c at depth 5 (inside .b's block) }) it('should track depth with at-rules', () => { @@ -217,9 +225,11 @@ describe('walk', () => { expect(typeAndDepth).toEqual([ { type: NODE_STYLESHEET, depth: 0 }, { type: NODE_AT_RULE, depth: 1 }, // @media - { type: NODE_STYLE_RULE, depth: 2 }, // body - { type: NODE_SELECTOR_LIST, depth: 3 }, // body selector - { type: NODE_DECLARATION, depth: 3 }, // color: red + { type: NODE_BLOCK, depth: 2 }, // @media block + { type: NODE_STYLE_RULE, depth: 3 }, // body + { type: NODE_SELECTOR_LIST, depth: 4 }, // body selector + { type: NODE_BLOCK, depth: 4 }, // body block + { type: NODE_DECLARATION, depth: 5 }, // color: red ]) }) @@ -239,13 +249,17 @@ describe('walk', () => { expect(typeAndDepth).toEqual([ { type: NODE_STYLESHEET, depth: 0 }, { type: NODE_AT_RULE, depth: 1 }, // @media - { type: NODE_STYLE_RULE, depth: 2 }, // body - { type: NODE_SELECTOR_LIST, depth: 3 }, // body selector - { type: NODE_DECLARATION, depth: 3 }, // color: red + { type: NODE_BLOCK, depth: 2 }, // @media block + { type: NODE_STYLE_RULE, depth: 3 }, // body + { type: NODE_SELECTOR_LIST, depth: 4 }, // body selector + { type: NODE_BLOCK, depth: 4 }, // body block + { type: NODE_DECLARATION, depth: 5 }, // color: red { type: NODE_AT_RULE, depth: 1 }, // @layer - { type: NODE_STYLE_RULE, depth: 2 }, - { type: NODE_SELECTOR_LIST, depth: 3 }, - { type: NODE_DECLARATION, depth: 3 }, + { type: NODE_BLOCK, depth: 2 }, // @layer block + { type: NODE_STYLE_RULE, depth: 3 }, + { type: NODE_SELECTOR_LIST, depth: 4 }, + { type: NODE_BLOCK, depth: 4 }, + { type: NODE_DECLARATION, depth: 5 }, ]) }) @@ -281,8 +295,8 @@ describe('walk enter/leave', () => { }, }) - expect(enter).toEqual([NODE_STYLESHEET, NODE_AT_RULE, NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_DECLARATION]) - expect(leave).toEqual([NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE, NODE_STYLESHEET]) + expect(enter).toEqual([NODE_STYLESHEET, NODE_AT_RULE, NODE_BLOCK, NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_BLOCK, NODE_DECLARATION]) + expect(leave).toEqual([NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_BLOCK, NODE_STYLE_RULE, NODE_BLOCK, NODE_AT_RULE, NODE_STYLESHEET]) }) test('only enter', () => { @@ -294,7 +308,7 @@ describe('walk enter/leave', () => { }, }) - expect(enter).toEqual([NODE_STYLESHEET, NODE_AT_RULE, NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_DECLARATION]) + expect(enter).toEqual([NODE_STYLESHEET, NODE_AT_RULE, NODE_BLOCK, NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_BLOCK, NODE_DECLARATION]) }) test('only leave', () => { @@ -306,7 +320,7 @@ describe('walk enter/leave', () => { }, }) - expect(leave).toEqual([NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE, NODE_STYLESHEET]) + expect(leave).toEqual([NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_BLOCK, NODE_STYLE_RULE, NODE_BLOCK, NODE_AT_RULE, NODE_STYLESHEET]) }) test('neither', () => { diff --git a/vite.config.ts b/vite.config.ts index 5938ade..8580d21 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ parse: './src/parse.ts', 'parse-selector': './src/parse-selector.ts', 'parse-atrule-prelude': './src/parse-atrule-prelude.ts', + 'parse-anplusb': './src/parse-anplusb.ts', }, formats: ['es'], },