Skip to content

LibWeb: Move selector matching to Rust#10731

Merged
awesomekling merged 19 commits into
LadybirdBrowser:masterfrom
awesomekling:sele-rs
Jul 17, 2026
Merged

LibWeb: Move selector matching to Rust#10731
awesomekling merged 19 commits into
LadybirdBrowser:masterfrom
awesomekling:sele-rs

Conversation

@awesomekling

Copy link
Copy Markdown
Member

This moves selector matching and the compiled selector representation from C++ to Rust. Each CSS::Selector is compiled once into immutable Rust data, including precomputed fast-match eligibility, :has() cache identity, pseudo-element information, and sibling invalidation distance.

The DOM remains in C++. Matching borrows opaque node handles for one synchronous FFI call and accesses DOM state through narrow SelectorDom callbacks. Rust owns any selector data retained after compilation, while DOM nodes, MatchContext, :has() caches, and invalidation metadata keep their existing C++ ownership. Call-scoped lifetimes prevent borrowed node handles from escaping into compiled selectors or other retained state.

The Rust matcher preserves the existing algorithms and optimizations: right-to-left selector matching, forward candidate discovery for relative selectors inside :has(), descendant backtracking, specialized :has() scans, ancestor-cache propagation, and invalidation bookkeeping. The migration initially ran both engines and asserted identical results before removing the C++ matcher.

cbindgen now generates the selector ABI header from Rust declarations. Rust pseudo-class and pseudo-element enums are generated from the same JSON sources as their C++ counterparts, removing the hand-maintained duplicate ABI definitions. Interned selector strings continue to be compared by C++ identity across the FFI boundary.

Coverage added with the migration includes:

  • public selector APIs, combinators, logical and positional selectors, relative selectors, namespaces, and shadow DOM;
  • descendant backtracking, :has() fast paths and caching, filtered child indices, invalidation distance, and selector cache IDs;
  • extreme An+B coefficients, :nth-* under document fragments and shadow roots, and ::slotted() argument handling;
  • restoration of nested :has() state during panic unwinding.

Cover public selector APIs, combinator traversal, logical and positional
selectors, relative selector shapes, XML namespace handling, and shadow
DOM matching before replacing the selector engine.
Represent selectors as immutable Rust compounds and simple selectors.
Precompute fast-match eligibility, target pseudo-elements, stable cache
identities, and sibling invalidation distances for the matcher.
Give every parsed CSS selector an immutable Rust counterpart that owns
its strings and retains nested selector arguments through shared
references. This prepares selector matching to cross the FFI boundary
without borrowing parser storage or recompiling selectors per match.

Add an explicit C ABI for transferring the complete selector grammar,
including namespaces, functional pseudo-classes, and pseudo-elements.
Keep the existing C++ matcher active until the Rust matcher is complete.
Implement selector matching control flow over a generic borrowed DOM
interface. Cover fast matching, combinator backtracking, functional and
structural pseudo-classes, relative :has() matching, pseudo-element
transitions, cache hooks, and invalidation metadata hooks.

Keep DOM semantics behind a monomorphized trait so the production bridge
can observe live C++ nodes without snapshots or dynamic dispatch. Add
unit coverage for backtracking, relative selectors, and filtered child
indices.
Connect the Rust selector matcher to live C++ DOM nodes through a
synchronous semantic FFI. Borrow node, scope, shadow-root, stylesheet,
and matching-context pointers for one call. Never retain or copy the DOM
tree.

Evaluate Rust alongside the existing matcher and assert that both
results agree while leaving C++ responsible for the returned result.
Suppress side effects in the comparison context so invalidation metadata
and :has() counters remain unchanged. Key shared :has() cache entries by
the stable Rust selector ID in preparation for the final cutover.
Retain the originating C++ selector as a borrowed identity alongside its
Rust representation so the existing subtree reject filter can be reused
without recomputing or translating its hashes.

