Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.87% covered (warning)
60.87%
56 / 92
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AnPlusBParser
60.87% covered (warning)
60.87%
56 / 92
50.00% covered (danger)
50.00%
4 / 8
259.56
0.00% covered (danger)
0.00%
0 / 1
 parseWithOf
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
 parse
46.67% covered (danger)
46.67%
28 / 60
0.00% covered (danger)
0.00%
0 / 1
209.37
 dimensionACoefficient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isNDimensionUnit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isNLikeIdent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trimWhitespace
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 skipWs
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 findOfKeyword
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Selector;
6
7use Phpdftk\Css\Token\DelimToken;
8use Phpdftk\Css\Token\DimensionToken;
9use Phpdftk\Css\Token\IdentToken;
10use Phpdftk\Css\Token\NumberToken;
11use Phpdftk\Css\Token\NumberTokenType;
12use Phpdftk\Css\Token\Token;
13use Phpdftk\Css\Token\WhitespaceToken;
14
15/**
16 * Parser for the An+B microsyntax per CSS Syntax 3 §6 / Selectors 4 §11.1.
17 * Handles: `2n+1`, `2n - 1`, `even`, `odd`, `+5`, `-n+3`, `n`, `0n+0`.
18 *
19 * Also supports the `:nth-child(... of S)` extension, returning the optional
20 * inner SelectorList.
21 */
22final class AnPlusBParser
23{
24    /**
25     * @param list<Token> $tokens
26     * @param bool $allowOf Whether an `of S` clause is valid here. Per
27     *   Selectors 4 §9 only `:nth-child` / `:nth-last-child` accept it;
28     *   `:nth-of-type` / `:nth-last-of-type` do NOT.
29     * @return array{AnPlusB, ?SelectorList}
30     */
31    public static function parseWithOf(array $tokens, bool $allowOf = true): array
32    {
33        $tokens = self::trimWhitespace($tokens);
34        if ($tokens === []) {
35            throw new SelectorSyntaxException('Empty An+B expression');
36        }
37        // Look for ` of ` separator at the top level.
38        $ofIndex = self::findOfKeyword($tokens);
39        if ($ofIndex !== null && !$allowOf) {
40            throw new SelectorSyntaxException('`of` clause is not valid for this pseudo-class');
41        }
42        $anbTokens = $ofIndex === null ? $tokens : array_slice($tokens, 0, $ofIndex);
43        $ofList = null;
44        if ($ofIndex !== null) {
45            $ofTokens = self::trimWhitespace(array_slice($tokens, $ofIndex + 1));
46            // Selectors 4 §9 — the `of` keyword MUST be followed by a
47            // non-empty <complex-selector-list>; `:nth-child(1 of)` is invalid.
48            if ($ofTokens === []) {
49                throw new SelectorSyntaxException('Empty `of` selector list');
50            }
51            $ofList = SelectorParser::parseTokens($ofTokens);
52            if ($ofList->isEmpty()) {
53                throw new SelectorSyntaxException('Invalid `of` selector list');
54            }
55        }
56        return [self::parse($anbTokens), $ofList];
57    }
58
59    /** @param list<Token> $tokens */
60    public static function parse(array $tokens): AnPlusB
61    {
62        $tokens = self::trimWhitespace($tokens);
63        if ($tokens === []) {
64            throw new SelectorSyntaxException('Empty An+B expression');
65        }
66        // Keyword shortcuts.
67        if (count($tokens) === 1 && $tokens[0] instanceof IdentToken) {
68            $low = strtolower($tokens[0]->value);
69            if ($low === 'even') {
70                return AnPlusB::even();
71            }
72            if ($low === 'odd') {
73                return AnPlusB::odd();
74            }
75            // `n` alone — a=1, b=0.
76            if ($low === 'n') {
77                return new AnPlusB(1, 0);
78            }
79            if ($low === '-n') {
80                return new AnPlusB(-1, 0);
81            }
82        }
83
84        // Pure integer: b only.
85        if (count($tokens) === 1 && $tokens[0] instanceof NumberToken
86            && $tokens[0]->type === NumberTokenType::Integer
87        ) {
88            return new AnPlusB(0, (int) $tokens[0]->value);
89        }
90
91        // <n-dimension> with optional sign and following integer.
92        // e.g. "2n", "2n+1", "2n -1", "-n+3"
93        $a = 0;
94        $b = 0;
95        $i = 0;
96        $count = count($tokens);
97
98        $tok = $tokens[$i];
99        if ($tok instanceof DimensionToken && self::isNDimensionUnit($tok->unit)
100            && $tok->type === NumberTokenType::Integer
101        ) {
102            // 2n, -2n, 0n
103            $a = self::dimensionACoefficient($tok);
104            $i++;
105        } elseif ($tok instanceof IdentToken && self::isNLikeIdent($tok->value)) {
106            $value = strtolower($tok->value);
107            $a = ($value === 'n' || $value === 'n-') ? 1 : -1;
108            // ident might encode a trailing -<digits>, e.g. "n-3".
109            if (preg_match('/^-?n-(\d+)$/i', $tok->value, $m)) {
110                return new AnPlusB($a, -(int) $m[1]);
111            }
112            $i++;
113        } elseif ($tok instanceof DelimToken && in_array($tok->value, ['+', '-'], true)
114            && ($next = $tokens[$i + 1] ?? null) instanceof IdentToken
115            && self::isNLikeIdent($next->value)
116        ) {
117            $sign = $tok->value === '-' ? -1 : 1;
118            $a = $sign;
119            // ident might be "n-3".
120            if (preg_match('/^n-(\d+)$/i', $next->value, $m)) {
121                return new AnPlusB($a, -(int) $m[1]);
122            }
123            $i += 2;
124        } else {
125            throw new SelectorSyntaxException('Invalid An+B expression');
126        }
127
128        // Optional `+ b` or `- b`.
129        $i = self::skipWs($tokens, $i);
130        if ($i >= $count) {
131            return new AnPlusB($a, 0);
132        }
133        $signTok = $tokens[$i] ?? null;
134        $sign = 0;
135        if ($signTok instanceof DelimToken && $signTok->value === '+') {
136            $sign = 1;
137            $i++;
138        } elseif ($signTok instanceof DelimToken && $signTok->value === '-') {
139            $sign = -1;
140            $i++;
141        } else {
142            // Maybe the integer carries a sign already (e.g. "2n -1").
143            if ($signTok instanceof NumberToken && $signTok->type === NumberTokenType::Integer) {
144                return new AnPlusB($a, (int) $signTok->value);
145            }
146            throw new SelectorSyntaxException('Expected + or - in An+B');
147        }
148        $i = self::skipWs($tokens, $i);
149        $numTok = $tokens[$i] ?? null;
150        if (!($numTok instanceof NumberToken) || $numTok->type !== NumberTokenType::Integer) {
151            throw new SelectorSyntaxException('Expected integer in An+B');
152        }
153        $b = $sign * (int) abs($numTok->value);
154        return new AnPlusB($a, $b);
155    }
156
157    private static function dimensionACoefficient(DimensionToken $t): int
158    {
159        // "n" or "n-" unit (the dash variant is consumed when followed by a number).
160        return (int) $t->value;
161    }
162
163    private static function isNDimensionUnit(string $unit): bool
164    {
165        $low = strtolower($unit);
166        return $low === 'n' || $low === 'n-';
167    }
168
169    private static function isNLikeIdent(string $ident): bool
170    {
171        return preg_match('/^-?n(-\d+)?$/i', $ident) === 1;
172    }
173
174    /**
175     * @param list<Token> $tokens
176     * @return list<Token>
177     */
178    private static function trimWhitespace(array $tokens): array
179    {
180        while ($tokens !== [] && $tokens[0] instanceof WhitespaceToken) {
181            array_shift($tokens);
182        }
183        while ($tokens !== [] && end($tokens) instanceof WhitespaceToken) {
184            array_pop($tokens);
185        }
186        return array_values($tokens);
187    }
188
189    /** @param list<Token> $tokens */
190    private static function skipWs(array $tokens, int $i): int
191    {
192        while (isset($tokens[$i]) && $tokens[$i] instanceof WhitespaceToken) {
193            $i++;
194        }
195        return $i;
196    }
197
198    /** @param list<Token> $tokens */
199    private static function findOfKeyword(array $tokens): ?int
200    {
201        foreach ($tokens as $i => $t) {
202            if ($t instanceof IdentToken && strtolower($t->value) === 'of') {
203                return $i;
204            }
205        }
206        return null;
207    }
208}