From d1e3505a88381d371b7041d58714458fd8114163 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 2 Dec 2025 22:24:33 +0100 Subject: [PATCH 1/2] WIP add has-parens check --- API.md | 34 ++++++++++++++++++- src/arena.ts | 1 + src/css-node.ts | 10 ++++++ src/parse-selector.test.ts | 68 +++++++++++++++++++++++++++++++++++++- src/parse-selector.ts | 6 ++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index 1ed4930..acf03e0 100644 --- a/API.md +++ b/API.md @@ -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` @@ -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 diff --git a/src/arena.ts b/src/arena.ts index a5ebb8c..efdbc98 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -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] diff --git a/src/css-node.ts b/src/css-node.ts index b4f2750..9672c15 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -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' @@ -316,7 +317,16 @@ 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 + if (this.arena.has_flag(this.index, FLAG_HAS_PARENS)) { + return true + } + } return this.arena.has_children(this.index) } diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index c3b0227..6c6e470 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -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]') @@ -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', () => { diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 09907eb..74d3c18 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -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, @@ -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) From 7a1612915c0a441dd7d83dce3b1cf1f01729848c Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Tue, 2 Dec 2025 22:39:39 +0100 Subject: [PATCH 2/2] improve --- src/css-node.ts | 1 + src/parse-selector.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/css-node.ts b/src/css-node.ts index 9672c15..52618a8 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -323,6 +323,7 @@ export class CSSNode { // 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 } diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index 6c6e470..7d7a326 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -1460,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"]')