Port the specialized child-tag and descendant tag-and-class scans to the
Rust traversal layer. Keep metadata collection on the general path and
preserve successful descendant cache propagation.
Make Rust responsible for selector traversal and the returned match
result. Enable its cache, counter, and invalidation metadata side
effects so style computation, DOM queries, and invalidation all use one
path.

Keep C++ limited to semantic DOM queries across the synchronous FFI.
Keep selector traversal, combinator evaluation, pseudo-element
transitions, and structural and functional pseudo-classes entirely in
Rust. Reduce C++ selector matching to synchronous semantic DOM queries
and existing invalidation caches used through FFI.

Pass MatchContext directly across the FFI now that comparison mode no
longer needs to suppress side effects. Drop the matching parameters,
caches, invalidation helpers, and traversal callbacks that only served
the deleted C++ engine.

Rename the remaining integration surface to SelectorMatching and update
all callers. Remove the old C++ matching implementation without a
fallback path.
Compute the index delta and divisor in i64 before applying modulo and
division. Preserve matching for extreme 32-bit coefficients without
overflowing in debug or sanitizer builds.

Add unit and end-to-end selector coverage for both i32 boundaries, fixed
offsets, and negative steps.
Count matching siblings directly instead of starting from the parent
element. Fragment-root children have no parent element, even though
their Node parent still exposes element siblings.

Reverse sibling counting no longer needs last_element_child, so drop
the now-unused trait method and FFI callback. Add forward, reverse,
filtered, type-based, and query selector coverage with text and comment
nodes mixed into both fragment root types.
A slot whose root is a shadow tree can never be a slotted element, and
the old C++ selector engine rejected such match targets before
evaluating the ::slotted() argument selector. The Rust engine evaluates
the argument first, which shows up as a :has() match invocation when
matching ::slotted(:has(...)) against a slot.

This test pins the current behavior, including the extra :has() match
invocation for the slot target. A subsequent commit restores the old
ordering and updates the expectations.
The old C++ selector engine rejected a match target that is itself a
slot in a shadow tree before evaluating the ::slotted() argument
selector. The Rust port folded that check into the slotted-parent FFI
lookup, which runs after the argument match, so matching a ::slotted()
rule against a slot element would evaluate the argument selector
against the slot first. That is wasted work, and for arguments with
side effects such as :has() result caching or selector involvement
metadata, it touched slot elements the old engine never touched.

Restore the old ordering with a dedicated is_shadow_tree_slot check
that runs before the argument match, and drop the now-redundant check
from the slotted-parent lookup. This removes the extra :has() match
invocation pinned by the test added in the previous commit.
The old C++ ANPlusBPattern::matches special-cased step size 1 and
rejected any such selector with a negative offset, so :nth-child(n-2)
matched nothing. The Rust selector engine uses the general An+B
formula, under which every index has a non-negative n solving
n + offset = index, so these selectors match every element. That is
what the specification and other engines do, but nothing pinned the
behavior. Extend the extreme An+B test with step-size-1 negative
offset cases for :nth-child, :nth-last-child, and :nth-of-type.
The compiled Rust selector stored id, class, tag, attribute, and state
names as raw UTF-16 code unit slices, so the innermost matching
callbacks compared string content or re-interned identifiers on every
test. The removed C++ engine compared interned Utf16FlyStrings, where
equality is a pointer comparison.

Store a pointer to the C++ simple selector in the compiled Rust
representation. The C++ selector outlives its compiled twin, which it
owns, so matching callbacks can use its interned strings directly:

- id matching is Utf16FlyString equality again
- class matching uses an overload that compares interned strings in the
  case-sensitive path
- tag names compare local_name against the interned selector name in the
  exact-match paths
- attribute name lookups compare interned local names
- :state() looks up the already-interned custom-state identifier

