From f7cba5cbf8ba3bcc44eea426c65739aa40d7ca90 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 16 Dec 2025 23:16:07 +0100 Subject: [PATCH] chore: reorganise tests --- src/{css-node.test.ts => api.test.ts} | 0 src/column-tracking.test.ts | 124 - src/parse-anplusb.test.ts | 244 -- src/parse-atrule-prelude.test.ts | 1600 ++++++---- src/parse-selector.test.ts | 3505 ++++++++++++--------- src/parse-value.test.ts | 1057 ++++--- src/parse.test.ts | 4150 ++++++++++++++----------- src/stylerule-structure.test.ts | 399 --- 8 files changed, 5910 insertions(+), 5169 deletions(-) rename src/{css-node.test.ts => api.test.ts} (100%) delete mode 100644 src/column-tracking.test.ts delete mode 100644 src/parse-anplusb.test.ts delete mode 100644 src/stylerule-structure.test.ts diff --git a/src/css-node.test.ts b/src/api.test.ts similarity index 100% rename from src/css-node.test.ts rename to src/api.test.ts diff --git a/src/column-tracking.test.ts b/src/column-tracking.test.ts deleted file mode 100644 index ebb5889..0000000 --- a/src/column-tracking.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { parse } from './parse' -import { STYLE_RULE, DECLARATION, AT_RULE, SELECTOR_LIST } from './constants' - -describe('Column Tracking', () => { - test('should track column for single-line CSS', () => { - const css = 'body { color: red; }' - const ast = parse(css) - - // Stylesheet should start at line 1, column 1 - expect(ast.line).toBe(1) - expect(ast.column).toBe(1) - - // First rule (body) - const rule = ast.first_child - expect(rule).not.toBeNull() - expect(rule!.type).toBe(STYLE_RULE) - expect(rule!.line).toBe(1) - expect(rule!.column).toBe(1) - - // Selector (body) - const selector = rule!.first_child - expect(selector).not.toBeNull() - expect(selector!.type).toBe(SELECTOR_LIST) - expect(selector!.line).toBe(1) - expect(selector!.column).toBe(1) - - // Declaration (color: red) - const block = selector!.next_sibling - const decl = block!.first_child - expect(decl).not.toBeNull() - expect(decl!.type).toBe(DECLARATION) - expect(decl!.line).toBe(1) - expect(decl!.column).toBe(8) - }) - - test('should track column across multiple lines', () => { - const css = `body { - color: red; - font-size: 16px; -}` - - const ast = parse(css) - const rule = ast.first_child! - const selector = rule.first_child! - const block = selector.next_sibling! - - // First declaration (color: red) at line 2, column 3 - const decl1 = block.first_child! - expect(decl1.type).toBe(DECLARATION) - expect(decl1.line).toBe(2) - expect(decl1.column).toBe(3) - - // Second declaration (font-size: 16px) at line 3, column 3 - const decl2 = decl1.next_sibling! - expect(decl2.type).toBe(DECLARATION) - expect(decl2.line).toBe(3) - expect(decl2.column).toBe(3) - }) - - test('should track column for at-rules', () => { - const css = '@media screen { body { color: blue; } }' - const ast = parse(css) - - // At-rule should start at column 1 - const atRule = ast.first_child! - expect(atRule.type).toBe(AT_RULE) - expect(atRule.line).toBe(1) - expect(atRule.column).toBe(1) - - // Get the block, then find the nested style rule - const block = atRule.block! - let nestedRule = block.first_child - while (nestedRule && nestedRule.type !== STYLE_RULE) { - nestedRule = nestedRule.next_sibling - } - - expect(nestedRule).not.toBeNull() - expect(nestedRule!.type).toBe(STYLE_RULE) - expect(nestedRule!.line).toBe(1) - // Column 17 is where 'body' starts (beginning of selector) - expect(nestedRule!.column).toBe(17) - }) - - test('should track column for multiple rules on same line', () => { - const css = 'a { color: red; } b { color: blue; }' - const ast = parse(css) - - // First rule at column 1 - const rule1 = ast.first_child! - expect(rule1.type).toBe(STYLE_RULE) - expect(rule1.line).toBe(1) - expect(rule1.column).toBe(1) - - // Second rule at column 19 - const rule2 = rule1.next_sibling! - expect(rule2.type).toBe(STYLE_RULE) - expect(rule2.line).toBe(1) - expect(rule2.column).toBe(19) - }) - - test('should track column with leading whitespace', () => { - const css = ' body { color: red; }' - const ast = parse(css) - - // Rule should start at column 5 (after 4 spaces) - const rule = ast.first_child! - expect(rule.type).toBe(STYLE_RULE) - expect(rule.line).toBe(1) - expect(rule.column).toBe(5) - }) - - test('should track column after comments', () => { - // Test with comments skipped (default) - const css1 = '/* comment */ body { color: red; }' - const ast1 = parse(css1) - - // Rule should start at column 15 (after comment and space) - const rule = ast1.first_child! - expect(rule.type).toBe(STYLE_RULE) - expect(rule.line).toBe(1) - expect(rule.column).toBe(15) - }) -}) diff --git a/src/parse-anplusb.test.ts b/src/parse-anplusb.test.ts deleted file mode 100644 index 8029ac5..0000000 --- a/src/parse-anplusb.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { ANplusBParser } from './parse-anplusb' -import { CSSDataArena, NTH_SELECTOR } from './arena' -import { CSSNode } from './css-node' - -// Helper to parse An+B expression -function parse_anplusb(expr: string): CSSNode | null { - const arena = new CSSDataArena(64) - const parser = new ANplusBParser(arena, expr) - const nodeIndex = parser.parse_anplusb(0, expr.length) - - if (nodeIndex === null) return null - return new CSSNode(arena, expr, nodeIndex) -} - -describe('ANplusBParser', () => { - describe('Simple integers (b only)', () => { - it('should parse positive integer', () => { - const node = parse_anplusb('3')! - expect(node).not.toBeNull() - expect(node.type).toBe(NTH_SELECTOR) - expect(node.nth_a).toBe(null) - expect(node.nth_b).toBe('3') - expect(node.text).toBe('3') - }) - - it('should parse negative integer', () => { - const node = parse_anplusb('-5')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe(null) - expect(node.nth_b).toBe('-5') - }) - - it('should parse zero', () => { - const node = parse_anplusb('0')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe(null) - expect(node.nth_b).toBe('0') - }) - }) - - describe('Keywords', () => { - it('should parse odd keyword', () => { - const node = parse_anplusb('odd')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('odd') - expect(node.nth_b).toBe(null) - }) - - it('should parse even keyword', () => { - const node = parse_anplusb('even')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('even') - expect(node.nth_b).toBe(null) - }) - - it('should parse ODD (case-insensitive)', () => { - const node = parse_anplusb('ODD')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('ODD') - expect(node.nth_b).toBe(null) - }) - - it('should parse EVEN (case-insensitive)', () => { - const node = parse_anplusb('EVEN')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('EVEN') - expect(node.nth_b).toBe(null) - }) - }) - - describe('Just n (a only)', () => { - it('should parse n', () => { - const node = parse_anplusb('n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe(null) - }) - - it('should parse +n', () => { - const node = parse_anplusb('+n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('+n') - expect(node.nth_b).toBe(null) - }) - - it('should parse -n', () => { - const node = parse_anplusb('-n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('-n') - expect(node.nth_b).toBe(null) - }) - }) - - describe('Dimension tokens (An)', () => { - it('should parse 2n', () => { - const node = parse_anplusb('2n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe(null) - }) - - it('should parse -3n', () => { - const node = parse_anplusb('-3n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('-3n') - expect(node.nth_b).toBe(null) - }) - - it('should parse +5n', () => { - const node = parse_anplusb('+5n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('+5n') - expect(node.nth_b).toBe(null) - }) - - it('should parse 10n', () => { - const node = parse_anplusb('10n')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('10n') - expect(node.nth_b).toBe(null) - }) - }) - - describe('An+B expressions', () => { - it('should parse 2n+1', () => { - const node = parse_anplusb('2n+1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('+1') - }) - - it('should parse 3n+5', () => { - const node = parse_anplusb('3n+5')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('3n') - expect(node.nth_b).toBe('+5') - }) - - it('should parse n+0', () => { - const node = parse_anplusb('n+0')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe('+0') - }) - - it('should parse -n+3', () => { - const node = parse_anplusb('-n+3')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('-n') - expect(node.nth_b).toBe('+3') - }) - }) - - describe('An-B expressions', () => { - it('should parse 2n-1', () => { - const node = parse_anplusb('2n-1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('-1') - }) - - it('should parse 3n-5', () => { - const node = parse_anplusb('3n-5')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('3n') - expect(node.nth_b).toBe('-5') - }) - - it('should parse n-2', () => { - const node = parse_anplusb('n-2')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe('-2') - }) - - it('should parse -n-1', () => { - const node = parse_anplusb('-n-1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('-n') - expect(node.nth_b).toBe('-1') - }) - - it('should parse -2n-3', () => { - const node = parse_anplusb('-2n-3')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('-2n') - expect(node.nth_b).toBe('-3') - }) - }) - - describe('Whitespace handling', () => { - it('should parse 2n + 1 with spaces', () => { - const node = parse_anplusb('2n + 1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('+1') - }) - - it('should parse 2n - 1 with spaces', () => { - const node = parse_anplusb('2n - 1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('-1') - }) - - it('should parse n + 5 with spaces', () => { - const node = parse_anplusb('n + 5')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('n') - expect(node.nth_b).toBe('+5') - }) - - it('should handle leading whitespace', () => { - const node = parse_anplusb(' 2n+1')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('+1') - }) - - it('should handle trailing whitespace', () => { - const node = parse_anplusb('2n+1 ')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('2n') - expect(node.nth_b).toBe('+1') - }) - }) - - describe('Edge cases', () => { - it('should parse +0n+0', () => { - const node = parse_anplusb('+0n+0')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('+0n') - expect(node.nth_b).toBe('+0') - }) - - it('should parse large coefficients', () => { - const node = parse_anplusb('100n+50')! - expect(node).not.toBeNull() - expect(node.nth_a).toBe('100n') - expect(node.nth_b).toBe('+50') - }) - }) -}) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 0b18295..584f6c7 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -15,487 +15,1118 @@ import { URL, } 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(AT_RULE) - expect(atRule?.name).toBe('media') +describe('At-Rule Prelude Nodes', () => { + describe('Locations', () => { + describe('MEDIA_QUERY', () => { + test('offset and length for simple media type', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! - // Should have prelude children - const children = atRule?.children || [] - expect(children.length).toBeGreaterThan(0) + expect(mediaQuery.type).toBe(MEDIA_QUERY) + expect(mediaQuery.offset).toBe(7) + expect(mediaQuery.length).toBe(6) + }) - // First child should be a media query - expect(children[0].type).toBe(MEDIA_QUERY) + test('offset and length for media feature', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! - // Query should have a media type child - const queryChildren = children[0].children - expect(queryChildren.some((c) => c.type === MEDIA_TYPE)).toBe(true) - }) + expect(mediaQuery.type).toBe(MEDIA_QUERY) + expect(mediaQuery.offset).toBe(7) + expect(mediaQuery.length).toBe(18) + }) - it('should parse media feature', () => { - const css = '@media (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] + test('offset and length for complex query', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! - expect(children[0].type).toBe(MEDIA_QUERY) + expect(mediaQuery.type).toBe(MEDIA_QUERY) + expect(mediaQuery.offset).toBe(7) + expect(mediaQuery.length).toBe(29) + }) + }) - // Query should have a media feature child - const queryChildren = children[0].children - expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) + describe('MEDIA_TYPE', () => { + test('offset and length', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaType = mediaQuery.first_child! - // Feature should have content - const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) - expect(feature?.value).toContain('min-width') + expect(mediaType.type).toBe(MEDIA_TYPE) + expect(mediaType.offset).toBe(7) + expect(mediaType.length).toBe(6) + }) }) - 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 === MEDIA_FEATURE) + describe('MEDIA_FEATURE', () => { + test('offset and length', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaFeature = mediaQuery.first_child! - expect(feature?.value).toBe('min-width: 768px') + expect(mediaFeature.type).toBe(MEDIA_FEATURE) + expect(mediaFeature.offset).toBe(7) + expect(mediaFeature.length).toBe(18) + }) }) - 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 || [] + describe('CONTAINER_QUERY', () => { + test('offset and length for unnamed query', () => { + const css = '@container (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const containerQuery = atRule.first_child! + + expect(containerQuery.type).toBe(CONTAINER_QUERY) + expect(containerQuery.offset).toBe(11) + expect(containerQuery.length).toBe(18) + }) - expect(children[0].type).toBe(MEDIA_QUERY) + test('offset and length for named query', () => { + const css = '@container sidebar (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const containerQuery = atRule.first_child! - const queryChildren = children[0].children - // Should have: media type, operator, media feature - expect(queryChildren.some((c) => c.type === MEDIA_TYPE)).toBe(true) - expect(queryChildren.some((c) => c.type === PRELUDE_OPERATOR)).toBe(true) - expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) + expect(containerQuery.type).toBe(CONTAINER_QUERY) + expect(containerQuery.offset).toBe(11) + expect(containerQuery.length).toBe(26) + }) }) - 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 || [] + describe('SUPPORTS_QUERY', () => { + test('offset and length', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child! + const supportsQuery = atRule.first_child! - const queryChildren = children[0].children - const features = queryChildren.filter((c) => c.type === MEDIA_FEATURE) - expect(features.length).toBe(2) + expect(supportsQuery.type).toBe(SUPPORTS_QUERY) + expect(supportsQuery.offset).toBe(10) + expect(supportsQuery.length).toBe(15) + }) }) - 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 || [] + describe('LAYER_NAME', () => { + test('offset and length', () => { + const css = '@layer utilities { }' + const ast = parse(css) + const atRule = ast.first_child! + const layerName = atRule.first_child! - // Should have 2 media query nodes - const queries = children.filter((c) => c.type === MEDIA_QUERY) - expect(queries.length).toBe(2) + expect(layerName.type).toBe(LAYER_NAME) + expect(layerName.offset).toBe(7) + expect(layerName.length).toBe(9) + }) }) - }) - describe('@container', () => { - it('should parse unnamed container query', () => { - const css = '@container (min-width: 400px) { }' - const ast = parse(css) - const atRule = ast.first_child + describe('IDENTIFIER', () => { + test('offset and length in @keyframes', () => { + const css = '@keyframes slidein { }' + const ast = parse(css) + const atRule = ast.first_child! + const identifier = atRule.first_child! - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('container') + expect(identifier.type).toBe(IDENTIFIER) + expect(identifier.offset).toBe(11) + expect(identifier.length).toBe(7) + }) - const children = atRule?.children || [] - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(CONTAINER_QUERY) - }) + test('offset and length in @property', () => { + const css = '@property --my-color { }' + const ast = parse(css) + const atRule = ast.first_child! + const identifier = atRule.first_child! - 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(identifier.type).toBe(IDENTIFIER) + expect(identifier.offset).toBe(10) + expect(identifier.length).toBe(10) + }) + }) - expect(children[0].type).toBe(CONTAINER_QUERY) + describe('PRELUDE_OPERATOR', () => { + test('offset and length in @media', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const operator = mediaQuery.children[1] - const queryChildren = children[0].children - // Should have name and feature - expect(queryChildren.some((c) => c.type === IDENTIFIER)).toBe(true) - expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) + expect(operator.type).toBe(PRELUDE_OPERATOR) + expect(operator.offset).toBe(14) + expect(operator.length).toBe(3) + }) }) - }) - describe('@supports', () => { - it('should parse single feature query', () => { - const css = '@supports (display: flex) { }' - const ast = parse(css) - const atRule = ast.first_child + describe('URL', () => { + test('offset and length with url() function', () => { + const css = '@import url("styles.css");' + const ast = parse(css) + const atRule = ast.first_child! + const url = atRule.first_child! - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('supports') + expect(url.type).toBe(URL) + expect(url.offset).toBe(8) + expect(url.length).toBe(17) + }) - const children = atRule?.children || [] - expect(children.some((c) => c.type === SUPPORTS_QUERY)).toBe(true) + test('offset and length with string', () => { + const css = '@import "styles.css";' + const ast = parse(css) + const atRule = ast.first_child! + const url = atRule.first_child! - const query = children.find((c) => c.type === SUPPORTS_QUERY) - expect(query?.value).toContain('display') - expect(query?.value).toContain('flex') + expect(url.type).toBe(URL) + expect(url.offset).toBe(8) + expect(url.length).toBe(12) + }) }) + }) - it('should trim whitespace and comments from supports queries', () => { - const css = '@supports (/* comment */ display: flex /* test */) { }' + describe('Types', () => { + test('MEDIA_QUERY type constant', () => { + const css = '@media screen { }' const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const query = children.find((c) => c.type === SUPPORTS_QUERY) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! - expect(query?.value).toBe('display: flex') + expect(mediaQuery.type).toBe(MEDIA_QUERY) }) - it('should parse complex supports query with operators', () => { - const css = '@supports (display: flex) and (gap: 1rem) { }' + test('MEDIA_TYPE type constant', () => { + const css = '@media screen { }' 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 === SUPPORTS_QUERY) - const operators = children.filter((c) => c.type === PRELUDE_OPERATOR) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaType = mediaQuery.first_child! - expect(queries.length).toBe(2) - expect(operators.length).toBe(1) + expect(mediaType.type).toBe(MEDIA_TYPE) }) - }) - describe('@layer', () => { - it('should parse single layer name', () => { - const css = '@layer base { }' + test('MEDIA_FEATURE type constant', () => { + const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaFeature = mediaQuery.first_child! - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('layer') - - // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] - expect(children.length).toBe(1) - expect(children[0].type).toBe(LAYER_NAME) - expect(children[0].text).toBe('base') + expect(mediaFeature.type).toBe(MEDIA_FEATURE) }) - it('should parse comma-separated layer names', () => { - const css = '@layer base, components, utilities;' + test('CONTAINER_QUERY type constant', () => { + const css = '@container (min-width: 400px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const containerQuery = atRule.first_child! - const children = atRule?.children || [] - expect(children.length).toBe(3) + expect(containerQuery.type).toBe(CONTAINER_QUERY) + }) - expect(children[0].type).toBe(LAYER_NAME) - expect(children[0].text).toBe('base') + test('SUPPORTS_QUERY type constant', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child! + const supportsQuery = atRule.first_child! + + expect(supportsQuery.type).toBe(SUPPORTS_QUERY) + }) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].text).toBe('components') + test('LAYER_NAME type constant', () => { + const css = '@layer utilities { }' + const ast = parse(css) + const atRule = ast.first_child! + const layerName = atRule.first_child! - expect(children[2].type).toBe(LAYER_NAME) - expect(children[2].text).toBe('utilities') + expect(layerName.type).toBe(LAYER_NAME) }) - }) - describe('@keyframes', () => { - it('should parse keyframe name', () => { + test('IDENTIFIER type constant in @keyframes', () => { const css = '@keyframes slidein { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const identifier = atRule.first_child! - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('keyframes') - - // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] - expect(children.length).toBe(1) - expect(children[0].type).toBe(IDENTIFIER) - expect(children[0].text).toBe('slidein') + expect(identifier.type).toBe(IDENTIFIER) }) - }) - describe('@property', () => { - it('should parse custom property name', () => { + test('IDENTIFIER type constant in @property', () => { const css = '@property --my-color { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const identifier = atRule.first_child! - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('property') - - // Filter out block node to get only prelude children - const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] - expect(children.length).toBe(1) - expect(children[0].type).toBe(IDENTIFIER) - expect(children[0].text).toBe('--my-color') + expect(identifier.type).toBe(IDENTIFIER) }) - }) - describe('@font-face', () => { - it('should have no prelude children', () => { - const css = '@font-face { font-family: "MyFont"; }' + test('PRELUDE_OPERATOR type constant', () => { + const css = '@media screen and (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const operator = mediaQuery.children[1] - expect(atRule?.type).toBe(AT_RULE) - expect(atRule?.name).toBe('font-face') + expect(operator.type).toBe(PRELUDE_OPERATOR) + }) - // @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(IDENTIFIER) - } + test('URL type constant', () => { + const css = '@import url("styles.css");' + const ast = parse(css) + const atRule = ast.first_child! + const url = atRule.first_child! + + expect(url.type).toBe(URL) }) }) - describe('parse_atrule_preludes option', () => { - it('should parse preludes when enabled (default)', () => { + describe('Type Names', () => { + test('MEDIA_QUERY type_name', () => { const css = '@media screen { }' - const ast = parse(css, { parse_atrule_preludes: true }) - const atRule = ast.first_child - const children = atRule?.children || [] + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! - expect(children.some((c) => c.type === MEDIA_QUERY)).toBe(true) + expect(mediaQuery.type_name).toBe('MediaQuery') }) - it('should not parse preludes when disabled', () => { + test('MEDIA_TYPE type_name', () => { const css = '@media screen { }' - const ast = parse(css, { parse_atrule_preludes: false }) - const atRule = ast.first_child - const children = atRule?.children || [] + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaType = mediaQuery.first_child! - expect(children.some((c) => c.type === MEDIA_QUERY)).toBe(false) + expect(mediaType.type_name).toBe('MediaType') }) - }) - describe('Prelude text access', () => { - it('should preserve prelude text in at-rule node', () => { - const css = '@media screen and (min-width: 768px) { }' + test('MEDIA_FEATURE type_name', () => { + const css = '@media (min-width: 768px) { }' const ast = parse(css) - const atRule = ast.first_child + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const mediaFeature = mediaQuery.first_child! - // The prelude text should still be accessible - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(mediaFeature.type_name).toBe('Feature') }) - }) - 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 || [] + test('CONTAINER_QUERY type_name', () => { + const css = '@container (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const containerQuery = atRule.first_child! - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(URL) - expect(children[0].text).toBe('url("styles.css")') + expect(containerQuery.type_name).toBe('ContainerQuery') }) - 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 || [] + test('SUPPORTS_QUERY type_name', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child! + const supportsQuery = atRule.first_child! - expect(children.length).toBeGreaterThan(0) - expect(children[0].type).toBe(URL) - expect(children[0].text).toBe('"styles.css"') + expect(supportsQuery.type_name).toBe('SupportsQuery') }) - 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 || [] + test('LAYER_NAME type_name', () => { + const css = '@layer utilities { }' + const ast = parse(css) + const atRule = ast.first_child! + const layerName = atRule.first_child! - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].text).toBe('layer') - expect(children[1].name).toBe('') + expect(layerName.type_name).toBe('Layer') }) - 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 || [] + test('IDENTIFIER type_name', () => { + const css = '@keyframes slidein { }' + const ast = parse(css) + const atRule = ast.first_child! + const identifier = atRule.first_child! - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].text).toBe('LAYER') - expect(children[1].name).toBe('') + expect(identifier.type_name).toBe('Identifier') }) - 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 || [] + test('PRELUDE_OPERATOR type_name', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child! + const mediaQuery = atRule.first_child! + const operator = mediaQuery.children[1] - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].text).toBe('layer(utilities)') - expect(children[1].name).toBe('utilities') + expect(operator.type_name).toBe('Operator') }) - 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 || [] + test('URL type_name', () => { + const css = '@import url("styles.css");' + const ast = parse(css) + const atRule = ast.first_child! + const url = atRule.first_child! - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].name).toBe('utilities') + expect(url.type_name).toBe('Url') }) + }) + + describe('Prelude Properties', () => { + describe('@media', () => { + it('should parse media type', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child - 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(atRule?.type).toBe(AT_RULE) + expect(atRule?.name).toBe('media') - expect(children[1].type).toBe(LAYER_NAME) - expect(children[1].name).toBe('utilities') - }) + // Should have prelude children + const children = atRule?.children || [] + expect(children.length).toBeGreaterThan(0) - 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 || [] + // First child should be a media query + expect(children[0].type).toBe(MEDIA_QUERY) + + // Query should have a media type child + const queryChildren = children[0].children + expect(queryChildren.some((c) => c.type === 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[1].type).toBe(LAYER_NAME) - expect(children[1].name).toBe('named.nested') + expect(children[0].type).toBe(MEDIA_QUERY) + + // Query should have a media feature child + const queryChildren = children[0].children + expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) + + // Feature should have content + const feature = queryChildren.find((c) => c.type === 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 === 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(MEDIA_QUERY) + + const queryChildren = children[0].children + // Should have: media type, operator, media feature + expect(queryChildren.some((c) => c.type === MEDIA_TYPE)).toBe(true) + expect(queryChildren.some((c) => c.type === PRELUDE_OPERATOR)).toBe(true) + expect(queryChildren.some((c) => c.type === 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 === 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 === MEDIA_QUERY) + expect(queries.length).toBe(2) + }) }) - 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 || [] + 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(AT_RULE) + expect(atRule?.name).toBe('container') + + const children = atRule?.children || [] + expect(children.length).toBeGreaterThan(0) + expect(children[0].type).toBe(CONTAINER_QUERY) + }) - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) - expect(children[1].text).toBe('supports(display: grid)') + 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(CONTAINER_QUERY) + + const queryChildren = children[0].children + // Should have name and feature + expect(queryChildren.some((c) => c.type === IDENTIFIER)).toBe(true) + expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true) + }) }) - 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 || [] + 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(AT_RULE) + expect(atRule?.name).toBe('supports') + + const children = atRule?.children || [] + expect(children.some((c) => c.type === SUPPORTS_QUERY)).toBe(true) + + const query = children.find((c) => c.type === 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 === 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 || [] - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(MEDIA_QUERY) + // Should have 2 queries and 1 operator + const queries = children.filter((c) => c.type === SUPPORTS_QUERY) + const operators = children.filter((c) => c.type === PRELUDE_OPERATOR) + + expect(queries.length).toBe(2) + expect(operators.length).toBe(1) + }) }) - 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 || [] + 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(AT_RULE) + expect(atRule?.name).toBe('layer') + + // Filter out block node to get only prelude children + const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(LAYER_NAME) + expect(children[0].text).toBe('base') + }) - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(MEDIA_QUERY) + 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(LAYER_NAME) + expect(children[0].text).toBe('base') + + expect(children[1].type).toBe(LAYER_NAME) + expect(children[1].text).toBe('components') + + expect(children[2].type).toBe(LAYER_NAME) + expect(children[2].text).toBe('utilities') + }) }) - 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 || [] + describe('@keyframes', () => { + it('should parse keyframe name', () => { + const css = '@keyframes slidein { }' + const ast = parse(css) + const atRule = ast.first_child - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(MEDIA_QUERY) + expect(atRule?.type).toBe(AT_RULE) + expect(atRule?.name).toBe('keyframes') + + // Filter out block node to get only prelude children + const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[0].text).toBe('slidein') + }) }) - 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 || [] + describe('@property', () => { + it('should parse custom property name', () => { + const css = '@property --my-color { }' + const ast = parse(css) + const atRule = ast.first_child - expect(children.length).toBe(3) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[2].type).toBe(MEDIA_QUERY) + expect(atRule?.type).toBe(AT_RULE) + expect(atRule?.name).toBe('property') + + // Filter out block node to get only prelude children + const children = atRule?.children.filter((c) => c.type !== BLOCK) || [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[0].text).toBe('--my-color') + }) }) - 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 || [] + 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(children.length).toBe(3) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[2].type).toBe(SUPPORTS_QUERY) + expect(atRule?.type).toBe(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(IDENTIFIER) + } + }) }) - 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 || [] + 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 === 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.length).toBe(3) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) - expect(children[2].type).toBe(MEDIA_QUERY) + expect(children.some((c) => c.type === MEDIA_QUERY)).toBe(false) + }) }) - 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 || [] + 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 - expect(children.length).toBe(4) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) - expect(children[2].type).toBe(SUPPORTS_QUERY) - expect(children[3].type).toBe(MEDIA_QUERY) + // The prelude text should still be accessible + expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + }) }) - 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 || [] + 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(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(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(URL) + expect(children[1].type).toBe(LAYER_NAME) + 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(URL) + expect(children[1].type).toBe(LAYER_NAME) + 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(URL) + expect(children[1].type).toBe(LAYER_NAME) + 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(LAYER_NAME) + 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(LAYER_NAME) + 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(LAYER_NAME) + 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(URL) + expect(children[1].type).toBe(SUPPORTS_QUERY) + 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(URL) + expect(children[1].type).toBe(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(URL) + expect(children[1].type).toBe(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(URL) + expect(children[1].type).toBe(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(URL) + expect(children[1].type).toBe(LAYER_NAME) + expect(children[2].type).toBe(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(URL) + expect(children[1].type).toBe(LAYER_NAME) + expect(children[2].type).toBe(SUPPORTS_QUERY) + }) + + 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(URL) + expect(children[1].type).toBe(SUPPORTS_QUERY) + expect(children[2].type).toBe(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(URL) + expect(children[1].type).toBe(LAYER_NAME) + expect(children[2].type).toBe(SUPPORTS_QUERY) + expect(children[3].type).toBe(MEDIA_QUERY) + }) - expect(children.length).toBe(2) - expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) - expect(children[1].text).toContain('supports(') + 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(URL) + expect(children[1].type).toBe(SUPPORTS_QUERY) + 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') + }) }) - it('should preserve prelude text', () => { - const css = '@import url("styles.css") layer(base) screen;' - const ast = parse(css) - const atRule = ast.first_child + describe('Length property correctness (regression tests for commit 5c6e2cd)', () => { + describe('At-rule prelude length', () => { + test('@media prelude length should match text', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('screen') + expect(atRule?.prelude?.length).toBe(6) + }) + + test('@media with feature prelude length', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('(min-width: 768px)') + expect(atRule?.prelude?.length).toBe(18) + }) + + test('@media complex prelude length', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(atRule?.prelude?.length).toBe(29) + }) + + test('@container prelude length', () => { + const css = '@container (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('(min-width: 768px)') + expect(atRule?.prelude?.length).toBe(18) + }) + + test('@container with name prelude length', () => { + const css = '@container sidebar (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('sidebar (min-width: 400px)') + expect(atRule?.prelude?.length).toBe(26) + }) + + test('@supports prelude length', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('(display: flex)') + expect(atRule?.prelude?.length).toBe(15) + }) + + test('@supports complex prelude length', () => { + const css = '@supports (display: flex) and (color: red) { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('(display: flex) and (color: red)') + expect(atRule?.prelude?.length).toBe(32) + }) + + test('@layer single name prelude length', () => { + const css = '@layer utilities { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('utilities') + expect(atRule?.prelude?.length).toBe(9) + }) + + test('@layer multiple names prelude length', () => { + const css = '@layer base, components, utilities { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('base, components, utilities') + expect(atRule?.prelude?.length).toBe(27) + }) + + test('@import url prelude length', () => { + const css = '@import url("styles.css") screen;' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('url("styles.css") screen') + expect(atRule?.prelude?.length).toBe(24) + }) + + test('@import with layer prelude length', () => { + const css = '@import "styles.css" layer(utilities);' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('"styles.css" layer(utilities)') + expect(atRule?.prelude?.length).toBe(29) + }) + + test('@import with supports prelude length', () => { + const css = '@import url("styles.css") supports(display: flex);' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('url("styles.css") supports(display: flex)') + expect(atRule?.prelude?.length).toBe(41) + }) + + test('@import complex prelude length', () => { + const css = '@import url("a.css") layer(utilities) supports(display: flex) screen;' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('url("a.css") layer(utilities) supports(display: flex) screen') + expect(atRule?.prelude?.length).toBe(60) + }) + }) - expect(atRule?.prelude).toBe('url("styles.css") layer(base) screen') + describe('Prelude child node text length', () => { + test('media query node text length', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + // First child should be media query + const mediaQuery = children[0] + expect(mediaQuery.type).toBe(MEDIA_QUERY) + expect(mediaQuery.text).toBe('screen and (min-width: 768px)') + expect(mediaQuery.text.length).toBe(29) + }) + + test('media type node text length', () => { + const css = '@media screen { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + const mediaQuery = children[0] + const queryChildren = mediaQuery?.children || [] + + const mediaType = queryChildren.find((c) => c.type === MEDIA_TYPE) + expect(mediaType?.text).toBe('screen') + expect(mediaType?.text.length).toBe(6) + }) + + test('media feature node text length', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + const mediaQuery = children[0] + const queryChildren = mediaQuery?.children || [] + + const mediaFeature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + expect(mediaFeature?.text).toBe('(min-width: 768px)') + expect(mediaFeature?.text.length).toBe(18) + }) + + test('container query node text length', () => { + const css = '@container sidebar (min-width: 400px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const containerQuery = children.find((c) => c.type === CONTAINER_QUERY) + expect(containerQuery?.text).toBe('sidebar (min-width: 400px)') + expect(containerQuery?.text.length).toBe(26) + }) + + test('supports query node text length', () => { + const css = '@supports (display: flex) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const supportsQuery = children.find((c) => c.type === SUPPORTS_QUERY) + expect(supportsQuery?.text).toBe('(display: flex)') + expect(supportsQuery?.text.length).toBe(15) + }) + + test('layer name node text length', () => { + const css = '@layer utilities { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const layerName = children.find((c) => c.type === LAYER_NAME) + expect(layerName?.text).toBe('utilities') + expect(layerName?.text.length).toBe(9) + }) + + test('import url node text length', () => { + const css = '@import url("styles.css") screen;' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const importUrl = children.find((c) => c.type === URL) + expect(importUrl?.text).toBe('url("styles.css")') + expect(importUrl?.text.length).toBe(17) + }) + + test('import layer node text length', () => { + const css = '@import "styles.css" layer(utilities);' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const importLayer = children.find((c) => c.type === LAYER_NAME) + expect(importLayer?.text).toBe('layer(utilities)') + expect(importLayer?.text.length).toBe(16) + }) + + test('import supports node text length', () => { + const css = '@import url("a.css") supports(display: flex);' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + + const importSupports = children.find((c) => c.type === SUPPORTS_QUERY) + expect(importSupports?.text).toBe('supports(display: flex)') + expect(importSupports?.text.length).toBe(23) + }) + + test('operator node text length', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const children = atRule?.children || [] + const mediaQuery = children[0] + const queryChildren = mediaQuery?.children || [] + + const operator = queryChildren.find((c) => c.type === PRELUDE_OPERATOR) + expect(operator?.text).toBe('and') + expect(operator?.text.length).toBe(3) + }) + }) + + describe('Edge cases and whitespace handling', () => { + test('@media with extra whitespace prelude length', () => { + const css = '@media screen and (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + + // Whitespace is trimmed from start/end but preserved internally + expect(atRule?.prelude).toBe('screen and (min-width: 768px)') + expect(atRule?.prelude?.length).toBe(33) + }) + + test('@layer with whitespace around commas', () => { + const css = '@layer base , components , utilities { }' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('base , components , utilities') + expect(atRule?.prelude?.length).toBe(29) + }) + + test('@import with newlines prelude length', () => { + const css = '@import url("styles.css")\n screen;' + const ast = parse(css) + const atRule = ast.first_child + + expect(atRule?.prelude).toBe('url("styles.css")\n screen') + expect(atRule?.prelude?.length).toBe(26) + }) + }) }) }) }) @@ -705,275 +1336,4 @@ describe('parse_atrule_prelude()', () => { expect(result.length).toBeGreaterThan(0) }) }) - - describe('length property correctness (regression tests for commit 5c6e2cd)', () => { - describe('At-rule prelude length', () => { - test('@media prelude length should match text', () => { - const css = '@media screen { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('screen') - expect(atRule?.prelude?.length).toBe(6) - }) - - test('@media with feature prelude length', () => { - const css = '@media (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('(min-width: 768px)') - expect(atRule?.prelude?.length).toBe(18) - }) - - test('@media complex prelude length', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') - expect(atRule?.prelude?.length).toBe(29) - }) - - test('@container prelude length', () => { - const css = '@container (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('(min-width: 768px)') - expect(atRule?.prelude?.length).toBe(18) - }) - - test('@container with name prelude length', () => { - const css = '@container sidebar (min-width: 400px) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('sidebar (min-width: 400px)') - expect(atRule?.prelude?.length).toBe(26) - }) - - test('@supports prelude length', () => { - const css = '@supports (display: flex) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('(display: flex)') - expect(atRule?.prelude?.length).toBe(15) - }) - - test('@supports complex prelude length', () => { - const css = '@supports (display: flex) and (color: red) { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('(display: flex) and (color: red)') - expect(atRule?.prelude?.length).toBe(32) - }) - - test('@layer single name prelude length', () => { - const css = '@layer utilities { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('utilities') - expect(atRule?.prelude?.length).toBe(9) - }) - - test('@layer multiple names prelude length', () => { - const css = '@layer base, components, utilities { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('base, components, utilities') - expect(atRule?.prelude?.length).toBe(27) - }) - - test('@import url prelude length', () => { - const css = '@import url("styles.css") screen;' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('url("styles.css") screen') - expect(atRule?.prelude?.length).toBe(24) - }) - - test('@import with layer prelude length', () => { - const css = '@import "styles.css" layer(utilities);' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('"styles.css" layer(utilities)') - expect(atRule?.prelude?.length).toBe(29) - }) - - test('@import with supports prelude length', () => { - const css = '@import url("styles.css") supports(display: flex);' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('url("styles.css") supports(display: flex)') - expect(atRule?.prelude?.length).toBe(41) - }) - - test('@import complex prelude length', () => { - const css = '@import url("a.css") layer(utilities) supports(display: flex) screen;' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('url("a.css") layer(utilities) supports(display: flex) screen') - expect(atRule?.prelude?.length).toBe(60) - }) - }) - - describe('Prelude child node text length', () => { - test('media query node text length', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - // First child should be media query - const mediaQuery = children[0] - expect(mediaQuery.type).toBe(MEDIA_QUERY) - expect(mediaQuery.text).toBe('screen and (min-width: 768px)') - expect(mediaQuery.text.length).toBe(29) - }) - - test('media type node text length', () => { - const css = '@media screen { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const mediaQuery = children[0] - const queryChildren = mediaQuery?.children || [] - - const mediaType = queryChildren.find((c) => c.type === MEDIA_TYPE) - expect(mediaType?.text).toBe('screen') - expect(mediaType?.text.length).toBe(6) - }) - - test('media feature node text length', () => { - const css = '@media (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const mediaQuery = children[0] - const queryChildren = mediaQuery?.children || [] - - const mediaFeature = queryChildren.find((c) => c.type === MEDIA_FEATURE) - expect(mediaFeature?.text).toBe('(min-width: 768px)') - expect(mediaFeature?.text.length).toBe(18) - }) - - test('container query node text length', () => { - const css = '@container sidebar (min-width: 400px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const containerQuery = children.find((c) => c.type === CONTAINER_QUERY) - expect(containerQuery?.text).toBe('sidebar (min-width: 400px)') - expect(containerQuery?.text.length).toBe(26) - }) - - test('supports query node text length', () => { - const css = '@supports (display: flex) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const supportsQuery = children.find((c) => c.type === SUPPORTS_QUERY) - expect(supportsQuery?.text).toBe('(display: flex)') - expect(supportsQuery?.text.length).toBe(15) - }) - - test('layer name node text length', () => { - const css = '@layer utilities { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const layerName = children.find((c) => c.type === LAYER_NAME) - expect(layerName?.text).toBe('utilities') - expect(layerName?.text.length).toBe(9) - }) - - test('import url node text length', () => { - const css = '@import url("styles.css") screen;' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const importUrl = children.find((c) => c.type === URL) - expect(importUrl?.text).toBe('url("styles.css")') - expect(importUrl?.text.length).toBe(17) - }) - - test('import layer node text length', () => { - const css = '@import "styles.css" layer(utilities);' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const importLayer = children.find((c) => c.type === LAYER_NAME) - expect(importLayer?.text).toBe('layer(utilities)') - expect(importLayer?.text.length).toBe(16) - }) - - test('import supports node text length', () => { - const css = '@import url("a.css") supports(display: flex);' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - - const importSupports = children.find((c) => c.type === SUPPORTS_QUERY) - expect(importSupports?.text).toBe('supports(display: flex)') - expect(importSupports?.text.length).toBe(23) - }) - - test('operator node text length', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - const children = atRule?.children || [] - const mediaQuery = children[0] - const queryChildren = mediaQuery?.children || [] - - const operator = queryChildren.find((c) => c.type === PRELUDE_OPERATOR) - expect(operator?.text).toBe('and') - expect(operator?.text.length).toBe(3) - }) - }) - - describe('Edge cases and whitespace handling', () => { - test('@media with extra whitespace prelude length', () => { - const css = '@media screen and (min-width: 768px) { }' - const ast = parse(css) - const atRule = ast.first_child - - // Whitespace is trimmed from start/end but preserved internally - expect(atRule?.prelude).toBe('screen and (min-width: 768px)') - expect(atRule?.prelude?.length).toBe(33) - }) - - test('@layer with whitespace around commas', () => { - const css = '@layer base , components , utilities { }' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('base , components , utilities') - expect(atRule?.prelude?.length).toBe(29) - }) - - test('@import with newlines prelude length', () => { - const css = '@import url("styles.css")\n screen;' - const ast = parse(css) - const atRule = ast.first_child - - expect(atRule?.prelude).toBe('url("styles.css")\n screen') - expect(atRule?.prelude?.length).toBe(26) - }) - }) - }) }) diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index 4350132..04c1cad 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -21,81 +21,6 @@ import { ATTR_FLAG_CASE_SENSITIVE, } 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(SELECTOR_LIST) - expect(node.text).toBe('div.container') - }) - - it('should parse type selector', () => { - const node = parse_selector('div') - expect(node.type).toBe(SELECTOR_LIST) - - const firstSelector = node.first_child - expect(firstSelector?.type).toBe(SELECTOR) - - const typeNode = firstSelector?.first_child - expect(typeNode?.type).toBe(TYPE_SELECTOR) - 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(CLASS_SELECTOR) - 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(ID_SELECTOR) - 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(TYPE_SELECTOR) - expect(children[1].type).toBe(CLASS_SELECTOR) - expect(children[2].type).toBe(ID_SELECTOR) - }) - - 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(TYPE_SELECTOR) - expect(children[1].type).toBe(COMBINATOR) - expect(children[2].type).toBe(CLASS_SELECTOR) - }) - - 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(TYPE_SELECTOR) - expect(selectors[1].first_child?.type).toBe(TYPE_SELECTOR) - expect(selectors[2].first_child?.type).toBe(TYPE_SELECTOR) - }) -}) - -// 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) @@ -130,1850 +55,2404 @@ function getChildren(arena: CSSDataArena, source: string, nodeIndex: number | nu 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 +describe('Selector Nodes', () => { + describe('Locations', () => { + describe('SELECTOR_LIST', () => { + test('offset and length for simple selector', () => { + const node = parse_selector('div') + expect(node.offset).toBe(0) + expect(node.length).toBe(3) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - expect(getNodeText(arena, source, rootNode)).toBe('div') + test('offset and length for selector list', () => { + const node = parse_selector('h1, h2, h3') + expect(node.offset).toBe(0) + expect(node.length).toBe(10) + }) + }) - // First child is NODE_SELECTOR wrapper - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + describe('TYPE_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('div') + const selector = node.first_child! + const typeSelector = selector.first_child! + expect(typeSelector.offset).toBe(0) + expect(typeSelector.length).toBe(3) + }) + }) - // First child of wrapper is the actual type - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('div') + describe('CLASS_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('.my-class') + const selector = node.first_child! + const classSelector = selector.first_child! + expect(classSelector.offset).toBe(0) + expect(classSelector.length).toBe(9) + }) }) - it('should parse class selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('.my-class') + describe('ID_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('#my-id') + const selector = node.first_child! + const idSelector = selector.first_child! + expect(idSelector.offset).toBe(0) + expect(idSelector.length).toBe(6) + }) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + describe('ATTRIBUTE_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('[disabled]') + const selector = node.first_child! + const attrSelector = selector.first_child! + expect(attrSelector.offset).toBe(0) + expect(attrSelector.length).toBe(10) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + test('offset and length with value', () => { + const node = parse_selector('[type="text"]') + const selector = node.first_child! + const attrSelector = selector.first_child! + expect(attrSelector.offset).toBe(0) + expect(attrSelector.length).toBe(13) + }) + }) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + describe('PSEUDO_CLASS_SELECTOR', () => { + test('offset and length for simple pseudo-class', () => { + const node = parse_selector('a:hover') + const selector = node.first_child! + const [_type, pseudoClass] = selector.children + expect(pseudoClass.offset).toBe(1) + expect(pseudoClass.length).toBe(6) + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(CLASS_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('.my-class') - expect(getNodeContent(arena, source, child)).toBe('.my-class') + test('offset and length for pseudo-class with function', () => { + const node = parse_selector('li:nth-child(2n+1)') + const selector = node.first_child! + const [_type, pseudoClass] = selector.children + expect(pseudoClass.offset).toBe(2) + expect(pseudoClass.length).toBe(16) + }) }) - it('should parse ID selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('#my-id') + describe('PSEUDO_ELEMENT_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('p::before') + const selector = node.first_child! + const [_type, pseudoElement] = selector.children + expect(pseudoElement.offset).toBe(1) + expect(pseudoElement.length).toBe(8) + }) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + describe('COMBINATOR', () => { + test('offset and length for child combinator', () => { + const node = parse_selector('div > p') + const selector = node.first_child! + const [_div, combinator, _p] = selector.children + expect(combinator.offset).toBeGreaterThan(2) + expect(combinator.length).toBeGreaterThan(0) + }) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + describe('UNIVERSAL_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('*') + const selector = node.first_child! + const universalSelector = selector.first_child! + expect(universalSelector.offset).toBe(0) + expect(universalSelector.length).toBe(1) + }) + }) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + describe('NESTING_SELECTOR', () => { + test('offset and length', () => { + const node = parse_selector('&') + const selector = node.first_child! + const nestingSelector = selector.first_child! + expect(nestingSelector.offset).toBe(0) + expect(nestingSelector.length).toBe(1) + }) + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(ID_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('#my-id') - expect(getNodeContent(arena, source, child)).toBe('#my-id') + describe('NTH_SELECTOR', () => { + test('offset and length in :nth-child()', () => { + const node = parse_selector(':nth-child(2n+1)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.type).toBe(NTH_SELECTOR) + expect(nthNode.length).toBeGreaterThan(0) + }) }) - it('should parse universal selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('*') + describe('NTH_OF_SELECTOR', () => { + test('offset and length in :nth-child() with "of" syntax', () => { + const node = parse_selector(':nth-child(2n of .selector)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthOfNode = pseudoClass.first_child! + expect(nthOfNode.type).toBe(NTH_OF_SELECTOR) + expect(nthOfNode.length).toBeGreaterThan(0) + }) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + describe('LANG_SELECTOR', () => { + test('offset and length in :lang()', () => { + const node = parse_selector(':lang(en)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const langNode = pseudoClass.first_child! + expect(langNode.type).toBe(LANG_SELECTOR) + expect(langNode.length).toBeGreaterThan(0) + }) + }) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + describe('Types', () => { + test('SELECTOR_LIST type constant', () => { + const node = parse_selector('div') + expect(node.type).toBe(SELECTOR_LIST) + }) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + test('SELECTOR type constant', () => { + const node = parse_selector('div') + const selector = node.first_child! + expect(selector.type).toBe(SELECTOR) + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(UNIVERSAL_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('*') + test('TYPE_SELECTOR type constant', () => { + const node = parse_selector('div') + const selector = node.first_child! + const typeSelector = selector.first_child! + expect(typeSelector.type).toBe(TYPE_SELECTOR) }) - it('should parse nesting selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('&') + test('CLASS_SELECTOR type constant', () => { + const node = parse_selector('.my-class') + const selector = node.first_child! + const classSelector = selector.first_child! + expect(classSelector.type).toBe(CLASS_SELECTOR) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + test('ID_SELECTOR type constant', () => { + const node = parse_selector('#my-id') + const selector = node.first_child! + const idSelector = selector.first_child! + expect(idSelector.type).toBe(ID_SELECTOR) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + test('ATTRIBUTE_SELECTOR type constant', () => { + const node = parse_selector('[disabled]') + const selector = node.first_child! + const attrSelector = selector.first_child! + expect(attrSelector.type).toBe(ATTRIBUTE_SELECTOR) + }) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + test('PSEUDO_CLASS_SELECTOR type constant', () => { + const node = parse_selector('a:hover') + const selector = node.first_child! + const pseudoClass = selector.children[1] + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(NESTING_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('&') + test('PSEUDO_ELEMENT_SELECTOR type constant', () => { + const node = parse_selector('p::before') + const selector = node.first_child! + const pseudoElement = selector.children[1] + expect(pseudoElement.type).toBe(PSEUDO_ELEMENT_SELECTOR) }) - }) - describe('Compound selectors', () => { - it('should parse element with class', () => { - const { arena, rootNode, source } = parseSelectorInternal('div.container') + test('COMBINATOR type constant', () => { + const node = parse_selector('div > p') + const selector = node.first_child! + const combinator = selector.children[1] + expect(combinator.type).toBe(COMBINATOR) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + test('UNIVERSAL_SELECTOR type constant', () => { + const node = parse_selector('*') + const selector = node.first_child! + const universalSelector = selector.first_child! + expect(universalSelector.type).toBe(UNIVERSAL_SELECTOR) + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + test('NESTING_SELECTOR type constant', () => { + const node = parse_selector('&') + const selector = node.first_child! + const nestingSelector = selector.first_child! + expect(nestingSelector.type).toBe(NESTING_SELECTOR) + }) - // Get the NODE_SELECTOR wrapper - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + test('NTH_SELECTOR type constant', () => { + const node = parse_selector(':nth-child(2n+1)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.type).toBe(NTH_SELECTOR) + }) - // Compound selector has multiple children - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[0])).toBe('div') - expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('.container') + test('NTH_OF_SELECTOR type constant', () => { + const node = parse_selector(':nth-child(2n of .selector)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthOfNode = pseudoClass.first_child! + expect(nthOfNode.type).toBe(NTH_OF_SELECTOR) }) - it('should parse element with ID', () => { - const { arena, rootNode, source } = parseSelectorInternal('div#app') + test('LANG_SELECTOR type constant', () => { + const node = parse_selector(':lang(en)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const langNode = pseudoClass.first_child! + expect(langNode.type).toBe(LANG_SELECTOR) + }) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + describe('Type Names', () => { + test('SELECTOR_LIST type_name', () => { + const node = parse_selector('div') + expect(node.type_name).toBe('SelectorList') + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + test('SELECTOR type_name', () => { + const node = parse_selector('div') + const selector = node.first_child! + expect(selector.type_name).toBe('Selector') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(ID_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('#app') + test('TYPE_SELECTOR type_name', () => { + const node = parse_selector('div') + const selector = node.first_child! + const typeSelector = selector.first_child! + expect(typeSelector.type_name).toBe('TypeSelector') }) - 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(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('.foo') - expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[2])).toBe('.bar') - expect(arena.get_type(children[3])).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[3])).toBe('.baz') + test('CLASS_SELECTOR type_name', () => { + const node = parse_selector('.my-class') + const selector = node.first_child! + const classSelector = selector.first_child! + expect(classSelector.type_name).toBe('ClassSelector') }) - 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(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[0])).toBe('div') - expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('.container') - expect(arena.get_type(children[2])).toBe(ID_SELECTOR) - expect(getNodeContent(arena, source, children[2])).toBe('#app') + test('ID_SELECTOR type_name', () => { + const node = parse_selector('#my-id') + const selector = node.first_child! + const idSelector = selector.first_child! + expect(idSelector.type_name).toBe('IdSelector') }) - }) - describe('Pseudo-classes', () => { - it('should parse simple pseudo-class', () => { - const { arena, rootNode, source } = parseSelectorInternal('a:hover') + test('ATTRIBUTE_SELECTOR type_name', () => { + const node = parse_selector('[disabled]') + const selector = node.first_child! + const attrSelector = selector.first_child! + expect(attrSelector.type_name).toBe('AttributeSelector') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + test('PSEUDO_CLASS_SELECTOR type_name', () => { + const node = parse_selector('a:hover') + const selector = node.first_child! + const pseudoClass = selector.children[1] + expect(pseudoClass.type_name).toBe('PseudoClassSelector') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('hover') + test('PSEUDO_ELEMENT_SELECTOR type_name', () => { + const node = parse_selector('p::before') + const selector = node.first_child! + const pseudoElement = selector.children[1] + expect(pseudoElement.type_name).toBe('PseudoElementSelector') }) - it('should parse pseudo-class with function', () => { - const { arena, rootNode, source } = parseSelectorInternal('li:nth-child(2n+1)') + test('COMBINATOR type_name', () => { + const node = parse_selector('div > p') + const selector = node.first_child! + const combinator = selector.children[1] + expect(combinator.type_name).toBe('Combinator') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + test('UNIVERSAL_SELECTOR type_name', () => { + const node = parse_selector('*') + const selector = node.first_child! + const universalSelector = selector.first_child! + expect(universalSelector.type_name).toBe('UniversalSelector') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('nth-child') - expect(getNodeText(arena, source, children[1])).toBe(':nth-child(2n+1)') + test('NESTING_SELECTOR type_name', () => { + const node = parse_selector('&') + const selector = node.first_child! + const nestingSelector = selector.first_child! + expect(nestingSelector.type_name).toBe('NestingSelector') }) - it('should parse multiple pseudo-classes', () => { - const { arena, rootNode, source } = parseSelectorInternal('input:focus:valid') + test('NTH_SELECTOR type_name', () => { + const node = parse_selector(':nth-child(2n+1)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.type_name).toBe('Nth') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + test('NTH_OF_SELECTOR type_name', () => { + const node = parse_selector(':nth-child(2n of .selector)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const nthOfNode = pseudoClass.first_child! + expect(nthOfNode.type_name).toBe('NthOf') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(3) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('focus') - expect(arena.get_type(children[2])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[2])).toBe('valid') + test('LANG_SELECTOR type_name', () => { + const node = parse_selector(':lang(en)') + const selector = node.first_child! + const pseudoClass = selector.first_child! + const langNode = pseudoClass.first_child! + expect(langNode.type_name).toBe('Lang') }) + }) - it('should parse :is() pseudo-class', () => { - const { arena, rootNode, source } = parseSelectorInternal('a:is(.active)') + describe('Selector Properties', () => { + describe('parse_selector() function', () => { + it('should parse and return a CSSNode', () => { + const node = parse_selector('div.container') + expect(node).toBeDefined() + expect(node.type).toBe(SELECTOR_LIST) + expect(node.text).toBe('div.container') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse type selector', () => { + const node = parse_selector('div') + expect(node.type).toBe(SELECTOR_LIST) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('is') - }) + const firstSelector = node.first_child + expect(firstSelector?.type).toBe(SELECTOR) - it('should parse :not() pseudo-class', () => { - const { arena, rootNode, source } = parseSelectorInternal('div:not(.disabled)') + const typeNode = firstSelector?.first_child + expect(typeNode?.type).toBe(TYPE_SELECTOR) + expect(typeNode?.text).toBe('div') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse class selector', () => { + const node = parse_selector('.my-class') + const firstSelector = node.first_child + const classNode = firstSelector?.first_child - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('not') - }) - }) + expect(classNode?.type).toBe(CLASS_SELECTOR) + 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 - describe('Pseudo-elements', () => { - it('should parse pseudo-element with double colon', () => { - const { arena, rootNode, source } = parseSelectorInternal('p::before') + expect(idNode?.type).toBe(ID_SELECTOR) + expect(idNode?.name).toBe('#my-id') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse compound selector', () => { + const node = parse_selector('div.container#app') + const firstSelector = node.first_child + const children = firstSelector?.children || [] - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(PSEUDO_ELEMENT_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('before') - }) + expect(children.length).toBe(3) + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[1].type).toBe(CLASS_SELECTOR) + expect(children[2].type).toBe(ID_SELECTOR) + }) - it('should parse pseudo-element with single colon (legacy)', () => { - const { arena, rootNode, source } = parseSelectorInternal('p:after') + it('should parse complex selector with descendant combinator', () => { + const node = parse_selector('div .container') + const firstSelector = node.first_child + const children = firstSelector?.children || [] - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(children.length).toBe(3) // div, combinator, .container + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[1].type).toBe(COMBINATOR) + expect(children[2].type).toBe(CLASS_SELECTOR) + }) + + it('should parse selector list', () => { + const node = parse_selector('div, span, p') + const selectors = node.children - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('after') + expect(selectors.length).toBe(3) + expect(selectors[0].first_child?.type).toBe(TYPE_SELECTOR) + expect(selectors[1].first_child?.type).toBe(TYPE_SELECTOR) + expect(selectors[2].first_child?.type).toBe(TYPE_SELECTOR) + }) }) - it('should parse ::first-line pseudo-element', () => { - const { arena, rootNode, source } = parseSelectorInternal('p::first-line') + describe('Simple selectors', () => { + it('should parse type selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('div') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(PSEUDO_ELEMENT_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('first-line') - }) - }) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + expect(getNodeText(arena, source, rootNode)).toBe('div') - describe('Pseudo-class function syntax detection (has_children)', () => { - it('should indicate :lang() has function syntax even when empty', () => { - const root = parse_selector(':lang()') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('lang') - expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty - }) + // First child is NODE_SELECTOR wrapper + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - it('should indicate :lang(en) has function syntax with children', () => { - const root = parse_selector(':lang(en)') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('lang') - expect(pseudoClass.has_children).toBe(true) // Function syntax with content - }) + // First child of wrapper is the actual type + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('div') + }) - it('should indicate :hover has no function syntax', () => { - const root = parse_selector(':hover') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('hover') - expect(pseudoClass.has_children).toBe(false) // Not a function - }) + it('should parse class selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.my-class') - it('should indicate :is() has function syntax even when empty', () => { - const root = parse_selector(':is()') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('is') - expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should indicate :has() has function syntax even when empty', () => { - const root = parse_selector(':has()') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('has') - expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty - }) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - it('should indicate :nth-child() has function syntax even when empty', () => { - const root = parse_selector(':nth-child()') - const pseudoClass = root.first_child!.first_child! - expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudoClass.name).toBe('nth-child') - expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty - }) + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - it('should indicate ::before has no function syntax', () => { - const root = parse_selector('::before') - const pseudoElement = root.first_child!.first_child! - expect(pseudoElement.type).toBe(PSEUDO_ELEMENT_SELECTOR) - expect(pseudoElement.name).toBe('before') - expect(pseudoElement.has_children).toBe(false) // Not a function - }) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(CLASS_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('.my-class') + expect(getNodeContent(arena, source, child)).toBe('.my-class') + }) - it('should indicate ::slotted() has function syntax even when empty', () => { - const root = parse_selector('::slotted()') - const pseudoElement = root.first_child!.first_child! - expect(pseudoElement.type).toBe(PSEUDO_ELEMENT_SELECTOR) - expect(pseudoElement.name).toBe('slotted') - expect(pseudoElement.has_children).toBe(true) // Function syntax, even if empty - }) - }) + it('should parse ID selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('#my-id') - describe('Attribute selectors', () => { - it('should parse simple attribute selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('[disabled]') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(rootNode).not.toBeNull() - if (!rootNode) return + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ID_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('#my-id') + expect(getNodeContent(arena, source, child)).toBe('#my-id') + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('[disabled]') - expect(getNodeContent(arena, source, child)).toBe('disabled') - }) + 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(SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(UNIVERSAL_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('*') + }) - it('should parse attribute with value', () => { - const { arena, rootNode, source } = parseSelectorInternal('[type="text"]') + it('should parse nesting selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('&') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('[type="text"]') - // Content now stores just the attribute name - expect(getNodeContent(arena, source, child)).toBe('type') + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(NESTING_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('&') + }) }) - it('should parse attribute with operator', () => { - const { arena, rootNode, source } = parseSelectorInternal('[class^="btn-"]') + describe('Compound selectors', () => { + it('should parse element with class', () => { + const { arena, rootNode, source } = parseSelectorInternal('div.container') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + // Get the NODE_SELECTOR wrapper + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('[class^="btn-"]') - }) + // Compound selector has multiple children + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[0])).toBe('div') + expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('.container') + }) - it('should parse element with attribute', () => { - const { arena, rootNode, source } = parseSelectorInternal('input[type="checkbox"]') + it('should parse element with ID', () => { + const { arena, rootNode, source } = parseSelectorInternal('div#app') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(TYPE_SELECTOR) - expect(arena.get_type(children[1])).toBe(ATTRIBUTE_SELECTOR) - }) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - it('should trim whitespace from attribute selectors', () => { - const { arena, rootNode, source } = parseSelectorInternal('[ data-test="value" ]') + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(ID_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('#app') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('.foo') + expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[2])).toBe('.bar') + expect(arena.get_type(children[3])).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[3])).toBe('.baz') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) - // 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 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(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[0])).toBe('div') + expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('.container') + expect(arena.get_type(children[2])).toBe(ID_SELECTOR) + expect(getNodeContent(arena, source, children[2])).toBe('#app') + }) }) - it('should trim comments from attribute selectors', () => { - const { arena, rootNode, source } = parseSelectorInternal('[/* comment */data-test="value"/* test */]') + describe('Pseudo-classes', () => { + it('should parse simple pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('a:hover') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - // Content now stores just the attribute name - expect(getNodeContent(arena, source, child)).toBe('data-test') - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('hover') + }) - it('should trim whitespace and comments from attribute selectors', () => { - const { arena, rootNode, source } = parseSelectorInternal('[/* comment */ data-test="value" /* test */]') + it('should parse pseudo-class with function', () => { + const { arena, rootNode, source } = parseSelectorInternal('li:nth-child(2n+1)') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - // Content now stores just the attribute name - expect(getNodeContent(arena, source, child)).toBe('data-test') - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('nth-child') + expect(getNodeText(arena, source, children[1])).toBe(':nth-child(2n+1)') + }) - it('should parse attribute with case-insensitive flag', () => { - const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]') + it('should parse multiple pseudo-classes', () => { + const { arena, rootNode, source } = parseSelectorInternal('input:focus:valid') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('[type="text" i]') - expect(getNodeContent(arena, source, child)).toBe('type') - expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('focus') + expect(arena.get_type(children[2])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[2])).toBe('valid') + }) - it('should parse attribute with case-insensitive flag', () => { - const root = parse_selector('[type="text" i]') + it('should parse :is() pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('a:is(.active)') - expect(root).not.toBeNull() - if (!root) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(root.type).toBe(SELECTOR_LIST) - let selector = root.first_child! - expect(selector.type).toBe(SELECTOR) - let attr = selector.first_child! - expect(attr.type).toBe(ATTRIBUTE_SELECTOR) - expect(attr.attr_flags).toBe(ATTR_FLAG_CASE_INSENSITIVE) - expect(attr.attr_operator).toBe(ATTR_OPERATOR_EQUAL) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('is') + }) - it('should parse attribute with case-sensitive flag', () => { - const { arena, rootNode, source } = parseSelectorInternal('[type="text" s]') + it('should parse :not() pseudo-class', () => { + const { arena, rootNode, source } = parseSelectorInternal('div:not(.disabled)') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('[type="text" s]') - expect(getNodeContent(arena, source, child)).toBe('type') - expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_SENSITIVE) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('not') + }) }) - it('should parse attribute with uppercase case-insensitive flag', () => { - const { arena, rootNode } = parseSelectorInternal('[type="text" I]') + 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 + 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(ATTRIBUTE_SELECTOR) - expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(PSEUDO_ELEMENT_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('before') + }) - it('should parse attribute with whitespace before flag', () => { - const { arena, rootNode } = parseSelectorInternal('[type="text" i]') + it('should parse pseudo-element with single colon (legacy)', () => { + const { arena, rootNode, source } = parseSelectorInternal('p:after') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('after') + }) - it('should parse attribute without flag', () => { - const { arena, rootNode } = parseSelectorInternal('[type="text"]') + it('should parse ::first-line pseudo-element', () => { + const { arena, rootNode, source } = parseSelectorInternal('p::first-line') - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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(ATTRIBUTE_SELECTOR) - expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_NONE) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[1])).toBe(PSEUDO_ELEMENT_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('first-line') + }) }) - it('should handle flag with various operators', () => { - // Test with ^= operator - const test1 = parseSelectorInternal('[class^="btn" i]') - if (!test1.rootNode) throw new Error('Expected rootNode') - const wrapper1 = test1.arena.get_first_child(test1.rootNode) - if (!wrapper1) throw new Error('Expected wrapper1') - const child1 = test1.arena.get_first_child(wrapper1) - if (!child1) throw new Error('Expected child1') - expect(test1.arena.get_attr_flags(child1)).toBe(ATTR_FLAG_CASE_INSENSITIVE) - - // Test with $= operator - const test2 = parseSelectorInternal('[class$="btn" s]') - if (!test2.rootNode) throw new Error('Expected rootNode') - const wrapper2 = test2.arena.get_first_child(test2.rootNode) - if (!wrapper2) throw new Error('Expected wrapper2') - const child2 = test2.arena.get_first_child(wrapper2) - if (!child2) throw new Error('Expected child2') - expect(test2.arena.get_attr_flags(child2)).toBe(ATTR_FLAG_CASE_SENSITIVE) - - // Test with ~= operator - const test3 = parseSelectorInternal('[class~="active" i]') - if (!test3.rootNode) throw new Error('Expected rootNode') - const wrapper3 = test3.arena.get_first_child(test3.rootNode) - if (!wrapper3) throw new Error('Expected wrapper3') - const child3 = test3.arena.get_first_child(wrapper3) - if (!child3) throw new Error('Expected child3') - expect(test3.arena.get_attr_flags(child3)).toBe(ATTR_FLAG_CASE_INSENSITIVE) - }) - }) + describe('Pseudo-class function syntax detection (has_children)', () => { + it('should indicate :lang() has function syntax even when empty', () => { + const root = parse_selector(':lang()') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('lang') + expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty + }) - describe('Combinators', () => { - it('should parse descendant combinator (space)', () => { - const { arena, rootNode, source } = parseSelectorInternal('div p') + it('should indicate :lang(en) has function syntax with children', () => { + const root = parse_selector(':lang(en)') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('lang') + expect(pseudoClass.has_children).toBe(true) // Function syntax with content + }) + + it('should indicate :hover has no function syntax', () => { + const root = parse_selector(':hover') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('hover') + expect(pseudoClass.has_children).toBe(false) // Not a function + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should indicate :is() has function syntax even when empty', () => { + const root = parse_selector(':is()') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('is') + expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + it('should indicate :has() has function syntax even when empty', () => { + const root = parse_selector(':has()') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('has') + expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children.length).toBeGreaterThanOrEqual(2) + it('should indicate :nth-child() has function syntax even when empty', () => { + const root = parse_selector(':nth-child()') + const pseudoClass = root.first_child!.first_child! + expect(pseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass.name).toBe('nth-child') + expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty + }) - // Should have: compound(div), combinator(space), compound(p) - const hasDescendantCombinator = children.some((child) => { - const type = arena.get_type(child) - return type === COMBINATOR + it('should indicate ::before has no function syntax', () => { + const root = parse_selector('::before') + const pseudoElement = root.first_child!.first_child! + expect(pseudoElement.type).toBe(PSEUDO_ELEMENT_SELECTOR) + expect(pseudoElement.name).toBe('before') + expect(pseudoElement.has_children).toBe(false) // Not a function + }) + + it('should indicate ::slotted() has function syntax even when empty', () => { + const root = parse_selector('::slotted()') + const pseudoElement = root.first_child!.first_child! + expect(pseudoElement.type).toBe(PSEUDO_ELEMENT_SELECTOR) + expect(pseudoElement.name).toBe('slotted') + expect(pseudoElement.has_children).toBe(true) // Function syntax, even if empty }) - expect(hasDescendantCombinator).toBe(true) }) - it('should parse child combinator (>)', () => { - const { arena, rootNode, source } = parseSelectorInternal('div > p') + describe('Attribute selectors', () => { + it('should parse simple attribute selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('[disabled]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === COMBINATOR) { - return getNodeText(arena, source, child).includes('>') - } - return false + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('[disabled]') + expect(getNodeContent(arena, source, child)).toBe('disabled') }) - expect(hasCombinator).toBe(true) - }) - it('should parse adjacent sibling combinator (+)', () => { - const { arena, rootNode, source } = parseSelectorInternal('h1 + p') + it('should parse attribute with value', () => { + const { arena, rootNode, source } = parseSelectorInternal('[type="text"]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === COMBINATOR) { - return getNodeText(arena, source, child).includes('+') - } - return false + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('[type="text"]') + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('type') }) - expect(hasCombinator).toBe(true) - }) - it('should parse general sibling combinator (~)', () => { - const { arena, rootNode, source } = parseSelectorInternal('h1 ~ p') + it('should parse attribute with operator', () => { + const { arena, rootNode, source } = parseSelectorInternal('[class^="btn-"]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const hasCombinator = children.some((child) => { - const type = arena.get_type(child) - if (type === COMBINATOR) { - return getNodeText(arena, source, child).includes('~') - } - return false + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('[class^="btn-"]') }) - expect(hasCombinator).toBe(true) - }) - }) - describe('Selector lists (comma-separated)', () => { - it('should parse selector list with two selectors', () => { - const { arena, rootNode, source } = parseSelectorInternal('div, p') + it('should parse element with attribute', () => { + const { arena, rootNode, source } = parseSelectorInternal('input[type="checkbox"]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(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(TYPE_SELECTOR) + expect(arena.get_type(children[1])).toBe(ATTRIBUTE_SELECTOR) + }) - // List contains the two selectors - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(2) - }) + it('should trim whitespace from attribute selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('[ data-test="value" ]') - it('should parse selector list with three selectors', () => { - const { arena, rootNode, source } = parseSelectorInternal('h1, h2, h3') + expect(rootNode).not.toBeNull() + if (!rootNode) return - 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(ATTRIBUTE_SELECTOR) + // 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" ]') + }) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + it('should trim comments from attribute selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('[/* comment */data-test="value"/* test */]') - // List contains the three selectors - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(3) - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should parse complex selector list', () => { - const { arena, rootNode, source } = parseSelectorInternal('div.container, .wrapper > p, #app') + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should trim whitespace and comments from attribute selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('[/* comment */ data-test="value" /* test */]') - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + expect(rootNode).not.toBeNull() + if (!rootNode) return - // List contains 3 NODE_SELECTOR wrappers: div.container, .wrapper > p, #app - const children = getChildren(arena, source, rootNode) - expect(children).toHaveLength(3) - }) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + // Content now stores just the attribute name + expect(getNodeContent(arena, source, child)).toBe('data-test') + }) - describe('Complex selectors', () => { - it('should parse navigation selector', () => { - const { arena, rootNode } = parseSelectorInternal('nav > ul > li > a') + it('should parse attribute with case-insensitive flag', () => { + const { arena, rootNode, source } = parseSelectorInternal('[type="text" i]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('[type="text" i]') + expect(getNodeContent(arena, source, child)).toBe('type') + expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) + }) - it('should parse form selector', () => { - const { arena, rootNode } = parseSelectorInternal('form input[type="text"]:focus') + it('should parse attribute with case-insensitive flag using CSSNode API', () => { + const root = parse_selector('[type="text" i]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(root).not.toBeNull() + if (!root) return - // Should parse without errors - expect(arena.get_type(rootNode)).toBeDefined() - }) + expect(root.type).toBe(SELECTOR_LIST) + let selector = root.first_child! + expect(selector.type).toBe(SELECTOR) + let attr = selector.first_child! + expect(attr.type).toBe(ATTRIBUTE_SELECTOR) + expect(attr.attr_flags).toBe(ATTR_FLAG_CASE_INSENSITIVE) + expect(attr.attr_operator).toBe(ATTR_OPERATOR_EQUAL) + }) - it('should parse complex nesting selector', () => { - const { arena, rootNode } = parseSelectorInternal('.parent .child:hover::before') + it('should parse attribute with case-sensitive flag', () => { + const { arena, rootNode, source } = parseSelectorInternal('[type="text" s]') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(arena.get_type(rootNode)).toBeDefined() - }) + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('[type="text" s]') + expect(getNodeContent(arena, source, child)).toBe('type') + expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_SENSITIVE) + }) + + it('should parse attribute with uppercase case-insensitive flag', () => { + const { arena, rootNode } = parseSelectorInternal('[type="text" I]') + + 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(ATTRIBUTE_SELECTOR) + expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) + }) + + it('should parse attribute with whitespace before flag', () => { + const { arena, rootNode } = parseSelectorInternal('[type="text" i]') - it('should parse multiple combinators', () => { - const { arena, rootNode, source } = parseSelectorInternal('div > .container + p ~ span') + expect(rootNode).not.toBeNull() + if (!rootNode) return - 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(ATTRIBUTE_SELECTOR) + expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_CASE_INSENSITIVE) + }) + + it('should parse attribute without flag', () => { + const { arena, rootNode } = parseSelectorInternal('[type="text"]') - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) + expect(rootNode).not.toBeNull() + if (!rootNode) return - const combinators = children.filter((child) => { - return arena.get_type(child) === COMBINATOR + const selectorWrapper = arena.get_first_child(rootNode) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(ATTRIBUTE_SELECTOR) + expect(arena.get_attr_flags(child)).toBe(ATTR_FLAG_NONE) }) - expect(combinators.length).toBeGreaterThan(0) + it('should handle flag with various operators', () => { + // Test with ^= operator + const test1 = parseSelectorInternal('[class^="btn" i]') + if (!test1.rootNode) throw new Error('Expected rootNode') + const wrapper1 = test1.arena.get_first_child(test1.rootNode) + if (!wrapper1) throw new Error('Expected wrapper1') + const child1 = test1.arena.get_first_child(wrapper1) + if (!child1) throw new Error('Expected child1') + expect(test1.arena.get_attr_flags(child1)).toBe(ATTR_FLAG_CASE_INSENSITIVE) + + // Test with $= operator + const test2 = parseSelectorInternal('[class$="btn" s]') + if (!test2.rootNode) throw new Error('Expected rootNode') + const wrapper2 = test2.arena.get_first_child(test2.rootNode) + if (!wrapper2) throw new Error('Expected wrapper2') + const child2 = test2.arena.get_first_child(wrapper2) + if (!child2) throw new Error('Expected child2') + expect(test2.arena.get_attr_flags(child2)).toBe(ATTR_FLAG_CASE_SENSITIVE) + + // Test with ~= operator + const test3 = parseSelectorInternal('[class~="active" i]') + if (!test3.rootNode) throw new Error('Expected rootNode') + const wrapper3 = test3.arena.get_first_child(test3.rootNode) + if (!wrapper3) throw new Error('Expected wrapper3') + const child3 = test3.arena.get_first_child(wrapper3) + if (!child3) throw new Error('Expected child3') + expect(test3.arena.get_attr_flags(child3)).toBe(ATTR_FLAG_CASE_INSENSITIVE) + }) }) - }) - describe('Modern CSS selectors', () => { - it('should parse :where() pseudo-class', () => { - const { arena, rootNode, source } = parseSelectorInternal(':where(article, section)') + describe('Combinators', () => { + it('should parse descendant combinator (space)', () => { + const { arena, rootNode, source } = parseSelectorInternal('div p') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children.length).toBeGreaterThanOrEqual(2) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, child)).toBe('where') - }) + // Should have: compound(div), combinator(space), compound(p) + const hasDescendantCombinator = children.some((child) => { + const type = arena.get_type(child) + return type === COMBINATOR + }) + expect(hasDescendantCombinator).toBe(true) + }) - it('should parse :has(a) pseudo-class', () => { - const root = parse_selector('div:has(a)') + it('should parse child combinator (>)', () => { + const { arena, rootNode, source } = parseSelectorInternal('div > p') - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(2) - const [_, has] = root.first_child!.children + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(has.text).toBe(':has(a)') + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) - // 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(SELECTOR_LIST) + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === COMBINATOR) { + return getNodeText(arena, source, child).includes('>') + } + return false + }) + expect(hasCombinator).toBe(true) + }) - // Selector list contains one selector - const selector = selectorList.first_child! - expect(selector.type).toBe(SELECTOR) + it('should parse adjacent sibling combinator (+)', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1 + p') - const selectorParts = selector.children - expect(selectorParts).toHaveLength(1) - expect(selectorParts[0].type).toBe(TYPE_SELECTOR) - expect(selectorParts[0].text).toBe('a') - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should parse :has(> p) pseudo-class', () => { - const root = parse_selector('div:has(> p)') + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(2) - const [div, has] = root.first_child!.children - expect(div.type).toBe(TYPE_SELECTOR) - expect(div.text).toBe('div') + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === COMBINATOR) { + return getNodeText(arena, source, child).includes('+') + } + return false + }) + expect(hasCombinator).toBe(true) + }) - expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(has.text).toBe(':has(> p)') + it('should parse general sibling combinator (~)', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1 ~ 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(SELECTOR_LIST) + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Selector list contains one selector - const selector = selectorList.first_child! - expect(selector.type).toBe(SELECTOR) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) - const selectorParts = selector.children - expect(selectorParts).toHaveLength(2) - expect(selectorParts[0].type).toBe(COMBINATOR) - expect(selectorParts[0].text).toBe('>') - expect(selectorParts[1].type).toBe(TYPE_SELECTOR) - expect(selectorParts[1].text).toBe('p') + const hasCombinator = children.some((child) => { + const type = arena.get_type(child) + if (type === COMBINATOR) { + return getNodeText(arena, source, child).includes('~') + } + return false + }) + expect(hasCombinator).toBe(true) + }) }) - 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(COMBINATOR) - expect(parts[0].text).toBe('+') - expect(parts[1].type).toBe(TYPE_SELECTOR) - expect(parts[1].text).toBe('p') - }) + describe('Selector lists (comma-separated)', () => { + it('should parse selector list with two selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('div, 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(COMBINATOR) - expect(parts[0].text).toBe('~') - expect(parts[1].type).toBe(TYPE_SELECTOR) - expect(parts[1].text).toBe('p') - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - 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! + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - expect(selector.children).toHaveLength(1) - expect(selector.children[0].type).toBe(TYPE_SELECTOR) - expect(selector.children[0].text).toBe('p') - }) + // List contains the two selectors + const children = getChildren(arena, source, rootNode) + expect(children).toHaveLength(2) + }) - 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 parse selector list with three selectors', () => { + const { arena, rootNode, source } = parseSelectorInternal('h1, h2, h3') - it('should handle empty :has()', () => { - const root = parse_selector('div:has()') - const has = root.first_child!.children[1] + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(has.text).toBe(':has()') - expect(has.has_children).toBe(true) // Has function syntax (parentheses) - }) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - it('should parse nesting with ampersand', () => { - const { arena, rootNode, source } = parseSelectorInternal('&.active') + // 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 + 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(NESTING_SELECTOR) - expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + + // List contains 3 NODE_SELECTOR wrappers: div.container, .wrapper > p, #app + const children = getChildren(arena, source, rootNode) + expect(children).toHaveLength(3) + }) }) - it('should parse nesting selector with descendant combinator as single selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('& span') + describe('Complex selectors', () => { + it('should parse navigation selector', () => { + const { arena, rootNode } = parseSelectorInternal('nav > ul > li > a') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + }) - // Should have only ONE selector, not two - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) + it('should parse form selector', () => { + const { arena, rootNode } = parseSelectorInternal('form input[type="text"]:focus') - // 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(NESTING_SELECTOR) - expect(arena.get_type(children[1])).toBe(COMBINATOR) - expect(arena.get_type(children[2])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[2])).toBe('span') - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - 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(NESTING_SELECTOR) - expect(arena.get_type(children[1])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[1]).trim()).toBe('>') - expect(arena.get_type(children[2])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[2])).toBe('div') - }) - }) + // Should parse without errors + expect(arena.get_type(rootNode)).toBeDefined() + }) - describe('Relaxed nesting (CSS Nesting Module Level 1)', () => { - it('should parse selector starting with child combinator', () => { - const { arena, rootNode, source } = parseSelectorInternal('> a') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Should have one selector - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) - - // The selector should have 2 children: combinator (>) and type selector (a) - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('>') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('a') - }) + 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 selector starting with next-sibling combinator', () => { - const { arena, rootNode, source } = parseSelectorInternal('+ div') + it('should parse multiple combinators', () => { + const { arena, rootNode, source } = parseSelectorInternal('div > .container + p ~ span') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('+') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('div') + const combinators = children.filter((child) => { + return arena.get_type(child) === COMBINATOR + }) + + expect(combinators.length).toBeGreaterThan(0) + }) }) - it('should parse selector starting with subsequent-sibling combinator', () => { - const { arena, rootNode, source } = parseSelectorInternal('~ span') + 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 + expect(rootNode).not.toBeNull() + if (!rootNode) return - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('~') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('span') - }) + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - it('should parse complex selector after leading combinator', () => { - const { arena, rootNode, source } = parseSelectorInternal('> a.link#nav[href]:hover') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) - - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) - - // Should have: combinator (>), type (a), class (.link), id (#nav), attribute ([href]), pseudo-class (:hover) - expect(children.length).toBeGreaterThanOrEqual(6) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('>') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('a') - expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) - expect(getNodeText(arena, source, children[2])).toBe('.link') - expect(arena.get_type(children[3])).toBe(ID_SELECTOR) - expect(getNodeText(arena, source, children[3])).toBe('#nav') - expect(arena.get_type(children[4])).toBe(ATTRIBUTE_SELECTOR) - expect(arena.get_type(children[5])).toBe(PSEUDO_CLASS_SELECTOR) - }) + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, child)).toBe('where') + }) - it('should parse multiple selectors with leading combinators', () => { - const { arena, rootNode, source } = parseSelectorInternal('> a, ~ span, + div') - - expect(rootNode).not.toBeNull() - if (!rootNode) return - - // Should have three selectors - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(3) - - // First selector: > a - let children = getChildren(arena, source, selectorWrappers[0]) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('>') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('a') - - // Second selector: ~ span - children = getChildren(arena, source, selectorWrappers[1]) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('~') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('span') - - // Third selector: + div - children = getChildren(arena, source, selectorWrappers[2]) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('+') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('div') - }) + it('should parse :has(a) pseudo-class', () => { + const root = parse_selector('div:has(a)') - it('should parse leading combinator with whitespace', () => { - const { arena, rootNode, source } = parseSelectorInternal('> a') + expect(root.first_child?.type).toBe(SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [_, has] = root.first_child!.children - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(has.text).toBe(':has(a)') - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) + // 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(SELECTOR_LIST) - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('>') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('a') - }) + // Selector list contains one selector + const selector = selectorList.first_child! + expect(selector.type).toBe(SELECTOR) - it('should parse selector with both leading and middle combinators', () => { - const { arena, rootNode, source } = parseSelectorInternal('> div span') + const selectorParts = selector.children + expect(selectorParts).toHaveLength(1) + expect(selectorParts[0].type).toBe(TYPE_SELECTOR) + expect(selectorParts[0].text).toBe('a') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse :has(> p) pseudo-class', () => { + const root = parse_selector('div:has(> p)') + + expect(root.first_child?.type).toBe(SELECTOR) + expect(root.first_child!.children).toHaveLength(2) + const [div, has] = root.first_child!.children + expect(div.type).toBe(TYPE_SELECTOR) + expect(div.text).toBe('div') + + expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) + 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(SELECTOR_LIST) + + // Selector list contains one selector + const selector = selectorList.first_child! + expect(selector.type).toBe(SELECTOR) + + const selectorParts = selector.children + expect(selectorParts).toHaveLength(2) + expect(selectorParts[0].type).toBe(COMBINATOR) + expect(selectorParts[0].text).toBe('>') + expect(selectorParts[1].type).toBe(TYPE_SELECTOR) + expect(selectorParts[1].text).toBe('p') + }) - const selectorWrappers = getChildren(arena, source, rootNode) - expect(selectorWrappers).toHaveLength(1) + 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(COMBINATOR) + expect(parts[0].text).toBe('+') + expect(parts[1].type).toBe(TYPE_SELECTOR) + expect(parts[1].text).toBe('p') + }) - const selectorWrapper = selectorWrappers[0] - const children = getChildren(arena, source, selectorWrapper) + 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(COMBINATOR) + expect(parts[0].text).toBe('~') + expect(parts[1].type).toBe(TYPE_SELECTOR) + expect(parts[1].text).toBe('p') + }) - // Should have: combinator (>), type (div), combinator (descendant), type (span) - expect(children).toHaveLength(4) - expect(arena.get_type(children[0])).toBe(COMBINATOR) - expect(getNodeText(arena, source, children[0]).trim()).toBe('>') - expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[1])).toBe('div') - expect(arena.get_type(children[2])).toBe(COMBINATOR) - expect(arena.get_type(children[3])).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, children[3])).toBe('span') - }) - }) + 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! - describe('Edge cases', () => { - it('should parse selector with multiple spaces', () => { - const { arena, rootNode, source } = parseSelectorInternal('div p') + expect(selector.children).toHaveLength(1) + expect(selector.children[0].type).toBe(TYPE_SELECTOR) + expect(selector.children[0].text).toBe('p') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + 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') + }) - // 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 handle empty :has()', () => { + const root = parse_selector('div:has()') + const has = root.first_child!.children[1] - it('should parse selector with tabs and newlines', () => { - const { arena, rootNode, source } = parseSelectorInternal('div\t\n\tp') + expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(has.text).toBe(':has()') + expect(has.has_children).toBe(true) // Has function syntax (parentheses) + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse nesting with ampersand', () => { + const { arena, rootNode, source } = parseSelectorInternal('&.active') - const children = getChildren(arena, source, rootNode) - expect(children.length).toBeGreaterThan(0) - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should handle empty selector gracefully', () => { - const { rootNode } = parseSelectorInternal('') + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NESTING_SELECTOR) + expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + }) - // Empty selector returns null - expect(rootNode).toBeNull() - }) + it('should parse nesting selector with descendant combinator as single selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('& span') - it('should parse class with dashes and numbers', () => { - const { arena, rootNode, source } = parseSelectorInternal('.my-class-123') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(rootNode).not.toBeNull() - if (!rootNode) return + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Should have only ONE selector, not two + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + // 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(NESTING_SELECTOR) + expect(arena.get_type(children[1])).toBe(COMBINATOR) + expect(arena.get_type(children[2])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[2])).toBe('span') + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, child)).toBe('.my-class-123') + 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(NESTING_SELECTOR) + expect(arena.get_type(children[1])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[1]).trim()).toBe('>') + expect(arena.get_type(children[2])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[2])).toBe('div') + }) }) - it('should parse hyphenated element names', () => { - const { arena, rootNode, source } = parseSelectorInternal('custom-element') + describe('Relaxed nesting (CSS Nesting Module Level 1)', () => { + it('should parse selector starting with child combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('> a') - expect(rootNode).not.toBeNull() - if (!rootNode) return + expect(rootNode).not.toBeNull() + if (!rootNode) return - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + // Should have one selector + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + // The selector should have 2 children: combinator (>) and type selector (a) + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('a') + }) - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(TYPE_SELECTOR) - expect(getNodeText(arena, source, child)).toBe('custom-element') - }) - }) + it('should parse selector starting with next-sibling combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('+ div') - describe('Real-world selectors', () => { - it('should parse BEM selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('.block__element--modifier') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(rootNode).not.toBeNull() - if (!rootNode) return + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - // Root is NODE_SELECTOR_LIST - expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('+') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('div') + }) - const selectorWrapper = arena.get_first_child(rootNode) - expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + it('should parse selector starting with subsequent-sibling combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('~ span') - const child = arena.get_first_child(selectorWrapper) - expect(arena.get_type(child)).toBe(CLASS_SELECTOR) - expect(getNodeContent(arena, source, child)).toBe('.block__element--modifier') - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should parse Bootstrap-style selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('.btn.btn-primary.btn-lg') + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - expect(rootNode).not.toBeNull() - if (!rootNode) return + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('~') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('span') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(3) - expect(arena.get_type(children[0])).toBe(CLASS_SELECTOR) - expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) - expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) - }) + it('should parse complex selector after leading combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('> a.link#nav[href]:hover') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) + + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + + // Should have: combinator (>), type (a), class (.link), id (#nav), attribute ([href]), pseudo-class (:hover) + expect(children.length).toBeGreaterThanOrEqual(6) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('a') + expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) + expect(getNodeText(arena, source, children[2])).toBe('.link') + expect(arena.get_type(children[3])).toBe(ID_SELECTOR) + expect(getNodeText(arena, source, children[3])).toBe('#nav') + expect(arena.get_type(children[4])).toBe(ATTRIBUTE_SELECTOR) + expect(arena.get_type(children[5])).toBe(PSEUDO_CLASS_SELECTOR) + }) - it('should parse table selector', () => { - const { arena, rootNode } = parseSelectorInternal('table tbody tr:nth-child(odd) td') + it('should parse multiple selectors with leading combinators', () => { + const { arena, rootNode, source } = parseSelectorInternal('> a, ~ span, + div') + + expect(rootNode).not.toBeNull() + if (!rootNode) return + + // Should have three selectors + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(3) + + // First selector: > a + let children = getChildren(arena, source, selectorWrappers[0]) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('a') + + // Second selector: ~ span + children = getChildren(arena, source, selectorWrappers[1]) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('~') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('span') + + // Third selector: + div + children = getChildren(arena, source, selectorWrappers[2]) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('+') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('div') + }) - expect(rootNode).not.toBeNull() - if (!rootNode) return + it('should parse leading combinator with whitespace', () => { + const { arena, rootNode, source } = parseSelectorInternal('> a') - // Should parse without errors - expect(arena.get_type(rootNode)).toBeDefined() - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - it('should parse nth-of-type selector', () => { - const { arena, rootNode, source } = parseSelectorInternal('p:nth-of-type(3)') + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - expect(rootNode).not.toBeNull() - if (!rootNode) return + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('a') + }) - const selectorWrapper = arena.get_first_child(rootNode) - const children = getChildren(arena, source, selectorWrapper) - expect(children).toHaveLength(2) - expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) - expect(getNodeContent(arena, source, children[1])).toBe('nth-of-type') - }) + it('should parse selector with both leading and middle combinators', () => { + const { arena, rootNode, source } = parseSelectorInternal('> div span') - it('should parse ul:has(:nth-child(1 of li))', () => { - const root = parse_selector('ul:has(:nth-child(1 of li))') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(2) - const [ul, has] = root.first_child!.children - expect(ul.type).toBe(TYPE_SELECTOR) - expect(ul.text).toBe('ul') + const selectorWrappers = getChildren(arena, source, rootNode) + expect(selectorWrappers).toHaveLength(1) - expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(has.text).toBe(':has(:nth-child(1 of li))') - }) + const selectorWrapper = selectorWrappers[0] + const children = getChildren(arena, source, selectorWrapper) - it('should parse :nth-child(1)', () => { - const root = parse_selector(':nth-child(1)') - - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(1) - const nth_child = root.first_child!.first_child! - expect(nth_child.type).toBe(PSEUDO_CLASS_SELECTOR) - 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(NTH_SELECTOR) - expect(anplusb.nth_a).toBe(null) // No 'a' coefficient, just 'b' - expect(anplusb.nth_b).toBe('1') + // Should have: combinator (>), type (div), combinator (descendant), type (span) + expect(children).toHaveLength(4) + expect(arena.get_type(children[0])).toBe(COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[1])).toBe('div') + expect(arena.get_type(children[2])).toBe(COMBINATOR) + expect(arena.get_type(children[3])).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, children[3])).toBe('span') + }) }) - it('should parse :nth-child(2n+1)', () => { - const root = parse_selector(':nth-child(2n+1)') - - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(1) - const nth_child = root.first_child!.first_child! - expect(nth_child.type).toBe(PSEUDO_CLASS_SELECTOR) - 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(NTH_SELECTOR) - expect(anplusb.nth_a).toBe('2n') - expect(anplusb.nth_b).toBe('+1') - expect(anplusb.text).toBe('2n+1') - }) + describe('An+B Expressions (from :nth-child, :nth-of-type, etc.)', () => { + describe('Simple integers (b only)', () => { + test(':nth-child(3)', () => { + const root = parse_selector(':nth-child(3)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.type).toBe(NTH_SELECTOR) + expect(nthNode.nth_a).toBe(null) + expect(nthNode.nth_b).toBe('3') + }) + + test(':nth-child(-5)', () => { + const root = parse_selector(':nth-child(-5)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe(null) + expect(nthNode.nth_b).toBe('-5') + }) + + test(':nth-child(0)', () => { + const root = parse_selector(':nth-child(0)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe(null) + expect(nthNode.nth_b).toBe('0') + }) + }) - it('should parse :nth-child(2n of .selector)', () => { - const root = parse_selector(':nth-child(2n of .selector)') - - expect(root.first_child?.type).toBe(SELECTOR) - expect(root.first_child!.children).toHaveLength(1) - const nth_child = root.first_child!.first_child! - expect(nth_child.type).toBe(PSEUDO_CLASS_SELECTOR) - 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(NTH_OF_SELECTOR) - 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(NTH_SELECTOR) - 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(SELECTOR_LIST) - const selector = selectorList.first_child! - expect(selector.type).toBe(SELECTOR) - expect(selector.first_child!.text).toBe('.selector') - }) + describe('Keywords', () => { + test('odd keyword', () => { + const root = parse_selector(':nth-child(odd)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('odd') + expect(nthNode.nth_b).toBe(null) + }) + + test('even keyword', () => { + const root = parse_selector(':nth-child(even)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('even') + expect(nthNode.nth_b).toBe(null) + }) + }) - test(':is(a, b)', () => { - const root = parse_selector(':is(a, b)') + describe('Just n (a only)', () => { + test('n', () => { + const root = parse_selector(':nth-child(n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('n') + expect(nthNode.nth_b).toBe(null) + }) + + test('+n', () => { + const root = parse_selector(':nth-child(+n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('+n') + expect(nthNode.nth_b).toBe(null) + }) + + test('-n', () => { + const root = parse_selector(':nth-child(-n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('-n') + expect(nthNode.nth_b).toBe(null) + }) + }) - // Root is selector list - expect(root.type).toBe(SELECTOR_LIST) + describe('Dimension tokens (An)', () => { + test('2n', () => { + const root = parse_selector(':nth-child(2n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('2n') + expect(nthNode.nth_b).toBe(null) + }) + + test('-3n', () => { + const root = parse_selector(':nth-child(-3n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('-3n') + expect(nthNode.nth_b).toBe(null) + }) + + test('+5n', () => { + const root = parse_selector(':nth-child(+5n)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('+5n') + expect(nthNode.nth_b).toBe(null) + }) + }) - // First selector in the list - const selector = root.first_child! - expect(selector.type).toBe(SELECTOR) + describe('An+B expressions', () => { + test('2n+1', () => { + const root = parse_selector(':nth-child(2n+1)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('2n') + expect(nthNode.nth_b).toBe('+1') + }) + + test('3n+5', () => { + const root = parse_selector(':nth-child(3n+5)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('3n') + expect(nthNode.nth_b).toBe('+5') + }) + + test('n+0', () => { + const root = parse_selector(':nth-child(n+0)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('n') + expect(nthNode.nth_b).toBe('+0') + }) + }) + + describe('An-B expressions', () => { + test('2n-1', () => { + const root = parse_selector(':nth-child(2n-1)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('2n') + expect(nthNode.nth_b).toBe('-1') + }) + + test('3n-5', () => { + const root = parse_selector(':nth-child(3n-5)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('3n') + expect(nthNode.nth_b).toBe('-5') + }) + + test('-n-1', () => { + const root = parse_selector(':nth-child(-n-1)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('-n') + expect(nthNode.nth_b).toBe('-1') + }) + }) - // Selector has :is() pseudo-class - const isPseudoClass = selector.first_child! - expect(isPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - 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(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(SELECTOR) - expect(selectorA.children).toHaveLength(1) - expect(selectorA.children[0].type).toBe(TYPE_SELECTOR) - expect(selectorA.children[0].text).toBe('a') - - // Second selector: 'b' - const selectorB = innerSelectorList.children[1] - expect(selectorB.type).toBe(SELECTOR) - expect(selectorB.children).toHaveLength(1) - expect(selectorB.children[0].type).toBe(TYPE_SELECTOR) - expect(selectorB.children[0].text).toBe('b') + describe('Whitespace handling', () => { + test('2n + 1 with spaces', () => { + const root = parse_selector(':nth-child(2n + 1)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('2n') + expect(nthNode.nth_b).toBe('+1') + }) + + test('2n - 1 with spaces', () => { + const root = parse_selector(':nth-child(2n - 1)') + const pseudoClass = root.first_child!.first_child! + const nthNode = pseudoClass.first_child! + expect(nthNode.nth_a).toBe('2n') + expect(nthNode.nth_b).toBe('-1') + }) + }) + + describe(':nth-of-type() with "of" syntax', () => { + test(':nth-child(2n of .selector)', () => { + const root = parse_selector(':nth-child(2n of .selector)') + const pseudoClass = root.first_child!.first_child! + const nthOfNode = pseudoClass.first_child! + expect(nthOfNode.type).toBe(NTH_OF_SELECTOR) + expect(nthOfNode.text).toBe('2n of .selector') + + // NTH_OF has two children: An+B and selector list + expect(nthOfNode.children).toHaveLength(2) + const anplusb = nthOfNode.first_child! + expect(anplusb.type).toBe(NTH_SELECTOR) + expect(anplusb.nth_a).toBe('2n') + expect(anplusb.nth_b).toBe(null) + + // Second child is the selector list + const selectorList = nthOfNode.children[1] + expect(selectorList.type).toBe(SELECTOR_LIST) + const selector = selectorList.first_child! + expect(selector.type).toBe(SELECTOR) + expect(selector.first_child!.text).toBe('.selector') + }) + + test(':nth-child(1 of li)', () => { + const root = parse_selector('ul:has(:nth-child(1 of li))') + const has = root.first_child!.children[1] + expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(has.text).toBe(':has(:nth-child(1 of li))') + }) + }) }) - test(':lang("nl", "de")', () => { - const root = parse_selector(':lang("nl", "de")') + describe(':lang() pseudo-class', () => { + test(':lang("nl", "de")', () => { + const root = parse_selector(':lang("nl", "de")') - // Root is selector list - expect(root.type).toBe(SELECTOR_LIST) + // Root is selector list + expect(root.type).toBe(SELECTOR_LIST) - // First selector in the list - const selector = root.first_child! - expect(selector.type).toBe(SELECTOR) + // First selector in the list + const selector = root.first_child! + expect(selector.type).toBe(SELECTOR) - // Selector has :lang() pseudo-class - const langPseudoClass = selector.first_child! - expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(langPseudoClass.text).toBe(':lang("nl", "de")') + // Selector has :lang() pseudo-class + const langPseudoClass = selector.first_child! + expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(langPseudoClass.text).toBe(':lang("nl", "de")') - // :lang() has 2 children - language identifiers - expect(langPseudoClass.has_children).toBe(true) - expect(langPseudoClass.children).toHaveLength(2) + // :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(LANG_SELECTOR) - expect(lang1.text).toBe('"nl"') + // First language identifier: "nl" + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(LANG_SELECTOR) + expect(lang1.text).toBe('"nl"') - // Second language identifier: "de" - const lang2 = langPseudoClass.children[1] - expect(lang2.type).toBe(LANG_SELECTOR) - expect(lang2.text).toBe('"de"') - }) + // Second language identifier: "de" + const lang2 = langPseudoClass.children[1] + expect(lang2.type).toBe(LANG_SELECTOR) + 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(PSEUDO_CLASS_SELECTOR) + expect(langPseudoClass.text).toBe(':lang(en, fr)') - test(':lang(en, fr) with unquoted identifiers', () => { - const root = parse_selector(':lang(en, fr)') + // :lang() has 2 children - language identifiers + expect(langPseudoClass.children).toHaveLength(2) - const selector = root.first_child! - const langPseudoClass = selector.first_child! + // First language identifier: en + const lang1 = langPseudoClass.children[0] + expect(lang1.type).toBe(LANG_SELECTOR) + expect(lang1.text).toBe('en') - expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(langPseudoClass.text).toBe(':lang(en, fr)') + // Second language identifier: fr + const lang2 = langPseudoClass.children[1] + expect(lang2.type).toBe(LANG_SELECTOR) + 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! - // :lang() has 2 children - language identifiers - expect(langPseudoClass.children).toHaveLength(2) + expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + 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(LANG_SELECTOR) + expect(lang1.text).toBe('en-US') + }) - // First language identifier: en - const lang1 = langPseudoClass.children[0] - expect(lang1.type).toBe(LANG_SELECTOR) - expect(lang1.text).toBe('en') + test(':lang("*-Latn") wildcard pattern', () => { + const root = parse_selector(':lang("*-Latn")') - // Second language identifier: fr - const lang2 = langPseudoClass.children[1] - expect(lang2.type).toBe(LANG_SELECTOR) - expect(lang2.text).toBe('fr') + const selector = root.first_child! + const langPseudoClass = selector.first_child! + + expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + 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(LANG_SELECTOR) + expect(lang1.text).toBe('"*-Latn"') + }) }) - test(':lang(en-US) single language with hyphen', () => { - const root = parse_selector(':lang(en-US)') + describe(':is() and :where() pseudo-classes', () => { + test(':is(a, b)', () => { + const root = parse_selector(':is(a, b)') + + // Root is selector list + expect(root.type).toBe(SELECTOR_LIST) + + // First selector in the list + const selector = root.first_child! + expect(selector.type).toBe(SELECTOR) - const selector = root.first_child! - const langPseudoClass = selector.first_child! + // Selector has :is() pseudo-class + const isPseudoClass = selector.first_child! + expect(isPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(isPseudoClass.text).toBe(':is(a, b)') - expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(langPseudoClass.text).toBe(':lang(en-US)') + // :is() has 1 child: a selector list + expect(isPseudoClass.children).toHaveLength(1) + const innerSelectorList = isPseudoClass.first_child! + expect(innerSelectorList.type).toBe(SELECTOR_LIST) - // :lang() has 1 child - single language identifier - expect(langPseudoClass.children).toHaveLength(1) + // The selector list has 2 children: selector for 'a' and selector for 'b' + expect(innerSelectorList.children).toHaveLength(2) - const lang1 = langPseudoClass.children[0] - expect(lang1.type).toBe(LANG_SELECTOR) - expect(lang1.text).toBe('en-US') + // First selector: 'a' + const selectorA = innerSelectorList.children[0] + expect(selectorA.type).toBe(SELECTOR) + expect(selectorA.children).toHaveLength(1) + expect(selectorA.children[0].type).toBe(TYPE_SELECTOR) + expect(selectorA.children[0].text).toBe('a') + + // Second selector: 'b' + const selectorB = innerSelectorList.children[1] + expect(selectorB.type).toBe(SELECTOR) + expect(selectorB.children).toHaveLength(1) + expect(selectorB.children[0].type).toBe(TYPE_SELECTOR) + expect(selectorB.children[0].text).toBe('b') + }) }) - test(':lang("*-Latn") wildcard pattern', () => { - const root = parse_selector(':lang("*-Latn")') + 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(SELECTOR_LIST) + + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, child)).toBe('.my-class-123') + }) - const selector = root.first_child! - const langPseudoClass = selector.first_child! + it('should parse hyphenated element names', () => { + const { arena, rootNode, source } = parseSelectorInternal('custom-element') - expect(langPseudoClass.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(langPseudoClass.text).toBe(':lang("*-Latn")') + expect(rootNode).not.toBeNull() + if (!rootNode) return - // :lang() has 1 child - wildcard language identifier - expect(langPseudoClass.children).toHaveLength(1) + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - const lang1 = langPseudoClass.children[0] - expect(lang1.type).toBe(LANG_SELECTOR) - expect(lang1.text).toBe('"*-Latn"') + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) + + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(TYPE_SELECTOR) + expect(getNodeText(arena, source, child)).toBe('custom-element') + }) }) - }) -}) -describe('parse_selector()', () => { - test('should parse simple type selector', () => { - const result = parse_selector('div') + describe('Real-world selectors', () => { + it('should parse BEM selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.block__element--modifier') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('div') - expect(result.has_children).toBe(true) - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - test('should parse class selector', () => { - const result = parse_selector('.classname') + // Root is NODE_SELECTOR_LIST + expect(arena.get_type(rootNode)).toBe(SELECTOR_LIST) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('.classname') - expect(result.has_children).toBe(true) - }) + const selectorWrapper = arena.get_first_child(rootNode) + expect(arena.get_type(selectorWrapper)).toBe(SELECTOR) - test('should parse ID selector', () => { - const result = parse_selector('#identifier') + const child = arena.get_first_child(selectorWrapper) + expect(arena.get_type(child)).toBe(CLASS_SELECTOR) + expect(getNodeContent(arena, source, child)).toBe('.block__element--modifier') + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('#identifier') - expect(result.has_children).toBe(true) - }) + it('should parse Bootstrap-style selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('.btn.btn-primary.btn-lg') - test('should parse compound selector', () => { - const result = parse_selector('div.class#id') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('div.class#id') - expect(result.has_children).toBe(true) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(3) + expect(arena.get_type(children[0])).toBe(CLASS_SELECTOR) + expect(arena.get_type(children[1])).toBe(CLASS_SELECTOR) + expect(arena.get_type(children[2])).toBe(CLASS_SELECTOR) + }) - test('should parse complex selector with combinator', () => { - const result = parse_selector('div.class > p#id') + it('should parse table selector', () => { + const { arena, rootNode } = parseSelectorInternal('table tbody tr:nth-child(odd) td') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('div.class > p#id') - expect(result.has_children).toBe(true) - }) + expect(rootNode).not.toBeNull() + if (!rootNode) return - test('should parse selector list', () => { - const result = parse_selector('h1, h2, h3') + // Should parse without errors + expect(arena.get_type(rootNode)).toBeDefined() + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('h1, h2, h3') - expect(result.has_children).toBe(true) - }) + it('should parse nth-of-type selector', () => { + const { arena, rootNode, source } = parseSelectorInternal('p:nth-of-type(3)') - test('should parse pseudo-class selector', () => { - const result = parse_selector('a:hover') + expect(rootNode).not.toBeNull() + if (!rootNode) return - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('a:hover') - expect(result.has_children).toBe(true) - }) + const selectorWrapper = arena.get_first_child(rootNode) + const children = getChildren(arena, source, selectorWrapper) + expect(children).toHaveLength(2) + expect(arena.get_type(children[1])).toBe(PSEUDO_CLASS_SELECTOR) + expect(getNodeContent(arena, source, children[1])).toBe('nth-of-type') + }) + }) - test('should parse pseudo-class with function', () => { - const result = parse_selector(':nth-child(2n+1)') + describe('Namespace selectors', () => { + test('should parse ns|* (namespace with universal selector)', () => { + const result = parse_selector('ns|*') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe(':nth-child(2n+1)') - expect(result.has_children).toBe(true) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|*') - test('should parse unknown pseudo-class without parens', () => { - let root = parse_selector(':hello') - let pseudo = root.first_child?.first_child - expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudo?.has_children).toBe(false) - }) + const selector = result.first_child + expect(selector?.type).toBe(SELECTOR) + expect(selector?.text).toBe('ns|*') - test('should parse unknown pseudo-class with empty parens', () => { - let root = parse_selector(':hello()') - let pseudo = root.first_child?.first_child - expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudo?.has_children).toBe(true) - expect(pseudo?.children.length).toBe(0) - }) + const universal = selector?.first_child + expect(universal?.type).toBe(UNIVERSAL_SELECTOR) + expect(universal?.text).toBe('ns|*') + expect(universal?.name).toBe('ns') + }) - test('should parse attribute selector', () => { - const result = parse_selector('[href^="https"]') + test('should parse ns|div (namespace with type selector)', () => { + const result = parse_selector('ns|div') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('[href^="https"]') - expect(result.has_children).toBe(true) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|div') - test('should parse universal selector', () => { - const result = parse_selector('*') + const selector = result.first_child + expect(selector?.type).toBe(SELECTOR) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('*') - expect(result.has_children).toBe(true) - }) + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('ns|div') + expect(typeSelector?.name).toBe('ns') + }) - test('should parse nesting selector', () => { - const result = parse_selector('& .child') + test('should parse *|* (any namespace with universal selector)', () => { + const result = parse_selector('*|*') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('& .child') - expect(result.has_children).toBe(true) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('*|*') - test('should parse descendant combinator', () => { - const result = parse_selector('div span') + const selector = result.first_child + const universal = selector?.first_child + expect(universal?.type).toBe(UNIVERSAL_SELECTOR) + expect(universal?.text).toBe('*|*') + expect(universal?.name).toBe('*') + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('div span') - expect(result.has_children).toBe(true) - }) + test('should parse *|div (any namespace with type selector)', () => { + const result = parse_selector('*|div') - test('should parse child combinator', () => { - const result = parse_selector('ul > li') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('*|div') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ul > li') - expect(result.has_children).toBe(true) - }) + const selector = result.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('*|div') + expect(typeSelector?.name).toBe('*') + }) - test('should parse adjacent sibling combinator', () => { - const result = parse_selector('h1 + p') + test('should parse |* (empty namespace with universal selector)', () => { + const result = parse_selector('|*') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('h1 + p') - expect(result.has_children).toBe(true) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('|*') - test('should parse general sibling combinator', () => { - const result = parse_selector('h1 ~ p') + const selector = result.first_child + const universal = selector?.first_child + expect(universal?.type).toBe(UNIVERSAL_SELECTOR) + expect(universal?.text).toBe('|*') + // Empty namespace should result in empty name + expect(universal?.name).toBe('|') + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('h1 ~ p') - expect(result.has_children).toBe(true) - }) + test('should parse |div (empty namespace with type selector)', () => { + const result = parse_selector('|div') - test('should parse modern pseudo-classes', () => { - const result = parse_selector(':is(h1, h2, h3)') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('|div') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe(':is(h1, h2, h3)') - expect(result.has_children).toBe(true) - }) + const selector = result.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('|div') + // Empty namespace should result in empty name + expect(typeSelector?.name).toBe('|') + }) - test('should parse :where() pseudo-class', () => { - const result = parse_selector(':where(.a, .b)') + test('should parse namespace selector with class', () => { + const result = parse_selector('ns|div.class') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe(':where(.a, .b)') - expect(result.has_children).toBe(true) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|div.class') - test('should parse :has() pseudo-class', () => { - const result = parse_selector('div:has(> img)') + const selector = result.first_child + const children = selector?.children || [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[0].text).toBe('ns|div') + expect(children[0].name).toBe('ns') + expect(children[1].type).toBe(CLASS_SELECTOR) + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('div:has(> img)') - expect(result.has_children).toBe(true) - }) + test('should parse namespace selector with ID', () => { + const result = parse_selector('ns|*#id') - test('should parse empty selector', () => { - const result = parse_selector('') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|*#id') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('') - }) + const selector = result.first_child + const children = selector?.children || [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(UNIVERSAL_SELECTOR) + expect(children[0].text).toBe('ns|*') + expect(children[1].type).toBe(ID_SELECTOR) + }) - test('should be iterable', () => { - const result = parse_selector('div.class') + test('should parse namespace selector in complex selector', () => { + const result = parse_selector('ns|div > *|span') - let childCount = 0 - for (const _child of result) { - childCount++ - } + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|div > *|span') - expect(childCount).toBeGreaterThan(0) - }) + const selector = result.first_child + const children = selector?.children || [] + expect(children.length).toBe(3) // div, >, span + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[0].text).toBe('ns|div') + expect(children[1].type).toBe(COMBINATOR) + expect(children[2].type).toBe(TYPE_SELECTOR) + expect(children[2].text).toBe('*|span') + }) - test('should have working children property', () => { - const result = parse_selector('div, span') + test('should parse namespace selector in selector list', () => { + const result = parse_selector('ns|div, |span, *|p') - expect(result.has_children).toBe(true) - expect(result.children.length).toBeGreaterThan(0) - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|div, |span, *|p') - describe('Namespace selectors', () => { - test('should parse ns|* (namespace with universal selector)', () => { - const result = parse_selector('ns|*') + const selectors = result.children + expect(selectors.length).toBe(3) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|*') + const firstType = selectors[0].first_child + expect(firstType?.type).toBe(TYPE_SELECTOR) + expect(firstType?.text).toBe('ns|div') + expect(firstType?.name).toBe('ns') - const selector = result.first_child - expect(selector?.type).toBe(SELECTOR) - expect(selector?.text).toBe('ns|*') + const secondType = selectors[1].first_child + expect(secondType?.type).toBe(TYPE_SELECTOR) + expect(secondType?.text).toBe('|span') + expect(secondType?.name).toBe('|') - const universal = selector?.first_child - expect(universal?.type).toBe(UNIVERSAL_SELECTOR) - expect(universal?.text).toBe('ns|*') - expect(universal?.name).toBe('ns') - }) + const thirdType = selectors[2].first_child + expect(thirdType?.type).toBe(TYPE_SELECTOR) + expect(thirdType?.text).toBe('*|p') + expect(thirdType?.name).toBe('*') + }) - test('should parse ns|div (namespace with type selector)', () => { - const result = parse_selector('ns|div') + test('should parse namespace selector with attribute', () => { + const result = parse_selector('ns|div[attr="value"]') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|div') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|div[attr="value"]') - const selector = result.first_child - expect(selector?.type).toBe(SELECTOR) + const selector = result.first_child + const children = selector?.children || [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[0].name).toBe('ns') + expect(children[1].type).toBe(ATTRIBUTE_SELECTOR) + }) - const typeSelector = selector?.first_child - expect(typeSelector?.type).toBe(TYPE_SELECTOR) - expect(typeSelector?.text).toBe('ns|div') - expect(typeSelector?.name).toBe('ns') - }) + test('should parse namespace selector with pseudo-class', () => { + const result = parse_selector('ns|a:hover') - test('should parse *|* (any namespace with universal selector)', () => { - const result = parse_selector('*|*') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ns|a:hover') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('*|*') + const selector = result.first_child + const children = selector?.children || [] + expect(children.length).toBe(2) + expect(children[0].type).toBe(TYPE_SELECTOR) + expect(children[0].name).toBe('ns') + expect(children[1].type).toBe(PSEUDO_CLASS_SELECTOR) + }) - const selector = result.first_child - const universal = selector?.first_child - expect(universal?.type).toBe(UNIVERSAL_SELECTOR) - expect(universal?.text).toBe('*|*') - expect(universal?.name).toBe('*') - }) + test('should parse namespace with various identifiers', () => { + const result = parse_selector('svg|rect') - test('should parse *|div (any namespace with type selector)', () => { - const result = parse_selector('*|div') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('svg|rect') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('*|div') + const selector = result.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('svg|rect') + expect(typeSelector?.name).toBe('svg') + }) - const selector = result.first_child - const typeSelector = selector?.first_child - expect(typeSelector?.type).toBe(TYPE_SELECTOR) - expect(typeSelector?.text).toBe('*|div') - expect(typeSelector?.name).toBe('*') - }) + test('should parse long namespace identifier', () => { + const result = parse_selector('myNamespace|element') - test('should parse |* (empty namespace with universal selector)', () => { - const result = parse_selector('|*') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('myNamespace|element') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('|*') + const selector = result.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.name).toBe('myNamespace') + }) - const selector = result.first_child - const universal = selector?.first_child - expect(universal?.type).toBe(UNIVERSAL_SELECTOR) - expect(universal?.text).toBe('|*') - // Empty namespace should result in empty name - expect(universal?.name).toBe('|') - }) + test('should handle namespace in nested pseudo-class', () => { + const result = parse_selector(':is(ns|div, *|span)') - test('should parse |div (empty namespace with type selector)', () => { - const result = parse_selector('|div') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe(':is(ns|div, *|span)') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('|div') + const selector = result.first_child + const pseudo = selector?.first_child + expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudo?.name).toBe('is') - const selector = result.first_child - const typeSelector = selector?.first_child - expect(typeSelector?.type).toBe(TYPE_SELECTOR) - expect(typeSelector?.text).toBe('|div') - // Empty namespace should result in empty name - expect(typeSelector?.name).toBe('|') - }) + // The content should contain namespace selectors + const nestedList = pseudo?.first_child + expect(nestedList?.type).toBe(SELECTOR_LIST) - test('should parse namespace selector with class', () => { - const result = parse_selector('ns|div.class') + const nestedSelectors = nestedList?.children || [] + expect(nestedSelectors.length).toBe(2) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|div.class') + const firstNestedType = nestedSelectors[0].first_child + expect(firstNestedType?.type).toBe(TYPE_SELECTOR) + expect(firstNestedType?.text).toBe('ns|div') - const selector = result.first_child - const children = selector?.children || [] - expect(children.length).toBe(2) - expect(children[0].type).toBe(TYPE_SELECTOR) - expect(children[0].text).toBe('ns|div') - expect(children[0].name).toBe('ns') - expect(children[1].type).toBe(CLASS_SELECTOR) + const secondNestedType = nestedSelectors[1].first_child + expect(secondNestedType?.type).toBe(TYPE_SELECTOR) + expect(secondNestedType?.text).toBe('*|span') + }) }) - test('should parse namespace selector with ID', () => { - const result = parse_selector('ns|*#id') + describe('API methods', () => { + test('should parse simple type selector', () => { + const result = parse_selector('div') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|*#id') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('div') + expect(result.has_children).toBe(true) + }) - const selector = result.first_child - const children = selector?.children || [] - expect(children.length).toBe(2) - expect(children[0].type).toBe(UNIVERSAL_SELECTOR) - expect(children[0].text).toBe('ns|*') - expect(children[1].type).toBe(ID_SELECTOR) - }) + test('should parse class selector', () => { + const result = parse_selector('.classname') - test('should parse namespace selector in complex selector', () => { - const result = parse_selector('ns|div > *|span') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('.classname') + expect(result.has_children).toBe(true) + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|div > *|span') + test('should parse ID selector', () => { + const result = parse_selector('#identifier') - const selector = result.first_child - const children = selector?.children || [] - expect(children.length).toBe(3) // div, >, span - expect(children[0].type).toBe(TYPE_SELECTOR) - expect(children[0].text).toBe('ns|div') - expect(children[1].type).toBe(COMBINATOR) - expect(children[2].type).toBe(TYPE_SELECTOR) - expect(children[2].text).toBe('*|span') - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('#identifier') + expect(result.has_children).toBe(true) + }) - test('should parse namespace selector in selector list', () => { - const result = parse_selector('ns|div, |span, *|p') + test('should parse compound selector', () => { + const result = parse_selector('div.class#id') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|div, |span, *|p') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('div.class#id') + expect(result.has_children).toBe(true) + }) - const selectors = result.children - expect(selectors.length).toBe(3) + test('should parse complex selector with combinator', () => { + const result = parse_selector('div.class > p#id') - const firstType = selectors[0].first_child - expect(firstType?.type).toBe(TYPE_SELECTOR) - expect(firstType?.text).toBe('ns|div') - expect(firstType?.name).toBe('ns') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('div.class > p#id') + expect(result.has_children).toBe(true) + }) - const secondType = selectors[1].first_child - expect(secondType?.type).toBe(TYPE_SELECTOR) - expect(secondType?.text).toBe('|span') - expect(secondType?.name).toBe('|') + test('should parse selector list', () => { + const result = parse_selector('h1, h2, h3') - const thirdType = selectors[2].first_child - expect(thirdType?.type).toBe(TYPE_SELECTOR) - expect(thirdType?.text).toBe('*|p') - expect(thirdType?.name).toBe('*') - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('h1, h2, h3') + expect(result.has_children).toBe(true) + }) - test('should parse namespace selector with attribute', () => { - const result = parse_selector('ns|div[attr="value"]') + test('should parse pseudo-class selector', () => { + const result = parse_selector('a:hover') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|div[attr="value"]') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('a:hover') + expect(result.has_children).toBe(true) + }) - const selector = result.first_child - const children = selector?.children || [] - expect(children.length).toBe(2) - expect(children[0].type).toBe(TYPE_SELECTOR) - expect(children[0].name).toBe('ns') - expect(children[1].type).toBe(ATTRIBUTE_SELECTOR) - }) + test('should parse pseudo-class with function', () => { + const result = parse_selector(':nth-child(2n+1)') - test('should parse namespace selector with pseudo-class', () => { - const result = parse_selector('ns|a:hover') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe(':nth-child(2n+1)') + expect(result.has_children).toBe(true) + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('ns|a:hover') + test('should parse unknown pseudo-class without parens', () => { + let root = parse_selector(':hello') + let pseudo = root.first_child?.first_child + expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudo?.has_children).toBe(false) + }) - const selector = result.first_child - const children = selector?.children || [] - expect(children.length).toBe(2) - expect(children[0].type).toBe(TYPE_SELECTOR) - expect(children[0].name).toBe('ns') - expect(children[1].type).toBe(PSEUDO_CLASS_SELECTOR) - }) + test('should parse unknown pseudo-class with empty parens', () => { + let root = parse_selector(':hello()') + let pseudo = root.first_child?.first_child + expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudo?.has_children).toBe(true) + expect(pseudo?.children.length).toBe(0) + }) - test('should parse namespace with various identifiers', () => { - const result = parse_selector('svg|rect') + test('should parse attribute selector', () => { + const result = parse_selector('[href^="https"]') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('svg|rect') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('[href^="https"]') + expect(result.has_children).toBe(true) + }) - const selector = result.first_child - const typeSelector = selector?.first_child - expect(typeSelector?.type).toBe(TYPE_SELECTOR) - expect(typeSelector?.text).toBe('svg|rect') - expect(typeSelector?.name).toBe('svg') - }) + test('should parse universal selector', () => { + const result = parse_selector('*') - test('should parse long namespace identifier', () => { - const result = parse_selector('myNamespace|element') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('*') + expect(result.has_children).toBe(true) + }) - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe('myNamespace|element') + test('should parse nesting selector', () => { + const result = parse_selector('& .child') - const selector = result.first_child - const typeSelector = selector?.first_child - expect(typeSelector?.type).toBe(TYPE_SELECTOR) - expect(typeSelector?.name).toBe('myNamespace') - }) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('& .child') + expect(result.has_children).toBe(true) + }) - test('should handle namespace in nested pseudo-class', () => { - const result = parse_selector(':is(ns|div, *|span)') + test('should parse descendant combinator', () => { + const result = parse_selector('div span') - expect(result.type).toBe(SELECTOR_LIST) - expect(result.text).toBe(':is(ns|div, *|span)') + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('div span') + expect(result.has_children).toBe(true) + }) - const selector = result.first_child - const pseudo = selector?.first_child - expect(pseudo?.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(pseudo?.name).toBe('is') + test('should parse child combinator', () => { + const result = parse_selector('ul > li') - // The content should contain namespace selectors - const nestedList = pseudo?.first_child - expect(nestedList?.type).toBe(SELECTOR_LIST) + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('ul > li') + expect(result.has_children).toBe(true) + }) + + test('should parse adjacent sibling combinator', () => { + const result = parse_selector('h1 + p') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('h1 + p') + expect(result.has_children).toBe(true) + }) + + test('should parse general sibling combinator', () => { + const result = parse_selector('h1 ~ p') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('h1 ~ p') + expect(result.has_children).toBe(true) + }) + + test('should parse modern pseudo-classes', () => { + const result = parse_selector(':is(h1, h2, h3)') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe(':is(h1, h2, h3)') + expect(result.has_children).toBe(true) + }) + + test('should parse :where() pseudo-class', () => { + const result = parse_selector(':where(.a, .b)') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe(':where(.a, .b)') + expect(result.has_children).toBe(true) + }) + + test('should parse :has() pseudo-class', () => { + const result = parse_selector('div:has(> img)') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('div:has(> img)') + expect(result.has_children).toBe(true) + }) + + test('should parse empty selector', () => { + const result = parse_selector('') + + expect(result.type).toBe(SELECTOR_LIST) + expect(result.text).toBe('') + }) + + test('should be iterable', () => { + const result = parse_selector('div.class') + + let childCount = 0 + for (const _child of result) { + childCount++ + } - const nestedSelectors = nestedList?.children || [] - expect(nestedSelectors.length).toBe(2) + expect(childCount).toBeGreaterThan(0) + }) - const firstNestedType = nestedSelectors[0].first_child - expect(firstNestedType?.type).toBe(TYPE_SELECTOR) - expect(firstNestedType?.text).toBe('ns|div') + test('should have working children property', () => { + const result = parse_selector('div, span') - const secondNestedType = nestedSelectors[1].first_child - expect(secondNestedType?.type).toBe(TYPE_SELECTOR) - expect(secondNestedType?.text).toBe('*|span') + expect(result.has_children).toBe(true) + expect(result.children.length).toBeGreaterThan(0) + }) }) }) }) diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index b826999..867edf7 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -2,521 +2,686 @@ import { describe, it, expect } from 'vitest' import { Parser } from './parse' import { IDENTIFIER, NUMBER, DIMENSION, STRING, HASH, FUNCTION, OPERATOR, PARENTHESIS, URL } from './arena' -describe('ValueParser', () => { - describe('Simple values', () => { - it('should parse keyword values', () => { - const parser = new Parser('body { color: red; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child // selector → block → declaration +describe('Value Node Types', () => { + // Helper to get first value node from a declaration + const getValue = (css: string) => { + const parser = new Parser(css) + const root = parser.parse() + const rule = root.first_child + const decl = rule?.first_child?.next_sibling?.first_child // selector → block → declaration + return decl?.values[0] + } - expect(decl?.value).toBe('red') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(IDENTIFIER) - expect(decl?.values[0].text).toBe('red') + describe('Locations', () => { + describe('IDENTIFIER', () => { + it('should have correct offset and length', () => { + const value = getValue('div { color: red; }') + expect(value?.offset).toBe(13) + expect(value?.length).toBe(3) + }) }) - it('should parse number values', () => { - const parser = new Parser('body { opacity: 0.5; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('NUMBER', () => { + it('should have correct offset and length', () => { + const value = getValue('div { opacity: 0.5; }') + expect(value?.offset).toBe(15) + expect(value?.length).toBe(3) + }) - expect(decl?.value).toBe('0.5') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(NUMBER) - expect(decl?.values[0].text).toBe('0.5') }) - it('should parse px dimension values', () => { - const parser = new Parser('body { width: 100px; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('DIMENSION', () => { + it('should have correct offset and length', () => { + const value = getValue('div { width: 100px; }') + expect(value?.offset).toBe(13) + expect(value?.length).toBe(5) + }) - expect(decl?.value).toBe('100px') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('100px') - expect(decl?.values[0].value).toBe(100) - expect(decl?.values[0].unit).toBe('px') }) - it('should parse px dimension values', () => { - const parser = new Parser('body { font-size: 3em; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('STRING', () => { + it('should have correct offset and length', () => { + const value = getValue('div { content: "hello"; }') + expect(value?.offset).toBe(15) + expect(value?.length).toBe(7) + }) - expect(decl?.value).toBe('3em') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('3em') - expect(decl?.values[0].value).toBe(3) - expect(decl?.values[0].unit).toBe('em') }) - it('should parse percentage values', () => { - const parser = new Parser('body { width: 50%; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('HASH', () => { + it('should have correct offset and length', () => { + const value = getValue('div { color: #ff0000; }') + expect(value?.offset).toBe(13) + expect(value?.length).toBe(7) + }) - expect(decl?.value).toBe('50%') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('50%') }) - it('should parse string values', () => { - const parser = new Parser('body { content: "hello"; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('FUNCTION', () => { + it('should have correct offset and length', () => { + const value = getValue('div { color: rgb(255, 0, 0); }') + expect(value?.offset).toBe(13) + expect(value?.length).toBe(14) + }) - expect(decl?.value).toBe('"hello"') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(STRING) - expect(decl?.values[0].text).toBe('"hello"') }) - it('should parse color values', () => { - const parser = new Parser('body { color: #ff0000; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('OPERATOR', () => { + it('should have correct offset and length', () => { + const parser = new Parser('div { font-family: Arial, sans-serif; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const comma = decl?.values[1] + expect(comma?.offset).toBe(24) + expect(comma?.length).toBe(1) + }) - expect(decl?.value).toBe('#ff0000') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(HASH) - expect(decl?.values[0].text).toBe('#ff0000') }) - }) - describe('Space-separated values', () => { - it('should parse multiple keywords', () => { - const parser = new Parser('body { font-family: Arial, sans-serif; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(3) - expect(decl?.values[0].type).toBe(IDENTIFIER) - expect(decl?.values[0].text).toBe('Arial') - expect(decl?.values[1].type).toBe(OPERATOR) - expect(decl?.values[1].text).toBe(',') - expect(decl?.values[2].type).toBe(IDENTIFIER) - expect(decl?.values[2].text).toBe('sans-serif') + describe('PARENTHESIS', () => { + it('should have correct offset and length', () => { + const parser = new Parser('div { width: calc((100% - 50px) / 2); }') + const root = parser.parse() + const func = root.first_child?.first_child?.next_sibling?.first_child?.values[0] + const paren = func?.children[0] + expect(paren?.offset).toBe(18) + expect(paren?.length).toBe(13) + }) }) - it('should parse margin shorthand', () => { - const parser = new Parser('body { margin: 10px 20px 30px 40px; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(4) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('10px') - expect(decl?.values[1].type).toBe(DIMENSION) - expect(decl?.values[1].text).toBe('20px') - expect(decl?.values[2].type).toBe(DIMENSION) - expect(decl?.values[2].text).toBe('30px') - expect(decl?.values[3].type).toBe(DIMENSION) - expect(decl?.values[3].text).toBe('40px') - }) - - it('should parse mixed value types', () => { - const parser = new Parser('body { border: 1px solid red; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child + describe('URL', () => { + it('should have correct offset and length', () => { + const value = getValue('div { background: url("image.png"); }') + expect(value?.offset).toBe(18) + expect(value?.length).toBe(16) + }) - expect(decl?.values).toHaveLength(3) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('1px') - expect(decl?.values[1].type).toBe(IDENTIFIER) - expect(decl?.values[1].text).toBe('solid') - expect(decl?.values[2].type).toBe(IDENTIFIER) - expect(decl?.values[2].text).toBe('red') }) }) - describe('Function values', () => { - it('should parse simple function', () => { - const parser = new Parser('body { color: rgb(255, 0, 0); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(FUNCTION) - expect(decl?.values[0].name).toBe('rgb') - expect(decl?.values[0].text).toBe('rgb(255, 0, 0)') + describe('Types', () => { + it('IDENTIFIER type constant', () => { + const value = getValue('div { color: red; }') + expect(value?.type).toBe(IDENTIFIER) }) - it('should parse function arguments', () => { - const parser = new Parser('body { color: rgb(255, 0, 0); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.children).toHaveLength(5) - expect(func?.children[0].type).toBe(NUMBER) - expect(func?.children[0].text).toBe('255') - expect(func?.children[1].type).toBe(OPERATOR) - expect(func?.children[1].text).toBe(',') - expect(func?.children[2].type).toBe(NUMBER) - expect(func?.children[2].text).toBe('0') - expect(func?.children[3].type).toBe(OPERATOR) - expect(func?.children[3].text).toBe(',') - expect(func?.children[4].type).toBe(NUMBER) - expect(func?.children[4].text).toBe('0') - }) - - it('should parse nested functions', () => { - const parser = new Parser('body { width: calc(100% - 20px); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(FUNCTION) - expect(decl?.values[0].name).toBe('calc') - expect(decl?.values[0].children).toHaveLength(3) - expect(decl?.values[0].children[0].type).toBe(DIMENSION) - expect(decl?.values[0].children[0].text).toBe('100%') - expect(decl?.values[0].children[1].type).toBe(OPERATOR) - expect(decl?.values[0].children[1].text).toBe('-') - expect(decl?.values[0].children[2].type).toBe(DIMENSION) - expect(decl?.values[0].children[2].text).toBe('20px') - }) - - it('should parse var() function', () => { - const parser = new Parser('body { color: var(--primary-color); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(FUNCTION) - expect(decl?.values[0].name).toBe('var') - expect(decl?.values[0].children).toHaveLength(1) - expect(decl?.values[0].children[0].type).toBe(IDENTIFIER) - expect(decl?.values[0].children[0].text).toBe('--primary-color') + it('NUMBER type constant', () => { + const value = getValue('div { opacity: 0.5; }') + expect(value?.type).toBe(NUMBER) }) - it('should parse url() function with quoted string', () => { - const parser = new Parser('body { background: url("image.png"); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(URL) - expect(decl?.values[0].name).toBe('url') - expect(decl?.values[0].children).toHaveLength(1) - expect(decl?.values[0].children[0].type).toBe(STRING) - expect(decl?.values[0].children[0].text).toBe('"image.png"') + it('DIMENSION type constant', () => { + const value = getValue('div { width: 100px; }') + expect(value?.type).toBe(DIMENSION) }) - it('should parse url() function with unquoted URL containing dots', () => { - const parser = new Parser('body { cursor: url(mycursor.cur); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(URL) - expect(func?.name).toBe('url') - - // URL function should not parse children - content is available via node.value - expect(func?.has_children).toBe(false) - expect(func?.text).toBe('url(mycursor.cur)') - expect(func?.value).toBe('mycursor.cur') + it('STRING type constant', () => { + const value = getValue('div { content: "hello"; }') + expect(value?.type).toBe(STRING) }) - it('should parse src() function with unquoted URL', () => { - const parser = new Parser('body { content: src(myfont.woff2); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('src') - expect(func?.has_children).toBe(false) - expect(func?.text).toBe('src(myfont.woff2)') - expect(func?.value).toBe('myfont.woff2') + it('HASH type constant', () => { + const value = getValue('div { color: #ff0000; }') + expect(value?.type).toBe(HASH) }) - it('should parse url() with base64 data URL', () => { - const parser = new Parser('body { background: url(data:image/png;base64,iVBORw0KGg); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(URL) - expect(func?.name).toBe('url') - expect(func?.has_children).toBe(false) - expect(func?.value).toBe('data:image/png;base64,iVBORw0KGg') + it('FUNCTION type constant', () => { + const value = getValue('div { color: rgb(255, 0, 0); }') + expect(value?.type).toBe(FUNCTION) }) - it('should parse url() with inline SVG', () => { - const parser = new Parser('body { background: url(data:image/svg+xml,); }') + it('OPERATOR type constant', () => { + const parser = new Parser('div { font-family: Arial, sans-serif; }') const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(URL) - expect(func?.name).toBe('url') - expect(func?.has_children).toBe(false) - expect(func?.value).toBe('data:image/svg+xml,') + const comma = root.first_child?.first_child?.next_sibling?.first_child?.values[1] + expect(comma?.type).toBe(OPERATOR) }) - it('should provide node.value for other functions like calc()', () => { - const parser = new Parser('body { width: calc(100% - 20px); }') + it('PARENTHESIS type constant', () => { + const parser = new Parser('div { width: calc((100% - 50px) / 2); }') const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('calc') - expect(func?.text).toBe('calc(100% - 20px)') - expect(func?.value).toBe('100% - 20px') - expect(func?.has_children).toBe(true) // calc() parses its children + const func = root.first_child?.first_child?.next_sibling?.first_child?.values[0] + const paren = func?.children[0] + expect(paren?.type).toBe(PARENTHESIS) }) - it('should provide node.value for var() function', () => { - const parser = new Parser('body { color: var(--primary-color); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('var') - expect(func?.text).toBe('var(--primary-color)') - expect(func?.value).toBe('--primary-color') - expect(func?.has_children).toBe(true) // var() parses its children + it('URL type constant', () => { + const value = getValue('div { background: url("image.png"); }') + expect(value?.type).toBe(URL) }) }) - describe('Complex values', () => { - it('should parse complex background value', () => { - const parser = new Parser('body { background: url("bg.png") no-repeat center center / cover; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values.length).toBeGreaterThan(1) - expect(decl?.values[0].type).toBe(URL) - expect(decl?.values[0].name).toBe('url') - expect(decl?.values[1].type).toBe(IDENTIFIER) - expect(decl?.values[1].text).toBe('no-repeat') + describe('Type Names', () => { + it('IDENTIFIER type_name', () => { + const value = getValue('div { color: red; }') + expect(value?.type_name).toBe('Identifier') }) - it('should parse transform value', () => { - const parser = new Parser('body { transform: translateX(10px) rotate(45deg); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(2) - expect(decl?.values[0].type).toBe(FUNCTION) - expect(decl?.values[0].name).toBe('translateX') - expect(decl?.values[1].type).toBe(FUNCTION) - expect(decl?.values[1].name).toBe('rotate') + it('NUMBER type_name', () => { + const value = getValue('div { opacity: 0.5; }') + expect(value?.type_name).toBe('Number') }) - it('should parse filter value', () => { - const parser = new Parser('body { filter: blur(5px) brightness(1.2); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(2) - expect(decl?.values[0].type).toBe(FUNCTION) - expect(decl?.values[0].name).toBe('blur') - expect(decl?.values[0].children[0].text).toBe('5px') - expect(decl?.values[1].type).toBe(FUNCTION) - expect(decl?.values[1].name).toBe('brightness') - expect(decl?.values[1].children[0].text).toBe('1.2') + it('DIMENSION type_name', () => { + const value = getValue('div { width: 100px; }') + expect(value?.type_name).toBe('Dimension') }) - }) - describe('Edge cases', () => { - it('should handle empty value', () => { - const parser = new Parser('body { color: ; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.value).toBeNull() - expect(decl?.values).toHaveLength(0) + it('STRING type_name', () => { + const value = getValue('div { content: "hello"; }') + expect(value?.type_name).toBe('String') }) - it('should handle value with !important', () => { - const parser = new Parser('body { color: red !important; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.value).toBe('red') - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(IDENTIFIER) - expect(decl?.values[0].text).toBe('red') - expect(decl?.is_important).toBe(true) + it('HASH type_name', () => { + const value = getValue('div { color: #ff0000; }') + expect(value?.type_name).toBe('Hash') }) - it('should handle negative numbers', () => { - const parser = new Parser('body { margin: -10px; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('-10px') + it('FUNCTION type_name', () => { + const value = getValue('div { color: rgb(255, 0, 0); }') + expect(value?.type_name).toBe('Function') }) - it('should handle zero with unit', () => { - const parser = new Parser('body { margin: 0px; }') + it('OPERATOR type_name', () => { + const parser = new Parser('div { font-family: Arial, sans-serif; }') const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(DIMENSION) - expect(decl?.values[0].text).toBe('0px') + const comma = root.first_child?.first_child?.next_sibling?.first_child?.values[1] + expect(comma?.type_name).toBe('Operator') }) - it('should handle zero without unit', () => { - const parser = new Parser('body { margin: 0; }') + it('PARENTHESIS type_name', () => { + const parser = new Parser('div { width: calc((100% - 50px) / 2); }') const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values).toHaveLength(1) - expect(decl?.values[0].type).toBe(NUMBER) - expect(decl?.values[0].text).toBe('0') - }) - }) - - describe('Operators', () => { - it('should parse comma operator', () => { - const parser = new Parser('body { font-family: Arial, sans-serif; }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - - expect(decl?.values[1].type).toBe(OPERATOR) - expect(decl?.values[1].text).toBe(',') - }) - - it('should parse calc operators', () => { - const parser = new Parser('body { width: calc(100% - 20px); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.children[1].type).toBe(OPERATOR) - expect(func?.children[1].text).toBe('-') + const func = root.first_child?.first_child?.next_sibling?.first_child?.values[0] + const paren = func?.children[0] + expect(paren?.type_name).toBe('Parentheses') }) - it('should parse all calc operators', () => { - const parser = new Parser('body { width: calc(1px + 2px * 3px / 4px - 5px); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - const operators = func?.children.filter((n) => n.type === OPERATOR) - expect(operators).toHaveLength(4) - expect(operators?.[0].text).toBe('+') - expect(operators?.[1].text).toBe('*') - expect(operators?.[2].text).toBe('/') - expect(operators?.[3].text).toBe('-') + it('URL type_name', () => { + const value = getValue('div { background: url("image.png"); }') + expect(value?.type_name).toBe('Url') }) }) - describe('Parentheses', () => { - it('should parse parenthesized expressions in calc()', () => { - const parser = new Parser('body { width: calc((100% - 50px) / 2); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('calc') - expect(func?.children).toHaveLength(3) - - // First child should be a parenthesis node - expect(func?.children[0].type).toBe(PARENTHESIS) - expect(func?.children[0].text).toBe('(100% - 50px)') - - // Check parenthesis content - const parenNode = func?.children[0] - expect(parenNode?.children).toHaveLength(3) - expect(parenNode?.children[0].type).toBe(DIMENSION) - expect(parenNode?.children[0].text).toBe('100%') - expect(parenNode?.children[1].type).toBe(OPERATOR) - expect(parenNode?.children[1].text).toBe('-') - expect(parenNode?.children[2].type).toBe(DIMENSION) - expect(parenNode?.children[2].text).toBe('50px') - - // Second child should be division operator - expect(func?.children[1].type).toBe(OPERATOR) - expect(func?.children[1].text).toBe('/') - - // Third child should be number - expect(func?.children[2].type).toBe(NUMBER) - expect(func?.children[2].text).toBe('2') - }) - - it('should parse complex nested parentheses', () => { - const parser = new Parser('body { width: calc(((100% - var(--x)) / 12 * 6) + (-1 * var(--y))); }') - const root = parser.parse() - const rule = root.first_child - const decl = rule?.first_child?.next_sibling?.first_child - const func = decl?.values[0] - - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('calc') - - // The calc function should have 3 children: parenthesis + operator + parenthesis - expect(func?.children).toHaveLength(3) - expect(func?.children[0].type).toBe(PARENTHESIS) - expect(func?.children[0].text).toBe('((100% - var(--x)) / 12 * 6)') - expect(func?.children[1].type).toBe(OPERATOR) - expect(func?.children[1].text).toBe('+') - expect(func?.children[2].type).toBe(PARENTHESIS) - expect(func?.children[2].text).toBe('(-1 * var(--y))') - - // Check first parenthesis has nested parenthesis and preserves structure - const firstParen = func?.children[0] - expect(firstParen?.children).toHaveLength(5) // paren + / + 12 + * + 6 - expect(firstParen?.children[0].type).toBe(PARENTHESIS) - expect(firstParen?.children[0].text).toBe('(100% - var(--x))') - - // Check nested parenthesis has function - const nestedParen = firstParen?.children[0] - expect(nestedParen?.children[2].type).toBe(FUNCTION) - expect(nestedParen?.children[2].name).toBe('var') - - // Check second parenthesis has content - const secondParen = func?.children[2] - expect(secondParen?.children).toHaveLength(3) // -1 * var(--y) - expect(secondParen?.children[0].type).toBe(NUMBER) - expect(secondParen?.children[0].text).toBe('-1') - expect(secondParen?.children[2].type).toBe(FUNCTION) - expect(secondParen?.children[2].name).toBe('var') + describe('Value Properties', () => { + describe('IDENTIFIER', () => { + it('should parse keyword values', () => { + const parser = new Parser('body { color: red; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('red') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('red') + }) + + it('should parse multiple keywords', () => { + const parser = new Parser('body { font-family: Arial, sans-serif; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(3) + expect(decl?.values[0].type).toBe(IDENTIFIER) + expect(decl?.values[0].text).toBe('Arial') + expect(decl?.values[2].type).toBe(IDENTIFIER) + expect(decl?.values[2].text).toBe('sans-serif') + }) + }) + + describe('NUMBER', () => { + it('should parse number values', () => { + const parser = new Parser('body { opacity: 0.5; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('0.5') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('0.5') + }) + + it('should handle negative numbers', () => { + const parser = new Parser('body { margin: -10px; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(DIMENSION) + expect(decl?.values[0].text).toBe('-10px') + }) + + it('should handle zero without unit', () => { + const parser = new Parser('body { margin: 0; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(NUMBER) + expect(decl?.values[0].text).toBe('0') + }) + }) + + describe('DIMENSION', () => { + it('should parse px dimension values', () => { + const parser = new Parser('body { width: 100px; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('100px') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('100px') + expect(decl?.values[0].value).toBe(100) + expect(decl?.values[0].unit).toBe('px') + }) + + it('should parse em dimension values', () => { + const parser = new Parser('body { font-size: 3em; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('3em') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('3em') + expect(decl?.values[0].value).toBe(3) + expect(decl?.values[0].unit).toBe('em') + }) + + it('should parse percentage values', () => { + const parser = new Parser('body { width: 50%; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('50%') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('50%') + }) + + it('should handle zero with unit', () => { + const parser = new Parser('body { margin: 0px; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(DIMENSION) + expect(decl?.values[0].text).toBe('0px') + }) + + it('should parse margin shorthand', () => { + const parser = new Parser('body { margin: 10px 20px 30px 40px; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(4) + expect(decl?.values[0].type).toBe(DIMENSION) + expect(decl?.values[0].text).toBe('10px') + expect(decl?.values[1].type).toBe(DIMENSION) + expect(decl?.values[1].text).toBe('20px') + expect(decl?.values[2].type).toBe(DIMENSION) + expect(decl?.values[2].text).toBe('30px') + expect(decl?.values[3].type).toBe(DIMENSION) + expect(decl?.values[3].text).toBe('40px') + }) + }) + + describe('STRING', () => { + it('should parse string values', () => { + const parser = new Parser('body { content: "hello"; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('"hello"') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('"hello"') + }) + }) + + describe('HASH', () => { + it('should parse color values', () => { + const parser = new Parser('body { color: #ff0000; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('#ff0000') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].text).toBe('#ff0000') + }) + }) + + describe('FUNCTION', () => { + it('should parse simple function', () => { + const parser = new Parser('body { color: rgb(255, 0, 0); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(FUNCTION) + expect(decl?.values[0].name).toBe('rgb') + expect(decl?.values[0].text).toBe('rgb(255, 0, 0)') + }) + + it('should parse function arguments', () => { + const parser = new Parser('body { color: rgb(255, 0, 0); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.children).toHaveLength(5) + expect(func?.children[0].type).toBe(NUMBER) + expect(func?.children[0].text).toBe('255') + expect(func?.children[1].type).toBe(OPERATOR) + expect(func?.children[1].text).toBe(',') + expect(func?.children[2].type).toBe(NUMBER) + expect(func?.children[2].text).toBe('0') + expect(func?.children[3].type).toBe(OPERATOR) + expect(func?.children[3].text).toBe(',') + expect(func?.children[4].type).toBe(NUMBER) + expect(func?.children[4].text).toBe('0') + }) + + it('should parse nested functions', () => { + const parser = new Parser('body { width: calc(100% - 20px); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(FUNCTION) + expect(decl?.values[0].name).toBe('calc') + expect(decl?.values[0].children).toHaveLength(3) + expect(decl?.values[0].children[0].type).toBe(DIMENSION) + expect(decl?.values[0].children[0].text).toBe('100%') + expect(decl?.values[0].children[1].type).toBe(OPERATOR) + expect(decl?.values[0].children[1].text).toBe('-') + expect(decl?.values[0].children[2].type).toBe(DIMENSION) + expect(decl?.values[0].children[2].text).toBe('20px') + }) + + it('should parse var() function', () => { + const parser = new Parser('body { color: var(--primary-color); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(FUNCTION) + expect(decl?.values[0].name).toBe('var') + expect(decl?.values[0].children).toHaveLength(1) + expect(decl?.values[0].children[0].type).toBe(IDENTIFIER) + expect(decl?.values[0].children[0].text).toBe('--primary-color') + }) + + it('should provide node.value for calc()', () => { + const parser = new Parser('body { width: calc(100% - 20px); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('calc') + expect(func?.text).toBe('calc(100% - 20px)') + expect(func?.value).toBe('100% - 20px') + expect(func?.has_children).toBe(true) + }) + + it('should provide node.value for var() function', () => { + const parser = new Parser('body { color: var(--primary-color); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('var') + expect(func?.text).toBe('var(--primary-color)') + expect(func?.value).toBe('--primary-color') + expect(func?.has_children).toBe(true) + }) + + it('should parse transform value', () => { + const parser = new Parser('body { transform: translateX(10px) rotate(45deg); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(2) + expect(decl?.values[0].type).toBe(FUNCTION) + expect(decl?.values[0].name).toBe('translateX') + expect(decl?.values[1].type).toBe(FUNCTION) + expect(decl?.values[1].name).toBe('rotate') + }) + + it('should parse filter value', () => { + const parser = new Parser('body { filter: blur(5px) brightness(1.2); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(2) + expect(decl?.values[0].type).toBe(FUNCTION) + expect(decl?.values[0].name).toBe('blur') + expect(decl?.values[0].children[0].text).toBe('5px') + expect(decl?.values[1].type).toBe(FUNCTION) + expect(decl?.values[1].name).toBe('brightness') + expect(decl?.values[1].children[0].text).toBe('1.2') + }) + }) + + describe('OPERATOR', () => { + it('should parse comma operator', () => { + const parser = new Parser('body { font-family: Arial, sans-serif; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values[1].type).toBe(OPERATOR) + expect(decl?.values[1].text).toBe(',') + }) + + it('should parse calc operators', () => { + const parser = new Parser('body { width: calc(100% - 20px); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.children[1].type).toBe(OPERATOR) + expect(func?.children[1].text).toBe('-') + }) + + it('should parse all calc operators', () => { + const parser = new Parser('body { width: calc(1px + 2px * 3px / 4px - 5px); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + const operators = func?.children.filter((n) => n.type === OPERATOR) + expect(operators).toHaveLength(4) + expect(operators?.[0].text).toBe('+') + expect(operators?.[1].text).toBe('*') + expect(operators?.[2].text).toBe('/') + expect(operators?.[3].text).toBe('-') + }) + }) + + describe('PARENTHESIS', () => { + it('should parse parenthesized expressions in calc()', () => { + const parser = new Parser('body { width: calc((100% - 50px) / 2); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('calc') + expect(func?.children).toHaveLength(3) + + // First child should be a parenthesis node + expect(func?.children[0].type).toBe(PARENTHESIS) + expect(func?.children[0].text).toBe('(100% - 50px)') + + // Check parenthesis content + const parenNode = func?.children[0] + expect(parenNode?.children).toHaveLength(3) + expect(parenNode?.children[0].type).toBe(DIMENSION) + expect(parenNode?.children[0].text).toBe('100%') + expect(parenNode?.children[1].type).toBe(OPERATOR) + expect(parenNode?.children[1].text).toBe('-') + expect(parenNode?.children[2].type).toBe(DIMENSION) + expect(parenNode?.children[2].text).toBe('50px') + + // Second child should be division operator + expect(func?.children[1].type).toBe(OPERATOR) + expect(func?.children[1].text).toBe('/') + + // Third child should be number + expect(func?.children[2].type).toBe(NUMBER) + expect(func?.children[2].text).toBe('2') + }) + + it('should parse complex nested parentheses', () => { + const parser = new Parser('body { width: calc(((100% - var(--x)) / 12 * 6) + (-1 * var(--y))); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('calc') + + // The calc function should have 3 children: parenthesis + operator + parenthesis + expect(func?.children).toHaveLength(3) + expect(func?.children[0].type).toBe(PARENTHESIS) + expect(func?.children[0].text).toBe('((100% - var(--x)) / 12 * 6)') + expect(func?.children[1].type).toBe(OPERATOR) + expect(func?.children[1].text).toBe('+') + expect(func?.children[2].type).toBe(PARENTHESIS) + expect(func?.children[2].text).toBe('(-1 * var(--y))') + + // Check first parenthesis has nested parenthesis and preserves structure + const firstParen = func?.children[0] + expect(firstParen?.children).toHaveLength(5) // paren + / + 12 + * + 6 + expect(firstParen?.children[0].type).toBe(PARENTHESIS) + expect(firstParen?.children[0].text).toBe('(100% - var(--x))') + + // Check nested parenthesis has function + const nestedParen = firstParen?.children[0] + expect(nestedParen?.children[2].type).toBe(FUNCTION) + expect(nestedParen?.children[2].name).toBe('var') + + // Check second parenthesis has content + const secondParen = func?.children[2] + expect(secondParen?.children).toHaveLength(3) // -1 * var(--y) + expect(secondParen?.children[0].type).toBe(NUMBER) + expect(secondParen?.children[0].text).toBe('-1') + expect(secondParen?.children[2].type).toBe(FUNCTION) + expect(secondParen?.children[2].name).toBe('var') + }) + }) + + describe('URL', () => { + it('should parse url() function with quoted string', () => { + const parser = new Parser('body { background: url("image.png"); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(URL) + expect(decl?.values[0].name).toBe('url') + expect(decl?.values[0].children).toHaveLength(1) + expect(decl?.values[0].children[0].type).toBe(STRING) + expect(decl?.values[0].children[0].text).toBe('"image.png"') + }) + + it('should parse url() function with unquoted URL containing dots', () => { + const parser = new Parser('body { cursor: url(mycursor.cur); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(URL) + expect(func?.name).toBe('url') + + // URL function should not parse children - content is available via node.value + expect(func?.has_children).toBe(false) + expect(func?.text).toBe('url(mycursor.cur)') + expect(func?.value).toBe('mycursor.cur') + }) + + it('should parse src() function with unquoted URL', () => { + const parser = new Parser('body { content: src(myfont.woff2); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('src') + expect(func?.has_children).toBe(false) + expect(func?.text).toBe('src(myfont.woff2)') + expect(func?.value).toBe('myfont.woff2') + }) + + it('should parse url() with base64 data URL', () => { + const parser = new Parser('body { background: url(data:image/png;base64,iVBORw0KGg); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(URL) + expect(func?.name).toBe('url') + expect(func?.has_children).toBe(false) + expect(func?.value).toBe('data:image/png;base64,iVBORw0KGg') + }) + + it('should parse url() with inline SVG', () => { + const parser = new Parser('body { background: url(data:image/svg+xml,); }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + const func = decl?.values[0] + + expect(func?.type).toBe(URL) + expect(func?.name).toBe('url') + expect(func?.has_children).toBe(false) + expect(func?.value).toBe('data:image/svg+xml,') + }) + + it('should parse complex background value with url()', () => { + const parser = new Parser('body { background: url("bg.png") no-repeat center center / cover; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values.length).toBeGreaterThan(1) + expect(decl?.values[0].type).toBe(URL) + expect(decl?.values[0].name).toBe('url') + expect(decl?.values[1].type).toBe(IDENTIFIER) + expect(decl?.values[1].text).toBe('no-repeat') + }) + }) + + describe('Mixed values', () => { + it('should parse mixed value types', () => { + const parser = new Parser('body { border: 1px solid red; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.values).toHaveLength(3) + expect(decl?.values[0].type).toBe(DIMENSION) + expect(decl?.values[0].text).toBe('1px') + expect(decl?.values[1].type).toBe(IDENTIFIER) + expect(decl?.values[1].text).toBe('solid') + expect(decl?.values[2].type).toBe(IDENTIFIER) + expect(decl?.values[2].text).toBe('red') + }) + + it('should handle empty value', () => { + const parser = new Parser('body { color: ; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBeNull() + expect(decl?.values).toHaveLength(0) + }) + + it('should handle value with !important', () => { + const parser = new Parser('body { color: red !important; }') + const root = parser.parse() + const decl = root.first_child?.first_child?.next_sibling?.first_child + + expect(decl?.value).toBe('red') + expect(decl?.values).toHaveLength(1) + expect(decl?.values[0].type).toBe(IDENTIFIER) + expect(decl?.values[0].text).toBe('red') + expect(decl?.is_important).toBe(true) + }) }) }) }) diff --git a/src/parse.test.ts b/src/parse.test.ts index ecdca39..afa5028 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -6,6 +6,7 @@ import { AT_RULE, DECLARATION, BLOCK, + COMMENT, SELECTOR_LIST, SELECTOR, PSEUDO_CLASS_SELECTOR, @@ -15,2120 +16,2623 @@ import { } from './constants' 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") - }) +describe('Core Nodes', () => { + describe('Locations', () => { + describe('STYLESHEET', () => { + test('offset and length for empty stylesheet', () => { + const ast = parse('') + expect(ast.offset).toBe(0) + expect(ast.length).toBe(0) + }) - test('should parse empty stylesheet', () => { - const parser = new Parser('') - const root = parser.parse() + test('offset and length for stylesheet with rules', () => { + const css = 'body { color: red; }' + const ast = parse(css) + expect(ast.offset).toBe(0) + expect(ast.length).toBe(css.length) + }) - expect(root.type).toBe(STYLESHEET) - expect(root.offset).toBe(0) - expect(root.length).toBe(0) - expect(root.has_children).toBe(false) + test('line and column for stylesheet', () => { + const ast = parse('body { color: red; }') + expect(ast.line).toBe(1) + expect(ast.column).toBe(1) + }) }) - test('should parse stylesheet with only whitespace', () => { - const parser = new Parser(' \n\n ') - const root = parser.parse() + describe('STYLE_RULE', () => { + test('offset and length for simple rule', () => { + const source = 'body { color: red; }' + const ast = parse(source) + const rule = ast.first_child! + expect(rule.offset).toBe(0) + expect(rule.length).toBe(source.length) + }) - expect(root.type).toBe(STYLESHEET) - expect(root.has_children).toBe(false) - }) + test('offset and length for multiple rules', () => { + const css = 'body { } div { }' + const ast = parse(css) + const [rule1, rule2] = ast.children + expect(rule1.offset).toBe(0) + expect(rule1.length).toBe(8) // 'body { }' + expect(rule2.offset).toBe(9) + expect(rule2.length).toBe(7) // 'div { }' + }) - test('should parse stylesheet with only comments', () => { - const parser = new Parser('/* comment */') - const root = parser.parse() + test('line and column for rules on single line', () => { + const css = 'body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + expect(rule.line).toBe(1) + expect(rule.column).toBe(1) + }) - expect(root.type).toBe(STYLESHEET) - // TODO: Once we parse comments, verify they're added as children - }) - }) + test('line and column for rules on multiple lines', () => { + const css = 'body { color: red; }\ndiv { margin: 0; }' + const ast = parse(css) + const [rule1, rule2] = ast.children + expect(rule1.line).toBe(1) + expect(rule1.column).toBe(1) + expect(rule2.line).toBe(2) + expect(rule2.column).toBe(1) + }) - describe('style rule parsing', () => { - test('should parse simple style rule', () => { - const parser = new Parser('body { }') - const root = parser.parse() + test('column for multiple rules on same line', () => { + const css = 'a { color: red; } b { color: blue; }' + const ast = parse(css) + const [rule1, rule2] = ast.children + expect(rule1.line).toBe(1) + expect(rule1.column).toBe(1) + expect(rule2.line).toBe(1) + expect(rule2.column).toBe(19) + }) - expect(root.has_children).toBe(true) + test('column with leading whitespace', () => { + const css = ' body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + expect(rule.line).toBe(1) + expect(rule.column).toBe(5) + }) - const rule = root.first_child! - expect(rule.type).toBe(STYLE_RULE) - expect(rule.offset).toBe(0) - expect(rule.length).toBeGreaterThan(0) + test('column for nested rule in at-rule', () => { + const css = '@media screen { body { color: blue; } }' + const ast = parse(css) + const atRule = ast.first_child! + const block = atRule.block! + const nestedRule = block.first_child! + expect(nestedRule.line).toBe(1) + expect(nestedRule.column).toBe(17) + }) }) - test('should parse style rule with selector', () => { - const source = 'body { }' - const parser = new Parser(source) - const root = parser.parse() + describe('AT_RULE', () => { + test('offset and length for @import', () => { + const source = '@import url("style.css");' + const ast = parse(source, { parse_atrule_preludes: false }) + const atRule = ast.first_child! + expect(atRule.offset).toBe(0) + expect(atRule.length).toBe(25) + }) + + test('offset and length for @media', () => { + const source = '@media (min-width: 768px) { body { color: red; } }' + const ast = parse(source, { parse_atrule_preludes: false }) + const media = ast.first_child! + expect(media.offset).toBe(0) + expect(media.length).toBe(50) + }) - const rule = root.first_child! - expect(rule.has_children).toBe(true) + test('line and column for at-rule', () => { + const css = '@media screen { body { color: blue; } }' + const ast = parse(css) + const atRule = ast.first_child! + expect(atRule.line).toBe(1) + expect(atRule.column).toBe(1) + }) - 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('line for at-rule after rule', () => { + const css = 'body { color: red; }\n\n@media screen { }' + const ast = parse(css) + const [_rule1, atRule] = ast.children + expect(atRule.line).toBe(3) + }) }) - test('should parse multiple style rules', () => { - const parser = new Parser('body { } div { }') - const root = parser.parse() + describe('DECLARATION', () => { + test('offset and length for simple declaration', () => { + const css = 'body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + const block = rule.block! + const decl = block.first_child! + expect(decl.offset).toBeGreaterThan(0) + expect(decl.length).toBeGreaterThan(0) + }) + + test('column for single-line declaration', () => { + const css = 'body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + const block = rule.block! + const decl = block.first_child! + expect(decl.line).toBe(1) + expect(decl.column).toBe(8) + }) - const [rule1, rule2] = root.children - expect(rule1.type).toBe(STYLE_RULE) - expect(rule2.type).toBe(STYLE_RULE) - expect(rule2.next_sibling).toBe(null) - }) + test('column for multi-line declarations', () => { + const css = `body { + color: red; + font-size: 16px; +}` + const ast = parse(css) + const rule = ast.first_child! + const block = rule.block! + const [decl1, decl2] = block.children + expect(decl1.line).toBe(2) + expect(decl1.column).toBe(3) + expect(decl2.line).toBe(3) + expect(decl2.column).toBe(3) + }) - 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('offset ordering for multiple declarations', () => { + const css = 'body { color: red; margin: 0; }' + const ast = parse(css) + const rule = ast.first_child! + const block = rule.block! + const [decl1, decl2] = block.children + expect(decl1.offset).toBeLessThan(decl2.offset) + }) }) - 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(SELECTOR) - expect(selector.children[0].type).toBe(TYPE_SELECTOR) - expect(selector.children[1].type).toBe(PSEUDO_CLASS_SELECTOR) - expect(selector.children[2]).toBeUndefined() - const pseudo = selector.children[1] - expect(pseudo.text).toBe(':has(a)') - expect(pseudo.children).toHaveLength(1) + describe('BLOCK', () => { + test('offset and length for block', () => { + const css = 'body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + const block = rule.block! + expect(block.offset).toBeGreaterThan(0) + expect(block.length).toBeGreaterThan(0) + }) }) - 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(SELECTOR) - const s = selector.children[0] - expect(s.type).toBe(ATTRIBUTE_SELECTOR) - expect(s.attr_operator).toEqual(ATTR_OPERATOR_PIPE_EQUAL) - expect(s.name).toBe('root') - expect(s.value).toBe('"test"') + describe('Column tracking after comments', () => { + test('column after comment', () => { + const css = '/* comment */ body { color: red; }' + const ast = parse(css) + const rule = ast.first_child! + expect(rule.line).toBe(1) + expect(rule.column).toBe(15) + }) }) }) - 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(DECLARATION) - expect(declaration.is_important).toBe(false) + describe('Types', () => { + test('STYLESHEET type constant', () => { + const ast = parse('body { }') + expect(ast.type).toBe(STYLESHEET) }) - test('should parse declaration with property name', () => { - const source = 'body { color: red; }' - const parser = new Parser(source) - const root = parser.parse() + test('STYLE_RULE type constant', () => { + const ast = parse('body { }') + const rule = ast.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + test('AT_RULE type constant', () => { + const ast = parse('@media screen { }') + const atRule = ast.first_child! + expect(atRule.type).toBe(AT_RULE) + }) - // Property name stored in the 'name' property - expect(declaration.name).toBe('color') + test('DECLARATION type constant', () => { + const ast = parse('body { color: red; }') + const rule = ast.first_child! + const block = rule.block! + const decl = block.first_child! + expect(decl.type).toBe(DECLARATION) }) - test('should parse multiple declarations', () => { - const source = 'body { color: red; margin: 0; }' - const parser = new Parser(source) - const root = parser.parse() + test('BLOCK type constant', () => { + const ast = parse('body { color: red; }') + const rule = ast.first_child! + const block = rule.block! + expect(block.type).toBe(BLOCK) + }) + }) - const rule = root.first_child! - const [_selector, block] = rule.children - const [decl1, decl2] = block.children + describe('Type Names', () => { + test('STYLESHEET type_name', () => { + const ast = parse('body { }') + expect(ast.type_name).toBe('StyleSheet') + }) - expect(decl1.type).toBe(DECLARATION) - expect(decl2.type).toBe(DECLARATION) - expect(decl2.next_sibling).toBe(null) + test('STYLE_RULE type_name', () => { + const ast = parse('body { }') + const rule = ast.first_child! + expect(rule.type_name).toBe('Rule') }) - test('should parse declaration with !important', () => { - const source = 'body { color: red !important; }' - const parser = new Parser(source) - const root = parser.parse() + test('AT_RULE type_name', () => { + const ast = parse('@media screen { }') + const atRule = ast.first_child! + expect(atRule.type_name).toBe('Atrule') + }) - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + test('DECLARATION type_name', () => { + const ast = parse('body { color: red; }') + const rule = ast.first_child! + const block = rule.block! + const decl = block.first_child! + expect(decl.type_name).toBe('Declaration') + }) - expect(declaration.type).toBe(DECLARATION) - expect(declaration.is_important).toBe(true) + test('BLOCK type_name', () => { + const ast = parse('body { color: red; }') + const rule = ast.first_child! + const block = rule.block! + expect(block.type_name).toBe('Block') }) + }) - test('should parse declaration with !ie (historic !important)', () => { - const source = 'body { color: red !ie; }' - const parser = new Parser(source) - const root = parser.parse() + describe('Node Properties', () => { + describe('STYLESHEET', () => { + test('empty stylesheet has no children', () => { + const parser = new Parser('') + const root = parser.parse() + expect(root.type).toBe(STYLESHEET) + expect(root.has_children).toBe(false) + }) - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + test('stylesheet with only whitespace has no children', () => { + const parser = new Parser(' \n\n ') + const root = parser.parse() + expect(root.type).toBe(STYLESHEET) + expect(root.has_children).toBe(false) + }) - expect(declaration.type).toBe(DECLARATION) - expect(declaration.is_important).toBe(true) + test('parser creates arena sized for source', () => { + const source = 'body { color: red; }' + const parser = new Parser(source) + const arena = parser.get_arena() + 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 declaration with ! followed by any identifier', () => { - const source = 'body { color: red !foo; }' - const parser = new Parser(source) - const root = parser.parse() + describe('STYLE_RULE', () => { + describe('Basic structure', () => { + test('should have selector list as first child', () => { + const ast = parse('body { color: red; margin: 0; }') + const rule = ast.first_child! + expect(rule.type).toBe(STYLE_RULE) + const firstChild = rule.first_child! + expect(firstChild.type).toBe(SELECTOR_LIST) + }) + + test('should have block as second child', () => { + const ast = parse('body { color: red; margin: 0; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + expect(block).not.toBeNull() + expect(block.type).toBe(BLOCK) + }) + + test('declarations should be inside the block', () => { + const ast = parse('body { color: red; margin: 0; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + const firstDecl = block.first_child! + expect(firstDecl.type).toBe(DECLARATION) + const secondDecl = firstDecl.next_sibling! + expect(secondDecl).not.toBeNull() + expect(secondDecl.type).toBe(DECLARATION) + expect(secondDecl.next_sibling).toBeNull() + }) + + test('selector list should be first child, never in middle or end', () => { + const testCases = [ + 'body { color: red; }', + 'div { margin: 0; padding: 10px; }', + 'h1 { color: blue; .nested { margin: 0; } }', + 'p { font-size: 16px; @media print { display: none; } }', + ] + + testCases.forEach((source) => { + const ast = parse(source) + const rule = ast.first_child! + expect(rule.first_child!.type).toBe(SELECTOR_LIST) + + // Walk through all children and verify no other selector lists + let child = rule.first_child!.next_sibling + while (child) { + expect(child.type).not.toBe(SELECTOR_LIST) + child = child.next_sibling + } + }) + }) + + test('empty rule should still have selector list and block', () => { + const ast = parse('body { }') + const rule = ast.first_child! + expect(rule.type).toBe(STYLE_RULE) + expect(rule.first_child!.type).toBe(SELECTOR_LIST) + const block = rule.first_child!.next_sibling + expect(block).not.toBeNull() + expect(block!.is_empty).toBe(true) + }) + }) - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + describe('Selector list structure', () => { + test('selector list children should have next_sibling links', () => { + const ast = parse('h1, h2, h3 { color: red; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + expect(selectorList.type).toBe(SELECTOR_LIST) + + const children = [] + let child = selectorList.first_child + while (child) { + children.push(child) + child = child.next_sibling + } - expect(declaration.type).toBe(DECLARATION) - expect(declaration.is_important).toBe(true) - }) + expect(children.length).toBe(3) - test('should parse declaration without semicolon at end of block', () => { - const source = 'body { color: red }' - const parser = new Parser(source) - const root = parser.parse() + for (let i = 0; i < children.length - 1; i++) { + const nextSibling = children[i].next_sibling + expect(nextSibling).not.toBeNull() + expect(nextSibling!.get_index()).toBe(children[i + 1].get_index()) + } - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + expect(children[children.length - 1].next_sibling).toBeNull() + }) - expect(declaration.type).toBe(DECLARATION) - }) + test('complex selectors should maintain component chains', () => { + const ast = parse('div.class, span#id { margin: 0; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + expect(selectorList.type).toBe(SELECTOR_LIST) - 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 selectors = [] + let selector = selectorList.first_child + while (selector) { + selectors.push(selector) + selector = selector.next_sibling + } - const rule = root.first_child! - const [_selector, block] = rule.children - const declaration = block.first_child! + expect(selectors.length).toBe(2) - expect(declaration.type).toBe(DECLARATION) - expect(declaration.name).toBe('background') - }) - }) + // First selector (div.class) should have 2 components + const components1 = [] + let comp = selectors[0].first_child + while (comp) { + components1.push(comp) + comp = comp.next_sibling + } + expect(components1.length).toBe(2) // div, .class + + // Second selector (span#id) should have 2 components + const components2 = [] + comp = selectors[1].first_child + while (comp) { + components2.push(comp) + comp = comp.next_sibling + } + expect(components2.length).toBe(2) // span, #id + }) + + test('selector list with combinators should chain all components', () => { + const ast = parse('div > p, span + a { color: blue; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + + const selectors = [] + let selector = selectorList.first_child + while (selector) { + selectors.push(selector) + selector = selector.next_sibling + } - 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() + expect(selectors.length).toBe(2) - const atRule = root.first_child! - expect(atRule.type).toBe(AT_RULE) - expect(atRule.name).toBe('import') - expect(atRule.has_children).toBe(false) - expect(atRule.length).toBe(25) + // First selector (div > p) should have 3 components: div, >, p + const components1 = [] + let comp = selectors[0].first_child + while (comp) { + components1.push(comp) + comp = comp.next_sibling + } + expect(components1.length).toBe(3) + + // Second selector (span + a) should have 3 components: span, +, a + const components2 = [] + comp = selectors[1].first_child + while (comp) { + components2.push(comp) + comp = comp.next_sibling + } + expect(components2.length).toBe(3) + }) }) - test('should parse @namespace', () => { - const source = '@namespace url(http://www.w3.org/1999/xhtml);' - const parser = new Parser(source) - const root = parser.parse() + describe('Block children structure', () => { + test('block children should be linked via next_sibling with declarations only', () => { + const ast = parse('body { color: red; margin: 0; padding: 10px; }') + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } - const atRule = root.first_child! - expect(atRule.type).toBe(AT_RULE) - expect(atRule.name).toBe('namespace') - expect(atRule.length).toBe(45) - }) - }) + expect(children.length).toBe(3) - 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() + for (let i = 0; i < children.length; i++) { + expect(children[i].type).toBe(DECLARATION) + } - const media = root.first_child! - expect(media.type).toBe(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(STYLE_RULE) - }) + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } - 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() + expect(children[children.length - 1].next_sibling).toBeNull() + }) + + test('block children should be linked via next_sibling with mixed content', () => { + const ast = parse(` + .parent { + color: red; + .nested { margin: 0; } + padding: 10px; + @media print { display: none; } + font-size: 16px; + } + `) + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } - const fontFace = root.first_child! - expect(fontFace.type).toBe(AT_RULE) - expect(fontFace.name).toBe('Font-Face') - expect(fontFace.length).toBe(60) - 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(DECLARATION) - }) + expect(children.length).toBe(5) - 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() + expect(children[0].type).toBe(DECLARATION) // color: red + expect(children[1].type).toBe(STYLE_RULE) // .nested { margin: 0; } + expect(children[2].type).toBe(DECLARATION) // padding: 10px + expect(children[3].type).toBe(AT_RULE) // @media print { display: none; } + expect(children[4].type).toBe(DECLARATION) // font-size: 16px - const supports = root.first_child! - expect(supports.type).toBe(AT_RULE) - expect(supports.name).toBe('SUPPORTS') - expect(supports.has_children).toBe(true) - }) - }) + for (let i = 0; i < children.length - 1; i++) { + const nextSibling = children[i].next_sibling + expect(nextSibling).not.toBeNull() + expect(nextSibling!.get_index()).toBe(children[i + 1].get_index()) + } - 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() + expect(children[children.length - 1].next_sibling).toBeNull() + }) - const media = root.first_child! - expect(media.type).toBe(AT_RULE) - expect(media.name).toBe('media') - expect(media.has_children).toBe(true) - expect(media.length).toBe(50) + test('block with only nested rules should have correct next_sibling chain', () => { + const ast = parse(` + .parent { + .child1 { color: red; } + .child2 { margin: 0; } + .child3 { padding: 10px; } + } + `) + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } - const block = media.block! - const nestedRule = block.first_child! - expect(nestedRule.type).toBe(STYLE_RULE) - expect(nestedRule.length).toBe(20) - }) + expect(children.length).toBe(3) - test('should parse @layer with name', () => { - const source = '@layer utilities { .text-center { text-align: center; } }' - const parser = new Parser(source) - const root = parser.parse() + for (const child of children) { + expect(child.type).toBe(STYLE_RULE) + } - const layer = root.first_child! - expect(layer.type).toBe(AT_RULE) - expect(layer.name).toBe('layer') - expect(layer.has_children).toBe(true) - }) + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } - test('should parse anonymous @layer', () => { - const source = '@layer { body { margin: 0; } }' - const parser = new Parser(source) - const root = parser.parse() + expect(children[children.length - 1].next_sibling).toBeNull() + }) - const layer = root.first_child! - expect(layer.type).toBe(AT_RULE) - expect(layer.name).toBe('layer') - expect(layer.has_children).toBe(true) - }) + test('block with only at-rules should have correct next_sibling chain', () => { + const ast = parse(` + .parent { + @media screen { color: blue; } + @media print { display: none; } + @supports (display: flex) { display: flex; } + } + `) + const rule = ast.first_child! + const selectorList = rule.first_child! + const block = selectorList.next_sibling! + + const children = [] + let child = block.first_child + while (child) { + children.push(child) + child = child.next_sibling + } - test('should parse @supports', () => { - const source = '@supports (display: grid) { .grid { display: grid; } }' - const parser = new Parser(source) - const root = parser.parse() + expect(children.length).toBe(3) - const supports = root.first_child! - expect(supports.type).toBe(AT_RULE) - expect(supports.name).toBe('supports') - expect(supports.has_children).toBe(true) - }) + for (const child of children) { + expect(child.type).toBe(AT_RULE) + } - test('should parse @container', () => { - const source = '@container (min-width: 400px) { .card { padding: 2rem; } }' - const parser = new Parser(source) - const root = parser.parse() + for (let i = 0; i < children.length - 1; i++) { + expect(children[i].next_sibling).not.toBeNull() + expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) + } - const container = root.first_child! - expect(container.type).toBe(AT_RULE) - expect(container.name).toBe('container') - expect(container.has_children).toBe(true) + expect(children[children.length - 1].next_sibling).toBeNull() + }) }) - }) - 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() + describe('Nested rules', () => { + test('nested style rules should have selector list as first child', () => { + const ast = parse('div { .nested { color: red; } }') + const outerRule = ast.first_child! - const fontFace = root.first_child! - expect(fontFace.type).toBe(AT_RULE) - expect(fontFace.name).toBe('font-face') - expect(fontFace.has_children).toBe(true) + expect(outerRule.type).toBe(STYLE_RULE) + expect(outerRule.first_child!.type).toBe(SELECTOR_LIST) - // Should have declarations as children - const block = fontFace.block! - const [decl1, decl2] = block.children - expect(decl1.type).toBe(DECLARATION) - expect(decl2.type).toBe(DECLARATION) - }) + const block = outerRule.first_child!.next_sibling! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(STYLE_RULE) + expect(nestedRule.first_child!.type).toBe(SELECTOR_LIST) - test('should parse @page', () => { - const source = '@page { margin: 1in; }' - const parser = new Parser(source) - const root = parser.parse() + const nestedBlock = nestedRule.first_child!.next_sibling! + expect(nestedBlock.first_child!.type).toBe(DECLARATION) + }) - const page = root.first_child! - expect(page.type).toBe(AT_RULE) - expect(page.name).toBe('page') + test('& span should be parsed as ONE selector with 3 components', () => { + const ast = parse('.parent { & span { color: red; } }') + const outerRule = ast.first_child! - const block = page.block! - const decl = block.first_child! - expect(decl.type).toBe(DECLARATION) - }) + const block = outerRule.first_child!.next_sibling! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(STYLE_RULE) - test('should parse @counter-style', () => { - const source = '@counter-style thumbs { system: cyclic; symbols: "👍"; }' - const parser = new Parser(source) - const root = parser.parse() + const selectorList = nestedRule.first_child! + expect(selectorList.type).toBe(SELECTOR_LIST) - const counterStyle = root.first_child! - expect(counterStyle.type).toBe(AT_RULE) - expect(counterStyle.name).toBe('counter-style') + const selectors = [] + let selector = selectorList.first_child + while (selector) { + selectors.push(selector) + selector = selector.next_sibling + } - const block = counterStyle.block! - const decl = block.first_child! - expect(decl.type).toBe(DECLARATION) - }) - }) + expect(selectors.length).toBe(1) - 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() + if (selectors.length === 1) { + const components = [] + let component = selectors[0].first_child + while (component) { + components.push(component) + component = component.next_sibling + } + expect(components.length).toBe(3) + } + }) + }) - const supports = root.first_child! - expect(supports.name).toBe('supports') - expect(supports.length).toBe(80) + describe('Selector parsing', () => { + test('should parse simple 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! + expect(selector.text).toBe('body') + expect(selector.line).toBe(1) + expect(selector.offset).toBe(0) + expect(selector.length).toBe(4) + }) + + 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! + + expect(selectorlist.offset).toBe(0) + expect(selectorlist.length).toBe(16) + 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(SELECTOR) + expect(selector.children[0].type).toBe(TYPE_SELECTOR) + expect(selector.children[1].type).toBe(PSEUDO_CLASS_SELECTOR) + 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(SELECTOR) + const s = selector.children[0] + expect(s.type).toBe(ATTRIBUTE_SELECTOR) + expect(s.attr_operator).toEqual(ATTR_OPERATOR_PIPE_EQUAL) + expect(s.name).toBe('root') + expect(s.value).toBe('"test"') + }) + }) - const supports_block = supports.block! - const media = supports_block.first_child! - expect(media.type).toBe(AT_RULE) - expect(media.name).toBe('media') - expect(media.text).toBe('@media (min-width: 768px) { body { color: red; } }') - expect(media.length).toBe(50) + describe('Multiple rules', () => { + test('should parse multiple style rules', () => { + const parser = new Parser('body { } div { }') + const root = parser.parse() - const media_block = media.block! - const rule = media_block.first_child! - expect(rule.type).toBe(STYLE_RULE) - expect(rule.length).toBe(20) + const [rule1, rule2] = root.children + expect(rule1.type).toBe(STYLE_RULE) + expect(rule2.type).toBe(STYLE_RULE) + expect(rule2.next_sibling).toBe(null) + }) }) }) - 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() + describe('AT_RULE', () => { + describe('Statement at-rules (no block)', () => { + test('@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(AT_RULE) + expect(atRule.name).toBe('import') + expect(atRule.has_children).toBe(false) + }) + + test('@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(AT_RULE) + expect(atRule.name).toBe('namespace') + expect(atRule.length).toBe(45) + }) + }) - const [import1, layer, media] = root.children - expect(import1.name).toBe('import') - expect(import1.length).toBe(21) - expect(layer.name).toBe('layer') - expect(layer.length).toBe(35) - expect(media.name).toBe('media') - expect(media.length).toBe(39) + describe('Case-insensitive at-rule names', () => { + test('should parse @MEDIA (uppercase)', () => { + 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(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(STYLE_RULE) + }) + + test('should parse @Font-Face (mixed case)', () => { + 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(AT_RULE) + expect(fontFace.name).toBe('Font-Face') + expect(fontFace.length).toBe(60) + expect(fontFace.has_children).toBe(true) + const block = fontFace.block! + const decl = block.first_child! + expect(decl.type).toBe(DECLARATION) + }) + + test('should parse @SUPPORTS (uppercase)', () => { + 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(AT_RULE) + expect(supports.name).toBe('SUPPORTS') + expect(supports.has_children).toBe(true) + }) }) - }) - }) - 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() + describe('Block at-rules with nested rules', () => { + test('@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(AT_RULE) + expect(media.name).toBe('media') + expect(media.has_children).toBe(true) + expect(media.length).toBe(50) + + const block = media.block! + const nestedRule = block.first_child! + expect(nestedRule.type).toBe(STYLE_RULE) + expect(nestedRule.length).toBe(20) + }) + + test('@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(AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.has_children).toBe(true) + }) + + test('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(AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.has_children).toBe(true) + }) + + test('@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(AT_RULE) + expect(supports.name).toBe('supports') + expect(supports.has_children).toBe(true) + }) + + test('@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(AT_RULE) + expect(container.name).toBe('container') + expect(container.has_children).toBe(true) + }) + }) - let parent = root.first_child! - expect(parent.type).toBe(STYLE_RULE) + describe('Descriptor at-rules (with declarations)', () => { + test('@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(AT_RULE) + expect(fontFace.name).toBe('font-face') + expect(fontFace.has_children).toBe(true) + + const block = fontFace.block! + const [decl1, decl2] = block.children + expect(decl1.type).toBe(DECLARATION) + expect(decl2.type).toBe(DECLARATION) + }) + + test('@page', () => { + const source = '@page { margin: 1in; }' + const parser = new Parser(source) + const root = parser.parse() + + const page = root.first_child! + expect(page.type).toBe(AT_RULE) + expect(page.name).toBe('page') + + const block = page.block! + const decl = block.first_child! + expect(decl.type).toBe(DECLARATION) + }) + + test('@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(AT_RULE) + expect(counterStyle.name).toBe('counter-style') + + const block = counterStyle.block! + const decl = block.first_child! + expect(decl.type).toBe(DECLARATION) + }) + }) - let [_selector, block] = parent.children - let [decl, nested_rule] = block.children - expect(decl.type).toBe(DECLARATION) - expect(decl.name).toBe('color') + describe('Nested at-rules', () => { + test('@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') + expect(supports.length).toBe(80) + + const supports_block = supports.block! + const media = supports_block.first_child! + expect(media.type).toBe(AT_RULE) + expect(media.name).toBe('media') + expect(media.text).toBe('@media (min-width: 768px) { body { color: red; } }') + expect(media.length).toBe(50) + + const media_block = media.block! + const rule = media_block.first_child! + expect(rule.type).toBe(STYLE_RULE) + expect(rule.length).toBe(20) + }) + }) - expect(nested_rule.type).toBe(STYLE_RULE) - let nested_selector = nested_rule.first_child! - // With parseSelectors enabled, selector is now detailed - expect(nested_selector.text).toBe('& .child') - }) + describe('Multiple at-rules', () => { + test('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(import1.length).toBe(21) + expect(layer.name).toBe('layer') + expect(layer.length).toBe(35) + expect(media.name).toBe('media') + expect(media.length).toBe(39) + }) + }) - test('should parse nested rule without & selector', () => { - let source = '.parent { color: red; .child { color: blue; } }' - let parser = new Parser(source) - let root = parser.parse() + describe('Special at-rules', () => { + test('@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(AT_RULE) + expect(charset.name).toBe('charset') + }) + + test('@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(AT_RULE) + expect(import_rule.name).toBe('import') + }) + + test('@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('@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('@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') + }) + }) - let parent = root.first_child! - let [_selector, block] = parent.children - let [_decl, nested_rule] = block.children + describe('At-rule preludes', () => { + test('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(AT_RULE) + expect(atrule.name).toBe('media') + expect(atrule.prelude).toBe('(min-width: 768px)') + }) + + test('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('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('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('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('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('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('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('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('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('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('value and prelude should 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('at-rule prelude line tracking', () => { + 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 + }) + }) - expect(nested_rule.type).toBe(STYLE_RULE) - let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('.child') + describe('At-rule 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(STYLESHEET) + expect(atrule!.type).toBe(AT_RULE) + expect(atrule?.block?.type).toBe(BLOCK) + }) + + test('block children should be stylerule', () => { + expect(atrule!.block).not.toBeNull() + expect(rule!.type).toBe(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(SELECTOR_LIST) + }) + + test('has correct nested selectors', () => { + let list = rule?.first_child + expect(list!.type).toBe(SELECTOR_LIST) + expect(list!.children).toHaveLength(1) + expect(list?.first_child?.type).toEqual(SELECTOR) + expect(list?.first_child?.text).toEqual('a') + }) + }) }) - 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 + describe('DECLARATION', () => { + describe('Basic declaration properties', () => { + test('should parse 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! + + expect(declaration.name).toBe('color') + }) + + test('simple declaration without !important', () => { + 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(DECLARATION) + expect(declaration.is_important).toBe(false) + }) + + test('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(DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('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(DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('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(DECLARATION) + expect(declaration.is_important).toBe(true) + }) + + test('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(DECLARATION) + }) + + test('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(DECLARATION) + expect(declaration.name).toBe('background') + }) + }) - expect(nested1.type).toBe(STYLE_RULE) - expect(nested2.type).toBe(STYLE_RULE) - }) + describe('Multiple declarations', () => { + test('should parse multiple declarations', () => { + const source = 'body { color: red; margin: 0; }' + const parser = new Parser(source) + const root = parser.parse() - 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! - expect(a.length).toBe(32) - let [_selector_a, block_a] = a.children - let b = block_a.first_child! - expect(b.type).toBe(STYLE_RULE) - expect(b.length).toBe(25) - - let [_selector_b, block_b] = b.children - let c = block_b.first_child! - expect(c.type).toBe(STYLE_RULE) - expect(c.length).toBe(18) - - let [_selector_c, block_c] = c.children - let decl = block_c.first_child! - expect(decl.type).toBe(DECLARATION) - expect(decl.name).toBe('color') - }) + const rule = root.first_child! + const [_selector, block] = rule.children + const [decl1, decl2] = block.children - 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() + expect(decl1.type).toBe(DECLARATION) + expect(decl2.type).toBe(DECLARATION) + expect(decl2.next_sibling).toBe(null) + }) + }) - let card = root.first_child! - let [_selector, block] = card.children - let [decl, media] = block.children + describe('Declaration values', () => { + test('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('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('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('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('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('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('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('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('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('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('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('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")') + }) + }) - expect(decl.type).toBe(DECLARATION) - expect(media.type).toBe(AT_RULE) - expect(media.name).toBe('media') + describe('Vendor prefix detection', () => { + test('-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('-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('-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('-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('no 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('no 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('no 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('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('complex property names with vendor prefix', () => { + 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('no vendor prefix for similar but non-vendor properties', () => { + 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('false for nodes without names', () => { + let source = 'body { }' + let parser = new Parser(source) + let root = parser.parse() + + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.is_vendor_prefixed).toBe(false) + }) + }) - let media_block = media.block! - let nested_decl = media_block.first_child! - expect(nested_decl.type).toBe(DECLARATION) - expect(nested_decl.name).toBe('padding') + describe('Vendor prefix detection for selectors', () => { + test('-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! + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-webkit-autofill') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('-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! + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-moz-focusring') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('-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! + 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('-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! + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('-webkit-scrollbar') + expect(pseudoElement.is_vendor_prefixed).toBe(true) + }) + + test('-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! + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('-moz-selection') + expect(pseudoElement.is_vendor_prefixed).toBe(true) + }) + + test('-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! + 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('-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! + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('-webkit-any') + expect(pseudoClass.is_vendor_prefixed).toBe(true) + }) + + test('no 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! + let typeSelector = selector.first_child! + let pseudoClass = typeSelector.next_sibling! + expect(pseudoClass.name).toBe('hover') + expect(pseudoClass.is_vendor_prefixed).toBe(false) + }) + + test('no 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! + let typeSelector = selector.first_child! + let pseudoElement = typeSelector.next_sibling! + expect(pseudoElement.name).toBe('before') + expect(pseudoElement.is_vendor_prefixed).toBe(false) + }) + + test('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! + 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! + 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! + let typeSelector3 = selector3.first_child! + let pseudo3 = typeSelector3.next_sibling! + expect(pseudo3.name).toBe('after') + expect(pseudo3.is_vendor_prefixed).toBe(false) + }) + + test('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! + let typeSelector = selector.first_child! + let webkitPseudo = typeSelector.next_sibling! + expect(webkitPseudo.name).toBe('-webkit-autofill') + expect(webkitPseudo.is_vendor_prefixed).toBe(true) + + let focusPseudo = webkitPseudo.next_sibling! + expect(focusPseudo.name).toBe('focus') + expect(focusPseudo.is_vendor_prefixed).toBe(false) + }) + }) }) - test('should parse :is() pseudo-class', () => { - let source = ':is(.a, .b) { color: red; }' - let parser = new Parser(source) - let root = parser.parse() + describe('BLOCK', () => { + test('block text excludes braces for empty at-rule block', () => { + const parser = new Parser('@layer test {}') + const root = parser.parse() + const atRule = root.first_child! - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe(':is(.a, .b)') - }) + expect(atRule.has_block).toBe(true) + expect(atRule.block!.text).toBe('') + expect(atRule.text).toBe('@layer test {}') + }) - test('should parse :where() pseudo-class', () => { - let source = ':where(h1, h2, h3) { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() + test('at-rule block with content excludes braces', () => { + const parser = new Parser('@layer test { .foo { color: red; } }') + const root = parser.parse() + const atRule = root.first_child! - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe(':where(h1, h2, h3)') - }) + expect(atRule.has_block).toBe(true) + expect(atRule.block!.text).toBe(' .foo { color: red; } ') + expect(atRule.text).toBe('@layer test { .foo { color: red; } }') + }) - test('should parse :has() pseudo-class', () => { - let source = 'div:has(> img) { display: flex; }' - let parser = new Parser(source) - let root = parser.parse() + test('empty style rule block has empty text', () => { + const parser = new Parser('body {}') + const root = parser.parse() + const styleRule = root.first_child! - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toBe('div:has(> img)') - }) + expect(styleRule.has_block).toBe(true) + expect(styleRule.block!.text).toBe('') + expect(styleRule.text).toBe('body {}') + }) - 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() + test('style rule block with declaration excludes braces', () => { + const parser = new Parser('body { color: red; }') + const root = parser.parse() + const styleRule = root.first_child! - let card = root.first_child! - let [_selector, block] = card.children - let [decl1, title, decl2, body] = block.children + expect(styleRule.has_block).toBe(true) + expect(styleRule.block!.text).toBe(' color: red; ') + expect(styleRule.text).toBe('body { color: red; }') + }) - expect(decl1.type).toBe(DECLARATION) - expect(decl1.name).toBe('color') + test('nested style rule blocks 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(title.type).toBe(STYLE_RULE) + expect(parentBlock.text).toBe(' .child { margin: 0; } ') + expect(childBlock.text).toBe(' margin: 0; ') + }) - expect(decl2.type).toBe(DECLARATION) - expect(decl2.name).toBe('padding') + test('at-rule with multiple declarations excludes braces', () => { + const parser = new Parser('@font-face { font-family: "Test"; src: url(test.woff); }') + const root = parser.parse() + const atRule = root.first_child! - expect(body.type).toBe(STYLE_RULE) - }) + expect(atRule.block!.text).toBe(' font-family: "Test"; src: url(test.woff); ') + }) - describe('Relaxed nesting (CSS Nesting Module Level 1)', () => { - test('should parse nested rule with leading child combinator', () => { - let source = '.parent { > a { color: red; } }' - let parser = new Parser(source) - let root = parser.parse() + test('media query with nested rules excludes braces', () => { + const parser = new Parser('@media screen { body { color: blue; } }') + const root = parser.parse() + const mediaRule = root.first_child! - let parent = root.first_child! - expect(parent.type).toBe(STYLE_RULE) + expect(mediaRule.block!.text).toBe(' body { color: blue; } ') + }) - let [_selector, block] = parent.children - let nested_rule = block.first_child! - expect(nested_rule.type).toBe(STYLE_RULE) + test('block with no whitespace is empty', () => { + const parser = new Parser('div{}') + const root = parser.parse() + const styleRule = root.first_child! - let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('> a') - // Verify selector has children (was parsed, not left empty) - expect(nested_selector.has_children).toBe(true) + expect(styleRule.block!.text).toBe('') }) - test('should parse nested rule with leading next-sibling combinator', () => { - let source = '.parent { + span { color: blue; } }' - let parser = new Parser(source) - let root = parser.parse() - - let parent = root.first_child! - let [_selector, block] = parent.children - let nested_rule = block.first_child! - expect(nested_rule.type).toBe(STYLE_RULE) + test('block with only whitespace preserves whitespace', () => { + const parser = new Parser('div{ \n\t }') + const root = parser.parse() + const styleRule = root.first_child! - let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('+ span') - expect(nested_selector.has_children).toBe(true) + expect(styleRule.block!.text).toBe(' \n\t ') }) + }) - test('should parse nested rule with leading subsequent-sibling combinator', () => { - let source = '.parent { ~ div { color: green; } }' + describe('CSS Nesting', () => { + test('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(STYLE_RULE) + let [_selector, block] = parent.children - let nested_rule = block.first_child! - expect(nested_rule.type).toBe(STYLE_RULE) + let [decl, nested_rule] = block.children + expect(decl.type).toBe(DECLARATION) + expect(decl.name).toBe('color') + expect(nested_rule.type).toBe(STYLE_RULE) let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('~ div') - expect(nested_selector.has_children).toBe(true) + expect(nested_selector.text).toBe('& .child') }) - test('should parse multiple nested rules with different leading combinators', () => { - let source = '.parent { > a { color: red; } ~ span { color: blue; } + div { color: green; } }' + test('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 [rule1, rule2, rule3] = block.children - - expect(rule1.type).toBe(STYLE_RULE) - expect(rule1.first_child!.text).toBe('> a') - expect(rule1.first_child!.has_children).toBe(true) + let [_decl, nested_rule] = block.children - expect(rule2.type).toBe(STYLE_RULE) - expect(rule2.first_child!.text).toBe('~ span') - expect(rule2.first_child!.has_children).toBe(true) - - expect(rule3.type).toBe(STYLE_RULE) - expect(rule3.first_child!.text).toBe('+ div') - expect(rule3.first_child!.has_children).toBe(true) + expect(nested_rule.type).toBe(STYLE_RULE) + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('.child') }) - test('should parse complex selector after leading combinator', () => { - let source = '.parent { > a.link#nav[href]:hover { color: red; } }' + test('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 nested_rule = block.first_child! + let [nested1, nested2] = block.children - let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('> a.link#nav[href]:hover') - expect(nested_selector.has_children).toBe(true) + expect(nested1.type).toBe(STYLE_RULE) + expect(nested2.type).toBe(STYLE_RULE) }) - test('should parse deeply nested rules with leading combinators', () => { - let source = '.a { > .b { > .c { color: red; } } }' + test('deeply nested rules', () => { + let source = '.a { .b { .c { color: red; } } }' let parser = new Parser(source) let root = parser.parse() let a = root.first_child! + expect(a.length).toBe(32) let [_selector_a, block_a] = a.children let b = block_a.first_child! expect(b.type).toBe(STYLE_RULE) - expect(b.first_child!.text).toBe('> .b') - expect(b.first_child!.has_children).toBe(true) + expect(b.length).toBe(25) let [_selector_b, block_b] = b.children let c = block_b.first_child! expect(c.type).toBe(STYLE_RULE) - expect(c.first_child!.text).toBe('> .c') - expect(c.first_child!.has_children).toBe(true) + expect(c.length).toBe(18) + + let [_selector_c, block_c] = c.children + let decl = block_c.first_child! + expect(decl.type).toBe(DECLARATION) + expect(decl.name).toBe('color') }) - test('should parse mixed nested rules with and without leading combinators', () => { - let source = '.parent { .normal { } > .combinator { } }' - let parser = new Parser(source) + test('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 parent = root.first_child! - let [_selector, block] = parent.children - let [normal, combinator] = block.children + let card = root.first_child! + let [_selector, block] = card.children + let [decl, media] = block.children - expect(normal.type).toBe(STYLE_RULE) - expect(normal.first_child!.text).toBe('.normal') + expect(decl.type).toBe(DECLARATION) + expect(media.type).toBe(AT_RULE) + expect(media.name).toBe('media') - expect(combinator.type).toBe(STYLE_RULE) - expect(combinator.first_child!.text).toBe('> .combinator') - expect(combinator.first_child!.has_children).toBe(true) + let media_block = media.block! + let nested_decl = media_block.first_child! + expect(nested_decl.type).toBe(DECLARATION) + expect(nested_decl.name).toBe('padding') }) - }) - }) - 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() + test(':is() pseudo-class', () => { + let source = ':is(.a, .b) { color: red; }' + let parser = new Parser(source) + let root = parser.parse() - let keyframes = root.first_child! - expect(keyframes.type).toBe(AT_RULE) - expect(keyframes.name).toBe('keyframes') + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe(':is(.a, .b)') + }) - let block = keyframes.block! - let [from_rule, to_rule] = block.children - expect(from_rule.type).toBe(STYLE_RULE) - expect(to_rule.type).toBe(STYLE_RULE) + test(':where() pseudo-class', () => { + let source = ':where(h1, h2, h3) { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - let from_selector = from_rule.first_child! - expect(from_selector.text).toBe('from') + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe(':where(h1, h2, h3)') + }) - let to_selector = to_rule.first_child! - expect(to_selector.text).toBe('to') - }) + test(':has() pseudo-class', () => { + let source = 'div:has(> img) { display: flex; }' + let parser = new Parser(source) + let root = parser.parse() - 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(STYLE_RULE) - expect(rule50.type).toBe(STYLE_RULE) - expect(rule100.type).toBe(STYLE_RULE) - - let selector0 = rule0.first_child! - expect(selector0.text).toBe('0%') - }) + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toBe('div:has(> img)') + }) - 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() + test('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 keyframes = root.first_child! - let block = keyframes.block! - let [rule1, _rule2] = block.children + let card = root.first_child! + let [_selector, block] = card.children + let [decl1, title, decl2, body] = block.children - let selector1 = rule1.first_child! - expect(selector1.text).toBe('0%, 100%') - }) - }) + expect(decl1.type).toBe(DECLARATION) + expect(decl1.name).toBe('color') - 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() + expect(title.type).toBe(STYLE_RULE) - let parent = root.first_child! - let [_selector, block] = parent.children - let nest = block.first_child! + expect(decl2.type).toBe(DECLARATION) + expect(decl2.name).toBe('padding') - expect(nest.type).toBe(AT_RULE) - expect(nest.name).toBe('nest') - expect(nest.has_children).toBe(true) + expect(body.type).toBe(STYLE_RULE) + }) - let nest_block = nest.block! - let decl = nest_block.first_child! - expect(decl.type).toBe(DECLARATION) - expect(decl.name).toBe('color') + describe('Relaxed nesting (CSS Nesting Module Level 1)', () => { + test('nested rule with leading child combinator', () => { + let source = '.parent { > a { color: red; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + expect(parent.type).toBe(STYLE_RULE) + + let [_selector, block] = parent.children + let nested_rule = block.first_child! + expect(nested_rule.type).toBe(STYLE_RULE) + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('> a') + expect(nested_selector.has_children).toBe(true) + }) + + test('nested rule with leading next-sibling combinator', () => { + let source = '.parent { + span { color: blue; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let nested_rule = block.first_child! + expect(nested_rule.type).toBe(STYLE_RULE) + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('+ span') + expect(nested_selector.has_children).toBe(true) + }) + + test('nested rule with leading subsequent-sibling combinator', () => { + let source = '.parent { ~ div { color: green; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let nested_rule = block.first_child! + expect(nested_rule.type).toBe(STYLE_RULE) + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('~ div') + expect(nested_selector.has_children).toBe(true) + }) + + test('multiple nested rules with different leading combinators', () => { + let source = '.parent { > a { color: red; } ~ span { color: blue; } + div { color: green; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let [rule1, rule2, rule3] = block.children + + expect(rule1.type).toBe(STYLE_RULE) + expect(rule1.first_child!.text).toBe('> a') + expect(rule1.first_child!.has_children).toBe(true) + + expect(rule2.type).toBe(STYLE_RULE) + expect(rule2.first_child!.text).toBe('~ span') + expect(rule2.first_child!.has_children).toBe(true) + + expect(rule3.type).toBe(STYLE_RULE) + expect(rule3.first_child!.text).toBe('+ div') + expect(rule3.first_child!.has_children).toBe(true) + }) + + test('complex selector after leading combinator', () => { + let source = '.parent { > a.link#nav[href]:hover { color: red; } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let nested_rule = block.first_child! + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('> a.link#nav[href]:hover') + expect(nested_selector.has_children).toBe(true) + }) + + test('deeply nested rules with leading combinators', () => { + 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(STYLE_RULE) + expect(b.first_child!.text).toBe('> .b') + expect(b.first_child!.has_children).toBe(true) + + let [_selector_b, block_b] = b.children + let c = block_b.first_child! + expect(c.type).toBe(STYLE_RULE) + expect(c.first_child!.text).toBe('> .c') + expect(c.first_child!.has_children).toBe(true) + }) + + test('mixed nested rules with and without leading combinators', () => { + let source = '.parent { .normal { } > .combinator { } }' + let parser = new Parser(source) + let root = parser.parse() + + let parent = root.first_child! + let [_selector, block] = parent.children + let [normal, combinator] = block.children + + expect(normal.type).toBe(STYLE_RULE) + expect(normal.first_child!.text).toBe('.normal') + + expect(combinator.type).toBe(STYLE_RULE) + expect(combinator.first_child!.text).toBe('> .combinator') + expect(combinator.first_child!.has_children).toBe(true) + }) + }) }) - 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! + describe('@keyframes parsing', () => { + test('@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() - expect(nest.type).toBe(AT_RULE) - expect(nest.name).toBe('nest') - }) - }) + let keyframes = root.first_child! + expect(keyframes.type).toBe(AT_RULE) + expect(keyframes.name).toBe('keyframes') - 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() + let block = keyframes.block! + let [from_rule, to_rule] = block.children + expect(from_rule.type).toBe(STYLE_RULE) + expect(to_rule.type).toBe(STYLE_RULE) - // Should skip malformed rule and parse valid one - expect(root.children.length).toBeGreaterThan(0) - }) + let from_selector = from_rule.first_child! + expect(from_selector.text).toBe('from') - test('should handle rule without closing brace', () => { - let source = 'body { color: red; div { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() + let to_selector = to_rule.first_child! + expect(to_selector.text).toBe('to') + }) - // Parser should recover - expect(root.has_children).toBe(true) - }) + test('@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() - test('should handle empty rule block', () => { - let source = '.empty { }' - let parser = new Parser(source) - let root = parser.parse() + let keyframes = root.first_child! + let block = keyframes.block! + let [rule0, rule50, rule100] = block.children - let rule = root.first_child! - expect(rule.type).toBe(STYLE_RULE) - // Only has selector and empty block - expect(rule.children.length).toBe(2) - }) + expect(rule0.type).toBe(STYLE_RULE) + expect(rule50.type).toBe(STYLE_RULE) + expect(rule100.type).toBe(STYLE_RULE) - test('should handle declaration without value', () => { - let source = 'body { color: }' - let parser = new Parser(source) - let root = parser.parse() + let selector0 = rule0.first_child! + expect(selector0.text).toBe('0%') + }) - let rule = root.first_child! - let [_selector, block] = rule.children - let decl = block.first_child! - expect(decl.type).toBe(DECLARATION) - }) + test('@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() - test('should handle multiple semicolons', () => { - let source = 'body { color: red;;; margin: 0;; }' - let parser = new Parser(source) - let root = parser.parse() + let keyframes = root.first_child! + let block = keyframes.block! + let [rule1, _rule2] = block.children - let rule = root.first_child! - // Rule has selector + block - expect(rule.children.length).toBe(2) - }) + let selector1 = rule1.first_child! + expect(selector1.text).toBe('0%, 100%') + }) - test('should skip invalid tokens in declaration block', () => { - let source = 'body { color: red; @@@; margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() + test('@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 rule = root.first_child! - // Should have selector + block - expect(rule.children.length).toBe(2) + let keyframes = root.first_child! + let block = keyframes.block! + expect(block.children.length).toBe(3) + }) }) - test('should handle declaration without colon', () => { - let source = 'body { color red; margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() + describe('@nest at-rule', () => { + test('@nest with & selector', () => { + let source = '.parent { @nest & .child { color: blue; } }' + 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) - }) + let parent = root.first_child! + let [_selector, block] = parent.children + let nest = block.first_child! - test('should handle at-rule without name', () => { - let source = '@ { color: red; } body { margin: 0; }' - let parser = new Parser(source) - let root = parser.parse() + expect(nest.type).toBe(AT_RULE) + expect(nest.name).toBe('nest') + expect(nest.has_children).toBe(true) - // Should recover and parse body rule - expect(root.children.length).toBeGreaterThan(0) - }) + let nest_block = nest.block! + let decl = nest_block.first_child! + expect(decl.type).toBe(DECLARATION) + expect(decl.name).toBe('color') + }) + + test('@nest with complex selector', () => { + let source = '.a { @nest :not(&) { color: red; } }' + let parser = new Parser(source) + let root = parser.parse() - 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! + let [_selector, block] = a.children + let nest = block.first_child! - let a = root.first_child! - expect(a.type).toBe(STYLE_RULE) + expect(nest.type).toBe(AT_RULE) + expect(nest.name).toBe('nest') + }) }) - test('should handle trailing comma in selector', () => { - let source = '.a, .b, { color: red; }' - let parser = new Parser(source) - let root = parser.parse() + describe('Error recovery and edge cases', () => { + test('malformed rule without opening brace', () => { + let source = 'body color: red; } div { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - let rule = root.first_child! - expect(rule.type).toBe(STYLE_RULE) - }) - }) + expect(root.children.length).toBeGreaterThan(0) + }) - 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('rule without closing brace', () => { + let source = 'body { color: red; div { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - test('should detect -moz- vendor prefix', () => { - let source = '.box { -moz-transform: scale(1); }' - let parser = new Parser(source) - let root = parser.parse() + expect(root.has_children).toBe(true) + }) - 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('empty rule block', () => { + let source = '.empty { }' + let parser = new Parser(source) + let root = parser.parse() - 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! + expect(rule.type).toBe(STYLE_RULE) + expect(rule.children.length).toBe(2) + }) - 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('declaration without value', () => { + let source = 'body { color: }' + let parser = new Parser(source) + let root = parser.parse() - 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.type).toBe(DECLARATION) + }) - 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('multiple semicolons', () => { + let source = 'body { color: red;;; margin: 0;; }' + let parser = new Parser(source) + let root = parser.parse() - 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! + expect(rule.children.length).toBe(2) + }) - 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('invalid tokens in declaration block', () => { + let source = 'body { color: red; @@@; margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - 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! + expect(rule.children.length).toBe(2) + }) - 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('declaration without colon', () => { + let source = 'body { color red; margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - 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! + expect(rule.children.length).toBe(2) + }) - 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('at-rule without name', () => { + let source = '@ { color: red; } body { margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - 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() + expect(root.children.length).toBeGreaterThan(0) + }) - let rule = root.first_child! - let [_selector, block] = rule.children - let [webkit, moz, standard] = block.children + test('nested empty blocks', () => { + let source = '.a { .b { .c { } } }' + let parser = new Parser(source) + let root = parser.parse() - expect(webkit.name).toBe('-webkit-transform') - expect(webkit.is_vendor_prefixed).toBe(true) + let a = root.first_child! + expect(a.type).toBe(STYLE_RULE) + }) - expect(moz.name).toBe('-moz-transform') - expect(moz.is_vendor_prefixed).toBe(true) + test('trailing comma in selector', () => { + let source = '.a, .b, { color: red; }' + let parser = new Parser(source) + let root = parser.parse() - expect(standard.name).toBe('transform') - expect(standard.is_vendor_prefixed).toBe(false) + let rule = root.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) }) - 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() + describe('Comment handling', () => { + test('skip comments at top level', () => { + let source = '/* comment */ body { color: red; } /* another comment */' + 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) - }) + expect(root.children.length).toBe(1) + let rule = root.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - 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('skip comments in declaration block', () => { + let source = 'body { color: red; /* comment */ margin: 0; }' + let parser = new Parser(source) + let root = parser.parse() - 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! + expect(rule.type).toBe(STYLE_RULE) + expect(rule.children.length).toBe(2) + }) - 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('skip comments in selector', () => { + let source = 'body /* comment */ , /* comment */ div { color: red; }' + let parser = new Parser(source) + let root = parser.parse() - 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! // 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) - }) + let rule = root.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - 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! // 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('comment between property and colon', () => { + let source = 'body { color /* comment */ : red; }' + let parser = new Parser(source) + let root = parser.parse() - 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! // 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) - }) + expect(root.has_children).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! // 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('multi-line comments', () => { + let source = ` + /* + * Multi-line + * comment + */ + body { color: red; } + ` + let parser = new Parser(source) + let root = parser.parse() - 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! // 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) + expect(root.children.length).toBe(1) + }) }) - 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! // 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) - }) + describe('Whitespace handling', () => { + test('excessive whitespace', () => { + let source = ' body { color : red ; } ' + let parser = new Parser(source) + let root = parser.parse() - 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! // 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) - }) + let rule = root.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - 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! // 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('tabs and newlines', () => { + let source = 'body\t{\n\tcolor:\tred;\n}\n' + let parser = new Parser(source) + let root = parser.parse() - 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! // SELECTOR wrapper - let typeSelector = selector.first_child! - let pseudoElement = typeSelector.next_sibling! - expect(pseudoElement.name).toBe('before') - expect(pseudoElement.is_vendor_prefixed).toBe(false) - }) + let rule = root.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - 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! // 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! // 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! // 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('no whitespace', () => { + let source = 'body{color:red;margin:0}' + let parser = new Parser(source) + let root = parser.parse() - 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! // 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) + 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('complex real-world scenarios', () => { - test('should parse complex nested structure', () => { - let source = ` - .card { - display: flex; - padding: 1rem; + describe('Complex real-world scenarios', () => { + test('complex nested structure', () => { + let source = ` + .card { + display: flex; + padding: 1rem; - .header { - font-size: 2rem; - font-weight: bold; + .header { + font-size: 2rem; + font-weight: bold; - &:hover { - color: blue; + &:hover { + color: blue; + } } - } - @media (min-width: 768px) { - padding: 2rem; + @media (min-width: 768px) { + padding: 2rem; - .header { - font-size: 3rem; + .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(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; } + .footer { + margin-top: auto; } } - } - ` - let parser = new Parser(source) - let root = parser.parse() - - let [layer1, layer2] = root.children - expect(layer1.type).toBe(AT_RULE) - expect(layer2.type).toBe(AT_RULE) - }) + ` + let parser = new Parser(source) + let root = parser.parse() - 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') - }) + let card = root.first_child! + expect(card.type).toBe(STYLE_RULE) + expect(card.children.length).toBe(2) + }) - 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() + test('multiple at-rules with nesting', () => { + let source = ` + @layer base { + body { margin: 0; } + } - let rule = root.first_child! - let selector = rule.first_child! - expect(selector.text).toContain('h1') - expect(selector.text).toContain('[role="heading"]') - }) + @layer components { + .btn { + padding: 0.5rem; - test('should parse deeply nested at-rules', () => { - let source = ` - @supports (display: grid) { - @media (min-width: 768px) { - @layer utilities { - .grid { display: grid; } + @media (hover: hover) { + &:hover { opacity: 0.8; } + } } } - } - ` - 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(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(STYLE_RULE) - expect(rule2.type).toBe(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 === 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(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(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(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(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(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(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(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(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(STYLESHEET) - expect(atrule!.type).toBe(AT_RULE) - expect(atrule?.block?.type).toBe(BLOCK) - }) - - test('block children should be stylerule', () => { - expect(atrule!.block).not.toBeNull() - expect(rule!.type).toBe(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(SELECTOR_LIST) - }) - - test('has correct nested selectors', () => { - let list = rule?.first_child - expect(list!.type).toBe(SELECTOR_LIST) - expect(list!.children).toHaveLength(1) - expect(list?.first_child?.type).toEqual(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! + ` + let parser = new Parser(source) + let root = parser.parse() - expect(atRule.has_block).toBe(true) - expect(atRule.block!.text).toBe('') - expect(atRule.text).toBe('@layer test {}') // at-rule includes braces - }) + let [layer1, layer2] = root.children + expect(layer1.type).toBe(AT_RULE) + expect(layer2.type).toBe(AT_RULE) + }) - 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! + test('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() - 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 - }) + 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('empty style rule block should have empty text', () => { - const parser = new Parser('body {}') - const root = parser.parse() - const styleRule = root.first_child! + test('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() - expect(styleRule.has_block).toBe(true) - expect(styleRule.block!.text).toBe('') - expect(styleRule.text).toBe('body {}') // style rule includes braces - }) + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toContain('h1') + expect(selector.text).toContain('[role="heading"]') + }) - 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! + test('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() - expect(styleRule.has_block).toBe(true) - expect(styleRule.block!.text).toBe(' color: red; ') - expect(styleRule.text).toBe('body { color: red; }') // style rule includes braces - }) + 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('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! + test('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() - expect(parentBlock.text).toBe(' .child { margin: 0; } ') - expect(childBlock.text).toBe(' margin: 0; ') - }) + 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('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! + test('custom properties', () => { + let source = ':root { --primary-color: #007bff; --spacing: 1rem; } body { color: var(--primary-color); }' + let parser = new Parser(source) + let root = parser.parse() - expect(atRule.block!.text).toBe(' font-family: "Test"; src: url(test.woff); ') - }) + expect(root.children.length).toBeGreaterThan(0) + let first_rule = root.first_child! + expect(first_rule.type).toBe(STYLE_RULE) + }) - 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! + test('attribute selectors with operators', () => { + let source = '[href^="https"][href$=".pdf"][class*="doc"] { color: red; }' + let parser = new Parser(source) + let root = parser.parse() - expect(mediaRule.block!.text).toBe(' body { color: blue; } ') - }) + let rule = root.first_child! + let selector = rule.first_child! + expect(selector.text).toContain('^=') + expect(selector.text).toContain('$=') + expect(selector.text).toContain('*=') + }) - test('block with no whitespace should be empty', () => { - const parser = new Parser('div{}') - const root = parser.parse() - const styleRule = root.first_child! + test('pseudo-elements', () => { + let source = '.text::before { content: "→"; } .text::after { content: "←"; }' + let parser = new Parser(source) + let root = parser.parse() - expect(styleRule.block!.text).toBe('') - }) + let [rule1, rule2] = root.children + expect(rule1.type).toBe(STYLE_RULE) + expect(rule2.type).toBe(STYLE_RULE) + }) - test('block with only whitespace should preserve whitespace', () => { - const parser = new Parser('div{ \n\t }') - const root = parser.parse() - const styleRule = root.first_child! + test('multiple !important declarations', () => { + let source = '.override { color: red !important; margin: 0 !important; padding: 0 !ie; }' + let parser = new Parser(source) + let root = parser.parse() - expect(styleRule.block!.text).toBe(' \n\t ') + let rule = root.first_child! + let block = rule.block! + expect(block.children.length).toBeGreaterThan(1) + let declarations = block.children.filter((c) => c.type === DECLARATION) + expect(declarations.length).toBeGreaterThan(0) + expect(declarations[0].is_important).toBe(true) + }) }) - }) - describe('deeply nested modern CSS', () => { - test('@container should parse nested style rules', () => { - let css = `@container (width > 0) { div { color: red; } }` - let ast = parse(css) + 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(AT_RULE) - expect(container.name).toBe('container') + const container = ast.first_child! + expect(container.type).toBe(AT_RULE) + expect(container.name).toBe('container') - const containerBlock = container.block! - const rule = containerBlock.first_child! - expect(rule.type).toBe(STYLE_RULE) - }) + const containerBlock = container.block! + const rule = containerBlock.first_child! + expect(rule.type).toBe(STYLE_RULE) + }) - test('@container should parse rules with :has() selector', () => { - let css = `@container (width > 0) { ul:has(li) { color: red; } }` - let ast = parse(css) + 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(STYLE_RULE) - }) + const container = ast.first_child! + const containerBlock = container.block! + const rule = containerBlock.first_child! + expect(rule.type).toBe(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; + 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) + }` + let ast = parse(css) - // Root should be stylesheet - expect(ast.type).toBe(STYLESHEET) - expect(ast.has_children).toBe(true) - - // First child: @layer what - const layer = ast.first_child! - expect(layer.type).toBe(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(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(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(SELECTOR_LIST) - const selector = selectorList.first_child! - expect(selector.type).toBe(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(TYPE_SELECTOR) - expect(selectorParts[0].text).toBe('ul') - - // Inside ul rule: @media (height > 0) - const media = ulRule.block!.first_child! - expect(media.type).toBe(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(STYLE_RULE) - expect(nestingRule.has_block).toBe(true) - - // Verify nesting selector &:hover - const nestingSelectorList = nestingRule.first_child! - expect(nestingSelectorList.type).toBe(SELECTOR_LIST) - const nestingSelector = nestingSelectorList.first_child! - expect(nestingSelector.type).toBe(SELECTOR) - const nestingParts = nestingSelector.children - expect(nestingParts.length).toBeGreaterThan(0) - expect(nestingParts[0].type).toBe(NESTING_SELECTOR) - expect(nestingParts[0].text).toBe('&') - - // Inside &:hover: --is: this declaration - const declaration = nestingRule.block!.first_child! - expect(declaration.type).toBe(DECLARATION) - expect(declaration.property).toBe('--is') - expect(declaration.value).toBe('this') + expect(ast.type).toBe(STYLESHEET) + expect(ast.has_children).toBe(true) + + const layer = ast.first_child! + expect(layer.type).toBe(AT_RULE) + expect(layer.name).toBe('layer') + expect(layer.prelude).toBe('what') + expect(layer.has_block).toBe(true) + + const container = layer.block!.first_child! + expect(container.type).toBe(AT_RULE) + expect(container.name).toBe('container') + expect(container.prelude).toBe('(width > 0)') + expect(container.has_block).toBe(true) + + const ulRule = container.block!.first_child! + expect(ulRule.type).toBe(STYLE_RULE) + expect(ulRule.has_block).toBe(true) + + const selectorList = ulRule.first_child! + expect(selectorList.type).toBe(SELECTOR_LIST) + const selector = selectorList.first_child! + expect(selector.type).toBe(SELECTOR) + const selectorParts = selector.children + expect(selectorParts.length).toBeGreaterThan(0) + expect(selectorParts[0].type).toBe(TYPE_SELECTOR) + expect(selectorParts[0].text).toBe('ul') + + const media = ulRule.block!.first_child! + expect(media.type).toBe(AT_RULE) + expect(media.name).toBe('media') + expect(media.prelude).toBe('(height > 0)') + expect(media.has_block).toBe(true) + + const nestingRule = media.block!.first_child! + expect(nestingRule.type).toBe(STYLE_RULE) + expect(nestingRule.has_block).toBe(true) + + const nestingSelectorList = nestingRule.first_child! + expect(nestingSelectorList.type).toBe(SELECTOR_LIST) + const nestingSelector = nestingSelectorList.first_child! + expect(nestingSelector.type).toBe(SELECTOR) + const nestingParts = nestingSelector.children + expect(nestingParts.length).toBeGreaterThan(0) + expect(nestingParts[0].type).toBe(NESTING_SELECTOR) + expect(nestingParts[0].text).toBe('&') + + const declaration = nestingRule.block!.first_child! + expect(declaration.type).toBe(DECLARATION) + expect(declaration.property).toBe('--is') + expect(declaration.value).toBe('this') + }) }) }) }) diff --git a/src/stylerule-structure.test.ts b/src/stylerule-structure.test.ts deleted file mode 100644 index 71a0eef..0000000 --- a/src/stylerule-structure.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { Parser } from './parse' -import { STYLE_RULE, SELECTOR_LIST, DECLARATION, AT_RULE } from './arena' - -describe('StyleRule Structure', () => { - test('should have selector list as first child, followed by declarations', () => { - const parser = new Parser('body { color: red; margin: 0; }') - const root = parser.parse() - const rule = root.first_child! - - expect(rule.type).toBe(STYLE_RULE) - - // First child must be selector list - const firstChild = rule.first_child! - expect(firstChild.type).toBe(SELECTOR_LIST) - - // Second child should be block containing declarations - const block = firstChild.next_sibling! - expect(block).not.toBeNull() - - // Declarations should be inside the block - const secondChild = block.first_child! - expect(secondChild.type).toBe(DECLARATION) - - // Second declaration - const thirdChild = secondChild.next_sibling! - expect(thirdChild).not.toBeNull() - expect(thirdChild.type).toBe(DECLARATION) - - // No more children - expect(thirdChild.next_sibling).toBeNull() - }) - - test('selector list children should be individual selector components with next_sibling links', () => { - const parser = new Parser('h1, h2, h3 { color: red; }') - const root = parser.parse() - const rule = root.first_child! - const selectorList = rule.first_child! - - expect(selectorList.type).toBe(SELECTOR_LIST) - - // Get all children of the selector list - const children = [] - let child = selectorList.first_child - while (child) { - children.push(child) - child = child.next_sibling - } - - // Should have 3 selector components (h1, h2, h3) - expect(children.length).toBe(3) - - // Each child except the last should have next_sibling - for (let i = 0; i < children.length - 1; i++) { - const nextSibling = children[i].next_sibling - expect(nextSibling).not.toBeNull() - // Compare by index since CSSNode creates new wrapper instances - expect(nextSibling!.get_index()).toBe(children[i + 1].get_index()) - } - - // Last child should NOT have next_sibling - expect(children[children.length - 1].next_sibling).toBeNull() - }) - - test('complex selectors should maintain component chains in selector list', () => { - const parser = new Parser('div.class, span#id { margin: 0; }') - const root = parser.parse() - const rule = root.first_child! - const selectorList = rule.first_child! - - expect(selectorList.type).toBe(SELECTOR_LIST) - - // Collect all NODE_SELECTOR wrappers (direct children of selector list) - const selectors = [] - let selector = selectorList.first_child - while (selector) { - selectors.push(selector) - selector = selector.next_sibling - } - - // Should have 2 NODE_SELECTOR wrappers: div.class and span#id - expect(selectors.length).toBe(2) - - // First selector (div.class) should have 2 components - const components1 = [] - let comp = selectors[0].first_child - while (comp) { - components1.push(comp) - comp = comp.next_sibling - } - expect(components1.length).toBe(2) // div, .class - - // Second selector (span#id) should have 2 components - const components2 = [] - comp = selectors[1].first_child - while (comp) { - components2.push(comp) - comp = comp.next_sibling - } - expect(components2.length).toBe(2) // span, #id - }) - - test('selector list should be first child, never in middle or end', () => { - const testCases = [ - 'body { color: red; }', - 'div { margin: 0; padding: 10px; }', - 'h1 { color: blue; .nested { margin: 0; } }', - 'p { font-size: 16px; @media print { display: none; } }', - ] - - testCases.forEach((source) => { - const parser = new Parser(source) - const root = parser.parse() - const rule = root.first_child! - - // First child must be selector list - expect(rule.first_child!.type).toBe(SELECTOR_LIST) - - // Walk through all children and verify no other selector lists - let child = rule.first_child!.next_sibling - while (child) { - expect(child.type).not.toBe(SELECTOR_LIST) - child = child.next_sibling - } - }) - }) - - test('nested style rules should also have selector list as first child', () => { - const parser = new Parser('div { .nested { color: red; } }') - const root = parser.parse() - const outerRule = root.first_child! - - // Outer rule structure - expect(outerRule.type).toBe(STYLE_RULE) - expect(outerRule.first_child!.type).toBe(SELECTOR_LIST) - - // Find the nested rule (inside the block) - const block = outerRule.first_child!.next_sibling! - const nestedRule = block.first_child! - expect(nestedRule.type).toBe(STYLE_RULE) - - // Nested rule should also have selector list as first child - expect(nestedRule.first_child!.type).toBe(SELECTOR_LIST) - - // Declaration comes after selector list in nested rule's block - const nestedBlock = nestedRule.first_child!.next_sibling! - expect(nestedBlock.first_child!.type).toBe(DECLARATION) - }) - - test('& span should be parsed as ONE selector with 3 components', () => { - const parser = new Parser('.parent { & span { color: red; } }') - const root = parser.parse() - const outerRule = root.first_child! - - // Find the nested rule (& span) - const block = outerRule.first_child!.next_sibling! - const nestedRule = block.first_child! - expect(nestedRule.type).toBe(STYLE_RULE) - - // Get selector list - const selectorList = nestedRule.first_child! - expect(selectorList.type).toBe(SELECTOR_LIST) - - // Count how many selectors in the list (should be 1, not 2) - const selectors = [] - let selector = selectorList.first_child - while (selector) { - selectors.push(selector) - selector = selector.next_sibling - } - - // BUG: This should be 1 selector, but might be 2 - expect(selectors.length).toBe(1) - - // The single selector should have 3 children: &, combinator (space), span - if (selectors.length === 1) { - const components = [] - let component = selectors[0].first_child - while (component) { - components.push(component) - component = component.next_sibling - } - expect(components.length).toBe(3) - } - }) - - test('selector list with combinators should chain all components correctly', () => { - const parser = new Parser('div > p, span + a { color: blue; }') - const root = parser.parse() - const rule = root.first_child! - const selectorList = rule.first_child! - - // Collect all NODE_SELECTOR wrappers (direct children of selector list) - const selectors = [] - let selector = selectorList.first_child - while (selector) { - selectors.push(selector) - selector = selector.next_sibling - } - - // Should have 2 NODE_SELECTOR wrappers: div > p and span + a - expect(selectors.length).toBe(2) - - // First selector (div > p) should have 3 components: div, >, p - const components1 = [] - let comp = selectors[0].first_child - while (comp) { - components1.push(comp) - comp = comp.next_sibling - } - expect(components1.length).toBe(3) - - // Second selector (span + a) should have 3 components: span, +, a - const components2 = [] - comp = selectors[1].first_child - while (comp) { - components2.push(comp) - comp = comp.next_sibling - } - expect(components2.length).toBe(3) - }) - - test('empty rule should still have selector list as first child', () => { - const parser = new Parser('body { }') - const root = parser.parse() - const rule = root.first_child! - - expect(rule.type).toBe(STYLE_RULE) - expect(rule.first_child!.type).toBe(SELECTOR_LIST) - - // Rule should have selector list + empty block - const block = rule.first_child!.next_sibling - expect(block).not.toBeNull() - expect(block!.is_empty).toBe(true) - }) - - test('block children should be correctly linked via next_sibling with declarations only', () => { - const parser = new Parser('body { color: red; margin: 0; padding: 10px; }') - const root = parser.parse() - const rule = root.first_child! - - // Get the block - const selectorList = rule.first_child! - const block = selectorList.next_sibling! - - // Collect all children using next_sibling - const children = [] - let child = block.first_child - while (child) { - children.push(child) - child = child.next_sibling - } - - // Should have 3 declarations - expect(children.length).toBe(3) - - // Verify each child is a declaration - for (let i = 0; i < children.length; i++) { - expect(children[i].type).toBe(DECLARATION) - } - - // Verify next_sibling chain - for (let i = 0; i < children.length - 1; i++) { - expect(children[i].next_sibling).not.toBeNull() - expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) - } - - // Last child should have null next_sibling - expect(children[children.length - 1].next_sibling).toBeNull() - }) - - test('block children should be correctly linked via next_sibling with mixed content', () => { - const parser = new Parser(` - .parent { - color: red; - .nested { margin: 0; } - padding: 10px; - @media print { display: none; } - font-size: 16px; - } - `) - const root = parser.parse() - const rule = root.first_child! - - // Get the block - const selectorList = rule.first_child! - const block = selectorList.next_sibling! - - // Collect all children using next_sibling - const children = [] - let child = block.first_child - while (child) { - children.push(child) - child = child.next_sibling - } - - // Should have 5 children: declaration, nested rule, declaration, at-rule, declaration - expect(children.length).toBe(5) - - // Verify types in order - expect(children[0].type).toBe(DECLARATION) // color: red - expect(children[1].type).toBe(STYLE_RULE) // .nested { margin: 0; } - expect(children[2].type).toBe(DECLARATION) // padding: 10px - expect(children[3].type).toBe(AT_RULE) // @media print { display: none; } - expect(children[4].type).toBe(DECLARATION) // font-size: 16px - - // Verify next_sibling chain - for (let i = 0; i < children.length - 1; i++) { - const nextSibling = children[i].next_sibling - expect(nextSibling).not.toBeNull() - expect(nextSibling!.get_index()).toBe(children[i + 1].get_index()) - } - - // Last child should have null next_sibling - expect(children[children.length - 1].next_sibling).toBeNull() - }) - - test('block with only nested rules should have correct next_sibling chain', () => { - const parser = new Parser(` - .parent { - .child1 { color: red; } - .child2 { margin: 0; } - .child3 { padding: 10px; } - } - `) - const root = parser.parse() - const rule = root.first_child! - - // Get the block - const selectorList = rule.first_child! - const block = selectorList.next_sibling! - - // Collect all children using next_sibling - const children = [] - let child = block.first_child - while (child) { - children.push(child) - child = child.next_sibling - } - - // Should have 3 nested rules - expect(children.length).toBe(3) - - // Verify each is a style rule - for (const child of children) { - expect(child.type).toBe(STYLE_RULE) - } - - // Verify next_sibling chain - for (let i = 0; i < children.length - 1; i++) { - expect(children[i].next_sibling).not.toBeNull() - expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) - } - - // Last child should have null next_sibling - expect(children[children.length - 1].next_sibling).toBeNull() - }) - - test('block with only at-rules should have correct next_sibling chain', () => { - const parser = new Parser(` - .parent { - @media screen { color: blue; } - @media print { display: none; } - @supports (display: flex) { display: flex; } - } - `) - const root = parser.parse() - const rule = root.first_child! - - // Get the block - const selectorList = rule.first_child! - const block = selectorList.next_sibling! - - // Collect all children using next_sibling - const children = [] - let child = block.first_child - while (child) { - children.push(child) - child = child.next_sibling - } - - // Should have 3 at-rules - expect(children.length).toBe(3) - - // Verify each is an at-rule - for (const child of children) { - expect(child.type).toBe(AT_RULE) - } - - // Verify next_sibling chain - for (let i = 0; i < children.length - 1; i++) { - expect(children[i].next_sibling).not.toBeNull() - expect(children[i].next_sibling!.get_index()).toBe(children[i + 1].get_index()) - } - - // Last child should have null next_sibling - expect(children[children.length - 1].next_sibling).toBeNull() - }) -})