Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.03% covered (warning)
84.03%
121 / 144
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Matcher
84.03% covered (warning)
84.03%
121 / 144
30.77% covered (danger)
30.77%
4 / 13
137.13
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
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
11
 wordListIncludes
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 matchPseudoClass
83.87% covered (warning)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
32.53
 matchNth
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 matchLang
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 hasMatches
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 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        if ($sel->caseInsensitive) {
148            $value = strtolower($value);
149            $target = strtolower($target);
150        }
151        if ($sel->matchType === AttributeMatchType::Equals) {
152            return $value === $target;
153        }
154        if ($sel->matchType === AttributeMatchType::Includes) {
155            return $this->wordListIncludes($value, $target);
156        }
157        if ($sel->matchType === AttributeMatchType::DashMatch) {
158            return $value === $target || str_starts_with($value, $target . '-');
159        }
160        if ($target === '') {
161            return false;
162        }
163        if ($sel->matchType === AttributeMatchType::PrefixMatch) {
164            return str_starts_with($value, $target);
165        }
166        if ($sel->matchType === AttributeMatchType::SuffixMatch) {
167            return str_ends_with($value, $target);
168        }
169        return str_contains($value, $target);
170    }
171
172    private function wordListIncludes(string $value, string $token): bool
173    {
174        if ($token === '' || preg_match('/\s/', $token) === 1) {
175            return false;
176        }
177        $parts = preg_split('/\s+/', trim($value)) ?: [];
178        return in_array($token, $parts, true);
179    }
180
181    private function matchPseudoClass(PseudoClassSelector $sel, MatchableElement $el): bool
182    {
183        $name = strtolower($sel->name);
184        return match ($name) {
185            'root' => $el->parentElement() === null,
186            'empty' => $el->elementChildren() === [],
187            'first-child' => $el->indexAmongSiblings() === 1,
188            'last-child' => $el->indexAmongSiblingsFromEnd() === 1,
189            'only-child' => $el->indexAmongSiblings() === 1 && $el->indexAmongSiblingsFromEnd() === 1,
190            'first-of-type' => $el->indexAmongTypeSiblings() === 1,
191            'last-of-type' => $el->indexAmongTypeSiblingsFromEnd() === 1,
192            'only-of-type' => $el->indexAmongTypeSiblings() === 1 && $el->indexAmongTypeSiblingsFromEnd() === 1,
193            'nth-child' => $this->matchNth($sel, $el, $el->indexAmongSiblings()),
194            'nth-last-child' => $this->matchNth($sel, $el, $el->indexAmongSiblingsFromEnd()),
195            'nth-of-type' => $this->matchNth($sel, $el, $el->indexAmongTypeSiblings()),
196            'nth-last-of-type' => $this->matchNth($sel, $el, $el->indexAmongTypeSiblingsFromEnd()),
197            'not' => $sel->arguments !== null
198                && !$this->listMatches($sel->arguments, $el),
199            'is', 'matches' => $sel->arguments !== null
200                && $this->listMatches($sel->arguments, $el),
201            'where' => $sel->arguments !== null
202                && $this->listMatches($sel->arguments, $el),
203            'has' => $sel->arguments !== null
204                && $this->hasMatches($sel->arguments, $el),
205            'scope' => false,
206            'lang' => $this->matchLang($sel, $el),
207            'dir' => false,
208            'host', 'host-context' => false,
209            // UI-state pseudos: print medium can't observe them. Cascade
210            // drops the rule cleanly when these don't match.
211            'hover', 'focus', 'focus-within', 'focus-visible', 'active',
212            'checked', 'disabled', 'enabled', 'required', 'optional',
213            'read-only', 'read-write', 'placeholder-shown', 'default',
214            'valid', 'invalid', 'target', 'visited', 'link' => false,
215            default => false,
216        };
217    }
218
219    private function matchNth(PseudoClassSelector $sel, MatchableElement $el, int $index): bool
220    {
221        if ($sel->anPlusB === null) {
222            return false;
223        }
224        if (!$sel->anPlusB->matches($index)) {
225            return false;
226        }
227        if ($sel->arguments !== null && !$sel->arguments->isEmpty()) {
228            // `... of S` form — additionally require the element to match S.
229            return $this->listMatches($sel->arguments, $el);
230        }
231        return true;
232    }
233
234    private function matchLang(PseudoClassSelector $sel, MatchableElement $el): bool
235    {
236        $arg = $sel->argText !== null ? strtolower(trim($sel->argText)) : '';
237        if ($arg === '') {
238            return false;
239        }
240        // Walk ancestor `lang` attributes — closest one wins.
241        for ($n = $el; $n !== null; $n = $n->parentElement()) {
242            $lang = $n->getAttributeValue('lang') ?? $n->getAttributeValue('xml:lang');
243            if ($lang === null) {
244                continue;
245            }
246            $lang = strtolower($lang);
247            if ($lang === $arg || str_starts_with($lang, $arg . '-')) {
248                return true;
249            }
250            return false;
251        }
252        return false;
253    }
254
255    private function hasMatches(SelectorList $list, MatchableElement $el): bool
256    {
257        // `:has(s)` matches when at least one descendant matches s.
258        $stack = $el->elementChildren();
259        while ($stack !== []) {
260            $node = array_shift($stack);
261            if ($this->listMatches($list, $node)) {
262                return true;
263            }
264            foreach ($node->elementChildren() as $c) {
265                $stack[] = $c;
266            }
267        }
268        return false;
269    }
270
271    private function matchPseudoElement(PseudoElementSelector $sel, MatchableElement $el): bool
272    {
273        // Pseudo-elements are virtual; the cascade attaches their rules to
274        // generated boxes rather than to host elements. From the perspective
275        // of "does this element match the selector," they're match-true on
276        // the element they're attached to. Refined in the cascade in 1D.3.
277        return true;
278    }
279}