From 52c6a2c8ff690ca9618a09e488d90c34693ad685 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:03:01 +0000 Subject: [PATCH 1/2] fix: keep function values whole in media range syntax and split bare = comparator parse_feature_range stopped at the function token, so calc(1px) in (width >= calc(1px)) became a Function with text 'calc(' and its arguments leaked out as siblings. It now consumes through the matching paren. A bare '=' is also its own comparator now, so '=>' is '=' then '>' consistently regardless of operand order. --- src/node-types.ts | 2 +- src/parse-atrule-prelude.test.ts | 32 ++++++++++++++++++++++++++++++++ src/parse-condition.ts | 17 ++++++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/node-types.ts b/src/node-types.ts index 54549bb..419cf06 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -622,7 +622,7 @@ export type PreludeOperator = Leaf export type FeatureRange = WithClone< CSSNode & - WithChildren & { + WithChildren & { readonly type: typeof FEATURE_RANGE readonly type_name: 'MediaFeatureRange' /** The feature name in a range comparison, e.g. "width" from "(width >= 400px)" */ diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 36b9633..6dafe93 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -769,6 +769,38 @@ describe('At-Rule Prelude Nodes', () => { expect(range?.children[1].type).toBe(DIMENSION) // 400px }) + test('should keep a function value whole in range syntax', () => { + const [query] = parse_atrule_prelude('media', '(width >= calc(1px + 2px)) and (color)') + const range = (query as MediaQuery).children[0] as FeatureRange + + expect(range.text).toBe('(width >= calc(1px + 2px))') + expect(range.children.map((c) => c.text)).toEqual(['>=', 'calc(1px + 2px)']) + const fn = range.children[1] as Function + expect(fn.name).toBe('calc') + expect(fn.value).toBe('1px + 2px') + }) + + test('should keep a leading function value whole in range syntax', () => { + const [query] = parse_atrule_prelude('media', '(calc(1px) < width < 5px)') + const range = (query as MediaQuery).children[0] as FeatureRange + + expect(range.children.map((c) => c.text)).toEqual(['calc(1px)', '<', '<', '5px']) + }) + + test('should tokenize a bare = as its own comparator', () => { + const [query] = parse_atrule_prelude('media', '(width=>1000px)') + const range = (query as MediaQuery).children[0] as FeatureRange + + expect(range.children.map((c) => c.text)).toEqual(['=', '>', '1000px']) + }) + + test('should tokenize >= and <= as a single comparator', () => { + const [query] = parse_atrule_prelude('media', '(100px<=width>=1000px)') + const range = (query as MediaQuery).children[0] as FeatureRange + + expect(range.children.map((c) => c.text)).toEqual(['100px', '<=', '>=', '1000px']) + }) + test('should parse range syntax with double comparison', () => { const css = '@media (50px <= width <= 100px) { }' const ast = parse(css) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 58ab6f3..493321e 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -246,7 +246,12 @@ export class ConditionParser { // Comparison operator if (ch === CHAR_LESS_THAN || ch === CHAR_GREATER_THAN || ch === CHAR_EQUALS) { const op_start = pos++ - if (pos < content_end && this.source.charCodeAt(pos) === CHAR_EQUALS) { + // '<=' and '>=' are one comparator; a bare '=' stands alone, so '=>' is '=' then '>' + if ( + ch !== CHAR_EQUALS && + pos < content_end && + this.source.charCodeAt(pos) === CHAR_EQUALS + ) { pos++ } @@ -268,8 +273,14 @@ export class ConditionParser { feature_name_start = this.lexer.token_start feature_name_end = this.lexer.token_end } else { - // Value (may itself be a short chain, e.g. a single dimension node) - const value_first = this.parse_feature_value(this.lexer.token_start, this.lexer.token_end) + // Value (may itself be a short chain, e.g. a single dimension node). A function + // token only covers 'name(' — extend to its matching ')' so calc() etc. stay whole + const value_start = this.lexer.token_start + let value_end = this.lexer.token_end + if (this.lexer.token_type === TOKEN_FUNCTION && this.scan_matching_paren()) { + value_end = this.paren_close_end + } + const value_first = this.parse_feature_value(value_start, value_end) if (value_first !== 0) { if (first_child === 0) { first_child = value_first From feec23d3a50909d89e838b360cece6f63b8e658f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:03:27 +0000 Subject: [PATCH 2/2] test: update FeatureRange child union type test --- src/node-types.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node-types.test.ts b/src/node-types.test.ts index f19740e..4a84587 100644 --- a/src/node-types.test.ts +++ b/src/node-types.test.ts @@ -332,10 +332,10 @@ describe('type narrowing — compile-time', () => { } }) - test('FeatureRange children are typed as Dimension | PreludeOperator, not Operator', () => { + test('FeatureRange children are typed as Dimension | Function | PreludeOperator, not Operator', () => { const atrule = parse('@media (width >= 400px) {}').first_child! as Atrule const range = atrule.prelude!.first_child!.first_child! as FeatureRange - expectTypeOf(range.children).toEqualTypeOf<(Dimension | PreludeOperator)[]>() + expectTypeOf(range.children).toEqualTypeOf<(Dimension | Function | PreludeOperator)[]>() const [op, dim] = range.children expect(op.type_name).toBe('Operator')