diff --git a/src/node-types.ts b/src/node-types.ts index b3e8ada..40c7e4b 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) — see parse_if_condition_function. + // `@import`'s `layer()` holds a LayerName child — see parse_import_layer 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 813fc20..9ea453b 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1847,6 +1847,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', () => { @@ -1860,6 +1861,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', () => { @@ -1870,9 +1872,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', () => { @@ -1881,8 +1888,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', () => { @@ -1891,8 +1899,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', () => { @@ -1901,8 +1910,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', () => { @@ -1913,11 +1923,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') }) @@ -1964,10 +1981,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) }) @@ -1982,15 +1999,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', () => { @@ -2001,7 +2023,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) }) @@ -2014,8 +2036,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) }) @@ -2027,9 +2049,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', () => { @@ -2267,7 +2317,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) }) @@ -2278,7 +2328,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) }) @@ -2563,7 +2613,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', () => { @@ -2571,7 +2622,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..36d1bd9 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='' + // - `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,39 @@ 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 + + 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) - // 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 trimmed = matched ? trim_boundaries(this.source, content_start, content_end) : null + if (trimmed) { + 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, name_start, trimmed[1]) + this.arena.set_content_start_delta(name_node, 0) + this.arena.set_content_length(name_node, name_length) + this.arena.set_first_child(func_node, name_node) } - return layer_node + return func_node } } @@ -611,6 +613,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 +632,50 @@ 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]) + 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) - // Check for simple declaration: supports(property: value) const decl_child = this.condition_parser.parse_supports_declaration_content( - trimmed[0], - trimmed[1], + condition_start, + condition_end, ) - 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( + condition_start, + condition_end, + line, + column, + ) + this.arena.append_children(func_node, children) + } else { + 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, condition_length) + this.arena.set_first_child(query, decl_child) + this.arena.set_first_child(func_node, query) } } - return supports_node + return func_node } }