Skip to content

Commit ed4abb8

Browse files
authored
Improve has_children for pseudo class with empty parens (#18)
case: `:hello()` - `node.has_children` => true - `node.children.length` => 0 case: `:hello` - `node.has_children` => false - `node.children.length` => 0
1 parent ddebfd4 commit ed4abb8

File tree

5 files changed

+133
-2
lines changed

5 files changed

+133
-2
lines changed

API.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function parse(source: string, options?: ParserOptions): CSSNode
4545
- `has_error` - Whether node has syntax error
4646
- `has_prelude` - Whether at-rule has a prelude
4747
- `has_block` - Whether rule has a `{ }` block
48-
- `has_children` - Whether node has child nodes
48+
- `has_children` - Whether node has child nodes (for pseudo-class/pseudo-element functions, returns `true` even if empty to indicate function syntax)
4949
- `block` - Block node containing declarations/nested rules (for style rules and at-rules with blocks)
5050
- `is_empty` - Whether block has no declarations or rules (only comments allowed)
5151
- `first_child` - First child node or `null`
@@ -437,6 +437,38 @@ import {
437437
- `NODE_PRELUDE_IMPORT_LAYER` (39) - Import layer
438438
- `NODE_PRELUDE_IMPORT_SUPPORTS` (40) - Import supports condition
439439

440+
## Pseudo-Class Function Syntax Detection
441+
442+
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:
443+
444+
- `:hover` → `has_children = false` (no function syntax)
445+
- `:lang()` → `has_children = true` (function syntax, even though empty)
446+
- `:lang(en)` → `has_children = true` (function syntax with content)
447+
448+
The `has_children` property on pseudo-class and pseudo-element nodes returns `true` if:
449+
1. The node has actual child nodes (parsed content), OR
450+
2. The node uses function syntax (has parentheses), indicated by the `FLAG_HAS_PARENS` flag
451+
452+
This allows formatters to correctly reconstruct selectors:
453+
- `:hover` → no parentheses needed
454+
- `:lang()` → parentheses needed (even though empty)
455+
456+
### Example
457+
458+
```javascript
459+
import { parse_selector } from '@projectwallace/css-parser'
460+
461+
// Function syntax (with parentheses) - even if empty
462+
const ast1 = parse_selector(':lang()')
463+
const pseudoClass1 = ast1.first_child.first_child
464+
console.log(pseudoClass1.has_children) // true - indicates function syntax
465+
466+
// Regular pseudo-class (no parentheses)
467+
const ast2 = parse_selector(':hover')
468+
const pseudoClass2 = ast2.first_child.first_child
469+
console.log(pseudoClass2.has_children) // false - no function syntax
470+
```
471+
440472
## Attribute Selector Constants
441473

442474
### Attribute Selector Operators

src/arena.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const FLAG_LENGTH_OVERFLOW = 1 << 2 // Node > 65k chars
7575
export const FLAG_HAS_BLOCK = 1 << 3 // Has { } block (for style rules and at-rules)
7676
export const FLAG_VENDOR_PREFIXED = 1 << 4 // Has vendor prefix (-webkit-, -moz-, -ms-, -o-)
7777
export const FLAG_HAS_DECLARATIONS = 1 << 5 // Has declarations (for style rules)
78+
export const FLAG_HAS_PARENS = 1 << 6 // Has parentheses syntax (for pseudo-class/pseudo-element functions)
7879

7980
// Attribute selector operator constants (stored in 1 byte at offset 2)
8081
export const ATTR_OPERATOR_NONE = 0 // [attr]

src/css-node.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
FLAG_HAS_BLOCK,
4545
FLAG_VENDOR_PREFIXED,
4646
FLAG_HAS_DECLARATIONS,
47+
FLAG_HAS_PARENS,
4748
} from './arena'
4849

4950
import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace } from './string-utils'
@@ -316,7 +317,17 @@ export class CSSNode {
316317
}
317318

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

src/parse-selector.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,72 @@ describe('SelectorParser', () => {
415415
})
416416
})
417417

