Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.21% covered (warning)
76.21%
583 / 765
56.52% covered (warning)
56.52%
26 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZestInst
76.21% covered (warning)
76.21%
583 / 765
56.52% covered (warning)
56.52%
26 / 46
1053.28
0.00% covered (danger)
0.00%
0 / 1
 sort
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 next
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 prev
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 child
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 lastChild
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 parentIsElement
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 nodeIsDocument
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 unichr
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 unquote
60.00% covered (warning)
60.00%
12 / 20
0.00% covered (danger)
0.00%
0 / 1
12.10
 decodeid
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 encodeid
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 makeInside
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 reSource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replace
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 truncateUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 xpathQuote
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getElementsById
66.67% covered (warning)
66.67%
16 / 24
0.00% covered (danger)
0.00%
0 / 1
17.33
 docFragHelper
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 getElementsByTagName
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
9.16
 getElementsByClassName
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
4.06
 parseNth
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 nth
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
10.09
 addSelector0
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addSelector1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initSelectors
70.53% covered (warning)
70.53%
146 / 207
0.00% covered (danger)
0.00%
0 / 1
69.17
 selectorsAttr
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 addOperator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initOperators
67.35% covered (warning)
67.35%
33 / 49
0.00% covered (danger)
0.00%
0 / 1
17.01
 addCombinator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initCombinators
