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()
- })
-})