418+
describe('Pseudo-class function syntax detection (has_children)', () => {
419+
it('should indicate :lang() has function syntax even when empty', () => {
420+
const root = parse_selector(':lang()')
421+
const pseudoClass = root.first_child!.first_child!
422+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
423+
expect(pseudoClass.name).toBe('lang')
424+
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
425+
})
426+
427+
it('should indicate :lang(en) has function syntax with children', () => {
428+
const root = parse_selector(':lang(en)')
429+
const pseudoClass = root.first_child!.first_child!
430+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
431+
expect(pseudoClass.name).toBe('lang')
432+
expect(pseudoClass.has_children).toBe(true) // Function syntax with content
433+
})
434+
435+
it('should indicate :hover has no function syntax', () => {
436+
const root = parse_selector(':hover')
437+
const pseudoClass = root.first_child!.first_child!
438+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
439+
expect(pseudoClass.name).toBe('hover')
440+
expect(pseudoClass.has_children).toBe(false) // Not a function
441+
})
442+
443+
it('should indicate :is() has function syntax even when empty', () => {
444+
const root = parse_selector(':is()')
445+
const pseudoClass = root.first_child!.first_child!
446+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
447+
expect(pseudoClass.name).toBe('is')
448+
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
449+
})
450+
451+
it('should indicate :has() has function syntax even when empty', () => {
452+
const root = parse_selector(':has()')
453+
const pseudoClass = root.first_child!.first_child!
454+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
455+
expect(pseudoClass.name).toBe('has')
456+
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
457+
})
458+
459+
it('should indicate :nth-child() has function syntax even when empty', () => {
460+
const root = parse_selector(':nth-child()')
461+
const pseudoClass = root.first_child!.first_child!
462+
expect(pseudoClass.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
463+
expect(pseudoClass.name).toBe('nth-child')
464+
expect(pseudoClass.has_children).toBe(true) // Function syntax, even if empty
465+
})
466+
467+
it('should indicate ::before has no function syntax', () => {
468+
const root = parse_selector('::before')
469+
const pseudoElement = root.first_child!.first_child!
470+
expect(pseudoElement.type).toBe(NODE_SELECTOR_PSEUDO_ELEMENT)
471+
expect(pseudoElement.name).toBe('before')
472+
expect(pseudoElement.has_children).toBe(false) // Not a function
473+
})
474+
475+
it('should indicate ::slotted() has function syntax even when empty', () => {
476+
const root = parse_selector('::slotted()')
477+
const pseudoElement = root.first_child!.first_child!
478+
expect(pseudoElement.type).toBe(NODE_SELECTOR_PSEUDO_ELEMENT)
479+
expect(pseudoElement.name).toBe('slotted')
480+
expect(pseudoElement.has_children).toBe(true) // Function syntax, even if empty
481+
})
482+
})
483+
418484
describe('Attribute selectors', () => {
419485
it('should parse simple attribute selector', () => {
420486
const { arena, rootNode, source } = parseSelectorInternal('[disabled]')
@@ -943,7 +1009,7 @@ describe('SelectorParser', () => {
9431009

9441010
expect(has.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
9451011
expect(has.text).toBe(':has()')
946-
expect(has.has_children).toBe(false)
1012+
expect(has.has_children).toBe(true) // Has function syntax (parentheses)
9471013
})
9481014

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

1463+
test('should parse unknown pseudo-class without parens', () => {
1464+
let root = parse_selector(':hello')
1465+
let pseudo = root.first_child?.first_child
1466+
expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
1467+
expect(pseudo?.has_children).toBe(false)
1468+
})
1469+
1470+
test('should parse unknown pseudo-class with empty parens', () => {
1471+
let root = parse_selector(':hello()')
1472+
let pseudo = root.first_child?.first_child
1473+
expect(pseudo?.type).toBe(NODE_SELECTOR_PSEUDO_CLASS)
1474+
expect(pseudo?.has_children).toBe(true)
1475+
expect(pseudo?.children.length).toBe(0)
1476+
})
1477+
13971478
test('should parse attribute selector', () => {
13981479
const result = parse_selector('[href^="https"]')
13991480

src/parse-selector.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
NODE_SELECTOR_NTH_OF,
1717
NODE_SELECTOR_LANG,
1818
FLAG_VENDOR_PREFIXED,
19+
FLAG_HAS_PARENS,
1920
ATTR_OPERATOR_NONE,
2021
ATTR_OPERATOR_EQUAL,
2122
ATTR_OPERATOR_TILDE_EQUAL,
@@ -692,6 +693,11 @@ export class SelectorParser {
692693
// Content is the function name (without colons and parentheses)
693694
this.arena.set_content_start(node, func_name_start)
694695
this.arena.set_content_length(node, func_name_end - func_name_start)
696+
697+
// Set FLAG_HAS_PARENS to indicate this is a function syntax (even if empty)
698+
// This allows formatters to distinguish :lang() from :hover
699+
this.arena.set_flag(node, FLAG_HAS_PARENS)
700+
695701
// Check for vendor prefix and set flag if detected
696702
if (is_vendor_prefixed(this.source, func_name_start, func_name_end)) {
697703
this.arena.set_flag(node, FLAG_VENDOR_PREFIXED)

0 commit comments

Comments
 (0)