Skip to content

Commit c3a2bed

Browse files
authored
fix: deep parse at-rule preludes (#274)
flagged by projectwallace/format-css#230
1 parent 7bb6565 commit c3a2bed

5 files changed

Lines changed: 491 additions & 439 deletions

File tree

src/parse-atrule-prelude.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import {
3636
URL,
3737
DIMENSION,
3838
FEATURE_RANGE,
39+
FUNCTION,
40+
OPERATOR,
41+
NUMBER,
3942
} from './arena'
4043

4144
describe('At-Rule Prelude Nodes', () => {
@@ -562,6 +565,41 @@ describe('At-Rule Prelude Nodes', () => {
562565
expect(feature?.value?.text).toBe('portrait')
563566
})
564567

568+
test('should parse calc() function value', () => {
569+
const css = '@media (min-width: calc(1px * 1)) { }'
570+
const ast = parse(css)
571+
const atRule = ast.first_child! as Atrule
572+
const queryChildren =
573+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
574+
?.children || []
575+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
576+
| MediaFeature
577+
| undefined
578+
579+
expect(feature?.value?.type).toBe(FUNCTION)
580+
expect(feature?.value?.text).toBe('calc(1px * 1)')
581+
582+
// calc()'s arguments should be parsed into structured children, not dropped
583+
const args = (feature?.value as Function | undefined)?.children || []
584+
expect(args.map((n) => n.type)).toEqual([DIMENSION, OPERATOR, NUMBER])
585+
expect(args.map((n) => n.text)).toEqual(['1px', '*', '1'])
586+
})
587+
588+
test('should parse env() function value', () => {
589+
const css = '@media (min-width: env(safe-area-inset-top)) { }'
590+
const ast = parse(css)
591+
const atRule = ast.first_child! as Atrule
592+
const queryChildren =
593+
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
594+
?.children || []
595+
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
596+
| MediaFeature
597+
| undefined
598+
599+
expect(feature?.value?.type).toBe(FUNCTION)
600+
expect(feature?.value?.text).toBe('env(safe-area-inset-top)')
601+
})
602+
565603
test('should have null value for boolean features', () => {
566604
const css = '@media (hover) { }'
567605
const ast = parse(css)
@@ -869,6 +907,17 @@ describe('At-Rule Prelude Nodes', () => {
869907
expect(query.children[0].type).toBe(SUPPORTS_DECLARATION)
870908
})
871909

910+
test('should not truncate the query at a nested function paren, e.g. calc()', () => {
911+
const css = '@supports (width: calc(1px + 2px)) { }'
912+
const ast = parse(css)
913+
const atRule = ast.first_child! as Atrule
914+
const children = (atRule.prelude as AtrulePrelude).children || []
915+
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery | undefined
916+
917+
// The query's own closing paren must be found, not calc()'s
918+
expect(query?.value).toBe('width: calc(1px + 2px)')
919+
})
920+
872921
test('should have a Declaration with property inside SupportsDeclaration', () => {
873922
const css = '@supports (display: flex) { }'
874923
const ast = parse(css)
@@ -1298,6 +1347,18 @@ describe('At-Rule Prelude Nodes', () => {
12981347
expect((children[2] as PreludeSelectorList).value).toBe('.dark')
12991348
})
13001349

1350+
test('should not truncate the scope selector at a nested function paren, e.g. :not()', () => {
1351+
const root = parse('@scope (:not(.a)) to (.b) { }')
1352+
const atRule = root.first_child! as Atrule
1353+
const children = (atRule.prelude as AtrulePrelude | null)?.children ?? []
1354+
1355+
expect(children.length).toBe(3)
1356+
// The scope-start's own closing paren must be found, not :not()'s
1357+
expect((children[0] as PreludeSelectorList).value).toBe(':not(.a)')
1358+
expect(children[1].type).toBe(PRELUDE_OPERATOR)
1359+
expect((children[2] as PreludeSelectorList).value).toBe('.b')
1360+
})
1361+
13011362
test('should have no prelude children for bare @scope', () => {
13021363
const root = parse('@scope { p { color: black; } }')
13031364
const atRule = root.first_child! as Atrule

src/parse-atrule-prelude.ts

Lines changed: 19 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import {
1616
PRELUDE_SELECTORLIST,
1717
URL,
1818
FUNCTION,
19-
NUMBER,
20-
DIMENSION,
2119
STRING,
2220
FEATURE_RANGE,
2321
} from './arena'
@@ -31,15 +29,11 @@ import {
3129
TOKEN_STRING,
3230
TOKEN_URL,
3331
TOKEN_FUNCTION,
34-
TOKEN_NUMBER,
35-
TOKEN_PERCENTAGE,
36-
TOKEN_DIMENSION,
3732
TOKEN_DELIM,
3833
type TokenType,
3934
} from './token-types'
4035
import {
4136
str_equals,
42-
is_whitespace,
4337
strip_vendor_prefix,
4438
CHAR_COLON,
4539
CHAR_LESS_THAN,
@@ -50,20 +44,26 @@ import {
5044
import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils'
5145
import { CSSNode } from './css-node'
5246
import type { AnyNode } from './node-types'
47+
import { ValueNodeParser } from './value-node-parser'
5348

5449
/** @internal */
5550
export class AtRulePreludeParser {
5651
private lexer: Lexer
5752
private arena: CSSDataArena
5853
private source: string
5954
private prelude_end: number
55+
// Shared with declaration-value parsing so feature values (calc(), env(), var(), ...)
56+
// get the same structured Number/Operator/Function tree, not just an opaque text span.
57+
// Runs on its own lexer instance, independent of this.lexer.
58+
private value_node_parser: ValueNodeParser
6059

6160
constructor(arena: CSSDataArena, source: string) {
6261
this.arena = arena
6362
this.source = source
6463
// Create a lexer instance for prelude parsing
6564
this.lexer = new Lexer(source)
6665
this.prelude_end = 0
66+
this.value_node_parser = new ValueNodeParser(arena, source)
6767
}
6868

6969
// Parse an at-rule prelude into nodes (standalone use)
@@ -261,7 +261,7 @@ export class AtRulePreludeParser {
261261
while (this.lexer.pos < this.prelude_end && depth > 0) {
262262
this.next_token()
263263
let token_type = this.lexer.token_type
264-
if (token_type === TOKEN_LEFT_PAREN) {
264+
if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) {
265265
depth++
266266
} else if (token_type === TOKEN_RIGHT_PAREN) {
267267
depth--
@@ -461,7 +461,7 @@ export class AtRulePreludeParser {
461461
while (this.lexer.pos < this.prelude_end && depth > 0) {
462462
this.next_token()
463463
let inner_token_type = this.lexer.token_type
464-
if (inner_token_type === TOKEN_LEFT_PAREN) {
464+
if (inner_token_type === TOKEN_LEFT_PAREN || inner_token_type === TOKEN_FUNCTION) {
465465
depth++
466466
} else if (inner_token_type === TOKEN_RIGHT_PAREN) {
467467
depth--
@@ -908,61 +908,13 @@ export class AtRulePreludeParser {
908908
return this.lexer.next_token_fast(false)
909909
}
910910

911-
// Helper: Parse a single value token into a node
912-
private parse_value_token(): number | null {
913-
switch (this.lexer.token_type) {
914-
case TOKEN_IDENT:
915-
return this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end)
916-
case TOKEN_NUMBER:
917-
return this.create_node(NUMBER, this.lexer.token_start, this.lexer.token_end)
918-
case TOKEN_PERCENTAGE:
919-
case TOKEN_DIMENSION:
920-
return this.create_node(DIMENSION, this.lexer.token_start, this.lexer.token_end)
921-
case TOKEN_STRING:
922-
return this.create_node(STRING, this.lexer.token_start, this.lexer.token_end)
923-
default:
924-
return null
925-
}
926-
}
927-
928-
// Helper: Parse feature value portion into typed nodes, chained as siblings without an
929-
// intermediate array. Returns the first node in the chain (0 if none) — the common case is
930-
// a single value (e.g. `min-width: 768px`), so callers get that node directly.
911+
// Parse feature value portion into typed nodes (Number, Dimension, Function, Operator, ...),
912+
// chained as siblings without an intermediate array. Delegates to the shared ValueNodeParser
913+
// (also used for declaration values) so calc(), env(), var(), etc. get full structured
914+
// children instead of being treated as opaque text. Runs on its own lexer instance, so it
915+
// doesn't disturb this.lexer's position — no save/restore needed around the call.
931916
private parse_feature_value(start: number, end: number): number {
932-
const saved_position = this.lexer.save_position()
933-
this.lexer.seek(start, this.lexer.line, this.lexer.column)
934-
935-
let first_node = 0
936-
let last_node = 0
937-
938-
while (this.lexer.pos < end) {
939-
this.lexer.next_token_fast(false)
940-
if (this.lexer.token_start >= end) break
941-
942-
// Skip whitespace tokens
943-
let all_whitespace = true
944-
for (let i = this.lexer.token_start; i < this.lexer.token_end && i < end; i++) {
945-
if (!is_whitespace(this.source.charCodeAt(i))) {
946-
all_whitespace = false
947-
break
948-
}
949-
}
950-
if (all_whitespace) continue
951-
952-
// Create node based on token type
953-
let node = this.parse_value_token()
954-
if (node !== null) {
955-
if (first_node === 0) {
956-
first_node = node
957-
} else {
958-
this.arena.set_next_sibling(last_node, node)
959-
}
960-
last_node = node
961-
}
962-
}
963-
964-
this.lexer.restore_position(saved_position)
965-
return first_node
917+
return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column)
966918
}
967919

968920
// Parse @namespace prelude: [prefix] url("...") | "..."
@@ -1009,7 +961,11 @@ export class AtRulePreludeParser {
1009961

1010962
while (this.lexer.pos < this.prelude_end && depth > 0) {
1011963
this.next_token()
1012-
if (this.lexer.token_type === TOKEN_LEFT_PAREN) depth++
964+
if (
965+
this.lexer.token_type === TOKEN_LEFT_PAREN ||
966+
this.lexer.token_type === TOKEN_FUNCTION
967+
)
968+
depth++
1013969
else if (this.lexer.token_type === TOKEN_RIGHT_PAREN) depth--
1014970
}
1015971

src/parse-options.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ describe('Parser Options', () => {
304304
const atrule = root.first_child! as Atrule
305305
expect(atrule.prelude).toBeNull()
306306
})
307+
308+
test('should not deep-parse function values (e.g. calc()) inside a disabled prelude', () => {
309+
const root = parse('@media (min-width: calc(1px * 1)) { }', {
310+
parse_atrule_preludes: false,
311+
})
312+
const atrule = root.first_child! as Atrule
313+
const prelude = atrule.prelude
314+
expect(prelude?.type).toBe(RAW)
315+
expect(prelude?.text).toBe('(min-width: calc(1px * 1))')
316+
expect(prelude?.first_child).toBeNull()
317+
})
307318
})
308319

309320
describe('on_comment callback', () => {

0 commit comments

Comments
 (0)