This also lets the universal, tag, and attribute callbacks use the
original qualified name instead of marshaling namespace and name
strings through the FFI, and drops per-call attribute struct marshaling.
Tie borrowed DOM nodes and matching contexts to each FFI call. Tag
scope nodes separately so they cannot reach element-only callbacks.
Retain C++ selector identities as typed pointers, and copy transient
FFI slices into owned storage. Document each unsafe boundary and its
lifetime requirements. Use Rc for the single-threaded selector graph.

Mirror the selector involvement flags in FfiDom as well. Hot matching
loops can then skip metadata callbacks that are no-ops and avoid an FFI
round trip when saving the per-:has()-argument state.
Generate the Rust pseudo-class and pseudo-element enums from the same
JSON sources as their C++ counterparts. This prevents positional drift
that count assertions could not detect.

Generate the selector ABI header from the Rust declarations as well.
This makes cbindgen the single source of truth for layouts, enum values,
and function signatures on both sides of the boundary. Teach the Rust
crate build helper to track and synchronize multiple generated headers.
Explain the compiled selector representation, its ownership boundaries,
and match-state invariants. Include the specification algorithm for
right-to-left matching beside the general recursive matcher.

Quote the relevant Selectors and CSS Scoping requirements beside their
matching paths. Document relative traversal, shadow transitions, cache
propagation, and matcher optimizations.
Represent retained C++ selector identities with a private nullable
pointer type and keep raw conversion at the FFI edges. Tie DOM handles
to a matching-call token and prevent them from escaping through a
higher-ranked matching closure.

Borrow transient FFI record arrays while compiling selectors. Continue
copying strings and other data retained by the compiled representation.
Own the mutable DOM borrow while matching a :has() argument and restore
the previous state when the guard is dropped. Cover restoration during
panic unwinding in the Rust selector tests.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change replaces the C++ selector engine with a Rust-backed CSS selector compiler and matcher, adds generated multi-header FFI support, wires LibWeb call sites to the new API, and adds coverage for selector, shadow-DOM, invalidation, and :nth-* behavior.

Changes

Rust CSS Selector Matching

