Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.87% covered (warning)
87.87%
268 / 305
48.15% covered (danger)
48.15%
13 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Matcher
87.87% covered (warning)
87.87%
268 / 305
48.15% covered (danger)
48.15%
13 / 27
266.28
0.00% covered (danger)
0.00%
0 / 1
 listMatches
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 complexMatches
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 matchAt
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
17.21
 compoundMatches
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 simpleMatches
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 matchType
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 matchAttribute
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
15
 wordListIncludes
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 matchPseudoClass
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
44.85
 matchNth
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 matchNthChild
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
11.27
 isHtmlCaseInsensitiveAttribute
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isFormControl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 matchDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 matchChecked
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 matchDefault
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
14.18
 isFirstSubmitInForm
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 descendantsInOrder
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 isSubmitControl
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 matchPlaceholderShown
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 matchReadOnly
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 matchLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 matchLang
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 matchDir
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
 hasMatches
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 relativeSelectorMatches
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
15
 matchPseudoElement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Selector;
6
7/**
8 * Selectors-4 matching engine. Operates on `MatchableElement`s so any DOM
9 * implementation can plug in.
10 *
11 * The right-most compound of a complex selector is the "subject" — the
12 * compound that must match the element passed in. Combinators read
13 * right-to-left, hopping to ancestors / preceding siblings as needed.
14 *
15 * Phase 1D.2 covers the structural / attribute / common pseudo-class matchers.
16 * Stateful pseudo-classes that depend on UI state (`:hover`, `:focus`,
17 * `:active`, `:checked`, `:disabled`) return false for now — print rendering
18 * doesn't observe those states. The matcher leaves them as forward-compat
19 * extension points so the cascade can drop the unmatching rule cleanly.
20 */
21final class Matcher
22{
23    /**
24     * Does any selector in `$list` match `$element`?
25     */
26    public function listMatches(SelectorList $list, MatchableElement $element): bool
27    {
28        foreach ($list->selectors as $sel) {
29            if ($this->complexMatches($sel, $element)) {
30                return true;
31            }
32        }
33        return false;
34    }
35
36    public function complexMatches(ComplexSelector $complex, MatchableElement $element): bool
37    {
38        $n = count($complex->compounds);
39        if ($n === 0) {
40            return false;
41        }
42        // Walk right-to-left starting from the subject (last compound).
43        return $this->matchAt($complex->compounds, $n - 1, $element);
44    }
45
46    /**
47     * @param list<CompoundSelectorWithCombinator> $compounds
48     */
49    private function matchAt(array $compounds, int $index, MatchableElement $element): bool
50    {
51        $part = $compounds[$index];
52        if (!$this->compoundMatches($part->compound, $element)) {
53            return false;
54        }
55        if ($index === 0) {
56            return true;
57        }
58        $combinator = $compounds[$index - 1]->combinatorToNext;
59        $nextIndex = $index - 1;
60        switch ($combinator) {
61            case Combinator::Descendant:
62                for ($p = $element->parentElement(); $p !== null; $p = $p->parentElement()) {
63                    if ($this->matchAt($compounds, $nextIndex, $p)) {
64                        return true;
65                    }
66                }
67                return false;
68            case Combinator::Child:
69                $p = $element->parentElement();
70                return $p !== null && $this->matchAt($compounds, $nextIndex, $p);
71            case Combinator::NextSibling:
72                $s = $element->previousElementSibling();
73                return $s !== null && $this->matchAt($compounds, $nextIndex, $s);
74            case Combinator::SubsequentSibling:
75                for ($s = $element->previousElementSibling(); $s !== null; $s = $s->previousElementSibling()) {
76                    if ($this->matchAt($compounds, $nextIndex, $s)) {
77                        return true;
78                    }
79                }
80                return false;
81            case Combinator::Column:
82                // Column combinator is rarely used and requires table-layout
83                // semantics; treat as non-matching for now.
84                return false;
85            case null:
86                // Should not occur for $index > 0.
87                return false;
88        }
89        return false;
90    }
91
92    public function compoundMatches(CompoundSelector $compound, MatchableElement $element): bool
93    {
94        foreach ($compound->components as $simple) {
95            if (!$this->simpleMatches($simple, $element)) {
96                return false;
97            }
98        }
99        return true;
100    }
101
102    public function simpleMatches(SimpleSelector $simple, MatchableElement $element): bool
103    {
104        return match (true) {
105            $simple instanceof TypeSelector => $this->matchType($simple, $element),
106            $simple instanceof UniversalSelector => true,
107            $simple instanceof IdSelector => $element->elementId() === $simple->id,
108            $simple instanceof ClassSelector => in_array(
109                $simple->className,
110                $element->classes(),
111                true,
112            ),
113            $simple instanceof AttributeSelector => $this->matchAttribute($simple, $element),
114            $simple instanceof PseudoClassSelector => $this->matchPseudoClass($simple, $element),
115            $simple instanceof PseudoElementSelector => $this->matchPseudoElement($simple, $element),
116            default => false,
117        };
118    }
119
120    private function matchType(TypeSelector $sel, MatchableElement $el): bool
121    {
122        if (strcasecmp($sel->localName, $el->localName()) !== 0) {
123            return false;
124        }
125        if ($sel->namespacePrefix === null || $sel->namespacePrefix === '*') {
126            return true;
127        }
128        // Resolved namespace check would consult an @namespace registry;
129        // 1D.2 ships the structural matcher and treats unknown prefixes as
130        // pass-through. Empty prefix `|tag` requires null namespace.
131        if ($sel->namespacePrefix === '') {
132            return $el->namespaceUri() === null;
133        }
134        return true;
135    }
136
137    private function matchAttribute(AttributeSelector $sel, MatchableElement $el): bool
138    {
139        if ($sel->matchType === AttributeMatchType::Exists) {
140            return $el->hasAttribute($sel->name);
141        }
142        $value = $el->getAttributeValue($sel->name);
143        if ($value === null) {
144            return false;
145        }
146        $target = $sel->value ?? '';
147        // CSS Selectors 4 §6.6 — attribute selectors are case-
148        // sensitive by default but HTML user agents treat a fixed
149        // list of attributes as ASCII-case-insensitive. Tri-state:
150        //   explicit `i` (caseInsensitive=true)   → case-insensitive
151        //   explicit `s` (caseInsensitive=false)  → case-sensitive
152        //   no flag       (caseInsensitive=null)  → HTML list lookup
153        $caseInsensitive = match ($sel->caseInsensitive) {
154            true => true,
155            false => false,
156            null => $sel->value !== null
157                && $this->isHtmlCaseInsensitiveAttribute($el, $sel->name),
158        };
159        if ($caseInsensitive) {
160            $value = strtolower($value);
161            $target = strtolower($target);
162        }
163        if ($sel->matchType === AttributeMatchType::Equals) {
164            return $value === $target;
165        }
166        if ($sel->matchType === AttributeMatchType::Includes) {
167            return $this->wordListIncludes($value, $target);
168        }
169        if ($sel->matchType === AttributeMatchType::DashMatch) {
170            return $value === $target || str_starts_with($value, $target . '-');
171        }
172        if ($target === '') {
173            return false;
174        }
175        if ($sel->matchType === AttributeMatchType::PrefixMatch) {
176            return str_starts_with($value, $target);
177        }
178        if ($sel->matchType === AttributeMatchType::SuffixMatch) {
179            return str_ends_with($value, $target);
180        }
181        return str_contains($value, $target);
182    }
183
184    private function wordListIncludes(string $value, string $token): bool
185    {
186        if ($token === '' || preg_match('/\s/', $token) === 1) {
187            return false;
188        }
189        $parts = preg_split('/\s+/', trim($value)) ?: [];
190        return in_array($token, $parts, true);
191    }
192
193    private function matchPseudoClass(PseudoClassSelector $sel, MatchableElement $el): bool
194    {
195        $name = strtolower($sel->name);
196        return match ($name) {
197            'root' => $el->parentElement() === null,
198            'empty' => $el->elementChildren() === [],
199            'first-child' => $el->indexAmongSiblings() === 1,
200            'last-child' => $el->indexAmongSiblingsFromEnd() === 1,
201            'only-child' => $el->indexAmongSiblings() === 1 && $el->indexAmongSiblingsFromEnd() === 1,
202            'first-of-type' => $el->indexAmongTypeSiblings() === 1,
203            'last-of-type' => $el->indexAmongTypeSiblingsFromEnd() === 1,
204            'only-of-type' => $el->indexAmongTypeSiblings() === 1 && $el->indexAmongTypeSiblingsFromEnd() === 1,
205            'nth-child' => $this->matchNthChild($sel, $el, fromEnd: false),
206            'nth-last-child' => $this->matchNthChild($sel, $el, fromEnd: true),
207            'nth-of-type' => $this->matchNth($sel, $el, $el->indexAmongTypeSiblings()),
208            'nth-last-of-type' => $this->matchNth($sel, $el, $el->indexAmongTypeSiblingsFromEnd()),
209            'not' => $sel->arguments !== null
210                && !$this->listMatches($sel->arguments, $el),
211            'is', 'matches' => $sel->arguments !== null
212                && $this->listMatches($sel->arguments, $el),
213            'where' => $sel->arguments !== null
214                && $this->listMatches($sel->arguments, $el),
215            'has' => $sel->arguments !== null
216                && $this->hasMatches($sel->arguments, $el),
217            // CSS Selectors 4 §13.5 — `:scope` matches the scoping
218            // element. Without an explicit @scope root, the
219            // document's root element is the scope, so treat
220            // `:scope` like `:root`.
221            'scope' => $el->parentElement() === null,
222            'lang' => $this->matchLang($sel, $el),
223            // CSS Selectors 4 §15.2 — `:dir(ltr)` / `:dir(rtl)`
224            // matches when the closest ancestor with a `dir=` attr
225            // declares the requested direction (HTML §3.2.6.4).
226            'dir' => $this->matchDir($sel, $el),
227            'host', 'host-context' => false,
228            // CSS Selectors 4 §10.4 — `:link` / `:any-link` match an
229            // <a>, <area>, or <link> element with an href attribute.
230            // `:any-link` also matches `:visited`; print medium can't
231            // observe visit state so the two collapse here.
232            'link', 'any-link' => $this->matchLink($el),
233            // CSS Selectors 4 §11.2 — `:disabled` / `:enabled` are
234            // static attribute-driven on form controls.
235            'disabled' => $this->matchDisabled($el),
236            'enabled' => $this->isFormControl($el) && !$this->matchDisabled($el),
237            // CSS Selectors 4 §11.3 — `:checked` matches a checkbox /
238            // radio / option element with the `checked` (or `selected`
239            // for <option>) attribute set. Static; doesn't need a
240            // user-state observation.
241            'checked' => $this->matchChecked($el),
242            // CSS Selectors 4 §11.5 — `:required` / `:optional` reflect
243            // the static `required` attribute on form controls.
244            'required' => $this->isFormControl($el) && $el->hasAttribute('required'),
245            'optional' => $this->isFormControl($el) && !$el->hasAttribute('required'),
246            // CSS Selectors 4 §11.6 — `:read-only` / `:read-write`
247            // reflect the static `readonly` / `disabled` state.
248            'read-only' => $this->matchReadOnly($el),
249            'read-write' => $this->isFormControl($el) && !$this->matchReadOnly($el),
250            // CSS Selectors 4 §11.7 — `:placeholder-shown` matches a
251            // form control with a `placeholder` attribute and an
252            // empty `value` (= the placeholder is currently visible).
253            // For a static print render the document attribute state
254            // is what we observe.
255            'placeholder-shown' => $this->matchPlaceholderShown($el),
256            // CSS Selectors 4 §11.4 — `:default`. Matches the
257            // default checkbox / radio / option (those with
258            // `checked` or `selected` set in markup) and the form's
259            // default-submit button. All four are observable from
260            // document attribute state.
261            'default' => $this->matchDefault($el),
262            // Remaining UI-state pseudos: print medium can't observe
263            // them. Cascade drops the rule cleanly when these don't
264            // match.
265            'hover', 'focus', 'focus-within', 'focus-visible', 'active',
266            'valid', 'invalid', 'target', 'visited',
267            'user-valid', 'user-invalid' => false,
268            default => false,
269        };
270    }
271
272    private function matchNth(PseudoClassSelector $sel, MatchableElement $el, int $index): bool
273    {
274        if ($sel->anPlusB === null) {
275            return false;
276        }
277        if (!$sel->anPlusB->matches($index)) {
278            return false;
279        }
280        if ($sel->arguments !== null && !$sel->arguments->isEmpty()) {
281            // `... of S` form — additionally require the element to match S.
282            return $this->listMatches($sel->arguments, $el);
283        }
284        return true;
285    }
286
287    /**
288     * CSS Selectors 4 §6.4 — `:nth-child(An+B [of S]?)` /
289     * `:nth-last-child(...)`. When the optional `of S` clause is
290     * present the An+B index is computed over the subset of
291     * siblings that also match S, not over all element children.
292     */
293    private function matchNthChild(PseudoClassSelector $sel, MatchableElement $el, bool $fromEnd): bool
294    {
295        if ($sel->anPlusB === null) {
296            return false;
297        }
298        if ($sel->arguments === null || $sel->arguments->isEmpty()) {
299            $index = $fromEnd
300                ? $el->indexAmongSiblingsFromEnd()
301                : $el->indexAmongSiblings();
302            return $sel->anPlusB->matches($index);
303        }
304        // `of S` form: the element must match S, and its index
305        // within the subset of S-matching siblings is what An+B
306        // compares against.
307        if (!$this->listMatches($sel->arguments, $el)) {
308            return false;
309        }
310        $parent = $el->parentElement();
311        if ($parent === null) {
312            return false;
313        }
314        $siblings = $parent->elementChildren();
315        if ($fromEnd) {
316            $siblings = array_reverse($siblings);
317        }
318        $index = 0;
319        foreach ($siblings as $sibling) {
320            if (!$this->listMatches($sel->arguments, $sibling)) {
321                continue;
322            }
323            $index++;
324            if ($sibling === $el) {
325                return $sel->anPlusB->matches($index);
326            }
327        }
328        return false;
329    }
330
331    /**
332     * Per HTML §3.2.6.1 (and the Selectors 4 §6.6 cross-reference),
333     * a fixed list of attributes are matched case-insensitively when
334     * the element is in the HTML namespace. Anything outside this
335     * list keeps the default case-sensitive comparison.
336     */
337    private function isHtmlCaseInsensitiveAttribute(MatchableElement $el, string $name): bool
338    {
339        $ns = $el->namespaceUri();
340        if ($ns !== null && $ns !== 'http://www.w3.org/1999/xhtml') {
341            return false;
342        }
343        static $list = null;
344        if ($list === null) {
345            $list = array_flip([
346                'accept', 'accept-charset', 'align', 'alink', 'axis', 'bgcolor',
347                'charset', 'checked', 'clear', 'codetype', 'color', 'compact',
348                'declare', 'defer', 'dir', 'direction', 'disabled', 'enctype',
349                'face', 'frame', 'frameborder', 'hreflang', 'http-equiv',
350                'lang', 'language', 'link', 'media', 'method', 'multiple',
351                'nohref', 'noresize', 'noshade', 'nowrap', 'readonly', 'rel',
352                'rev', 'rules', 'scope', 'scrolling', 'selected', 'shape',
353                'target', 'text', 'type', 'valign', 'valuetype', 'vlink',
354            ]);
355        }
356        return isset($list[strtolower($name)]);
357    }
358
359    /**
360     * Form-control element check used by the static UI-state
361     * pseudos (`:enabled`, `:required`, `:optional`, `:read-write`).
362     * Per HTML §15.3 the controllable elements are the form
363     * elements that accept these attributes.
364     */
365    private function isFormControl(MatchableElement $el): bool
366    {
367        return in_array(strtolower($el->localName()), [
368            'input', 'select', 'textarea', 'button',
369            'fieldset', 'option', 'optgroup',
370        ], true);
371    }
372
373    /**
374     * `:disabled` — the element has the `disabled` attribute
375     * present (HTML §15.3) AND is a form control.
376     */
377    private function matchDisabled(MatchableElement $el): bool
378    {
379        return $this->isFormControl($el) && $el->hasAttribute('disabled');
380    }
381
382    /**
383     * `:checked` — checkboxes/radios with the `checked` attribute,
384     * or `<option>` elements with the `selected` attribute.
385     */
386    private function matchChecked(MatchableElement $el): bool
387    {
388        $tag = strtolower($el->localName());
389        if ($tag === 'input') {
390            $type = strtolower($el->getAttributeValue('type') ?? 'text');
391            if ($type === 'checkbox' || $type === 'radio') {
392                return $el->hasAttribute('checked');
393            }
394            return false;
395        }
396        if ($tag === 'option') {
397            return $el->hasAttribute('selected');
398        }
399        return false;
400    }
401
402    /**
403     * `:default` — matches the default option in a group of related
404     * controls. For static documents this collapses to:
405     *
406     *   - <input type="checkbox|radio"> with `checked` attribute
407     *   - <option> with `selected` attribute
408     *   - <input type="submit"> / <button type="submit"|empty> that
409     *     is the first such submit control inside a form
410     *
411     * The third case is the subtle one — the "default submit
412     * button" is whichever form-submit control appears first in
413     * document order. We approximate by walking the form ancestor
414     * to confirm we ARE the first submit-capable descendant.
415     */
416    private function matchDefault(MatchableElement $el): bool
417    {
418        $tag = strtolower($el->localName());
419        if ($tag === 'option') {
420            return $el->hasAttribute('selected');
421        }
422        if ($tag === 'input') {
423            $type = strtolower($el->getAttributeValue('type') ?? 'text');
424            if ($type === 'checkbox' || $type === 'radio') {
425                return $el->hasAttribute('checked');
426            }
427            if ($type === 'submit' || $type === 'image') {
428                return $this->isFirstSubmitInForm($el);
429            }
430            return false;
431        }
432        if ($tag === 'button') {
433            $type = strtolower($el->getAttributeValue('type') ?? 'submit');
434            if ($type === 'submit') {
435                return $this->isFirstSubmitInForm($el);
436            }
437        }
438        return false;
439    }
440
441    /**
442     * Walk to the nearest form ancestor and check whether this
443     * element is the first submit-capable descendant in document
444     * order.
445     */
446    private function isFirstSubmitInForm(MatchableElement $el): bool
447    {
448        $form = $el;
449        while ($form !== null) {
450            if (strtolower($form->localName()) === 'form') {
451                break;
452            }
453            $form = $form->parentElement();
454        }
455        if ($form === null) {
456            return false;
457        }
458        foreach ($this->descendantsInOrder($form) as $candidate) {
459            if ($this->isSubmitControl($candidate)) {
460                return $candidate === $el;
461            }
462        }
463        return false;
464    }
465
466    /**
467     * @return iterable<MatchableElement>
468     */
469    private function descendantsInOrder(MatchableElement $root): iterable
470    {
471        $stack = [$root];
472        while ($stack !== []) {
473            $node = array_shift($stack);
474            // Pre-order: yield $node first, then push children.
475            // Skip the root itself.
476            if ($node !== $root) {
477                yield $node;
478            }
479            $children = $node->elementChildren();
480            // Prepend in reverse so the first child is processed
481            // first.
482            for ($i = count($children) - 1; $i >= 0; $i--) {
483                array_unshift($stack, $children[$i]);
484            }
485        }
486    }
487
488    private function isSubmitControl(MatchableElement $el): bool
489    {
490        $tag = strtolower($el->localName());
491        if ($tag === 'button') {
492            $type = strtolower($el->getAttributeValue('type') ?? 'submit');
493            return $type === 'submit';
494        }
495        if ($tag === 'input') {
496            $type = strtolower($el->getAttributeValue('type') ?? 'text');
497            return $type === 'submit' || $type === 'image';
498        }
499        return false;
500    }
501
502    /**
503     * `:placeholder-shown` — matches an `<input>` or `<textarea>`
504     * with a non-empty `placeholder` attribute and an empty (or
505     * absent) `value` attribute. For static print rendering the
506     * document's attribute state is what we observe.
507     */
508    private function matchPlaceholderShown(MatchableElement $el): bool
509    {
510        $tag = strtolower($el->localName());
511        if ($tag !== 'input' && $tag !== 'textarea') {
512            return false;
513        }
514        $placeholder = $el->getAttributeValue('placeholder');
515        if ($placeholder === null || $placeholder === '') {
516            return false;
517        }
518        $value = $el->getAttributeValue('value');
519        return $value === null || $value === '';
520    }
521
522    /**
523     * `:read-only` — read-write controls become read-only when they
524     * carry `readonly` or `disabled`. Non-form-control elements are
525     * read-only by default (since they're not editable at all),
526     * matching browser behaviour.
527     */
528    private function matchReadOnly(MatchableElement $el): bool
529    {
530        if (!$this->isFormControl($el)) {
531            return true;
532        }
533        return $el->hasAttribute('readonly') || $el->hasAttribute('disabled');
534    }
535
536    /**
537     * CSS Selectors 4 §10.4.1 — `:link`. Matches an HTML
538     * `<a>`, `<area>`, or `<link>` element that carries an
539     * `href` attribute.
540     */
541    private function matchLink(MatchableElement $el): bool
542    {
543        $tag = strtolower($el->localName());
544        if (!in_array($tag, ['a', 'area', 'link'], true)) {
545            return false;
546        }
547        return $el->getAttributeValue('href') !== null;
548    }
549
550    private function matchLang(PseudoClassSelector $sel, MatchableElement $el): bool
551    {
552        $arg = $sel->argText !== null ? strtolower(trim($sel->argText)) : '';
553        if ($arg === '') {
554            return false;
555        }
556        // Walk ancestor `lang` attributes — closest one wins.
557        for ($n = $el; $n !== null; $n = $n->parentElement()) {
558            $lang = $n->getAttributeValue('lang') ?? $n->getAttributeValue('xml:lang');
559            if ($lang === null) {
560                continue;
561            }
562            $lang = strtolower($lang);
563            if ($lang === $arg || str_starts_with($lang, $arg . '-')) {
564                return true;
565            }
566            return false;
567        }
568        return false;
569    }
570
571    /**
572     * CSS Selectors 4 §15.2 — `:dir(ltr)` / `:dir(rtl)`. The
573     * direction comes from the closest ancestor `dir=` attribute
574     * (HTML §3.2.6.4). When no ancestor sets dir, defaults to
575     * `ltr` per the HTML spec.
576     */
577    private function matchDir(PseudoClassSelector $sel, MatchableElement $el): bool
578    {
579        $arg = $sel->argText !== null ? strtolower(trim($sel->argText)) : '';
580        if ($arg !== 'ltr' && $arg !== 'rtl' && $arg !== 'auto') {
581            return false;
582        }
583        for ($n = $el; $n !== null; $n = $n->parentElement()) {
584            $dir = $n->getAttributeValue('dir');
585            if ($dir === null) {
586                continue;
587            }
588            $dir = strtolower(trim($dir));
589            if ($dir === 'ltr' || $dir === 'rtl' || $dir === 'auto') {
590                return $dir === $arg;
591            }
592            // Invalid value — fall through to next ancestor.
593        }
594        // No dir set anywhere — HTML default is ltr.
595        return $arg === 'ltr';
596    }
597
598    private function hasMatches(SelectorList $list, MatchableElement $el): bool
599    {
600        // `:has(s)` is a relative selector — its inner ComplexSelectors
601        // may carry a `leadingCombinator` that constrains the search
602        // (CSS Selectors 4 §17.5). Dispatch per selector:
603        //
604        //   Child (>)          → only direct children of $el
605        //   NextSibling (+)    → the immediately-following sibling
606        //   SubsequentSibling  → siblings after $el in the tree
607        //   Descendant (null)  → all descendants (v1 behaviour)
608        //
609        // The list as a whole matches if any of its branches matches.
610        foreach ($list->selectors as $branch) {
611            $combinator = $branch->leadingCombinator;
612            $branchList = new SelectorList($branch->text, [$branch]);
613            if ($this->relativeSelectorMatches($branchList, $branch, $combinator, $el)) {
614                return true;
615            }
616        }
617        return false;
618    }
619
620    private function relativeSelectorMatches(
621        SelectorList $list,
622        ComplexSelector $branch,
623        ?Combinator $combinator,
624        MatchableElement $el,
625    ): bool {
626        switch ($combinator) {
627            case Combinator::Child:
628                foreach ($el->elementChildren() as $child) {
629                    if ($this->listMatches($list, $child)) {
630                        return true;
631                    }
632                }
633                return false;
634            case Combinator::NextSibling:
635                $next = $el->nextElementSibling();
636                if ($next === null) {
637                    return false;
638                }
639                return $this->listMatches($list, $next);
640            case Combinator::SubsequentSibling:
641                $sib = $el->nextElementSibling();
642                while ($sib !== null) {
643                    if ($this->listMatches($list, $sib)) {
644                        return true;
645                    }
646                    $sib = $sib->nextElementSibling();
647                }
648                return false;
649            case Combinator::Descendant:
650            case null:
651            default:
652                unset($branch);
653                // Walk all descendants.
654                $stack = $el->elementChildren();
655                while ($stack !== []) {
656                    $node = array_shift($stack);
657                    if ($this->listMatches($list, $node)) {
658                        return true;
659                    }
660                    foreach ($node->elementChildren() as $c) {
661                        $stack[] = $c;
662                    }
663                }
664                return false;
665        }
666    }
667
668    private function matchPseudoElement(PseudoElementSelector $sel, MatchableElement $el): bool
669    {
670        // Pseudo-elements are virtual; the cascade attaches their rules to
671        // generated boxes rather than to host elements. From the perspective
672        // of "does this element match the selector," they're match-true on
673        // the element they're attached to. Refined in the cascade in 1D.3.
674        return true;
675    }
676}