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
34 changes: 33 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function parse(source: string, options?: ParserOptions): CSSNode
- `has_error` - Whether node has syntax error
- `has_prelude` - Whether at-rule has a prelude
- `has_block` - Whether rule has a `{ }` block
- `has_children` - Whether node has child nodes
- `has_children` - Whether node has child nodes (for pseudo-class/pseudo-element functions, returns `true` even if empty to indicate function syntax)
- `block` - Block node containing declarations/nested rules (for style rules and at-rules with blocks)
- `is_empty` - Whether block has no declarations or rules (only comments allowed)
- `first_child` - First child node or `null`
Expand Down Expand Up @@ -437,6 +437,38 @@ import {
- `NODE_PRELUDE_IMPORT_LAYER` (39) - Import layer
- `NODE_PRELUDE_IMPORT_SUPPORTS` (40) - Import supports condition

## Pseudo-Class Function Syntax Detection

For formatters and tools that need to reconstruct CSS, the parser distinguishes between pseudo-classes that use function syntax (with parentheses) and those that don't:

- `:hover` → `has_children = false` (no function syntax)
- `:lang()` → `has_children = true` (function syntax, even though empty)
- `:lang(en)` → `has_children = true` (function syntax with content)

The `has_children` property on pseudo-class and pseudo-element nodes returns `true` if:
1. The node has actual child nodes (parsed content), OR
2. The node uses function syntax (has parentheses), indicated by the `FLAG_HAS_PARENS` flag

This allows formatters to correctly reconstruct selectors:
- `:hover` → no parentheses needed
- `:lang()` → parentheses needed (even though empty)

### Example

```javascript
import { parse_selector } from '@projectwallace/css-parser'

// Function syntax (with parentheses) - even if empty
const ast1 = parse_selector(':lang()')
const pseudoClass1 = ast1.first_child.first_child
console.log(pseudoClass1.has_children) // true - indicates function syntax

// Regular pseudo-class (no parentheses)
const ast2 = parse_selector(':hover')
const pseudoClass2 = ast2.first_child.first_child
console.log(pseudoClass2.has_children) // false - no function syntax
```

## Attribute Selector Constants

### Attribute Selector Operators
Expand Down
1 change: 1 addition & 0 deletions src/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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_DECLARATIONS = 1 << 5 // Has declarations (for style rules)
export const FLAG_HAS_PARENS = 1 << 6 // Has parentheses syntax (for pseudo-class/pseudo-element functions)

// Attribute selector operator constants (stored in 1 byte at offset 2)
export const ATTR_OPERATOR_NONE = 0 // [attr]
Expand Down
11 changes: 11 additions & 0 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
FLAG_HAS_BLOCK,
FLAG_VENDOR_PREFIXED,
FLAG_HAS_DECLARATIONS,
FLAG_HAS_PARENS,
} from './arena'

import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace } from './string-utils'
Expand Down Expand Up @@ -316,7 +317,17 @@ export class CSSNode {
}

// Check if this node has children
// For pseudo-class/pseudo-element functions, returns true if FLAG_HAS_PARENS is set
// This allows formatters to distinguish :lang() from :hover
get has_children(): boolean {
// For pseudo-class/pseudo-element nodes, check if they have function syntax
if (this.type === NODE_SELECTOR_PSEUDO_CLASS || this.type === NODE_SELECTOR_PSEUDO_ELEMENT) {
// If FLAG_HAS_PARENS is set, return true even if no actual children
// This indicates that `()` is there but contains no children which can be caught by checking `.children.length`
if (this.arena.has_flag(this.index, FLAG_HAS_PARENS)) {
return true
}
}
return this.arena.has_children(this.index)
}

Expand Down
83 changes: 82 additions & 1 deletion src/parse-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,72 @@ describe('SelectorParser', () => {
})
})

