mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 17:25:58 -04:00
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:
@@ -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)) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user