diff --git a/package.json b/package.json index 605b97c..9856eaf 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "test-build": "pnpm run build && vitest run --config vitest.config.build.ts", "build": "tsdown", "benchmark": "pnpm run build && node --expose-gc benchmark/index.ts", - "lint": "oxlint --config .oxlintrc.json; oxfmt --check", + "lint": "oxlint --config .oxlintrc.json && oxfmt --check", "check": "tsc --noEmit", "knip": "knip", "precommit": "pnpm run test --run; pnpm run lint; pnpm run check" diff --git a/src/arena.ts b/src/arena.ts index 23983df..2fad41c 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -61,6 +61,7 @@ export const OPERATOR = 16 // operator: +, -, *, /, comma export const PARENTHESIS = 17 // parenthesized expression: (100% - 50px) export const URL = 18 // URL: url("file.css"), url(image.png), used in values and @import export const UNICODE_RANGE = 19 // unicode range: u+0025-00ff, u+4?? +export const IF_BRANCH = 59 // Branch inside an if() function: : // Selector node type constants (for detailed selector parsing) export const SELECTOR_LIST = 20 // comma-separated selectors diff --git a/src/constants.ts b/src/constants.ts index e0db88d..d073d4a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -67,6 +68,7 @@ export { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -117,6 +119,7 @@ export const NODE_TYPES = { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, // Selector nodes SELECTOR_LIST, diff --git a/src/css-node.ts b/src/css-node.ts index bf50ca8..0c14215 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -45,6 +45,7 @@ import { PRELUDE_SELECTORLIST, SUPPORTS_DECLARATION, RATIO, + IF_BRANCH, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -66,6 +67,7 @@ import { is_whitespace, is_vendor_prefixed, str_starts_with, + str_equals, } from './string-utils' import { parse_dimension } from './parse-dimension' @@ -115,6 +117,7 @@ export const TYPE_NAMES = { [AT_RULE_PRELUDE]: 'AtrulePrelude', [PRELUDE_SELECTORLIST]: 'PreludeSelectorList', [RATIO]: 'Ratio', + [IF_BRANCH]: 'IfBranch', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -165,6 +168,7 @@ export type CSSNodeType = | typeof PRELUDE_SELECTORLIST | typeof SUPPORTS_DECLARATION | typeof RATIO + | typeof IF_BRANCH // Options for cloning nodes export interface CloneOptions { @@ -199,6 +203,10 @@ export type PlainCSSNode = { left?: PlainCSSNode right?: PlainCSSNode + // IfBranch-specific + condition?: PlainCSSNode + is_else?: boolean + // Flags (only when true) is_important?: boolean is_vendor_prefixed?: boolean @@ -253,6 +261,7 @@ const nodes_with_children = new Set([ FEATURE_RANGE, SUPPORTS_QUERY, SUPPORTS_DECLARATION, + IF_BRANCH, ]) const enumerable_properties = [ @@ -266,6 +275,8 @@ const enumerable_properties = [ 'nth_a', 'nth_b', 'selector', + 'condition', + 'is_else', 'is_browserhack', 'is_vendor_prefixed', 'has_error', @@ -380,6 +391,11 @@ export class CSSNode { return first_child?.first_child ?? null } + if (type === IF_BRANCH) { + // First child is the condition node; second child (if any) is the VALUE wrapper + return first_child?.next_sibling ?? null + } + if (type === DIMENSION) { return parse_dimension(text).value } @@ -518,6 +534,20 @@ export class CSSNode { return this.first_child?.next_sibling ?? undefined } + /** Get the parsed condition node of an if() branch, e.g. the Function "style(--active: 1)" or the Identifier "else" */ + get condition(): CSSNode | undefined { + if (this.type !== IF_BRANCH) { + return undefined + } + return this.first_child ?? undefined + } + + /** True when this is the else branch of an if() function */ + get is_else(): boolean | undefined { + if (this.type !== IF_BRANCH) return undefined + return str_equals('else', this.get_content()) + } + /** Check if this declaration has !important */ get is_important(): boolean | undefined { if (this.type !== DECLARATION) return undefined diff --git a/src/index.ts b/src/index.ts index 832278f..6d4cf03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ export { type Parenthesis, type Url, type UnicodeRange, + type IfBranch, type Value, type SelectorNode, type TypeSelector, @@ -103,6 +104,7 @@ export { is_parenthesis, is_url, is_unicode_range, + is_if_branch, is_value, is_type_selector, is_class_selector, diff --git a/src/node-types.ts b/src/node-types.ts index e868d8c..5a4adfc 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -36,6 +36,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -245,6 +246,7 @@ export type Raw = Leaf type ValueLike = | Function + | IfBranch | Identifier | Operator | Parenthesis @@ -290,7 +292,19 @@ export type Hash = Leaf export type Function = WithClone< CSSNode & - WithChildren & { + // if()-conditions reuse the shared condition parser (parse-condition.ts), so `style()`/ + // `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 + WithChildren< + | ValueLike + | MediaFeature + | SupportsDeclaration + | SupportsQuery + | FeatureRange + | PreludeOperator + > & { readonly type: typeof FUNCTION readonly type_name: 'Function' /** Function name, e.g. "rgb", "calc" */ @@ -328,6 +342,31 @@ export type Value = WithClone< CSSNode & WithChildren & { readonly type: typeof VALUE; readonly type_name: 'Value' } > +/** + * One branch inside a CSS `if()` inline conditional function. + * + * Each branch corresponds to a `: ` pair in: + * `if( : ; … else: )` + * + * - `condition` — the parsed condition node (Function, e.g. `style(--x: 1)`, or Identifier `else`) + * - `value` — the value text, e.g. `"green"`; `null` when omitted + * - `is_else` — `true` for the `else` branch + * - `first_child` — same node as `condition` + * - `children` — condition node followed by parsed value nodes + */ +export type IfBranch = CSSNode & + WithChildren & { + readonly type: typeof IF_BRANCH + readonly type_name: 'IfBranch' + /** The parsed condition node, e.g. the Function "style(--active: 1)" or the Identifier "else" */ + readonly condition: Function | Identifier + /** The parsed value as a VALUE node, or null when the branch value is empty */ + readonly value: Value | null + /** True when this is the else branch */ + readonly is_else: boolean + clone(options?: CloneOptions): ToPlain + } + // --------------------------------------------------------------------------- // Selector nodes // --------------------------------------------------------------------------- @@ -598,6 +637,7 @@ export type AnyNode = | Parenthesis | Url | UnicodeRange + | IfBranch | Value | TypeSelector | ClassSelector @@ -688,6 +728,9 @@ export function is_url(node: CSSNode): node is Url { export function is_unicode_range(node: CSSNode): node is UnicodeRange { return node.type === UNICODE_RANGE } +export function is_if_branch(node: CSSNode): node is IfBranch { + return node.type === IF_BRANCH +} export function is_value(node: CSSNode): node is Value { return node.type === VALUE } diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 9215d29..ed5796e 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -39,7 +39,11 @@ import { CHAR_EQUALS, CHAR_FORWARD_SLASH, } from './string-utils' -import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' +import { + trim_boundaries, + skip_whitespace_and_comments_forward, + find_colon_at_depth_zero, +} from './parse-utils' import { SelectorParser } from './parse-selector' import type { ValueNodeParser } from './value-node-parser' @@ -308,6 +312,12 @@ export class ConditionParser { content_start: number, content_end: number, ): number { + // parse_feature_range() below tokenizes via this.next_token(), which is bounded by + // this.end — set it here so a direct call (bypassing parse_media_feature(), which would + // otherwise set it) doesn't leave it stale. Never widens: content_end is always within + // whatever bound the caller already established. + this.end = content_end + // Check for range syntax (has comparison operators) let has_comparison = false let i = content_start @@ -448,22 +458,11 @@ export class ConditionParser { * `@import … supports(…)`. Returns null if no top-level ':' is found. */ parse_supports_declaration_content(content_start: number, content_end: number): number | null { - let colon_pos = this.find_colon_at_depth_zero(content_start, content_end) + let colon_pos = find_colon_at_depth_zero(this.source, content_start, content_end) if (colon_pos === -1) return null return this.create_supports_declaration(content_start, content_end, colon_pos) } - private find_colon_at_depth_zero(start: number, end: number): number { - let depth = 0 - for (let i = start; i < end; i++) { - let ch = this.source.charCodeAt(i) - if (ch === 0x28 /* ( */) depth++ - else if (ch === 0x29 /* ) */) depth-- - else if (ch === CHAR_COLON && depth === 0) return i - } - return -1 - } - /** * Parse a `` — the compound and/or/not grammar shared by `@supports` * preludes and if()'s `supports(...)` condition function: parenthesized `(property: value)` diff --git a/src/parse-utils.ts b/src/parse-utils.ts index 946ed7f..e175ba6 100644 --- a/src/parse-utils.ts +++ b/src/parse-utils.ts @@ -1,4 +1,11 @@ -import { CHAR_ASTERISK, CHAR_FORWARD_SLASH, is_whitespace } from './string-utils' +import { + CHAR_ASTERISK, + CHAR_COLON, + CHAR_FORWARD_SLASH, + CHAR_LEFT_PAREN, + CHAR_RIGHT_PAREN, + is_whitespace, +} from './string-utils' /** @internal */ export function skip_whitespace_forward(source: string, pos: number, end: number): number { @@ -101,3 +108,15 @@ export function trim_boundaries( if (start >= end) return null return [start, end] } + +/** Find the position of the first ':' at parenthesis depth 0 in [start, end). Returns -1 if not found. @internal */ +export function find_colon_at_depth_zero(source: string, start: number, end: number): number { + let depth = 0 + for (let i = start; i < end; i++) { + let ch = source.charCodeAt(i) + if (ch === CHAR_LEFT_PAREN) depth++ + else if (ch === CHAR_RIGHT_PAREN) depth-- + else if (ch === CHAR_COLON && depth === 0) return i + } + return -1 +} diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 8597b61..de2ec84 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -11,18 +11,29 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, DECLARATION, + MEDIA_FEATURE, + SUPPORTS_DECLARATION, + FEATURE_RANGE, + SUPPORTS_QUERY, + PRELUDE_OPERATOR, } from './arena' import type { Atrule, Block, Declaration, Dimension, + FeatureRange, Function, + IfBranch, + MediaFeature, Number, Operator, Parenthesis, + SupportsDeclaration, + SupportsQuery, Url, Value, } from './node-types' @@ -1117,4 +1128,377 @@ describe('Value Node Types', () => { expect((value as Function | undefined)?.children[0].type).toBe(FUNCTION) }) }) + + describe('FUNCTION if()', () => { + const getFunc = (css: string) => { + const root = parse(css) + const decl = root.first_child?.first_child?.next_sibling?.first_child + return (decl!.first_child! as Value).children[0] as Function | undefined + } + const getBranch = (func: Function | undefined, index: number) => + func?.children[index] as IfBranch | undefined + + // ── Basic structure ────────────────────────────────────────────────── + + test('should parse if() as a FUNCTION node with IF_BRANCH children', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + // The two branches are the only direct children + expect(func?.children).toHaveLength(2) + expect(func?.children[0].type).toBe(IF_BRANCH) + expect(func?.children[1].type).toBe(IF_BRANCH) + }) + + test('should expose full function text and inner value', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(func?.text).toBe('if(style(--active: 1): green; else: red)') + expect(func?.value).toBe('style(--active: 1): green; else: red') + }) + + // ── IF_BRANCH node properties ───────────────────────────────────────── + + test('branch has correct condition and value text', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0) + const b1 = getBranch(func, 1) + + expect(b0!.condition?.text).toBe('style(--active: 1)') + expect((b0!.value as Value).text).toBe('green') + expect(b0!.is_else).toBe(false) + + expect(b1!.condition?.text).toBe('else') + expect((b1!.value as Value).text).toBe('red') + expect(b1!.is_else).toBe(true) + }) + + test('branch.text spans condition through value', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(getBranch(func, 0)?.text).toBe('style(--active: 1): green') + expect(getBranch(func, 1)?.text).toBe('else: red') + }) + + test('branch type_name is IfBranch', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(getBranch(func, 0)?.type_name).toBe('IfBranch') + }) + + // ── Branch children: condition node + value nodes ───────────────────── + + test('branch.first_child is the condition node', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0)! + expect(b0.first_child?.type).toBe(FUNCTION) + expect((b0.first_child as Function).name).toBe('style') + + const b1 = getBranch(func, 1)! + expect(b1.first_child?.type).toBe(IDENTIFIER) + expect(b1.first_child?.text).toBe('else') + }) + + test('branch children contain condition node then VALUE wrapper', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0)! + // children: FUNCTION("style"), VALUE("green") + expect(b0.children).toHaveLength(2) + expect(b0.children[0].type).toBe(FUNCTION) + expect(b0.children[1].type).toBe(VALUE) + expect(b0.children[1].text).toBe('green') + }) + + // ── style() condition ───────────────────────────────────────────────── + + test('style() condition has a SUPPORTS_DECLARATION child', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const styleFunc = getBranch(func, 0)?.first_child as Function | undefined + expect(styleFunc?.name).toBe('style') + // 1 child: SUPPORTS_DECLARATION, matching @supports style()'s shape + expect(styleFunc?.children).toHaveLength(1) + const decl = styleFunc?.children[0] as SupportsDeclaration + expect(decl.type).toBe(SUPPORTS_DECLARATION) + expect(decl.property).toBe('--active') + expect((decl.value as Value).children[0].type).toBe(NUMBER) + expect((decl.value as Value).children[0].text).toBe('1') + }) + + // ── supports() condition ────────────────────────────────────────────── + + test('should parse if() with supports() condition', () => { + const func = getFunc('div { display: if(supports(display: grid): grid; else: block) }') + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + const b0 = getBranch(func, 0)! + expect(b0.condition.text).toBe('supports(display: grid)') + expect((b0.value as Value).text).toBe('grid') + + const supportsFunc = b0.first_child as Function + expect(supportsFunc.name).toBe('supports') + // 1 child: SUPPORTS_DECLARATION, matching @supports's own shape + expect(supportsFunc.children).toHaveLength(1) + const decl = supportsFunc.children[0] as SupportsDeclaration + expect(decl.type).toBe(SUPPORTS_DECLARATION) + expect(decl.property).toBe('display') + expect((decl.value as Value).children[0].text).toBe('grid') + + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + expect((b1.value as Value).text).toBe('block') + }) + + // ── media() condition ──────────────────────────────────────────────── + + test('should parse if() with media() condition', () => { + const func = getFunc('div { color: if(media(min-width: 600px): blue; else: red) }') + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + const b0 = getBranch(func, 0)! + expect(b0.condition.text).toBe('media(min-width: 600px)') + expect((b0.value as Value).text).toBe('blue') + + const mediaFunc = b0.first_child as Function + expect(mediaFunc.name).toBe('media') + // 1 child: MEDIA_FEATURE + expect(mediaFunc.children).toHaveLength(1) + const feature = mediaFunc.children[0] as MediaFeature + expect(feature.type).toBe(MEDIA_FEATURE) + expect(feature.property).toBe('min-width') + expect(feature.value?.type).toBe(DIMENSION) + + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + expect((b1.value as Value).text).toBe('red') + }) + + test('media() condition can be a boolean feature (bare media type)', () => { + const func = getFunc('div { color: if(media(screen): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + expect(mediaFunc.children).toHaveLength(1) + const feature = mediaFunc.children[0] as MediaFeature + expect(feature.type).toBe(MEDIA_FEATURE) + expect(feature.property).toBe('screen') + expect(feature.value).toBeNull() + }) + + test('media() condition supports range syntax', () => { + const func = getFunc('div { color: if(media(400px <= width): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + expect(mediaFunc.children).toHaveLength(1) + const range = mediaFunc.children[0] as FeatureRange + expect(range.type).toBe(FEATURE_RANGE) + expect(range.name).toBe('width') + expect(range.children[0].type).toBe(DIMENSION) + expect(range.children[0].text).toBe('400px') + expect(range.children[1].type).toBe(PRELUDE_OPERATOR) + expect(range.children[1].text).toBe('<=') + }) + + test('media() condition supports double-sided range syntax', () => { + const func = getFunc('div { color: if(media(400px <= width <= 800px): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + const range = mediaFunc.children[0] as FeatureRange + expect(range.type).toBe(FEATURE_RANGE) + expect(range.name).toBe('width') + expect(range.children.map((c) => c.text)).toEqual(['400px', '<=', '<=', '800px']) + }) + + test('supports() condition supports the full compound and/or/not grammar', () => { + const func = getFunc( + 'div { display: if(supports((display: grid) and (gap: 1rem)): grid; else: block) }', + ) + const supportsFunc = getBranch(func, 0)?.first_child as Function + expect(supportsFunc.name).toBe('supports') + // children: SupportsQuery, PreludeOperator("and"), SupportsQuery + expect(supportsFunc.children).toHaveLength(3) + + const first = supportsFunc.children[0] as SupportsQuery + expect(first.type).toBe(SUPPORTS_QUERY) + const firstDecl = first.first_child as SupportsDeclaration + expect(firstDecl.property).toBe('display') + expect((firstDecl.value as Value).text).toBe('grid') + + expect(supportsFunc.children[1].type).toBe(PRELUDE_OPERATOR) + expect(supportsFunc.children[1].text).toBe('and') + + const second = supportsFunc.children[2] as SupportsQuery + expect(second.type).toBe(SUPPORTS_QUERY) + const secondDecl = second.first_child as SupportsDeclaration + expect(secondDecl.property).toBe('gap') + expect((secondDecl.value as Value).text).toBe('1rem') + }) + + // ── Multiple branches ───────────────────────────────────────────────── + + test('should parse if() with three branches', () => { + const func = getFunc( + 'div { font-size: if(style(--large: 1): 2rem; style(--medium: 1): 1.5rem; else: 1rem) }', + ) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(3) + + const b0 = getBranch(func, 0)! + expect(b0.condition.text).toBe('style(--large: 1)') + expect((b0.value as Value).text).toBe('2rem') + expect(b0.is_else).toBe(false) + + const b1 = getBranch(func, 1)! + expect(b1.condition.text).toBe('style(--medium: 1)') + expect((b1.value as Value).text).toBe('1.5rem') + expect(b1.is_else).toBe(false) + + const b2 = getBranch(func, 2)! + expect(b2.condition.text).toBe('else') + expect((b2.value as Value).text).toBe('1rem') + expect(b2.is_else).toBe(true) + }) + + // ── Value node types ────────────────────────────────────────────────── + + test('value can be a DIMENSION', () => { + const func = getFunc('div { width: if(style(--wide: 1): 100%; else: 50%) }') + // children[1] is the VALUE wrapper; VALUE.text spans the value text + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) + expect(getBranch(func, 0)?.children[1].text).toBe('100%') + expect(getBranch(func, 1)?.children[1].type).toBe(VALUE) + expect(getBranch(func, 1)?.children[1].text).toBe('50%') + }) + + test('value can be a HASH color', () => { + const func = getFunc('div { color: if(style(--dark: 1): #000; else: #fff) }') + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) + expect(getBranch(func, 0)?.children[1].text).toBe('#000') + expect(getBranch(func, 1)?.children[1].type).toBe(VALUE) + expect(getBranch(func, 1)?.children[1].text).toBe('#fff') + }) + + test('value can be a FUNCTION (e.g. oklch())', () => { + const func = getFunc( + 'div { color: if(supports(color: oklch(0 0 0)): oklch(0.5 0.2 240); else: blue) }', + ) + // children[1] is the VALUE wrapper; VALUE.children[0] is the FUNCTION + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) + expect(((getBranch(func, 0)!.children[1] as Value).children[0] as Function).name).toBe( + 'oklch', + ) + }) + + test('value can be empty (condition-only branch)', () => { + // if(style(--x: 1):; else: red) — empty value between : and ; + const func = getFunc('div { color: if(style(--x: 1):; else: red) }') + expect(func?.children).toHaveLength(2) + const b0 = getBranch(func, 0)! + expect(b0.condition.text).toBe('style(--x: 1)') + expect(b0.value).toBeNull() + // Only the condition node as child, no value nodes + expect(b0.children).toHaveLength(1) + }) + + test('trailing semicolon before closing paren is accepted', () => { + const func = getFunc('div { color: if(style(--x: 1): red;) }') + expect(func?.children).toHaveLength(1) + const b0 = getBranch(func, 0)! + expect(b0.condition.text).toBe('style(--x: 1)') + expect((b0.value as Value).text).toBe('red') + }) + + test('unterminated condition function inside if() does not throw or run past the value', () => { + // style( never closes: parsing should stop gracefully instead of consuming + // past the declaration value's end + const func = getFunc('div { color: if(style(--x: 1 }') + expect(func?.name).toBe('if') + const branch = getBranch(func, 0)! + const condition = branch.condition as Function + expect(condition.name).toBe('style') + expect(condition.text).toBe('style(') + expect(condition.children).toHaveLength(0) + }) + + // ── Nested if() ─────────────────────────────────────────────────────── + + test('nested if() in value is parsed recursively', () => { + const func = getFunc( + 'div { color: if(style(--a: 1): if(style(--b: 1): blue; else: green); else: red) }', + ) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + // Branch 0 value is a VALUE wrapper containing a nested if() FUNCTION + const valueNode = getBranch(func, 0)?.children[1] as Value | undefined + expect(valueNode?.type).toBe(VALUE) + const innerIf = valueNode?.children[0] as Function | undefined + expect(innerIf?.type).toBe(FUNCTION) + expect(innerIf?.name).toBe('if') + expect(innerIf?.children).toHaveLength(2) + expect(getBranch(innerIf, 0)?.condition?.text).toBe('style(--b: 1)') + expect((getBranch(innerIf, 0)!.value as Value).text).toBe('blue') + expect(getBranch(innerIf, 1)?.is_else).toBe(true) + expect((getBranch(innerIf, 1)!.value as Value).text).toBe('green') + }) + + // ── Real-world example from the spec ────────────────────────────────── + + test('spec example: supports(color: lch(…))', () => { + const css = + 'h2 { color: if(supports(color: lch(29.57% 43.25 344.44)): lch(29.57% 43.25 344.44); else: #792359) }' + const func = getFunc(css) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + const b0 = getBranch(func, 0)! + expect(b0.is_else).toBe(false) + expect((b0.first_child as Function).name).toBe('supports') + // children[1] is VALUE; VALUE.children[0] is the lch() FUNCTION + expect(b0.children[1].type).toBe(VALUE) + expect(((b0.children[1] as Value).children[0] as Function).name).toBe('lch') + + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + // children[1] is VALUE; VALUE.children[0] is the HASH + expect(b1.children[1].type).toBe(VALUE) + expect((b1.children[1] as Value).children[0].type).toBe(HASH) + expect((b1.children[1] as Value).children[0].text).toBe('#792359') + }) + + // ── Declaration boundary ────────────────────────────────────────────── + + test('declaration following if() is parsed correctly', () => { + const root = parse('div { color: if(style(--x: 1): red; else: blue); font-size: 1em }') + const block = root.first_child?.first_child?.next_sibling + const children = (block as import('./node-types').Block | null | undefined)?.children + expect(children).toHaveLength(2) + expect(children?.[0].type).toBe(DECLARATION) + expect(children?.[1].type).toBe(DECLARATION) + }) + + // ── Location tracking ───────────────────────────────────────────────── + + test('if() function has correct location', () => { + const func = getFunc('div { color: if(style(--x: 1): red; else: blue) }') + // "div { color: " = 13 chars, so if() starts at offset 13 + expect(func?.start).toBe(13) + expect(func?.text).toBe('if(style(--x: 1): red; else: blue)') + expect(func?.length).toBe(34) + expect(func?.end).toBe(47) + expect(func?.line).toBe(1) + expect(func?.column).toBe(14) + }) + + test('IF_BRANCH has correct location', () => { + const func = getFunc('div { color: if(style(--x: 1): red; else: blue) }') + const b0 = getBranch(func, 0)! + // "div { color: if(" = 16 chars, so first branch starts at 16 + expect(b0.start).toBe(16) + expect(b0.text).toBe('style(--x: 1): red') + expect(b0.end).toBe(34) + expect(b0.line).toBe(1) + expect(b0.column).toBe(17) + + const b1 = getBranch(func, 1)! + // "; else: blue" — else starts at 36 + expect(b1.start).toBe(36) + expect(b1.text).toBe('else: blue') + expect(b1.end).toBe(46) + }) + }) }) diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 3465d60..339d049 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -14,6 +14,8 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, + VALUE, } from './arena' import { TOKEN_IDENT, @@ -25,6 +27,8 @@ import { TOKEN_FUNCTION, TOKEN_DELIM, TOKEN_COMMA, + TOKEN_COLON, + TOKEN_SEMICOLON, TOKEN_EOF, TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, @@ -38,6 +42,7 @@ import { CHAR_FORWARD_SLASH, str_equals, } from './string-utils' +import { ConditionParser } from './parse-condition' /** @internal */ export class ValueNodeParser { @@ -47,6 +52,27 @@ export class ValueNodeParser { protected end: number = 0 // Last node from parse_chain(), for callers sizing a wrapper node. Avoids a tuple/array return. last_chain_node: number = 0 + // Shared media-feature/supports-condition/style() parsing for if()'s condition functions, so + // they produce the same MediaFeature/FeatureRange/SupportsQuery/SupportsDeclaration nodes real + // @media/@supports conditions do. Lazily built with a dedicated helper ValueNodeParser — NOT + // `this` — because ConditionParser's content methods call back into the injected parser's + // parse_chain(), which reseeks its own lexer/end; reentering `this` while it's still mid-parse + // (inside parse_if_function_node's own scan) would clobber that in-progress state. Lazy, since + // eagerly constructing the helper here would recurse: every ValueNodeParser's constructor would + // try to build another ValueNodeParser to inject, forever. The helper's own condition_parser is + // simply never accessed (it's only ever used via parse_chain), so the recursion stops there. + private _condition_parser: ConditionParser | null = null + + private get condition_parser(): ConditionParser { + if (this._condition_parser === null) { + this._condition_parser = new ConditionParser( + this.arena, + this.source, + new ValueNodeParser(this.arena, this.source), + ) + } + return this._condition_parser + } constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -137,6 +163,8 @@ export class ValueNodeParser { return this.parse_operator_node(start, end) case TOKEN_COMMA: + case TOKEN_COLON: + case TOKEN_SEMICOLON: return this.create_node(OPERATOR, start, end) case TOKEN_LEFT_PAREN: @@ -176,6 +204,42 @@ export class ValueNodeParser { return null } + // Scan tokens from just after an already-open '(' or function-call (depth 1) to its + // matching ')'. Must be called right after consuming the opening token. + // When `bounded` is true, scanning stops at `this.end` (the if()-condition-function + // case, which must not overrun its enclosing range). When `bounded` is false, `this.end` + // is ignored and scanning continues to a real ')' or EOF (the unquoted url()/src() case, + // whose content may contain characters like ';' that would otherwise truncate it early — + // see the caller for why). + // Returns [content_end, close_end, matched]; matched is false if EOF was hit first, in + // which case content_end/close_end are left at their initial (scan-start) values. + private scan_matching_paren( + bounded: boolean, + ): [content_end: number, close_end: number, matched: boolean] { + let depth = 1 + let content_end = this.lexer.pos + let close_end = this.lexer.token_end + + while ((!bounded || this.lexer.pos < this.end) && depth > 0) { + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (bounded && this.lexer.token_start >= this.end) break + + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { + depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + depth-- + if (depth === 0) { + content_end = this.lexer.token_start + close_end = this.lexer.token_end + } + } + } + + return [content_end, close_end, depth === 0] + } + private parse_function_node(start: number, end: number): number { // Function name is everything before the '(' // The lexer's TOKEN_FUNCTION includes the '(' at the end @@ -184,6 +248,11 @@ export class ValueNodeParser { // Get function name to check for special handling let func_name_substr = this.source.substring(start, name_end) + // Dispatch to dedicated parser for if() + if (str_equals('if', func_name_substr)) { + return this.parse_if_function_node(start, end) + } + // Create URL or function node based on function name (length will be set later) let node = this.arena.create_node( str_equals('url', func_name_substr) ? URL : FUNCTION, @@ -223,30 +292,14 @@ export class ValueNodeParser { // Note: We can't rely on `end` because URLs may contain semicolons // that confuse the declaration parser (e.g., data:image/png;base64,...) // So we consume tokens until we find the matching ')' regardless of `end` - let paren_depth = 1 let func_end = end let content_start = end // Position after 'url(' let content_end = end - // Just consume tokens until we find the matching ')' - // Don't create child nodes - while (paren_depth > 0) { - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - - // Track parentheses depth - if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { - paren_depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start // Position of ')' - func_end = this.lexer.token_end - break - } - } + let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(false) + if (matched) { + content_end = scanned_content_end + func_end = scanned_func_end } // Set function total length (includes opening and closing parens) @@ -318,6 +371,287 @@ export class ValueNodeParser { return node } + /** + * Parse an if() inline conditional function. + * + * Spec grammar (CSS Values Level 5): + * if( + ) + * = : ? ;? + * = style(…) | media(…) | supports(…) | else + * + * Each branch becomes an IF_BRANCH child (see the `IfBranch` type in node-types.ts + * for its shape). Colons/semicolons here are structural separators, not OPERATOR nodes. + */ + private parse_if_function_node(start: number, end: number): number { + let name_end = end - 1 // exclude '(' + let save_line = this.lexer.token_line + let save_col = this.lexer.token_column + + let node = this.arena.create_node(FUNCTION, start, 0, save_line, save_col) + this.arena.set_content_start_delta(node, 0) + this.arena.set_content_length(node, name_end - start) // length of "if" + + let branches: number[] = [] + let func_end = end + let content_start = end // right after 'if(' + let content_end = end + let if_closed = false + + while (this.lexer.pos < this.end && !if_closed) { + this.lexer.next_token_fast(false) + let tt = this.lexer.token_type + + if (tt === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + + if (tt === TOKEN_RIGHT_PAREN) { + content_end = this.lexer.token_start + func_end = this.lexer.token_end + break + } + + // Skip whitespace and any stray separators between branches + if (this.is_whitespace_inline() || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue + + // ── Condition ────────────────────────────────────────────────────── + let branch_start = this.lexer.token_start + let branch_line = this.lexer.token_line + let branch_col = this.lexer.token_column + + // Condition functions get specialized parsing; identifiers ("else") use generic + let condition_node: number | null + if (tt === TOKEN_FUNCTION) { + condition_node = this.parse_if_condition_function( + this.lexer.token_start, + this.lexer.token_end, + ) + } else { + condition_node = this.parse_value_node() + } + if (condition_node === null) continue + + let condition_end_pos = + this.arena.get_start_offset(condition_node) + this.arena.get_length(condition_node) + + // ── Find the ':' separator ───────────────────────────────────────── + let colon_found = false + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + let t = this.lexer.token_type + if (t === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + if (this.is_whitespace_inline()) continue + if (t === TOKEN_COLON) { + colon_found = true + break + } + if (t === TOKEN_RIGHT_PAREN) { + // Condition with no colon — malformed; still record branch, close if() + content_end = this.lexer.token_start + func_end = this.lexer.token_end + if_closed = true + break + } + // Skip other unexpected tokens + } + + // ── Value tokens until ';' or end of if() ──────────────────────── + let value_tokens: number[] = [] + let value_start = -1 + let value_last_end = condition_end_pos + let value_line = 0 + let value_col = 0 + + if (colon_found && !if_closed) { + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + let t = this.lexer.token_type + if (t === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + if (this.is_whitespace_inline()) continue + + if (t === TOKEN_SEMICOLON) break // end of this branch + + if (t === TOKEN_RIGHT_PAREN) { + content_end = this.lexer.token_start + func_end = this.lexer.token_end + if_closed = true + break + } + + let vnode = this.parse_value_node() + if (vnode !== null) { + let ns = this.arena.get_start_offset(vnode) + if (value_start === -1) { + value_start = ns + value_line = this.arena.get_start_line(vnode) + value_col = this.arena.get_start_column(vnode) + } + value_tokens.push(vnode) + value_last_end = ns + this.arena.get_length(vnode) + } + } + } + + // ── Wrap value tokens in a VALUE node ───────────────────────────── + let value_node: number | null = null + if (value_tokens.length > 0) { + value_node = this.arena.create_node( + VALUE, + value_start, + value_last_end - value_start, + value_line, + value_col, + ) + this.arena.append_children(value_node, value_tokens) + } + + // ── Create IF_BRANCH node ────────────────────────────────────────── + let branch_end = value_node === null ? condition_end_pos : value_last_end + let branch_node = this.arena.create_node( + IF_BRANCH, + branch_start, + branch_end - branch_start, + branch_line, + branch_col, + ) + this.arena.set_content_start_delta(branch_node, 0) + this.arena.set_content_length(branch_node, condition_end_pos - branch_start) + + if (value_start !== -1) { + this.arena.set_value_start_delta(branch_node, value_start - branch_start) + this.arena.set_value_length(branch_node, value_last_end - value_start) + } + + let branch_children: number[] = [condition_node] + if (value_node !== null) branch_children.push(value_node) + this.arena.append_children(branch_node, branch_children) + branches.push(branch_node) + } + + this.arena.set_length(node, func_end - start) + this.arena.set_value_start_delta(node, content_start - start) + this.arena.set_value_length(node, content_end - content_start) + this.arena.append_children(node, branches) + + return node + } + + /** + * Parse a condition function inside if() — style(), supports(), or media(). Content parsing + * is delegated to the shared ConditionParser (see parse-condition.ts), so these produce the + * same node shapes real `@supports`/`@media` conditions do: + * style()/supports() → SUPPORTS_DECLARATION(s) — supports() gets the full compound + * grammar (and/or/not, nested conditions), same as `@supports`. + * media() → a single MEDIA_FEATURE or FEATURE_RANGE (comparison syntax), same as `@media`. + * anything else → generic value nodes, as before. + * + * Called with the current token at TOKEN_FUNCTION (the '(' already consumed). + * @param func_start Offset of the function name's first char. + * @param token_end Offset right after '(' (== lexer.token_end here). + */ + private parse_if_condition_function(func_start: number, token_end: number): number { + let func_name_end = token_end - 1 // before '(' + let func_name = this.source.substring(func_start, func_name_end) + let func_line = this.lexer.token_line + let func_col = this.lexer.token_column + + let content_start = token_end // right after '(' + let content_end = content_start + let func_end = content_start + + // Scan for matching ')' to find the full function extent + let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(true) + if (matched) { + content_end = scanned_content_end + func_end = scanned_func_end + } + + // Create FUNCTION node spanning the full function text + let func_node = this.arena.create_node( + FUNCTION, + func_start, + func_end - func_start, + func_line, + func_col, + ) + this.arena.set_content_start_delta(func_node, 0) + this.arena.set_content_length(func_node, func_name_end - func_start) + this.arena.set_value_start_delta(func_node, content_start - func_start) + this.arena.set_value_length(func_node, content_end - content_start) + + // Parse content based on function name + let child_nodes: number[] = [] + + if (str_equals('style', func_name)) { + let decl = this.condition_parser.parse_supports_declaration_content( + content_start, + content_end, + ) + if (decl !== null) child_nodes = [decl] + } else if (str_equals('supports', func_name)) { + // supports(): a bare single declaration when the content has a + // top-level ':' (matching style()'s shorthand — supports(display: grid), no extra + // parens needed); otherwise the full compound grammar, same as `@supports`'s own + // prelude — supports((a) and (b)), supports(not (c)), nested style()/selector()/… + let decl = this.condition_parser.parse_supports_declaration_content( + content_start, + content_end, + ) + if (decl === null) { + child_nodes = this.condition_parser.parse_supports_condition( + content_start, + content_end, + func_line, + func_col, + ) + } else { + child_nodes = [decl] + } + } else if (str_equals('media', func_name)) { + // media()'s own parens delimit the feature; there's no separate inner paren pair, + // so the feature span equals the content span (see parse_media_feature_content's docs) + let feature = this.condition_parser.parse_media_feature_content( + content_start, + content_end, + content_start, + content_end, + ) + child_nodes = [feature] + } else { + // Generic: parse content as value nodes + child_nodes = this.parse_value_nodes_in_range(content_start, content_end) + } + + this.arena.append_children(func_node, child_nodes) + return func_node + } + + /** Parse value tokens in a source sub-range using save/restore to protect lexer state. */ + private parse_value_nodes_in_range(start: number, end: number): number[] { + let saved_end = this.end + let saved_pos = this.lexer.save_position() + + this.end = end + this.lexer.seek(start, this.lexer.line, this.lexer.column) + + let nodes: number[] = [] + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + if (this.lexer.token_start >= this.end) break + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (this.is_whitespace_inline()) continue + let node = this.parse_value_node() + if (node !== null) nodes.push(node) + } + + this.lexer.restore_position(saved_pos) + this.end = saved_end + + return nodes + } + private parse_parenthesis_node(start: number, end: number): number { // Create parenthesis node (length will be set later) let node = this.arena.create_node(