diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index c4028f7..c856b0a 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1798,6 +1798,43 @@ describe('parse_atrule_prelude()', () => { }) }) + describe('function', () => { + test('should parse function name', () => { + const result = parse_atrule_prelude('function', '--transparent(--color, --alpha)') + + expect(result.length).toBe(1) + expect(result[0].type).toBe(IDENTIFIER) + expect(result[0].text).toBe('--transparent') + }) + + test('should parse function name with no parameters', () => { + const result = parse_atrule_prelude('function', '--my-func()') + + expect(result.length).toBe(1) + expect(result[0].type).toBe(IDENTIFIER) + expect(result[0].text).toBe('--my-func') + }) + + test('should parse function name with type annotations', () => { + const result = parse_atrule_prelude('function', '--add(--a , --b )') + + expect(result.length).toBe(1) + expect(result[0].type).toBe(IDENTIFIER) + expect(result[0].text).toBe('--add') + }) + + test('should parse function name with returns clause', () => { + const result = parse_atrule_prelude( + 'function', + '--clamp-it(--val, --min, --max) returns ', + ) + + expect(result.length).toBe(1) + expect(result[0].type).toBe(IDENTIFIER) + expect(result[0].text).toBe('--clamp-it') + }) + }) + describe('@import', () => { test('should parse import with URL string', () => { const result = parse_atrule_prelude('import', 'url("styles.css")') @@ -2093,4 +2130,44 @@ and (min-width: 768px) { }`) expect(mediaQuery?.children.length).toBeGreaterThan(0) }) }) + + describe('@function', () => { + it('should parse function name as IDENTIFIER', () => { + const css = + '@function --transparent(--color, --alpha) { result: oklch(from var(--color) l c h / var(--alpha)); }' + const ast = parse(css) + const atRule = ast.first_child! + + expect(atRule.type).toBe(AT_RULE) + expect(atRule.name).toBe('function') + + const children = atRule.prelude?.children + expect(children?.length).toBe(1) + expect(children?.[0].type).toBe(IDENTIFIER) + expect(children?.[0].text).toBe('--transparent') + }) + + it('should parse function with no parameters', () => { + const css = '@function --my-func() { result: 42px; }' + const ast = parse(css) + const atRule = ast.first_child! + + const children = atRule.prelude?.children || [] + expect(children.length).toBe(1) + expect(children[0].type).toBe(IDENTIFIER) + expect(children[0].text).toBe('--my-func') + }) + + it('should parse function name location', () => { + const css = '@function --my-func(--x) { result: var(--x); }' + const ast = parse(css) + const atRule = ast.first_child! + const ident = atRule.prelude!.first_child! + + // '@function ' = 10 chars, so name starts at offset 10 + expect(ident.start).toBe(10) + expect(ident.text).toBe('--my-func') + expect(ident.length).toBe(9) + }) + }) }) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 50ca454..7ea7f92 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -99,6 +99,8 @@ export class AtRulePreludeParser { case 'font-feature-values': case 'page': return this.parse_identifier() + case 'function': + return this.parse_function_prelude() case 'import': return this.parse_import_prelude() case 'charset': @@ -502,6 +504,26 @@ export class AtRulePreludeParser { return nodes } + // Parse @function prelude: --function-name(--param1, --param2, ...) [returns ]? + // The function name is a dashed-ident immediately followed by '(' (TOKEN_FUNCTION). + // Parameters and return type remain in the raw prelude text (accessible via .value). + private parse_function_prelude(): number[] { + this.skip_whitespace() + if (this.lexer.pos >= this.prelude_end) return [] + + this.next_token() + + // @function prelude starts with a token like --name( + // which the CSS tokenizer produces as TOKEN_FUNCTION (includes the '(') + if (this.lexer.token_type !== TOKEN_FUNCTION) return [] + + // Create an IDENTIFIER node for just the function name (without '(') + let name_start = this.lexer.token_start + let name_end = this.lexer.token_end - 1 // Exclude '(' + + return [this.create_node(IDENTIFIER, name_start, name_end)] + } + // Parse single identifier: keyframe name, property name private parse_identifier(): number[] { this.skip_whitespace() diff --git a/src/parse.test.ts b/src/parse.test.ts index 3307e73..df46117 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -2111,6 +2111,68 @@ describe('Core Nodes', () => { }) }) + describe('@function at-rule', () => { + test('@function basic', () => { + let source = + '@function --transparent(--color, --alpha) { result: oklch(from var(--color) l c h / var(--alpha)); }' + let root = parse(source, { parse_atrule_preludes: false }) + + let fn = root.first_child! + expect(fn.type).toBe(AT_RULE) + expect(fn.name).toBe('function') + expect(fn.has_block).toBe(true) + + let block = fn.block! + expect(block.children.length).toBe(1) + + let result_decl = block.first_child! + expect(result_decl.type).toBe(DECLARATION) + expect(result_decl.property).toBe('result') + }) + + test('@function with local custom properties', () => { + let source = + '@function --anim(--animation, --count) { --duration: 1s; --easing: linear; result: var(--animation) var(--duration) var(--count) var(--easing); }' + let root = parse(source, { parse_atrule_preludes: false }) + + let fn = root.first_child! + let block = fn.block! + expect(block.children.length).toBe(3) + + let [dur, ease, result_decl] = block.children + expect(dur.type).toBe(DECLARATION) + expect(dur.property).toBe('--duration') + expect(ease.property).toBe('--easing') + expect(result_decl.property).toBe('result') + }) + + test('@function with nested @media', () => { + let source = + '@function --narrow-wide(--narrow, --wide) { result: var(--wide); @media (width < 700px) { result: var(--narrow); } }' + let root = parse(source, { parse_atrule_preludes: false }) + + let fn = root.first_child! + let block = fn.block! + expect(block.children.length).toBe(2) + + let [result_decl, media] = block.children + expect(result_decl.type).toBe(DECLARATION) + expect(result_decl.property).toBe('result') + expect(media.type).toBe(AT_RULE) + expect(media.name).toBe('media') + }) + + test('@function with no parameters', () => { + let source = '@function --my-func() { result: 42px; }' + let root = parse(source, { parse_atrule_preludes: false }) + + let fn = root.first_child! + expect(fn.type).toBe(AT_RULE) + expect(fn.name).toBe('function') + expect(fn.has_block).toBe(true) + }) + }) + describe('@nest at-rule', () => { test('@nest with & selector', () => { let source = '.parent { @nest & .child { color: blue; } }' diff --git a/src/parse.ts b/src/parse.ts index 90be432..0dc396e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -69,6 +69,7 @@ let CONDITIONAL_AT_RULES = new Set([ 'nest', 'scope', 'starting-style', + 'function', ]) /** @internal */