From 3c9735c0c96237b34f955fe97b15021f6dd132c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:45:48 +0000 Subject: [PATCH 1/4] feat!: wrap @import layer() and supports() in a Function node BREAKING CHANGE: in an `@import` prelude, `layer(name)` and `supports(...)` are now `Function` nodes instead of `LayerName` / `SupportsQuery` nodes. - `layer(a.b)` -> Function{name='layer'} > LayerName{name='a.b'} - `layer()` -> Function{name='layer'} without children - `layer` -> LayerName (unchanged, name='') - `supports(d: g)` -> Function{name='supports'} > SupportsQuery > SupportsDeclaration - `supports((a) and (b))` -> Function{name='supports'} with the same flat children as an `@supports` prelude The Function owns the surrounding parentheses; a SupportsQuery only owns parentheses that are present in the source. Co-Authored-By: Claude Sonnet 5.5 --- src/node-types.ts | 4 +- src/parse-atrule-prelude.test.ts | 102 +++++++++++++++++++++++-------- src/parse-atrule-prelude.ts | 98 +++++++++++++++++------------ 3 files changed, 139 insertions(+), 65 deletions(-) diff --git a/src/node-types.ts b/src/node-types.ts index 419cf06..434808d 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -301,12 +301,14 @@ export type Function = WithClone< // `supports()` hold SupportsDeclaration/SupportsQuery/PreludeOperator children (matching // `@supports`'s own shape, including the full compound and/or/not grammar for // `supports()`) and `media()` holds a MediaFeature or FeatureRange child (matching - // `@media`'s own shape, including range comparison syntax) — see parse_if_condition_function + // `@media`'s own shape, including range comparison syntax). `@import`'s `layer()` holds a + // LayerName child — see parse_import_layer — see parse_if_condition_function WithChildren< | ValueLike | MediaFeature | SupportsDeclaration | SupportsQuery + | LayerName | FeatureRange | PreludeOperator > & { diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 6dafe93..c041b20 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1700,6 +1700,7 @@ describe('At-Rule Prelude Nodes', () => { expect(children[1].type).toBe(LAYER_NAME) expect(children[1].text).toBe('layer') expect((children[1] as LayerName).name).toBe('') + expect((children[1] as LayerName).value).toBe('') }) test('should parse with anonymous LAYER', () => { @@ -1713,6 +1714,7 @@ describe('At-Rule Prelude Nodes', () => { expect(children[1].type).toBe(LAYER_NAME) expect(children[1].text).toBe('LAYER') expect((children[1] as LayerName).name).toBe('') + expect((children[1] as LayerName).value).toBe('') }) test('should parse with named layer', () => { @@ -1723,9 +1725,14 @@ describe('At-Rule Prelude Nodes', () => { expect(children.length).toBe(2) expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) + expect(children[1].type).toBe(FUNCTION) expect(children[1].text).toBe('layer(utilities)') - expect((children[1] as LayerName).name).toBe('utilities') + expect((children[1] as Function).name).toBe('layer') + expect((children[1] as Function).value).toBe('utilities') + const layer = (children[1] as Function).first_child as LayerName + expect(layer.type).toBe(LAYER_NAME) + expect(layer.text).toBe('utilities') + expect(layer.name).toBe('utilities') }) test('should trim whitespace from layer names', () => { @@ -1734,8 +1741,9 @@ describe('At-Rule Prelude Nodes', () => { const atRule = ast.first_child! as Atrule const children = (atRule.prelude as AtrulePrelude | null)?.children || [] - expect(children[1].type).toBe(LAYER_NAME) - expect((children[1] as LayerName).name).toBe('utilities') + expect(children[1].type).toBe(FUNCTION) + expect(((children[1] as Function).first_child as LayerName).type).toBe(LAYER_NAME) + expect(((children[1] as Function).first_child as LayerName).name).toBe('utilities') }) test('should trim comments from layer names', () => { @@ -1744,8 +1752,9 @@ describe('At-Rule Prelude Nodes', () => { const atRule = ast.first_child! as Atrule const children = (atRule.prelude as AtrulePrelude | null)?.children || [] - expect(children[1].type).toBe(LAYER_NAME) - expect((children[1] as LayerName).name).toBe('utilities') + expect(children[1].type).toBe(FUNCTION) + expect(((children[1] as Function).first_child as LayerName).type).toBe(LAYER_NAME) + expect(((children[1] as Function).first_child as LayerName).name).toBe('utilities') }) test('should trim whitespace and comments from dotted layer names', () => { @@ -1754,8 +1763,9 @@ describe('At-Rule Prelude Nodes', () => { const atRule = ast.first_child! as Atrule const children = (atRule.prelude as AtrulePrelude | null)?.children || [] - expect(children[1].type).toBe(LAYER_NAME) - expect((children[1] as LayerName).name).toBe('named.nested') + expect(children[1].type).toBe(FUNCTION) + expect(((children[1] as Function).first_child as LayerName).type).toBe(LAYER_NAME) + expect(((children[1] as Function).first_child as LayerName).name).toBe('named.nested') }) test('should parse with supports query', () => { @@ -1766,11 +1776,18 @@ describe('At-Rule Prelude Nodes', () => { expect(children.length).toBe(2) expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) + expect(children[1].type).toBe(FUNCTION) expect(children[1].text).toBe('supports(display: grid)') + expect((children[1] as Function).name).toBe('supports') + + // The Function owns the parentheses, the SupportsQuery inside has none + const query = (children[1] as Function).first_child as SupportsQuery + expect(query.type).toBe(SUPPORTS_QUERY) + expect(query.text).toBe('display: grid') + expect(query.value).toBe('display: grid') // supports()'s argument should be deep-parsed into a SupportsDeclaration - const decl = (children[1] as SupportsQuery).children[0] + const decl = query.children[0] expect(decl.type).toBe(SUPPORTS_DECLARATION) expect(decl.text).toBe('display: grid') }) @@ -1817,10 +1834,10 @@ describe('At-Rule Prelude Nodes', () => { expect(children.length).toBe(3) expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(LAYER_NAME) + expect(children[1].type).toBe(FUNCTION) expect(children[1].text).toBe('layer(base)') - expect((children[1] as LayerName).value).toBe('base') - expect((children[1] as LayerName).name).toBe('base') + expect(((children[1] as Function).first_child as LayerName).value).toBe('base') + expect(((children[1] as Function).first_child as LayerName).name).toBe('base') expect(children[2].type).toBe(MEDIA_QUERY) }) @@ -1835,15 +1852,20 @@ describe('At-Rule Prelude Nodes', () => { expect(prelude.first_child?.type).toBe(URL) expect(url.value).toBe('"styles.css"') - const layer = url?.next_sibling as LayerName + const layer_fn = url?.next_sibling as Function + expect(layer_fn.type).toBe(FUNCTION) + expect(layer_fn.name).toBe('layer') + const layer = layer_fn.first_child as LayerName expect(layer.type).toBe(LAYER_NAME) expect(layer.value).toBe('base') expect(layer.name).toBe('base') - const supports = layer?.next_sibling as SupportsQuery - expect(supports.type).toBe(SUPPORTS_QUERY) + const supports = layer_fn.next_sibling as Function + expect(supports.type).toBe(FUNCTION) + expect(supports.name).toBe('supports') expect(supports.value).toBe('display: grid') expect(supports.text).toBe('supports(display: grid)') + expect((supports.first_child as SupportsQuery).type).toBe(SUPPORTS_QUERY) }) test('should parse with supports and media query', () => { @@ -1854,7 +1876,7 @@ describe('At-Rule Prelude Nodes', () => { expect(children.length).toBe(3) expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) + expect(children[1].type).toBe(FUNCTION) expect(children[2].type).toBe(MEDIA_QUERY) }) @@ -1867,8 +1889,8 @@ describe('At-Rule Prelude Nodes', () => { 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[1].type).toBe(FUNCTION) + expect(children[2].type).toBe(FUNCTION) expect(children[3].type).toBe(MEDIA_QUERY) }) @@ -1880,9 +1902,37 @@ describe('At-Rule Prelude Nodes', () => { expect(children.length).toBe(2) expect(children[0].type).toBe(URL) - expect(children[1].type).toBe(SUPPORTS_QUERY) + expect(children[1].type).toBe(FUNCTION) expect(children[1].text).toBe('supports((display: grid) and (gap: 1rem))') - expect((children[1] as SupportsQuery).value).toBe('(display: grid) and (gap: 1rem)') + expect((children[1] as Function).value).toBe('(display: grid) and (gap: 1rem)') + + // Same flat children as an @supports prelude: each SupportsQuery owns its own parentheses + const inner = (children[1] as Function).children + expect(inner.map((c) => c.type)).toEqual([SUPPORTS_QUERY, PRELUDE_OPERATOR, SUPPORTS_QUERY]) + expect(inner.map((c) => c.text)).toEqual(['(display: grid)', 'and', '(gap: 1rem)']) + }) + + test('should parse bare layer() as Function without children', () => { + const css = '@import url("styles.css") layer();' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child! as Atrule + const fn = (atRule.prelude as AtrulePrelude).children[1] as Function + + expect(fn.type).toBe(FUNCTION) + expect(fn.name).toBe('layer') + expect(fn.text).toBe('layer()') + expect(fn.has_children).toBe(false) + }) + + test('should parse empty supports() as Function without children', () => { + const css = '@import url("styles.css") supports();' + const ast = parse(css, { parse_atrule_preludes: true }) + const atRule = ast.first_child! as Atrule + const fn = (atRule.prelude as AtrulePrelude).children[1] as Function + + expect(fn.type).toBe(FUNCTION) + expect(fn.name).toBe('supports') + expect(fn.has_children).toBe(false) }) test('should preserve prelude text', () => { @@ -2120,7 +2170,7 @@ describe('At-Rule Prelude Nodes', () => { const atRule = ast.first_child! as Atrule const children = (atRule.prelude as AtrulePrelude | null)?.children || [] - const importLayer = children.find((c) => c.type === LAYER_NAME) + const importLayer = children.find((c) => c.type === FUNCTION) expect(importLayer?.text).toBe('layer(utilities)') expect(importLayer?.text.length).toBe(16) }) @@ -2131,7 +2181,7 @@ describe('At-Rule Prelude Nodes', () => { const atRule = ast.first_child! as Atrule const children = (atRule.prelude as AtrulePrelude | null)?.children || [] - const importSupports = children.find((c) => c.type === SUPPORTS_QUERY) + const importSupports = children.find((c) => c.type === FUNCTION) expect(importSupports?.text).toBe('supports(display: flex)') expect(importSupports?.text.length).toBe(23) }) @@ -2416,7 +2466,8 @@ describe('parse_atrule_prelude()', () => { expect(result.length).toBeGreaterThanOrEqual(2) expect(result[0].type).toBe(URL) - expect(result[1].type).toBe(LAYER_NAME) + expect(result[1].type).toBe(FUNCTION) + expect((result[1].first_child as LayerName).type).toBe(LAYER_NAME) }) test('should parse import with supports', () => { @@ -2424,7 +2475,8 @@ describe('parse_atrule_prelude()', () => { expect(result.length).toBeGreaterThanOrEqual(2) expect(result[0].type).toBe(URL) - expect(result[1].type).toBe(SUPPORTS_QUERY) + expect(result[1].type).toBe(FUNCTION) + expect(result[1].first_child?.type).toBe(SUPPORTS_QUERY) }) }) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 8b85c42..61ea094 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -553,7 +553,10 @@ export class AtRulePreludeParser { return url_node } - // Parse import layer: layer or layer(name) + // Parse import layer: `layer` or `layer(name)` + // - `layer` → LayerName with name=null + // - `layer()` → Function{name='layer'} without children + // - `layer(a.b)` → Function{name='layer'} > LayerName{name='a.b'} private parse_import_layer(): number | null { // Peek at next token const saved = this.lexer.save_position() @@ -568,40 +571,37 @@ export class AtRulePreludeParser { if (str_equals_range(this.source, this.lexer.token_start, name_end, 'layer')) { const layer_start = this.lexer.token_start - let layer_end = this.lexer.token_end - let content_start = 0 - let content_length = 0 - - // If it's a function token, parse the contents until closing paren - if (this.lexer.token_type === TOKEN_FUNCTION) { - // Track the content inside the parentheses - content_start = this.lexer.pos - const matched = this.scan_matching_paren() - const content_end = this.paren_content_end - const close_end = this.paren_close_end - if (matched) { - content_length = content_end - content_start - layer_end = close_end - } + + // Bare `layer` keyword: an unnamed layer + if (this.lexer.token_type === TOKEN_IDENT) { + const layer_node = this.create_node(LAYER_NAME, layer_start, this.lexer.token_end) + this.arena.set_content_start_delta(layer_node, 0) + this.arena.set_content_length(layer_node, 0) + return layer_node } - // Create layer node - const layer_node = this.create_node(LAYER_NAME, layer_start, layer_end) + // layer(...): the Function owns the parentheses, the LayerName only the name + const content_start = this.lexer.token_end // After the opening '(' + const matched = this.scan_matching_paren() + const content_end = this.paren_content_end + const layer_end = matched ? this.paren_close_end : this.lexer.pos - // Store the layer name (content inside parentheses), trimmed - if (content_length > 0) { - const trimmed = trim_boundaries( - this.source, - content_start, - content_start + content_length, - ) - if (trimmed) { - this.arena.set_content_start_delta(layer_node, trimmed[0] - layer_start) - this.arena.set_content_length(layer_node, trimmed[1] - trimmed[0]) - } + const func_node = this.create_node(FUNCTION, layer_start, layer_end) + this.arena.set_content_start_delta(func_node, 0) + this.arena.set_content_length(func_node, name_end - layer_start) + + const trimmed = matched ? trim_boundaries(this.source, content_start, content_end) : null + if (trimmed) { + this.arena.set_value_start_delta(func_node, trimmed[0] - layer_start) + this.arena.set_value_length(func_node, trimmed[1] - trimmed[0]) + + const name_node = this.create_node(LAYER_NAME, trimmed[0], trimmed[1]) + this.arena.set_content_start_delta(name_node, 0) + this.arena.set_content_length(name_node, trimmed[1] - trimmed[0]) + this.arena.set_first_child(func_node, name_node) } - return layer_node + return func_node } } @@ -611,6 +611,12 @@ export class AtRulePreludeParser { } // Parse import supports: supports(condition) + // Function{name='supports'} owns the outer parentheses. Its children are either + // - SupportsQuery > SupportsDeclaration for `supports(display: grid)`, where the + // SupportsQuery has no parentheses of its own because the source has none, or + // - the same flat condition nodes as an `@supports` prelude for + // `supports((display: grid) and (color: red))`, where each SupportsQuery + // owns its own `(…)`. private parse_import_supports(): number | null { // Peek at next token const saved = this.lexer.save_position() @@ -624,33 +630,47 @@ export class AtRulePreludeParser { str_equals_range(this.source, this.lexer.token_start, this.lexer.token_end - 1, 'supports') ) { const supports_start = this.lexer.token_start + const name_end = this.lexer.token_end - 1 const content_start = this.lexer.token_end // After the opening '(' + const line = this.lexer.line + const column = this.lexer.column // Find matching closing parenthesis this.scan_matching_paren() const content_end = this.paren_content_end const supports_end = this.paren_close_end - // Create supports node - const supports_node = this.create_node(SUPPORTS_QUERY, supports_start, supports_end) + const func_node = this.create_node(FUNCTION, supports_start, supports_end) + this.arena.set_content_start_delta(func_node, 0) + this.arena.set_content_length(func_node, name_end - supports_start) - // Store query content in value fields, trimmed const trimmed = trim_boundaries(this.source, content_start, content_end) if (trimmed) { - this.arena.set_value_start_delta(supports_node, trimmed[0] - supports_start) - this.arena.set_value_length(supports_node, trimmed[1] - trimmed[0]) + this.arena.set_value_start_delta(func_node, trimmed[0] - supports_start) + this.arena.set_value_length(func_node, trimmed[1] - trimmed[0]) - // Check for simple declaration: supports(property: value) const decl_child = this.condition_parser.parse_supports_declaration_content( trimmed[0], trimmed[1], ) - if (decl_child !== null) { - this.arena.set_first_child(supports_node, decl_child) + if (decl_child === null) { + const children = this.condition_parser.parse_supports_condition( + trimmed[0], + trimmed[1], + line, + column, + ) + this.arena.append_children(func_node, children) + } else { + const query = this.create_node(SUPPORTS_QUERY, trimmed[0], trimmed[1]) + this.arena.set_value_start_delta(query, 0) + this.arena.set_value_length(query, trimmed[1] - trimmed[0]) + this.arena.set_first_child(query, decl_child) + this.arena.set_first_child(func_node, query) } } - return supports_node + return func_node } } From d0485cabbc139d8240e02e664cafc20c06dafbdf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:56:37 +0000 Subject: [PATCH 2/4] docs: fix bare layer comment in parse_import_layer Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 61ea094..6611dd9 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -554,7 +554,7 @@ export class AtRulePreludeParser { } // Parse import layer: `layer` or `layer(name)` - // - `layer` → LayerName with name=null + // - `layer` → LayerName with name='' // - `layer()` → Function{name='layer'} without children // - `layer(a.b)` → Function{name='layer'} > LayerName{name='a.b'} private parse_import_layer(): number | null { From e99e8e72f2fe8ab879fbcc7e044bb71c2f710423 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:38:08 +0000 Subject: [PATCH 3/4] perf: cache trimmed bounds in import layer/supports parsing Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 6611dd9..36d1bd9 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -592,12 +592,14 @@ export class AtRulePreludeParser { const trimmed = matched ? trim_boundaries(this.source, content_start, content_end) : null if (trimmed) { - this.arena.set_value_start_delta(func_node, trimmed[0] - layer_start) - this.arena.set_value_length(func_node, trimmed[1] - trimmed[0]) + const name_start = trimmed[0] + const name_length = trimmed[1] - name_start + this.arena.set_value_start_delta(func_node, name_start - layer_start) + this.arena.set_value_length(func_node, name_length) - const name_node = this.create_node(LAYER_NAME, trimmed[0], trimmed[1]) + const name_node = this.create_node(LAYER_NAME, name_start, trimmed[1]) this.arena.set_content_start_delta(name_node, 0) - this.arena.set_content_length(name_node, trimmed[1] - trimmed[0]) + this.arena.set_content_length(name_node, name_length) this.arena.set_first_child(func_node, name_node) } @@ -646,25 +648,28 @@ export class AtRulePreludeParser { const trimmed = trim_boundaries(this.source, content_start, content_end) if (trimmed) { - this.arena.set_value_start_delta(func_node, trimmed[0] - supports_start) - this.arena.set_value_length(func_node, trimmed[1] - trimmed[0]) + const condition_start = trimmed[0] + const condition_end = trimmed[1] + const condition_length = condition_end - condition_start + this.arena.set_value_start_delta(func_node, condition_start - supports_start) + this.arena.set_value_length(func_node, condition_length) const decl_child = this.condition_parser.parse_supports_declaration_content( - trimmed[0], - trimmed[1], + condition_start, + condition_end, ) if (decl_child === null) { const children = this.condition_parser.parse_supports_condition( - trimmed[0], - trimmed[1], + condition_start, + condition_end, line, column, ) this.arena.append_children(func_node, children) } else { - const query = this.create_node(SUPPORTS_QUERY, trimmed[0], trimmed[1]) + const query = this.create_node(SUPPORTS_QUERY, condition_start, condition_end) this.arena.set_value_start_delta(query, 0) - this.arena.set_value_length(query, trimmed[1] - trimmed[0]) + this.arena.set_value_length(query, condition_length) this.arena.set_first_child(query, decl_child) this.arena.set_first_child(func_node, query) } From 615f8136f796f49150dd8f9cc6a87fa060fd4ead Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:52:15 +0000 Subject: [PATCH 4/4] docs: fix garbled Function children comment in node-types Co-Authored-By: Claude Sonnet 5.5 --- src/node-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node-types.ts b/src/node-types.ts index 434808d..9843a33 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -301,8 +301,8 @@ export type Function = WithClone< // `supports()` hold SupportsDeclaration/SupportsQuery/PreludeOperator children (matching // `@supports`'s own shape, including the full compound and/or/not grammar for // `supports()`) and `media()` holds a MediaFeature or FeatureRange child (matching - // `@media`'s own shape, including range comparison syntax). `@import`'s `layer()` holds a - // LayerName child — see parse_import_layer — see parse_if_condition_function + // `@media`'s own shape, including range comparison syntax) — see parse_if_condition_function. + // `@import`'s `layer()` holds a LayerName child — see parse_import_layer WithChildren< | ValueLike | MediaFeature