Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import {
URL,
DIMENSION,
FEATURE_RANGE,
FUNCTION,
OPERATOR,
NUMBER,
} from './arena'

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

test('should parse calc() function value', () => {
const css = '@media (min-width: calc(1px * 1)) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const queryChildren =
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
?.children || []
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
| MediaFeature
| undefined

expect(feature?.value?.type).toBe(FUNCTION)
expect(feature?.value?.text).toBe('calc(1px * 1)')

// calc()'s arguments should be parsed into structured children, not dropped
const args = (feature?.value as Function | undefined)?.children || []
expect(args.map((n) => n.type)).toEqual([DIMENSION, OPERATOR, NUMBER])
expect(args.map((n) => n.text)).toEqual(['1px', '*', '1'])
})

test('should parse env() function value', () => {
const css = '@media (min-width: env(safe-area-inset-top)) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const queryChildren =
((atRule.prelude as AtrulePrelude | null)?.children[0] as MediaQuery | undefined)
?.children || []
const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) as
| MediaFeature
| undefined

expect(feature?.value?.type).toBe(FUNCTION)
expect(feature?.value?.text).toBe('env(safe-area-inset-top)')
})

test('should have null value for boolean features', () => {
const css = '@media (hover) { }'
const ast = parse(css)
Expand Down Expand Up @@ -869,6 +907,17 @@ describe('At-Rule Prelude Nodes', () => {
expect(query.children[0].type).toBe(SUPPORTS_DECLARATION)
})

test('should not truncate the query at a nested function paren, e.g. calc()', () => {
const css = '@supports (width: calc(1px + 2px)) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude).children || []
const query = children.find((c) => c.type === SUPPORTS_QUERY) as SupportsQuery | undefined

// The query's own closing paren must be found, not calc()'s
expect(query?.value).toBe('width: calc(1px + 2px)')
})

test('should have a Declaration with property inside SupportsDeclaration', () => {
const css = '@supports (display: flex) { }'
const ast = parse(css)
Expand Down Expand Up @@ -1298,6 +1347,18 @@ describe('At-Rule Prelude Nodes', () => {
expect((children[2] as PreludeSelectorList).value).toBe('.dark')
})

test('should not truncate the scope selector at a nested function paren, e.g. :not()', () => {
const root = parse('@scope (:not(.a)) to (.b) { }')
const atRule = root.first_child! as Atrule
const children = (atRule.prelude as AtrulePrelude | null)?.children ?? []

expect(children.length).toBe(3)
// The scope-start's own closing paren must be found, not :not()'s
expect((children[0] as PreludeSelectorList).value).toBe(':not(.a)')
expect(children[1].type).toBe(PRELUDE_OPERATOR)
expect((children[2] as PreludeSelectorList).value).toBe('.b')
})

test('should have no prelude children for bare @scope', () => {
const root = parse('@scope { p { color: black; } }')
const atRule = root.first_child! as Atrule
Expand Down
82 changes: 19 additions & 63 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import {
PRELUDE_SELECTORLIST,
URL,
FUNCTION,
NUMBER,
DIMENSION,
STRING,
FEATURE_RANGE,
} from './arena'
Expand All @@ -31,15 +29,11 @@ import {
TOKEN_STRING,
TOKEN_URL,
TOKEN_FUNCTION,
TOKEN_NUMBER,
TOKEN_PERCENTAGE,
TOKEN_DIMENSION,
TOKEN_DELIM,
type TokenType,
} from './token-types'
import {
str_equals,
is_whitespace,
strip_vendor_prefix,
CHAR_COLON,
CHAR_LESS_THAN,
Expand All @@ -50,20 +44,26 @@ import {
import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils'
import { CSSNode } from './css-node'
import type { AnyNode } from './node-types'
import { ValueNodeParser } from './value-node-parser'

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

constructor(arena: CSSDataArena, source: string) {
this.arena = arena
this.source = source
// Create a lexer instance for prelude parsing
this.lexer = new Lexer(source)
this.prelude_end = 0
this.value_node_parser = new ValueNodeParser(arena, source)
}

// Parse an at-rule prelude into nodes (standalone use)
Expand Down Expand Up @@ -261,7 +261,7 @@ export class AtRulePreludeParser {
while (this.lexer.pos < this.prelude_end && depth > 0) {
this.next_token()
let token_type = this.lexer.token_type
if (token_type === TOKEN_LEFT_PAREN) {
if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) {
depth++
} else if (token_type === TOKEN_RIGHT_PAREN) {
depth--
Expand Down Expand Up @@ -461,7 +461,7 @@ export class AtRulePreludeParser {
while (this.lexer.pos < this.prelude_end && depth > 0) {
this.next_token()
let inner_token_type = this.lexer.token_type
if (inner_token_type === TOKEN_LEFT_PAREN) {
if (inner_token_type === TOKEN_LEFT_PAREN || inner_token_type === TOKEN_FUNCTION) {
depth++
} else if (inner_token_type === TOKEN_RIGHT_PAREN) {
depth--
Expand Down Expand Up @@ -908,61 +908,13 @@ export class AtRulePreludeParser {
return this.lexer.next_token_fast(false)
}

// Helper: Parse a single value token into a node
private parse_value_token(): number | null {
switch (this.lexer.token_type) {
case TOKEN_IDENT:
return this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end)
case TOKEN_NUMBER:
return this.create_node(NUMBER, this.lexer.token_start, this.lexer.token_end)
case TOKEN_PERCENTAGE:
case TOKEN_DIMENSION:
return this.create_node(DIMENSION, this.lexer.token_start, this.lexer.token_end)
case TOKEN_STRING:
return this.create_node(STRING, this.lexer.token_start, this.lexer.token_end)
default:
return null
}
}

// Helper: Parse feature value portion into typed nodes, chained as siblings without an
// intermediate array. Returns the first node in the chain (0 if none) — the common case is
// a single value (e.g. `min-width: 768px`), so callers get that node directly.
// Parse feature value portion into typed nodes (Number, Dimension, Function, Operator, ...),
// chained as siblings without an intermediate array. Delegates to the shared ValueNodeParser
// (also used for declaration values) so calc(), env(), var(), etc. get full structured
// children instead of being treated as opaque text. Runs on its own lexer instance, so it
// doesn't disturb this.lexer's position — no save/restore needed around the call.
private parse_feature_value(start: number, end: number): number {
const saved_position = this.lexer.save_position()
this.lexer.seek(start, this.lexer.line, this.lexer.column)

let first_node = 0
let last_node = 0

while (this.lexer.pos < end) {
this.lexer.next_token_fast(false)
if (this.lexer.token_start >= end) break

// Skip whitespace tokens
let all_whitespace = true
for (let i = this.lexer.token_start; i < this.lexer.token_end && i < end; i++) {
if (!is_whitespace(this.source.charCodeAt(i))) {
all_whitespace = false
break
}
}
if (all_whitespace) continue

// Create node based on token type
let node = this.parse_value_token()
if (node !== null) {
if (first_node === 0) {
first_node = node
} else {
this.arena.set_next_sibling(last_node, node)
}
last_node = node
}
}

this.lexer.restore_position(saved_position)
return first_node
return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column)
}

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

while (this.lexer.pos < this.prelude_end && depth > 0) {
this.next_token()
if (this.lexer.token_type === TOKEN_LEFT_PAREN) depth++
if (
this.lexer.token_type === TOKEN_LEFT_PAREN ||
this.lexer.token_type === TOKEN_FUNCTION
)
depth++
else if (this.lexer.token_type === TOKEN_RIGHT_PAREN) depth--
}

Expand Down
11 changes: 11 additions & 0 deletions src/parse-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ describe('Parser Options', () => {
const atrule = root.first_child! as Atrule
expect(atrule.prelude).toBeNull()
})

test('should not deep-parse function values (e.g. calc()) inside a disabled prelude', () => {
const root = parse('@media (min-width: calc(1px * 1)) { }', {
parse_atrule_preludes: false,
})
const atrule = root.first_child! as Atrule
const prelude = atrule.prelude
expect(prelude?.type).toBe(RAW)
expect(prelude?.text).toBe('(min-width: calc(1px * 1))')
expect(prelude?.first_child).toBeNull()
})
})

describe('on_comment callback', () => {
Expand Down
Loading