Layer / File(s) Summary
FFI build and header generation
Libraries/LibWeb/CMakeLists.txt, Meta/CMake/*, Libraries/LibWeb/Rust/*
Rust builds now generate selector pseudo-type bindings and synchronize multiple FFI headers, including selector and tokenizer interfaces.
Selector representation and Rust bridge
Libraries/LibWeb/CSS/Selector.*, Libraries/LibWeb/CSS/SelectorRustBridge.*, Libraries/LibWeb/Rust/src/selector_engine.rs
Selectors are compiled into Rust-owned handles with C++ string and selector data marshalled through FFI; selector lifetime and pseudo-element metadata are managed explicitly.
Rust matching engine
Libraries/LibWeb/Rust/src/selector_engine.rs
Adds recursive and optimized matching for combinators, pseudo-classes, :has(), pseudo-elements, shadow scoping, caching, and relative selectors.
C++ matching callbacks and caches
Libraries/LibWeb/CSS/SelectorMatching.*
Implements DOM matching callbacks, pseudo-class state checks, namespace and attribute matching, shadow-DOM traversal, invalidation metadata, and :has() caching.
Call-site migration and selector coverage
Libraries/LibWeb/CSS/StyleComputer.*, Libraries/LibWeb/CSS/StyleSheetInvalidation.cpp, Libraries/LibWeb/DOM/*, Tests/LibWeb/Text/*
Style, invalidation, DOM matching, and selector queries use SelectorMatching; selector, XML, shadow-DOM, invalidation, and :nth-* fixtures are added or updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StyleComputer
  participant SelectorMatching
  participant RustSelector
  participant DOMCallbacks
  StyleComputer->>SelectorMatching: matches(selector, target, context)
  SelectorMatching->>RustSelector: rust_selector_matches(...)
  RustSelector->>DOMCallbacks: evaluate selectors and traverse DOM
  DOMCallbacks-->>RustSelector: match result and metadata
  RustSelector-->>SelectorMatching: return boolean result
  SelectorMatching-->>StyleComputer: return match result
Loading
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description accurately matches the Rust migration of selector matching, FFI bridges, generated ABI headers, and added selector coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Libraries/LibWeb/CSS/SelectorMatching.cpp`:
- Around line 1122-1125: Update selector_ffi_is_document_root to determine root
status from document position by returning Element::is_document_element() for
the element obtained via ffi_element(element), rather than checking for
HTMLHtmlElement, so detached HTML elements do not match and valid XML/SVG
document roots do.
- Around line 952-963: Update the Named namespace branch in the attribute
matching loop to treat an empty namespace URI as the null namespace, matching
the contract used by the element path. Adjust the `attribute->namespace_uri()`
condition so unnamespaced attributes can match when `selector_namespace`
resolves to an empty value, while preserving matching for non-empty namespace
URIs and the existing `attribute_matches` call.
- Around line 853-856: Update the ContainsWord branch in the attribute selector
matching logic to tokenize element_value using all CSS whitespace
characters—space, tab, newline, carriage return, and form feed—instead of only
U+0020. Preserve the existing empty selector_value check and values_equal
comparison behavior.

In `@Libraries/LibWeb/Rust/build.rs`:
- Around line 125-129: The cbindgen build path currently ignores
ParseSyntaxError and can succeed with a stale header. Update the error handling
in builder.generate() to fail the build for parse errors as well as other
cbindgen errors, rather than silently returning success.

In `@Libraries/LibWeb/Rust/src/selector_engine.rs`:
- Line 704: Update the Combinator::Column branch in the selector matching logic
to avoid invoking unimplemented!, which can terminate processing for
page-controlled selectors. Implement column combinator matching if supported;
otherwise return the existing no-match result through the surrounding matching
flow while preserving behavior for all other combinators.
- Around line 778-786: Update the SimpleSelector::PseudoClass matching logic to
handle PseudoClassType::HostContext explicitly rather than rejecting it as
featureless-host matching. Evaluate its argument selector list against the host
and relevant ancestors using the selector-matching machinery, and preserve the
existing Host, Has, Is, Where, and Scope behavior for other pseudo-classes.
Ensure the generic pseudo-class callback is not used for HostContext without its
required selector arguments.
- Around line 1257-1279: Update compound_may_match_host_for_part_scope to
recognize :host nested through both PseudoClassType::Is and
PseudoClassType::Where, using the same recursive compound-selector traversal for
either wrapper. Preserve direct :host detection and return true whenever either
wrapper contains a nested host selector.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5880675a-a76a-4e84-a527-83c415773c79

📥 Commits

Reviewing files that changed from the base of the PR and between f27b4da and a8dd9e0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • Libraries/LibWeb/CMakeLists.txt
  • Libraries/LibWeb/CSS/Selector.cpp
  • Libraries/LibWeb/CSS/Selector.h
  • Libraries/LibWeb/CSS/SelectorEngine.cpp
  • Libraries/LibWeb/CSS/SelectorMatching.cpp
  • Libraries/LibWeb/CSS/SelectorMatching.h
  • Libraries/LibWeb/CSS/SelectorRustBridge.cpp
  • Libraries/LibWeb/CSS/SelectorRustBridge.h
  • Libraries/LibWeb/CSS/StyleComputer.cpp
  • Libraries/LibWeb/CSS/StyleComputer.h
  • Libraries/LibWeb/CSS/StyleSheetInvalidation.cpp
  • Libraries/LibWeb/DOM/Document.cpp
  • Libraries/LibWeb/DOM/Element.cpp
  • Libraries/LibWeb/DOM/Element.h
  • Libraries/LibWeb/DOM/ParentNode.cpp
  • Libraries/LibWeb/DOM/SelectorQuery.cpp
  • Libraries/LibWeb/Rust/Cargo.toml
  • Libraries/LibWeb/Rust/build.rs
  • Libraries/LibWeb/Rust/src/css_tokenizer.rs
  • Libraries/LibWeb/Rust/src/lib.rs
  • Libraries/LibWeb/Rust/src/selector_engine.rs
  • Meta/CMake/rust_crate.cmake
  • Meta/CMake/sync_rust_ffi_header.cmake
  • Tests/LibWeb/Text/expected/css/nth-child-extreme-an-plus-b.txt
  • Tests/LibWeb/Text/expected/css/nth-child-non-element-parent.txt
  • Tests/LibWeb/Text/expected/css/selector-engine-characterization.txt
  • Tests/LibWeb/Text/expected/css/style-invalidation/slotted-argument-slot-early-out.txt
  • Tests/LibWeb/Text/input/css/nth-child-extreme-an-plus-b.html
  • Tests/LibWeb/Text/input/css/nth-child-non-element-parent.html
  • Tests/LibWeb/Text/input/css/selector-engine-characterization.html
  • Tests/LibWeb/Text/input/css/style-invalidation/slotted-argument-slot-early-out.html

Comment on lines +853 to +856
case CSS::Selector::SimpleSelector::Attribute::MatchType::ContainsWord:
if (selector_value.is_empty())
return false;
return element_value.split_view(' ', SplitBehavior::Nothing).contains([&](auto value) { return values_equal(value, selector_value); });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Tokenize ~= attribute values using all CSS whitespace.

Line 856 only splits on U+0020, so valid values separated by tabs, newlines, carriage returns, or form feeds fail to match.

Proposed fix
-    case CSS::Selector::SimpleSelector::Attribute::MatchType::ContainsWord:
+    case CSS::Selector::SimpleSelector::Attribute::MatchType::ContainsWord: {
         if (selector_value.is_empty())
             return false;
-        return element_value.split_view(' ', SplitBehavior::Nothing).contains([&](auto value) { return values_equal(value, selector_value); });
+        size_t start = 0;
+        for (size_t i = 0; i <= element_value.length_in_code_units(); ++i) {
+            if (i != element_value.length_in_code_units()
+                && !Infra::is_ascii_whitespace(element_value.code_unit_at(i))) {
+                continue;
+            }
+            if (i > start && values_equal(element_value.substring_view(start, i - start), selector_value))
+                return true;
+            start = i + 1;
+        }
+        return false;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case CSS::Selector::SimpleSelector::Attribute::MatchType::ContainsWord:
if (selector_value.is_empty())
return false;
return element_value.split_view(' ', SplitBehavior::Nothing).contains([&](auto value) { return values_equal(value, selector_value); });
case CSS::Selector::SimpleSelector::Attribute::MatchType::ContainsWord: {
if (selector_value.is_empty())
return false;
size_t start = 0;
for (size_t i = 0; i <= element_value.length_in_code_units(); ++i) {
if (i != element_value.length_in_code_units()
&& !Infra::is_ascii_whitespace(element_value.code_unit_at(i))) {
continue;
}
if (i > start && values_equal(element_value.substring_view(start, i - start), selector_value))
return true;
start = i + 1;
}
return false;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/CSS/SelectorMatching.cpp` around lines 853 - 856, Update the
ContainsWord branch in the attribute selector matching logic to tokenize
element_value using all CSS whitespace characters—space, tab, newline, carriage
return, and form feed—instead of only U+0020. Preserve the existing empty
selector_value check and values_equal comparison behavior.

Comment on lines +952 to +963
case CSS::Selector::SimpleSelector::QualifiedName::NamespaceType::Named: {
if (!match_context.style_sheet_for_rule)
return false;
auto selector_namespace = match_context.style_sheet_for_rule->namespace_uri(qualified_name.namespace_);
if (!selector_namespace.has_value())
return false;
for (u32 i = 0; i < target.attributes()->length(); ++i) {
auto const* attribute = target.attributes()->item(i);
if (attribute->namespace_uri().has_value()
&& *selector_namespace == attribute->namespace_uri()->view()
&& attribute->local_name() == attribute_name)
return attribute_matches(*attribute);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat an empty named namespace as the null namespace for attributes.

The element path handles this contract at Lines 744–748, but this branch requires namespace_uri().has_value(). Consequently, a prefix mapped to an empty namespace cannot match an unnamespaced attribute.

Proposed fix
         for (u32 i = 0; i < target.attributes()->length(); ++i) {
             auto const* attribute = target.attributes()->item(i);
-            if (attribute->namespace_uri().has_value()
-                && *selector_namespace == attribute->namespace_uri()->view()
+            bool namespace_matches = selector_namespace->is_empty()
+                ? (!attribute->namespace_uri().has_value() || attribute->namespace_uri()->is_empty())
+                : (attribute->namespace_uri().has_value()
+                    && *selector_namespace == attribute->namespace_uri()->view());
+            if (namespace_matches
                 && attribute->local_name() == attribute_name)
                 return attribute_matches(*attribute);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case CSS::Selector::SimpleSelector::QualifiedName::NamespaceType::Named: {
if (!match_context.style_sheet_for_rule)
return false;
auto selector_namespace = match_context.style_sheet_for_rule->namespace_uri(qualified_name.namespace_);
if (!selector_namespace.has_value())
return false;
for (u32 i = 0; i < target.attributes()->length(); ++i) {
auto const* attribute = target.attributes()->item(i);
if (attribute->namespace_uri().has_value()
&& *selector_namespace == attribute->namespace_uri()->view()
&& attribute->local_name() == attribute_name)
return attribute_matches(*attribute);
case CSS::Selector::SimpleSelector::QualifiedName::NamespaceType::Named: {
if (!match_context.style_sheet_for_rule)
return false;
auto selector_namespace = match_context.style_sheet_for_rule->namespace_uri(qualified_name.namespace_);
if (!selector_namespace.has_value())
return false;
for (u32 i = 0; i < target.attributes()->length(); ++i) {
auto const* attribute = target.attributes()->item(i);
bool namespace_matches = selector_namespace->is_empty()
? (!attribute->namespace_uri().has_value() || attribute->namespace_uri()->is_empty())
: (attribute->namespace_uri().has_value()
&& *selector_namespace == attribute->namespace_uri()->view());
if (namespace_matches
&& attribute->local_name() == attribute_name)
return attribute_matches(*attribute);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/CSS/SelectorMatching.cpp` around lines 952 - 963, Update the
Named namespace branch in the attribute matching loop to treat an empty
namespace URI as the null namespace, matching the contract used by the element
path. Adjust the `attribute->namespace_uri()` condition so unnamespaced
attributes can match when `selector_namespace` resolves to an empty value, while
preserving matching for non-empty namespace URIs and the existing
`attribute_matches` call.

Comment on lines +1122 to +1125
extern "C" bool selector_ffi_is_document_root(void const* element)
{
return is<HTML::HTMLHtmlElement>(ffi_element(element));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Determine :root from document position, not HTML element type.

This makes every detached <html> match :root while rejecting valid XML/SVG document roots. Use Element::is_document_element().

Proposed fix
 extern "C" bool selector_ffi_is_document_root(void const* element)
 {
-    return is<HTML::HTMLHtmlElement>(ffi_element(element));
+    return ffi_element(element).is_document_element();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extern "C" bool selector_ffi_is_document_root(void const* element)
{
return is<HTML::HTMLHtmlElement>(ffi_element(element));
}
extern "C" bool selector_ffi_is_document_root(void const* element)
{
return ffi_element(element).is_document_element();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/CSS/SelectorMatching.cpp` around lines 1122 - 1125, Update
selector_ffi_is_document_root to determine root status from document position by
returning Element::is_document_element() for the element obtained via
ffi_element(element), rather than checking for HTMLHtmlElement, so detached HTML
elements do not match and valid XML/SVG document roots do.

Comment on lines +125 to +129
builder.generate().map_or_else(
|error| match error {
cbindgen::Error::ParseSyntaxError { .. } => {}
other => panic!("{other:?}"),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C6 'ParseSyntaxError|write_to_file' Libraries/LibWeb/Rust/build.rs
rg -n -C6 'ffi_output|sync_ffi_header_commands' Meta/CMake/rust_crate.cmake

Repository: LadybirdBrowser/ladybird

Length of output: 3529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Libraries/LibWeb/Rust/build.rs (relevant section) ---'
sed -n '1,180p' Libraries/LibWeb/Rust/build.rs

echo
echo '--- Meta/CMake/rust_crate.cmake (sync section) ---'
sed -n '1,140p' Meta/CMake/rust_crate.cmake

echo
echo '--- search for cbindgen / header cleanup / copy commands ---'
rg -n -C3 'cbindgen|copy_if_different|remove_file|ParseSyntaxError|write_to_file|OUT_DIR|ffi_out_dir' Libraries/LibWeb/Rust/build.rs Meta/CMake/rust_crate.cmake

Repository: LadybirdBrowser/ladybird

Length of output: 18278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' Meta/CMake/sync_rust_ffi_header.cmake

Repository: LadybirdBrowser/ladybird

Length of output: 1812


Fail cbindgen on parse errors Libraries/LibWeb/Rust/build.rs:125-129
sync_rust_ffi_header.cmake copies the header from the latest build-script output, so returning success here can publish a stale OUT_DIR header alongside the new archive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/Rust/build.rs` around lines 125 - 129, The cbindgen build
path currently ignores ParseSyntaxError and can succeed with a stale header.
Update the error handling in builder.generate() to fail the build for parse
errors as well as other cbindgen errors, rather than silently returning success.

Comment thread Libraries/LibWeb/Rust/src/selector_engine.rs
Comment on lines +778 to +786
match simple_selector {
SimpleSelector::PseudoClass(pseudo_class) => {
!(matches!(
pseudo_class.pseudo_class,
PseudoClassType::Host | PseudoClassType::Has | PseudoClassType::Is | PseudoClassType::Where
) || pseudo_class.pseudo_class == PseudoClassType::Scope && scope == Some(element))
}
SimpleSelector::Nesting | SimpleSelector::PseudoElement(_) => false,
_ => true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Implement :host-context() instead of rejecting it as featureless-host matching.

The guard explicitly omits HostContext, so it returns false before matching. Even if admitted, the generic callback receives no argument selector list, so this functional pseudo-class needs a Rust matching branch that evaluates the host/ancestor context.

Also applies to: 800-876

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/Rust/src/selector_engine.rs` around lines 778 - 786, Update
the SimpleSelector::PseudoClass matching logic to handle
PseudoClassType::HostContext explicitly rather than rejecting it as
featureless-host matching. Evaluate its argument selector list against the host
and relevant ancestors using the selector-matching machinery, and preserve the
existing Host, Has, Is, Where, and Scope behavior for other pseudo-classes.
Ensure the generic pseudo-class callback is not used for HostContext without its
required selector arguments.

Comment on lines +1257 to +1279
fn compound_may_match_host_for_part_scope(compound_selector: &CompoundSelector) -> bool {
compound_selector.simple_selectors.iter().any(|simple_selector| {
let SimpleSelector::PseudoClass(pseudo_class) = simple_selector else {
return false;
};
if pseudo_class.pseudo_class == PseudoClassType::Host {
return true;
}
pseudo_class.pseudo_class == PseudoClassType::Is
&& pseudo_class.argument_selector_list.iter().any(|selector| {
selector.compound_selectors.iter().any(|compound| {
compound.simple_selectors.iter().any(|simple| {
matches!(
simple,
SimpleSelector::PseudoClass(PseudoClassSelector {
pseudo_class: PseudoClassType::Host,
..
})
)
})
})
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Recognize :where(:host) when selecting the ::part() traversal scope.

:where() and :is() share matching semantics, but this helper only unwraps :is(). Consequently, selectors such as :where(:host)::part(foo) incorrectly disallow the same-shadow-root path. Recurse through both wrappers.

Proposed fix
 fn compound_may_match_host_for_part_scope(compound_selector: &CompoundSelector) -> bool {
-    compound_selector.simple_selectors.iter().any(|simple_selector| {
+    fn may_match_host(simple_selector: &SimpleSelector) -> bool {
         let SimpleSelector::PseudoClass(pseudo_class) = simple_selector else {
             return false;
         };
         if pseudo_class.pseudo_class == PseudoClassType::Host {
             return true;
         }
-        pseudo_class.pseudo_class == PseudoClassType::Is
+        matches!(
+            pseudo_class.pseudo_class,
+            PseudoClassType::Is | PseudoClassType::Where
+        )
             && pseudo_class.argument_selector_list.iter().any(|selector| {
                 selector.compound_selectors.iter().any(|compound| {
-                    compound.simple_selectors.iter().any(|simple| {
-                        matches!(
-                            simple,
-                            SimpleSelector::PseudoClass(PseudoClassSelector {
-                                pseudo_class: PseudoClassType::Host,
-                                ..
-                            })
-                        )
-                    })
+                    compound.simple_selectors.iter().any(may_match_host)
                 })
             })
-    })
+    }
+
+    compound_selector.simple_selectors.iter().any(may_match_host)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn compound_may_match_host_for_part_scope(compound_selector: &CompoundSelector) -> bool {
compound_selector.simple_selectors.iter().any(|simple_selector| {
let SimpleSelector::PseudoClass(pseudo_class) = simple_selector else {
return false;
};
if pseudo_class.pseudo_class == PseudoClassType::Host {
return true;
}
pseudo_class.pseudo_class == PseudoClassType::Is
&& pseudo_class.argument_selector_list.iter().any(|selector| {
selector.compound_selectors.iter().any(|compound| {
compound.simple_selectors.iter().any(|simple| {
matches!(
simple,
SimpleSelector::PseudoClass(PseudoClassSelector {
pseudo_class: PseudoClassType::Host,
..
})
)
})
})
})
})
fn compound_may_match_host_for_part_scope(compound_selector: &CompoundSelector) -> bool {
fn may_match_host(simple_selector: &SimpleSelector) -> bool {
let SimpleSelector::PseudoClass(pseudo_class) = simple_selector else {
return false;
};
if pseudo_class.pseudo_class == PseudoClassType::Host {
return true;
}
matches!(
pseudo_class.pseudo_class,
PseudoClassType::Is | PseudoClassType::Where
) && pseudo_class.argument_selector_list.iter().any(|selector| {
selector.compound_selectors.iter().any(|compound| {
compound.simple_selectors.iter().any(may_match_host)
})
})
}
compound_selector.simple_selectors.iter().any(may_match_host)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Libraries/LibWeb/Rust/src/selector_engine.rs` around lines 1257 - 1279,
Update compound_may_match_host_for_part_scope to recognize :host nested through
both PseudoClassType::Is and PseudoClassType::Where, using the same recursive
compound-selector traversal for either wrapper. Preserve direct :host detection
and return true whenever either wrapper contains a nested host selector.

@awesomekling

Copy link
Copy Markdown
Member Author

The review findings here are all pre-existing issues, and fixing them is outside the scope of a Rust port.

@awesomekling
awesomekling merged commit 498acab into LadybirdBrowser:master Jul 17, 2026
14 checks passed
@awesomekling
awesomekling deleted the sele-rs branch July 17, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant