Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.87% |
268 / 305 |
|
48.15% |
13 / 27 |
CRAP | |
0.00% |
0 / 1 |
| Matcher | |
87.87% |
268 / 305 |
|
48.15% |
13 / 27 |
266.28 | |
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 | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
15 | |||
| wordListIncludes | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| matchPseudoClass | |
90.00% |
36 / 40 |
|
0.00% |
0 / 1 |
44.85 | |||
| matchNth | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
6.97 | |||
| matchNthChild | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
11.27 | |||
| isHtmlCaseInsensitiveAttribute | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| isFormControl | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| matchDisabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| matchChecked | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| matchDefault | |
60.00% |
9 / 15 |
|
0.00% |
0 / 1 |
14.18 | |||
| isFirstSubmitInForm | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
6.22 | |||
| descendantsInOrder | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| isSubmitControl | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
4.84 | |||
| matchPlaceholderShown | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
6.07 | |||
| matchReadOnly | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| matchLink | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| matchLang | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
7.23 | |||
| matchDir | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
10 | |||
| hasMatches | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| relativeSelectorMatches | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
15 | |||
| 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 | // 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 | } |