Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.76% covered (warning)
88.76%
316 / 356
51.85% covered (warning)
51.85%
14 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
SelectorParser
88.76% covered (warning)
88.76%
316 / 356
51.85% covered (warning)
51.85%
14 / 27
224.45
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 parseTokens
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseComplexSelectorList
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
11.77
 parseRelativeComplexSelector
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 tryParseLeadingCombinator
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 parseComplexSelector
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 parseCompoundSelector
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 tryParseTypeOrUniversal
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
17
 tryParseSubclassOrPseudo
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 parseAttributeSelector
83.64% covered (warning)
83.64%
46 / 55
0.00% covered (danger)
0.00%
0 / 1
26.52
 parseAttrMatcher
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
12.04
 parsePseudoSelector
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 buildPseudoClassFunction
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
14.34
 buildPseudoElementFunction
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseTokensInner
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseCombinator
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 startsCompound
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 collectUntilMatchingParen
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 skipWhitespace
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 skipToNextSelector
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
90
 eof
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 peek
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 peekAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeTokens
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 serializeTokenRange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 serializeToken
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
18
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Selector;
6
7use Phpdftk\Css\Token\AtKeywordToken;
8use Phpdftk\Css\Token\ColonToken;
9use Phpdftk\Css\Token\CommaToken;
10use Phpdftk\Css\Token\DelimToken;
11use Phpdftk\Css\Token\DimensionToken;
12use Phpdftk\Css\Token\EofToken;
13use Phpdftk\Css\Token\FunctionToken;
14use Phpdftk\Css\Token\HashToken;
15use Phpdftk\Css\Token\HashTokenType;
16use Phpdftk\Css\Token\IdentToken;
17use Phpdftk\Css\Token\LeftBracketToken;
18use Phpdftk\Css\Token\NumberToken;
19use Phpdftk\Css\Token\NumberTokenType;
20use Phpdftk\Css\Token\RightBracketToken;
21use Phpdftk\Css\Token\RightParenToken;
22use Phpdftk\Css\Token\StringToken;
23use Phpdftk\Css\Token\Token;
24use Phpdftk\Css\Token\WhitespaceToken;
25use Phpdftk\Css\Tokenizer;
26
27/**
28 * Selectors-4 parser. Consumes the prelude token list of a style rule (or a
29 * raw string for `:is()` / `:where()` / `:not()` / `:has()` argument
30 * parsing) and produces a `SelectorList` of `ComplexSelector`s.
31 *
32 * Parsing follows the grammar in Selectors 4 ยง17 with the common-path subset
33 * needed for print rendering. Specifically supported:
34 *  - type, universal, id, class, attribute, pseudo-class, pseudo-element
35 *  - all combinators: descendant, `>`, `+`, `~`, `||`
36 *  - `:is`, `:not`, `:where`, `:has` argument lists (recursive)
37 *  - `:nth-child` family with An+B
38 *  - `:lang`, `:dir`, `:host`, `:host-context`, `::slotted`, `::part`,
39 *    `::theme` and other functional forms (parsed but match semantics live
40 *    in the matcher in Phase 1D.2).
41 *
42 * The parser is forgiving by default for top-level selector lists and
43 * non-forgiving for `:not` / `:has`. `:is` / `:where` are forgiving per spec.
44 */
45final class SelectorParser
46{
47    /** @var list<Token> */
48    private array $tokens;
49    private int $i = 0;
50    private int $count;
51
52    /** Parse a selector source string into a SelectorList. */
53    public static function parse(string $source): SelectorList
54    {
55        $tokenizer = new Tokenizer($source);
56        $tokens = array_values(array_filter(
57            $tokenizer->tokenize(),
58            static fn(Token $t): bool => !($t instanceof EofToken),
59        ));
60        return self::parseTokens($tokens, $source);
61    }
62
63    /**
64     * Parse a pre-tokenised prelude into a SelectorList. The CSS stylesheet
65     * parser calls this with the prelude tokens of a qualified rule.
66     *
67     * @param list<Token> $tokens
68     */
69    public static function parseTokens(array $tokens, string $sourceText = ''): SelectorList
70    {
71        $self = new self($tokens);
72        $selectors = $self->parseComplexSelectorList(forgiving: true);
73        return new SelectorList($sourceText, $selectors);
74    }
75
76    /** @param list<Token> $tokens */
77    private function __construct(array $tokens)
78    {
79        $this->tokens = $tokens;
80        $this->count = count($tokens);
81    }
82
83    /**
84     * `<complex-selector-list> = <complex-selector>#`
85     *
86     * @return list<ComplexSelector>
87     */
88    private function parseComplexSelectorList(bool $forgiving, bool $relative = false): array
89    {
90        $out = [];
91        while (true) {
92            $this->skipWhitespace();
93            if ($this->eof()) {
94                break;
95            }
96            $start = $this->i;
97            try {
98                $sel = $relative
99                    ? $this->parseRelativeComplexSelector()
100                    : $this->parseComplexSelector();
101                if ($sel !== null) {
102                    $out[] = $sel;
103                }
104            } catch (SelectorSyntaxException) {
105                if (!$forgiving) {
106                    throw new SelectorSyntaxException('Invalid selector');
107                }
108                // Skip to next comma or EOF.
109                $this->skipToNextSelector();
110            }
111            $this->skipWhitespace();
112            if ($this->eof()) {
113                break;
114            }
115            $next = $this->peek();
116            if ($next instanceof CommaToken) {
117                $this->i++;
118                continue;
119            }
120            // No comma but tokens remain โ€” recover or fail.
121            if ($this->i === $start) {
122                // Made no progress; bail out to avoid infinite loop.
123                $this->i++;
124                if (!$forgiving) {
125                    throw new SelectorSyntaxException('Unexpected token in selector list');
126                }
127            }
128        }
129        return $out;
130    }
131
132    /**
133     * Parse a CSS Selectors 4 ยง17.5 *relative selector*:
134     * `<combinator>? <complex-selector>`. Used inside `:has(...)`
135     * arguments so authors can write `:has(> child)`,
136     * `:has(+ sibling)`, `:has(~ sibling)` to bind the inner
137     * selector against an implicit subject.
138     *
139     * A leading combinator is optional โ€” when absent the relative
140     * selector is equivalent to a descendant relative selector
141     * (which is the v1 `:has(s)` behaviour).
142     */
143    private function parseRelativeComplexSelector(): ?ComplexSelector
144    {
145        $startTok = $this->i;
146        $this->skipWhitespace();
147        $leadingCombinator = $this->tryParseLeadingCombinator();
148        $this->skipWhitespace();
149        $base = $this->parseComplexSelector();
150        if ($base === null) {
151            return null;
152        }
153        if ($leadingCombinator === null) {
154            return $base;
155        }
156        $text = self::serializeTokenRange($this->tokens, $startTok, $this->i);
157        return new ComplexSelector(
158            compounds: $base->compounds,
159            text: trim($text),
160            leadingCombinator: $leadingCombinator,
161        );
162    }
163
164    private function tryParseLeadingCombinator(): ?Combinator
165    {
166        $save = $this->i;
167        $tok = $this->peek();
168        if (!($tok instanceof DelimToken)) {
169            return null;
170        }
171        $combinator = match ($tok->value) {
172            '>' => Combinator::Child,
173            '+' => Combinator::NextSibling,
174            '~' => Combinator::SubsequentSibling,
175            default => null,
176        };
177        if ($combinator === null) {
178            $this->i = $save;
179            return null;
180        }
181        $this->i++;
182        return $combinator;
183    }
184
185    private function parseComplexSelector(): ?ComplexSelector
186    {
187        $startTok = $this->i;
188        $compound = $this->parseCompoundSelector();
189        if ($compound === null) {
190            return null;
191        }
192        $parts = [];
193        while (true) {
194            $combinator = $this->parseCombinator();
195            if ($combinator === null) {
196                $parts[] = new CompoundSelectorWithCombinator($compound, null);
197                break;
198            }
199            $next = $this->parseCompoundSelector();
200            if ($next === null) {
201                throw new SelectorSyntaxException('Expected compound selector after combinator');
202            }
203            $parts[] = new CompoundSelectorWithCombinator($compound, $combinator);
204            $compound = $next;
205        }
206        $text = self::serializeTokenRange($this->tokens, $startTok, $this->i);
207        return new ComplexSelector($parts, trim($text));
208    }
209
210    private function parseCompoundSelector(): ?CompoundSelector
211    {
212        $components = [];
213
214        // Optional type/universal selector first.
215        $typed = $this->tryParseTypeOrUniversal();
216        if ($typed !== null) {
217            $components[] = $typed;
218        }
219        while (true) {
220            $sub = $this->tryParseSubclassOrPseudo();
221            if ($sub === null) {
222                break;
223            }
224            $components[] = $sub;
225        }
226        if ($components === []) {
227            return null;
228        }
229        return new CompoundSelector($components);
230    }
231
232    private function tryParseTypeOrUniversal(): ?SimpleSelector
233    {
234        $save = $this->i;
235        $prefix = null;
236
237        // Look for ns-prefix: ident|, *|, or |.
238        if ($this->peek() instanceof IdentToken
239            && $this->peekAt(1) instanceof DelimToken
240            && $this->peekAt(1)->value === '|'
241            && !($this->peekAt(2) instanceof DelimToken && $this->peekAt(2)->value === '=')
242        ) {
243            $prefix = $this->peek()->value;
244            $this->i += 2;
245        } elseif ($this->peek() instanceof DelimToken
246            && $this->peek()->value === '*'
247            && $this->peekAt(1) instanceof DelimToken
248            && $this->peekAt(1)->value === '|'
249        ) {
250            $prefix = '*';
251            $this->i += 2;
252        } elseif ($this->peek() instanceof DelimToken
253            && $this->peek()->value === '|'
254            && !($this->peekAt(1) instanceof DelimToken && $this->peekAt(1)->value === '|')
255        ) {
256            $prefix = '';
257            $this->i++;
258        }
259
260        $tok = $this->peek();
261        if ($tok instanceof IdentToken) {
262            $this->i++;
263            return new TypeSelector($tok->value, $prefix);
264        }
265        if ($tok instanceof DelimToken && $tok->value === '*') {
266            $this->i++;
267            return new UniversalSelector($prefix);
268        }
269        // Not a type selector โ€” rewind.
270        $this->i = $save;
271        return null;
272    }
273
274    private function tryParseSubclassOrPseudo(): ?SimpleSelector
275    {
276        $tok = $this->peek();
277        if ($tok instanceof HashToken && $tok->type === HashTokenType::Id) {
278            $this->i++;
279            return new IdSelector($tok->value);
280        }
281        if ($tok instanceof DelimToken && $tok->value === '.') {
282            if ($this->peekAt(1) instanceof IdentToken) {
283                $this->i += 2;
284                /** @var IdentToken $ident */
285                $ident = $this->tokens[$this->i - 1];
286                return new ClassSelector($ident->value);
287            }
288            return null;
289        }
290        if ($tok instanceof LeftBracketToken) {
291            return $this->parseAttributeSelector();
292        }
293        if ($tok instanceof ColonToken) {
294            return $this->parsePseudoSelector();
295        }
296        return null;
297    }
298
299    private function parseAttributeSelector(): AttributeSelector
300    {
301        // Caller verified the `[`.
302        $this->i++;
303        $this->skipWhitespace();
304        $prefix = null;
305        // Optional namespace prefix. A `|` followed by `=` is the |= match
306        // operator, not a namespace separator.
307        if ($this->peek() instanceof IdentToken
308            && $this->peekAt(1) instanceof DelimToken
309            && $this->peekAt(1)->value === '|'
310            && !($this->peekAt(2) instanceof DelimToken && $this->peekAt(2)->value === '=')
311        ) {
312            $prefix = $this->peek()->value;
313            $this->i += 2;
314        } elseif ($this->peek() instanceof DelimToken && $this->peek()->value === '*'
315            && $this->peekAt(1) instanceof DelimToken && $this->peekAt(1)->value === '|'
316            && !($this->peekAt(2) instanceof DelimToken && $this->peekAt(2)->value === '=')
317        ) {
318            $prefix = '*';
319            $this->i += 2;
320        } elseif ($this->peek() instanceof DelimToken && $this->peek()->value === '|'
321            && !($this->peekAt(1) instanceof DelimToken && in_array($this->peekAt(1)->value, ['|', '='], true))
322        ) {
323            $prefix = '';
324            $this->i++;
325        }
326
327        $nameTok = $this->peek();
328        if (!$nameTok instanceof IdentToken) {
329            throw new SelectorSyntaxException('Expected attribute name');
330        }
331        $name = $nameTok->value;
332        $this->i++;
333        $this->skipWhitespace();
334
335        // `]` โ†’ existence only.
336        if ($this->peek() instanceof RightBracketToken) {
337            $this->i++;
338            return new AttributeSelector($name, AttributeMatchType::Exists, namespacePrefix: $prefix);
339        }
340        $matcher = $this->parseAttrMatcher();
341        $this->skipWhitespace();
342        $valueTok = $this->peek();
343        $value = null;
344        if ($valueTok instanceof StringToken) {
345            $value = $valueTok->value;
346            $this->i++;
347        } elseif ($valueTok instanceof IdentToken) {
348            $value = $valueTok->value;
349            $this->i++;
350        } else {
351            throw new SelectorSyntaxException('Expected attribute value');
352        }
353        $this->skipWhitespace();
354        // Tri-state: explicit `i` / `s` modifiers vs no flag.
355        // Null means "use host-language defaults" (HTML's listed
356        // case-insensitive attribute names apply per Selectors 4
357        // ยง6.6); explicit flags override.
358        $ci = null;
359        $modifier = $this->peek();
360        if ($modifier instanceof IdentToken) {
361            $lower = strtolower($modifier->value);
362            if ($lower === 'i') {
363                $ci = true;
364                $this->i++;
365                $this->skipWhitespace();
366            } elseif ($lower === 's') {
367                $ci = false;
368                $this->i++;
369                $this->skipWhitespace();
370            }
371        }
372        if (!($this->peek() instanceof RightBracketToken)) {
373            throw new SelectorSyntaxException('Expected `]`');
374        }
375        $this->i++;
376        return new AttributeSelector($name, $matcher, $value, $prefix, $ci);
377    }
378
379    private function parseAttrMatcher(): AttributeMatchType
380    {
381        $tok = $this->peek();
382        if ($tok instanceof DelimToken && $tok->value === '=') {
383            $this->i++;
384            return AttributeMatchType::Equals;
385        }
386        if ($tok instanceof DelimToken && in_array($tok->value, ['~', '|', '^', '$', '*'], true)) {
387            $next = $this->peekAt(1);
388            if ($next instanceof DelimToken && $next->value === '=') {
389                $this->i += 2;
390                return match ($tok->value) {
391                    '~' => AttributeMatchType::Includes,
392                    '|' => AttributeMatchType::DashMatch,
393                    '^' => AttributeMatchType::PrefixMatch,
394                    '$' => AttributeMatchType::SuffixMatch,
395                    '*' => AttributeMatchType::SubstringMatch,
396                };
397            }
398        }
399        throw new SelectorSyntaxException('Expected attribute matcher operator');
400    }
401
402    private function parsePseudoSelector(): SimpleSelector
403    {
404        $this->i++; // consume `:`
405        $isPseudoElement = false;
406        if ($this->peek() instanceof ColonToken) {
407            $isPseudoElement = true;
408            $this->i++;
409        }
410        $next = $this->peek();
411        if ($next instanceof IdentToken) {
412            $this->i++;
413            $name = strtolower($next->value);
414            // Per CSS 2.1 legacy: ::before/::after/::first-line/::first-letter
415            // can be written with a single colon; route those to pseudo-element.
416            if (!$isPseudoElement
417                && in_array($name, ['before', 'after', 'first-line', 'first-letter'], true)
418            ) {
419                $isPseudoElement = true;
420            }
421            return $isPseudoElement
422                ? new PseudoElementSelector($name)
423                : new PseudoClassSelector($name);
424        }
425        if ($next instanceof FunctionToken) {
426            $this->i++;
427            $name = strtolower($next->name);
428            $argTokens = $this->collectUntilMatchingParen();
429            return $isPseudoElement
430                ? $this->buildPseudoElementFunction($name, $argTokens)
431                : $this->buildPseudoClassFunction($name, $argTokens);
432        }
433        throw new SelectorSyntaxException('Expected identifier or function after `:`');
434    }
435
436    /** @param list<Token> $argTokens */
437    private function buildPseudoClassFunction(string $name, array $argTokens): PseudoClassSelector
438    {
439        switch ($name) {
440            case 'is':
441            case 'where':
442            case 'not':
443                $forgiving = in_array($name, ['is', 'where'], true);
444                $inner = self::parseTokensInner($argTokens, $forgiving);
445                return new PseudoClassSelector($name, $inner);
446            case 'has':
447                // CSS Selectors 4 ยง17 โ€” `:has()` accepts a relative
448                // selector list (each arg may start with a leading
449                // `>` / `+` / `~`). Per ยง17 `:has()` is forgiving.
450                $inner = self::parseTokensInner($argTokens, true, relative: true);
451                return new PseudoClassSelector($name, $inner);
452            case 'nth-child':
453            case 'nth-last-child':
454            case 'nth-of-type':
455            case 'nth-last-of-type':
456                [$anb, $of] = AnPlusBParser::parseWithOf($argTokens);
457                return new PseudoClassSelector($name, $of, $anb);
458            case 'lang':
459            case 'dir':
460                $argText = self::serializeTokens($argTokens);
461                return new PseudoClassSelector($name, argText: trim($argText));
462            case 'host':
463            case 'host-context':
464                $inner = self::parseTokensInner($argTokens, false);
465                return new PseudoClassSelector($name, $inner);
466            default:
467                // Unknown functional pseudo-class โ€” keep as raw text.
468                $argText = self::serializeTokens($argTokens);
469                return new PseudoClassSelector($name, argText: trim($argText));
470        }
471    }
472
473    /** @param list<Token> $argTokens */
474    private function buildPseudoElementFunction(string $name, array $argTokens): PseudoElementSelector
475    {
476        $inner = match ($name) {
477            'slotted', 'part', 'theme' => self::parseTokensInner($argTokens, false),
478            default => self::parseTokensInner($argTokens, true),
479        };
480        return new PseudoElementSelector($name, $inner);
481    }
482
483    /** @param list<Token> $tokens */
484    private static function parseTokensInner(array $tokens, bool $forgiving, bool $relative = false): SelectorList
485    {
486        $self = new self($tokens);
487        $sels = $self->parseComplexSelectorList($forgiving, $relative);
488        return new SelectorList(self::serializeTokens($tokens), $sels);
489    }
490
491    private function parseCombinator(): ?Combinator
492    {
493        $hadWhitespace = $this->skipWhitespace();
494        $tok = $this->peek();
495        if ($tok instanceof DelimToken) {
496            switch ($tok->value) {
497                case '>':
498                    $this->i++;
499                    $this->skipWhitespace();
500                    return Combinator::Child;
501                case '+':
502                    $this->i++;
503                    $this->skipWhitespace();
504                    return Combinator::NextSibling;
505                case '~':
506                    $this->i++;
507                    $this->skipWhitespace();
508                    return Combinator::SubsequentSibling;
509                case '|':
510                    if ($this->peekAt(1) instanceof DelimToken && $this->peekAt(1)->value === '|') {
511                        $this->i += 2;
512                        $this->skipWhitespace();
513                        return Combinator::Column;
514                    }
515            }
516        }
517        // Descendant: whitespace followed by something that starts a compound.
518        if ($hadWhitespace && $this->startsCompound($tok)) {
519            return Combinator::Descendant;
520        }
521        return null;
522    }
523
524    private function startsCompound(?Token $tok): bool
525    {
526        if ($tok === null) {
527            return false;
528        }
529        if ($tok instanceof IdentToken
530            || $tok instanceof HashToken
531            || $tok instanceof LeftBracketToken
532            || $tok instanceof ColonToken
533        ) {
534            return true;
535        }
536        if ($tok instanceof DelimToken) {
537            return in_array($tok->value, ['.', '*', '|'], true);
538        }
539        return false;
540    }
541
542    /**
543     * Collect tokens until the matching `)` of a function-token argument
544     * list. The opening `(` was consumed as part of the FunctionToken.
545     *
546     * @return list<Token>
547     */
548    private function collectUntilMatchingParen(): array
549    {
550        $depth = 1;
551        $out = [];
552        while ($this->i < $this->count) {
553            $t = $this->tokens[$this->i];
554            if ($t instanceof RightParenToken) {
555                $depth--;
556                if ($depth === 0) {
557                    $this->i++;
558                    return $out;
559                }
560            }
561            if ($t instanceof FunctionToken) {
562                $depth++;
563            }
564            $out[] = $t;
565            $this->i++;
566        }
567        // Implicit closer at EOF per CSS Syntax 3.
568        return $out;
569    }
570
571    private function skipWhitespace(): bool
572    {
573        $had = false;
574        while ($this->i < $this->count && $this->tokens[$this->i] instanceof WhitespaceToken) {
575            $this->i++;
576            $had = true;
577        }
578        return $had;
579    }
580
581    private function skipToNextSelector(): void
582    {
583        $depth = 0;
584        while ($this->i < $this->count) {
585            $t = $this->tokens[$this->i];
586            if ($t instanceof CommaToken && $depth === 0) {
587                return;
588            }
589            if ($t instanceof FunctionToken || $t instanceof LeftBracketToken) {
590                $depth++;
591            } elseif ($t instanceof RightParenToken || $t instanceof RightBracketToken) {
592                if ($depth > 0) {
593                    $depth--;
594                }
595            }
596            $this->i++;
597        }
598    }
599
600    private function eof(): bool
601    {
602        return $this->i >= $this->count;
603    }
604
605    private function peek(): ?Token
606    {
607        return $this->tokens[$this->i] ?? null;
608    }
609
610    private function peekAt(int $offset): ?Token
611    {
612        return $this->tokens[$this->i + $offset] ?? null;
613    }
614
615    /** @param list<Token> $tokens */
616    private static function serializeTokens(array $tokens): string
617    {
618        $out = '';
619        foreach ($tokens as $t) {
620            $out .= self::serializeToken($t);
621        }
622        return $out;
623    }
624
625    /** @param list<Token> $tokens */
626    private static function serializeTokenRange(array $tokens, int $from, int $to): string
627    {
628        $slice = array_slice($tokens, $from, $to - $from);
629        return self::serializeTokens($slice);
630    }
631
632    private static function serializeToken(Token $t): string
633    {
634        return match (true) {
635            $t instanceof IdentToken => $t->value,
636            $t instanceof AtKeywordToken => '@' . $t->value,
637            $t instanceof HashToken => '#' . $t->value,
638            $t instanceof StringToken => '"' . str_replace('"', '\\"', $t->value) . '"',
639            $t instanceof DelimToken => $t->value,
640            $t instanceof CommaToken => ',',
641            $t instanceof ColonToken => ':',
642            $t instanceof WhitespaceToken => ' ',
643            $t instanceof LeftBracketToken => '[',
644            $t instanceof RightBracketToken => ']',
645            $t instanceof FunctionToken => $t->name . '(',
646            $t instanceof RightParenToken => ')',
647            $t instanceof NumberToken => $t->type === NumberTokenType::Integer
648                ? (string) (int) $t->value
649                : (string) $t->value,
650            $t instanceof DimensionToken => ($t->type === NumberTokenType::Integer
651                ? (string) (int) $t->value
652                : (string) $t->value) . $t->unit,
653            default => '',
654        };
655    }
656}