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
21 changes: 20 additions & 1 deletion src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,14 +427,33 @@ describe('CSSNode', () => {
expect(rule.has_declarations).toBe(true)
})

test('should return false for at-rules', () => {
test('should return false for at-rules with only nested rules', () => {
const source = '@media screen { body { color: red; } }'
const root = parse(source)
const media = root.first_child! as Atrule

expect(media.type).toBe(AT_RULE)
expect(media.has_declarations).toBe(false)
})

test('should return true for at-rules with direct declarations', () => {
const source = '@font-face { font-family: "Arial"; src: url(a.woff); }'
const root = parse(source)
const fontFace = root.first_child! as Atrule

expect(fontFace.type).toBe(AT_RULE)
expect(fontFace.has_declarations).toBe(true)
})

test('should return true for nested conditional at-rules with a direct declaration (CSS Nesting)', () => {
const source = '.foo { color: red; @media (min-width: 800px) { color: blue; } }'
const root = parse(source)
const rule = root.first_child! as Rule
const media = (rule.block!.children.find((c) => c.type === AT_RULE) as Atrule) ?? null

expect(media).not.toBeNull()
expect(media!.has_declarations).toBe(true)
})
})

describe('type_name property', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const FLAG_LENGTH_OVERFLOW = 1 << 2 // Node > 65k chars
export const FLAG_HAS_BLOCK = 1 << 3 // Has { } block (for style rules and at-rules)
// export const FLAG_VENDOR_PREFIXED = 1 << 4 // Has vendor prefix (-webkit-, -moz-, -ms-, -o-)
export const FLAG_HAS_NAMESPACE = 1 << 4 // Has namespace qualifier (for type/universal selectors)
export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has declarations (for style rules)
export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has direct declaration children (style rules and at-rules)
export const FLAG_HAS_PARENS = 1 << 6 // Has parentheses syntax (for pseudo-class/pseudo-element functions)
export const FLAG_BROWSERHACK = 1 << 7 // Has browser hack prefix (*property, _property, etc.)

Expand Down
4 changes: 0 additions & 4 deletions src/char-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,6 @@ function is_alpha(ch: number): boolean {
return ch < 128 && (char_types[ch] & CHAR_ALPHA) !== 0
}

export function is_whitespace(ch: number): boolean {
return ch < 128 && (char_types[ch] & CHAR_WHITESPACE) !== 0
}

// CSS ident start: letter, underscore, or non-ASCII (>= 0x80)
export function is_ident_start(ch: number): boolean {
if (ch >= 0x80) return true // Non-ASCII
Expand Down
33 changes: 33 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2047,6 +2047,39 @@ describe('At-Rule Prelude Nodes', () => {
expect(atRule?.prelude?.length).toBe(33)
})

test('@media query stays a single MediaQuery when a newline separates its components', () => {
// Regression test: a newline between tokens inside an at-rule prelude used to
// desync the internal whitespace-skip helper from the tokenizer's own newline
// handling, splitting one media query into two at the newline.
const css = '@media screen\nand (min-width: 768px) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const prelude = atRule.prelude as AtrulePrelude

expect(prelude.child_count).toBe(1)

const query = prelude.first_child as MediaQuery
expect(query.type).toBe(MEDIA_QUERY)
expect(query.children.map((c) => c.type_name)).toEqual([
'MediaType',
'Operator',
'Feature',
])
})

test('@supports condition stays intact when a newline separates and/or operators', () => {
const css = '@supports (display: grid)\nand (gap: 1rem) { }'
const ast = parse(css)
const atRule = ast.first_child! as Atrule
const prelude = atRule.prelude as AtrulePrelude

expect(prelude.children.map((c) => c.type_name)).toEqual([
'SupportsQuery',
'Operator',
'SupportsQuery',
])
})

test('@layer with whitespace around commas', () => {
const css = '@layer base , components , utilities { }'
const ast = parse(css)
Expand Down
156 changes: 45 additions & 111 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,32 +166,44 @@ export class AtRulePreludeParser {
return str_equals('and', str) || str_equals('or', str) || str_equals('not', str)
}

// 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. Returns
// [content_end, close_end, matched]: content_end/close_end are the positions right
// before/after the closing ')'; matched is false if EOF was hit first, in which case
// content_end/close_end are left at the position scanning started from (mirroring the
// caller's own pre-loop defaults, so callers that don't check `matched` still get sane
// fallback spans).
private scan_matching_paren(): [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 (this.lexer.pos < this.prelude_end && depth > 0) {
let token_type = this.next_token()
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
}
} else if (token_type === TOKEN_EOF) {
break
}
}

return [content_end, close_end, depth === 0]
}

// Parse a bare function condition: style(...), selector(...), font-tech(...). Current token must be TOKEN_FUNCTION.
private parse_function_condition(): number {
let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '('
let func_start = this.lexer.token_start
let content_start = this.lexer.token_end // After '('

// Find matching closing paren
let paren_depth = 1
let func_end = this.lexer.token_end
let content_end = content_start

while (this.lexer.pos < this.prelude_end && paren_depth > 0) {
this.next_token()
let inner_token = this.lexer.token_type
if (inner_token === TOKEN_LEFT_PAREN || inner_token === TOKEN_FUNCTION) {
paren_depth++
} else if (inner_token === TOKEN_RIGHT_PAREN) {
paren_depth--
if (paren_depth === 0) {
content_end = this.lexer.token_start
func_end = this.lexer.token_end
}
} else if (inner_token === TOKEN_EOF) {
break
}
}
let [content_end, func_end] = this.scan_matching_paren()

// Create function node
let func_node = this.create_node(FUNCTION, func_start, func_end)
Expand Down Expand Up @@ -322,25 +334,11 @@ export class AtRulePreludeParser {
// Parse media feature: (min-width: 768px) or range: (50px <= width <= 100px)
private parse_media_feature(): number | null {
let feature_start = this.lexer.token_start // '(' position

// Find matching right paren
let depth = 1
let content_start = this.lexer.pos

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 || token_type === TOKEN_FUNCTION) {
depth++
} else if (token_type === TOKEN_RIGHT_PAREN) {
depth--
}
}

if (depth !== 0) return null // Unmatched parentheses

let content_end = this.lexer.token_start // Before ')'
let feature_end = this.lexer.token_end // After ')'
// Find matching right paren
let [content_end, feature_end, matched] = this.scan_matching_paren()
if (!matched) return null // Unmatched parentheses

// Check for range syntax (has comparison operators)
let has_comparison = false
Expand Down Expand Up @@ -486,25 +484,12 @@ export class AtRulePreludeParser {
// Feature query: (property: value)
if (token_type === TOKEN_LEFT_PAREN) {
let feature_start = this.lexer.token_start

// Find matching right paren
let depth = 1
let content_start = this.lexer.pos

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 || inner_token_type === TOKEN_FUNCTION) {
depth++
} else if (inner_token_type === TOKEN_RIGHT_PAREN) {
depth--
}
}

if (depth === 0) {
let content_end = this.lexer.token_start
let feature_end = this.lexer.token_end
// Find matching right paren
let [content_end, feature_end, matched] = this.scan_matching_paren()

if (matched) {
// Create supports query node
let query = this.create_node(SUPPORTS_QUERY, feature_start, feature_end)

Expand Down Expand Up @@ -785,20 +770,8 @@ export class AtRulePreludeParser {

if (this.lexer.token_type === TOKEN_FUNCTION) {
// It's url( ... we need to find the matching )
let paren_depth = 1
while (this.lexer.pos < this.prelude_end && paren_depth > 0) {
let tokenType = this.next_token()
if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) {
paren_depth++
} else if (tokenType === TOKEN_RIGHT_PAREN) {
paren_depth--
if (paren_depth === 0) {
url_end = this.lexer.token_end
}
} else if (tokenType === TOKEN_EOF) {
break
}
}
let [, close_end] = this.scan_matching_paren()
url_end = close_end
}

// Create URL node
Expand Down Expand Up @@ -831,20 +804,10 @@ export class AtRulePreludeParser {
if (this.lexer.token_type === TOKEN_FUNCTION) {
// Track the content inside the parentheses
content_start = this.lexer.pos
let paren_depth = 1
while (this.lexer.pos < this.prelude_end && paren_depth > 0) {
let tokenType = this.next_token()
if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) {
paren_depth++
} else if (tokenType === TOKEN_RIGHT_PAREN) {
paren_depth--
if (paren_depth === 0) {
content_length = this.lexer.token_start - content_start
layer_end = this.lexer.token_end
}
} else if (tokenType === TOKEN_EOF) {
break
}
let [content_end, close_end, matched] = this.scan_matching_paren()
if (matched) {
content_length = content_end - content_start
layer_end = close_end
}
}

Expand Down Expand Up @@ -884,24 +847,7 @@ export class AtRulePreludeParser {
let content_start = this.lexer.token_end // After the opening '('

// Find matching closing parenthesis
let paren_depth = 1
let supports_end = this.lexer.token_end
let content_end = content_start

while (this.lexer.pos < this.prelude_end && paren_depth > 0) {
let tokenType = this.next_token()
if (tokenType === TOKEN_LEFT_PAREN || tokenType === TOKEN_FUNCTION) {
paren_depth++
} else if (tokenType === TOKEN_RIGHT_PAREN) {
paren_depth--
if (paren_depth === 0) {
content_end = this.lexer.token_start // Before the closing ')'
supports_end = this.lexer.token_end
}
} else if (tokenType === TOKEN_EOF) {
break
}
}
let [content_end, supports_end] = this.scan_matching_paren()

// Create supports node
let supports_node = this.create_node(SUPPORTS_QUERY, supports_start, supports_end)
Expand Down Expand Up @@ -1001,20 +947,8 @@ export class AtRulePreludeParser {
this.next_token() // consume '('
let paren_start = this.lexer.token_start
let content_start = this.lexer.pos
let depth = 1

while (this.lexer.pos < this.prelude_end && depth > 0) {
this.next_token()
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--
}

let content_end = this.lexer.token_start
let paren_end = this.lexer.token_end
let [content_end, paren_end] = this.scan_matching_paren()

let scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end)
let trimmed = trim_boundaries(this.source, content_start, content_end)
Expand Down
Loading
Loading