describe('Pseudo-class function syntax detection (has_children)', () => {
it('should indicate :lang() has function syntax even when empty', () => {
const root = parse_selector(':lang()')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('lang')
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
})

it('should indicate :lang(en) has function syntax with children', () => {
const root = parse_selector(':lang(en)')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('lang')
expect(pseudoClass.has_children).toBe(true) // Function syntax with content
})

it('should indicate :hover has no function syntax', () => {
const root = parse_selector(':hover')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('hover')
expect(pseudoClass.has_children).toBe(false) // Not a function
})

it('should indicate :is() has function syntax even when empty', () => {
const root = parse_selector(':is()')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('is')
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
})

it('should indicate :has() has function syntax even when empty', () => {
const root = parse_selector(':has()')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('has')
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
})

it('should indicate :nth-child() has function syntax even when empty', () => {
const root = parse_selector(':nth-child()')
const pseudoClass = root.first_child!.first_child!
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudoClass.name).toBe('nth-child')
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
})

it('should indicate ::before has no function syntax', () => {
const root = parse_selector('::before')
const pseudoElement = root.first_child!.first_child!
expect(pseudoElement.type).toBe(NODE_SELECTOR_PSEUDO_ELEMENT)
expect(pseudoElement.name).toBe('before')
expect(pseudoElement.has_children).toBe(false) // Not a function
})

it('should indicate ::slotted() has function syntax even when empty', () => {
const root = parse_selector('::slotted()')
const pseudoElement = root.first_child!.first_child!
expect(pseudoElement.type).toBe(NODE_SELECTOR_PSEUDO_ELEMENT)
expect(pseudoElement.name).toBe('slotted')
expect(pseudoElement.has_children).toBe(true) // Function syntax, even if empty
})
})

describe('Attribute selectors', () => {
it('should parse simple attribute selector', () => {
const { arena, rootNode, source } = parseSelectorInternal('[disabled]')
Expand Down Expand Up @@ -943,7 +1009,7 @@ describe('SelectorParser', () => {

expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(has.text).toBe(':has()')
expect(has.has_children).toBe(false)
expect(has.has_children).toBe(true) // Has function syntax (parentheses)
})

it('should parse nesting with ampersand', () => {
Expand Down Expand Up @@ -1394,6 +1460,21 @@ describe('parse_selector()', () => {
expect(result.has_children).toBe(true)
})

test('should parse unknown pseudo-class without parens', () => {
let root = parse_selector(':hello')
let pseudo = root.first_child?.first_child
expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudo?.has_children).toBe(false)
})

test('should parse unknown pseudo-class with empty parens', () => {
let root = parse_selector(':hello()')
let pseudo = root.first_child?.first_child
expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
expect(pseudo?.has_children).toBe(true)
expect(pseudo?.children.length).toBe(0)
})

test('should parse attribute selector', () => {
const result = parse_selector('[href^="https"]')

Expand Down
6 changes: 6 additions & 0 deletions src/parse-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
NODE_SELECTOR_NTH_OF,
NODE_SELECTOR_LANG,
FLAG_VENDOR_PREFIXED,
FLAG_HAS_PARENS,
ATTR_OPERATOR_NONE,
ATTR_OPERATOR_EQUAL,
ATTR_OPERATOR_TILDE_EQUAL,
Expand Down Expand Up @@ -692,6 +693,11 @@ export class SelectorParser {
// Content is the function name (without colons and parentheses)
this.arena.set_content_start(node, func_name_start)
this.arena.set_content_length(node, func_name_end - func_name_start)

// Set FLAG_HAS_PARENS to indicate this is a function syntax (even if empty)
// This allows formatters to distinguish :lang() from :hover
this.arena.set_flag(node, FLAG_HAS_PARENS)

// Check for vendor prefix and set flag if detected
if (is_vendor_prefixed(this.source, func_name_start, func_name_end)) {
this.arena.set_flag(node, FLAG_VENDOR_PREFIXED)
Expand Down
Loading