Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.14% covered (warning)
58.14%
50 / 86
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AnPlusBParser
58.14% covered (warning)
58.14%
50 / 86
37.50% covered (danger)
37.50%
3 / 8
267.89
0.00% covered (danger)
0.00%
0 / 1
 parseWithOf
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 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
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 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     * @return array{AnPlusB, ?SelectorList}
27     */
28    public static function parseWithOf(array $tokens): array
29    {
30        $tokens = self::trimWhitespace($tokens);
31        if ($tokens === []) {
32            throw new SelectorSyntaxException('Empty An+B expression');
33        }
34        // Look for ` of ` separator at the top level.
35        $ofIndex = self::findOfKeyword($tokens);
36        $anbTokens = $ofIndex === null ? $tokens : array_slice($tokens, 0, $ofIndex);
37        $ofList = null;
38        if ($ofIndex !== null) {
39            $ofTokens = array_slice($tokens, $ofIndex + 1);
40            $ofList = SelectorParser::parseTokens($ofTokens);
41        }
42        return [self::parse($anbTokens), $ofList];
43    }
44
45    /** @param list<Token> $tokens */
46    public static function parse(array $tokens): AnPlusB
47    {
48        $tokens = self::trimWhitespace($tokens);
49        if ($tokens === []) {
50            throw new SelectorSyntaxException('Empty An+B expression');
51        }
52        // Keyword shortcuts.
53        if (count($tokens) === 1 && $tokens[0] instanceof IdentToken) {
54            $low = strtolower($tokens[0]->value);
55            if ($low === 'even') {
56                return AnPlusB::even();
57            }
58            if ($low === 'odd') {
59                return AnPlusB::odd();
60            }
61            // `n` alone — a=1, b=0.
62            if ($low === 'n') {
63                return new AnPlusB(1, 0);
64            }
65            if ($low === '-n') {
66                return new AnPlusB(-1, 0);
67            }
68        }
69
70        // Pure integer: b only.
71        if (count($tokens) === 1 && $tokens[0] instanceof NumberToken
72            && $tokens[0]->type === NumberTokenType::Integer
73        ) {
74            return new AnPlusB(0, (int) $tokens[0]->value);
75        }
76
77        // <n-dimension> with optional sign and following integer.
78        // e.g. "2n", "2n+1", "2n -1", "-n+3"
79        $a = 0;
80        $b = 0;
81        $i = 0;
82        $count = count($tokens);
83
84        $tok = $tokens[$i];
85        if ($tok instanceof DimensionToken && self::isNDimensionUnit($tok->unit)
86            && $tok->type === NumberTokenType::Integer
87        ) {
88            // 2n, -2n, 0n
89            $a = self::dimensionACoefficient($tok);
90            $i++;
91        } elseif ($tok instanceof IdentToken && self::isNLikeIdent($tok->value)) {
92            $value = strtolower($tok->value);
93            $a = ($value === 'n' || $value === 'n-') ? 1 : -1;
94            // ident might encode a trailing -<digits>, e.g. "n-3".
95            if (preg_match('/^-?n-(\d+)$/i', $tok->value, $m)) {
96                return new AnPlusB($a, -(int) $m[1]);
97            }
98            $i++;
99        } elseif ($tok instanceof DelimToken && in_array($tok->value, ['+', '-'], true)
100            && ($next = $tokens[$i + 1] ?? null) instanceof IdentToken
101            && self::isNLikeIdent($next->value)
102        ) {
103            $sign = $tok->value === '-' ? -1 : 1;
104            $a = $sign;
105            // ident might be "n-3".
106            if (preg_match('/^n-(\d+)$/i', $next->value, $m)) {
107                return new AnPlusB($a, -(int) $m[1]);
108            }
109            $i += 2;
110        } else {
111            throw new SelectorSyntaxException('Invalid An+B expression');
112        }
113
114        // Optional `+ b` or `- b`.
115        $i = self::skipWs($tokens, $i);
116        if ($i >= $count) {
117            return new AnPlusB($a, 0);
118        }
119        $signTok = $tokens[$i] ?? null;
120        $sign = 0;
121        if ($signTok instanceof DelimToken && $signTok->value === '+') {
122            $sign = 1;
123            $i++;
124        } elseif ($signTok instanceof DelimToken && $signTok->value === '-') {
125            $sign = -1;
126            $i++;
127        } else {
128            // Maybe the integer carries a sign already (e.g. "2n -1").
129            if ($signTok instanceof NumberToken && $signTok->type === NumberTokenType::Integer) {
130                return new AnPlusB($a, (int) $signTok->value);
131            }
132            throw new SelectorSyntaxException('Expected + or - in An+B');
133        }
134        $i = self::skipWs($tokens, $i);
135        $numTok = $tokens[$i] ?? null;
136        if (!($numTok instanceof NumberToken) || $numTok->type !== NumberTokenType::Integer) {
137            throw new SelectorSyntaxException('Expected integer in An+B');
138        }
139        $b = $sign * (int) abs($numTok->value);
140        return new AnPlusB($a, $b);
141    }
142
143    private static function dimensionACoefficient(DimensionToken $t): int
144    {
145        // "n" or "n-" unit (the dash variant is consumed when followed by a number).
146        return (int) $t->value;
147    }
148
149    private static function isNDimensionUnit(string $unit): bool
150    {
151        $low = strtolower($unit);
152        return $low === 'n' || $low === 'n-';
153    }
154
155    private static function isNLikeIdent(string $ident): bool
156    {
157        return preg_match('/^-?n(-\d+)?$/i', $ident) === 1;
158    }
159
160    /**
161     * @param list<Token> $tokens
162     * @return list<Token>
163     */
164    private static function trimWhitespace(array $tokens): array
165    {
166        while ($tokens !== [] && $tokens[0] instanceof WhitespaceToken) {
167            array_shift($tokens);
168        }
169        while ($tokens !== [] && end($tokens) instanceof WhitespaceToken) {
170            array_pop($tokens);
171        }
172        return array_values($tokens);
173    }
174
175    /** @param list<Token> $tokens */
176    private static function skipWs(array $tokens, int $i): int
177    {
178        while (isset($tokens[$i]) && $tokens[$i] instanceof WhitespaceToken) {
179            $i++;
180        }
181        return $i;
182    }
183
184    /** @param list<Token> $tokens */
185    private static function findOfKeyword(array $tokens): ?int
186    {
187        foreach ($tokens as $i => $t) {
188            if ($t instanceof IdentToken && strtolower($t->value) === 'of') {
189                return $i;
190            }
191        }
192        return null;
193    }
194}