94.87% covered (success)
94.87%
37 / 39
0.00% covered (danger)
0.00%
0 / 1
12.02
 makeRef
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 initRules
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 compile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 doCompile
68.75% covered (warning)
68.75%
44 / 64
0.00% covered (danger)
0.00%
0 / 1
21.87
 tokQname
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 tok
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
10.09
 makeSimple
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 makeTest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 makeSubject
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 compileGroup
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 findInternal
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 find
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 matches
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 newBadSelectorException
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isStandardsMode
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace Wikimedia\Zest;
4
5use DOMDocument;
6use DOMDocumentFragment;
7use DOMElement;
8use DOMNode;
9use InvalidArgumentException;
10use Throwable;
11
12/**
13 * Zest.php (https://github.com/wikimedia/zest.php)
14 * Copyright (c) 2019, C. Scott Ananian. (MIT licensed)
15 * PHP port based on:
16 *
17 * Zest (https://github.com/chjj/zest)
18 * A css selector engine.
19 * Copyright (c) 2011-2012, Christopher Jeffrey. (MIT Licensed)
20 * Domino version based on Zest v0.1.3 with bugfixes applied.
21 */
22
23class ZestInst {
24
25    /** @var ZestFunc[] */
26    private $compileCache = [];
27
28    /**
29     * Helpers
30     */
31
32    /**
33     * Sort query results in document order.
34     * @param array &$results
35     * @param bool $isStandardsMode
36     */
37    private static function sort( &$results, bool $isStandardsMode ): void {
38        if ( count( $results ) < 2 ) {
39            return;
40        }
41        if ( $isStandardsMode ) {
42            // DOM spec-compliant version:
43            usort( $results, static function ( $a, $b ) {
44                return ( $a->compareDocumentPosition( $b ) & 2 ) ? 1 : -1;
45            } );
46        }
47        // PHP's dom extension returns true for method_exists on
48        // compareDocumentPosition, but when called it throws a
49        // "Not yet implemented" exception
50
51        // If compareDocumentPosition isn't implemented, skip the sort.
52        // Our results are generally added as a result of an in-order
53        // traversal of the tree, so absent any funny business with complex
54        // selectors, our natural order should be more-or-less sorted.
55    }
56
57    /**
58     * @param DOMNode $el
59     * @return ?DOMNode
60     */
61    private static function next( $el ) {
62        while ( ( $el = $el->nextSibling ) && $el->nodeType !== 1 ) {
63            // no op
64        }
65        return $el;
66    }
67
68    /**
69     * @param DOMNode $el
70     * @return ?DOMNode
71     */
72    private static function prev( $el ) {
73        while ( ( $el = $el->previousSibling ) && $el->nodeType !== 1 ) {
74            // no op
75        }
76        return $el;
77    }
78
79    /**
80     * @param DOMNode $el
81     * @return ?DOMNode
82     */
83    private static function child( $el ) {
84        if ( $el = $el->firstChild ) {
85            while ( $el->nodeType !== 1 && ( $el = $el->nextSibling ) ) {
86                // no op
87            }
88        }
89        return $el;
90    }
91
92    /**
93     * @param DOMNode $el
94     * @return ?DOMNode
95     */
96    private static function lastChild( $el ) {
97        if ( $el = $el->lastChild ) {
98            while ( $el->nodeType !== 1 && ( $el = $el->previousSibling ) ) {
99                // no op
100            }
101        }
102        return $el;
103    }
104
105    /**
106     * @param DOMNode $n
107     * @return bool
108     */
109    private static function parentIsElement( $n ): bool {
110        $parent = $n->parentNode;
111        if ( !$parent ) {
112            return false;
113        }
114        // The root `html` element (node type 9) can be a first- or
115        // last-child, too, which means that the document (or document
116        // fragment) counts as an "element".
117        return $parent->nodeType === 1 /* Element */ ||
118            self::nodeIsDocument( $parent ) /* Document */ ||
119            $parent->nodeType === 11; /* DocumentFragment */
120    }
121
122    /**
123     * @param DOMNode $n
124     * @return bool
125     */
126    private static function nodeIsDocument( $n ): bool {
127        $nodeType = $n->nodeType;
128        return $nodeType === 9 /* Document */ ||
129            // In PHP, if you load a document with
130            // DOMDocument::loadHTML, your root DOMDocument will have node
131            // type 13 (!) which is PHP's bespoke "XML_HTML_DOCUMENT_NODE"
132            // and Not A Real Thing.  But we'll recognize it anyway...
133            $nodeType === 13; /* HTMLDocument */
134    }
135
136    private static function unichr( int $codepoint ): string {
137        if ( extension_loaded( 'intl' ) ) {
138            return \IntlChar::chr( $codepoint );
139        } else {
140            return mb_chr( $codepoint, "utf-8" );
141        }
142    }
143
144    private static function unquote( string $str ): string {
145        if ( !$str ) {
146            return $str;
147        }
148        self::initRules();
149        $ch = $str[ 0 ];
150        if ( $ch === '"' || $ch === "'" ) {
151            if ( substr( $str, -1 ) === $ch ) {
152                $str = substr( $str, 1, -1 );
153            } else {
154                // bad string.
155                $str = substr( $str, 1 );
156            }
157            return preg_replace_callback( self::$rules->str_escape, function ( array $matches ) {
158                $s = $matches[0];
159                if ( !preg_match( '/^\\\(?:([0-9A-Fa-f]+)|([\r\n\f]+))/', $s, $m ) ) {
160                    return substr( $s, 1 );
161                }
162                if ( $m[ 2 ] ) {
163                    return ''; /* escaped newlines are ignored in strings. */
164                }
165                $cp = intval( $m[ 1 ], 16 );
166                return self::unichr( $cp );
167            }, $str );
168        } elseif ( preg_match( self::$rules->ident, $str ) ) {
169            return self::decodeid( $str );
170        } else {
171            // NUMBER, PERCENTAGE, DIMENSION, etc
172            return $str;
173        }
174    }
175
176    private static function decodeid( string $str ): string {
177        return preg_replace_callback( self::$rules->escape, function ( array $matches ) {
178            $s = $matches[0];
179            if ( !preg_match( '/^\\\([0-9A-Fa-f]+)/', $s, $m ) ) {
180                return $s[ 1 ];
181            }
182            $cp = intval( $m[ 1 ], 16 );
183            return self::unichr( $cp );
184        }, $str );
185    }
186
187    /**
188     * Escape an identifier for CSS.
189     * This is equivalent to CSS.escape
190     * (https://drafts.csswg.org/cssom/#the-css.escape()-method)
191     * and is the opposite of self::decodeid().
192     */
193    private static function encodeid( string $str ): string {
194        return preg_replace_callback( '/(\\x00)|([\\x01-\\x1F\\x7F])|(^[0-9])|(^-[0-9])|(^-$)|([^-A-Za-z0-9_\\x{80}-\\x{10FFFF}])/u', static function ( array $matches ) {
195            if ( isset( $matches[1] ) ) {
196                return "\u{FFFD}";
197            } elseif ( isset( $matches[2] ) || isset( $matches[3] ) ) {
198                $cp = mb_ord( $matches[0], "UTF-8" );
199                return '\\' . dechex( $cp ) . ' ';
200            } elseif ( isset( $matches[4] ) ) {
201                $cp = mb_ord( $matches[0][1], "UTF-8" );
202                return '-\\' . dechex( $cp ) . ' ';
203            } else {
204                return '\\' . $matches[0];
205            }
206        }, $str, -1, $ignore, PREG_UNMATCHED_AS_NULL );
207    }
208
209    private static function makeInside( string $start, string $end ): string {
210        $regex = preg_replace(
211            '/>/', $end, preg_replace(
212                '/</', $start, self::reSource( self::$rules->inside )
213            )
214        );
215        return '/' . $regex . '/Su';
216    }
217
218    private static function reSource( string $regex ): string {
219        // strip delimiter and flags from regular expression
220        return preg_replace( '/(^\/)|(\/[a-z]*$)/Diu', '', $regex );
221    }
222
223    private static function replace( string $regex, string $name, string $val ): string {
224        $regex = self::reSource( $regex );
225        $regex = str_replace( $name, self::reSource( $val ), $regex );
226        return '/' . $regex . '/Su';
227    }
228
229    private static function truncateUrl( string $url, int $num ): string {
230        $url = preg_replace( '/^(?:\w+:\/\/|\/+)/', '', $url );
231        $url = preg_replace( '/(?:\/+|\/*#.*?)$/', '', $url );
232        return implode( '/', explode( '/', $url, $num ) );
233    }
234
235    private static function xpathQuote( string $s ): string {
236        // Ugly-but-functional escape mechanism for xpath query
237        $parts = explode( "'", $s );
238        $parts = array_map( static function ( string $ss ) {
239            return "'$ss'";
240        }, $parts );
241        if ( count( $parts ) === 1 ) {
242            return $parts[0];
243        } else {
244            return 'concat(' . implode( ',"\'",', $parts ) . ')';
245        }
246    }
247
248    /**
249     * Get descendants by ID.
250     *
251     * The PHP DOM doesn't provide this method for DOMElement, and the
252     * implementation in DOMDocument is broken.
253     *
254     * Further, the web spec only provides for returning a single element
255     * here.  This function can support returning *all* of the matches for
256     * a given ID, if the underlying DOM implementation supports this.
257     *
258     * This is an *exclusive* query; that is, $context should never be included
259     * among the results.
260     *
261     * Although a `getElementsById` key can be passed in the options array
262     * to override the default implementation, for efficiency it is recommended
263     * that clients subclass ZestInst and override this entire method if
264     * they can provide an efficient id index.
265     *
266     * @param DOMDocument|DOMDocumentFragment|DOMElement $context
267     *   The scoping root for the search
268     * @param string $id
269     * @param array $opts Additional match-context options (optional)
270     * @return array<DOMElement> A list of the elements with the given ID. When there are more
271     *   than one, this method might return all of them or only the first one.
272     */
273    public function getElementsById( $context, string $id, array $opts = [] ): array {
274        if ( is_callable( $opts['getElementsById'] ?? null ) ) {
275            // Third-party DOM implementation might provide a way to
276            // get multiple results for a given ID.
277            // Note that this must work for DocumentFragment and Element
278            // as well!
279            $func = $opts['getElementsById'];
280            return $func( $context, $id );
281        }
282        // Neither PHP nor the web standards provide an DOMElement-scoped
283        // version of getElementById, so we can't call this directly on
284        // $context -- but that's okay because (1) IDs should be unique, and
285        // (2) we verify the scope of the returned element below
286        // anyway (to work around bugs with deleted-but-not-gc'ed
287        // nodes).
288        $doc = self::nodeIsDocument( $context ) ?
289            $context : $context->ownerDocument;
290        $r = $doc->getElementById( $id );
291        // Note that $r could be null here because the
292        // DOMDocument hasn't had an "id attribute" set, even if the id
293        // exists in the document. See:
294        // http://php.net/manual/en/domdocument.getelementbyid.php
295        if ( $r !== null ) {
296            // Verify that this node is actually connected to the
297            // document (or to the context), since the element
298            // isn't removed from the index immediately when it
299            // is deleted. (Also PHP's call is not scoped.)
300            // (Note that scoped getElementsById is *exclusive* of $context,
301            // so we start this search at r's parent node.)
302            for ( $parent = $r->parentNode; $parent; $parent = $parent->parentNode ) {
303                if ( $parent === $context ) {
304                    return [ $r ];
305                }
306            }
307            // It's possible a deleted-but-still-indexed element was
308            // shadowing a later-added element, so we can't return
309            // null here directly; fallback to a full search.
310        }
311        if ( $this->isStandardsMode( $context, $opts ) ) {
312            // The workaround below only works (and is only necessary!)
313            // when this is a PHP-provided \DOMDocument.  For 3rd-party
314            // DOM implementations, we assume that getElementById() was
315            // reliable.
316            // @phan-suppress-next-line PhanUndeclaredProperty
317            if ( $context->isConnected || $id === '' ) {
318                return [];
319            }
320            // For disconnected Elements and DocumentFragments, we need
321            // to do this the hard/slow way
322            $r = [];
323            foreach ( $this->getElementsByTagName( $context, '*', $opts ) as $el ) {
324                // Work around Phan 8.1 / 7.4 issues by telling Phan what
325                // the expected type is. Zest could be used with newer DOM
326                // implementations that could return null for missing attributes.
327                // See https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute#non-existing_attributes
328                // But, Phan doesn't know that and only sees the native PHP
329                // implementation that returns '' for missing attributes.
330                $elId = $el->getAttribute( 'id' );
331                '@phan-var ?string $elId';
332                if ( $id === ( $elId ?? '' ) ) {
333                    $r[] = $el;
334                }
335            }
336            return $r;
337        }
338        // Do an xpath search, which is still a full traversal of the tree
339        // (sigh) but 25% faster than traversing it wholly in PHP.
340        $xpath = new \DOMXPath( $doc );
341        $query = './/*[@id=' . self::xpathQuote( $id ) . ']';
342        if ( $context->nodeType === 11 ) {
343            // ugh, PHP dom extension workaround: nodes which are direct
344            // children of the DocumentFragment are not matched unless we
345            // use a ./ query in addition to the .// query.
346            $query = "./" . substr( $query, 3 ) . "|$query";
347        }
348        return iterator_to_array( $xpath->query( $query, $context ) );
349    }
350
351    private function docFragHelper( $docFrag, string $sel, array $opts, callable $collectFunc ) {
352        $result = [];
353        for ( $n = $docFrag->firstChild; $n; $n = $n->nextSibling ) {
354            if ( $n->nodeType !== 1 ) {
355                continue; // Not an element
356            }
357            // See if $n itself should be included
358            if ( $this->matches( $n, $sel, $opts ) ) {
359                $result[] = $n;
360            }
361            // Now include all of $n's children
362            array_splice( $result, count( $result ), 0, $collectFunc( $n ) );
363        }
364        return $result;
365    }
366
367    /**
368     * Get descendants by tag name.
369     * The PHP DOM doesn't provide this method for DOMElement, and the
370     * implementation in DOMDocument has performance issues.
371     *
372     * This is an *exclusive* query; that is, $context should never be included
373     * among the results.
374     *
375     * Clients can subclass and override this to provide a more efficient
376     * implementation if one is available.
377     *
378     * @param DOMDocument|DOMDocumentFragment|DOMElement $context
379     * @param string $tagName
380     * @param array $opts Additional match-context options (optional)
381     * @return array<DOMElement>
382     */
383    public function getElementsByTagName( $context, string $tagName, array $opts = [] ) {
384        if ( $context->nodeType === 11 /* DocumentFragment */ ) {
385            // DOM standards don't define getElementsByTagName on
386            // DocumentFragment, and XPath supports it but has a bug which
387            // omits root elements.  So emulate in both these cases.
388            $selector = $tagName === '*' ? '*' : self::encodeid( $tagName );
389            return $this->docFragHelper(
390                $context, $selector, $opts,
391                function ( $el ) use ( $tagName, $opts ): array {
392                    return $this->getElementsByTagName( $el, $tagName, $opts );
393                }
394            );
395        }
396        if ( $this->isStandardsMode( $context, $opts ) ) {
397            // For third-party DOM implementations, just use native func.
398            return iterator_to_array(
399                $context->getElementsByTagName( $tagName )
400            );
401        }
402        // This *should* just be a call to PHP's `getElementByTagName`
403        // function *BUT* PHP's implementation is 100x slower than using
404        // XPath to get the same results (!)
405
406        // XXX this assumes default PHP DOM implementation, which
407        // reports lowercase tag names in DOMNode->tagName (even though
408        // the DOM spec says it should report uppercase)
409        $tagName = strtolower( $tagName );
410
411        $doc = self::nodeIsDocument( $context ) ?
412            $context : $context->ownerDocument;
413        $xpath = new \DOMXPath( $doc );
414        $ns = $doc->documentElement === null ? 'force use of local-name' :
415            $doc->documentElement->namespaceURI;
416        if ( $tagName === '*' ) {
417            $query = ".//*";
418        } elseif ( $ns || !preg_match( '/^[_a-z][-.0-9_a-z]*$/S', $tagName ) ) {
419            $query = './/*[local-name()=' . self::xpathQuote( $tagName ) . ']';
420        } else {
421            $query = ".//$tagName";
422        }
423        return iterator_to_array( $xpath->query( $query, $context ) );
424    }
425
426    /**
427     * Clients can subclass and override this to provide a more efficient
428     * implementation if one is available.
429     *
430     * This is an *exclusive* query; that is, $context should never be included
431     * among the results.
432     *
433     * @param DOMDocument|DOMDocumentFragment|DOMElement $context
434     * @param string $className
435     * @param array $opts
436     * @return array<DOMElement>
437     */
438    protected function getElementsByClassName( $context, string $className, $opts ) {
439        if ( $context->nodeType === 11 /* DocumentFragment */ ) {
440            // DOM standards don't define getElementsByClassName on
441            // DocumentFragment, and XPath supports it but has a bug which
442            // omits root elements.  So emulate in both these cases.
443            return $this->docFragHelper(
444                $context,
445                // NOTE this only works when $className is a single class,
446                // but that's the only way we invoke it.
447                "." . self::encodeid( $className ),
448                $opts,
449                function ( $el ) use ( $className, $opts ): array {
450                    return $this->getElementsByClassName( $el, $className, $opts );
451                }
452            );
453        }
454        if ( $this->isStandardsMode( $context, $opts ) ) {
455            // For third-party DOM implementations, just use native func.
456            return iterator_to_array(
457                // @phan-suppress-next-line PhanUndeclaredMethod
458                $context->getElementsByClassName( $className )
459            );
460        }
461
462        // PHP doesn't have an implementation of this method; use XPath
463        // to quickly get results.  (It would be faster still if there was an
464        // actual index, but this will be about 25% faster than doing the
465        // tree traversal all in PHP.)
466        $doc = self::nodeIsDocument( $context ) ?
467            $context : $context->ownerDocument;
468        $xpath = new \DOMXPath( $doc );
469        $quotedClassName = self::xpathQuote( " $className " );
470        $query = ".//*[contains(concat(' ', normalize-space(@class), ' '), $quotedClassName)]";
471        return iterator_to_array( $xpath->query( $query, $context ) );
472    }
473
474    /**
475     * Handle `nth` Selectors
476     */
477    private static function parseNth( string $param ): object {
478        $param = preg_replace( '/\s+/', '', $param );
479
480        if ( $param === 'even' ) {
481            $param = '2n+0';
482        } elseif ( $param === 'odd' ) {
483            $param = '2n+1';
484        } elseif ( strpos( $param, 'n' ) === false ) {
485            $param = '0n' . $param;
486        }
487
488        preg_match( '/^([+-])?(\d+)?n([+-])?(\d+)?$/', $param, $cap, PREG_UNMATCHED_AS_NULL );
489
490        $group = intval( ( $cap[1] ?? '' ) . ( $cap[2] ?? '1' ), 10 );
491        $offset = intval( ( $cap[3] ?? '' ) . ( $cap[4] ?? '0' ), 10 );
492        return (object)[
493            'group' => $group,
494            'offset' => $offset,
495        ];
496    }
497
498    /**
499     * @param string $param
500     * @param callable(DOMNode,DOMNode,array):bool $test
501     * @param bool $last
502     * @return callable(DOMNode,array):bool
503     */
504    private static function nth( string $param, callable $test, bool $last ): callable {
505        $param = self::parseNth( $param );
506        $group = $param->group;
507        $offset = $param->offset;
508        $find = ( !$last ) ? [ self::class, 'child' ] : [ self::class, 'lastChild' ];
509        $advance = ( !$last ) ? [ self::class, 'next' ] : [ self::class, 'prev' ];
510        return function ( $el, array $opts ) use ( $find, $test, $offset, $group, $advance ): bool {
511            if ( !self::parentIsElement( $el ) ) {
512                return false;
513            }
514
515            $rel = call_user_func( $find, $el->parentNode );
516            $pos = 0;
517
518            while ( $rel ) {
519                if ( call_user_func( $test, $rel, $el, $opts ) ) {
520                    $pos++;
521                }
522                if ( $rel === $el ) {
523                    $pos -= $offset;
524                    return ( $group && $pos )
525                        ? ( $pos % $group ) === 0 && ( ( $pos < 0 ) === ( $group < 0 ) )
526                        : !$pos;
527                }
528                $rel = call_user_func( $advance, $rel );
529            }
530            return false;
531        };
532    }
533
534    /**
535     * Simple Selectors which take no arguments.
536     * @var array<string,(callable(DOMNode,array):bool)>
537     */
538    private $selectors0;
539
540    /**
541     * Simple Selectors which take one argument.
542     * @var array<string,(callable(string,ZestInst):(callable(DOMNode,array):bool))>
543     */
544    private $selectors1;
545
546    /**
547     * Add a custom selector that takes no parameters.
548     * @param string $key Name of the selector
549     * @param callable(DOMNode,array):bool $func
550     *   The selector match function
551     */
552    public function addSelector0( string $key, callable $func ) {
553        $this->selectors0[$key] = $func;
554    }
555
556    /**
557     * Add a custom selector that takes 1 parameter, which is passed as a
558     * string.
559     * @param string $key Name of the selector
560     * @param callable(string,ZestInst):(callable(DOMNode,array):bool)|callable(string):(callable(DOMNode,array):bool) $func
561     *   The selector match function
562     */
563    public function addSelector1( string $key, callable $func ) {
564        $this->selectors1[$key] = $func;
565    }
566
567    private function initSelectors() {
568        // Careful: this method is only called once, on the singleton
569        // ZestInst, which all child ZestInst instances inherit their
570        // default selector lists from.  But as a result $this is
571        // always the singleton; be sure to use the $self argument (for
572        // selector1) or $opts['this'] (for selector0) to access the
573        // dynamically-bound $this.
574
575        $this->addSelector0( '*', static function ( $el, $opts ): bool {
576            return true;
577        } );
578        $this->addSelector1( 'type', static function ( string $type ): callable {
579            $type = strtolower( $type );
580            return static function ( $el, $opts ) use ( $type ): bool {
581                return strtolower( $el->nodeName ) === $type;
582            };
583        } );
584        $this->addSelector1( 'typeNoNS', static function ( string $type ): callable {
585            $type = strtolower( $type );
586            return static function ( $el, $opts ) use ( $type ): bool {
587                return ( $el->namespaceURI ?? '' ) === '' &&
588                    strtolower( $el->nodeName ) === $type;
589            };
590        } );
591        $this->addSelector0( ':first-child', function ( $el, $opts ): bool {
592            return !self::prev( $el ) && self::parentIsElement( $el );
593        } );
594        $this->addSelector0( ':last-child', function ( $el, $opts ): bool {
595            return !self::next( $el ) && self::parentIsElement( $el );
596        } );
597        $this->addSelector0( ':only-child', function ( $el, $opts ): bool {
598            return !self::prev( $el ) && !self::next( $el )
599                && self::parentIsElement( $el );
600        } );
601        $this->addSelector1( ':nth-child', function ( string $param ): callable {
602            return self::nth( $param, static function ( $rel, $el, $opts ): bool {
603                return true;
604            }, false /* last */ );
605        } );
606        $this->addSelector1( ':nth-last-child', function ( string $param ): callable {
607            return self::nth( $param, static function ( $rel, $el, $opts ): bool {
608                return true;
609            }, true /* last */ );
610        } );
611        $this->addSelector0( ':root', static function ( $el, $opts ): bool {
612            return $el->ownerDocument->documentElement === $el;
613        } );
614        $this->addSelector0( ':empty', static function ( $el, $opts ): bool {
615            return !$el->firstChild;
616        } );
617        $this->addSelector1( ':not', static function ( string $sel, ZestInst $self ) {
618            $test = $self->compileGroup( $sel );
619            return static function ( $el, $opts ) use ( $test ): bool {
620                return !call_user_func( $test, $el, $opts );
621            };
622        } );
623        $this->addSelector0( ':first-of-type', function ( $el, $opts ): bool {
624            if ( !self::parentIsElement( $el ) ) {
625                return false;
626            }
627            $type = $el->nodeName;
628            while ( $el = self::prev( $el ) ) {
629                if ( $el->nodeName === $type ) {
630                    return false;
631                }
632            }
633            return true;
634        } );
635        $this->addSelector0( ':last-of-type', function ( $el, $opts ): bool {
636            if ( !self::parentIsElement( $el ) ) {
637                return false;
638            }
639            $type = $el->nodeName;
640            while ( $el = self::next( $el ) ) {
641                if ( $el->nodeName === $type ) {
642                    return false;
643                }
644            }
645            return true;
646        } );
647        $this->addSelector0( ':only-of-type', static function ( $el, $opts ): bool {
648            $self = $opts['this'];
649            return $self->selectors0[ ':first-of-type' ]( $el, $opts ) &&
650                $self->selectors0[ ':last-of-type' ]( $el, $opts );
651        } );
652        $makeNthOfType = static function ( bool $last ) {
653            return function ( string $param ) use ( $last ): callable  {
654                return self::nth( $param, static function ( $rel, $el, $opts ): bool {
655                    return $rel->nodeName === $el->nodeName;
656                }, $last );
657            };
658        };
659        $this->addSelector1( ':nth-of-type', $makeNthOfType( false ) );
660        $this->addSelector1( ':nth-last-of-type', $makeNthOfType( true ) );
661        /** @suppress PhanUndeclaredProperty not defined in PHP DOM */
662        $this->addSelector0( ':checked', static function ( $el, $opts ): bool {
663            '@phan-var DOMElement $el';
664            $self = $opts['this'];
665            if ( $self->isStandardsMode( $el, $opts ) ) {
666                // These properties don't exist in the PHP DOM, and in fact
667                // they are supposed to reflect the *dynamic* state of the
668                // widget, not the 'default' state (which is given by the
669                // attribute value)
670                if ( isset( $el->checked ) || isset( $el->selected ) ) {
671                    return ( isset( $el->checked ) && $el->checked ) ||
672                        ( isset( $el->selected ) && $el->selected );
673                }
674            }
675            return $el->hasAttribute( 'checked' ) || $el->hasAttribute( 'selected' );
676        } );
677        $this->addSelector0( ':indeterminate', static function ( $el, $opts ): bool {
678            $self = $opts['this'];
679            return !$self->selectors0[ ':checked' ]( $el, $opts );
680        } );
681        /** @suppress PhanUndeclaredProperty not defined in PHP DOM */
682        $this->addSelector0( ':enabled', static function ( $el, $opts ): bool {
683            '@phan-var DOMElement $el';
684            $self = $opts['this'];
685            if ( $self->isStandardsMode( $el, $opts ) && isset( $el->type ) ) {
686                $type = $el->type; // this does case normalization in spec
687            } else {
688                $type = $el->getAttribute( 'type' );
689            }
690            return !$el->hasAttribute( 'disabled' ) && $type !== 'hidden';
691        } );
692        $this->addSelector0( ':disabled', static function ( $el, $opts ): bool {
693            '@phan-var DOMElement $el';
694            return $el->hasAttribute( 'disabled' );
695        } );
696        /*
697        $this->addSelector0( ':target', function ( $el ) use ( &$window ) {
698            return $el->id === $window->location->hash->substring( 1 );
699        });
700        $this->addSelector0( ':focus', function ( $el ) {
701            return $el === $el->ownerDocument->activeElement;
702        });
703        */
704        $this->addSelector1( ':is', static function ( string $sel, ZestInst $self ): callable {
705            return $self->compileGroup( $sel );
706        } );
707        // :matches is an older name for :is; see
708        // https://github.com/w3c/csswg-drafts/issues/3258
709        $this->addSelector1( ':matches', static function ( string $sel, ZestInst $self ): callable {
710            return $self->selectors1[ ':is' ]( $sel, $self );
711        } );
712        $makeNthMatch = static function ( bool $last ) {
713            return function ( string $param, ZestInst $self ) use ( $last ): callable {
714                $args = preg_split( '/\s*,\s*/', $param );
715                $arg = array_shift( $args );
716                $test = $self->compileGroup( implode( ',', $args ) );
717
718                return self::nth( $arg, static function ( $rel, $el, $opts ) use ( $test ): bool {
719                    return call_user_func( $test, $el, $opts );
720                }, $last );
721            };
722        };
723        $this->addSelector1( ':nth-match', $makeNthMatch( false ) );
724        $this->addSelector1( ':nth-last-match', $makeNthMatch( true ) );
725        /*
726        $this->addSelector0( ':links-here', function ( $el ) use ( &$window ) {
727            return $el . '' === $window->location . '';
728        });
729        */
730        $this->addSelector1( ':lang', static function ( string $param ): callable {
731            return static function ( $el, $opts ) use ( $param ): bool {
732                while ( $el ) {
733                    if ( $el->nodeType === 1 /* Element */ ) {
734                        '@phan-var DOMElement $el';
735                        // PHP DOM doesn't have 'lang' property
736                        $lang = $el->getAttribute( 'lang' );
737                        if ( $lang ) {
738                            return strpos( $lang, $param ) === 0;
739                        }
740                    }
741                    $el = $el->parentNode;
742                }
743                return false;
744            };
745        } );
746        $this->addSelector1( ':dir', static function ( string $param ): callable {
747            return static function ( $el, $opts ) use ( $param ): bool {
748                while ( $el ) {
749                    if ( $el->nodeType === 1 /* Element */ ) {
750                        '@phan-var DOMElement $el';
751                        $dir = $el->getAttribute( 'dir' );
752                        if ( $dir ) {
753                            return $dir === $param;
754                        }
755                    }
756                    $el = $el->parentNode;
757                }
758                return false;
759            };
760        } );
761        $this->addSelector0( ':scope', static function ( $el, $opts ): bool {
762            $self = $opts['this'];
763            $scope = $opts['scope'] ?? null;
764            if ( $scope !== null && $scope->nodeType === 1 ) {
765                return $el === $scope;
766            }
767            // If the scoping root is missing or not an element, then :scope
768            // should be a synonym for :root
769            return $self->selectors0[ ':root' ]( $el, $opts );
770        } );
771        /*
772        $this->addSelector0( ':any-link', function ( $el ):bool {
773            return gettype( $el->href ) === 'string';
774        });
775        $this->addSelector( ':local-link', function ( $el ) use ( &$window ) {
776            if ( $el->nodeName ) {
777                return $el->href && $el->host === $window->location->host;
778            }
779            // XXX this is really selector1 not selector0
780            $param = +$el + 1;
781            return function ( $el ) use ( &$window, $param ) {
782                if ( !$el->href ) { return;  }
783
784                $url = $window->location . '';
785                $href = $el . '';
786
787                return self::truncateUrl( $url, $param ) === self::truncateUrl( $href, $param );
788            };
789        });
790        $this->addSelector0( ':default', function ( $el ):bool {
791            return !!$el->defaultSelected;
792        });
793        $this->addSelector0( ':valid', function ( $el ):bool {
794            return $el->willValidate || ( $el->validity && $el->validity->valid );
795        });
796        */
797        $this->addSelector0( ':invalid', static function ( $el, $opts ): bool {
798            $self = $opts['this'];
799            return !$self->selectors0[ ':valid' ]( $el, $opts );
800        } );
801        /*
802        $this->addSelector0( ':in-range', function ( $el ):bool {
803            return $el->value > $el->min && $el->value <= $el->max;
804        });
805        */
806        $this->addSelector0( ':out-of-range', static function ( $el, $opts ): bool {
807            $self = $opts['this'];
808            return !$self->selectors0[ ':in-range' ]( $el, $opts );
809        } );
810        $this->addSelector0( ':required', static function ( $el, $opts ): bool {
811            '@phan-var DOMElement $el';
812            return $el->hasAttribute( 'required' );
813        } );
814        $this->addSelector0( ':optional', static function ( $el, $opts ): bool {
815            $self = $opts['this'];
816            return !$self->selectors0[ ':required' ]( $el, $opts );
817        } );
818        $this->addSelector0( ':read-only', static function ( $el, $opts ): bool {
819            '@phan-var DOMElement $el';
820            if ( $el->hasAttribute( 'readOnly' ) ) {
821                return true;
822            }
823
824            $attr = $el->getAttribute( 'contenteditable' );
825            $name = strtolower( $el->nodeName );
826
827            $name = $name !== 'input' && $name !== 'textarea';
828
829            return ( $name || $el->hasAttribute( 'disabled' ) ) && $attr == null;
830        } );
831        $this->addSelector0( ':read-write', static function ( $el, $opts ): bool {
832            $self = $opts['this'];
833            return !$self->selectors0[ ':read-only' ]( $el, $opts );
834        } );
835        foreach ( [
836            ':hover',
837            ':active',
838            ':link',
839            ':visited',
840            ':column',
841            ':nth-column',
842            ':nth-last-column',
843            ':current',
844            ':past',
845            ':future',
846        ] as $selector ) {
847            $this->addSelector0(
848                $selector,
849                /**
850                 * @param DOMNode $el
851                 * @param array $opts
852                 * @return never
853                 */
854                static function ( $el, $opts ) use ( $selector ): bool {
855                    $self = $opts['this'];
856                    throw $self->newBadSelectorException( $selector . ' is not supported.' );
857                }
858            );
859        }
860        // Non-standard, for compatibility purposes.
861        $this->addSelector1( ':contains', static function ( string $param ): callable {
862            return static function ( $el ) use ( $param ): bool {
863                $text = $el->textContent;
864                return strpos( $text, $param ) !== false;
865            };
866        } );
867        $this->addSelector1( ':has', static function ( string $param ): callable {
868            return static function ( $el, array $opts ) use ( $param ): bool {
869                '@phan-var DOMElement $el';
870                $self = $opts['this'];
871                return count( $self->find( $param, $el, $opts ) ) > 0;
872            };
873        } );
874        // Potentially add more pseudo selectors for
875        // compatibility with sizzle and most other
876        // selector engines (?).
877    }
878
879    /** @return callable(DOMNode,array):bool */
880    private function selectorsAttr( string $key, string $op, string $val, bool $i ): callable {
881        $op = $this->operators[ $op ];
882        return static function ( $el, $opts ) use ( $key, $i, $op, $val ): bool {
883            /* XXX: the below all assumes a more complete PHP DOM than we have
884            switch ( $key ) {
885            #case 'for':
886            #    $attr = $el->htmlFor; // Not supported in PHP DOM
887            #    break;
888            case 'class':
889                // PHP DOM doesn't support $el->className
890                // className is '' when non-existent
891                // getAttribute('class') is null
892                if ($el->hasAttributes() && $el->hasAttribute( 'class' ) ) {
893                    $attr = $el->getAttribute( 'class' );
894                } else {
895                    $attr = null;
896                }
897                break;
898            case 'href':
899            case 'src':
900                $attr = $el->getAttribute( $key, 2 );
901                break;
902            case 'title':
903                // getAttribute('title') can be '' when non-existent sometimes?
904                if ($el->hasAttribute('title')) {
905                    $attr = $el->getAttribute( 'title' );
906                } else {
907                    $attr = null;
908                }
909                break;
910                // careful with attributes with special getter functions
911            case 'id':
912            case 'lang':
913            case 'dir':
914            case 'accessKey':
915            case 'hidden':
916            case 'tabIndex':
917            case 'style':
918                if ( $el->getAttribute ) {
919                    $attr = $el->getAttribute( $key );
920                    break;
921                }
922                // falls through
923            default:
924                if ( $el->hasAttribute && !$el->hasAttribute( $key ) ) {
925                    break;
926                }
927                $attr = ( $el[ $key ] != null ) ?
928                    $el[ $key ] :
929                    $el->getAttribute && $el->getAttribute( $key );
930                break;
931            }
932            */
933            // This is our simple PHP DOM version
934            '@phan-var DOMElement $el';
935            if ( $el->hasAttributes() && $el->hasAttribute( $key ) ) {
936                $attr = $el->getAttribute( $key );
937            } else {
938                return false;
939            }
940            // End simple PHP DOM version
941            if ( $i ) {
942                $attr = strtolower( $attr );
943                $val = strtolower( $val );
944            }
945            return call_user_func( $op, $attr, $val );
946        };
947    }
948
949    /**
950     * Attribute Operators
951     * @var array<string,(callable(string,string):bool)>
952     */
953    private $operators;
954
955    /**
956     * Add a custom operator
957     * @param string $key Name of the operator
958     * @param callable(string,string):bool $func
959     *   The operator match function
960     */
961    public function addOperator( string $key, callable $func ) {
962        $this->operators[$key] = $func;
963    }
964
965    private function initOperators() {
966        // Be careful: $this always points to the singleton ZestInst
967        $this->addOperator( '-', static function ( string $attr, string $val ): bool {
968            return true;
969        } );
970        $this->addOperator( '=', static function ( string $attr, string $val ): bool {
971            return $attr === $val;
972        } );
973        $this->addOperator( '*=', static function ( string $attr, string $val ): bool {
974            return strpos( $attr, $val ) !== false;
975        } );
976        $this->addOperator( '~=', static function ( string $attr, string $val ): bool {
977            // https://drafts.csswg.org/selectors-4/#attribute-representation
978            //     If "val" contains whitespace, it will never represent
979            //     anything (since the words are separated by spaces)
980            if ( strcspn( $val, " \t\r\n\f" ) !== strlen( $val ) ) {
981                return false;
982            }
983            // Also if "val" is the empty string, it will never
984            //     represent anything.
985            if ( strlen( $val ) === 0 ) {
986                return false;
987            }
988            $attrLen = strlen( $attr );
989            $valLen = strlen( $val );
990            for ( $s = 0;  $s < $attrLen;  $s = $i + 1 ) {
991                $i = strpos( $attr, $val, $s );
992                if ( $i === false ) {
993                    return false;
994                }
995                $j = $i + $valLen;
996                $f = ( $i === 0 ) ? ' ' : $attr[ $i - 1 ];
997                $l = ( $j >= $attrLen ) ? ' ' : $attr[ $j ];
998                $f = strtr( $f, "\t\r\n\f", "    " );
999                $l = strtr( $l, "\t\r\n\f", "    " );
1000                if ( $f === ' ' && $l === ' ' ) {
1001                    return true;
1002                }
1003            }
1004            return false;
1005        } );
1006        $this->addOperator( '|=', static function ( string $attr, string $val ): bool {
1007            $i = strpos( $attr, $val );
1008            if ( $i !== 0 ) {
1009                return false;
1010            }
1011            $j = $i + strlen( $val );
1012            if ( $j >= strlen( $attr ) ) {
1013                return true;
1014            }
1015            $l = $attr[ $j ];
1016            return $l === '-';
1017        } );
1018        $this->addOperator( '^=', static function ( string $attr, string $val ): bool {
1019            return strpos( $attr, $val ) === 0;
1020        } );
1021        $this->addOperator( '$=', static function ( string $attr, string $val ): bool {
1022            $i = strrpos( $attr, $val );
1023            return $i !== false && $i + strlen( $val ) === strlen( $attr );
1024        } );
1025        // non-standard
1026        $this->addOperator( '!=', static function ( string $attr, string $val ): bool {
1027            return $attr !== $val;
1028        } );
1029    }
1030
1031    /**
1032     * Combinator Logic
1033     * @var array<string,(callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)))>
1034     */
1035    private $combinators;
1036
1037    /**
1038     * Add a custom combinator
1039     * @param string $key Name of the combinator
1040     * @param callable(callable(DOMNode,array):bool):(callable(DOMNode,array):(?DOMNode)) $func
1041     *   The combinator match function
1042     */
1043    public function addCombinator( string $key, callable $func ) {
1044        $this->combinators[$key] = $func;
1045    }
1046
1047    private function initCombinators() {
1048        // Be careful: $this always points to the singleton ZestInst
1049        $this->addCombinator( ' ', static function ( callable $test ): callable {
1050            return static function ( $el, $opts ) use ( $test ) {
1051                while ( $el = $el->parentNode ) {
1052                    if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) {
1053                        return $el;
1054                    }
1055                }
1056                return null;
1057            };
1058        } );
1059        $this->addCombinator( '>', static function ( callable $test ): callable {
1060            return static function ( $el, $opts ) use ( $test ) {
1061                if ( $el = $el->parentNode ) {
1062                    if ( $el->nodeType === 1 && call_user_func( $test, $el, $opts ) ) {
1063                        return $el;
1064                    }
1065                }
1066                return null;
1067            };
1068        } );
1069        $this->addCombinator( '+', function ( callable $test ): callable {
1070            return function ( $el, $opts ) use ( $test ) {
1071                if ( $el = self::prev( $el ) ) {
1072                    if ( call_user_func( $test, $el, $opts ) ) {
1073                        return $el;
1074                    }
1075                }
1076                return null;
1077            };
1078        } );
1079        $this->addCombinator( '~', function ( callable $test ): callable {
1080            return function ( $el, $opts ) use ( $test ) {
1081                while ( $el = self::prev( $el ) ) {
1082                    if ( call_user_func( $test, $el, $opts ) ) {
1083                        return $el;
1084                    }
1085                }
1086                return null;
1087            };
1088        } );
1089        $this->addCombinator( 'noop', static function ( callable $test ): callable {
1090            return static function ( $el, $opts ) use ( $test ) {
1091                if ( call_user_func( $test, $el, $opts ) ) {
1092                    return $el;
1093                }
1094                return null;
1095            };
1096        } );
1097    }
1098
1099    /**
1100     * @param callable(DOMNode,array):bool $test
1101     * @param string $name
1102     * @return ZestFunc
1103     */
1104    private function makeRef( callable $test, string $name ): ZestFunc {
1105        $node = null;
1106        $ref = new ZestFunc( function ( $el, $opts ) use ( &$node, &$ref ): bool {
1107            $doc = $el->ownerDocument;
1108            $nodes = $this->getElementsByTagName( $doc, '*', $opts );
1109            $i = count( $nodes );
1110
1111            while ( $i-- ) {
1112                $node = $nodes[$i];
1113                if ( call_user_func( $ref->test->func, $el, $opts ) ) {
1114                    $node = null;
1115                    return true;
1116                }
1117            }
1118
1119            $node = null;
1120            return false;
1121        } );
1122
1123        $ref->combinator = static function ( $el, $opts ) use ( &$node, $name, $test ) {
1124            if ( !$node || $node->nodeType !== 1 /* Element */ ) {
1125                return null;
1126            }
1127
1128            $attr = $node->getAttribute( $name ) ?? '';
1129            if ( $attr !== '' && $attr[ 0 ] === '#' ) {
1130                $attr = substr( $attr, 1 );
1131            }
1132
1133            $id = $node->getAttribute( 'id' ) ?? '';
1134            if ( $attr === $id && call_user_func( $test, $node, $opts ) ) {
1135                return $node;
1136            }
1137            return null;
1138        };
1139
1140        return $ref;
1141    }
1142
1143    /**
1144     * Grammar
1145     */
1146
1147    /** @var \stdClass */
1148    private static $rules;
1149
1150    public static function initRules() {
1151        self::$rules = (object)[
1152        'escape' => '/\\\(?:[^0-9A-Fa-f\r\n]|[0-9A-Fa-f]{1,6}[\r\n\t ]?)/',
1153        'str_escape' => '/(escape)|\\\(\n|\r\n?|\f)/',
1154        'nonascii' => '/[\x{00A0}-\x{FFFF}]/',
1155        'cssid' => '/(?:(?!-?[0-9])(?:escape|nonascii|[-_a-zA-Z0-9])+)/',
1156        'qname' => '/^ *((?:\*?\|)?cssid|\*)/',
1157        'simple' => '/^(?:([.#]cssid)|pseudo|attr)/',
1158        'ref' => '/^ *\/(cssid)\/ */',
1159        'combinator' => '/^(?: +([^ \w*.#\\\]) +|( )+|([^ \w*.#\\\]))(?! *$)/',
1160        'attr' => '/^\[(cssid)(?:([^\w]?=)(inside))?\]/',
1161        'pseudo' => '/^(:cssid)(?:\((inside)\))?/',
1162        'inside' => "/(?:\"(?:\\\\\"|[^\"])*\"|'(?:\\\\'|[^'])*'|<[^\"'>]*>|\\\\[\"'>]|[^\"'>])*/",
1163        'ident' => '/^(cssid)$/',
1164        ];
1165        self::$rules->cssid = self::replace( self::$rules->cssid, 'nonascii', self::$rules->nonascii );
1166        self::$rules->cssid = self::replace( self::$rules->cssid, 'escape', self::$rules->escape );
1167        self::$rules->qname = self::replace( self::$rules->qname, 'cssid', self::$rules->cssid );
1168        self::$rules->simple = self::replace( self::$rules->simple, 'cssid', self::$rules->cssid );
1169        self::$rules->ref = self::replace( self::$rules->ref, 'cssid', self::$rules->cssid );
1170        self::$rules->attr = self::replace( self::$rules->attr, 'cssid', self::$rules->cssid );
1171        self::$rules->pseudo = self::replace( self::$rules->pseudo, 'cssid', self::$rules->cssid );
1172        self::$rules->inside = self::replace( self::$rules->inside, "[^\"'>]*", self::$rules->inside );
1173        self::$rules->attr = self::replace( self::$rules->attr, 'inside', self::makeInside( '\[', '\]' ) );
1174        self::$rules->pseudo = self::replace( self::$rules->pseudo, 'inside', self::makeInside( '\(', '\)' ) );
1175        self::$rules->simple = self::replace( self::$rules->simple, 'pseudo', self::$rules->pseudo );
1176        self::$rules->simple = self::replace( self::$rules->simple, 'attr', self::$rules->attr );
1177        self::$rules->ident = self::replace( self::$rules->ident, 'cssid', self::$rules->cssid );
1178        self::$rules->str_escape = self::replace( self::$rules->str_escape, 'escape', self::$rules->escape );
1179    }
1180
1181    /**
1182     * Compiling
1183     */
1184
1185    private function compile( string $sel ): ZestFunc {
1186        if ( !isset( $this->compileCache[$sel] ) ) {
1187            $this->compileCache[$sel] = $this->doCompile( $sel );
1188        }
1189        return $this->compileCache[$sel];
1190    }
1191
1192    private function doCompile( string $sel ): ZestFunc {
1193        $sel = preg_replace( '/^\s+|\s+$/', '', $sel );
1194        $test = null;
1195        $filter = [];
1196        $buff = [];
1197        $subject = null;
1198        $qname = null;
1199        $cap = null;
1200        $op = null;
1201        $ref = null;
1202
1203        while ( $sel ) {
1204            if ( preg_match( self::$rules->qname, $sel, $cap ) ) {
1205                $sel = substr( $sel, strlen( $cap[0] ) );
1206                $qname = self::decodeid( $cap[ 1 ] );
1207                $buff[] = $this->tokQname( $qname );
1208                // strip off *| or | prefix
1209                if ( substr( $qname, 0, 1 ) === '|' ) {
1210                    $qname = substr( $qname, 1 );
1211                } elseif ( substr( $qname, 0, 2 ) === '*|' ) {
1212                    $qname = substr( $qname, 2 );
1213                }
1214            } elseif ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
1215                $sel = substr( $sel, strlen( $cap[0] ) );
1216                $qname = '*';
1217                $buff[] = $this->tokQname( $qname );
1218                $buff[] = $this->tok( $cap );
1219            } else {
1220                throw $this->newBadSelectorException( 'Invalid selector.' );
1221            }
1222
1223            while ( preg_match( self::$rules->simple, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
1224                $sel = substr( $sel, strlen( $cap[0] ) );
1225                $buff[] = $this->tok( $cap );
1226            }
1227
1228            if ( $sel && $sel[ 0 ] === '!' ) {
1229                $sel = substr( $sel, 1 );
1230                $subject = $this->makeSubject();
1231                $subject->qname = $qname;
1232                $buff[] = $subject->simple;
1233            }
1234
1235            if ( preg_match( self::$rules->ref, $sel, $cap ) ) {
1236                $sel = substr( $sel, strlen( $cap[0] ) );
1237                $ref = $this->makeRef( self::makeSimple( $buff ), self::decodeid( $cap[ 1 ] ) );
1238                $filter[] = $ref->combinator;
1239                $buff = [];
1240                continue;
1241            }
1242
1243            if ( preg_match( self::$rules->combinator, $sel, $cap, PREG_UNMATCHED_AS_NULL ) ) {
1244                $sel = substr( $sel, strlen( $cap[0] ) );
1245                $op = $cap[ 1 ] ?? $cap[ 2 ] ?? $cap[ 3 ];
1246                if ( $op === ',' ) {
1247                    $filter[] = $this->combinators['noop']( self::makeSimple( $buff ) );
1248                    break;
1249                }
1250            } else {
1251                $op = 'noop';
1252            }
1253
1254            if ( !isset( $this->combinators[ $op ] ) ) {
1255                throw $this->newBadSelectorException( 'Bad combinator: ' . $op );
1256            }
1257            $filter[] = $this->combinators[ $op ]( self::makeSimple( $buff ) );
1258            $buff = [];
1259        }
1260
1261        $test = self::makeTest( $filter );
1262        $test->qname = $qname;
1263        $test->sel = $sel;
1264
1265        if ( $subject ) {
1266            $subject->lname = $test->qname;
1267
1268            $subject->test = $test;
1269            // @phan-suppress-next-line PhanPluginDuplicateExpressionAssignment
1270            $subject->qname = $subject->qname;
1271            $subject->sel = $test->sel;
1272            $test = $subject;
1273        }
1274
1275        if ( $ref ) {
1276            $ref->test = $test;
1277            $ref->qname = $test->qname;
1278            $ref->sel = $test->sel;
1279            $test = $ref;
1280        }
1281
1282        return $test;
1283    }
1284
1285    /** @return callable(DOMNode,array):bool */
1286    private function tokQname( string $cap ): callable {
1287        // qname
1288        if ( $cap === '*' ) {
1289            return $this->selectors0['*'];
1290        } elseif ( substr( $cap, 0, 1 ) === '|' ) {
1291            // no namespace
1292            return $this->selectors1['typeNoNS']( substr( $cap, 1 ), $this );
1293        } elseif ( substr( $cap, 0, 2 ) === '*|' ) {
1294            // any namespace including no namespace
1295            return $this->selectors1['type']( substr( $cap, 2 ), $this );
1296        } else {
1297            return $this->selectors1['type']( $cap, $this );
1298        }
1299    }
1300
1301    /** @return callable(DOMNode,array):bool */
1302    private function tok( array $cap ): callable {
1303        // class/id
1304        if ( $cap[ 1 ] ) {
1305            return $cap[ 1 ][ 0 ] === '.'
1306            // XXX unescape here?  or in attr?
1307                ? $this->selectorsAttr( 'class', '~=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false ) :
1308                $this->selectorsAttr( 'id', '=', self::decodeid( substr( $cap[ 1 ], 1 ) ), false );
1309        }
1310
1311        // pseudo-name
1312        // inside-pseudo
1313        if ( $cap[ 2 ] ) {
1314            $id = self::decodeid( $cap[ 2 ] );
1315            if ( isset( $cap[3] ) && $cap[ 3 ] ) {
1316                if ( !isset( $this->selectors1[ $id ] ) ) {
1317                    throw $this->newBadSelectorException( "Unknown Selector: $id" );
1318                }
1319                return $this->selectors1[ $id ]( self::unquote( $cap[ 3 ] ), $this );
1320            } else {
1321                if ( !isset( $this->selectors0[ $id ] ) ) {
1322                    throw $this->newBadSelectorException( "Unknown Selector: $id" );
1323                }
1324                return $this->selectors0[ $id ];
1325            }
1326        }
1327
1328        // attr name
1329        // attr op
1330        // attr value
1331        if ( $cap[ 4 ] ) {
1332            $value = $cap[ 6 ] ?? '';
1333            $i = preg_match( "/[\"'\\s]\\s*I\$/i", $value );
1334            if ( $i ) {
1335                $value = preg_replace( '/\s*I$/i', '', $value, 1 );
1336            }
1337            return $this->selectorsAttr( self::decodeid( $cap[ 4 ] ), $cap[ 5 ] ?? '-', self::unquote( $value ), (bool)$i );
1338        }
1339
1340        throw $this->newBadSelectorException( 'Unknown Selector.' );
1341    }
1342
1343    /**
1344     * Returns true if all $func return true
1345     * @param array<callable(DOMNode,array):bool> $func
1346     * @return callable(DOMNode,array):bool
1347     */
1348    private static function makeSimple( array $func ): callable {
1349        $l = count( $func );
1350
1351        // Potentially make sure
1352        // `el` is truthy.
1353        if ( $l < 2 ) {
1354            return $func[ 0 ];
1355        }
1356
1357        return static function ( $el, $opts ) use ( $l, $func ): bool {
1358            for ( $i = 0;  $i < $l;  $i++ ) {
1359                if ( !call_user_func( $func[ $i ], $el, $opts ) ) {
1360                    return false;
1361                }
1362            }
1363            return true;
1364        };
1365    }
1366
1367    /**
1368     * Returns the element that all $func return
1369     * @param array<callable(DOMNode,array):(?DOMNode)> $func
1370     * @return ZestFunc
1371     */
1372    private static function makeTest( array $func ): ZestFunc {
1373        if ( count( $func ) < 2 ) {
1374            return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool {
1375                return (bool)call_user_func( $func[ 0 ], $el, $opts );
1376            } );
1377        }
1378        return new ZestFunc( static function ( $el, $opts ) use ( $func ): bool {
1379            $i = count( $func );
1380            while ( $i-- ) {
1381                if ( !( $el = call_user_func( $func[ $i ], $el, $opts ) ) ) {
1382                    return false;
1383                }
1384            }
1385            return true;
1386        } );
1387    }
1388
1389    /**
1390     * Return a skeleton ZestFunc for the caller to fill in.
1391     * @return ZestFunc
1392     */
1393    private function makeSubject(): ZestFunc {
1394        $target = null;
1395
1396        $subject = new ZestFunc( function ( $el, $opts ) use ( &$subject, &$target ): bool {
1397            $node = $el->ownerDocument;
1398            $scope = $this->getElementsByTagName( $node, $subject->lname, $opts );
1399            $i = count( $scope );
1400
1401            while ( $i-- ) {
1402                if ( call_user_func( $subject->test->func, $scope[$i], $opts ) && $target === $el ) {
1403                    $target = null;
1404                    return true;
1405                }
1406            }
1407
1408            $target = null;
1409            return false;
1410        } );
1411
1412        $subject->simple = static function ( $el, $opts ) use ( &$target ): bool {
1413            $target = $el;
1414            return true;
1415        };
1416
1417        return $subject;
1418    }
1419
1420    /**
1421     * @return callable(DOMNode,array):bool
1422     */
1423    private function compileGroup( string $sel ): callable {
1424        $test = $this->compile( $sel );
1425        $tests = [ $test ];
1426
1427        while ( $test->sel ) {
1428            $test = $this->compile( $test->sel );
1429            $tests[] = $test;
1430        }
1431
1432        if ( count( $tests ) < 2 ) {
1433            return $test->func;
1434        }
1435
1436        return static function ( $el, $opts ) use ( $tests ): bool {
1437            for ( $i = 0, $l = count( $tests );  $i < $l;  $i++ ) {
1438                if ( call_user_func( $tests[ $i ]->func, $el, $opts ) ) {
1439                    return true;
1440                }
1441            }
1442            return false;
1443        };
1444    }
1445
1446    /**
1447     * Selection
1448     */
1449
1450    // $node should be a DOMDocument, DOMDocumentFragment, or a DOMElement
1451    // These are "ParentNode" in the DOM spec.
1452
1453    /**
1454     * @param string $sel
1455     * @param DOMDocument|DOMDocumentFragment|DOMElement $node
1456     * @param array $opts
1457     * @return DOMElement[]
1458     */
1459    private function findInternal( string $sel, $node, $opts ): array {
1460        $results = [];
1461        $test = $this->compile( $sel );
1462        $scope = $this->getElementsByTagName( $node, $test->qname, $opts );
1463        $i = 0;
1464        $el = null;
1465        $needsSort = false;
1466
1467        foreach ( $scope as $el ) {
1468            if ( call_user_func( $test->func, $el, $opts ) ) {
1469                $results[spl_object_id( $el )] = $el;
1470            }
1471        }
1472
1473        if ( $test->sel ) {
1474            $needsSort = true;
1475            while ( $test->sel ) {
1476                $test = $this->compile( $test->sel );
1477                $scope = $this->getElementsByTagName( $node, $test->qname, $opts );
1478                foreach ( $scope as $el ) {
1479                    if ( call_user_func( $test->func, $el, $opts ) ) {
1480                        $results[spl_object_id( $el )] = $el;
1481                    }
1482                }
1483            }
1484        }
1485
1486        $results = array_values( $results );
1487        if ( $needsSort ) {
1488             self::sort( $results, $this->isStandardsMode( $node, $opts ) );
1489        }
1490        return $results;
1491    }
1492
1493    /**
1494     * Find elements matching a CSS selector underneath $context.
1495     *
1496     * This search is exclusive; that is, `find(':scope', ...)` returns
1497     * no matches, although `:scope *` would return matches.
1498     *
1499     * @param string $sel The CSS selector string
1500     * @param DOMDocument|DOMDocumentFragment|DOMElement $context
1501     *   The scoping root for the search
1502     * @param array $opts Additional match-context options (optional)
1503     * @return DOMElement[] Elements matching the CSS selector
1504     */
1505    public function find( string $sel, $context, array $opts = [] ): array {
1506        $opts['this'] = $this;
1507        $opts['scope'] = $context;
1508
1509        /* when context isn't a DocumentFragment and the selector is simple: */
1510        if ( $context->nodeType !== 11 && strpos( $sel, ' ' ) === false ) {
1511            // https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier
1512            // Valid identifiers starting with a hyphen or with escape
1513            // sequences will be handled correctly by the fall-through case.
1514            if ( $sel[ 0 ] === '#' && preg_match( '/^#[A-Za-z_](?:[-A-Za-z0-9_]|[^\0-\237])*$/Su', $sel ) ) {
1515                // Setting 'getElementsById' to `true` disables this
1516                // optimization and forces the hard/slow search in
1517                // order to guarantee that multiple elements will be
1518                // returned if there are multiple elements in the
1519                // $context with the given id.
1520                if ( ( $opts['getElementsById'] ?? null ) !== true ) {
1521                    $id = substr( $sel, 1 );
1522                    return $this->getElementsById( $context, $id, $opts );
1523                }
1524            }
1525            if ( $sel[ 0 ] === '.' && preg_match( '/^\.\w+$/', $sel ) ) {
1526                return $this->getElementsByClassName( $context, substr( $sel, 1 ), $opts );
1527            }
1528            if ( preg_match( '/^\w+$/', $sel ) ) {
1529                return $this->getElementsByTagName( $context, $sel, $opts );
1530            }
1531        }
1532        /* do things the hard/slow way */
1533        return $this->findInternal( $sel, $context, $opts );
1534    }
1535
1536    /**
1537     * Determine whether an element matches the given selector.
1538     *
1539     * This test is inclusive; that is, `matches($el, ':scope')`
1540     * returns true.
1541     *
1542     * @param DOMNode $el The element to be tested
1543     * @param string $sel The CSS selector string
1544     * @param array $opts Additional match-context options (optional)
1545     * @return bool True iff the element matches the selector
1546     */
1547    public function matches( $el, string $sel, array $opts = [] ): bool {
1548        $opts['this'] = $this;
1549        $opts['scope'] = $el;
1550
1551        $test = new ZestFunc( static function ( $el, $opts ): bool {
1552            return true;
1553        } );
1554        $test->sel = $sel;
1555        do {
1556            $test = $this->compile( $test->sel );
1557            if ( call_user_func( $test->func, $el, $opts ) ) {
1558                return true;
1559            }
1560        } while ( $test->sel );
1561        return false;
1562    }
1563
1564    /**
1565     * Allow customization of the exception thrown for a bad selector.
1566     * @param string $msg Description of the failure
1567     * @return Throwable
1568     */
1569    protected function newBadSelectorException( string $msg ): Throwable {
1570        return new InvalidArgumentException( $msg );
1571    }
1572
1573    /**
1574     * Allow subclasses to force Zest into "standards mode" (or not).
1575     * The default implementation looks for a 'standardsMode' key in the
1576     * option and if that is not present switches to standards mode if
1577     * the ownerDocument of the given node is not a \DOMDocument.
1578     * @param DOMNode $context a context node
1579     * @param array $opts The zest options array pased to ::find, ::matches, etc
1580     * @return bool True for standards mode, otherwise false.
1581     */
1582    protected function isStandardsMode( $context, array $opts ): bool {
1583        // The $opts array can force a specific mode, if key is present
1584        if ( array_key_exists( 'standardsMode', $opts ) ) {
1585            return (bool)$opts['standardsMode'];
1586        }
1587        // Otherwise guess "not standard mode" if the node document is a
1588        // \DOMDocument, otherwise use standards mode.
1589        $doc = self::nodeIsDocument( $context ) ?
1590             $context : $context->ownerDocument;
1591        return !( $doc instanceof DOMDocument );
1592    }
1593
1594    /** @var ?ZestInst */
1595    private static $singleton = null;
1596
1597    /**
1598     * Create a new instance of Zest.  Custom combinators and selectors
1599     * registered for each instance of Zest do not bleed
1600     * over into other instances.
1601     */
1602    public function __construct() {
1603        $z = self::$singleton;
1604        $this->selectors0 = $z ? $z->selectors0 : [];
1605        $this->selectors1 = $z ? $z->selectors1 : [];
1606        $this->operators = $z ? $z->operators : [];
1607        $this->combinators = $z ? $z->combinators : [];
1608        if ( !$z ) {
1609            $this->initRules();
1610            $this->initSelectors();
1611            $this->initOperators();
1612            $this->initCombinators();
1613            self::$singleton = $this;
1614            // Now create another instance so that backing arrays are cloned
1615            // @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams
1616            self::$singleton = new ZestInst;
1617        }
1618    }
1619}