From a40b8a6977913b3dd8e515e0028f0c91ceed307b Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 3 Dec 2025 15:36:47 +0100 Subject: [PATCH] fix: improve nested selectors --- src/parse-selector.test.ts | 160 +++++++++++++++++++++++++++++++++++++ src/parse-selector.ts | 7 +- src/parse.test.ts | 122 ++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 3 deletions(-) diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index 7d7a326..c52c2c9 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -1070,6 +1070,166 @@ describe('SelectorParser', () => { }) }) + 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(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('a') + }) + + it('should parse selector starting with next-sibling combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('+ div') + + 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) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('+') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('div') + }) + + it('should parse selector starting with subsequent-sibling combinator', () => { + const { arena, rootNode, source } = parseSelectorInternal('~ span') + + 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) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('~') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('span') + }) + + 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(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('a') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_CLASS) + expect(getNodeText(arena, source, children[2])).toBe('.link') + expect(arena.get_type(children[3])).toBe(NODE_SELECTOR_ID) + expect(getNodeText(arena, source, children[3])).toBe('#nav') + expect(arena.get_type(children[4])).toBe(NODE_SELECTOR_ATTRIBUTE) + expect(arena.get_type(children[5])).toBe(NODE_SELECTOR_PSEUDO_CLASS) + }) + + 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(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + 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(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('~') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + 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(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('+') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('div') + }) + + it('should parse leading combinator with whitespace', () => { + const { arena, rootNode, source } = parseSelectorInternal('> a') + + 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) + expect(children).toHaveLength(2) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('a') + }) + + it('should parse selector with both leading and middle combinators', () => { + const { arena, rootNode, source } = parseSelectorInternal('> div span') + + 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 (div), combinator (descendant), type (span) + expect(children).toHaveLength(4) + expect(arena.get_type(children[0])).toBe(NODE_SELECTOR_COMBINATOR) + expect(getNodeText(arena, source, children[0]).trim()).toBe('>') + expect(arena.get_type(children[1])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[1])).toBe('div') + expect(arena.get_type(children[2])).toBe(NODE_SELECTOR_COMBINATOR) + expect(arena.get_type(children[3])).toBe(NODE_SELECTOR_TYPE) + expect(getNodeText(arena, source, children[3])).toBe('span') + }) + }) + describe('Edge cases', () => { it('should parse selector with multiple spaces', () => { const { arena, rootNode, source } = parseSelectorInternal('div p') diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 74d3c18..2dc7a1d 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -81,7 +81,7 @@ export class SelectorParser { // Parse a selector range into selector nodes // Always returns a NODE_SELECTOR_LIST with selector components as children - parse_selector(start: number, end: number, line: number = 1, column: number = 1, allow_relative: boolean = false): number | null { + parse_selector(start: number, end: number, line: number = 1, column: number = 1, allow_relative: boolean = true): number | null { this.selector_end = end // Position lexer at selector start @@ -95,7 +95,7 @@ export class SelectorParser { } // Parse comma-separated selectors - private parse_selector_list(allow_relative: boolean = false): number | null { + private parse_selector_list(allow_relative: boolean = true): number | null { let selectors: number[] = [] let list_start = this.lexer.pos let list_line = this.lexer.line @@ -170,7 +170,8 @@ export class SelectorParser { // Parse a complex selector (with combinators) // e.g., "div.class > p + span" - private parse_complex_selector(allow_relative: boolean = false): number | null { + // Also supports CSS Nesting relaxed syntax: "> a", "~ span", etc. + private parse_complex_selector(allow_relative: boolean = true): number | null { let components: number[] = [] // Skip leading whitespace diff --git a/src/parse.test.ts b/src/parse.test.ts index a31382e..8bee9e8 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -603,6 +603,128 @@ describe('Parser', () => { expect(body.type).toBe(NODE_STYLE_RULE) }) + + 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() + + let parent = root.first_child! + expect(parent.type).toBe(NODE_STYLE_RULE) + + let [_selector, block] = parent.children + let nested_rule = block.first_child! + expect(nested_rule.type).toBe(NODE_STYLE_RULE) + + 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) + }) + + 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(NODE_STYLE_RULE) + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('+ span') + expect(nested_selector.has_children).toBe(true) + }) + + test('should parse 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(NODE_STYLE_RULE) + + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('~ div') + expect(nested_selector.has_children).toBe(true) + }) + + test('should parse 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(NODE_STYLE_RULE) + expect(rule1.first_child!.text).toBe('> a') + expect(rule1.first_child!.has_children).toBe(true) + + expect(rule2.type).toBe(NODE_STYLE_RULE) + expect(rule2.first_child!.text).toBe('~ span') + expect(rule2.first_child!.has_children).toBe(true) + + expect(rule3.type).toBe(NODE_STYLE_RULE) + expect(rule3.first_child!.text).toBe('+ div') + expect(rule3.first_child!.has_children).toBe(true) + }) + + test('should parse 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('should parse 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(NODE_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(NODE_STYLE_RULE) + expect(c.first_child!.text).toBe('> .c') + expect(c.first_child!.has_children).toBe(true) + }) + + test('should parse 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(NODE_STYLE_RULE) + expect(normal.first_child!.text).toBe('.normal') + + expect(combinator.type).toBe(NODE_STYLE_RULE) + expect(combinator.first_child!.text).toBe('> .combinator') + expect(combinator.first_child!.has_children).toBe(true) + }) + }) }) describe('@keyframes parsing', () => {