From d92cc849c3dddb07ccb8dcaaf5477a30bb4bf4f6 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 13:19:12 +0200 Subject: [PATCH] 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 (

is :empty,

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 --- src/browser/webapi/selector/List.zig | 57 ++++++++++++++++++++++---- src/browser/webapi/selector/Parser.zig | 37 +++++++++++++---- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index e5cbfd5e6..8bea494f5 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -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)) { diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index caf6f1d7f..29adb4b3b 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -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 },