From be931a5653e16e12d07739137694173d0256326f Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 7 Dec 2025 21:14:33 +0100 Subject: [PATCH 1/2] regression tests for prelude lengths --- src/parse-atrule-prelude.test.ts | 271 +++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index bf9049d..7859d2a 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -707,4 +707,275 @@ 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(NODE_PRELUDE_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 === NODE_PRELUDE_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 === NODE_PRELUDE_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 === NODE_PRELUDE_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 === NODE_PRELUDE_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 === NODE_PRELUDE_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 === NODE_PRELUDE_IMPORT_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 === NODE_PRELUDE_IMPORT_LAYER) + 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 === NODE_PRELUDE_IMPORT_SUPPORTS) + 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 === NODE_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) + }) + }) + }) }) From 7f53c14ac8bb6521cb02f3285c786e33b0843eb3 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 7 Dec 2025 21:40:45 +0100 Subject: [PATCH 2/2] fix: correct atrule lengths --- src/parse.test.ts | 211 +++++++++++++++++++++++++--------------------- src/parse.ts | 2 +- 2 files changed, 114 insertions(+), 99 deletions(-) diff --git a/src/parse.test.ts b/src/parse.test.ts index 8bee9e8..aa3fb15 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -265,6 +265,7 @@ describe('Parser', () => { expect(atRule.type).toBe(NODE_AT_RULE) expect(atRule.name).toBe('import') expect(atRule.has_children).toBe(false) + expect(atRule.length).toBe(25) }) test('should parse @namespace', () => { @@ -275,6 +276,7 @@ describe('Parser', () => { const atRule = root.first_child! expect(atRule.type).toBe(NODE_AT_RULE) expect(atRule.name).toBe('namespace') + expect(atRule.length).toBe(45) }) }) @@ -302,6 +304,7 @@ describe('Parser', () => { const fontFace = root.first_child! expect(fontFace.type).toBe(NODE_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! @@ -331,10 +334,12 @@ describe('Parser', () => { expect(media.type).toBe(NODE_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(NODE_STYLE_RULE) + expect(nestedRule.length).toBe(20) }) test('should parse @layer with name', () => { @@ -437,15 +442,19 @@ describe('Parser', () => { 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(NODE_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(NODE_STYLE_RULE) + expect(rule.length).toBe(20) }) }) @@ -457,8 +466,11 @@ describe('Parser', () => { 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) }) }) }) @@ -516,13 +528,16 @@ describe('Parser', () => { 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(NODE_STYLE_RULE) + expect(b.length).toBe(25) let [_selector_b, block_b] = b.children let c = block_b.first_child! expect(c.type).toBe(NODE_STYLE_RULE) + expect(c.length).toBe(18) let [_selector_c, block_c] = c.children let decl = block_c.first_child! @@ -604,128 +619,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() + 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 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 [_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('> a') + // Verify selector has children (was parsed, not left empty) + expect(nested_selector.has_children).toBe(true) + }) - 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 next-sibling combinator', () => { + let source = '.parent { + span { color: blue; } }' + let parser = new Parser(source) + let root = parser.parse() - 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 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) + }) - let nested_selector = nested_rule.first_child! - expect(nested_selector.text).toBe('~ div') - 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() - 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 nested_rule = block.first_child! + expect(nested_rule.type).toBe(NODE_STYLE_RULE) - let parent = root.first_child! - let [_selector, block] = parent.children - let [rule1, rule2, rule3] = block.children + let nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('~ div') + expect(nested_selector.has_children).toBe(true) + }) - expect(rule1.type).toBe(NODE_STYLE_RULE) - expect(rule1.first_child!.text).toBe('> a') - expect(rule1.first_child!.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() - expect(rule2.type).toBe(NODE_STYLE_RULE) - expect(rule2.first_child!.text).toBe('~ span') - expect(rule2.first_child!.has_children).toBe(true) + let parent = root.first_child! + let [_selector, block] = parent.children + let [rule1, rule2, rule3] = block.children - expect(rule3.type).toBe(NODE_STYLE_RULE) - expect(rule3.first_child!.text).toBe('+ div') - expect(rule3.first_child!.has_children).toBe(true) - }) + expect(rule1.type).toBe(NODE_STYLE_RULE) + expect(rule1.first_child!.text).toBe('> a') + expect(rule1.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() + expect(rule2.type).toBe(NODE_STYLE_RULE) + expect(rule2.first_child!.text).toBe('~ span') + expect(rule2.first_child!.has_children).toBe(true) - let parent = root.first_child! - let [_selector, block] = parent.children - let nested_rule = block.first_child! + expect(rule3.type).toBe(NODE_STYLE_RULE) + expect(rule3.first_child!.text).toBe('+ div') + expect(rule3.first_child!.has_children).toBe(true) + }) - 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 complex selector after leading combinator', () => { + let source = '.parent { > a.link#nav[href]:hover { color: red; } }' + let parser = new Parser(source) + let root = parser.parse() - 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 parent = root.first_child! + let [_selector, block] = parent.children + let nested_rule = block.first_child! - 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 nested_selector = nested_rule.first_child! + expect(nested_selector.text).toBe('> a.link#nav[href]:hover') + expect(nested_selector.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 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() + 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 + 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(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) + 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', () => { test('should parse @keyframes with from/to', () => { diff --git a/src/parse.ts b/src/parse.ts index 097c6da..bd8fa51 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -528,8 +528,8 @@ export class Parser { // Consume '}' (block excludes closing brace, but at-rule includes it) if (this.peek_type() === TOKEN_RIGHT_BRACE) { let block_end = this.lexer.token_start // Position of '}' (not included in block) - this.next_token() last_end = this.lexer.token_end // Position after '}' (included in at-rule) + this.next_token() // Set block length (excludes closing brace) this.arena.set_length(block_node, block_end - block_start)