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
1 change: 1 addition & 0 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ const nodes_with_children = new Set<number>([
SUPPORTS_DECLARATION,
IF_BRANCH,
IF_CONDITION,
PRELUDE_SELECTORLIST,
])

const enumerable_properties = [
Expand Down
15 changes: 10 additions & 5 deletions src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,12 +593,17 @@ export type LayerName = Leaf<
* A parenthesised selector argument in an at-rule prelude, e.g. `(.parent)`
* in `@scope (.parent) to (.child)`. Distinct from SELECTOR_LIST, which
* already means "matches elements", so tooling can tell scoping arguments
* apart from real selectors. `value` is the trimmed text inside the parens.
* apart from real selectors. `value` is the trimmed text inside the parens;
* the parsed selectors are available as a single `SelectorList` child
* (absent when the parens are empty).
*/
export type PreludeSelectorList = Leaf<
typeof PRELUDE_SELECTORLIST,
'PreludeSelectorList',
{ readonly value: string }
export type PreludeSelectorList = WithClone<
CSSNode &
WithChildren<SelectorList> & {
readonly type: typeof PRELUDE_SELECTORLIST
readonly type_name: 'PreludeSelectorList'
readonly value: string
}
>

export type PreludeOperator = Leaf<typeof PRELUDE_OPERATOR, 'Operator'>
Expand Down
148 changes: 148 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, test, expect } from 'vitest'
import { parse } from './parse'
import { parse_atrule_prelude } from './parse-atrule-prelude'
import { walk } from './walk'
import { parse_selector_list } from './parse-selector'
import { CSSNode, type PlainCSSNode } from './css-node'
import type {
Atrule,
Expand Down Expand Up @@ -1729,6 +1731,152 @@ describe('At-Rule Prelude Nodes', () => {
expect(children.length).toBe(0)
})

describe('selector children', () => {
const scope = (prelude: string) => parse_atrule_prelude('scope', prelude) as any[]

test('parses both arguments into SelectorList children', () => {
const [start, op, end] = scope('(.a) to (.b)') as [
PreludeSelectorList,
CSSNode,
PreludeSelectorList,
]
expect(op.type).toBe(PRELUDE_OPERATOR)
for (const [node, name] of [
[start, 'a'],
[end, 'b'],
] as const) {
expect(node.type_name).toBe('PreludeSelectorList')
expect(node.value).toBe('.' + name)
expect(node.child_count).toBe(1)
const list = node.first_child
expect(list.type).toBe(SELECTOR_LIST)
expect(list.type_name).toBe('SelectorList')
expect(list.child_count).toBe(1)
const selector = list.first_child
expect(selector.type_name).toBe('Selector')
expect(selector.first_child.type_name).toBe('ClassSelector')
expect(selector.first_child.text).toBe('.' + name)
}
})

test('parses selector lists', () => {
const [start, , end] = scope('(.a, .b > .c) to (#d)') as PreludeSelectorList[]
expect(start.first_child.children.map((c) => c.text)).toEqual(['.a', '.b > .c'])
expect(start.first_child.children.every((c) => c.type_name === 'Selector')).toBe(true)
expect(end.first_child.first_child.first_child.type_name).toBe('IdSelector')
})

test('trims value and keeps accurate child offsets', () => {
const source = '( .a ) to ( .b )'
const [start, , end] = scope(source) as PreludeSelectorList[]
expect(start.value).toBe('.a')
expect(start.text).toBe('( .a )')
expect(end.value).toBe('.b')
for (const node of [start, end]) {
const cls = node.first_child.first_child.first_child
expect(source.slice(cls.start, cls.end)).toBe(node.value)
expect(source.slice(node.first_child.start, node.first_child.end)).toBe(node.value)
expect(cls.line).toBe(1)
expect(cls.column).toBe(cls.start + 1)
}
})

test('tracks line/column across newlines', () => {
const source = '(\n .a\n) to (.b)'
const [start] = scope(source) as PreludeSelectorList[]
const cls = start.first_child.first_child.first_child
expect(source.slice(cls.start, cls.end)).toBe('.a')
// Same line/column the selector parser reports for this text on its own
const reference = parse_selector_list('\n .a').first_child!.first_child!
expect(cls.line).toBe(2)
expect(cls.column).toBe(reference.column)
expect(start.value).toBe('.a')
})

test('parses pseudo-classes and nested selectors', () => {
const [start, , end] = scope('(.a:hover) to (.b:not(.c))') as PreludeSelectorList[]
const compound = start.first_child.first_child.children
expect(compound.map((c) => c.type_name)).toEqual(['ClassSelector', 'PseudoClassSelector'])
const not = end.first_child.first_child.children[1]
expect(not.type_name).toBe('PseudoClassSelector')
expect(not.first_child!.type_name).toBe('SelectorList')
expect(not.first_child!.text).toBe('.c')
})

test('parses :scope', () => {
const [start] = scope(
':scope > .a'.replace(/^/, '(').concat(')'),
) as PreludeSelectorList[]
expect(start.value).toBe(':scope > .a')
expect(start.first_child.first_child.children.map((c) => c.type_name)).toEqual([
'PseudoClassSelector',
'Combinator',
'ClassSelector',
])
})

test('handles root-only and limit-only preludes', () => {
const root_only = scope('(.a)')
expect(root_only.length).toBe(1)
expect(root_only[0].first_child.type_name).toBe('SelectorList')

const limit_only = scope('to (.b)')
expect(limit_only.map((n) => n.type_name)).toEqual(['Operator', 'PreludeSelectorList'])
expect(limit_only[1].first_child.first_child.first_child.text).toBe('.b')
})

test('empty parens have no children and no value', () => {
const root = parse('@scope () { a { color: red } }')
const prelude = (root.first_child as Atrule).prelude as AtrulePrelude
const node = prelude.children[0] as PreludeSelectorList
expect(node.type).toBe(PRELUDE_SELECTORLIST)
expect(node.has_children).toBe(false)
expect(node.value).toBeNull()
expect(() => scope('( )')).not.toThrow()
})

test('invalid selector content does not throw and keeps value', () => {
for (const content of ['> .a', '.a,', ',', '!!!', '.a {']) {
let nodes: any[] = []
expect(() => {
nodes = scope(`(${content})`)
}).not.toThrow()
expect(nodes.length).toBe(1)
expect(nodes[0].type).toBe(PRELUDE_SELECTORLIST)
expect((nodes[0] as PreludeSelectorList).value).toBe(content.trim())
}
})

test('text/value/start/end are unchanged', () => {
const source = '@scope (.parent) to (.child) { }'
const root = parse(source)
const prelude = (root.first_child as Atrule).prelude as AtrulePrelude
const [a, op, b] = prelude.children as PreludeSelectorList[]
expect([a.text, a.value, a.start, a.end]).toEqual(['(.parent)', '.parent', 7, 16])
expect(op.text).toBe('to')
expect([b.text, b.value, b.start, b.end]).toEqual(['(.child)', '.child', 20, 28])
})

test('clone includes the selector child', () => {
const [node] = scope('(.a)') as PreludeSelectorList[]
const plain = node.clone()
expect(plain.value).toBe('.a')
expect(plain.children?.[0].type_name).toBe('SelectorList')
expect(plain.children?.[0].children?.[0].children?.[0].type_name).toBe('ClassSelector')
})

test('walk reaches selector nodes inside @scope', () => {
const root = parse('@scope (.a) to (.b:hover) { }')
const seen: string[] = []
walk(root, (node) => {
if (node.type_name === 'ClassSelector' || node.type_name === 'PseudoClassSelector') {
seen.push(node.text)
}
})
expect(seen).toEqual(['.a', '.b', ':hover'])
})
})

test('should parse nested rules in block', () => {
const root = parse('@scope (.parent) { p { color: black; } }')
const atRule = root.first_child! as Atrule
Expand Down
29 changes: 29 additions & 0 deletions src/parse-atrule-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { CSSNode } from './css-node'
import type { AnyNode } from './node-types'
import { ValueNodeParser } from './value-node-parser'
import { ConditionParser } from './parse-condition'
import { SelectorParser } from './parse-selector'

/** @internal */
export class AtRulePreludeParser {
Expand All @@ -50,6 +51,8 @@ export class AtRulePreludeParser {
// Shared media-feature/supports-condition/style()-selector() parsing, also used by ValueNodeParser
// for if()'s condition functions — see parse-condition.ts for why value_node_parser is injected
private condition_parser: ConditionParser
// Deep-parses the parenthesised arguments of @scope into real SelectorLists
private selector_parser: SelectorParser

constructor(arena: CSSDataArena, source: string) {
this.arena = arena
Expand All @@ -59,6 +62,7 @@ export class AtRulePreludeParser {
this.prelude_end = 0
this.value_node_parser = new ValueNodeParser(arena, source)
this.condition_parser = new ConditionParser(arena, source, this.value_node_parser)
this.selector_parser = new SelectorParser(arena, source)
}

// Parse an at-rule prelude into nodes (standalone use)
Expand Down Expand Up @@ -750,6 +754,9 @@ export class AtRulePreludeParser {
this.next_token() // consume '('
const paren_start = this.lexer.token_start
const content_start = this.lexer.pos
// '(' is a single char on one line, so the content starts right after it
const content_line = this.lexer.token_line
const content_column = this.lexer.token_column

this.scan_matching_paren()
const content_end = this.paren_content_end
Expand All @@ -760,6 +767,28 @@ export class AtRulePreludeParser {
if (trimmed) {
this.arena.set_value_start_delta(scope_node, trimmed[0] - paren_start)
this.arena.set_value_length(scope_node, trimmed[1] - trimmed[0])

// <scope-start>/<scope-end> are plain <selector-list>s; unlike :has(), no leading combinator
let line = content_line
let column = content_column
for (let i = content_start; i < trimmed[0]; i++) {
if (this.source.charCodeAt(i) === 0x0a) {
line++
column = 1
} else {
column++
}
}
const selector_list = this.selector_parser.parse_selector(
trimmed[0],
trimmed[1],
line,
column,
false,
)
if (selector_list !== null) {
this.arena.set_first_child(scope_node, selector_list)
}
}
nodes.push(scope_node)
} else if (token_type === TOKEN_IDENT) {
Expand Down
Loading