diff --git a/src/anplusb-parser.ts b/src/anplusb-parser.ts deleted file mode 100644 index e375e6a..0000000 --- a/src/anplusb-parser.ts +++ /dev/null @@ -1,314 +0,0 @@ -// 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/at-rule-prelude-parser.test.ts b/src/at-rule-prelude-parser.test.ts deleted file mode 100644 index 4ae9156..0000000 --- a/src/at-rule-prelude-parser.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -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, - NODE_PRELUDE_CONTAINER_QUERY, - NODE_PRELUDE_SUPPORTS_QUERY, - NODE_PRELUDE_LAYER_NAME, - NODE_PRELUDE_IDENTIFIER, - NODE_PRELUDE_OPERATOR, - NODE_PRELUDE_IMPORT_URL, - NODE_PRELUDE_IMPORT_LAYER, - NODE_PRELUDE_IMPORT_SUPPORTS, -} from './arena' - -describe('At-Rule Prelude Parser', () => { - describe('@media', () => { - it('should parse media type', () => { - const css = '@media screen { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('media') - - // Should have prelude children - const children = atRule?.children || [] - expect(children.length).toBeGreaterThan(0) - - // First child should be a media query - expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - - // Query should have a media type child - const queryChildren = children[0].children - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_TYPE)).toBe(true) - }) - - it('should parse media feature', () => { - const css = '@media (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - - // Query should have a media feature child - const queryChildren = children[0].children - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) - - // Feature should have content - const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) - expect(feature?.value).toContain('min-width') - }) - - it('should trim whitespace and comments from media features', () => { - const css = '@media (/* comment */ min-width: 768px /* test */) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const queryChildren = children[0].children - const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) - - expect(feature?.value).toBe('min-width: 768px') - }) - - it('should parse complex media query with and operator', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - - const queryChildren = children[0].children - // Should have: media type, operator, media feature - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_TYPE)).toBe(true) - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_OPERATOR)).toBe(true) - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) - }) - - it('should parse multiple media features', () => { - const css = '@media (min-width: 768px) and (max-width: 1024px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const queryChildren = children[0].children - const features = queryChildren.filter((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) - expect(features.length).toBe(2) - }) - - it('should parse comma-separated media queries', () => { - const css = '@media screen, print { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - // Should have 2 media query nodes - const queries = children.filter((c) => c.type === NODE_PRELUDE_MEDIA_QUERY) - expect(queries.length).toBe(2) - }) - }) - - describe('@container', () => { - it('should parse unnamed container query', () => { - const css = '@container (min-width: 400px) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('container') - - const children = atRule?.children || [] - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(NODE_PRELUDE_CONTAINER_QUERY) - }) - - it('should parse named container query', () => { - const css = '@container sidebar (min-width: 400px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[0].type).toBe(NODE_PRELUDE_CONTAINER_QUERY) - - const queryChildren = children[0].children - // Should have name and feature - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_IDENTIFIER)).toBe(true) - expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) - }) - }) - - describe('@supports', () => { - it('should parse single feature query', () => { - const css = '@supports (display: flex) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('supports') - - const children = atRule?.children || [] - expect(children.some((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY)).toBe(true) - - const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) - expect(query?.value).toContain('display') - expect(query?.value).toContain('flex') - }) - - it('should trim whitespace and comments from supports queries', () => { - const css = '@supports (/* comment */ display: flex /* test */) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) - - expect(query?.value).toBe('display: flex') - }) - - it('should parse complex supports query with operators', () => { - const css = '@supports (display: flex) and (gap: 1rem) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - // Should have 2 queries and 1 operator - const queries = children.filter((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) - const operators = children.filter((c) => c.type === NODE_PRELUDE_OPERATOR) - - expect(queries.length).toBe(2) - expect(operators.length).toBe(1) - }) - }) - - describe('@layer', () => { - it('should parse single layer name', () => { - const css = '@layer base { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('layer') - - // 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') - }) - - it('should parse comma-separated layer names', () => { - const css = '@layer base, components, utilities;' - const ast = parse(css) - const atRule = ast.first_child - - const children = atRule?.children || [] - expect(children.length).toBe(3) - - expect(children[0].type).toBe(NODE_PRELUDE_LAYER_NAME) - expect(children[0].text).toBe('base') - - expect(children[1].type).toBe(NODE_PRELUDE_LAYER_NAME) - expect(children[1].text).toBe('components') - - expect(children[2].type).toBe(NODE_PRELUDE_LAYER_NAME) - expect(children[2].text).toBe('utilities') - }) - }) - - describe('@keyframes', () => { - it('should parse keyframe name', () => { - const css = '@keyframes slidein { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('keyframes') - - // 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') - }) - }) - - describe('@property', () => { - it('should parse custom property name', () => { - const css = '@property --my-color { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('property') - - // 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') - }) - }) - - describe('@font-face', () => { - it('should have no prelude children', () => { - const css = '@font-face { font-family: "MyFont"; }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.type).toBe(NODE_AT_RULE) - expect(atRule?.name).toBe('font-face') - - // @font-face has no prelude, children should be declarations - const children = atRule?.children || [] - if (children.length > 0) { - // If parse_values is enabled, there might be declaration children - expect(children[0].type).not.toBe(NODE_PRELUDE_IDENTIFIER) - } - }) - }) - - describe('parse_atrule_preludes option', () => { - it('should parse preludes when enabled (default)', () => { - const css = '@media screen { }' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(true) - }) - - it('should not parse preludes when disabled', () => { - const css = '@media screen { }' - const ast = parse(css, { parse_atrule_preludes: false }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(false) - }) - }) - - describe('Prelude text access', () => { - it('should preserve prelude text in at-rule node', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - - // The prelude text should still be accessible - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') - }) - }) - - describe('@import', () => { - it('should parse URL with url() function', () => { - const css = '@import url("styles.css");' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[0].text).toBe('url("styles.css")') - }) - - it('should parse URL with string', () => { - const css = '@import "styles.css";' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[0].text).toBe('"styles.css"') - }) - - it('should parse with anonymous layer', () => { - const css = '@import url("styles.css") layer;' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].text).toBe('layer') - expect(children[1].name).toBe('') - }) - - it('should parse with anonymous LAYER', () => { - const css = '@import url("styles.css") LAYER;' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].text).toBe('LAYER') - expect(children[1].name).toBe('') - }) - - it('should parse with named layer', () => { - const css = '@import url("styles.css") layer(utilities);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].text).toBe('layer(utilities)') - expect(children[1].name).toBe('utilities') - }) - - it('should trim whitespace from layer names', () => { - const css = '@import url("styles.css") layer( utilities );' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('utilities') - }) - - it('should trim comments from layer names', () => { - const css = '@import url("styles.css") layer(/* comment */utilities/* test */);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('utilities') - }) - - it('should trim whitespace and comments from dotted layer names', () => { - const css = '@import url("foo.css") layer(/* test */named.nested );' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[1].name).toBe('named.nested') - }) - - it('should parse with supports query', () => { - const css = '@import url("styles.css") supports(display: grid);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) - expect(children[1].text).toBe('supports(display: grid)') - }) - - it('should parse with media query', () => { - const css = '@import url("styles.css") screen;' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with media feature', () => { - const css = '@import url("styles.css") (min-width: 768px);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with combined media query', () => { - const css = '@import url("styles.css") screen and (min-width: 768px);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with layer and media query', () => { - const css = '@import url("styles.css") layer(base) screen;' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(3) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[2].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with layer and supports', () => { - const css = '@import url("styles.css") layer(base) supports(display: grid);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(3) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[2].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) - }) - - it('should parse with supports and media query', () => { - const css = '@import url("styles.css") supports(display: grid) screen;' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(3) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) - expect(children[2].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with all features combined', () => { - const css = '@import url("styles.css") layer(base) supports(display: grid) screen and (min-width: 768px);' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(4) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) - expect(children[2].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) - expect(children[3].type).toBe(NODE_PRELUDE_MEDIA_QUERY) - }) - - it('should parse with complex supports condition', () => { - const css = '@import url("styles.css") supports((display: grid) and (gap: 1rem));' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] - - expect(children.length).toBe(2) - expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) - expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) - expect(children[1].text).toContain('supports(') - }) - - it('should preserve prelude text', () => { - const css = '@import url("styles.css") layer(base) screen;' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('url("styles.css") layer(base) screen') - }) - }) -}) diff --git a/src/at-rule-prelude-parser.ts b/src/at-rule-prelude-parser.ts deleted file mode 100644 index 48ef263..0000000 --- a/src/at-rule-prelude-parser.ts +++ /dev/null @@ -1,663 +0,0 @@ -// At-Rule Prelude Parser - Parses at-rule preludes into structured AST nodes -import { Lexer } from './lexer' -import type { CSSDataArena } from './arena' -import { - NODE_PRELUDE_MEDIA_QUERY, - NODE_PRELUDE_MEDIA_FEATURE, - NODE_PRELUDE_MEDIA_TYPE, - NODE_PRELUDE_CONTAINER_QUERY, - NODE_PRELUDE_SUPPORTS_QUERY, - NODE_PRELUDE_LAYER_NAME, - NODE_PRELUDE_IDENTIFIER, - NODE_PRELUDE_OPERATOR, - NODE_PRELUDE_IMPORT_URL, - NODE_PRELUDE_IMPORT_LAYER, - NODE_PRELUDE_IMPORT_SUPPORTS, -} from './arena' -import { - TOKEN_IDENT, - TOKEN_LEFT_PAREN, - TOKEN_RIGHT_PAREN, - TOKEN_COMMA, - TOKEN_EOF, - TOKEN_WHITESPACE, - TOKEN_STRING, - TOKEN_URL, - TOKEN_FUNCTION, - type TokenType, -} from './token-types' -import { trim_boundaries, str_equals, CHAR_SPACE, CHAR_TAB, CHAR_NEWLINE, CHAR_CARRIAGE_RETURN, CHAR_FORM_FEED } from './string-utils' - -export class AtRulePreludeParser { - private lexer: Lexer - private arena: CSSDataArena - private source: string - private prelude_end: number - - constructor(arena: CSSDataArena, source: string) { - this.arena = arena - this.source = source - // Create a lexer instance for prelude parsing (don't skip comments) - this.lexer = new Lexer(source, false) - this.prelude_end = 0 - } - - // Parse an at-rule prelude into nodes based on the at-rule type - parse_prelude(at_rule_name: string, start: number, end: number, line: number = 1, column: number = 1): number[] { - this.prelude_end = end - - // Position lexer at prelude start - this.lexer.pos = start - this.lexer.line = line - this.lexer.column = column - - // Dispatch to appropriate parser based on at-rule type - if (str_equals('media', at_rule_name)) { - return this.parse_media_query_list() - } else if (str_equals('container', at_rule_name)) { - return this.parse_container_query() - } else if (str_equals('supports', at_rule_name)) { - return this.parse_supports_query() - } else if (str_equals('layer', at_rule_name)) { - return this.parse_layer_names() - } else if (str_equals('keyframes', at_rule_name)) { - return this.parse_identifier() - } else if (str_equals('property', at_rule_name)) { - return this.parse_identifier() - } else if (str_equals('import', at_rule_name)) { - return this.parse_import_prelude() - } - // For now, @namespace and other at-rules are not parsed - - return [] - } - - // Parse media query list: screen, (min-width: 768px), ... - private parse_media_query_list(): number[] { - let nodes: number[] = [] - - while (this.lexer.pos < this.prelude_end) { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) break - - let query = this.parse_single_media_query() - if (query !== null) { - nodes.push(query) - } - - // Skip comma separator - this.skip_whitespace() - if (this.peek_token_type() === TOKEN_COMMA) { - this.next_token() // consume comma - } - } - - return nodes - } - - private is_and_or_not(str: string): boolean { - if (str.length > 3 || str.length < 2) return false - return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) - } - - // Parse a single media query: screen and (min-width: 768px) - private parse_single_media_query(): number | null { - let query_start = this.lexer.pos - let query_line = this.lexer.line - - // Skip whitespace - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return null - - // Check for modifier (only, not) - // let has_modifier = false - let token_start = this.lexer.pos - this.next_token() - - if (this.lexer.token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (!str_equals('only', text) && !str_equals('not', text)) { - // Reset - this is a media type - this.lexer.pos = token_start - } - } else { - this.lexer.pos = token_start - } - - // Parse components (media type, features, operators) - let components: number[] = [] - - while (this.lexer.pos < this.prelude_end) { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) break - - // Check for comma (end of this query) - if (this.peek_token_type() === TOKEN_COMMA) break - - this.next_token() - - let token_type = this.lexer.token_type - // Media feature: (min-width: 768px) - if (token_type === TOKEN_LEFT_PAREN) { - let feature = this.parse_media_feature() - if (feature !== null) { - components.push(feature) - } - } - // Identifier: media type or operator (and, or, not) - else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { - // Logical operator - let op = this.arena.create_node() - this.arena.set_type(op, NODE_PRELUDE_OPERATOR) - this.arena.set_start_offset(op, this.lexer.token_start) - this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(op, this.lexer.token_line) - components.push(op) - } else { - // Media type: screen, print, all - let media_type = this.arena.create_node() - this.arena.set_type(media_type, NODE_PRELUDE_MEDIA_TYPE) - this.arena.set_start_offset(media_type, this.lexer.token_start) - this.arena.set_length(media_type, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(media_type, this.lexer.token_line) - this.arena.set_start_column(media_type, this.lexer.token_column) - components.push(media_type) - } - } else { - // Unknown token, skip - break - } - } - - if (components.length === 0) return null - - // Create media query node - let query_node = this.arena.create_node() - this.arena.set_type(query_node, NODE_PRELUDE_MEDIA_QUERY) - this.arena.set_start_offset(query_node, query_start) - this.arena.set_length(query_node, this.lexer.pos - query_start) - this.arena.set_start_line(query_node, query_line) - - // Append components as children - for (let component of components) { - this.arena.append_child(query_node, component) - } - - return query_node - } - - // Parse media feature: (min-width: 768px) - private parse_media_feature(): number | null { - let feature_start = this.lexer.token_start // '(' position - let feature_line = this.lexer.token_line - - // Find matching right paren - let depth = 1 - let content_start = this.lexer.pos - - while (this.lexer.pos < this.prelude_end && depth > 0) { - this.next_token() - let token_type = this.lexer.token_type - if (token_type === TOKEN_LEFT_PAREN) { - depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - depth-- - } - } - - if (depth !== 0) return null // Unmatched parentheses - - let content_end = this.lexer.token_start // Before ')' - let feature_end = this.lexer.token_end // After ')' - - // Create media feature node - let feature = this.arena.create_node() - this.arena.set_type(feature, NODE_PRELUDE_MEDIA_FEATURE) - this.arena.set_start_offset(feature, feature_start) - this.arena.set_length(feature, feature_end - feature_start) - this.arena.set_start_line(feature, feature_line) - - // Store feature content (without parentheses) in value fields, trimmed - let trimmed = trim_boundaries(this.source, content_start, content_end) - if (trimmed) { - this.arena.set_value_start(feature, trimmed[0]) - this.arena.set_value_length(feature, trimmed[1] - trimmed[0]) - } - - return feature - } - - // Parse container query: [name] and (min-width: 400px) - private parse_container_query(): number[] { - let nodes: number[] = [] - let query_start = this.lexer.pos - let query_line = this.lexer.line - - // Parse components (identifiers, operators, features) - let components: number[] = [] - - while (this.lexer.pos < this.prelude_end) { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) break - - this.next_token() - - let token_type = this.lexer.token_type - // Container feature: (min-width: 400px) - if (token_type === TOKEN_LEFT_PAREN) { - let feature = this.parse_media_feature() // Reuse media feature parser - if (feature !== null) { - components.push(feature) - } - } - // Identifier: operator (and, or, not) or container name - else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { - // Logical operator - let op = this.arena.create_node() - this.arena.set_type(op, NODE_PRELUDE_OPERATOR) - this.arena.set_start_offset(op, this.lexer.token_start) - this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(op, this.lexer.token_line) - components.push(op) - } else { - // Container name or other identifier - let name = this.arena.create_node() - this.arena.set_type(name, NODE_PRELUDE_IDENTIFIER) - this.arena.set_start_offset(name, this.lexer.token_start) - this.arena.set_length(name, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(name, this.lexer.token_line) - components.push(name) - } - } - } - - if (components.length === 0) return [] - - // Create container query node - let query_node = this.arena.create_node() - this.arena.set_type(query_node, NODE_PRELUDE_CONTAINER_QUERY) - this.arena.set_start_offset(query_node, query_start) - this.arena.set_length(query_node, this.lexer.pos - query_start) - this.arena.set_start_line(query_node, query_line) - - // Append components as children - for (let component of components) { - this.arena.append_child(query_node, component) - } - - nodes.push(query_node) - return nodes - } - - // Parse supports query: (display: flex) and (gap: 1rem) - private parse_supports_query(): number[] { - let nodes: number[] = [] - - while (this.lexer.pos < this.prelude_end) { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) break - - this.next_token() - - let token_type = this.lexer.token_type - // Feature query: (property: value) - if (token_type === TOKEN_LEFT_PAREN) { - let feature_start = this.lexer.token_start - let feature_line = this.lexer.token_line - - // Find matching right paren - let depth = 1 - let content_start = this.lexer.pos - - while (this.lexer.pos < this.prelude_end && depth > 0) { - this.next_token() - let inner_token_type = this.lexer.token_type - if (inner_token_type === TOKEN_LEFT_PAREN) { - depth++ - } else if (inner_token_type === TOKEN_RIGHT_PAREN) { - depth-- - } - } - - if (depth === 0) { - let content_end = this.lexer.token_start - let feature_end = this.lexer.token_end - - // Create supports query node - let query = this.arena.create_node() - this.arena.set_type(query, NODE_PRELUDE_SUPPORTS_QUERY) - this.arena.set_start_offset(query, feature_start) - this.arena.set_length(query, feature_end - feature_start) - this.arena.set_start_line(query, feature_line) - - // Store query content in value fields, trimmed - let trimmed = trim_boundaries(this.source, content_start, content_end) - if (trimmed) { - this.arena.set_value_start(query, trimmed[0]) - this.arena.set_value_length(query, trimmed[1] - trimmed[0]) - } - - nodes.push(query) - } - } - // Identifier: operator (and, or, not) - else if (token_type === TOKEN_IDENT) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - - if (this.is_and_or_not(text)) { - let op = this.arena.create_node() - this.arena.set_type(op, NODE_PRELUDE_OPERATOR) - this.arena.set_start_offset(op, this.lexer.token_start) - this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(op, this.lexer.token_line) - nodes.push(op) - } - } - } - - return nodes - } - - // Parse layer names: base, components, utilities - private parse_layer_names(): number[] { - let nodes: number[] = [] - - while (this.lexer.pos < this.prelude_end) { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) break - - this.next_token() - - let token_type = this.lexer.token_type - if (token_type === TOKEN_IDENT) { - // Layer name - let layer = this.arena.create_node() - this.arena.set_type(layer, NODE_PRELUDE_LAYER_NAME) - this.arena.set_start_offset(layer, this.lexer.token_start) - this.arena.set_length(layer, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(layer, this.lexer.token_line) - nodes.push(layer) - } else if (token_type === TOKEN_COMMA) { - // Skip comma separator - continue - } else if (token_type === TOKEN_WHITESPACE) { - // Skip whitespace - continue - } - } - - return nodes - } - - // Parse single identifier: keyframe name, property name - private parse_identifier(): number[] { - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return [] - - this.next_token() - - if (this.lexer.token_type !== TOKEN_IDENT) return [] - - // Create identifier node - let ident = this.arena.create_node() - this.arena.set_type(ident, NODE_PRELUDE_IDENTIFIER) - this.arena.set_start_offset(ident, this.lexer.token_start) - this.arena.set_length(ident, this.lexer.token_end - this.lexer.token_start) - this.arena.set_start_line(ident, this.lexer.token_line) - - return [ident] - } - - // Parse @import prelude: url() [layer] [supports()] [media-query-list] - // @import url("styles.css") layer(base) supports(display: grid) screen and (min-width: 768px); - private parse_import_prelude(): number[] { - let nodes: number[] = [] - - // 1. Parse URL (required) - url("...") or "..." - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return [] - - let url_node = this.parse_import_url() - if (url_node !== null) { - nodes.push(url_node) - } else { - return [] // URL is required, fail if not found - } - - // 2. Parse optional layer - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return nodes - - let layer_node = this.parse_import_layer() - if (layer_node !== null) { - nodes.push(layer_node) - } - - // 3. Parse optional supports() - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return nodes - - let supports_node = this.parse_import_supports() - if (supports_node !== null) { - nodes.push(supports_node) - } - - // 4. Parse optional media query list (remaining tokens) - this.skip_whitespace() - if (this.lexer.pos >= this.prelude_end) return nodes - - // Parse media queries (reuse existing parser) - let media_nodes = this.parse_media_query_list() - nodes.push(...media_nodes) - - return nodes - } - - // Parse import URL: url("file.css") or "file.css" - private parse_import_url(): number | null { - this.next_token() - - // Accept TOKEN_URL, TOKEN_FUNCTION (url(...)), or TOKEN_STRING - if (this.lexer.token_type !== TOKEN_URL && this.lexer.token_type !== TOKEN_FUNCTION && this.lexer.token_type !== TOKEN_STRING) { - return null - } - - // For url() function, we need to consume all tokens until the closing paren - let url_start = this.lexer.token_start - let url_end = this.lexer.token_end - let url_line = this.lexer.token_line - - if (this.lexer.token_type === TOKEN_FUNCTION) { - // It's url( ... we need to find the matching ) - let paren_depth = 1 - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - url_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } - } - } - - // Create URL node - let url_node = this.arena.create_node() - this.arena.set_type(url_node, NODE_PRELUDE_IMPORT_URL) - this.arena.set_start_offset(url_node, url_start) - this.arena.set_length(url_node, url_end - url_start) - this.arena.set_start_line(url_node, url_line) - - return url_node - } - - // Parse import layer: layer or layer(name) - private parse_import_layer(): number | null { - // 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() - - // Check for 'layer' keyword or 'layer(' function - if (this.lexer.token_type === TOKEN_IDENT || this.lexer.token_type === TOKEN_FUNCTION) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - // For function tokens, remove the trailing '(' - if (this.lexer.token_type === TOKEN_FUNCTION && text.endsWith('(')) { - text = text.slice(0, -1) - } - - if (str_equals('layer', text)) { - let layer_start = this.lexer.token_start - let layer_end = this.lexer.token_end - let layer_line = this.lexer.token_line - let content_start = 0 - let content_length = 0 - - // If it's a function token, parse the contents until closing paren - if (this.lexer.token_type === TOKEN_FUNCTION) { - // Track the content inside the parentheses - content_start = this.lexer.pos - let paren_depth = 1 - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_length = this.lexer.token_start - content_start - layer_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } - } - } - - // Create layer node - let layer_node = this.arena.create_node() - this.arena.set_type(layer_node, NODE_PRELUDE_IMPORT_LAYER) - this.arena.set_start_offset(layer_node, layer_start) - this.arena.set_length(layer_node, layer_end - layer_start) - this.arena.set_start_line(layer_node, layer_line) - - // Store the layer name (content inside parentheses), trimmed - if (content_length > 0) { - let trimmed = trim_boundaries(this.source, content_start, content_start + content_length) - if (trimmed) { - this.arena.set_content_start(layer_node, trimmed[0]) - this.arena.set_content_length(layer_node, trimmed[1] - trimmed[0]) - } - } - - return layer_node - } - } - - // Not a layer, restore position - this.lexer.pos = saved_pos - this.lexer.line = saved_line - this.lexer.column = saved_column - return null - } - - // Parse import supports: supports(condition) - private parse_import_supports(): number | null { - // 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() - - // Check for 'supports(' function - if (this.lexer.token_type === TOKEN_FUNCTION) { - let text = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' - if (str_equals('supports', text)) { - let supports_start = this.lexer.token_start - let supports_line = this.lexer.token_line - - // Find matching closing parenthesis - let paren_depth = 1 - let supports_end = this.lexer.token_end - - while (this.lexer.pos < this.prelude_end && paren_depth > 0) { - let tokenType = this.next_token() - if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { - paren_depth++ - } else if (tokenType === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - supports_end = this.lexer.token_end - } - } else if (tokenType === TOKEN_EOF) { - break - } - } - - // Create supports node - let supports_node = this.arena.create_node() - this.arena.set_type(supports_node, NODE_PRELUDE_IMPORT_SUPPORTS) - this.arena.set_start_offset(supports_node, supports_start) - this.arena.set_length(supports_node, supports_end - supports_start) - this.arena.set_start_line(supports_node, supports_line) - - return supports_node - } - } - - // Not supports(), restore position - this.lexer.pos = saved_pos - this.lexer.line = saved_line - this.lexer.column = saved_column - return null - } - - // Helper: Skip whitespace - private skip_whitespace(): void { - while (this.lexer.pos < this.prelude_end) { - let ch = this.source.charCodeAt(this.lexer.pos) - if (ch !== CHAR_SPACE && ch !== CHAR_TAB && ch !== CHAR_NEWLINE && ch !== CHAR_CARRIAGE_RETURN && ch !== CHAR_FORM_FEED) { - break - } - this.lexer.pos++ - } - } - - // Helper: Peek at next token type without consuming - 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 - } - - // Helper: Get next token - private next_token(): TokenType { - if (this.lexer.pos >= this.prelude_end) { - this.lexer.token_type = TOKEN_EOF - return TOKEN_EOF - } - return this.lexer.next_token_fast(false) - } -} diff --git a/src/column-tracking.test.ts b/src/column-tracking.test.ts index 4b3f5f9..be56fcc 100644 --- a/src/column-tracking.test.ts +++ b/src/column-tracking.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' import { parse } from './parse' -import { NODE_STYLE_RULE, NODE_DECLARATION, NODE_AT_RULE, NODE_SELECTOR_LIST } from './parser' +import { NODE_STYLE_RULE, NODE_DECLARATION, NODE_AT_RULE, NODE_SELECTOR_LIST } from './parse' describe('Column Tracking', () => { test('should track column for single-line CSS', () => { diff --git a/src/css-node.test.ts b/src/css-node.test.ts index b507dc7..49ac462 100644 --- a/src/css-node.test.ts +++ b/src/css-node.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' -import { Parser } from './parser' -import { NODE_DECLARATION, NODE_SELECTOR_LIST, NODE_STYLE_RULE, NODE_AT_RULE } from './arena' +import { Parser } from './parse' +import { NODE_DECLARATION, NODE_STYLE_RULE, NODE_AT_RULE } from './arena' describe('CSSNode', () => { describe('iteration', () => { diff --git a/src/css-node.ts b/src/css-node.ts index 0cb04fa..490df7d 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -167,7 +167,8 @@ export class CSSNode { } // Check if this declaration has !important - get is_important(): boolean { + get is_important(): boolean | null { + if (this.type !== NODE_DECLARATION) return null return this.arena.has_flag(this.index, FLAG_IMPORTANT) } @@ -229,9 +230,7 @@ export class CSSNode { // 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 - } + if (this.type !== NODE_BLOCK) return false // Empty if no children, or all children are comments let child = this.first_child diff --git a/src/index.ts b/src/index.ts index 7d8e365..89e2944 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { tokenize } from './tokenize' export { walk, walk_enter_leave } from './walk' // Advanced/class-based API -export { type ParserOptions } from './parser' +export { type ParserOptions } from './parse' // Types export { CSSNode, type CSSNodeType } from './css-node' @@ -62,7 +62,7 @@ export { NODE_PRELUDE_IMPORT_LAYER, NODE_PRELUDE_IMPORT_SUPPORTS, FLAG_IMPORTANT, -} from './parser' +} from './parse' export { TOKEN_IDENT, TOKEN_FUNCTION, diff --git a/src/anplusb-parser.test.ts b/src/parse-anplusb.test.ts similarity index 99% rename from src/anplusb-parser.test.ts rename to src/parse-anplusb.test.ts index 0150390..459d581 100644 --- a/src/anplusb-parser.test.ts +++ b/src/parse-anplusb.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { ANplusBParser } from './anplusb-parser' +import { ANplusBParser } from './parse-anplusb' import { CSSDataArena, NODE_SELECTOR_NTH } from './arena' import { CSSNode } from './css-node' diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index 4ee3712..3dad4b7 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -1,6 +1,317 @@ +// 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 { NODE_SELECTOR_NTH, CSSDataArena } 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' import { CSSNode } from './css-node' -import { CSSDataArena } from './arena' -import { ANplusBParser } from './anplusb-parser' + +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 = this.source.substring(this.lexer.token_start, this.lexer.token_end) + a_start = this.lexer.token_start + a_end = this.lexer.token_end + return this.create_anplusb_node(node_start, a, null, a_start, a_end, 0, 0) + } + + // 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 + } +} export function parse_anplusb(expr: string): CSSNode | null { const arena = new CSSDataArena(64) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index f840845..bf9049d 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1,13 +1,507 @@ -import { describe, test, expect } from 'vitest' +import { describe, it, test, expect } from 'vitest' +import { parse } from './parse' import { parse_atrule_prelude } from './parse-atrule-prelude' import { - NODE_PRELUDE_IDENTIFIER, + NODE_AT_RULE, + NODE_BLOCK, + NODE_PRELUDE_MEDIA_QUERY, + NODE_PRELUDE_MEDIA_FEATURE, + NODE_PRELUDE_MEDIA_TYPE, + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_SUPPORTS_QUERY, NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_OPERATOR, NODE_PRELUDE_IMPORT_URL, NODE_PRELUDE_IMPORT_LAYER, NODE_PRELUDE_IMPORT_SUPPORTS, } from './arena' +describe('At-Rule Prelude Parser', () => { + describe('@media', () => { + it('should parse media type', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('media') + + // Should have prelude children + const children = atRule?.children || [] + expect(children.length).toBeGreaterThan(0) + + // First child should be a media query + expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + + // Query should have a media type child + const queryChildren = children[0].children + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_TYPE)).toBe(true) + }) + + it('should parse media feature', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + + // Query should have a media feature child + const queryChildren = children[0].children + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) + + // Feature should have content + const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) + expect(feature?.value).toContain('min-width') + }) + + it('should trim whitespace and comments from media features', () => { + const css = '@media (/* comment */ min-width: 768px /* test */) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + const queryChildren = children[0].children + const feature = queryChildren.find((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) + + expect(feature?.value).toBe('min-width: 768px') + }) + + it('should parse complex media query with and operator', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[0].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + + const queryChildren = children[0].children + // Should have: media type, operator, media feature + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_TYPE)).toBe(true) + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_OPERATOR)).toBe(true) + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) + }) + + it('should parse multiple media features', () => { + const css = '@media (min-width: 768px) and (max-width: 1024px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const queryChildren = children[0].children + const features = queryChildren.filter((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE) + expect(features.length).toBe(2) + }) + + it('should parse comma-separated media queries', () => { + const css = '@media screen, print { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + // Should have 2 media query nodes + const queries = children.filter((c) => c.type === NODE_PRELUDE_MEDIA_QUERY) + expect(queries.length).toBe(2) + }) + }) + + describe('@container', () => { + it('should parse unnamed container query', () => { + const css = '@container (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('container') + + const children = atRule?.children || [] + expect(children.length).toBeGreaterThan(0) + expect(children[0].type).toBe(NODE_PRELUDE_CONTAINER_QUERY) + }) + + it('should parse named container query', () => { + const css = '@container sidebar (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[0].type).toBe(NODE_PRELUDE_CONTAINER_QUERY) + + const queryChildren = children[0].children + // Should have name and feature + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_IDENTIFIER)).toBe(true) + expect(queryChildren.some((c) => c.type === NODE_PRELUDE_MEDIA_FEATURE)).toBe(true) + }) + }) + + describe('@supports', () => { + it('should parse single feature query', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('supports') + + const children = atRule?.children || [] + expect(children.some((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY)).toBe(true) + + const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) + expect(query?.value).toContain('display') + expect(query?.value).toContain('flex') + }) + + it('should trim whitespace and comments from supports queries', () => { + const css = '@supports (/* comment */ display: flex /* test */) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + const query = children.find((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) + + expect(query?.value).toBe('display: flex') + }) + + it('should parse complex supports query with operators', () => { + const css = '@supports (display: flex) and (gap: 1rem) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + // Should have 2 queries and 1 operator + const queries = children.filter((c) => c.type === NODE_PRELUDE_SUPPORTS_QUERY) + const operators = children.filter((c) => c.type === NODE_PRELUDE_OPERATOR) + + expect(queries.length).toBe(2) + expect(operators.length).toBe(1) + }) + }) + + describe('@layer', () => { + it('should parse single layer name', () => { + const css = '@layer base { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('layer') + + // 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') + }) + + it('should parse comma-separated layer names', () => { + const css = '@layer base, components, utilities;' + const ast = parse(css) + const atRule = ast.first_child + + const children = atRule?.children || [] + expect(children.length).toBe(3) + + expect(children[0].type).toBe(NODE_PRELUDE_LAYER_NAME) + expect(children[0].text).toBe('base') + + expect(children[1].type).toBe(NODE_PRELUDE_LAYER_NAME) + expect(children[1].text).toBe('components') + + expect(children[2].type).toBe(NODE_PRELUDE_LAYER_NAME) + expect(children[2].text).toBe('utilities') + }) + }) + + describe('@keyframes', () => { + it('should parse keyframe name', () => { + const css = '@keyframes slidein { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('keyframes') + + // 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') + }) + }) + + describe('@property', () => { + it('should parse custom property name', () => { + const css = '@property --my-color { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('property') + + // 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') + }) + }) + + describe('@font-face', () => { + it('should have no prelude children', () => { + const css = '@font-face { font-family: "MyFont"; }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.type).toBe(NODE_AT_RULE) + expect(atRule?.name).toBe('font-face') + + // @font-face has no prelude, children should be declarations + const children = atRule?.children || [] + if (children.length > 0) { + // If parse_values is enabled, there might be declaration children + expect(children[0].type).not.toBe(NODE_PRELUDE_IDENTIFIER) + } + }) + }) + + describe('parse_atrule_preludes option', () => { + it('should parse preludes when enabled (default)', () => { + const css = '@media screen { }' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(true) + }) + + it('should not parse preludes when disabled', () => { + const css = '@media screen { }' + const ast = parse(css, { parse_atrule_preludes: false }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.some((c) => c.type === NODE_PRELUDE_MEDIA_QUERY)).toBe(false) + }) + }) + + describe('Prelude text access', () => { + it('should preserve prelude text in at-rule node', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + + // The prelude text should still be accessible + expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + }) + }) + + describe('@import', () => { + it('should parse URL with url() function', () => { + const css = '@import url("styles.css");' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBeGreaterThan(0) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[0].text).toBe('url("styles.css")') + }) + + it('should parse URL with string', () => { + const css = '@import "styles.css";' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBeGreaterThan(0) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[0].text).toBe('"styles.css"') + }) + + it('should parse with anonymous layer', () => { + const css = '@import url("styles.css") layer;' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].text).toBe('layer') + expect(children[1].name).toBe('') + }) + + it('should parse with anonymous LAYER', () => { + const css = '@import url("styles.css") LAYER;' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].text).toBe('LAYER') + expect(children[1].name).toBe('') + }) + + it('should parse with named layer', () => { + const css = '@import url("styles.css") layer(utilities);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].text).toBe('layer(utilities)') + expect(children[1].name).toBe('utilities') + }) + + it('should trim whitespace from layer names', () => { + const css = '@import url("styles.css") layer( utilities );' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].name).toBe('utilities') + }) + + it('should trim comments from layer names', () => { + const css = '@import url("styles.css") layer(/* comment */utilities/* test */);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].name).toBe('utilities') + }) + + it('should trim whitespace and comments from dotted layer names', () => { + const css = '@import url("foo.css") layer(/* test */named.nested );' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[1].name).toBe('named.nested') + }) + + it('should parse with supports query', () => { + const css = '@import url("styles.css") supports(display: grid);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) + expect(children[1].text).toBe('supports(display: grid)') + }) + + it('should parse with media query', () => { + const css = '@import url("styles.css") screen;' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with media feature', () => { + const css = '@import url("styles.css") (min-width: 768px);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with combined media query', () => { + const css = '@import url("styles.css") screen and (min-width: 768px);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with layer and media query', () => { + const css = '@import url("styles.css") layer(base) screen;' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(3) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[2].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with layer and supports', () => { + const css = '@import url("styles.css") layer(base) supports(display: grid);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(3) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[2].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) + }) + + it('should parse with supports and media query', () => { + const css = '@import url("styles.css") supports(display: grid) screen;' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(3) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) + expect(children[2].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with all features combined', () => { + const css = '@import url("styles.css") layer(base) supports(display: grid) screen and (min-width: 768px);' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(4) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_LAYER) + expect(children[2].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) + expect(children[3].type).toBe(NODE_PRELUDE_MEDIA_QUERY) + }) + + it('should parse with complex supports condition', () => { + const css = '@import url("styles.css") supports((display: grid) and (gap: 1rem));' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child + const children = atRule?.children || [] + + expect(children.length).toBe(2) + expect(children[0].type).toBe(NODE_PRELUDE_IMPORT_URL) + expect(children[1].type).toBe(NODE_PRELUDE_IMPORT_SUPPORTS) + expect(children[1].text).toContain('supports(') + }) + + it('should preserve prelude text', () => { + const css = '@import url("styles.css") layer(base) screen;' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('url("styles.css") layer(base) screen') + }) + }) +}) + describe('parse_atrule_prelude()', () => { describe('media queries', () => { test('should parse simple media feature', () => { diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 8bab2a7..90bd8d8 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -1,7 +1,668 @@ -import { CSSDataArena } from './arena' -import { AtRulePreludeParser } from './at-rule-prelude-parser' +// At-Rule Prelude Parser - Parses at-rule preludes into structured AST nodes +import { Lexer } from './lexer' +import { + CSSDataArena, + NODE_PRELUDE_MEDIA_QUERY, + NODE_PRELUDE_MEDIA_FEATURE, + NODE_PRELUDE_MEDIA_TYPE, + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_SUPPORTS_QUERY, + NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_OPERATOR, + NODE_PRELUDE_IMPORT_URL, + NODE_PRELUDE_IMPORT_LAYER, + NODE_PRELUDE_IMPORT_SUPPORTS, +} from './arena' +import { + TOKEN_IDENT, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_COMMA, + TOKEN_EOF, + TOKEN_WHITESPACE, + TOKEN_STRING, + TOKEN_URL, + TOKEN_FUNCTION, + type TokenType, +} from './token-types' +import { trim_boundaries, str_equals, CHAR_SPACE, CHAR_TAB, CHAR_NEWLINE, CHAR_CARRIAGE_RETURN, CHAR_FORM_FEED } from './string-utils' import { CSSNode } from './css-node' +export class AtRulePreludeParser { + private lexer: Lexer + private arena: CSSDataArena + private source: string + private prelude_end: number + + constructor(arena: CSSDataArena, source: string) { + this.arena = arena + this.source = source + // Create a lexer instance for prelude parsing (don't skip comments) + this.lexer = new Lexer(source, false) + this.prelude_end = 0 + } + + // Parse an at-rule prelude into nodes based on the at-rule type + parse_prelude(at_rule_name: string, start: number, end: number, line: number = 1, column: number = 1): number[] { + this.prelude_end = end + + // Position lexer at prelude start + this.lexer.pos = start + this.lexer.line = line + this.lexer.column = column + + // Dispatch to appropriate parser based on at-rule type + if (str_equals('media', at_rule_name)) { + return this.parse_media_query_list() + } else if (str_equals('container', at_rule_name)) { + return this.parse_container_query() + } else if (str_equals('supports', at_rule_name)) { + return this.parse_supports_query() + } else if (str_equals('layer', at_rule_name)) { + return this.parse_layer_names() + } else if (str_equals('keyframes', at_rule_name)) { + return this.parse_identifier() + } else if (str_equals('property', at_rule_name)) { + return this.parse_identifier() + } else if (str_equals('import', at_rule_name)) { + return this.parse_import_prelude() + } + // For now, @namespace and other at-rules are not parsed + + return [] + } + + // Parse media query list: screen, (min-width: 768px), ... + private parse_media_query_list(): number[] { + let nodes: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + let query = this.parse_single_media_query() + if (query !== null) { + nodes.push(query) + } + + // Skip comma separator + this.skip_whitespace() + if (this.peek_token_type() === TOKEN_COMMA) { + this.next_token() // consume comma + } + } + + return nodes + } + + private is_and_or_not(str: string): boolean { + if (str.length > 3 || str.length < 2) return false + return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) + } + + // Parse a single media query: screen and (min-width: 768px) + private parse_single_media_query(): number | null { + let query_start = this.lexer.pos + let query_line = this.lexer.line + + // Skip whitespace + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return null + + // Check for modifier (only, not) + // let has_modifier = false + let token_start = this.lexer.pos + this.next_token() + + if (this.lexer.token_type === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + if (!str_equals('only', text) && !str_equals('not', text)) { + // Reset - this is a media type + this.lexer.pos = token_start + } + } else { + this.lexer.pos = token_start + } + + // Parse components (media type, features, operators) + let components: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + // Check for comma (end of this query) + if (this.peek_token_type() === TOKEN_COMMA) break + + this.next_token() + + let token_type = this.lexer.token_type + // Media feature: (min-width: 768px) + if (token_type === TOKEN_LEFT_PAREN) { + let feature = this.parse_media_feature() + if (feature !== null) { + components.push(feature) + } + } + // Identifier: media type or operator (and, or, not) + else if (token_type === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + + if (this.is_and_or_not(text)) { + // Logical operator + let op = this.arena.create_node() + this.arena.set_type(op, NODE_PRELUDE_OPERATOR) + this.arena.set_start_offset(op, this.lexer.token_start) + this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(op, this.lexer.token_line) + components.push(op) + } else { + // Media type: screen, print, all + let media_type = this.arena.create_node() + this.arena.set_type(media_type, NODE_PRELUDE_MEDIA_TYPE) + this.arena.set_start_offset(media_type, this.lexer.token_start) + this.arena.set_length(media_type, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(media_type, this.lexer.token_line) + this.arena.set_start_column(media_type, this.lexer.token_column) + components.push(media_type) + } + } else { + // Unknown token, skip + break + } + } + + if (components.length === 0) return null + + // Create media query node + let query_node = this.arena.create_node() + this.arena.set_type(query_node, NODE_PRELUDE_MEDIA_QUERY) + this.arena.set_start_offset(query_node, query_start) + this.arena.set_length(query_node, this.lexer.pos - query_start) + this.arena.set_start_line(query_node, query_line) + + // Append components as children + for (let component of components) { + this.arena.append_child(query_node, component) + } + + return query_node + } + + // Parse media feature: (min-width: 768px) + private parse_media_feature(): number | null { + let feature_start = this.lexer.token_start // '(' position + let feature_line = this.lexer.token_line + + // Find matching right paren + let depth = 1 + let content_start = this.lexer.pos + + while (this.lexer.pos < this.prelude_end && depth > 0) { + this.next_token() + let token_type = this.lexer.token_type + if (token_type === TOKEN_LEFT_PAREN) { + depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + depth-- + } + } + + if (depth !== 0) return null // Unmatched parentheses + + let content_end = this.lexer.token_start // Before ')' + let feature_end = this.lexer.token_end // After ')' + + // Create media feature node + let feature = this.arena.create_node() + this.arena.set_type(feature, NODE_PRELUDE_MEDIA_FEATURE) + this.arena.set_start_offset(feature, feature_start) + this.arena.set_length(feature, feature_end - feature_start) + this.arena.set_start_line(feature, feature_line) + + // Store feature content (without parentheses) in value fields, trimmed + let trimmed = trim_boundaries(this.source, content_start, content_end) + if (trimmed) { + this.arena.set_value_start(feature, trimmed[0]) + this.arena.set_value_length(feature, trimmed[1] - trimmed[0]) + } + + return feature + } + + // Parse container query: [name] and (min-width: 400px) + private parse_container_query(): number[] { + let nodes: number[] = [] + let query_start = this.lexer.pos + let query_line = this.lexer.line + + // Parse components (identifiers, operators, features) + let components: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + this.next_token() + + let token_type = this.lexer.token_type + // Container feature: (min-width: 400px) + if (token_type === TOKEN_LEFT_PAREN) { + let feature = this.parse_media_feature() // Reuse media feature parser + if (feature !== null) { + components.push(feature) + } + } + // Identifier: operator (and, or, not) or container name + else if (token_type === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + + if (this.is_and_or_not(text)) { + // Logical operator + let op = this.arena.create_node() + this.arena.set_type(op, NODE_PRELUDE_OPERATOR) + this.arena.set_start_offset(op, this.lexer.token_start) + this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(op, this.lexer.token_line) + components.push(op) + } else { + // Container name or other identifier + let name = this.arena.create_node() + this.arena.set_type(name, NODE_PRELUDE_IDENTIFIER) + this.arena.set_start_offset(name, this.lexer.token_start) + this.arena.set_length(name, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(name, this.lexer.token_line) + components.push(name) + } + } + } + + if (components.length === 0) return [] + + // Create container query node + let query_node = this.arena.create_node() + this.arena.set_type(query_node, NODE_PRELUDE_CONTAINER_QUERY) + this.arena.set_start_offset(query_node, query_start) + this.arena.set_length(query_node, this.lexer.pos - query_start) + this.arena.set_start_line(query_node, query_line) + + // Append components as children + for (let component of components) { + this.arena.append_child(query_node, component) + } + + nodes.push(query_node) + return nodes + } + + // Parse supports query: (display: flex) and (gap: 1rem) + private parse_supports_query(): number[] { + let nodes: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + this.next_token() + + let token_type = this.lexer.token_type + // Feature query: (property: value) + if (token_type === TOKEN_LEFT_PAREN) { + let feature_start = this.lexer.token_start + let feature_line = this.lexer.token_line + + // Find matching right paren + let depth = 1 + let content_start = this.lexer.pos + + while (this.lexer.pos < this.prelude_end && depth > 0) { + this.next_token() + let inner_token_type = this.lexer.token_type + if (inner_token_type === TOKEN_LEFT_PAREN) { + depth++ + } else if (inner_token_type === TOKEN_RIGHT_PAREN) { + depth-- + } + } + + if (depth === 0) { + let content_end = this.lexer.token_start + let feature_end = this.lexer.token_end + + // Create supports query node + let query = this.arena.create_node() + this.arena.set_type(query, NODE_PRELUDE_SUPPORTS_QUERY) + this.arena.set_start_offset(query, feature_start) + this.arena.set_length(query, feature_end - feature_start) + this.arena.set_start_line(query, feature_line) + + // Store query content in value fields, trimmed + let trimmed = trim_boundaries(this.source, content_start, content_end) + if (trimmed) { + this.arena.set_value_start(query, trimmed[0]) + this.arena.set_value_length(query, trimmed[1] - trimmed[0]) + } + + nodes.push(query) + } + } + // Identifier: operator (and, or, not) + else if (token_type === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + + if (this.is_and_or_not(text)) { + let op = this.arena.create_node() + this.arena.set_type(op, NODE_PRELUDE_OPERATOR) + this.arena.set_start_offset(op, this.lexer.token_start) + this.arena.set_length(op, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(op, this.lexer.token_line) + nodes.push(op) + } + } + } + + return nodes + } + + // Parse layer names: base, components, utilities + private parse_layer_names(): number[] { + let nodes: number[] = [] + + while (this.lexer.pos < this.prelude_end) { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) break + + this.next_token() + + let token_type = this.lexer.token_type + if (token_type === TOKEN_IDENT) { + // Layer name + let layer = this.arena.create_node() + this.arena.set_type(layer, NODE_PRELUDE_LAYER_NAME) + this.arena.set_start_offset(layer, this.lexer.token_start) + this.arena.set_length(layer, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(layer, this.lexer.token_line) + nodes.push(layer) + } else if (token_type === TOKEN_COMMA) { + // Skip comma separator + continue + } else if (token_type === TOKEN_WHITESPACE) { + // Skip whitespace + continue + } + } + + return nodes + } + + // Parse single identifier: keyframe name, property name + private parse_identifier(): number[] { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return [] + + this.next_token() + + if (this.lexer.token_type !== TOKEN_IDENT) return [] + + // Create identifier node + let ident = this.arena.create_node() + this.arena.set_type(ident, NODE_PRELUDE_IDENTIFIER) + this.arena.set_start_offset(ident, this.lexer.token_start) + this.arena.set_length(ident, this.lexer.token_end - this.lexer.token_start) + this.arena.set_start_line(ident, this.lexer.token_line) + + return [ident] + } + + // Parse @import prelude: url() [layer] [supports()] [media-query-list] + // @import url("styles.css") layer(base) supports(display: grid) screen and (min-width: 768px); + private parse_import_prelude(): number[] { + let nodes: number[] = [] + + // 1. Parse URL (required) - url("...") or "..." + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return [] + + let url_node = this.parse_import_url() + if (url_node !== null) { + nodes.push(url_node) + } else { + return [] // URL is required, fail if not found + } + + // 2. Parse optional layer + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return nodes + + let layer_node = this.parse_import_layer() + if (layer_node !== null) { + nodes.push(layer_node) + } + + // 3. Parse optional supports() + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return nodes + + let supports_node = this.parse_import_supports() + if (supports_node !== null) { + nodes.push(supports_node) + } + + // 4. Parse optional media query list (remaining tokens) + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return nodes + + // Parse media queries (reuse existing parser) + let media_nodes = this.parse_media_query_list() + nodes.push(...media_nodes) + + return nodes + } + + // Parse import URL: url("file.css") or "file.css" + private parse_import_url(): number | null { + this.next_token() + + // Accept TOKEN_URL, TOKEN_FUNCTION (url(...)), or TOKEN_STRING + if (this.lexer.token_type !== TOKEN_URL && this.lexer.token_type !== TOKEN_FUNCTION && this.lexer.token_type !== TOKEN_STRING) { + return null + } + + // For url() function, we need to consume all tokens until the closing paren + let url_start = this.lexer.token_start + let url_end = this.lexer.token_end + let url_line = this.lexer.token_line + + if (this.lexer.token_type === TOKEN_FUNCTION) { + // It's url( ... we need to find the matching ) + let paren_depth = 1 + while (this.lexer.pos < this.prelude_end && paren_depth > 0) { + let tokenType = this.next_token() + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { + paren_depth++ + } else if (tokenType === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + url_end = this.lexer.token_end + } + } else if (tokenType === TOKEN_EOF) { + break + } + } + } + + // Create URL node + let url_node = this.arena.create_node() + this.arena.set_type(url_node, NODE_PRELUDE_IMPORT_URL) + this.arena.set_start_offset(url_node, url_start) + this.arena.set_length(url_node, url_end - url_start) + this.arena.set_start_line(url_node, url_line) + + return url_node + } + + // Parse import layer: layer or layer(name) + private parse_import_layer(): number | null { + // 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() + + // Check for 'layer' keyword or 'layer(' function + if (this.lexer.token_type === TOKEN_IDENT || this.lexer.token_type === TOKEN_FUNCTION) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + // For function tokens, remove the trailing '(' + if (this.lexer.token_type === TOKEN_FUNCTION && text.endsWith('(')) { + text = text.slice(0, -1) + } + + if (str_equals('layer', text)) { + let layer_start = this.lexer.token_start + let layer_end = this.lexer.token_end + let layer_line = this.lexer.token_line + let content_start = 0 + let content_length = 0 + + // If it's a function token, parse the contents until closing paren + if (this.lexer.token_type === TOKEN_FUNCTION) { + // Track the content inside the parentheses + content_start = this.lexer.pos + let paren_depth = 1 + while (this.lexer.pos < this.prelude_end && paren_depth > 0) { + let tokenType = this.next_token() + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { + paren_depth++ + } else if (tokenType === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + content_length = this.lexer.token_start - content_start + layer_end = this.lexer.token_end + } + } else if (tokenType === TOKEN_EOF) { + break + } + } + } + + // Create layer node + let layer_node = this.arena.create_node() + this.arena.set_type(layer_node, NODE_PRELUDE_IMPORT_LAYER) + this.arena.set_start_offset(layer_node, layer_start) + this.arena.set_length(layer_node, layer_end - layer_start) + this.arena.set_start_line(layer_node, layer_line) + + // Store the layer name (content inside parentheses), trimmed + if (content_length > 0) { + let trimmed = trim_boundaries(this.source, content_start, content_start + content_length) + if (trimmed) { + this.arena.set_content_start(layer_node, trimmed[0]) + this.arena.set_content_length(layer_node, trimmed[1] - trimmed[0]) + } + } + + return layer_node + } + } + + // Not a layer, restore position + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + return null + } + + // Parse import supports: supports(condition) + private parse_import_supports(): number | null { + // 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() + + // Check for 'supports(' function + if (this.lexer.token_type === TOKEN_FUNCTION) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '(' + if (str_equals('supports', text)) { + let supports_start = this.lexer.token_start + let supports_line = this.lexer.token_line + + // Find matching closing parenthesis + let paren_depth = 1 + let supports_end = this.lexer.token_end + + while (this.lexer.pos < this.prelude_end && paren_depth > 0) { + let tokenType = this.next_token() + if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) { + paren_depth++ + } else if (tokenType === TOKEN_RIGHT_PAREN) { + paren_depth-- + if (paren_depth === 0) { + supports_end = this.lexer.token_end + } + } else if (tokenType === TOKEN_EOF) { + break + } + } + + // Create supports node + let supports_node = this.arena.create_node() + this.arena.set_type(supports_node, NODE_PRELUDE_IMPORT_SUPPORTS) + this.arena.set_start_offset(supports_node, supports_start) + this.arena.set_length(supports_node, supports_end - supports_start) + this.arena.set_start_line(supports_node, supports_line) + + return supports_node + } + } + + // Not supports(), restore position + this.lexer.pos = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + return null + } + + // Helper: Skip whitespace + private skip_whitespace(): void { + while (this.lexer.pos < this.prelude_end) { + let ch = this.source.charCodeAt(this.lexer.pos) + if (ch !== CHAR_SPACE && ch !== CHAR_TAB && ch !== CHAR_NEWLINE && ch !== CHAR_CARRIAGE_RETURN && ch !== CHAR_FORM_FEED) { + break + } + this.lexer.pos++ + } + } + + // Helper: Peek at next token type without consuming + 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 + } + + // Helper: Get next token + private next_token(): TokenType { + if (this.lexer.pos >= this.prelude_end) { + this.lexer.token_type = TOKEN_EOF + return TOKEN_EOF + } + return this.lexer.next_token_fast(false) + } +} + /** * Parse an at-rule prelude string and return an array of AST nodes * @param at_rule_name - The name of the at-rule (e.g., "media", "supports", "layer") diff --git a/src/parser-options.test.ts b/src/parse-options.test.ts similarity index 99% rename from src/parser-options.test.ts rename to src/parse-options.test.ts index 16350bf..a0c1f85 100644 --- a/src/parser-options.test.ts +++ b/src/parse-options.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { Parser } from './parser' +import { Parser } from './parse' import { NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_VALUE_KEYWORD } from './arena' describe('Parser Options', () => { diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index 6d96ca3..e5dea82 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -1,6 +1,1222 @@ -import { describe, test, expect } from 'vitest' -import { parse_selector } from './parse-selector' -import { NODE_SELECTOR_LIST } from './arena' +import { describe, it, expect, test } from 'vitest' +import { SelectorParser, parse_selector } from './parse-selector' +import { CSSDataArena } from './arena' +import { + NODE_SELECTOR, + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_CLASS, + NODE_SELECTOR_ID, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_PSEUDO_ELEMENT, + NODE_SELECTOR_COMBINATOR, + NODE_SELECTOR_UNIVERSAL, + NODE_SELECTOR_NESTING, + NODE_SELECTOR_NTH, + NODE_SELECTOR_NTH_OF, + NODE_SELECTOR_LANG, +} from './arena' + +// Tests using the exported parse_selector() function +describe('parse_selector() function', () => { + it('should parse and return a CSSNode', () => { + const node = parse_selector('div.container') + expect(node).toBeDefined() + expect(node.type).toBe(NODE_SELECTOR_LIST) + expect(node.text).toBe('div.container') + }) + + it('should parse type selector', () => { + const node = parse_selector('div') + expect(node.type).toBe(NODE_SELECTOR_LIST) + + const firstSelector = node.first_child + expect(firstSelector?.type).toBe(NODE_SELECTOR) + + const typeNode = firstSelector?.first_child + expect(typeNode?.type).toBe(NODE_SELECTOR_TYPE) + expect(typeNode?.text).toBe('div') + }) + + it('should parse class selector', () => { + const node = parse_selector('.my-class') + const firstSelector = node.first_child + const classNode = firstSelector?.first_child + + expect(classNode?.type).toBe(NODE_SELECTOR_CLASS) + expect(classNode?.name).toBe('my-class') + }) + + it('should parse ID selector', () => { + const node = parse_selector('#my-id') + const firstSelector = node.first_child + const idNode = firstSelector?.first_child + + expect(idNode?.type).toBe(NODE_SELECTOR_ID) + expect(idNode?.name).toBe('my-id') + }) + + it('should parse compound selector', () => { + const node = parse_selector('div.container#app') + const firstSelector = node.first_child + const children = firstSelector?.children || [] + + expect(children.length).toBe(3) + expect(children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(children[1].type).toBe(NODE_SELECTOR_CLASS) + expect(children[2].type).toBe(NODE_SELECTOR_ID) + }) + + it('should parse complex selector with descendant combinator', () => { + const node = parse_selector('div .container') + const firstSelector = node.first_child + const children = firstSelector?.children || [] + + expect(children.length).toBe(3) // div, combinator, .container + expect(children[0].type).toBe(NODE_SELECTOR_TYPE) + expect(children[1].type).toBe(NODE_SELECTOR_COMBINATOR) + expect(children[2].type).toBe(NODE_SELECTOR_CLASS) + }) + + it('should parse selector list', () => { + const node = parse_selector('div, span, p') + const selectors = node.children + + expect(selectors.length).toBe(3) + expect(selectors[0].first_child?.type).toBe(NODE_SELECTOR_TYPE) + expect(selectors[1].first_child?.type).toBe(NODE_SELECTOR_TYPE) + expect(selectors[2].first_child?.type).toBe(NODE_SELECTOR_TYPE) + }) +}) + +// Internal SelectorParser class tests (for implementation details) +// These tests use low-level arena API to test internal implementation + +// Helper for low-level testing +function parseSelectorInternal(selector: string) { + const arena = new CSSDataArena(256) + const parser = new SelectorParser(arena, selector) + const rootNode = parser.parse_selector(0, selector.length) + return { arena, rootNode, source: selector } +} + +// Helper to get node text +function getNodeText(arena: CSSDataArena, source: string, nodeIndex: number): string { + const start = arena.get_start_offset(nodeIndex) + const length = arena.get_length(nodeIndex) + return source.substring(start, start + length) +} + +// Helper to get node content (name) +function getNodeContent(arena: CSSDataArena, source: string, nodeIndex: number): string { + const start = arena.get_content_start(nodeIndex) + const length = arena.get_content_length(nodeIndex) + return source.substring(start, start + length) +} + +// Helper to get all children +function getChildren(arena: CSSDataArena, source: string, nodeIndex: number | null) { + if (nodeIndex === null) return [] + const children: number[] = [] + let child = arena.get_first_child(nodeIndex) + while (child !== 0) { + children.push(child) + child = arena.get_next_sibling(child) + } + return children +} + +describe('SelectorParser', () => { + describe('Simple selectors', () => { + it('should parse type selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('div') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + expect(getNodeText(arena, source, rootNode)).toBe('div') + + // First child is NODE_SELECTOR wrapper + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + // First child of wrapper is the actual type + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, child)).toBe('div') + }) + + it('should parse class selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.my-class') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) + expect(getNodeText(arena, source, child)).toBe('.my-class') + expect(getNodeContent(arena, source, child)).toBe('my-class') + }) + + it('should parse ID selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('#my-id') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ID) + expect(getNodeText(arena, source, child)).toBe('#my-id') + expect(getNodeContent(arena, source, child)).toBe('my-id') + }) + + it('should parse universal selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('*') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_UNIVERSAL) + expect(getNodeText(arena, source, child)).toBe('*') + }) + + it('should parse nesting selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('&') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_NESTING) + expect(getNodeText(arena, source, child)).toBe('&') + }) + }) + + describe('Compound selectors', () => { + it('should parse element with class', () => { + const { arena, rootNode, source } = parseSelectorInternal('div.container') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + // Get the NODE_SELECTOR wrapper + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + // Compound selector has multiple children + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[0])).toBe('div') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('container') + }) + + it('should parse element with ID', () => { + const { arena, rootNode, source } = parseSelectorInternal('div#app') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_ID) + expect(getNodeContent(arena, source, children[1])).toBe('app') + }) + + it('should parse element with multiple classes', () => { + const { arena, rootNode, source } = parseSelectorInternal('div.foo.bar.baz') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(4) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('foo') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, children[2])).toBe('bar') + expect(arena.get_type(children[3])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, children[3])).toBe('baz') + }) + + it('should parse complex compound selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('div.container#app') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[0])).toBe('div') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('container') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_ID) + expect(getNodeContent(arena, source, children[2])).toBe('app') + }) + }) + + describe('Pseudo-classes', () => { + it('should parse simple pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('a:hover') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('hover') + }) + + it('should parse pseudo-class with function', () => { + const { arena, rootNode, source } = parseSelectorInternal('li:nth-child(2n+1)') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('nth-child') + expect(getNodeText(arena, source, children[1])).toBe(':nth-child(2n+1)') + }) + + it('should parse multiple pseudo-classes', () => { + const { arena, rootNode, source } = parseSelectorInternal('input:focus:valid') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('focus') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, children[2])).toBe('valid') + }) + + it('should parse :is() pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('a:is(.active)') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + 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('is') + }) + + it('should parse :not() pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('div:not(.disabled)') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + 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('not') + }) + }) + + describe('Pseudo-elements', () => { + it('should parse pseudo-element with double colon', () => { + const { arena, rootNode, source } = parseSelectorInternal('p::before') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_ELEMENT) + expect(getNodeContent(arena, source, children[1])).toBe('before') + }) + + it('should parse pseudo-element with single colon (legacy)', () => { + const { arena, rootNode, source } = parseSelectorInternal('p:after') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, children[1])).toBe('after') + }) + + it('should parse ::first-line pseudo-element', () => { + const { arena, rootNode, source } = parseSelectorInternal('p::first-line') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + 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_ELEMENT) + expect(getNodeContent(arena, source, children[1])).toBe('first-line') + }) + }) + + describe('Attribute selectors', () => { + it('should parse simple attribute selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('[disabled]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(getNodeText(arena, source, child)).toBe('[disabled]') + expect(getNodeContent(arena, source, child)).toBe('disabled') + }) + + it('should parse attribute with value', () => { + const { arena, rootNode, source } = parseSelectorInternal('[type="text"]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(getNodeText(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', () => { + const { arena, rootNode, source } = parseSelectorInternal('[class^="btn-"]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(getNodeText(arena, source, child)).toBe('[class^="btn-"]') + }) + + it('should parse element with attribute', () => { + const { arena, rootNode, source } = parseSelectorInternal('input[type="checkbox"]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_ATTRIBUTE) + }) + + it('should trim whitespace from attribute selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('[ data-test="value" ]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + // 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', () => { + const { arena, rootNode, source } = parseSelectorInternal('[/* comment */data-test="value"/* test */]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') + }) + + it('should trim whitespace and comments from attribute selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('[/* comment */ data-test="value" /* test */]') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') + }) + }) + + describe('Combinators', () => { + it('should parse descendant combinator (space)', () => { + const { arena, rootNode, source } = parseSelectorInternal('div p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children.length).toBeGreaterThanOrEqual(2) + + // Should have: compound(div), combinator(space), compound(p) + const hasDescendantCombinator = children.some((child) => { + const type = arena.get_type(child) + return type === NODE_SELECTOR_COMBINATOR + }) + expect(hasDescendantCombinator).toBe(true) + }) + + it('should parse child combinator (>)', () => { + const { arena, rootNode, source } = parseSelectorInternal('div > p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === NODE_SELECTOR_COMBINATOR) { + return getNodeText(arena, source, child).includes('>') + } + return false + }) + expect(hasCombinator).toBe(true) + }) + + it('should parse adjacent sibling combinator (+)', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1 + p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === NODE_SELECTOR_COMBINATOR) { + return getNodeText(arena, source, child).includes('+') + } + return false + }) + expect(hasCombinator).toBe(true) + }) + + it('should parse general sibling combinator (~)', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1 ~ p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === NODE_SELECTOR_COMBINATOR) { + return getNodeText(arena, source, child).includes('~') + } + return false + }) + expect(hasCombinator).toBe(true) + }) + }) + + describe('Selector lists (comma-separated)', () => { + it('should parse selector list with two selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('div, p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + // List contains the two selectors + const children = getChildren(arena, source, rootNode) + expect(children).toHaveLength(2) + }) + + it('should parse selector list with three selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1, h2, h3') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + // List contains the three selectors + const children = getChildren(arena, source, rootNode) + expect(children).toHaveLength(3) + }) + + it('should parse complex selector list', () => { + const { arena, rootNode, source } = parseSelectorInternal('div.container, .wrapper > p, #app') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + // List contains 3 NODE_SELECTOR wrappers: div.container, .wrapper > p, #app + const children = getChildren(arena, source, rootNode) + expect(children).toHaveLength(3) + }) + }) + + describe('Complex selectors', () => { + it('should parse navigation selector', () => { + const { arena, rootNode } = parseSelectorInternal('nav > ul > li > a') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + }) + + it('should parse form selector', () => { + const { arena, rootNode } = parseSelectorInternal('form input[type="text"]:focus') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Should parse without errors + expect(arena.get_type(rootNode)).toBeDefined() + }) + + it('should parse complex nesting selector', () => { + const { arena, rootNode } = parseSelectorInternal('.parent .child:hover::before') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + expect(arena.get_type(rootNode)).toBeDefined() + }) + + it('should parse multiple combinators', () => { + const { arena, rootNode, source } = parseSelectorInternal('div > .container + p ~ span') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + + const combinators = children.filter((child) => { + return arena.get_type(child) === NODE_SELECTOR_COMBINATOR + }) + + expect(combinators.length).toBeGreaterThan(0) + }) + }) + + describe('Modern CSS selectors', () => { + it('should parse :where() pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal(':where(article, section)') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_PSEUDO_CLASS) + expect(getNodeContent(arena, source, child)).toBe('where') + }) + + it('should parse :has(a) pseudo-class', () => { + const root = parse_selector('div:has(a)') + + expect(root.first_child?.type).toBe(NODE_SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [_, has] = root.first_child!.children + + 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', () => { + const { arena, rootNode, source } = parseSelectorInternal('&.active') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + 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 } = parseSelectorInternal('& 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 } = parseSelectorInternal('& > 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', () => { + it('should parse selector with multiple spaces', () => { + const { arena, rootNode, source } = parseSelectorInternal('div p') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Should collapse multiple spaces into single combinator + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children.length).toBeGreaterThan(0) + }) + + it('should parse selector with tabs and newlines', () => { + const { arena, rootNode, source } = parseSelectorInternal('div\t\n\tp') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const children = getChildren(arena, source, rootNode) + expect(children.length).toBeGreaterThan(0) + }) + + it('should handle empty selector gracefully', () => { + const { rootNode } = parseSelectorInternal('') + + // Empty selector returns null + expect(rootNode).toBeNull() + }) + + it('should parse class with dashes and numbers', () => { + const { arena, rootNode, source } = parseSelectorInternal('.my-class-123') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, child)).toBe('my-class-123') + }) + + it('should parse hyphenated element names', () => { + const { arena, rootNode, source } = parseSelectorInternal('custom-element') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, child)).toBe('custom-element') + }) + }) + + describe('Real-world selectors', () => { + it('should parse BEM selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.block__element--modifier') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) + expect(getNodeContent(arena, source, child)).toBe('block__element--modifier') + }) + + it('should parse Bootstrap-style selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.btn.btn-primary.btn-lg') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_CLASS) + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_CLASS) + }) + + it('should parse table selector', () => { + const { arena, rootNode } = parseSelectorInternal('table tbody tr:nth-child(odd) td') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Should parse without errors + expect(arena.get_type(rootNode)).toBeDefined() + }) + + it('should parse nth-of-type selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('p:nth-of-type(3)') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + 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('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"') + }) + }) +}) describe('parse_selector()', () => { test('should parse simple type selector', () => { diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 4f05398..00f3e95 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -1,7 +1,1044 @@ -import { CSSDataArena, NODE_SELECTOR_LIST } from './arena' -import { SelectorParser } from './selector-parser' +// Selector Parser - Parses CSS selectors into structured AST nodes +import { Lexer } from './lexer' +import { CSSDataArena } from './arena' +import { + NODE_SELECTOR, + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_CLASS, + NODE_SELECTOR_ID, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_PSEUDO_ELEMENT, + NODE_SELECTOR_COMBINATOR, + NODE_SELECTOR_UNIVERSAL, + NODE_SELECTOR_NESTING, + 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, + TOKEN_HASH, + TOKEN_DELIM, + TOKEN_COLON, + TOKEN_COMMA, + TOKEN_LEFT_BRACKET, + TOKEN_RIGHT_BRACKET, + TOKEN_FUNCTION, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_EOF, + TOKEN_WHITESPACE, + TOKEN_STRING, +} from './token-types' +import { is_whitespace as is_whitespace_char, is_vendor_prefixed } from './string-utils' +import { ANplusBParser } from './parse-anplusb' import { CSSNode } from './css-node' +export class SelectorParser { + private lexer: Lexer + private arena: CSSDataArena + private source: string + private selector_end: number + + constructor(arena: CSSDataArena, source: string) { + this.arena = arena + this.source = source + // Create a lexer instance for selector parsing + this.lexer = new Lexer(source, false) + this.selector_end = 0 + } + + // 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, allow_relative: boolean = false): number | null { + this.selector_end = end + + // Position lexer at selector start + this.lexer.pos = start + this.lexer.line = line + this.lexer.column = column + + // Parse selector list (comma-separated selectors) + // Returns NODE_SELECTOR_LIST directly (no wrapper) + return this.parse_selector_list(allow_relative) + } + + // Parse comma-separated selectors + 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 + let list_column = this.lexer.column + + while (this.lexer.pos < this.selector_end) { + let selector_start = this.lexer.pos + let selector_line = this.lexer.line + let selector_column = this.lexer.column + + 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() + this.arena.set_type(selector_wrapper, NODE_SELECTOR) + this.arena.set_start_offset(selector_wrapper, selector_start) + this.arena.set_length(selector_wrapper, this.lexer.pos - selector_start) + this.arena.set_start_line(selector_wrapper, selector_line) + this.arena.set_start_column(selector_wrapper, selector_column) + + // Find the last component in the chain + let last_component = complex_selector + while (this.arena.get_next_sibling(last_component) !== 0) { + last_component = this.arena.get_next_sibling(last_component) + } + + // Set the complex selector chain as children + this.arena.set_first_child(selector_wrapper, complex_selector) + this.arena.set_last_child(selector_wrapper, last_component) + + selectors.push(selector_wrapper) + } + + // Check for comma (selector separator) + this.skip_whitespace() + if (this.lexer.pos >= this.selector_end) break + + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + if (token_type === TOKEN_COMMA) { + this.skip_whitespace() + continue + } else { + // No more selectors + break + } + } + + // Always wrap in selector list node, even for single selectors + if (selectors.length >= 1) { + let list_node = this.arena.create_node() + this.arena.set_type(list_node, NODE_SELECTOR_LIST) + this.arena.set_start_offset(list_node, list_start) + this.arena.set_length(list_node, this.lexer.pos - list_start) + this.arena.set_start_line(list_node, list_line) + this.arena.set_start_column(list_node, list_column) + + // Link selector wrapper nodes as children + this.arena.set_first_child(list_node, selectors[0]) + this.arena.set_last_child(list_node, selectors[selectors.length - 1]) + + // Chain selector wrappers as siblings (simple since they're already wrapped) + for (let i = 0; i < selectors.length - 1; i++) { + this.arena.set_next_sibling(selectors[i], selectors[i + 1]) + } + + return list_node + } + + return null + } + + // Parse a complex selector (with combinators) + // e.g., "div.class > p + span" + 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 + + // Parse compound selector first + let compound = this.parse_compound_selector() + if (compound !== null) { + components.push(compound) + } else { + break + } + + // After a compound selector, check if there's a combinator + let combinator = this.try_parse_combinator() + if (combinator !== null) { + components.push(combinator) + // Skip whitespace after combinator before next compound + this.skip_whitespace() + continue + } + + // Peek ahead for comma or end + 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 + + this.lexer.next_token_fast(false) + 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 = saved_pos + this.lexer.line = saved_line + this.lexer.column = saved_column + break + } + // Reset for next iteration + 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 (need to find last node in each compound selector chain) + for (let i = 0; i < components.length - 1; i++) { + // 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) + return components[0] + } + + // Parse a compound selector (no combinators) + // e.g., "div.class#id[attr]:hover" + private parse_compound_selector(): number | null { + let parts: number[] = [] + + while (this.lexer.pos < this.selector_end) { + // 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 + + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + + let part = this.parse_simple_selector() + if (part !== null) { + parts.push(part) + } else { + // 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 + } + } + + if (parts.length === 0) return null + + // Chain parts as siblings + for (let i = 0; i < parts.length - 1; i++) { + this.arena.set_next_sibling(parts[i], parts[i + 1]) + } + + // Return first part (others are chained as siblings) + return parts[0] + } + + // Parse a simple selector (single component) + private parse_simple_selector(): number | null { + let token_type = this.lexer.token_type + let start = this.lexer.token_start + let end = this.lexer.token_end + + switch (token_type) { + case TOKEN_IDENT: + // Type selector: div, span, p + return this.create_type_selector(start, end) + + case TOKEN_HASH: + // ID selector: #id + return this.create_id_selector(start, end) + + case TOKEN_DELIM: + // Could be: . (class), * (universal), & (nesting) + let ch = this.source.charCodeAt(start) + if (ch === 0x2e) { + // . - class selector + return this.parse_class_selector(start) + } else if (ch === 0x2a) { + // * - universal selector + return this.create_universal_selector(start, end) + } else if (ch === 0x26) { + // & - nesting selector + return this.create_nesting_selector(start, end) + } + // Other delimiters signal end of selector + return null + + case TOKEN_LEFT_BRACKET: + // Attribute selector: [attr], [attr=value] + return this.parse_attribute_selector(start) + + case TOKEN_COLON: + // Pseudo-class or pseudo-element: :hover, ::before + return this.parse_pseudo(start) + + case TOKEN_FUNCTION: + // Pseudo-class function: :nth-child(), :is() + return this.parse_pseudo_function(start, end) + + case TOKEN_WHITESPACE: + case TOKEN_COMMA: + // These signal end of compound selector + return null + + default: + return null + } + } + + // Parse combinator (>, +, ~, or descendant space) + private try_parse_combinator(): number | null { + let whitespace_start = this.lexer.pos + let has_whitespace = false + + // Skip whitespace and check for combinator + while (this.lexer.pos < this.selector_end) { + let ch = this.source.charCodeAt(this.lexer.pos) + if (is_whitespace_char(ch)) { + has_whitespace = true + this.lexer.pos++ + } else { + break + } + } + + if (this.lexer.pos >= this.selector_end) return null + + this.lexer.next_token_fast(false) + + // Check for explicit combinators + if (this.lexer.token_type === TOKEN_DELIM) { + let ch = this.source.charCodeAt(this.lexer.token_start) + if (ch === 0x3e || ch === 0x2b || ch === 0x7e) { + // > + ~ (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 = whitespace_start + while (this.lexer.pos < this.selector_end) { + let ch = this.source.charCodeAt(this.lexer.pos) + if (is_whitespace_char(ch)) { + this.lexer.pos++ + } else { + break + } + } + return this.create_combinator(whitespace_start, this.lexer.pos) + } + + // No combinator found, reset position + 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 + } + + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_CLASS) + this.arena.set_start_offset(node, dot_pos) + this.arena.set_length(node, this.lexer.token_end - dot_pos) + this.arena.set_start_line(node, this.lexer.line) + this.arena.set_start_column(node, this.lexer.column) + // Content is the class name (without the dot) + this.arena.set_content_start(node, this.lexer.token_start) + this.arena.set_content_length(node, this.lexer.token_end - this.lexer.token_start) + return node + } + + // Parse attribute selector ([attr], [attr=value], etc.) + 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) { + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + if (token_type === TOKEN_LEFT_BRACKET) { + bracket_depth++ + } 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 + } + } + } + + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_ATTRIBUTE) + this.arena.set_start_offset(node, start) + 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) + + // 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_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 + 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) { + is_pseudo_element = true + this.lexer.pos++ // skip second colon + } + + // Next token should be identifier or function + this.lexer.next_token_fast(false) + + let token_type = this.lexer.token_type + if (token_type === TOKEN_IDENT) { + let node = this.arena.create_node() + this.arena.set_type(node, is_pseudo_element ? NODE_SELECTOR_PSEUDO_ELEMENT : NODE_SELECTOR_PSEUDO_CLASS) + this.arena.set_start_offset(node, start) + this.arena.set_length(node, this.lexer.token_end - start) + this.arena.set_start_line(node, this.lexer.line) + this.arena.set_start_column(node, this.lexer.column) + // Content is the pseudo name (without colons) + this.arena.set_content_start(node, this.lexer.token_start) + this.arena.set_content_length(node, this.lexer.token_end - this.lexer.token_start) + // Check for vendor prefix and set flag if detected + if (is_vendor_prefixed(this.source, this.lexer.token_start, this.lexer.token_end)) { + this.arena.set_flag(node, FLAG_VENDOR_PREFIXED) + } + return node + } else if (token_type === TOKEN_FUNCTION) { + // Pseudo-class function like :nth-child() + 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 + } + + // Parse pseudo-class function (:nth-child(), :is(), etc.) + private parse_pseudo_function(_start: number, _end: number): number | null { + // This should not be called in current flow, but keep for completeness + return null + } + + // Parse pseudo-class function after we've seen the colon + private parse_pseudo_function_after_colon(start: number, is_pseudo_element: boolean): number | null { + 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 + + 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 || 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 + } + } + } + + let node = this.arena.create_node() + this.arena.set_type(node, is_pseudo_element ? NODE_SELECTOR_PSEUDO_ELEMENT : NODE_SELECTOR_PSEUDO_CLASS) + this.arena.set_start_offset(node, start) + 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 the function name (without colons and parentheses) + this.arena.set_content_start(node, func_name_start) + this.arena.set_content_length(node, func_name_end - func_name_start) + // Check for vendor prefix and set flag if detected + 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) + 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): 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() + this.arena.set_type(node, NODE_SELECTOR_TYPE) + this.arena.set_start_offset(node, start) + 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) + this.arena.set_content_start(node, start) + this.arena.set_content_length(node, end - start) + return node + } + + private create_id_selector(start: number, end: number): number { + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_ID) + this.arena.set_start_offset(node, start) + 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 the ID name (without the #) + this.arena.set_content_start(node, start + 1) + this.arena.set_content_length(node, end - start - 1) + return node + } + + private create_universal_selector(start: number, end: number): number { + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_UNIVERSAL) + this.arena.set_start_offset(node, start) + 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) + this.arena.set_content_start(node, start) + this.arena.set_content_length(node, end - start) + return node + } + + private create_nesting_selector(start: number, end: number): number { + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_NESTING) + this.arena.set_start_offset(node, start) + 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) + this.arena.set_content_start(node, start) + this.arena.set_content_length(node, end - start) + return node + } + + private create_combinator(start: number, end: number): number { + let node = this.arena.create_node() + this.arena.set_type(node, NODE_SELECTOR_COMBINATOR) + this.arena.set_start_offset(node, start) + 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) + this.arena.set_content_start(node, start) + this.arena.set_content_length(node, end - start) + return node + } + + // Helper to skip whitespace + private skip_whitespace(): void { + while (this.lexer.pos < this.selector_end) { + let ch = this.source.charCodeAt(this.lexer.pos) + if (is_whitespace_char(ch)) { + this.lexer.pos++ + } else { + break + } + } + } +} + /** * Parse a CSS selector string and return an AST * @param source - The CSS selector to parse (e.g., "div.class > p#id") diff --git a/src/parse.test.ts b/src/parse.test.ts index b03be53..a31382e 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -1,114 +1,1998 @@ import { describe, test, expect } from 'vitest' -import { parse } from './parse' -import { NODE_STYLESHEET, NODE_STYLE_RULE, NODE_DECLARATION, NODE_AT_RULE } from './arena' +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, + parse, +} from './parse' +import { ATTR_OPERATOR_PIPE_EQUAL } from './arena' -describe('parse()', () => { - test('should parse CSS and return CSSNode', () => { - const result = parse('body { color: red; }') +describe('Parser', () => { + describe('basic parsing', () => { + test('should create parser with arena sized for source', () => { + const source = 'body { color: red; }' + const parser = new Parser(source) + const arena = parser.get_arena() - expect(result.type).toBe(NODE_STYLESHEET) - expect(result.has_children).toBe(true) + // Should have capacity based on source size + expect(arena.get_capacity()).toBeGreaterThan(0) + expect(arena.get_count()).toBe(1) // Count starts at 1 (0 is reserved for "no node") + }) + + test('should parse empty stylesheet', () => { + const parser = new Parser('') + const root = parser.parse() + + expect(root.type).toBe(NODE_STYLESHEET) + expect(root.offset).toBe(0) + expect(root.length).toBe(0) + expect(root.has_children).toBe(false) + }) + + test('should parse stylesheet with only whitespace', () => { + const parser = new Parser(' \n\n ') + const root = parser.parse() + + expect(root.type).toBe(NODE_STYLESHEET) + expect(root.has_children).toBe(false) + }) + + test('should parse stylesheet with only comments', () => { + const parser = new Parser('/* comment */') + const root = parser.parse() + + expect(root.type).toBe(NODE_STYLESHEET) + // TODO: Once we parse comments, verify they're added as children + }) }) - test('should parse empty CSS', () => { - const result = parse('') + describe('style rule parsing', () => { + test('should parse simple style rule', () => { + const parser = new Parser('body { }') + const root = parser.parse() + + expect(root.has_children).toBe(true) + + const rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + expect(rule.offset).toBe(0) + expect(rule.length).toBeGreaterThan(0) + }) + + test('should parse style rule with selector', () => { + const source = 'body { }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + expect(rule.has_children).toBe(true) + + const selector = rule.first_child! + // With parseSelectors enabled by default, we get detailed selector nodes + expect(selector.text).toBe('body') + expect(selector.line).toBe(1) // Line numbers start at 1 + expect(selector.offset).toBe(0) + expect(selector.length).toBe(4) // "body" + }) + + test('should parse multiple style rules', () => { + const parser = new Parser('body { } div { }') + const root = parser.parse() - expect(result.type).toBe(NODE_STYLESHEET) - expect(result.has_children).toBe(false) + const [rule1, rule2] = root.children + expect(rule1.type).toBe(NODE_STYLE_RULE) + expect(rule2.type).toBe(NODE_STYLE_RULE) + expect(rule2.next_sibling).toBe(null) + }) + + test('should parse complex selector', () => { + const source = 'div.class > p#id { }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const selectorlist = rule.first_child! + + // With parseSelectors enabled, selector is now detailed + 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(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"') + }) }) - test('should parse CSS with style rules', () => { - const result = parse('body { color: red; } div { margin: 0; }') + describe('declaration parsing', () => { + test('should parse simple declaration', () => { + const source = 'body { color: red; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.is_important).toBe(false) + }) + + test('should parse declaration with property name', () => { + const source = 'body { color: red; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + // Property name stored in the 'name' property + expect(declaration.name).toBe('color') + }) + + test('should parse multiple declarations', () => { + const source = 'body { color: red; margin: 0; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const [decl1, decl2] = block.children + + expect(decl1.type).toBe(NODE_DECLARATION) + expect(decl2.type).toBe(NODE_DECLARATION) + expect(decl2.next_sibling).toBe(null) + }) + + test('should parse declaration with !important', () => { + const source = 'body { color: red !important; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('should parse declaration with !ie (historic !important)', () => { + const source = 'body { color: red !ie; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('should parse declaration with ! followed by any identifier', () => { + const source = 'body { color: red !foo; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('should parse declaration without semicolon at end of block', () => { + const source = 'body { color: red }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + }) + + test('should parse complex declaration value', () => { + const source = 'body { background: url(image.png) no-repeat center; }' + const parser = new Parser(source) + const root = parser.parse() + + const rule = root.first_child! + const [_selector, block] = rule.children + const declaration = block.first_child! + + expect(declaration.type).toBe(NODE_DECLARATION) + expect(declaration.name).toBe('background') + }) + }) + + describe('at-rule parsing', () => { + describe('statement at-rules (no block)', () => { + test('should parse @import', () => { + const source = '@import url("style.css");' + const parser = new Parser(source, { parse_atrule_preludes: false }) + const root = parser.parse() + + const atRule = root.first_child! + expect(atRule.type).toBe(NODE_AT_RULE) + expect(atRule.name).toBe('import') + expect(atRule.has_children).toBe(false) + }) - expect(result.type).toBe(NODE_STYLESHEET) - const [rule1, rule2] = result.children - expect(rule1.type).toBe(NODE_STYLE_RULE) - expect(rule2.type).toBe(NODE_STYLE_RULE) + test('should parse @namespace', () => { + const source = '@namespace url(http://www.w3.org/1999/xhtml);' + const parser = new Parser(source) + const root = parser.parse() + + const atRule = root.first_child! + expect(atRule.type).toBe(NODE_AT_RULE) + expect(atRule.name).toBe('namespace') + }) + }) + + describe('case-insensitive at-rule names', () => { + test('should parse @MEDIA (uppercase conditional at-rule)', () => { + const source = '@MEDIA (min-width: 768px) { body { color: red; } }' + const parser = new Parser(source, { parse_atrule_preludes: false }) + const root = parser.parse() + + const media = root.first_child! + expect(media.type).toBe(NODE_AT_RULE) + expect(media.name).toBe('MEDIA') + expect(media.has_children).toBe(true) + // Should parse as conditional (containing rules) + const block = media.block! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(NODE_STYLE_RULE) + }) + + test('should parse @Font-Face (mixed case declaration at-rule)', () => { + const source = '@Font-Face { font-family: "MyFont"; src: url("font.woff"); }' + const parser = new Parser(source) + const root = parser.parse() + + const fontFace = root.first_child! + expect(fontFace.type).toBe(NODE_AT_RULE) + expect(fontFace.name).toBe('Font-Face') + expect(fontFace.has_children).toBe(true) + // Should parse as declaration at-rule (containing declarations) + const block = fontFace.block! + const decl = block.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + }) + + test('should parse @SUPPORTS (uppercase conditional at-rule)', () => { + const source = '@SUPPORTS (display: grid) { .grid { display: grid; } }' + const parser = new Parser(source, { parse_atrule_preludes: false }) + const root = parser.parse() + + const supports = root.first_child! + expect(supports.type).toBe(NODE_AT_RULE) + expect(supports.name).toBe('SUPPORTS') + expect(supports.has_children).toBe(true) + }) + }) + + describe('block at-rules with nested rules', () => { + test('should parse @media with nested rule', () => { + const source = '@media (min-width: 768px) { body { color: red; } }' + const parser = new Parser(source, { parse_atrule_preludes: false }) + const root = parser.parse() + + const media = root.first_child! + expect(media.type).toBe(NODE_AT_RULE) + expect(media.name).toBe('media') + expect(media.has_children).toBe(true) + + const block = media.block! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(NODE_STYLE_RULE) + }) + + test('should parse @layer with name', () => { + const source = '@layer utilities { .text-center { text-align: center; } }' + const parser = new Parser(source) + const root = parser.parse() + + const layer = root.first_child! + expect(layer.type).toBe(NODE_AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.has_children).toBe(true) + }) + + test('should parse anonymous @layer', () => { + const source = '@layer { body { margin: 0; } }' + const parser = new Parser(source) + const root = parser.parse() + + const layer = root.first_child! + expect(layer.type).toBe(NODE_AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.has_children).toBe(true) + }) + + test('should parse @supports', () => { + const source = '@supports (display: grid) { .grid { display: grid; } }' + const parser = new Parser(source) + const root = parser.parse() + + const supports = root.first_child! + expect(supports.type).toBe(NODE_AT_RULE) + expect(supports.name).toBe('supports') + expect(supports.has_children).toBe(true) + }) + + test('should parse @container', () => { + const source = '@container (min-width: 400px) { .card { padding: 2rem; } }' + const parser = new Parser(source) + const root = parser.parse() + + const container = root.first_child! + expect(container.type).toBe(NODE_AT_RULE) + expect(container.name).toBe('container') + expect(container.has_children).toBe(true) + }) + }) + + describe('descriptor at-rules (with declarations)', () => { + test('should parse @font-face', () => { + const source = '@font-face { font-family: "Open Sans"; src: url(font.woff2); }' + const parser = new Parser(source) + const root = parser.parse() + + const fontFace = root.first_child! + expect(fontFace.type).toBe(NODE_AT_RULE) + expect(fontFace.name).toBe('font-face') + expect(fontFace.has_children).toBe(true) + + // Should have declarations as children + const block = fontFace.block! + const [decl1, decl2] = block.children + expect(decl1.type).toBe(NODE_DECLARATION) + expect(decl2.type).toBe(NODE_DECLARATION) + }) + + test('should parse @page', () => { + const source = '@page { margin: 1in; }' + const parser = new Parser(source) + const root = parser.parse() + + const page = root.first_child! + expect(page.type).toBe(NODE_AT_RULE) + expect(page.name).toBe('page') + + const block = page.block! + const decl = block.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + }) + + test('should parse @counter-style', () => { + const source = '@counter-style thumbs { system: cyclic; symbols: "👍"; }' + const parser = new Parser(source) + const root = parser.parse() + + const counterStyle = root.first_child! + expect(counterStyle.type).toBe(NODE_AT_RULE) + expect(counterStyle.name).toBe('counter-style') + + const block = counterStyle.block! + const decl = block.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + }) + }) + + describe('nested at-rules', () => { + test('should parse @media inside @supports', () => { + const source = '@supports (display: grid) { @media (min-width: 768px) { body { color: red; } } }' + const parser = new Parser(source, { parse_atrule_preludes: false }) + const root = parser.parse() + + const supports = root.first_child! + expect(supports.name).toBe('supports') + + const supports_block = supports.block! + const media = supports_block.first_child! + expect(media.type).toBe(NODE_AT_RULE) + expect(media.name).toBe('media') + + const media_block = media.block! + const rule = media_block.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + }) + + describe('multiple at-rules', () => { + test('should parse multiple at-rules at top level', () => { + const source = '@import url("a.css"); @layer base { body { margin: 0; } } @media print { body { color: black; } }' + const parser = new Parser(source) + const root = parser.parse() + + const [import1, layer, media] = root.children + expect(import1.name).toBe('import') + expect(layer.name).toBe('layer') + expect(media.name).toBe('media') + }) + }) }) - test('should parse CSS with at-rules', () => { - const result = parse('@media (min-width: 768px) { body { color: blue; } }') + describe('CSS Nesting', () => { + test('should parse nested rule with & selector', () => { + let source = '.parent { color: red; & .child { color: blue; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + expect(parent.type).toBe(NODE_STYLE_RULE) + + let [_selector, block] = parent.children + let [decl, nested_rule] = block.children + expect(decl.type).toBe(NODE_DECLARATION) + expect(decl.name).toBe('color') + + expect(nested_rule.type).toBe(NODE_STYLE_RULE) + let nested_selector = nested_rule.first_child! + // With parseSelectors enabled, selector is now detailed + expect(nested_selector.text).toBe('& .child') + }) + + test('should parse nested rule without & selector', () => { + let source = '.parent { color: red; .child { color: blue; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + 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! + expect(nested_selector.text).toBe('.child') + }) + + test('should parse multiple nested rules', () => { + let source = '.parent { .child1 { } .child2 { } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let [nested1, nested2] = block.children + + expect(nested1.type).toBe(NODE_STYLE_RULE) + expect(nested2.type).toBe(NODE_STYLE_RULE) + }) + + test('should parse deeply nested rules', () => { + let source = '.a { .b { .c { color: red; } } }' + let parser = new Parser(source) + let root = parser.parse() + + let a = root.first_child! + let [_selector_a, block_a] = a.children + let b = block_a.first_child! + expect(b.type).toBe(NODE_STYLE_RULE) + + let [_selector_b, block_b] = b.children + let c = block_b.first_child! + expect(c.type).toBe(NODE_STYLE_RULE) - expect(result.type).toBe(NODE_STYLESHEET) - const media = result.first_child! - expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') + let [_selector_c, block_c] = c.children + let decl = block_c.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + expect(decl.name).toBe('color') + }) + + test('should parse nested @media inside rule', () => { + let source = '.card { color: red; @media (min-width: 768px) { padding: 2rem; } }' + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() + + let card = root.first_child! + 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 media_block = media.block! + let nested_decl = media_block.first_child! + expect(nested_decl.type).toBe(NODE_DECLARATION) + expect(nested_decl.name).toBe('padding') + }) + + test('should parse :is() pseudo-class', () => { + let source = ':is(.a, .b) { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe(':is(.a, .b)') + }) + + test('should parse :where() pseudo-class', () => { + let source = ':where(h1, h2, h3) { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe(':where(h1, h2, h3)') + }) + + test('should parse :has() pseudo-class', () => { + let source = 'div:has(> img) { display: flex; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe('div:has(> img)') + }) + + test('should parse complex nesting with mixed declarations and rules', () => { + let source = `.card { + color: red; + .title { font-size: 2rem; } + padding: 1rem; + .body { line-height: 1.5; } + }` + let parser = new Parser(source) + let root = parser.parse() + + let card = root.first_child! + let [_selector, block] = card.children + let [decl1, title, decl2, body] = block.children + + expect(decl1.type).toBe(NODE_DECLARATION) + expect(decl1.name).toBe('color') + + expect(title.type).toBe(NODE_STYLE_RULE) + + expect(decl2.type).toBe(NODE_DECLARATION) + expect(decl2.name).toBe('padding') + + expect(body.type).toBe(NODE_STYLE_RULE) + }) }) - test('should parse CSS with declarations', () => { - const result = parse('body { color: red; margin: 0; }') + describe('@keyframes parsing', () => { + test('should parse @keyframes with from/to', () => { + let source = '@keyframes fade { from { opacity: 0; } to { opacity: 1; } }' + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() + + let keyframes = root.first_child! + expect(keyframes.type).toBe(NODE_AT_RULE) + expect(keyframes.name).toBe('keyframes') + + 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) + + let from_selector = from_rule.first_child! + expect(from_selector.text).toBe('from') + + let to_selector = to_rule.first_child! + expect(to_selector.text).toBe('to') + }) + + test('should parse @keyframes with percentages', () => { + let source = '@keyframes slide { 0% { left: 0; } 50% { left: 50%; } 100% { left: 100%; } }' + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() - const rule = result.first_child! - 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) - expect(decl2.name).toBe('margin') + let keyframes = root.first_child! + let block = keyframes.block! + let [rule0, rule50, rule100] = block.children + + expect(rule0.type).toBe(NODE_STYLE_RULE) + expect(rule50.type).toBe(NODE_STYLE_RULE) + expect(rule100.type).toBe(NODE_STYLE_RULE) + + let selector0 = rule0.first_child! + expect(selector0.text).toBe('0%') + }) + + test('should parse @keyframes with multiple selectors', () => { + let source = '@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }' + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() + + let keyframes = root.first_child! + let block = keyframes.block! + let [rule1, _rule2] = block.children + + let selector1 = rule1.first_child! + expect(selector1.text).toBe('0%, 100%') + }) }) - test('should accept parser options', () => { - const result = parse('body { color: red; }', { parse_selectors: false }) + describe('@nest at-rule', () => { + test('should parse @nest with & selector', () => { + let source = '.parent { @nest & .child { color: blue; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let nest = block.first_child! - expect(result.type).toBe(NODE_STYLESHEET) - expect(result.has_children).toBe(true) + expect(nest.type).toBe(NODE_AT_RULE) + expect(nest.name).toBe('nest') + expect(nest.has_children).toBe(true) + + let nest_block = nest.block! + let decl = nest_block.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + expect(decl.name).toBe('color') + }) + + test('should parse @nest with complex selector', () => { + let source = '.a { @nest :not(&) { color: red; } }' + let parser = new Parser(source) + let root = parser.parse() + + let a = root.first_child! + let [_selector, block] = a.children + let nest = block.first_child! + + expect(nest.type).toBe(NODE_AT_RULE) + expect(nest.name).toBe('nest') + }) }) - test('should parse with parse_values enabled', () => { - const result = parse('body { color: red; }', { parse_values: true }) + describe('error recovery and edge cases', () => { + test('should handle malformed rule without opening brace', () => { + let source = 'body color: red; } div { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + // Should skip malformed rule and parse valid one + expect(root.children.length).toBeGreaterThan(0) + }) + + test('should handle rule without closing brace', () => { + let source = 'body { color: red; div { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + // Parser should recover + expect(root.has_children).toBe(true) + }) + + test('should handle empty rule block', () => { + let source = '.empty { }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + // Only has selector and empty block + expect(rule.children.length).toBe(2) + }) + + test('should handle declaration without value', () => { + let source = 'body { color: }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.type).toBe(NODE_DECLARATION) + }) + + test('should handle multiple semicolons', () => { + let source = 'body { color: red;;; margin: 0;; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + // Rule has selector + block + expect(rule.children.length).toBe(2) + }) + + test('should skip invalid tokens in declaration block', () => { + let source = 'body { color: red; @@@; margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + // Should have selector + block + expect(rule.children.length).toBe(2) + }) + + test('should handle declaration without colon', () => { + let source = 'body { color red; margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - const rule = result.first_child! - 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 - expect(decl.has_children).toBe(true) + let rule = root.first_child! + // 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', () => { + let source = '@ { color: red; } body { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + // Should recover and parse body rule + expect(root.children.length).toBeGreaterThan(0) + }) + + test('should handle nested empty blocks', () => { + let source = '.a { .b { .c { } } }' + let parser = new Parser(source) + let root = parser.parse() + + let a = root.first_child! + expect(a.type).toBe(NODE_STYLE_RULE) + }) + + test('should handle trailing comma in selector', () => { + let source = '.a, .b, { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) }) - test('should parse with parse_atrule_preludes enabled', () => { - const result = parse('@media (min-width: 768px) { }', { parse_atrule_preludes: true }) + describe('vendor prefix detection', () => { + test('should detect -webkit- vendor prefix', () => { + let source = '.box { -webkit-transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('-webkit-transform') + expect(decl.is_vendor_prefixed).toBe(true) + }) + + test('should detect -moz- vendor prefix', () => { + let source = '.box { -moz-transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('-moz-transform') + expect(decl.is_vendor_prefixed).toBe(true) + }) + + test('should detect -ms- vendor prefix', () => { + let source = '.box { -ms-transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('-ms-transform') + expect(decl.is_vendor_prefixed).toBe(true) + }) + + test('should detect -o- vendor prefix', () => { + let source = '.box { -o-transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('-o-transform') + expect(decl.is_vendor_prefixed).toBe(true) + }) + + test('should not detect vendor prefix for standard properties', () => { + let source = '.box { transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('transform') + expect(decl.is_vendor_prefixed).toBe(false) + }) + + test('should not detect vendor prefix for properties with hyphens', () => { + let source = '.box { background-color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('background-color') + expect(decl.is_vendor_prefixed).toBe(false) + }) + + test('should not detect vendor prefix for custom properties', () => { + let source = ':root { --primary-color: blue; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('--primary-color') + expect(decl.is_vendor_prefixed).toBe(false) + }) + + test('should detect vendor prefix with multiple vendor-prefixed properties', () => { + let source = '.box { -webkit-transform: scale(1); -moz-transform: scale(1); transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let [webkit, moz, standard] = block.children + + expect(webkit.name).toBe('-webkit-transform') + expect(webkit.is_vendor_prefixed).toBe(true) + + expect(moz.name).toBe('-moz-transform') + expect(moz.is_vendor_prefixed).toBe(true) - const media = result.first_child! - expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') - // With parse_atrule_preludes, should have prelude children - expect(media.has_children).toBe(true) + expect(standard.name).toBe('transform') + expect(standard.is_vendor_prefixed).toBe(false) + }) + + test('should detect vendor prefix for complex property names', () => { + let source = '.box { -webkit-border-top-left-radius: 5px; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + 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) + }) + + test('should not detect vendor prefix for similar but non-vendor properties', () => { + // Edge case: property that starts with hyphen but isn't a vendor prefix + let source = '.box { border-radius: 5px; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + expect(decl.name).toBe('border-radius') + expect(decl.is_vendor_prefixed).toBe(false) + }) + + test('should return false for nodes without names', () => { + // Nodes like selectors or at-rules without property names + let source = 'body { }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + // Selectors have text but checking is_vendor_prefixed should be safe + expect(selector.is_vendor_prefixed).toBe(false) + }) }) - test('should handle complex CSS', () => { - const css = ` - .card { - color: red; - .title { font-size: 2rem; } - @media (min-width: 768px) { - padding: 2rem; + describe('vendor prefix detection for selectors', () => { + test('should detect -webkit- vendor prefix in pseudo-class', () => { + let source = 'input:-webkit-autofill { color: black; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + // Selector has detailed parsing enabled by default + expect(selector.has_children).toBe(true) + // Navigate: selector -> type selector (input) -> pseudo-class (next sibling) + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-webkit-autofill') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('should detect -moz- vendor prefix in pseudo-class', () => { + let source = 'button:-moz-focusring { outline: 2px solid blue; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-moz-focusring') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('should detect -ms- vendor prefix in pseudo-class', () => { + let source = 'input:-ms-input-placeholder { color: gray; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-ms-input-placeholder') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('should detect -webkit- vendor prefix in pseudo-element', () => { + let source = 'div::-webkit-scrollbar { width: 10px; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('-webkit-scrollbar') + expect(pseudoElement.is_vendor_prefixed).toBe(true) + }) + + test('should detect -moz- vendor prefix in pseudo-element', () => { + let source = 'div::-moz-selection { background: yellow; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('-moz-selection') + expect(pseudoElement.is_vendor_prefixed).toBe(true) + }) + + test('should detect -webkit- vendor prefix in pseudo-element with multiple parts', () => { + let source = 'input::-webkit-input-placeholder { color: gray; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('-webkit-input-placeholder') + expect(pseudoElement.is_vendor_prefixed).toBe(true) + }) + + test('should detect -webkit- vendor prefix in pseudo-class function', () => { + let source = 'input:-webkit-any(input, button) { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-webkit-any') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('should not detect vendor prefix for standard pseudo-classes', () => { + let source = 'a:hover { color: blue; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('hover') + expect(pseudoClass.is_vendor_prefixed).toBe(false) + }) + + test('should not detect vendor prefix for standard pseudo-elements', () => { + let source = 'div::before { content: ""; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('before') + expect(pseudoElement.is_vendor_prefixed).toBe(false) + }) + + test('should detect vendor prefix with multiple vendor-prefixed pseudo-elements', () => { + let source = 'div::-webkit-scrollbar { } div::-webkit-scrollbar-thumb { } div::after { }' + let parser = new Parser(source) + let root = parser.parse() + + let [rule1, rule2, rule3] = root.children + + let selectorList1 = rule1.first_child! + let selector1 = selectorList1.first_child! // NODE_SELECTOR wrapper + let typeSelector1 = selector1.first_child! + let pseudo1 = typeSelector1.next_sibling! + expect(pseudo1.name).toBe('-webkit-scrollbar') + expect(pseudo1.is_vendor_prefixed).toBe(true) + + let selectorList2 = rule2.first_child! + let selector2 = selectorList2.first_child! // NODE_SELECTOR wrapper + let typeSelector2 = selector2.first_child! + let pseudo2 = typeSelector2.next_sibling! + expect(pseudo2.name).toBe('-webkit-scrollbar-thumb') + expect(pseudo2.is_vendor_prefixed).toBe(true) + + let selectorList3 = rule3.first_child! + let selector3 = selectorList3.first_child! // NODE_SELECTOR wrapper + let typeSelector3 = selector3.first_child! + let pseudo3 = typeSelector3.next_sibling! + expect(pseudo3.name).toBe('after') + expect(pseudo3.is_vendor_prefixed).toBe(false) + }) + + test('should detect vendor prefix in complex selector', () => { + let source = 'input:-webkit-autofill:focus { color: black; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selectorList = rule.first_child! + let selector = selectorList.first_child! // NODE_SELECTOR wrapper + // Navigate through compound selector: input (type) -> -webkit-autofill (pseudo) -> :focus (pseudo) + let typeSelector = selector.first_child! + let webkitPseudo = typeSelector.next_sibling! + expect(webkitPseudo.name).toBe('-webkit-autofill') + expect(webkitPseudo.is_vendor_prefixed).toBe(true) + + // Check the :focus pseudo-class is not vendor prefixed + let focusPseudo = webkitPseudo.next_sibling! + expect(focusPseudo.name).toBe('focus') + expect(focusPseudo.is_vendor_prefixed).toBe(false) + }) + }) + + describe('complex real-world scenarios', () => { + test('should parse complex nested structure', () => { + let source = ` + .card { + display: flex; + padding: 1rem; + + .header { + font-size: 2rem; + font-weight: bold; + + &:hover { + color: blue; + } + } + + @media (min-width: 768px) { + padding: 2rem; + + .header { + font-size: 3rem; + } + } + + .footer { + margin-top: auto; + } + } + ` + let parser = new Parser(source) + let root = parser.parse() + + let card = root.first_child! + expect(card.type).toBe(NODE_STYLE_RULE) + // Card has selector + block + expect(card.children.length).toBe(2) + }) + + test('should parse multiple at-rules with nesting', () => { + let source = ` + @layer base { + body { margin: 0; } + } + + @layer components { + .btn { + padding: 0.5rem; + + @media (hover: hover) { + &:hover { opacity: 0.8; } + } + } + } + ` + let parser = new Parser(source) + let root = parser.parse() + + let [layer1, layer2] = root.children + expect(layer1.type).toBe(NODE_AT_RULE) + expect(layer2.type).toBe(NODE_AT_RULE) + }) + + test('should parse vendor prefixed properties', () => { + let source = '.box { -webkit-transform: scale(1); -moz-transform: scale(1); transform: scale(1); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + 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') + }) + + test('should parse complex selector list', () => { + let source = 'h1, h2, h3, h4, h5, h6, .heading, [role="heading"] { font-family: sans-serif; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toContain('h1') + expect(selector.text).toContain('[role="heading"]') + }) + + test('should parse deeply nested at-rules', () => { + let source = ` + @supports (display: grid) { + @media (min-width: 768px) { + @layer utilities { + .grid { display: grid; } + } + } + } + ` + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() + + let supports = root.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') + }) + + test('should parse CSS with calc() and other functions', () => { + let source = '.box { width: calc(100% - 2rem); background: linear-gradient(to right, red, blue); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let [width_decl, bg_decl] = block.children + expect(width_decl.name).toBe('width') + expect(bg_decl.name).toBe('background') + }) + + test('should parse custom properties', () => { + let source = ':root { --primary-color: #007bff; --spacing: 1rem; } body { color: var(--primary-color); }' + let parser = new Parser(source) + let root = parser.parse() + + // Parser may have issues with -- custom property names, check what we got + expect(root.children.length).toBeGreaterThan(0) + let first_rule = root.first_child! + expect(first_rule.type).toBe(NODE_STYLE_RULE) + }) + + test('should parse attribute selectors with operators', () => { + let source = '[href^="https"][href$=".pdf"][class*="doc"] { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toContain('^=') + expect(selector.text).toContain('$=') + expect(selector.text).toContain('*=') + }) + + test('should parse pseudo-elements', () => { + let source = '.text::before { content: "→"; } .text::after { content: "←"; }' + let parser = new Parser(source) + let root = parser.parse() + + let [rule1, rule2] = root.children + expect(rule1.type).toBe(NODE_STYLE_RULE) + expect(rule2.type).toBe(NODE_STYLE_RULE) + }) + + test('should parse multiple !important declarations', () => { + let source = '.override { color: red !important; margin: 0 !important; padding: 0 !ie; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let block = rule.block! + expect(block.children.length).toBeGreaterThan(1) + // Check at least first declaration has important flag + let declarations = block.children.filter((c) => c.type === NODE_DECLARATION) + expect(declarations.length).toBeGreaterThan(0) + expect(declarations[0].is_important).toBe(true) + }) + }) + + describe('comment handling', () => { + test('should skip comments at top level', () => { + let source = '/* comment */ body { color: red; } /* another comment */' + let parser = new Parser(source) + let root = parser.parse() + + // Comments are skipped, only rule remains + expect(root.children.length).toBe(1) + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('should skip comments in declaration block', () => { + let source = 'body { color: red; /* comment */ margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + // Comments don't break parsing + expect(rule.type).toBe(NODE_STYLE_RULE) + // Rule has selector + block + expect(rule.children.length).toBe(2) + }) + + test('should skip comments in selector', () => { + let source = 'body /* comment */ , /* comment */ div { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('should handle comment between property and colon', () => { + let source = 'body { color /* comment */ : red; }' + let parser = new Parser(source) + let root = parser.parse() + + // Parser behavior with comments in unusual positions + expect(root.has_children).toBe(true) + }) + + test('should handle multi-line comments', () => { + let source = ` + /* + * Multi-line + * comment + */ + body { color: red; } + ` + let parser = new Parser(source) + let root = parser.parse() + + expect(root.children.length).toBe(1) + }) + }) + + describe('whitespace handling', () => { + test('should handle excessive whitespace', () => { + let source = ' body { color : red ; } ' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('should handle tabs and newlines', () => { + let source = 'body\t{\n\tcolor:\tred;\n}\n' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.type).toBe(NODE_STYLE_RULE) + }) + + test('should handle no whitespace', () => { + let source = 'body{color:red;margin:0}' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let [decl1, decl2] = block.children + expect(decl1.name).toBe('color') + expect(decl2.name).toBe('margin') + }) + }) + + describe('special at-rules', () => { + test('should parse @charset', () => { + let source = '@charset "UTF-8"; body { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let [charset, _body] = root.children + expect(charset.type).toBe(NODE_AT_RULE) + expect(charset.name).toBe('charset') + }) + + test('should parse @import with media query', () => { + let source = '@import url("print.css") print;' + let parser = new Parser(source) + let root = parser.parse() + + let import_rule = root.first_child! + expect(import_rule.type).toBe(NODE_AT_RULE) + expect(import_rule.name).toBe('import') + }) + + test('should parse @font-face with multiple descriptors', () => { + let source = ` + @font-face { + font-family: "Custom"; + src: url("font.woff2") format("woff2"), + url("font.woff") format("woff"); + font-weight: 400; + font-style: normal; + font-display: swap; } - } - ` - const result = parse(css) + ` + let parser = new Parser(source) + let root = parser.parse() + + let font_face = root.first_child! + expect(font_face.name).toBe('font-face') + let block = font_face.block! + expect(block.children.length).toBeGreaterThan(3) + }) + + test('should parse @keyframes with mixed percentages and keywords', () => { + let source = '@keyframes slide { from { left: 0; } 25%, 75% { left: 50%; } to { left: 100%; } }' + let parser = new Parser(source, { parse_atrule_preludes: false }) + let root = parser.parse() + + let keyframes = root.first_child! + let block = keyframes.block! + expect(block.children.length).toBe(3) + }) + + test('should parse @counter-style', () => { + let source = '@counter-style custom { system: cyclic; symbols: "⚫" "⚪"; suffix: " "; }' + let parser = new Parser(source) + let root = parser.parse() + + let counter = root.first_child! + expect(counter.name).toBe('counter-style') + let block = counter.block! + expect(block.children.length).toBeGreaterThan(1) + }) + + test('should parse @property', () => { + let source = '@property --my-color { syntax: ""; inherits: false; initial-value: #c0ffee; }' + let parser = new Parser(source) + let root = parser.parse() + + let property = root.first_child! + expect(property.name).toBe('property') + }) + }) + + describe('location tracking', () => { + test('should track line numbers for rules', () => { + let source = 'body { color: red; }\ndiv { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let [rule1, rule2] = root.children + expect(rule1.line).toBe(1) + expect(rule2.line).toBe(2) + }) + + test('should track line numbers for at-rule preludes', () => { + let source = 'body { color: red; }\n\n@media screen { }' + let parser = new Parser(source) + let root = parser.parse() + + let [_rule1, atRule] = root.children + expect(atRule.line).toBe(3) + + // Check that prelude nodes inherit the correct line + let preludeNode = atRule.first_child + expect(preludeNode).toBeTruthy() + expect(preludeNode!.line).toBe(3) // Should be line 3, not line 1 + }) + + test('should track offsets correctly', () => { + let source = 'body { color: red; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + expect(rule.offset).toBe(0) + expect(rule.length).toBe(source.length) + }) + + test('should track declaration positions', () => { + let source = 'body { color: red; margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let [decl1, decl2] = block.children + + expect(decl1.offset).toBeLessThan(decl2.offset) + }) + }) + + describe('declaration values', () => { + test('should extract simple value', () => { + let source = 'a { color: blue; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe('blue') + }) + + test('should extract value with spaces', () => { + let source = 'a { padding: 1rem 2rem 3rem 4rem; }' + let parser = new Parser(source) + let root = parser.parse() - expect(result.type).toBe(NODE_STYLESHEET) - expect(result.has_children).toBe(true) - const card = result.first_child! - expect(card.type).toBe(NODE_STYLE_RULE) + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('padding') + expect(decl.value).toBe('1rem 2rem 3rem 4rem') + }) + + test('should extract function value', () => { + let source = 'a { background: linear-gradient(to bottom, red, blue); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + 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)') + }) + + test('should extract calc value', () => { + let source = 'a { width: calc(100% - 2rem); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('width') + expect(decl.value).toBe('calc(100% - 2rem)') + }) + + test('should exclude !important from value', () => { + let source = 'a { color: blue !important; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe('blue') + expect(decl.is_important).toBe(true) + }) + + test('should handle value with extra whitespace', () => { + let source = 'a { color: blue ; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe('blue') + }) + + test('should extract CSS custom property value', () => { + let source = ':root { --brand-color: rgb(0% 10% 50% / 0.5); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + 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)') + }) + + test('should extract var() reference value', () => { + let source = 'a { color: var(--primary-color); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe('var(--primary-color)') + }) + + test('should extract nested function value', () => { + let source = 'a { transform: translate(calc(50% - 1rem), 0); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('transform') + expect(decl.value).toBe('translate(calc(50% - 1rem), 0)') + }) + + test('should handle value without semicolon', () => { + let source = 'a { color: blue }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe('blue') + }) + + test('should handle empty value', () => { + let source = 'a { color: ; }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('color') + expect(decl.value).toBe(null) + }) + + test('should extract URL value', () => { + let source = 'a { background: url("image.png"); }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let [_selector, block] = rule.children + let decl = block.first_child! + + expect(decl.name).toBe('background') + expect(decl.value).toBe('url("image.png")') + }) + }) + + describe('at-rule preludes', () => { + test('should extract media query prelude', () => { + let source = '@media (min-width: 768px) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.type).toBe(NODE_AT_RULE) + expect(atrule.name).toBe('media') + expect(atrule.prelude).toBe('(min-width: 768px)') + }) + + test('should extract complex media query prelude', () => { + let source = '@media screen and (min-width: 768px) and (max-width: 1024px) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('media') + expect(atrule.prelude).toBe('screen and (min-width: 768px) and (max-width: 1024px)') + }) + + test('should extract container query prelude', () => { + let source = '@container (width >= 200px) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('container') + expect(atrule.prelude).toBe('(width >= 200px)') + }) + + test('should extract supports query prelude', () => { + let source = '@supports (display: grid) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('supports') + expect(atrule.prelude).toBe('(display: grid)') + }) + + test('should extract import prelude', () => { + let source = '@import url("styles.css");' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('import') + expect(atrule.prelude).toBe('url("styles.css")') + }) + + test('should handle at-rule without prelude', () => { + let source = '@font-face { font-family: MyFont; }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('font-face') + expect(atrule.prelude).toBe(null) + }) + + test('should extract layer prelude', () => { + let source = '@layer utilities { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('layer') + expect(atrule.prelude).toBe('utilities') + }) + + test('should extract keyframes prelude', () => { + let source = '@keyframes slide-in { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('keyframes') + expect(atrule.prelude).toBe('slide-in') + }) + + test('should handle prelude with extra whitespace', () => { + let source = '@media (min-width: 768px) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('media') + expect(atrule.prelude).toBe('(min-width: 768px)') + }) + + test('should extract charset prelude', () => { + let source = '@charset "UTF-8";' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('charset') + expect(atrule.prelude).toBe('"UTF-8"') + }) + + test('should extract namespace prelude', () => { + let source = '@namespace svg url(http://www.w3.org/2000/svg);' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.name).toBe('namespace') + expect(atrule.prelude).toBe('svg url(http://www.w3.org/2000/svg)') + }) + + test('should value and prelude be aliases for at-rules', () => { + let source = '@media (min-width: 768px) { }' + let parser = new Parser(source) + let root = parser.parse() + + let atrule = root.first_child! + expect(atrule.value).toBe(atrule.prelude) + expect(atrule.value).toBe('(min-width: 768px)') + }) }) - test('should preserve text property', () => { - const css = 'body { color: red; }' - const result = parse(css) + 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) + }) - expect(result.text).toBe(css) + 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') + }) }) - test('should be iterable', () => { - const result = parse('body { } div { }') + 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) - const types: number[] = [] - for (const child of result) { - types.push(child.type) - } + // 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('&') - expect(types).toEqual([NODE_STYLE_RULE, NODE_STYLE_RULE]) + // 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/parse.ts b/src/parse.ts index 1f75e3f..22a5662 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,6 +1,587 @@ -import { Parser } from './parser' -import type { ParserOptions } from './parser' -import type { CSSNode } from './css-node' +// CSS Parser - Builds AST using the arena +import { Lexer } from './lexer' +import { + CSSDataArena, + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_SELECTOR, + NODE_SELECTOR_LIST, + NODE_DECLARATION, + NODE_AT_RULE, + NODE_BLOCK, + FLAG_IMPORTANT, + FLAG_HAS_BLOCK, + FLAG_VENDOR_PREFIXED, + FLAG_HAS_DECLARATIONS, +} from './arena' +import { CSSNode } from './css-node' +import { ValueParser } from './value-parser' +import { SelectorParser } from './parse-selector' +import { AtRulePreludeParser } from './parse-atrule-prelude' +import { + TOKEN_EOF, + TOKEN_LEFT_BRACE, + TOKEN_RIGHT_BRACE, + TOKEN_COLON, + TOKEN_SEMICOLON, + TOKEN_IDENT, + TOKEN_DELIM, + TOKEN_AT_KEYWORD, +} from './token-types' +import { trim_boundaries, is_vendor_prefixed } from './string-utils' + +export interface ParserOptions { + skip_comments?: boolean + parse_values?: boolean + parse_selectors?: boolean + parse_atrule_preludes?: boolean +} + +// Static at-rule lookup sets for fast classification +let DECLARATION_AT_RULES = new Set(['font-face', 'font-feature-values', 'page', 'property', 'counter-style']) +let CONDITIONAL_AT_RULES = new Set(['media', 'supports', 'container', 'layer', 'nest']) + +export class Parser { + private source: string + private lexer: Lexer + private arena: CSSDataArena + private value_parser: ValueParser | null + private selector_parser: SelectorParser | null + private prelude_parser: AtRulePreludeParser | null + private parse_values_enabled: boolean + private parse_selectors_enabled: boolean + private parse_atrule_preludes_enabled: boolean + + constructor(source: string, options?: ParserOptions) { + this.source = source + + // Support legacy boolean parameter for backwards compatibility + let opts: ParserOptions = options || {} + + let skip_comments = opts.skip_comments ?? true + this.parse_values_enabled = opts.parse_values ?? true + this.parse_selectors_enabled = opts.parse_selectors ?? true + this.parse_atrule_preludes_enabled = opts.parse_atrule_preludes ?? true + + this.lexer = new Lexer(source, skip_comments) + // Calculate optimal capacity based on source size + let capacity = CSSDataArena.capacity_for_source(source.length) + this.arena = new CSSDataArena(capacity) + + // Only create parsers if needed + this.value_parser = this.parse_values_enabled ? new ValueParser(this.arena, source) : null + this.selector_parser = this.parse_selectors_enabled ? new SelectorParser(this.arena, source) : null + this.prelude_parser = this.parse_atrule_preludes_enabled ? new AtRulePreludeParser(this.arena, source) : null + } + + // Get the arena (for internal/advanced use only) + get_arena(): CSSDataArena { + return this.arena + } + + // Get the source code + get_source(): string { + return this.source + } + + // Advance to the next token, skipping whitespace + private next_token(): void { + this.lexer.next_token_fast(true) + } + + // Peek at current token type + private peek_type(): number { + return this.lexer.token_type + } + + // Check if we're at the end of input + private is_eof(): boolean { + return this.peek_type() === TOKEN_EOF + } + + // Parse the entire stylesheet and return the root CSSNode + parse(): CSSNode { + // Start by getting the first token + this.next_token() + + // Create the root stylesheet node + let stylesheet = this.arena.create_node() + this.arena.set_type(stylesheet, NODE_STYLESHEET) + this.arena.set_start_offset(stylesheet, 0) + this.arena.set_length(stylesheet, this.source.length) + this.arena.set_start_line(stylesheet, 1) + this.arena.set_start_column(stylesheet, 1) + + // Parse all rules at the top level + while (!this.is_eof()) { + let rule = this.parse_rule() + if (rule !== null) { + this.arena.append_child(stylesheet, rule) + } else { + // Skip unknown tokens + this.next_token() + } + } + + // Return wrapped node + return new CSSNode(this.arena, this.source, stylesheet) + } + + // Parse a rule (style rule or at-rule) + private parse_rule(): number | null { + if (this.is_eof()) { + return null + } + + // Check for at-rule + if (this.peek_type() === TOKEN_AT_KEYWORD) { + return this.parse_atrule() + } + + // Try to parse as style rule + return this.parse_style_rule() + } + + // Parse a style rule: selector { declarations } + private parse_style_rule(): number | null { + if (this.is_eof()) return null + + let rule_start = this.lexer.token_start + let rule_line = this.lexer.token_line + let rule_column = this.lexer.token_column + + // Create the style rule node + let style_rule = this.arena.create_node() + this.arena.set_type(style_rule, NODE_STYLE_RULE) + this.arena.set_start_line(style_rule, rule_line) + this.arena.set_start_column(style_rule, rule_column) + + // Parse selector (everything until '{') + let selector = this.parse_selector() + if (selector !== null) { + this.arena.append_child(style_rule, selector) + } + + // Expect '{' + if (this.peek_type() !== TOKEN_LEFT_BRACE) { + // 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() + if (token_type === TOKEN_RIGHT_BRACE) break + + // Check for nested at-rule + if (token_type === TOKEN_AT_KEYWORD) { + let nested_at_rule = this.parse_atrule() + if (nested_at_rule !== null) { + this.arena.append_child(block_node, nested_at_rule) + } else { + this.next_token() + } + continue + } + + // Try to parse as declaration first + let declaration = this.parse_declaration() + if (declaration !== null) { + this.arena.set_flag(style_rule, FLAG_HAS_DECLARATIONS) + 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(block_node, nested_rule) + } else { + // Skip unknown tokens + this.next_token() + } + } + + // 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, rule_end - rule_start) + + return style_rule + } + + // Parse a selector (everything before '{') + private parse_selector(): number | null { + if (this.is_eof()) return null + + let selector_start = this.lexer.token_start + let selector_line = this.lexer.token_line + let selector_column = this.lexer.token_column + + // Consume tokens until we hit '{' + let last_end = this.lexer.token_end + while (!this.is_eof() && this.peek_type() !== TOKEN_LEFT_BRACE) { + last_end = this.lexer.token_end + this.next_token() + } + + // If detailed selector parsing is enabled, use SelectorParser + if (this.parse_selectors_enabled && this.selector_parser) { + let selectorNode = this.selector_parser.parse_selector(selector_start, last_end, selector_line, selector_column) + if (selectorNode !== null) { + return selectorNode + } + } + + // Otherwise create a simple selector list node with just text offsets + let selector = this.arena.create_node() + this.arena.set_type(selector, NODE_SELECTOR_LIST) + this.arena.set_start_line(selector, selector_line) + this.arena.set_start_column(selector, selector_column) + this.arena.set_start_offset(selector, selector_start) + this.arena.set_length(selector, last_end - selector_start) + + return selector + } + + // Parse a declaration: property: value; + private parse_declaration(): number | null { + // Expect identifier (property name) + if (this.peek_type() !== TOKEN_IDENT) { + return null + } + + let prop_start = this.lexer.token_start + let prop_end = this.lexer.token_end + 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 ':' + + // Create declaration node + let declaration = this.arena.create_node() + this.arena.set_type(declaration, NODE_DECLARATION) + this.arena.set_start_line(declaration, decl_line) + this.arena.set_start_column(declaration, decl_column) + this.arena.set_start_offset(declaration, prop_start) + + // Store property name position + this.arena.set_content_start(declaration, prop_start) + this.arena.set_content_length(declaration, prop_end - prop_start) + + // Check for vendor prefix and set flag if detected + if (is_vendor_prefixed(this.source, prop_start, prop_end)) { + this.arena.set_flag(declaration, FLAG_VENDOR_PREFIXED) + } + + // Track value start (after colon, skipping whitespace) + let value_start = this.lexer.token_start + let value_end = value_start + + // Parse value (everything until ';' or '}') + let has_important = false + let last_end = this.lexer.token_end + + while (!this.is_eof()) { + 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 + value_end = this.lexer.token_start + // Check if next token is an identifier + let next_type = this.lexer.next_token_fast() + if (next_type === TOKEN_IDENT) { + has_important = true + last_end = this.lexer.token_end + this.next_token() // Advance to next token after "important" + break + } + } + + last_end = this.lexer.token_end + value_end = last_end + this.next_token() + } + + // Store value position (trimmed) and parse value nodes + let trimmed = trim_boundaries(this.source, value_start, value_end) + if (trimmed) { + // Store raw value string offsets (for fast string access) + this.arena.set_value_start(declaration, trimmed[0]) + this.arena.set_value_length(declaration, trimmed[1] - trimmed[0]) + + // Parse value into structured nodes (only if enabled) + if (this.parse_values_enabled && this.value_parser) { + let valueNodes = this.value_parser.parse_value(trimmed[0], trimmed[1]) + + // Link value nodes as children of the declaration + if (valueNodes.length > 0) { + this.arena.set_first_child(declaration, valueNodes[0]) + this.arena.set_last_child(declaration, valueNodes[valueNodes.length - 1]) + + // Chain value nodes as siblings + for (let i = 0; i < valueNodes.length - 1; i++) { + this.arena.set_next_sibling(valueNodes[i], valueNodes[i + 1]) + } + } + } + } + + // Set !important flag if found + if (has_important) { + this.arena.set_flag(declaration, FLAG_IMPORTANT) + } + + // Consume ';' if present + if (this.peek_type() === TOKEN_SEMICOLON) { + last_end = this.lexer.token_end + this.next_token() + } + + // Set declaration length + this.arena.set_length(declaration, last_end - prop_start) + + return declaration + } + + // Parse an at-rule: @media, @import, @font-face, etc. + private parse_atrule(): number | null { + if (this.peek_type() !== TOKEN_AT_KEYWORD) { + return null + } + + let at_rule_start = this.lexer.token_start + let at_rule_line = this.lexer.token_line + let at_rule_column = this.lexer.token_column + + // Extract at-rule name (skip the '@') + let at_rule_name = this.source.substring(this.lexer.token_start + 1, this.lexer.token_end) + let name_start = this.lexer.token_start + 1 + let name_length = at_rule_name.length + + this.next_token() // consume @keyword + + // Create at-rule node + let at_rule = this.arena.create_node() + this.arena.set_type(at_rule, NODE_AT_RULE) + this.arena.set_start_line(at_rule, at_rule_line) + this.arena.set_start_column(at_rule, at_rule_column) + this.arena.set_start_offset(at_rule, at_rule_start) + + // Store at-rule name in contentStart/contentLength + this.arena.set_content_start(at_rule, name_start) + this.arena.set_content_length(at_rule, name_length) + + // Track prelude start and end + let prelude_start = this.lexer.token_start + let prelude_end = prelude_start + + // Parse prelude (everything before '{' or ';') + while (!this.is_eof() && this.peek_type() !== TOKEN_LEFT_BRACE && this.peek_type() !== TOKEN_SEMICOLON) { + prelude_end = this.lexer.token_end + this.next_token() + } + + // Store prelude position (trimmed) + let trimmed = trim_boundaries(this.source, prelude_start, prelude_end) + if (trimmed) { + this.arena.set_value_start(at_rule, trimmed[0]) + this.arena.set_value_length(at_rule, trimmed[1] - trimmed[0]) + + // Parse prelude if enabled + if (this.prelude_parser) { + let prelude_nodes = this.prelude_parser.parse_prelude(at_rule_name, trimmed[0], trimmed[1], at_rule_line, at_rule_column) + for (let prelude_node of prelude_nodes) { + this.arena.append_child(at_rule, prelude_node) + } + } + } + + let last_end = this.lexer.token_end + + // 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) + + if (has_declarations) { + // Parse declarations only (like @font-face, @page) + while (!this.is_eof()) { + let token_type = this.peek_type() + if (token_type === TOKEN_RIGHT_BRACE) break + + let declaration = this.parse_declaration() + if (declaration !== null) { + this.arena.append_child(block_node, declaration) + } else { + this.next_token() + } + } + } else if (is_conditional) { + // Conditional at-rules can contain both declarations and rules (CSS Nesting) + while (!this.is_eof()) { + let token_type = this.peek_type() + if (token_type === TOKEN_RIGHT_BRACE) break + + // Check for nested at-rule + if (token_type === TOKEN_AT_KEYWORD) { + let nested_at_rule = this.parse_atrule() + if (nested_at_rule !== null) { + this.arena.append_child(block_node, nested_at_rule) + } else { + this.next_token() + } + continue + } + + // Try to parse as declaration first + let declaration = this.parse_declaration() + if (declaration !== null) { + 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(block_node, nested_rule) + } else { + // Skip unknown tokens + this.next_token() + } + } + } else { + // Parse nested rules only (like @keyframes) + while (!this.is_eof()) { + let token_type = this.peek_type() + if (token_type === TOKEN_RIGHT_BRACE) break + + let rule = this.parse_rule() + if (rule !== null) { + this.arena.append_child(block_node, rule) + } else { + this.next_token() + } + } + } + + // Consume '}' (block excludes closing brace, but at-rule includes it) + if (this.peek_type() === TOKEN_RIGHT_BRACE) { + 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 + this.next_token() // consume ';' + } + + // Set at-rule length + this.arena.set_length(at_rule, last_end - at_rule_start) + + return at_rule + } + + // Determine if an at-rule contains declarations or nested rules + private atrule_has_declarations(name: string): boolean { + return DECLARATION_AT_RULES.has(name.toLowerCase()) + } + + // Determine if an at-rule is conditional (can contain both declarations and rules in CSS Nesting) + private atrule_is_conditional(name: string): boolean { + return CONDITIONAL_AT_RULES.has(name.toLowerCase()) + } +} /** * Parse CSS and return an AST @@ -12,3 +593,46 @@ export function parse(source: string, options?: ParserOptions): CSSNode { const parser = new Parser(source, options) return parser.parse() } + +// Re-export node type constants so consumers don't need to import from arena +export { + NODE_STYLESHEET, + NODE_STYLE_RULE, + NODE_AT_RULE, + NODE_DECLARATION, + NODE_SELECTOR, + NODE_COMMENT, + NODE_BLOCK, + NODE_VALUE_KEYWORD, + NODE_VALUE_NUMBER, + NODE_VALUE_DIMENSION, + NODE_VALUE_STRING, + NODE_VALUE_COLOR, + NODE_VALUE_FUNCTION, + NODE_VALUE_OPERATOR, + NODE_SELECTOR_LIST, + NODE_SELECTOR_TYPE, + NODE_SELECTOR_CLASS, + NODE_SELECTOR_ID, + NODE_SELECTOR_ATTRIBUTE, + NODE_SELECTOR_PSEUDO_CLASS, + NODE_SELECTOR_PSEUDO_ELEMENT, + 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, + NODE_PRELUDE_CONTAINER_QUERY, + NODE_PRELUDE_SUPPORTS_QUERY, + NODE_PRELUDE_LAYER_NAME, + NODE_PRELUDE_IDENTIFIER, + NODE_PRELUDE_OPERATOR, + NODE_PRELUDE_IMPORT_URL, + NODE_PRELUDE_IMPORT_LAYER, + NODE_PRELUDE_IMPORT_SUPPORTS, + FLAG_IMPORTANT, +} from './arena' diff --git a/src/parser.test.ts b/src/parser.test.ts deleted file mode 100644 index 5c8cdf9..0000000 --- a/src/parser.test.ts +++ /dev/null @@ -1,1998 +0,0 @@ -import { describe, test, expect } from 'vitest' -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', () => { - test('should create parser with arena sized for source', () => { - const source = 'body { color: red; }' - const parser = new Parser(source) - const arena = parser.get_arena() - - // Should have capacity based on source size - expect(arena.get_capacity()).toBeGreaterThan(0) - expect(arena.get_count()).toBe(1) // Count starts at 1 (0 is reserved for "no node") - }) - - test('should parse empty stylesheet', () => { - const parser = new Parser('') - const root = parser.parse() - - expect(root.type).toBe(NODE_STYLESHEET) - expect(root.offset).toBe(0) - expect(root.length).toBe(0) - expect(root.has_children).toBe(false) - }) - - test('should parse stylesheet with only whitespace', () => { - const parser = new Parser(' \n\n ') - const root = parser.parse() - - expect(root.type).toBe(NODE_STYLESHEET) - expect(root.has_children).toBe(false) - }) - - test('should parse stylesheet with only comments', () => { - const parser = new Parser('/* comment */') - const root = parser.parse() - - expect(root.type).toBe(NODE_STYLESHEET) - // TODO: Once we parse comments, verify they're added as children - }) - }) - - describe('style rule parsing', () => { - test('should parse simple style rule', () => { - const parser = new Parser('body { }') - const root = parser.parse() - - expect(root.has_children).toBe(true) - - const rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - expect(rule.offset).toBe(0) - expect(rule.length).toBeGreaterThan(0) - }) - - test('should parse style rule with selector', () => { - const source = 'body { }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - expect(rule.has_children).toBe(true) - - const selector = rule.first_child! - // With parseSelectors enabled by default, we get detailed selector nodes - expect(selector.text).toBe('body') - expect(selector.line).toBe(1) // Line numbers start at 1 - expect(selector.offset).toBe(0) - expect(selector.length).toBe(4) // "body" - }) - - test('should parse multiple style rules', () => { - const parser = new Parser('body { } div { }') - const root = parser.parse() - - const [rule1, rule2] = root.children - expect(rule1.type).toBe(NODE_STYLE_RULE) - expect(rule2.type).toBe(NODE_STYLE_RULE) - expect(rule2.next_sibling).toBe(null) - }) - - test('should parse complex selector', () => { - const source = 'div.class > p#id { }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const selectorlist = rule.first_child! - - // With parseSelectors enabled, selector is now detailed - 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(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"') - }) - }) - - describe('declaration parsing', () => { - test('should parse simple declaration', () => { - const source = 'body { color: red; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - expect(declaration.is_important).toBe(false) - }) - - test('should parse declaration with property name', () => { - const source = 'body { color: red; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - // Property name stored in the 'name' property - expect(declaration.name).toBe('color') - }) - - test('should parse multiple declarations', () => { - const source = 'body { color: red; margin: 0; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const [decl1, decl2] = block.children - - expect(decl1.type).toBe(NODE_DECLARATION) - expect(decl2.type).toBe(NODE_DECLARATION) - expect(decl2.next_sibling).toBe(null) - }) - - test('should parse declaration with !important', () => { - const source = 'body { color: red !important; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - expect(declaration.is_important).toBe(true) - }) - - test('should parse declaration with !ie (historic !important)', () => { - const source = 'body { color: red !ie; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - expect(declaration.is_important).toBe(true) - }) - - test('should parse declaration with ! followed by any identifier', () => { - const source = 'body { color: red !foo; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - expect(declaration.is_important).toBe(true) - }) - - test('should parse declaration without semicolon at end of block', () => { - const source = 'body { color: red }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - }) - - test('should parse complex declaration value', () => { - const source = 'body { background: url(image.png) no-repeat center; }' - const parser = new Parser(source) - const root = parser.parse() - - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! - - expect(declaration.type).toBe(NODE_DECLARATION) - expect(declaration.name).toBe('background') - }) - }) - - describe('at-rule parsing', () => { - describe('statement at-rules (no block)', () => { - test('should parse @import', () => { - const source = '@import url("style.css");' - const parser = new Parser(source, { parse_atrule_preludes: false }) - const root = parser.parse() - - const atRule = root.first_child! - expect(atRule.type).toBe(NODE_AT_RULE) - expect(atRule.name).toBe('import') - expect(atRule.has_children).toBe(false) - }) - - test('should parse @namespace', () => { - const source = '@namespace url(http://www.w3.org/1999/xhtml);' - const parser = new Parser(source) - const root = parser.parse() - - const atRule = root.first_child! - expect(atRule.type).toBe(NODE_AT_RULE) - expect(atRule.name).toBe('namespace') - }) - }) - - describe('case-insensitive at-rule names', () => { - test('should parse @MEDIA (uppercase conditional at-rule)', () => { - const source = '@MEDIA (min-width: 768px) { body { color: red; } }' - const parser = new Parser(source, { parse_atrule_preludes: false }) - const root = parser.parse() - - const media = root.first_child! - expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('MEDIA') - expect(media.has_children).toBe(true) - // Should parse as conditional (containing rules) - const block = media.block! - const nestedRule = block.first_child! - expect(nestedRule.type).toBe(NODE_STYLE_RULE) - }) - - test('should parse @Font-Face (mixed case declaration at-rule)', () => { - const source = '@Font-Face { font-family: "MyFont"; src: url("font.woff"); }' - const parser = new Parser(source) - const root = parser.parse() - - const fontFace = root.first_child! - expect(fontFace.type).toBe(NODE_AT_RULE) - expect(fontFace.name).toBe('Font-Face') - expect(fontFace.has_children).toBe(true) - // Should parse as declaration at-rule (containing declarations) - const block = fontFace.block! - const decl = block.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - }) - - test('should parse @SUPPORTS (uppercase conditional at-rule)', () => { - const source = '@SUPPORTS (display: grid) { .grid { display: grid; } }' - const parser = new Parser(source, { parse_atrule_preludes: false }) - const root = parser.parse() - - const supports = root.first_child! - expect(supports.type).toBe(NODE_AT_RULE) - expect(supports.name).toBe('SUPPORTS') - expect(supports.has_children).toBe(true) - }) - }) - - describe('block at-rules with nested rules', () => { - test('should parse @media with nested rule', () => { - const source = '@media (min-width: 768px) { body { color: red; } }' - const parser = new Parser(source, { parse_atrule_preludes: false }) - const root = parser.parse() - - const media = root.first_child! - expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') - expect(media.has_children).toBe(true) - - const block = media.block! - const nestedRule = block.first_child! - expect(nestedRule.type).toBe(NODE_STYLE_RULE) - }) - - test('should parse @layer with name', () => { - const source = '@layer utilities { .text-center { text-align: center; } }' - const parser = new Parser(source) - const root = parser.parse() - - const layer = root.first_child! - expect(layer.type).toBe(NODE_AT_RULE) - expect(layer.name).toBe('layer') - expect(layer.has_children).toBe(true) - }) - - test('should parse anonymous @layer', () => { - const source = '@layer { body { margin: 0; } }' - const parser = new Parser(source) - const root = parser.parse() - - const layer = root.first_child! - expect(layer.type).toBe(NODE_AT_RULE) - expect(layer.name).toBe('layer') - expect(layer.has_children).toBe(true) - }) - - test('should parse @supports', () => { - const source = '@supports (display: grid) { .grid { display: grid; } }' - const parser = new Parser(source) - const root = parser.parse() - - const supports = root.first_child! - expect(supports.type).toBe(NODE_AT_RULE) - expect(supports.name).toBe('supports') - expect(supports.has_children).toBe(true) - }) - - test('should parse @container', () => { - const source = '@container (min-width: 400px) { .card { padding: 2rem; } }' - const parser = new Parser(source) - const root = parser.parse() - - const container = root.first_child! - expect(container.type).toBe(NODE_AT_RULE) - expect(container.name).toBe('container') - expect(container.has_children).toBe(true) - }) - }) - - describe('descriptor at-rules (with declarations)', () => { - test('should parse @font-face', () => { - const source = '@font-face { font-family: "Open Sans"; src: url(font.woff2); }' - const parser = new Parser(source) - const root = parser.parse() - - const fontFace = root.first_child! - expect(fontFace.type).toBe(NODE_AT_RULE) - expect(fontFace.name).toBe('font-face') - expect(fontFace.has_children).toBe(true) - - // Should have declarations as children - const block = fontFace.block! - const [decl1, decl2] = block.children - expect(decl1.type).toBe(NODE_DECLARATION) - expect(decl2.type).toBe(NODE_DECLARATION) - }) - - test('should parse @page', () => { - const source = '@page { margin: 1in; }' - const parser = new Parser(source) - const root = parser.parse() - - const page = root.first_child! - expect(page.type).toBe(NODE_AT_RULE) - expect(page.name).toBe('page') - - const block = page.block! - const decl = block.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - }) - - test('should parse @counter-style', () => { - const source = '@counter-style thumbs { system: cyclic; symbols: "👍"; }' - const parser = new Parser(source) - const root = parser.parse() - - const counterStyle = root.first_child! - expect(counterStyle.type).toBe(NODE_AT_RULE) - expect(counterStyle.name).toBe('counter-style') - - const block = counterStyle.block! - const decl = block.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - }) - }) - - describe('nested at-rules', () => { - test('should parse @media inside @supports', () => { - const source = '@supports (display: grid) { @media (min-width: 768px) { body { color: red; } } }' - const parser = new Parser(source, { parse_atrule_preludes: false }) - const root = parser.parse() - - const supports = root.first_child! - expect(supports.name).toBe('supports') - - const supports_block = supports.block! - const media = supports_block.first_child! - expect(media.type).toBe(NODE_AT_RULE) - expect(media.name).toBe('media') - - const media_block = media.block! - const rule = media_block.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - }) - - describe('multiple at-rules', () => { - test('should parse multiple at-rules at top level', () => { - const source = '@import url("a.css"); @layer base { body { margin: 0; } } @media print { body { color: black; } }' - const parser = new Parser(source) - const root = parser.parse() - - const [import1, layer, media] = root.children - expect(import1.name).toBe('import') - expect(layer.name).toBe('layer') - expect(media.name).toBe('media') - }) - }) - }) - - describe('CSS Nesting', () => { - test('should parse nested rule with & selector', () => { - let source = '.parent { color: red; & .child { color: blue; } }' - let parser = new Parser(source) - let root = parser.parse() - - let parent = root.first_child! - expect(parent.type).toBe(NODE_STYLE_RULE) - - let [_selector, block] = parent.children - let [decl, nested_rule] = block.children - expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') - - expect(nested_rule.type).toBe(NODE_STYLE_RULE) - let nested_selector = nested_rule.first_child! - // With parseSelectors enabled, selector is now detailed - expect(nested_selector.text).toBe('& .child') - }) - - test('should parse nested rule without & selector', () => { - let source = '.parent { color: red; .child { color: blue; } }' - let parser = new Parser(source) - let root = parser.parse() - - let parent = root.first_child! - 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! - expect(nested_selector.text).toBe('.child') - }) - - test('should parse multiple nested rules', () => { - let source = '.parent { .child1 { } .child2 { } }' - let parser = new Parser(source) - let root = parser.parse() - - let parent = root.first_child! - let [_selector, block] = parent.children - let [nested1, nested2] = block.children - - expect(nested1.type).toBe(NODE_STYLE_RULE) - expect(nested2.type).toBe(NODE_STYLE_RULE) - }) - - test('should parse deeply nested rules', () => { - let source = '.a { .b { .c { color: red; } } }' - let parser = new Parser(source) - let root = parser.parse() - - let a = root.first_child! - let [_selector_a, block_a] = a.children - let b = block_a.first_child! - expect(b.type).toBe(NODE_STYLE_RULE) - - let [_selector_b, block_b] = b.children - let c = block_b.first_child! - expect(c.type).toBe(NODE_STYLE_RULE) - - let [_selector_c, block_c] = c.children - let decl = block_c.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') - }) - - test('should parse nested @media inside rule', () => { - let source = '.card { color: red; @media (min-width: 768px) { padding: 2rem; } }' - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let card = root.first_child! - 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 media_block = media.block! - let nested_decl = media_block.first_child! - expect(nested_decl.type).toBe(NODE_DECLARATION) - expect(nested_decl.name).toBe('padding') - }) - - test('should parse :is() pseudo-class', () => { - let source = ':is(.a, .b) { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe(':is(.a, .b)') - }) - - test('should parse :where() pseudo-class', () => { - let source = ':where(h1, h2, h3) { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe(':where(h1, h2, h3)') - }) - - test('should parse :has() pseudo-class', () => { - let source = 'div:has(> img) { display: flex; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe('div:has(> img)') - }) - - test('should parse complex nesting with mixed declarations and rules', () => { - let source = `.card { - color: red; - .title { font-size: 2rem; } - padding: 1rem; - .body { line-height: 1.5; } - }` - let parser = new Parser(source) - let root = parser.parse() - - let card = root.first_child! - let [_selector, block] = card.children - let [decl1, title, decl2, body] = block.children - - expect(decl1.type).toBe(NODE_DECLARATION) - expect(decl1.name).toBe('color') - - expect(title.type).toBe(NODE_STYLE_RULE) - - expect(decl2.type).toBe(NODE_DECLARATION) - expect(decl2.name).toBe('padding') - - expect(body.type).toBe(NODE_STYLE_RULE) - }) - }) - - describe('@keyframes parsing', () => { - test('should parse @keyframes with from/to', () => { - let source = '@keyframes fade { from { opacity: 0; } to { opacity: 1; } }' - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let keyframes = root.first_child! - expect(keyframes.type).toBe(NODE_AT_RULE) - expect(keyframes.name).toBe('keyframes') - - 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) - - let from_selector = from_rule.first_child! - expect(from_selector.text).toBe('from') - - let to_selector = to_rule.first_child! - expect(to_selector.text).toBe('to') - }) - - test('should parse @keyframes with percentages', () => { - let source = '@keyframes slide { 0% { left: 0; } 50% { left: 50%; } 100% { left: 100%; } }' - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let keyframes = root.first_child! - let block = keyframes.block! - let [rule0, rule50, rule100] = block.children - - expect(rule0.type).toBe(NODE_STYLE_RULE) - expect(rule50.type).toBe(NODE_STYLE_RULE) - expect(rule100.type).toBe(NODE_STYLE_RULE) - - let selector0 = rule0.first_child! - expect(selector0.text).toBe('0%') - }) - - test('should parse @keyframes with multiple selectors', () => { - let source = '@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }' - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let keyframes = root.first_child! - let block = keyframes.block! - let [rule1, _rule2] = block.children - - let selector1 = rule1.first_child! - expect(selector1.text).toBe('0%, 100%') - }) - }) - - describe('@nest at-rule', () => { - test('should parse @nest with & selector', () => { - let source = '.parent { @nest & .child { color: blue; } }' - let parser = new Parser(source) - let root = parser.parse() - - let parent = root.first_child! - 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 nest_block = nest.block! - let decl = nest_block.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - expect(decl.name).toBe('color') - }) - - test('should parse @nest with complex selector', () => { - let source = '.a { @nest :not(&) { color: red; } }' - let parser = new Parser(source) - let root = parser.parse() - - let a = root.first_child! - let [_selector, block] = a.children - let nest = block.first_child! - - expect(nest.type).toBe(NODE_AT_RULE) - expect(nest.name).toBe('nest') - }) - }) - - describe('error recovery and edge cases', () => { - test('should handle malformed rule without opening brace', () => { - let source = 'body color: red; } div { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - // Should skip malformed rule and parse valid one - expect(root.children.length).toBeGreaterThan(0) - }) - - test('should handle rule without closing brace', () => { - let source = 'body { color: red; div { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - // Parser should recover - expect(root.has_children).toBe(true) - }) - - test('should handle empty rule block', () => { - let source = '.empty { }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - // Only has selector and empty block - expect(rule.children.length).toBe(2) - }) - - test('should handle declaration without value', () => { - let source = 'body { color: }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.type).toBe(NODE_DECLARATION) - }) - - test('should handle multiple semicolons', () => { - let source = 'body { color: red;;; margin: 0;; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - // Rule has selector + block - expect(rule.children.length).toBe(2) - }) - - test('should skip invalid tokens in declaration block', () => { - let source = 'body { color: red; @@@; margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - // Should have selector + block - expect(rule.children.length).toBe(2) - }) - - test('should handle declaration without colon', () => { - let source = 'body { color red; margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - // 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', () => { - let source = '@ { color: red; } body { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - // Should recover and parse body rule - expect(root.children.length).toBeGreaterThan(0) - }) - - test('should handle nested empty blocks', () => { - let source = '.a { .b { .c { } } }' - let parser = new Parser(source) - let root = parser.parse() - - let a = root.first_child! - expect(a.type).toBe(NODE_STYLE_RULE) - }) - - test('should handle trailing comma in selector', () => { - let source = '.a, .b, { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - }) - - describe('vendor prefix detection', () => { - test('should detect -webkit- vendor prefix', () => { - let source = '.box { -webkit-transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-webkit-transform') - expect(decl.is_vendor_prefixed).toBe(true) - }) - - test('should detect -moz- vendor prefix', () => { - let source = '.box { -moz-transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-moz-transform') - expect(decl.is_vendor_prefixed).toBe(true) - }) - - test('should detect -ms- vendor prefix', () => { - let source = '.box { -ms-transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-ms-transform') - expect(decl.is_vendor_prefixed).toBe(true) - }) - - test('should detect -o- vendor prefix', () => { - let source = '.box { -o-transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('-o-transform') - expect(decl.is_vendor_prefixed).toBe(true) - }) - - test('should not detect vendor prefix for standard properties', () => { - let source = '.box { transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('transform') - expect(decl.is_vendor_prefixed).toBe(false) - }) - - test('should not detect vendor prefix for properties with hyphens', () => { - let source = '.box { background-color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('background-color') - expect(decl.is_vendor_prefixed).toBe(false) - }) - - test('should not detect vendor prefix for custom properties', () => { - let source = ':root { --primary-color: blue; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('--primary-color') - expect(decl.is_vendor_prefixed).toBe(false) - }) - - test('should detect vendor prefix with multiple vendor-prefixed properties', () => { - let source = '.box { -webkit-transform: scale(1); -moz-transform: scale(1); transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let [webkit, moz, standard] = block.children - - expect(webkit.name).toBe('-webkit-transform') - expect(webkit.is_vendor_prefixed).toBe(true) - - expect(moz.name).toBe('-moz-transform') - expect(moz.is_vendor_prefixed).toBe(true) - - expect(standard.name).toBe('transform') - expect(standard.is_vendor_prefixed).toBe(false) - }) - - test('should detect vendor prefix for complex property names', () => { - let source = '.box { -webkit-border-top-left-radius: 5px; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - 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) - }) - - test('should not detect vendor prefix for similar but non-vendor properties', () => { - // Edge case: property that starts with hyphen but isn't a vendor prefix - let source = '.box { border-radius: 5px; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.name).toBe('border-radius') - expect(decl.is_vendor_prefixed).toBe(false) - }) - - test('should return false for nodes without names', () => { - // Nodes like selectors or at-rules without property names - let source = 'body { }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - // Selectors have text but checking is_vendor_prefixed should be safe - expect(selector.is_vendor_prefixed).toBe(false) - }) - }) - - describe('vendor prefix detection for selectors', () => { - test('should detect -webkit- vendor prefix in pseudo-class', () => { - let source = 'input:-webkit-autofill { color: black; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - // Selector has detailed parsing enabled by default - expect(selector.has_children).toBe(true) - // Navigate: selector -> type selector (input) -> pseudo-class (next sibling) - let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! - expect(pseudoClass.name).toBe('-webkit-autofill') - expect(pseudoClass.is_vendor_prefixed).toBe(true) - }) - - test('should detect -moz- vendor prefix in pseudo-class', () => { - let source = 'button:-moz-focusring { outline: 2px solid blue; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! - expect(pseudoClass.name).toBe('-moz-focusring') - expect(pseudoClass.is_vendor_prefixed).toBe(true) - }) - - test('should detect -ms- vendor prefix in pseudo-class', () => { - let source = 'input:-ms-input-placeholder { color: gray; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! - expect(pseudoClass.name).toBe('-ms-input-placeholder') - expect(pseudoClass.is_vendor_prefixed).toBe(true) - }) - - test('should detect -webkit- vendor prefix in pseudo-element', () => { - let source = 'div::-webkit-scrollbar { width: 10px; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! - expect(pseudoElement.name).toBe('-webkit-scrollbar') - expect(pseudoElement.is_vendor_prefixed).toBe(true) - }) - - test('should detect -moz- vendor prefix in pseudo-element', () => { - let source = 'div::-moz-selection { background: yellow; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! - expect(pseudoElement.name).toBe('-moz-selection') - expect(pseudoElement.is_vendor_prefixed).toBe(true) - }) - - test('should detect -webkit- vendor prefix in pseudo-element with multiple parts', () => { - let source = 'input::-webkit-input-placeholder { color: gray; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! - expect(pseudoElement.name).toBe('-webkit-input-placeholder') - expect(pseudoElement.is_vendor_prefixed).toBe(true) - }) - - test('should detect -webkit- vendor prefix in pseudo-class function', () => { - let source = 'input:-webkit-any(input, button) { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! - expect(pseudoClass.name).toBe('-webkit-any') - expect(pseudoClass.is_vendor_prefixed).toBe(true) - }) - - test('should not detect vendor prefix for standard pseudo-classes', () => { - let source = 'a:hover { color: blue; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoClass = typeSelector.next_sibling! - expect(pseudoClass.name).toBe('hover') - expect(pseudoClass.is_vendor_prefixed).toBe(false) - }) - - test('should not detect vendor prefix for standard pseudo-elements', () => { - let source = 'div::before { content: ""; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! - expect(pseudoElement.name).toBe('before') - expect(pseudoElement.is_vendor_prefixed).toBe(false) - }) - - test('should detect vendor prefix with multiple vendor-prefixed pseudo-elements', () => { - let source = 'div::-webkit-scrollbar { } div::-webkit-scrollbar-thumb { } div::after { }' - let parser = new Parser(source) - let root = parser.parse() - - let [rule1, rule2, rule3] = root.children - - let selectorList1 = rule1.first_child! - let selector1 = selectorList1.first_child! // NODE_SELECTOR wrapper - let typeSelector1 = selector1.first_child! - let pseudo1 = typeSelector1.next_sibling! - expect(pseudo1.name).toBe('-webkit-scrollbar') - expect(pseudo1.is_vendor_prefixed).toBe(true) - - let selectorList2 = rule2.first_child! - let selector2 = selectorList2.first_child! // NODE_SELECTOR wrapper - let typeSelector2 = selector2.first_child! - let pseudo2 = typeSelector2.next_sibling! - expect(pseudo2.name).toBe('-webkit-scrollbar-thumb') - expect(pseudo2.is_vendor_prefixed).toBe(true) - - let selectorList3 = rule3.first_child! - let selector3 = selectorList3.first_child! // NODE_SELECTOR wrapper - let typeSelector3 = selector3.first_child! - let pseudo3 = typeSelector3.next_sibling! - expect(pseudo3.name).toBe('after') - expect(pseudo3.is_vendor_prefixed).toBe(false) - }) - - test('should detect vendor prefix in complex selector', () => { - let source = 'input:-webkit-autofill:focus { color: black; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selectorList = rule.first_child! - let selector = selectorList.first_child! // NODE_SELECTOR wrapper - // Navigate through compound selector: input (type) -> -webkit-autofill (pseudo) -> :focus (pseudo) - let typeSelector = selector.first_child! - let webkitPseudo = typeSelector.next_sibling! - expect(webkitPseudo.name).toBe('-webkit-autofill') - expect(webkitPseudo.is_vendor_prefixed).toBe(true) - - // Check the :focus pseudo-class is not vendor prefixed - let focusPseudo = webkitPseudo.next_sibling! - expect(focusPseudo.name).toBe('focus') - expect(focusPseudo.is_vendor_prefixed).toBe(false) - }) - }) - - describe('complex real-world scenarios', () => { - test('should parse complex nested structure', () => { - let source = ` - .card { - display: flex; - padding: 1rem; - - .header { - font-size: 2rem; - font-weight: bold; - - &:hover { - color: blue; - } - } - - @media (min-width: 768px) { - padding: 2rem; - - .header { - font-size: 3rem; - } - } - - .footer { - margin-top: auto; - } - } - ` - let parser = new Parser(source) - let root = parser.parse() - - let card = root.first_child! - expect(card.type).toBe(NODE_STYLE_RULE) - // Card has selector + block - expect(card.children.length).toBe(2) - }) - - test('should parse multiple at-rules with nesting', () => { - let source = ` - @layer base { - body { margin: 0; } - } - - @layer components { - .btn { - padding: 0.5rem; - - @media (hover: hover) { - &:hover { opacity: 0.8; } - } - } - } - ` - let parser = new Parser(source) - let root = parser.parse() - - let [layer1, layer2] = root.children - expect(layer1.type).toBe(NODE_AT_RULE) - expect(layer2.type).toBe(NODE_AT_RULE) - }) - - test('should parse vendor prefixed properties', () => { - let source = '.box { -webkit-transform: scale(1); -moz-transform: scale(1); transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - 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') - }) - - test('should parse complex selector list', () => { - let source = 'h1, h2, h3, h4, h5, h6, .heading, [role="heading"] { font-family: sans-serif; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toContain('h1') - expect(selector.text).toContain('[role="heading"]') - }) - - test('should parse deeply nested at-rules', () => { - let source = ` - @supports (display: grid) { - @media (min-width: 768px) { - @layer utilities { - .grid { display: grid; } - } - } - } - ` - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let supports = root.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') - }) - - test('should parse CSS with calc() and other functions', () => { - let source = '.box { width: calc(100% - 2rem); background: linear-gradient(to right, red, blue); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let [width_decl, bg_decl] = block.children - expect(width_decl.name).toBe('width') - expect(bg_decl.name).toBe('background') - }) - - test('should parse custom properties', () => { - let source = ':root { --primary-color: #007bff; --spacing: 1rem; } body { color: var(--primary-color); }' - let parser = new Parser(source) - let root = parser.parse() - - // Parser may have issues with -- custom property names, check what we got - expect(root.children.length).toBeGreaterThan(0) - let first_rule = root.first_child! - expect(first_rule.type).toBe(NODE_STYLE_RULE) - }) - - test('should parse attribute selectors with operators', () => { - let source = '[href^="https"][href$=".pdf"][class*="doc"] { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toContain('^=') - expect(selector.text).toContain('$=') - expect(selector.text).toContain('*=') - }) - - test('should parse pseudo-elements', () => { - let source = '.text::before { content: "→"; } .text::after { content: "←"; }' - let parser = new Parser(source) - let root = parser.parse() - - let [rule1, rule2] = root.children - expect(rule1.type).toBe(NODE_STYLE_RULE) - expect(rule2.type).toBe(NODE_STYLE_RULE) - }) - - test('should parse multiple !important declarations', () => { - let source = '.override { color: red !important; margin: 0 !important; padding: 0 !ie; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let block = rule.block! - expect(block.children.length).toBeGreaterThan(1) - // Check at least first declaration has important flag - let declarations = block.children.filter((c) => c.type === NODE_DECLARATION) - expect(declarations.length).toBeGreaterThan(0) - expect(declarations[0].is_important).toBe(true) - }) - }) - - describe('comment handling', () => { - test('should skip comments at top level', () => { - let source = '/* comment */ body { color: red; } /* another comment */' - let parser = new Parser(source) - let root = parser.parse() - - // Comments are skipped, only rule remains - expect(root.children.length).toBe(1) - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - - test('should skip comments in declaration block', () => { - let source = 'body { color: red; /* comment */ margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - // Comments don't break parsing - expect(rule.type).toBe(NODE_STYLE_RULE) - // Rule has selector + block - expect(rule.children.length).toBe(2) - }) - - test('should skip comments in selector', () => { - let source = 'body /* comment */ , /* comment */ div { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - - test('should handle comment between property and colon', () => { - let source = 'body { color /* comment */ : red; }' - let parser = new Parser(source) - let root = parser.parse() - - // Parser behavior with comments in unusual positions - expect(root.has_children).toBe(true) - }) - - test('should handle multi-line comments', () => { - let source = ` - /* - * Multi-line - * comment - */ - body { color: red; } - ` - let parser = new Parser(source) - let root = parser.parse() - - expect(root.children.length).toBe(1) - }) - }) - - describe('whitespace handling', () => { - test('should handle excessive whitespace', () => { - let source = ' body { color : red ; } ' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - - test('should handle tabs and newlines', () => { - let source = 'body\t{\n\tcolor:\tred;\n}\n' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.type).toBe(NODE_STYLE_RULE) - }) - - test('should handle no whitespace', () => { - let source = 'body{color:red;margin:0}' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let [decl1, decl2] = block.children - expect(decl1.name).toBe('color') - expect(decl2.name).toBe('margin') - }) - }) - - describe('special at-rules', () => { - test('should parse @charset', () => { - let source = '@charset "UTF-8"; body { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let [charset, _body] = root.children - expect(charset.type).toBe(NODE_AT_RULE) - expect(charset.name).toBe('charset') - }) - - test('should parse @import with media query', () => { - let source = '@import url("print.css") print;' - let parser = new Parser(source) - let root = parser.parse() - - let import_rule = root.first_child! - expect(import_rule.type).toBe(NODE_AT_RULE) - expect(import_rule.name).toBe('import') - }) - - test('should parse @font-face with multiple descriptors', () => { - let source = ` - @font-face { - font-family: "Custom"; - src: url("font.woff2") format("woff2"), - url("font.woff") format("woff"); - font-weight: 400; - font-style: normal; - font-display: swap; - } - ` - let parser = new Parser(source) - let root = parser.parse() - - let font_face = root.first_child! - expect(font_face.name).toBe('font-face') - let block = font_face.block! - expect(block.children.length).toBeGreaterThan(3) - }) - - test('should parse @keyframes with mixed percentages and keywords', () => { - let source = '@keyframes slide { from { left: 0; } 25%, 75% { left: 50%; } to { left: 100%; } }' - let parser = new Parser(source, { parse_atrule_preludes: false }) - let root = parser.parse() - - let keyframes = root.first_child! - let block = keyframes.block! - expect(block.children.length).toBe(3) - }) - - test('should parse @counter-style', () => { - let source = '@counter-style custom { system: cyclic; symbols: "⚫" "⚪"; suffix: " "; }' - let parser = new Parser(source) - let root = parser.parse() - - let counter = root.first_child! - expect(counter.name).toBe('counter-style') - let block = counter.block! - expect(block.children.length).toBeGreaterThan(1) - }) - - test('should parse @property', () => { - let source = '@property --my-color { syntax: ""; inherits: false; initial-value: #c0ffee; }' - let parser = new Parser(source) - let root = parser.parse() - - let property = root.first_child! - expect(property.name).toBe('property') - }) - }) - - describe('location tracking', () => { - test('should track line numbers for rules', () => { - let source = 'body { color: red; }\ndiv { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let [rule1, rule2] = root.children - expect(rule1.line).toBe(1) - expect(rule2.line).toBe(2) - }) - - test('should track line numbers for at-rule preludes', () => { - let source = 'body { color: red; }\n\n@media screen { }' - let parser = new Parser(source) - let root = parser.parse() - - let [_rule1, atRule] = root.children - expect(atRule.line).toBe(3) - - // Check that prelude nodes inherit the correct line - let preludeNode = atRule.first_child - expect(preludeNode).toBeTruthy() - expect(preludeNode!.line).toBe(3) // Should be line 3, not line 1 - }) - - test('should track offsets correctly', () => { - let source = 'body { color: red; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - expect(rule.offset).toBe(0) - expect(rule.length).toBe(source.length) - }) - - test('should track declaration positions', () => { - let source = 'body { color: red; margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let [decl1, decl2] = block.children - - expect(decl1.offset).toBeLessThan(decl2.offset) - }) - }) - - describe('declaration values', () => { - test('should extract simple value', () => { - let source = 'a { color: blue; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe('blue') - }) - - test('should extract value with spaces', () => { - let source = 'a { padding: 1rem 2rem 3rem 4rem; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('padding') - expect(decl.value).toBe('1rem 2rem 3rem 4rem') - }) - - test('should extract function value', () => { - let source = 'a { background: linear-gradient(to bottom, red, blue); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - 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)') - }) - - test('should extract calc value', () => { - let source = 'a { width: calc(100% - 2rem); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('width') - expect(decl.value).toBe('calc(100% - 2rem)') - }) - - test('should exclude !important from value', () => { - let source = 'a { color: blue !important; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe('blue') - expect(decl.is_important).toBe(true) - }) - - test('should handle value with extra whitespace', () => { - let source = 'a { color: blue ; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe('blue') - }) - - test('should extract CSS custom property value', () => { - let source = ':root { --brand-color: rgb(0% 10% 50% / 0.5); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - 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)') - }) - - test('should extract var() reference value', () => { - let source = 'a { color: var(--primary-color); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe('var(--primary-color)') - }) - - test('should extract nested function value', () => { - let source = 'a { transform: translate(calc(50% - 1rem), 0); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('transform') - expect(decl.value).toBe('translate(calc(50% - 1rem), 0)') - }) - - test('should handle value without semicolon', () => { - let source = 'a { color: blue }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe('blue') - }) - - test('should handle empty value', () => { - let source = 'a { color: ; }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('color') - expect(decl.value).toBe(null) - }) - - test('should extract URL value', () => { - let source = 'a { background: url("image.png"); }' - let parser = new Parser(source) - let root = parser.parse() - - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - - expect(decl.name).toBe('background') - expect(decl.value).toBe('url("image.png")') - }) - }) - - describe('at-rule preludes', () => { - test('should extract media query prelude', () => { - let source = '@media (min-width: 768px) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.type).toBe(NODE_AT_RULE) - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') - }) - - test('should extract complex media query prelude', () => { - let source = '@media screen and (min-width: 768px) and (max-width: 1024px) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('screen and (min-width: 768px) and (max-width: 1024px)') - }) - - test('should extract container query prelude', () => { - let source = '@container (width >= 200px) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('container') - expect(atrule.prelude).toBe('(width >= 200px)') - }) - - test('should extract supports query prelude', () => { - let source = '@supports (display: grid) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('supports') - expect(atrule.prelude).toBe('(display: grid)') - }) - - test('should extract import prelude', () => { - let source = '@import url("styles.css");' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('import') - expect(atrule.prelude).toBe('url("styles.css")') - }) - - test('should handle at-rule without prelude', () => { - let source = '@font-face { font-family: MyFont; }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('font-face') - expect(atrule.prelude).toBe(null) - }) - - test('should extract layer prelude', () => { - let source = '@layer utilities { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('layer') - expect(atrule.prelude).toBe('utilities') - }) - - test('should extract keyframes prelude', () => { - let source = '@keyframes slide-in { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('keyframes') - expect(atrule.prelude).toBe('slide-in') - }) - - test('should handle prelude with extra whitespace', () => { - let source = '@media (min-width: 768px) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('media') - expect(atrule.prelude).toBe('(min-width: 768px)') - }) - - test('should extract charset prelude', () => { - let source = '@charset "UTF-8";' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('charset') - expect(atrule.prelude).toBe('"UTF-8"') - }) - - test('should extract namespace prelude', () => { - let source = '@namespace svg url(http://www.w3.org/2000/svg);' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.name).toBe('namespace') - expect(atrule.prelude).toBe('svg url(http://www.w3.org/2000/svg)') - }) - - test('should value and prelude be aliases for at-rules', () => { - let source = '@media (min-width: 768px) { }' - let parser = new Parser(source) - let root = parser.parse() - - let atrule = root.first_child! - expect(atrule.value).toBe(atrule.prelude) - 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 deleted file mode 100644 index e6d3819..0000000 --- a/src/parser.ts +++ /dev/null @@ -1,627 +0,0 @@ -// CSS Parser - Builds AST using the arena -import { Lexer } from './lexer' -import { - CSSDataArena, - NODE_STYLESHEET, - NODE_STYLE_RULE, - NODE_SELECTOR, - NODE_SELECTOR_LIST, - NODE_DECLARATION, - NODE_AT_RULE, - NODE_BLOCK, - FLAG_IMPORTANT, - FLAG_HAS_BLOCK, - FLAG_VENDOR_PREFIXED, - FLAG_HAS_DECLARATIONS, -} from './arena' -import { CSSNode } from './css-node' -import { ValueParser } from './value-parser' -import { SelectorParser } from './selector-parser' -import { AtRulePreludeParser } from './at-rule-prelude-parser' -import { - TOKEN_EOF, - TOKEN_LEFT_BRACE, - TOKEN_RIGHT_BRACE, - TOKEN_COLON, - TOKEN_SEMICOLON, - TOKEN_IDENT, - TOKEN_DELIM, - TOKEN_AT_KEYWORD, -} from './token-types' -import { trim_boundaries, is_vendor_prefixed } from './string-utils' - -export interface ParserOptions { - skip_comments?: boolean - parse_values?: boolean - parse_selectors?: boolean - parse_atrule_preludes?: boolean -} - -// Static at-rule lookup sets for fast classification -let DECLARATION_AT_RULES = new Set(['font-face', 'font-feature-values', 'page', 'property', 'counter-style']) -let CONDITIONAL_AT_RULES = new Set(['media', 'supports', 'container', 'layer', 'nest']) - -export class Parser { - private source: string - private lexer: Lexer - private arena: CSSDataArena - private value_parser: ValueParser | null - private selector_parser: SelectorParser | null - private prelude_parser: AtRulePreludeParser | null - private parse_values_enabled: boolean - private parse_selectors_enabled: boolean - private parse_atrule_preludes_enabled: boolean - - constructor(source: string, options?: ParserOptions) { - this.source = source - - // Support legacy boolean parameter for backwards compatibility - let opts: ParserOptions = options || {} - - let skip_comments = opts.skip_comments ?? true - this.parse_values_enabled = opts.parse_values ?? true - this.parse_selectors_enabled = opts.parse_selectors ?? true - this.parse_atrule_preludes_enabled = opts.parse_atrule_preludes ?? true - - this.lexer = new Lexer(source, skip_comments) - // Calculate optimal capacity based on source size - let capacity = CSSDataArena.capacity_for_source(source.length) - this.arena = new CSSDataArena(capacity) - - // Only create parsers if needed - this.value_parser = this.parse_values_enabled ? new ValueParser(this.arena, source) : null - this.selector_parser = this.parse_selectors_enabled ? new SelectorParser(this.arena, source) : null - this.prelude_parser = this.parse_atrule_preludes_enabled ? new AtRulePreludeParser(this.arena, source) : null - } - - // Get the arena (for internal/advanced use only) - get_arena(): CSSDataArena { - return this.arena - } - - // Get the source code - get_source(): string { - return this.source - } - - // Advance to the next token, skipping whitespace - private next_token(): void { - this.lexer.next_token_fast(true) - } - - // Peek at current token type - private peek_type(): number { - return this.lexer.token_type - } - - // Check if we're at the end of input - private is_eof(): boolean { - return this.peek_type() === TOKEN_EOF - } - - // Parse the entire stylesheet and return the root CSSNode - parse(): CSSNode { - // Start by getting the first token - this.next_token() - - // Create the root stylesheet node - let stylesheet = this.arena.create_node() - this.arena.set_type(stylesheet, NODE_STYLESHEET) - this.arena.set_start_offset(stylesheet, 0) - this.arena.set_length(stylesheet, this.source.length) - this.arena.set_start_line(stylesheet, 1) - this.arena.set_start_column(stylesheet, 1) - - // Parse all rules at the top level - while (!this.is_eof()) { - let rule = this.parse_rule() - if (rule !== null) { - this.arena.append_child(stylesheet, rule) - } else { - // Skip unknown tokens - this.next_token() - } - } - - // Return wrapped node - return new CSSNode(this.arena, this.source, stylesheet) - } - - // Parse a rule (style rule or at-rule) - private parse_rule(): number | null { - if (this.is_eof()) { - return null - } - - // Check for at-rule - if (this.peek_type() === TOKEN_AT_KEYWORD) { - return this.parse_atrule() - } - - // Try to parse as style rule - return this.parse_style_rule() - } - - // Parse a style rule: selector { declarations } - private parse_style_rule(): number | null { - if (this.is_eof()) return null - - let rule_start = this.lexer.token_start - let rule_line = this.lexer.token_line - let rule_column = this.lexer.token_column - - // Create the style rule node - let style_rule = this.arena.create_node() - this.arena.set_type(style_rule, NODE_STYLE_RULE) - this.arena.set_start_line(style_rule, rule_line) - this.arena.set_start_column(style_rule, rule_column) - - // Parse selector (everything until '{') - let selector = this.parse_selector() - if (selector !== null) { - this.arena.append_child(style_rule, selector) - } - - // Expect '{' - if (this.peek_type() !== TOKEN_LEFT_BRACE) { - // 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() - if (token_type === TOKEN_RIGHT_BRACE) break - - // Check for nested at-rule - if (token_type === TOKEN_AT_KEYWORD) { - let nested_at_rule = this.parse_atrule() - if (nested_at_rule !== null) { - this.arena.append_child(block_node, nested_at_rule) - } else { - this.next_token() - } - continue - } - - // Try to parse as declaration first - let declaration = this.parse_declaration() - if (declaration !== null) { - this.arena.set_flag(style_rule, FLAG_HAS_DECLARATIONS) - 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(block_node, nested_rule) - } else { - // Skip unknown tokens - this.next_token() - } - } - - // 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, rule_end - rule_start) - - return style_rule - } - - // Parse a selector (everything before '{') - private parse_selector(): number | null { - if (this.is_eof()) return null - - let selector_start = this.lexer.token_start - let selector_line = this.lexer.token_line - let selector_column = this.lexer.token_column - - // Consume tokens until we hit '{' - let last_end = this.lexer.token_end - while (!this.is_eof() && this.peek_type() !== TOKEN_LEFT_BRACE) { - last_end = this.lexer.token_end - this.next_token() - } - - // If detailed selector parsing is enabled, use SelectorParser - if (this.parse_selectors_enabled && this.selector_parser) { - let selectorNode = this.selector_parser.parse_selector(selector_start, last_end, selector_line, selector_column) - if (selectorNode !== null) { - return selectorNode - } - } - - // Otherwise create a simple selector list node with just text offsets - let selector = this.arena.create_node() - this.arena.set_type(selector, NODE_SELECTOR_LIST) - this.arena.set_start_line(selector, selector_line) - this.arena.set_start_column(selector, selector_column) - this.arena.set_start_offset(selector, selector_start) - this.arena.set_length(selector, last_end - selector_start) - - return selector - } - - // Parse a declaration: property: value; - private parse_declaration(): number | null { - // Expect identifier (property name) - if (this.peek_type() !== TOKEN_IDENT) { - return null - } - - let prop_start = this.lexer.token_start - let prop_end = this.lexer.token_end - 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 ':' - - // Create declaration node - let declaration = this.arena.create_node() - this.arena.set_type(declaration, NODE_DECLARATION) - this.arena.set_start_line(declaration, decl_line) - this.arena.set_start_column(declaration, decl_column) - this.arena.set_start_offset(declaration, prop_start) - - // Store property name position - this.arena.set_content_start(declaration, prop_start) - this.arena.set_content_length(declaration, prop_end - prop_start) - - // Check for vendor prefix and set flag if detected - if (is_vendor_prefixed(this.source, prop_start, prop_end)) { - this.arena.set_flag(declaration, FLAG_VENDOR_PREFIXED) - } - - // Track value start (after colon, skipping whitespace) - let value_start = this.lexer.token_start - let value_end = value_start - - // Parse value (everything until ';' or '}') - let has_important = false - let last_end = this.lexer.token_end - - while (!this.is_eof()) { - 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 - value_end = this.lexer.token_start - // Check if next token is an identifier - let next_type = this.lexer.next_token_fast() - if (next_type === TOKEN_IDENT) { - has_important = true - last_end = this.lexer.token_end - this.next_token() // Advance to next token after "important" - break - } - } - - last_end = this.lexer.token_end - value_end = last_end - this.next_token() - } - - // Store value position (trimmed) and parse value nodes - let trimmed = trim_boundaries(this.source, value_start, value_end) - if (trimmed) { - // Store raw value string offsets (for fast string access) - this.arena.set_value_start(declaration, trimmed[0]) - this.arena.set_value_length(declaration, trimmed[1] - trimmed[0]) - - // Parse value into structured nodes (only if enabled) - if (this.parse_values_enabled && this.value_parser) { - let valueNodes = this.value_parser.parse_value(trimmed[0], trimmed[1]) - - // Link value nodes as children of the declaration - if (valueNodes.length > 0) { - this.arena.set_first_child(declaration, valueNodes[0]) - this.arena.set_last_child(declaration, valueNodes[valueNodes.length - 1]) - - // Chain value nodes as siblings - for (let i = 0; i < valueNodes.length - 1; i++) { - this.arena.set_next_sibling(valueNodes[i], valueNodes[i + 1]) - } - } - } - } - - // Set !important flag if found - if (has_important) { - this.arena.set_flag(declaration, FLAG_IMPORTANT) - } - - // Consume ';' if present - if (this.peek_type() === TOKEN_SEMICOLON) { - last_end = this.lexer.token_end - this.next_token() - } - - // Set declaration length - this.arena.set_length(declaration, last_end - prop_start) - - return declaration - } - - // Parse an at-rule: @media, @import, @font-face, etc. - private parse_atrule(): number | null { - if (this.peek_type() !== TOKEN_AT_KEYWORD) { - return null - } - - let at_rule_start = this.lexer.token_start - let at_rule_line = this.lexer.token_line - let at_rule_column = this.lexer.token_column - - // Extract at-rule name (skip the '@') - let at_rule_name = this.source.substring(this.lexer.token_start + 1, this.lexer.token_end) - let name_start = this.lexer.token_start + 1 - let name_length = at_rule_name.length - - this.next_token() // consume @keyword - - // Create at-rule node - let at_rule = this.arena.create_node() - this.arena.set_type(at_rule, NODE_AT_RULE) - this.arena.set_start_line(at_rule, at_rule_line) - this.arena.set_start_column(at_rule, at_rule_column) - this.arena.set_start_offset(at_rule, at_rule_start) - - // Store at-rule name in contentStart/contentLength - this.arena.set_content_start(at_rule, name_start) - this.arena.set_content_length(at_rule, name_length) - - // Track prelude start and end - let prelude_start = this.lexer.token_start - let prelude_end = prelude_start - - // Parse prelude (everything before '{' or ';') - while (!this.is_eof() && this.peek_type() !== TOKEN_LEFT_BRACE && this.peek_type() !== TOKEN_SEMICOLON) { - prelude_end = this.lexer.token_end - this.next_token() - } - - // Store prelude position (trimmed) - let trimmed = trim_boundaries(this.source, prelude_start, prelude_end) - if (trimmed) { - this.arena.set_value_start(at_rule, trimmed[0]) - this.arena.set_value_length(at_rule, trimmed[1] - trimmed[0]) - - // Parse prelude if enabled - if (this.prelude_parser) { - let prelude_nodes = this.prelude_parser.parse_prelude(at_rule_name, trimmed[0], trimmed[1], at_rule_line, at_rule_column) - for (let prelude_node of prelude_nodes) { - this.arena.append_child(at_rule, prelude_node) - } - } - } - - let last_end = this.lexer.token_end - - // 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) - - if (has_declarations) { - // Parse declarations only (like @font-face, @page) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let declaration = this.parse_declaration() - if (declaration !== null) { - this.arena.append_child(block_node, declaration) - } else { - this.next_token() - } - } - } else if (is_conditional) { - // Conditional at-rules can contain both declarations and rules (CSS Nesting) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - // Check for nested at-rule - if (token_type === TOKEN_AT_KEYWORD) { - let nested_at_rule = this.parse_atrule() - if (nested_at_rule !== null) { - this.arena.append_child(block_node, nested_at_rule) - } else { - this.next_token() - } - continue - } - - // Try to parse as declaration first - let declaration = this.parse_declaration() - if (declaration !== null) { - 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(block_node, nested_rule) - } else { - // Skip unknown tokens - this.next_token() - } - } - } else { - // Parse nested rules only (like @keyframes) - while (!this.is_eof()) { - let token_type = this.peek_type() - if (token_type === TOKEN_RIGHT_BRACE) break - - let rule = this.parse_rule() - if (rule !== null) { - this.arena.append_child(block_node, rule) - } else { - this.next_token() - } - } - } - - // Consume '}' (block excludes closing brace, but at-rule includes it) - if (this.peek_type() === TOKEN_RIGHT_BRACE) { - 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 - this.next_token() // consume ';' - } - - // Set at-rule length - this.arena.set_length(at_rule, last_end - at_rule_start) - - return at_rule - } - - // Determine if an at-rule contains declarations or nested rules - private atrule_has_declarations(name: string): boolean { - return DECLARATION_AT_RULES.has(name.toLowerCase()) - } - - // Determine if an at-rule is conditional (can contain both declarations and rules in CSS Nesting) - private atrule_is_conditional(name: string): boolean { - return CONDITIONAL_AT_RULES.has(name.toLowerCase()) - } -} - -// Re-export node type constants so consumers don't need to import from arena -export { - NODE_STYLESHEET, - NODE_STYLE_RULE, - NODE_AT_RULE, - NODE_DECLARATION, - NODE_SELECTOR, - NODE_COMMENT, - NODE_BLOCK, - NODE_VALUE_KEYWORD, - NODE_VALUE_NUMBER, - NODE_VALUE_DIMENSION, - NODE_VALUE_STRING, - NODE_VALUE_COLOR, - NODE_VALUE_FUNCTION, - NODE_VALUE_OPERATOR, - NODE_SELECTOR_LIST, - NODE_SELECTOR_TYPE, - NODE_SELECTOR_CLASS, - NODE_SELECTOR_ID, - NODE_SELECTOR_ATTRIBUTE, - NODE_SELECTOR_PSEUDO_CLASS, - NODE_SELECTOR_PSEUDO_ELEMENT, - 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, - NODE_PRELUDE_CONTAINER_QUERY, - NODE_PRELUDE_SUPPORTS_QUERY, - NODE_PRELUDE_LAYER_NAME, - NODE_PRELUDE_IDENTIFIER, - NODE_PRELUDE_OPERATOR, - NODE_PRELUDE_IMPORT_URL, - NODE_PRELUDE_IMPORT_LAYER, - NODE_PRELUDE_IMPORT_SUPPORTS, - FLAG_IMPORTANT, -} from './arena' diff --git a/src/selector-parser.test.ts b/src/selector-parser.test.ts deleted file mode 100644 index efa49cf..0000000 --- a/src/selector-parser.test.ts +++ /dev/null @@ -1,1145 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { SelectorParser } from './selector-parser' -import { CSSDataArena } from './arena' -import { - NODE_SELECTOR, - NODE_SELECTOR_LIST, - NODE_SELECTOR_TYPE, - NODE_SELECTOR_CLASS, - NODE_SELECTOR_ID, - NODE_SELECTOR_ATTRIBUTE, - NODE_SELECTOR_PSEUDO_CLASS, - NODE_SELECTOR_PSEUDO_ELEMENT, - 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) { - const arena = new CSSDataArena(256) - const parser = new SelectorParser(arena, selector) - const rootNode = parser.parse_selector(0, selector.length) - return { arena, rootNode, source: selector } -} - -// Helper to get node text -function getNodeText(arena: CSSDataArena, source: string, nodeIndex: number): string { - const start = arena.get_start_offset(nodeIndex) - const length = arena.get_length(nodeIndex) - return source.substring(start, start + length) -} - -// Helper to get node content (name) -function getNodeContent(arena: CSSDataArena, source: string, nodeIndex: number): string { - const start = arena.get_content_start(nodeIndex) - const length = arena.get_content_length(nodeIndex) - return source.substring(start, start + length) -} - -// Helper to get all children -function getChildren(arena: CSSDataArena, source: string, nodeIndex: number | null) { - if (nodeIndex === null) return [] - const children: number[] = [] - let child = arena.get_first_child(nodeIndex) - while (child !== 0) { - children.push(child) - child = arena.get_next_sibling(child) - } - return children -} - -describe('SelectorParser', () => { - describe('Simple selectors', () => { - it('should parse type selector', () => { - const { arena, rootNode, source } = parseSelector('div') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - expect(getNodeText(arena, source, rootNode)).toBe('div') - - // First child is NODE_SELECTOR wrapper - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - // First child of wrapper is the actual type - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_TYPE) - expect(getNodeText(arena, source, child)).toBe('div') - }) - - it('should parse class selector', () => { - const { arena, rootNode, source } = parseSelector('.my-class') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) - expect(getNodeText(arena, source, child)).toBe('.my-class') - expect(getNodeContent(arena, source, child)).toBe('my-class') - }) - - it('should parse ID selector', () => { - const { arena, rootNode, source } = parseSelector('#my-id') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ID) - expect(getNodeText(arena, source, child)).toBe('#my-id') - expect(getNodeContent(arena, source, child)).toBe('my-id') - }) - - it('should parse universal selector', () => { - const { arena, rootNode, source } = parseSelector('*') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_UNIVERSAL) - expect(getNodeText(arena, source, child)).toBe('*') - }) - - it('should parse nesting selector', () => { - const { arena, rootNode, source } = parseSelector('&') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_NESTING) - expect(getNodeText(arena, source, child)).toBe('&') - }) - }) - - describe('Compound selectors', () => { - it('should parse element with class', () => { - const { arena, rootNode, source } = parseSelector('div.container') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - // Get the NODE_SELECTOR wrapper - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - // Compound selector has multiple children - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(getNodeText(arena, source, children[0])).toBe('div') - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('container') - }) - - it('should parse element with ID', () => { - const { arena, rootNode, source } = parseSelector('div#app') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_ID) - expect(getNodeContent(arena, source, children[1])).toBe('app') - }) - - it('should parse element with multiple classes', () => { - const { arena, rootNode, source } = parseSelector('div.foo.bar.baz') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(4) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('foo') - expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, children[2])).toBe('bar') - expect(arena.get_type(children[3])).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, children[3])).toBe('baz') - }) - - it('should parse complex compound selector', () => { - const { arena, rootNode, source } = parseSelector('div.container#app') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(3) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(getNodeText(arena, source, children[0])).toBe('div') - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('container') - expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_ID) - expect(getNodeContent(arena, source, children[2])).toBe('app') - }) - }) - - describe('Pseudo-classes', () => { - it('should parse simple pseudo-class', () => { - const { arena, rootNode, source } = parseSelector('a:hover') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('hover') - }) - - it('should parse pseudo-class with function', () => { - const { arena, rootNode, source } = parseSelector('li:nth-child(2n+1)') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('nth-child') - expect(getNodeText(arena, source, children[1])).toBe(':nth-child(2n+1)') - }) - - it('should parse multiple pseudo-classes', () => { - const { arena, rootNode, source } = parseSelector('input:focus:valid') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(3) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('focus') - expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[2])).toBe('valid') - }) - - it('should parse :is() pseudo-class', () => { - const { arena, rootNode, source } = parseSelector('a:is(.active)') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - 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('is') - }) - - it('should parse :not() pseudo-class', () => { - const { arena, rootNode, source } = parseSelector('div:not(.disabled)') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - 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('not') - }) - }) - - describe('Pseudo-elements', () => { - it('should parse pseudo-element with double colon', () => { - const { arena, rootNode, source } = parseSelector('p::before') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_ELEMENT) - expect(getNodeContent(arena, source, children[1])).toBe('before') - }) - - it('should parse pseudo-element with single colon (legacy)', () => { - const { arena, rootNode, source } = parseSelector('p:after') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, children[1])).toBe('after') - }) - - it('should parse ::first-line pseudo-element', () => { - const { arena, rootNode, source } = parseSelector('p::first-line') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - 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_ELEMENT) - expect(getNodeContent(arena, source, children[1])).toBe('first-line') - }) - }) - - describe('Attribute selectors', () => { - it('should parse simple attribute selector', () => { - const { arena, rootNode, source } = parseSelector('[disabled]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeText(arena, source, child)).toBe('[disabled]') - expect(getNodeContent(arena, source, child)).toBe('disabled') - }) - - it('should parse attribute with value', () => { - const { arena, rootNode, source } = parseSelector('[type="text"]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeText(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', () => { - const { arena, rootNode, source } = parseSelector('[class^="btn-"]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - expect(getNodeText(arena, source, child)).toBe('[class^="btn-"]') - }) - - it('should parse element with attribute', () => { - const { arena, rootNode, source } = parseSelector('input[type="checkbox"]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_TYPE) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_ATTRIBUTE) - }) - - it('should trim whitespace from attribute selectors', () => { - const { arena, rootNode, source } = parseSelector('[ data-test="value" ]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - // 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', () => { - const { arena, rootNode, source } = parseSelector('[/* comment */data-test="value"/* test */]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - // Content now stores just the attribute name - expect(getNodeContent(arena, source, child)).toBe('data-test') - }) - - it('should trim whitespace and comments from attribute selectors', () => { - const { arena, rootNode, source } = parseSelector('[/* comment */ data-test="value" /* test */]') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_ATTRIBUTE) - // Content now stores just the attribute name - expect(getNodeContent(arena, source, child)).toBe('data-test') - }) - }) - - describe('Combinators', () => { - it('should parse descendant combinator (space)', () => { - const { arena, rootNode, source } = parseSelector('div p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children.length).toBeGreaterThanOrEqual(2) - - // Should have: compound(div), combinator(space), compound(p) - const hasDescendantCombinator = children.some((child) => { - const type = arena.get_type(child) - return type === NODE_SELECTOR_COMBINATOR - }) - expect(hasDescendantCombinator).toBe(true) - }) - - it('should parse child combinator (>)', () => { - const { arena, rootNode, source } = parseSelector('div > p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === NODE_SELECTOR_COMBINATOR) { - return getNodeText(arena, source, child).includes('>') - } - return false - }) - expect(hasCombinator).toBe(true) - }) - - it('should parse adjacent sibling combinator (+)', () => { - const { arena, rootNode, source } = parseSelector('h1 + p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === NODE_SELECTOR_COMBINATOR) { - return getNodeText(arena, source, child).includes('+') - } - return false - }) - expect(hasCombinator).toBe(true) - }) - - it('should parse general sibling combinator (~)', () => { - const { arena, rootNode, source } = parseSelector('h1 ~ p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === NODE_SELECTOR_COMBINATOR) { - return getNodeText(arena, source, child).includes('~') - } - return false - }) - expect(hasCombinator).toBe(true) - }) - }) - - describe('Selector lists (comma-separated)', () => { - it('should parse selector list with two selectors', () => { - const { arena, rootNode, source } = parseSelector('div, p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - // List contains the two selectors - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(2) - }) - - it('should parse selector list with three selectors', () => { - const { arena, rootNode, source } = parseSelector('h1, h2, h3') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - // List contains the three selectors - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(3) - }) - - it('should parse complex selector list', () => { - const { arena, rootNode, source } = parseSelector('div.container, .wrapper > p, #app') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - // List contains 3 NODE_SELECTOR wrappers: div.container, .wrapper > p, #app - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(3) - }) - }) - - describe('Complex selectors', () => { - it('should parse navigation selector', () => { - const { arena, rootNode } = parseSelector('nav > ul > li > a') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - }) - - it('should parse form selector', () => { - const { arena, rootNode } = parseSelector('form input[type="text"]:focus') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Should parse without errors - expect(arena.get_type(rootNode)).toBeDefined() - }) - - it('should parse complex nesting selector', () => { - const { arena, rootNode } = parseSelector('.parent .child:hover::before') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - expect(arena.get_type(rootNode)).toBeDefined() - }) - - it('should parse multiple combinators', () => { - const { arena, rootNode, source } = parseSelector('div > .container + p ~ span') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - - const combinators = children.filter((child) => { - return arena.get_type(child) === NODE_SELECTOR_COMBINATOR - }) - - expect(combinators.length).toBeGreaterThan(0) - }) - }) - - describe('Modern CSS selectors', () => { - it('should parse :where() pseudo-class', () => { - const { arena, rootNode, source } = parseSelector(':where(article, section)') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_PSEUDO_CLASS) - expect(getNodeContent(arena, source, child)).toBe('where') - }) - - it('should parse :has(a) pseudo-class', () => { - const root = parse_selector('div:has(a)') - - expect(root.first_child?.type).toBe(NODE_SELECTOR) - expect(root.first_child!.children).toHaveLength(2) - const [_, has] = root.first_child!.children - - 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', () => { - const { arena, rootNode, source } = parseSelector('&.active') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - 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', () => { - it('should parse selector with multiple spaces', () => { - const { arena, rootNode, source } = parseSelector('div p') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Should collapse multiple spaces into single combinator - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children.length).toBeGreaterThan(0) - }) - - it('should parse selector with tabs and newlines', () => { - const { arena, rootNode, source } = parseSelector('div\t\n\tp') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const children = getChildren(arena, source, rootNode) - expect(children.length).toBeGreaterThan(0) - }) - - it('should handle empty selector gracefully', () => { - const { rootNode } = parseSelector('') - - // Empty selector returns null - expect(rootNode).toBeNull() - }) - - it('should parse class with dashes and numbers', () => { - const { arena, rootNode, source } = parseSelector('.my-class-123') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, child)).toBe('my-class-123') - }) - - it('should parse hyphenated element names', () => { - const { arena, rootNode, source } = parseSelector('custom-element') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_TYPE) - expect(getNodeText(arena, source, child)).toBe('custom-element') - }) - }) - - describe('Real-world selectors', () => { - it('should parse BEM selector', () => { - const { arena, rootNode, source } = parseSelector('.block__element--modifier') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(NODE_SELECTOR_LIST) - - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(NODE_SELECTOR) - - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NODE_SELECTOR_CLASS) - expect(getNodeContent(arena, source, child)).toBe('block__element--modifier') - }) - - it('should parse Bootstrap-style selector', () => { - const { arena, rootNode, source } = parseSelector('.btn.btn-primary.btn-lg') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(3) - expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_CLASS) - expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_CLASS) - expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_CLASS) - }) - - it('should parse table selector', () => { - const { arena, rootNode } = parseSelector('table tbody tr:nth-child(odd) td') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Should parse without errors - expect(arena.get_type(rootNode)).toBeDefined() - }) - - it('should parse nth-of-type selector', () => { - const { arena, rootNode, source } = parseSelector('p:nth-of-type(3)') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - 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('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 deleted file mode 100644 index af4ae4e..0000000 --- a/src/selector-parser.ts +++ /dev/null @@ -1,1034 +0,0 @@ -// Selector Parser - Parses CSS selectors into structured AST nodes -import { Lexer } from './lexer' -import type { CSSDataArena } from './arena' -import { - NODE_SELECTOR, - NODE_SELECTOR_LIST, - NODE_SELECTOR_TYPE, - NODE_SELECTOR_CLASS, - NODE_SELECTOR_ID, - NODE_SELECTOR_ATTRIBUTE, - NODE_SELECTOR_PSEUDO_CLASS, - NODE_SELECTOR_PSEUDO_ELEMENT, - 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, - TOKEN_HASH, - TOKEN_DELIM, - TOKEN_COLON, - TOKEN_COMMA, - TOKEN_LEFT_BRACKET, - TOKEN_RIGHT_BRACKET, - TOKEN_FUNCTION, - TOKEN_LEFT_PAREN, - 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 - private arena: CSSDataArena - private source: string - private selector_end: number - - constructor(arena: CSSDataArena, source: string) { - this.arena = arena - this.source = source - // Create a lexer instance for selector parsing - this.lexer = new Lexer(source, false) - this.selector_end = 0 - } - - // 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, allow_relative: boolean = false): number | null { - this.selector_end = end - - // Position lexer at selector start - this.lexer.pos = start - this.lexer.line = line - this.lexer.column = column - - // Parse selector list (comma-separated selectors) - // Returns NODE_SELECTOR_LIST directly (no wrapper) - return this.parse_selector_list(allow_relative) - } - - // Parse comma-separated selectors - 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 - let list_column = this.lexer.column - - while (this.lexer.pos < this.selector_end) { - let selector_start = this.lexer.pos - let selector_line = this.lexer.line - let selector_column = this.lexer.column - - 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() - this.arena.set_type(selector_wrapper, NODE_SELECTOR) - this.arena.set_start_offset(selector_wrapper, selector_start) - this.arena.set_length(selector_wrapper, this.lexer.pos - selector_start) - this.arena.set_start_line(selector_wrapper, selector_line) - this.arena.set_start_column(selector_wrapper, selector_column) - - // Find the last component in the chain - let last_component = complex_selector - while (this.arena.get_next_sibling(last_component) !== 0) { - last_component = this.arena.get_next_sibling(last_component) - } - - // Set the complex selector chain as children - this.arena.set_first_child(selector_wrapper, complex_selector) - this.arena.set_last_child(selector_wrapper, last_component) - - selectors.push(selector_wrapper) - } - - // Check for comma (selector separator) - this.skip_whitespace() - if (this.lexer.pos >= this.selector_end) break - - this.lexer.next_token_fast(false) - let token_type = this.lexer.token_type - if (token_type === TOKEN_COMMA) { - this.skip_whitespace() - continue - } else { - // No more selectors - break - } - } - - // Always wrap in selector list node, even for single selectors - if (selectors.length >= 1) { - let list_node = this.arena.create_node() - this.arena.set_type(list_node, NODE_SELECTOR_LIST) - this.arena.set_start_offset(list_node, list_start) - this.arena.set_length(list_node, this.lexer.pos - list_start) - this.arena.set_start_line(list_node, list_line) - this.arena.set_start_column(list_node, list_column) - - // Link selector wrapper nodes as children - this.arena.set_first_child(list_node, selectors[0]) - this.arena.set_last_child(list_node, selectors[selectors.length - 1]) - - // Chain selector wrappers as siblings (simple since they're already wrapped) - for (let i = 0; i < selectors.length - 1; i++) { - this.arena.set_next_sibling(selectors[i], selectors[i + 1]) - } - - return list_node - } - - return null - } - - // Parse a complex selector (with combinators) - // e.g., "div.class > p + span" - 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 - - // Parse compound selector first - let compound = this.parse_compound_selector() - if (compound !== null) { - components.push(compound) - } else { - break - } - - // After a compound selector, check if there's a combinator - let combinator = this.try_parse_combinator() - if (combinator !== null) { - components.push(combinator) - // Skip whitespace after combinator before next compound - this.skip_whitespace() - continue - } - - // Peek ahead for comma or end - 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 - - this.lexer.next_token_fast(false) - 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 = saved_pos - this.lexer.line = saved_line - this.lexer.column = saved_column - break - } - // Reset for next iteration - 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 (need to find last node in each compound selector chain) - for (let i = 0; i < components.length - 1; i++) { - // 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) - return components[0] - } - - // Parse a compound selector (no combinators) - // e.g., "div.class#id[attr]:hover" - private parse_compound_selector(): number | null { - let parts: number[] = [] - - while (this.lexer.pos < this.selector_end) { - // 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 - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - - let part = this.parse_simple_selector() - if (part !== null) { - parts.push(part) - } else { - // 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 - } - } - - if (parts.length === 0) return null - - // Chain parts as siblings - for (let i = 0; i < parts.length - 1; i++) { - this.arena.set_next_sibling(parts[i], parts[i + 1]) - } - - // Return first part (others are chained as siblings) - return parts[0] - } - - // Parse a simple selector (single component) - private parse_simple_selector(): number | null { - let token_type = this.lexer.token_type - let start = this.lexer.token_start - let end = this.lexer.token_end - - switch (token_type) { - case TOKEN_IDENT: - // Type selector: div, span, p - return this.create_type_selector(start, end) - - case TOKEN_HASH: - // ID selector: #id - return this.create_id_selector(start, end) - - case TOKEN_DELIM: - // Could be: . (class), * (universal), & (nesting) - let ch = this.source.charCodeAt(start) - if (ch === 0x2e) { - // . - class selector - return this.parse_class_selector(start) - } else if (ch === 0x2a) { - // * - universal selector - return this.create_universal_selector(start, end) - } else if (ch === 0x26) { - // & - nesting selector - return this.create_nesting_selector(start, end) - } - // Other delimiters signal end of selector - return null - - case TOKEN_LEFT_BRACKET: - // Attribute selector: [attr], [attr=value] - return this.parse_attribute_selector(start) - - case TOKEN_COLON: - // Pseudo-class or pseudo-element: :hover, ::before - return this.parse_pseudo(start) - - case TOKEN_FUNCTION: - // Pseudo-class function: :nth-child(), :is() - return this.parse_pseudo_function(start, end) - - case TOKEN_WHITESPACE: - case TOKEN_COMMA: - // These signal end of compound selector - return null - - default: - return null - } - } - - // Parse combinator (>, +, ~, or descendant space) - private try_parse_combinator(): number | null { - let whitespace_start = this.lexer.pos - let has_whitespace = false - - // Skip whitespace and check for combinator - while (this.lexer.pos < this.selector_end) { - let ch = this.source.charCodeAt(this.lexer.pos) - if (is_whitespace_char(ch)) { - has_whitespace = true - this.lexer.pos++ - } else { - break - } - } - - if (this.lexer.pos >= this.selector_end) return null - - this.lexer.next_token_fast(false) - - // Check for explicit combinators - if (this.lexer.token_type === TOKEN_DELIM) { - let ch = this.source.charCodeAt(this.lexer.token_start) - if (ch === 0x3e || ch === 0x2b || ch === 0x7e) { - // > + ~ (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 = whitespace_start - while (this.lexer.pos < this.selector_end) { - let ch = this.source.charCodeAt(this.lexer.pos) - if (is_whitespace_char(ch)) { - this.lexer.pos++ - } else { - break - } - } - return this.create_combinator(whitespace_start, this.lexer.pos) - } - - // No combinator found, reset position - 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 - } - - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_CLASS) - this.arena.set_start_offset(node, dot_pos) - this.arena.set_length(node, this.lexer.token_end - dot_pos) - this.arena.set_start_line(node, this.lexer.line) - this.arena.set_start_column(node, this.lexer.column) - // Content is the class name (without the dot) - this.arena.set_content_start(node, this.lexer.token_start) - this.arena.set_content_length(node, this.lexer.token_end - this.lexer.token_start) - return node - } - - // Parse attribute selector ([attr], [attr=value], etc.) - 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) { - this.lexer.next_token_fast(false) - let token_type = this.lexer.token_type - if (token_type === TOKEN_LEFT_BRACKET) { - bracket_depth++ - } 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 - } - } - } - - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_ATTRIBUTE) - this.arena.set_start_offset(node, start) - 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) - - // 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) { - is_pseudo_element = true - this.lexer.pos++ // skip second colon - } - - // Next token should be identifier or function - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_IDENT) { - let node = this.arena.create_node() - this.arena.set_type(node, is_pseudo_element ? NODE_SELECTOR_PSEUDO_ELEMENT : NODE_SELECTOR_PSEUDO_CLASS) - this.arena.set_start_offset(node, start) - this.arena.set_length(node, this.lexer.token_end - start) - this.arena.set_start_line(node, this.lexer.line) - this.arena.set_start_column(node, this.lexer.column) - // Content is the pseudo name (without colons) - this.arena.set_content_start(node, this.lexer.token_start) - this.arena.set_content_length(node, this.lexer.token_end - this.lexer.token_start) - // Check for vendor prefix and set flag if detected - if (is_vendor_prefixed(this.source, this.lexer.token_start, this.lexer.token_end)) { - this.arena.set_flag(node, FLAG_VENDOR_PREFIXED) - } - return node - } else if (token_type === TOKEN_FUNCTION) { - // Pseudo-class function like :nth-child() - 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 - } - - // Parse pseudo-class function (:nth-child(), :is(), etc.) - private parse_pseudo_function(_start: number, _end: number): number | null { - // This should not be called in current flow, but keep for completeness - return null - } - - // Parse pseudo-class function after we've seen the colon - private parse_pseudo_function_after_colon(start: number, is_pseudo_element: boolean): number | null { - 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 - - 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 || 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 - } - } - } - - let node = this.arena.create_node() - this.arena.set_type(node, is_pseudo_element ? NODE_SELECTOR_PSEUDO_ELEMENT : NODE_SELECTOR_PSEUDO_CLASS) - this.arena.set_start_offset(node, start) - 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 the function name (without colons and parentheses) - this.arena.set_content_start(node, func_name_start) - this.arena.set_content_length(node, func_name_end - func_name_start) - // Check for vendor prefix and set flag if detected - 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() - this.arena.set_type(node, NODE_SELECTOR_TYPE) - this.arena.set_start_offset(node, start) - 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) - this.arena.set_content_start(node, start) - this.arena.set_content_length(node, end - start) - return node - } - - private create_id_selector(start: number, end: number): number { - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_ID) - this.arena.set_start_offset(node, start) - 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 the ID name (without the #) - this.arena.set_content_start(node, start + 1) - this.arena.set_content_length(node, end - start - 1) - return node - } - - private create_universal_selector(start: number, end: number): number { - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_UNIVERSAL) - this.arena.set_start_offset(node, start) - 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) - this.arena.set_content_start(node, start) - this.arena.set_content_length(node, end - start) - return node - } - - private create_nesting_selector(start: number, end: number): number { - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_NESTING) - this.arena.set_start_offset(node, start) - 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) - this.arena.set_content_start(node, start) - this.arena.set_content_length(node, end - start) - return node - } - - private create_combinator(start: number, end: number): number { - let node = this.arena.create_node() - this.arena.set_type(node, NODE_SELECTOR_COMBINATOR) - this.arena.set_start_offset(node, start) - 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) - this.arena.set_content_start(node, start) - this.arena.set_content_length(node, end - start) - return node - } - - // Helper to skip whitespace - private skip_whitespace(): void { - while (this.lexer.pos < this.selector_end) { - let ch = this.source.charCodeAt(this.lexer.pos) - if (is_whitespace_char(ch)) { - this.lexer.pos++ - } else { - break - } - } - } -} diff --git a/src/stylerule-structure.test.ts b/src/stylerule-structure.test.ts index a31b833..63155c4 100644 --- a/src/stylerule-structure.test.ts +++ b/src/stylerule-structure.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest' -import { Parser } from './parser' +import { Parser } from './parse' import { NODE_STYLE_RULE, NODE_SELECTOR_LIST, NODE_DECLARATION, NODE_AT_RULE } from './arena' describe('StyleRule Structure', () => { @@ -105,10 +105,10 @@ describe('StyleRule Structure', () => { 'body { color: red; }', 'div { margin: 0; padding: 10px; }', 'h1 { color: blue; .nested { margin: 0; } }', - 'p { font-size: 16px; @media print { display: none; } }' + 'p { font-size: 16px; @media print { display: none; } }', ] - testCases.forEach(source => { + testCases.forEach((source) => { const parser = new Parser(source) const root = parser.parse() const rule = root.first_child! diff --git a/src/value-parser.test.ts b/src/value-parser.test.ts index a324641..4a27545 100644 --- a/src/value-parser.test.ts +++ b/src/value-parser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { Parser } from './parser' +import { Parser } from './parse' import { NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, diff --git a/src/walk.test.ts b/src/walk.test.ts index aa3bb33..44e6965 100644 --- a/src/walk.test.ts +++ b/src/walk.test.ts @@ -10,7 +10,7 @@ import { NODE_VALUE_KEYWORD, NODE_VALUE_NUMBER, NODE_VALUE_DIMENSION, -} from './parser' +} from './parse' import { walk, walk_enter_leave } from './walk' describe('walk', () => {