Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.03% |
121 / 144 |
|
30.77% |
4 / 13 |
CRAP | |
0.00% |
0 / 1 |
| Matcher | |
84.03% |
121 / 144 |
|
30.77% |
4 / 13 |
137.13 | |
0.00% |
0 / 1 |
| listMatches | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| complexMatches | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| matchAt | |
78.57% |
22 / 28 |
|
0.00% |
0 / 1 |
17.21 | |||
| compoundMatches | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| simpleMatches | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
9 | |||
| matchType | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
6.97 | |||
| matchAttribute | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
11 | |||
| wordListIncludes | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| matchPseudoClass | |
83.87% |
26 / 31 |
|
0.00% |
0 / 1 |
32.53 | |||
| matchNth | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
5.58 | |||
| matchLang | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
7.23 | |||
| hasMatches | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
4.25 | |||
| matchPseudoElement | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 21 | final 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 | } |