selector: implement :link, :lang, :empty spec semantics; parser fixes

Fixes the remaining 30 failing subtests of WPT
/dom/nodes/Element-matches.html, in five clusters:

Matching (selector/List.zig):

- :link now matches a and area elements with an href attribute (in a
  headless browser no link is ever visited, so :visited stays
  unmatched and :link covers every hyperlink). :any-link gains the
  missing area case.
- :empty now ignores comment and processing-instruction children, per
  Selectors Level 3 only elements and non-empty text/cdata affect
  emptiness (<p><!-- comment --></p> is :empty, <p> </p> is not).
- :lang() is implemented: the element's language is the nearest
  ancestor-or-self lang attribute, falling back to the UA default (en)
  for elements in a document and to no language at all in detached
  subtrees, which is what the WPT expects for the detached/fragment
  contexts. Matching is ASCII case-insensitive on the exact tag or a
  `-` separated prefix (:lang(en) matches lang="en-AU").

Parsing (selector/Parser.zig):

- An empty selector-list segment ("div," or ",div") is now a parse
  error instead of being silently skipped.
- Unexpected EOF closes open attribute brackets per CSS Syntax:
  '#attr-value [align="center"' parses and matches.
- Attribute selectors accept a namespace component: [*|TiTlE] (any
  namespace) and [|title] (no namespace). Attributes are stored by
  qualified name and almost never namespaced, so both forms match by
  name; [*|*=test] stays invalid.

Coverage:
- /dom/nodes/Element-matches.html 639/669 -> 669/669 (fully green)
- /dom/nodes/ParentNode-querySelectors-namespaces.html 0/1 -> 1/1
- /dom/nodes/moveBefore/moveBefore-lang.html 0/1 -> 1/1
- /dom/nodes/moveBefore/Node-moveBefore.html 31/32 -> 32/32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 13:19:12 +02:00
parent ed84301e70
commit d92cc849c3
2 changed files with 79 additions and 15 deletions

View File

@@ -612,13 +612,15 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
},
.focus_visible => return false,
// Link states
.link => return false,
.visited => return false,
.any_link => {
if (el.getTag() != .anchor) return false;
// Link states. In a headless browser no link is ever visited, so
// :link matches every hyperlink (a or area with an href attribute)
// and :visited matches nothing.
.link, .any_link => {
const tag = el.getTag();
if (tag != .anchor and tag != .area) return false;
return el.getAttributeSafe(comptime .wrap("href")) != null;
},
.visited => return false,
.target => {
const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse return false;
const doc = node.ownerDocument(frame) orelse return false;
@@ -638,7 +640,19 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
return node == scope;
},
.empty => {
return node.firstChild() == null;
// Only element and content (non-empty text/cdata) children affect
// emptiness; comments and processing instructions are ignored.
var child = node.firstChild();
while (child) |c| : (child = c.nextSibling()) {
switch (c._type) {
.cdata => |cdata| switch (cdata._type) {
.comment, .processing_instruction => {},
else => if (cdata.getLength() > 0) return false,
},
else => return false,
}
}
return true;
},
.first_child => return isFirstChild(el),
.last_child => return isLastChild(el),
@@ -660,7 +674,36 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
},
// Functional
.lang => return false,
.lang => |expected| {
if (expected.len == 0) return false;
// The element's language is the nearest ancestor-or-self lang
// attribute. Elements in a document with no declared language
// fall back to the UA default (en); detached subtrees have no
// language at all.
var lang: ?[]const u8 = null;
var current: ?*Node = node;
while (current) |cur| : (current = cur.parentNode()) {
switch (cur._type) {
.element => |ancestor| {
if (ancestor.getAttributeSafe(comptime .wrap("lang"))) |value| {
lang = value;
break;
}
},
.document => {
lang = "en";
break;
},
else => {},
}
}
const value = lang orelse return false;
// Match the exact language or a `-` separated sub-tag prefix
// (:lang(en) matches lang="en-AU"), ASCII case-insensitively.
if (value.len < expected.len) return false;
if (!std.ascii.eqlIgnoreCase(value[0..expected.len], expected)) return false;
return value.len == expected.len or value[expected.len] == '-';
},
.not => |selectors| {
for (selectors) |selector| {
if (matches(node, selector, scope, frame)) {

View File

@@ -81,7 +81,11 @@ pub fn parseList(arena: Allocator, input: []const u8) ParseError![]const Selecto
var remaining = preprocessed;
while (true) {
const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace);
if (trimmed.len == 0) break;
if (trimmed.len == 0) {
// Either an empty selector or a dangling comma ("div,"): both are
// parse errors per the selector-list grammar.
return error.InvalidSelector;
}
var comma_pos: usize = trimmed.len;
var depth: usize = 0;
@@ -137,10 +141,12 @@ pub fn parseList(arena: Allocator, input: []const u8) ParseError![]const Selecto
const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace);
if (selector_input.len > 0) {
const selector = try parse(arena, selector_input);
try selectors.append(arena, selector);
if (selector_input.len == 0) {
// An empty segment between commas (",div") is a parse error.
return error.InvalidSelector;
}
const selector = try parse(arena, selector_input);
try selectors.append(arena, selector);
if (comma_pos >= trimmed.len) break;
remaining = trimmed[comma_pos + 1 ..];
@@ -946,6 +952,15 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
self.input = self.input[1..];
_ = self.skipSpaces();
// Optional namespace component: `*|name` (any namespace) or `|name`
// (no namespace). Attributes are stored by qualified name and almost
// never namespaced, so both forms match by name.
if (self.peek() == '*' and self.input.len > 1 and self.input[1] == '|') {
self.input = self.input[2..];
} else if (self.peek() == '|' and self.input.len > 1 and self.input[1] != '=') {
self.input = self.input[1..];
}
const attr_name = try self.attributeName(arena);
// Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup)
@@ -953,8 +968,12 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
var case_insensitive = false;
_ = self.skipSpaces();
if (self.peek() == ']') {
self.input = self.input[1..];
// Unexpected EOF closes all open constructs per CSS Syntax, so a
// dangling `[foo` is a valid presence selector.
if (self.peek() == ']' or self.peek() == 0) {
if (self.peek() == ']') {
self.input = self.input[1..];
}
return .{ .name = name, .matcher = .presence, .case_insensitive = case_insensitive };
}
@@ -976,10 +995,12 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
_ = self.skipSpaces();
}
if (self.peek() != ']') {
// As above, EOF stands in for the closing bracket ([align="center").
if (self.peek() == ']') {
self.input = self.input[1..];
} else if (self.peek() != 0) {
return error.InvalidAttributeSelector;
}
self.input = self.input[1..];
const matcher: Selector.AttributeMatcher = switch (matcher_type) {
.exact => .{ .exact = value },