Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.74% covered (warning)
80.74%
570 / 706
24.44% covered (danger)
24.44%
11 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValueParser
80.74% covered (warning)
80.74%
570 / 706
24.44% covered (danger)
24.44%
11 / 45
1348.60
0.00% covered (danger)
0.00%
0 / 1
 parseFromString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseSlashList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseSpaceList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseSingle
85.00% covered (warning)
85.00%
34 / 40
0.00% covered (danger)
0.00%
0 / 1
17.98
 parseFunction
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 parseTransform
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postProcessTransform
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 valueToTransformFunction
56.25% covered (warning)
56.25%
18 / 32
0.00% covered (danger)
0.00%
0 / 1
151.64
 buildTranslate
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 buildRotate3d
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 buildScale
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildSkew
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 toLengthOrPct
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
19.12
 toAngleDeg
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
10.40
 toFloat
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 parseArgs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseRgbFunction
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
8.19
 extractRgbComponent
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 extractAlphaComponent
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 splitRgbSpaceForm
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 parseHslFunction
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
 extractHueComponent
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
16.98
 extractPercentageComponent
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 hslToRgb
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 parseHexColor
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
6.63
 parseVarFunction
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 parseColorFunction
72.41% covered (warning)
72.41%
21 / 29
0.00% covered (danger)
0.00%
0 / 1
21.37
 extractColorComponent
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 parseCalcFunction
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 parseCalcSum
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
15.22
 parseCalcProduct
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
15.22
 parseCalcValue
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
17.19
 isMatchingParenWrap
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 parseLinearGradient
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
6.12
 parseLinearAngleHeader
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 sidesToAngle
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
29.85
 parseGradientStop
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
7.39
 parseRadialGradient
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
6.08
 isRadialHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseRadialHeader
75.61% covered (warning)
75.61%
31 / 41
0.00% covered (danger)
0.00%
0 / 1
21.19
 trimWhitespace
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 splitTopLevel
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 splitTopLevelDelim
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
13
 splitOnWhitespace
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css;
6
7use Phpdftk\Css\Token\CommaToken;
8use Phpdftk\Css\Token\DelimToken;
9use Phpdftk\Css\Token\DimensionToken;
10use Phpdftk\Css\Token\EofToken;
11use Phpdftk\Css\Token\FunctionToken;
12use Phpdftk\Css\Token\HashToken;
13use Phpdftk\Css\Token\IdentToken;
14use Phpdftk\Css\Token\LeftBraceToken;
15use Phpdftk\Css\Token\LeftBracketToken;
16use Phpdftk\Css\Token\LeftParenToken;
17use Phpdftk\Css\Token\NumberToken;
18use Phpdftk\Css\Token\PercentageToken;
19use Phpdftk\Css\Token\RightBraceToken;
20use Phpdftk\Css\Token\RightBracketToken;
21use Phpdftk\Css\Token\RightParenToken;
22use Phpdftk\Css\Token\StringToken;
23use Phpdftk\Css\Token\Token;
24use Phpdftk\Css\Token\UrlToken;
25use Phpdftk\Css\Token\WhitespaceToken;
26use Phpdftk\Css\Value\Angle;
27use Phpdftk\Css\Value\AngleUnit;
28use Phpdftk\Css\Value\Calc;
29use Phpdftk\Css\Value\CalcBinary;
30use Phpdftk\Css\Value\CalcExpression;
31use Phpdftk\Css\Value\CalcFunc;
32use Phpdftk\Css\Value\CalcFunction;
33use Phpdftk\Css\Value\CalcLeaf;
34use Phpdftk\Css\Value\CalcOp;
35use Phpdftk\Css\Value\Color;
36use Phpdftk\Css\Value\ColorSpace;
37use Phpdftk\Css\Value\CssFunction;
38use Phpdftk\Css\Value\CustomProperty;
39use Phpdftk\Css\Value\Gradient;
40use Phpdftk\Css\Value\GradientShape;
41use Phpdftk\Css\Value\GradientStop;
42use Phpdftk\Css\Value\Integer;
43use Phpdftk\Css\Value\Keyword;
44use Phpdftk\Css\Value\Length;
45use Phpdftk\Css\Value\LengthUnit;
46use Phpdftk\Css\Value\LinearGradient;
47use Phpdftk\Css\Value\ListSeparator;
48use Phpdftk\Css\Value\MatrixTransform;
49use Phpdftk\Css\Value\NamedColors;
50use Phpdftk\Css\Value\Number;
51use Phpdftk\Css\Value\Percentage;
52use Phpdftk\Css\Value\RadialGradient;
53use Phpdftk\Css\Value\RotateTransform;
54use Phpdftk\Css\Value\ScaleTransform;
55use Phpdftk\Css\Value\SkewTransform;
56use Phpdftk\Css\Value\StringValue;
57use Phpdftk\Css\Value\Transform;
58use Phpdftk\Css\Value\TransformFunction;
59use Phpdftk\Css\Value\TranslateTransform;
60use Phpdftk\Css\Value\Url;
61use Phpdftk\Css\Value\Value;
62use Phpdftk\Css\Value\ValueList;
63
64/**
65 * Parses a CSS token stream into typed Value instances per CSS Values 4.
66 *
67 * Phase 1A.2 covers the common path: keywords, numbers, integers,
68 * percentages, lengths (all units), colors (hex / named / rgb / rgba /
69 * hsl / hsla / transparent), strings, urls, single-argument function
70 * fallback. Calc, gradients, transforms, and color() with explicit space
71 * are deferred â€” they get dedicated parsers in 1A.2-bis.
72 */
73final class ValueParser
74{
75    /**
76     * Parse the entire token stream as a single value. Whitespace-separated
77     * tokens become a `ValueList(Space)`; comma-separated become
78     * `ValueList(Comma)`. A lone value is returned bare.
79     */
80    public function parseFromString(string $css): Value
81    {
82        $tokens = (new Tokenizer($css))->tokenize();
83        return $this->parse($tokens);
84    }
85
86    /** @param list<Token> $tokens */
87    public function parse(array $tokens): Value
88    {
89        $tokens = self::trimWhitespace($tokens);
90
91        // Split on top-level commas first.
92        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
93        if (count($commaGroups) > 1) {
94            $values = array_map(fn($g): Value => $this->parseSlashList($g), $commaGroups);
95            return new ValueList($values, ListSeparator::Comma);
96        }
97        return $this->parseSlashList($tokens);
98    }
99
100    /**
101     * Per CSS Values 4, `/` is a top-level separator inside a comma group
102     * for several shorthands (`font: 16px/1.5 sans-serif`, `border-radius:
103     * 4px / 8px`, `background: <bg-color> / <bg-size>` etc.). Splits the
104     * group on top-level `/` delims, then recurses into the space-list
105     * parser for each slash segment.
106     *
107     * @param list<Token> $tokens
108     */
109    private function parseSlashList(array $tokens): Value
110    {
111        $slashGroups = self::splitTopLevelDelim($tokens, '/');
112        if (count($slashGroups) > 1) {
113            $values = array_map(fn($g): Value => $this->parseSpaceList($g), $slashGroups);
114            return new ValueList($values, ListSeparator::Slash);
115        }
116        return $this->parseSpaceList($tokens);
117    }
118
119    /** @param list<Token> $tokens */
120    private function parseSpaceList(array $tokens): Value
121    {
122        $tokens = self::trimWhitespace($tokens);
123        $parts = self::splitOnWhitespace($tokens);
124        if (count($parts) === 1) {
125            return $this->parseSingle($parts[0]);
126        }
127        $values = array_map(fn($p): Value => $this->parseSingle($p), $parts);
128        return new ValueList($values, ListSeparator::Space);
129    }
130
131    /** @param list<Token> $tokens A single value without separators. */
132    private function parseSingle(array $tokens): Value
133    {
134        if ($tokens === []) {
135            return new Keyword(''); // shouldn't happen in well-formed input
136        }
137        $head = $tokens[0];
138        if ($head instanceof IdentToken) {
139            $name = strtolower($head->value);
140            if ($name === 'transparent') {
141                return new Color(0, 0, 0, 0);
142            }
143            $named = NamedColors::lookup($name);
144            if ($named !== null) {
145                return $named;
146            }
147            return new Keyword($name);
148        }
149        if ($head instanceof HashToken) {
150            $color = $this->parseHexColor($head->value);
151            if ($color !== null) {
152                return $color;
153            }
154            return new Keyword('#' . $head->value);
155        }
156        if ($head instanceof NumberToken) {
157            return $head->type->name === 'Integer'
158                ? new Integer((int) $head->value)
159                : new Number($head->value);
160        }
161        if ($head instanceof PercentageToken) {
162            return new Percentage($head->value);
163        }
164        if ($head instanceof DimensionToken) {
165            $unit = strtolower($head->unit);
166            $lengthUnit = LengthUnit::tryFrom($unit);
167            if ($lengthUnit !== null) {
168                return new Length($head->value, $lengthUnit);
169            }
170            $angleUnit = AngleUnit::tryFrom($unit);
171            if ($angleUnit !== null) {
172                return new Angle($head->value, $angleUnit);
173            }
174            // Unknown unit â€” fall back to a CssFunction representation so the
175            // value survives round-trip without being silently dropped.
176            return new CssFunction($head->unit, [new Number($head->value)]);
177        }
178        if ($head instanceof StringToken) {
179            return new StringValue($head->value);
180        }
181        if ($head instanceof UrlToken) {
182            return new Url($head->value);
183        }
184        if ($head instanceof FunctionToken) {
185            return $this->parseFunction($head->name, array_slice($tokens, 1));
186        }
187        // Fallback â€” keep the raw delim as a one-char keyword.
188        if ($head instanceof DelimToken) {
189            return new Keyword($head->value);
190        }
191        return new Keyword('');
192    }
193
194    /**
195     * @param list<Token> $tokens The function body, excluding the
196     *        FunctionToken header and the closing RightParenToken.
197     */
198    private function parseFunction(string $name, array $tokens): Value
199    {
200        $name = strtolower($name);
201        // Drop the trailing ) if present.
202        if ($tokens !== [] && end($tokens) instanceof RightParenToken) {
203            array_pop($tokens);
204        }
205        if ($name === 'url') {
206            // url("...") form.
207            foreach ($tokens as $tok) {
208                if ($tok instanceof StringToken) {
209                    return new Url($tok->value);
210                }
211            }
212            return new Url('');
213        }
214        if ($name === 'rgb' || $name === 'rgba') {
215            return $this->parseRgbFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
216        }
217        if ($name === 'hsl' || $name === 'hsla') {
218            return $this->parseHslFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
219        }
220        if ($name === 'var') {
221            return $this->parseVarFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
222        }
223        if ($name === 'color') {
224            return $this->parseColorFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
225        }
226        if (CalcFunction::tryFrom($name) !== null) {
227            $calc = $this->parseCalcFunction(CalcFunction::from($name), $tokens);
228            if ($calc !== null) {
229                return $calc;
230            }
231        }
232        if ($name === 'linear-gradient' || $name === 'repeating-linear-gradient') {
233            $g = $this->parseLinearGradient($tokens, $name === 'repeating-linear-gradient');
234            if ($g !== null) {
235                return $g;
236            }
237        }
238        if ($name === 'radial-gradient' || $name === 'repeating-radial-gradient') {
239            $g = $this->parseRadialGradient($tokens, $name === 'repeating-radial-gradient');
240            if ($g !== null) {
241                return $g;
242            }
243        }
244        // Generic fallback: each comma-separated group becomes one argument.
245        return new CssFunction($name, $this->parseArgs($tokens));
246    }
247
248    /**
249     * Parse a sequence of transform functions into a single Transform value.
250     * Call from the cascade when the property being computed is `transform`.
251     */
252    public function parseTransform(string $css): Value
253    {
254        return $this->postProcessTransform($this->parseFromString($css));
255    }
256
257    /**
258     * Convert an already-parsed generic value (`CssFunction` or
259     * `ValueList` of `CssFunction`s) into a typed `Transform` value.
260     * Falls back to the original value when a function isn't a
261     * recognised transform â€” preserves `none` and any future
262     * additions that aren't yet handled.
263     */
264    public function postProcessTransform(Value $value): Value
265    {
266        if ($value instanceof Transform) {
267            return $value;
268        }
269        $items = $value instanceof ValueList ? $value->values : [$value];
270        $fns = [];
271        foreach ($items as $v) {
272            $fn = $this->valueToTransformFunction($v);
273            if ($fn === null) {
274                return $value;
275            }
276            $fns[] = $fn;
277        }
278        return new Transform($fns);
279    }
280
281    private function valueToTransformFunction(Value $value): ?TransformFunction
282    {
283        if (!$value instanceof CssFunction) {
284            return null;
285        }
286        $name = strtolower($value->name);
287        $args = $value->arguments;
288        return match ($name) {
289            'translate' => $this->buildTranslate($args, false),
290            'translatex' => count($args) === 1 ? new TranslateTransform($this->toLengthOrPct($args[0]), new Length(0, LengthUnit::Px)) : null,
291            'translatey' => count($args) === 1 ? new TranslateTransform(new Length(0, LengthUnit::Px), $this->toLengthOrPct($args[0])) : null,
292            'translatez' => count($args) === 1 && $args[0] instanceof Length ? new TranslateTransform(new Length(0, LengthUnit::Px), new Length(0, LengthUnit::Px), $args[0]) : null,
293            'translate3d' => $this->buildTranslate($args, true),
294            'rotate' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0])) : null,
295            'rotatex' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 1.0, 0.0, 0.0) : null,
296            'rotatey' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 0.0, 1.0, 0.0) : null,
297            'rotatez' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 0.0, 0.0, 1.0) : null,
298            'rotate3d' => $this->buildRotate3d($args),
299            'scale' => $this->buildScale($args),
300            'scalex' => count($args) === 1 ? new ScaleTransform($this->toFloat($args[0]), 1.0) : null,
301            'scaley' => count($args) === 1 ? new ScaleTransform(1.0, $this->toFloat($args[0])) : null,
302            'scalez' => count($args) === 1 ? new ScaleTransform(1.0, 1.0, $this->toFloat($args[0])) : null,
303            'scale3d' => count($args) === 3 ? new ScaleTransform($this->toFloat($args[0]), $this->toFloat($args[1]), $this->toFloat($args[2])) : null,
304            'skew' => $this->buildSkew($args),
305            'skewx' => count($args) === 1 ? new SkewTransform($this->toAngleDeg($args[0])) : null,
306            'skewy' => count($args) === 1 ? new SkewTransform(0.0, $this->toAngleDeg($args[0])) : null,
307            'matrix' => count($args) === 6 ? new MatrixTransform(
308                $this->toFloat($args[0]),
309                $this->toFloat($args[1]),
310                $this->toFloat($args[2]),
311                $this->toFloat($args[3]),
312                $this->toFloat($args[4]),
313                $this->toFloat($args[5]),
314            ) : null,
315            default => null,
316        };
317    }
318
319    /** @param list<Value> $args */
320    private function buildTranslate(array $args, bool $is3d): ?TransformFunction
321    {
322        if ($is3d) {
323            if (count($args) !== 3) {
324                return null;
325            }
326            $z = $args[2] instanceof Length ? $args[2] : null;
327            if ($z === null) {
328                return null;
329            }
330            return new TranslateTransform($this->toLengthOrPct($args[0]), $this->toLengthOrPct($args[1]), $z);
331        }
332        if (count($args) === 1) {
333            return new TranslateTransform($this->toLengthOrPct($args[0]), new Length(0, LengthUnit::Px));
334        }
335        if (count($args) === 2) {
336            return new TranslateTransform($this->toLengthOrPct($args[0]), $this->toLengthOrPct($args[1]));
337        }
338        return null;
339    }
340
341    /** @param list<Value> $args */
342    private function buildRotate3d(array $args): ?RotateTransform
343    {
344        if (count($args) !== 4) {
345            return null;
346        }
347        return new RotateTransform(
348            $this->toAngleDeg($args[3]),
349            $this->toFloat($args[0]),
350            $this->toFloat($args[1]),
351            $this->toFloat($args[2]),
352        );
353    }
354
355    /** @param list<Value> $args */
356    private function buildScale(array $args): ?ScaleTransform
357    {
358        if (count($args) === 1) {
359            $sx = $this->toFloat($args[0]);
360            return new ScaleTransform($sx, $sx);
361        }
362        if (count($args) === 2) {
363            return new ScaleTransform($this->toFloat($args[0]), $this->toFloat($args[1]));
364        }
365        return null;
366    }
367
368    /** @param list<Value> $args */
369    private function buildSkew(array $args): ?SkewTransform
370    {
371        if (count($args) === 1) {
372            return new SkewTransform($this->toAngleDeg($args[0]));
373        }
374        if (count($args) === 2) {
375            return new SkewTransform($this->toAngleDeg($args[0]), $this->toAngleDeg($args[1]));
376        }
377        return null;
378    }
379
380    private function toLengthOrPct(Value $v): Length|Percentage
381    {
382        if ($v instanceof Length) {
383            return $v;
384        }
385        if ($v instanceof Percentage) {
386            return $v;
387        }
388        if ($v instanceof Number || $v instanceof Integer) {
389            // Per spec: in some contexts unitless numbers are treated as pixels.
390            return new Length((float) ($v instanceof Number ? $v->value : $v->value), LengthUnit::Px);
391        }
392        return new Length(0, LengthUnit::Px);
393    }
394
395    private function toAngleDeg(Value $v): float
396    {
397        if ($v instanceof Angle) {
398            return $v->toDegrees();
399        }
400        if ($v instanceof Number || $v instanceof Integer) {
401            // Unitless 0 is the only valid angle without a unit per spec;
402            // other unitless values are technically a parse error, but we
403            // accept them by treating as degrees.
404            return (float) ($v instanceof Number ? $v->value : $v->value);
405        }
406        return 0.0;
407    }
408
409    private function toFloat(Value $v): float
410    {
411        if ($v instanceof Number || $v instanceof Integer) {
412            return (float) ($v instanceof Number ? $v->value : $v->value);
413        }
414        return 0.0;
415    }
416
417    /** @param list<Token> $tokens
418     *  @return list<Value>
419     */
420    private function parseArgs(array $tokens): array
421    {
422        $groups = self::splitTopLevel($tokens, CommaToken::class);
423        return array_map(fn($g): Value => $this->parseSpaceList($g), $groups);
424    }
425
426    /** @param list<Token> $tokens */
427    private function parseRgbFunction(array $tokens): ?Color
428    {
429        // Accept both legacy comma form `rgb(r, g, b[, a])` and modern
430        // space form `rgb(r g b [/ a])`. Components are number 0-255 or
431        // percentage 0-100; alpha is number 0-1 or percentage.
432        $tokens = self::trimWhitespace($tokens);
433        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
434        if (count($commaGroups) >= 3) {
435            $groups = $commaGroups;
436        } else {
437            // Space-separated; the slash separates alpha.
438            $groups = self::splitRgbSpaceForm($tokens);
439        }
440        $count = count($groups);
441        if ($count < 3 || $count > 4) {
442            return null;
443        }
444        $rgb = [];
445        for ($i = 0; $i < 3; $i++) {
446            $v = $this->extractRgbComponent($groups[$i]);
447            if ($v === null) {
448                return null;
449            }
450            $rgb[] = $v;
451        }
452        $a = 1.0;
453        if ($count === 4) {
454            $alphaTok = $this->extractAlphaComponent($groups[3]);
455            if ($alphaTok === null) {
456                return null;
457            }
458            $a = $alphaTok;
459        }
460        return new Color($rgb[0], $rgb[1], $rgb[2], $a);
461    }
462
463    /** @param list<Token> $group */
464    private function extractRgbComponent(array $group): ?float
465    {
466        $group = self::trimWhitespace($group);
467        if (count($group) !== 1) {
468            return null;
469        }
470        $t = $group[0];
471        if ($t instanceof NumberToken) {
472            return max(0.0, min(1.0, $t->value / 255.0));
473        }
474        if ($t instanceof PercentageToken) {
475            return max(0.0, min(1.0, $t->value / 100.0));
476        }
477        return null;
478    }
479
480    /** @param list<Token> $group */
481    private function extractAlphaComponent(array $group): ?float
482    {
483        $group = self::trimWhitespace($group);
484        if (count($group) !== 1) {
485            return null;
486        }
487        $t = $group[0];
488        if ($t instanceof NumberToken) {
489            return max(0.0, min(1.0, $t->value));
490        }
491        if ($t instanceof PercentageToken) {
492            return max(0.0, min(1.0, $t->value / 100.0));
493        }
494        return null;
495    }
496
497    /**
498     * Split tokens by top-level whitespace, with `/` recognised as the
499     * alpha separator that yields a 4th group.
500     *
501     * @param list<Token> $tokens
502     * @return list<list<Token>>
503     */
504    private static function splitRgbSpaceForm(array $tokens): array
505    {
506        $groups = [];
507        $current = [];
508        foreach ($tokens as $t) {
509            if ($t instanceof WhitespaceToken) {
510                if ($current !== []) {
511                    $groups[] = $current;
512                    $current = [];
513                }
514                continue;
515            }
516            if ($t instanceof DelimToken && $t->value === '/') {
517                if ($current !== []) {
518                    $groups[] = $current;
519                    $current = [];
520                }
521                continue;
522            }
523            $current[] = $t;
524        }
525        if ($current !== []) {
526            $groups[] = $current;
527        }
528        return $groups;
529    }
530
531    /** @param list<Token> $tokens */
532    private function parseHslFunction(array $tokens): ?Color
533    {
534        $tokens = self::trimWhitespace($tokens);
535        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
536        $groups = count($commaGroups) >= 3 ? $commaGroups : self::splitRgbSpaceForm($tokens);
537        $count = count($groups);
538        if ($count < 3 || $count > 4) {
539            return null;
540        }
541        $h = $this->extractHueComponent($groups[0]);
542        $s = $this->extractPercentageComponent($groups[1]);
543        $l = $this->extractPercentageComponent($groups[2]);
544        if ($h === null || $s === null || $l === null) {
545            return null;
546        }
547        $a = $count === 4 ? $this->extractAlphaComponent($groups[3]) : 1.0;
548        if ($a === null) {
549            return null;
550        }
551        [$r, $g, $b] = self::hslToRgb($h, $s, $l);
552        return new Color($r, $g, $b, $a);
553    }
554
555    /** @param list<Token> $group */
556    private function extractHueComponent(array $group): ?float
557    {
558        $group = self::trimWhitespace($group);
559        if (count($group) !== 1) {
560            return null;
561        }
562        $t = $group[0];
563        if ($t instanceof NumberToken) {
564            return fmod(fmod($t->value, 360) + 360, 360) / 360.0; // normalise to [0, 1)
565        }
566        if ($t instanceof DimensionToken) {
567            $degrees = match (strtolower($t->unit)) {
568                'deg' => $t->value,
569                'rad' => $t->value * 180 / M_PI,
570                'grad' => $t->value * 0.9,
571                'turn' => $t->value * 360,
572                default => null,
573            };
574            if ($degrees === null) {
575                return null;
576            }
577            return fmod(fmod($degrees, 360) + 360, 360) / 360.0;
578        }
579        return null;
580    }
581
582    /** @param list<Token> $group */
583    private function extractPercentageComponent(array $group): ?float
584    {
585        $group = self::trimWhitespace($group);
586        if (count($group) !== 1) {
587            return null;
588        }
589        $t = $group[0];
590        if ($t instanceof PercentageToken) {
591            return max(0.0, min(1.0, $t->value / 100.0));
592        }
593        return null;
594    }
595
596    /**
597     * Convert HSL (each component in [0, 1]) to sRGB (each component in [0, 1])
598     * per CSS Color 4 algorithm.
599     *
600     * @return array{0:float,1:float,2:float}
601     */
602    private static function hslToRgb(float $h, float $s, float $l): array
603    {
604        if ($s === 0.0) {
605            return [$l, $l, $l];
606        }
607        $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
608        $p = 2 * $l - $q;
609        $hueToRgb = static function (float $p, float $q, float $t): float {
610            if ($t < 0) {
611                $t += 1;
612            }
613            if ($t > 1) {
614                $t -= 1;
615            }
616            if ($t < 1 / 6) {
617                return $p + ($q - $p) * 6 * $t;
618            }
619            if ($t < 1 / 2) {
620                return $q;
621            }
622            if ($t < 2 / 3) {
623                return $p + ($q - $p) * (2 / 3 - $t) * 6;
624            }
625            return $p;
626        };
627        return [
628            $hueToRgb($p, $q, $h + 1 / 3),
629            $hueToRgb($p, $q, $h),
630            $hueToRgb($p, $q, $h - 1 / 3),
631        ];
632    }
633
634    private function parseHexColor(string $hex): ?Color
635    {
636        $hex = strtolower($hex);
637        // Accept 3, 4, 6, 8 hex digits.
638        if (!preg_match('/^[0-9a-f]+$/', $hex)) {
639            return null;
640        }
641        $len = strlen($hex);
642        if ($len === 3) {
643            $r = hexdec($hex[0] . $hex[0]);
644            $g = hexdec($hex[1] . $hex[1]);
645            $b = hexdec($hex[2] . $hex[2]);
646            return new Color($r / 255.0, $g / 255.0, $b / 255.0);
647        }
648        if ($len === 4) {
649            $r = hexdec($hex[0] . $hex[0]);
650            $g = hexdec($hex[1] . $hex[1]);
651            $b = hexdec($hex[2] . $hex[2]);
652            $a = hexdec($hex[3] . $hex[3]);
653            return new Color($r / 255.0, $g / 255.0, $b / 255.0, $a / 255.0);
654        }
655        if ($len === 6) {
656            $r = hexdec(substr($hex, 0, 2));
657            $g = hexdec(substr($hex, 2, 2));
658            $b = hexdec(substr($hex, 4, 2));
659            return new Color($r / 255.0, $g / 255.0, $b / 255.0);
660        }
661        if ($len === 8) {
662            $r = hexdec(substr($hex, 0, 2));
663            $g = hexdec(substr($hex, 2, 2));
664            $b = hexdec(substr($hex, 4, 2));
665            $a = hexdec(substr($hex, 6, 2));
666            return new Color($r / 255.0, $g / 255.0, $b / 255.0, $a / 255.0);
667        }
668        return null;
669    }
670
671    // ============================================================
672    // var(--name, fallback)
673    // ============================================================
674    /** @param list<Token> $tokens */
675    private function parseVarFunction(array $tokens): ?CustomProperty
676    {
677        $tokens = self::trimWhitespace($tokens);
678        $groups = self::splitTopLevel($tokens, CommaToken::class);
679        if (count($groups) < 1 || count($groups) > 2) {
680            return null;
681        }
682        $nameTokens = self::trimWhitespace($groups[0]);
683        if (count($nameTokens) !== 1 || !($nameTokens[0] instanceof IdentToken)) {
684            return null;
685        }
686        $name = $nameTokens[0]->value;
687        if (!str_starts_with($name, '--')) {
688            return null;
689        }
690        $fallback = null;
691        if (count($groups) === 2) {
692            $fallback = $this->parseSpaceList($groups[1]);
693        }
694        return new CustomProperty($name, $fallback);
695    }
696
697    // ============================================================
698    // color(<space> r g b [/ a])
699    // ============================================================
700    /** @param list<Token> $tokens */
701    private function parseColorFunction(array $tokens): ?Color
702    {
703        $tokens = self::trimWhitespace($tokens);
704        $groups = self::splitRgbSpaceForm($tokens);
705        // Expect: [space] [r] [g] [b] (4 groups), with optional [a] (5 groups).
706        if (count($groups) < 4 || count($groups) > 5) {
707            return null;
708        }
709        $spaceTokens = self::trimWhitespace($groups[0]);
710        if (count($spaceTokens) !== 1 || !($spaceTokens[0] instanceof IdentToken)) {
711            return null;
712        }
713        $space = match (strtolower($spaceTokens[0]->value)) {
714            'srgb' => ColorSpace::sRGB,
715            'display-p3' => ColorSpace::DisplayP3,
716            'a98-rgb' => ColorSpace::A98RGB,
717            'prophoto-rgb' => ColorSpace::ProPhotoRGB,
718            'rec2020' => ColorSpace::Rec2020,
719            default => null,
720        };
721        if ($space === null) {
722            return null;
723        }
724        $components = [];
725        for ($i = 1; $i <= 3; $i++) {
726            $c = $this->extractColorComponent($groups[$i]);
727            if ($c === null) {
728                return null;
729            }
730            $components[] = $c;
731        }
732        $a = 1.0;
733        if (count($groups) === 5) {
734            $aValue = $this->extractAlphaComponent($groups[4]);
735            if ($aValue === null) {
736                return null;
737            }
738            $a = $aValue;
739        }
740        return new Color($components[0], $components[1], $components[2], $a, $space);
741    }
742
743    /** @param list<Token> $group */
744    private function extractColorComponent(array $group): ?float
745    {
746        $group = self::trimWhitespace($group);
747        if (count($group) !== 1) {
748            return null;
749        }
750        $t = $group[0];
751        if ($t instanceof NumberToken) {
752            // color() takes 0-1 numbers for sRGB-ish spaces.
753            return max(0.0, min(1.0, $t->value));
754        }
755        if ($t instanceof PercentageToken) {
756            return max(0.0, min(1.0, $t->value / 100.0));
757        }
758        return null;
759    }
760
761    // ============================================================
762    // calc() / min() / max() / clamp() / sin() / cos() / ...
763    // ============================================================
764    /** @param list<Token> $tokens */
765    private function parseCalcFunction(CalcFunction $func, array $tokens): ?Calc
766    {
767        $tokens = self::trimWhitespace($tokens);
768        if ($func === CalcFunction::Calc) {
769            $expr = $this->parseCalcSum($tokens);
770            if ($expr === null) {
771                return null;
772            }
773            return new Calc($expr);
774        }
775        // Multi-argument math functions: comma-separated arguments.
776        $groups = self::splitTopLevel($tokens, CommaToken::class);
777        $args = [];
778        foreach ($groups as $group) {
779            $expr = $this->parseCalcSum(self::trimWhitespace($group));
780            if ($expr === null) {
781                return null;
782            }
783            $args[] = $expr;
784        }
785        return new Calc(new CalcFunc($func, $args));
786    }
787
788    /**
789     * Parse a calc-sum expression: product (('+' | '-') product)*. Per CSS
790     * Values 4 the +/- operators MUST have whitespace on both sides; the
791     * tokenizer guarantees this by greedily consuming `+2`/`-2` as signed
792     * numbers when there's no whitespace.
793     *
794     * @param list<Token> $tokens
795     */
796    private function parseCalcSum(array $tokens): ?CalcExpression
797    {
798        $tokens = self::trimWhitespace($tokens);
799        if ($tokens === []) {
800            return null;
801        }
802        // Split on top-level +/- delim tokens.
803        $parts = []; // alternating: [expr, op, expr, op, ...]
804        $current = [];
805        $depth = 0;
806        foreach ($tokens as $t) {
807            if ($depth === 0 && $t instanceof DelimToken && ($t->value === '+' || $t->value === '-')) {
808                $parts[] = self::trimWhitespace($current);
809                $parts[] = $t->value;
810                $current = [];
811                continue;
812            }
813            $cls = $t::class;
814            if (str_ends_with($cls, '\\LeftParenToken') || $t instanceof FunctionToken) {
815                $depth++;
816            } elseif (str_ends_with($cls, '\\RightParenToken')) {
817                if ($depth > 0) {
818                    $depth--;
819                }
820            }
821            $current[] = $t;
822        }
823        $parts[] = self::trimWhitespace($current);
824
825        $left = $this->parseCalcProduct($parts[0]);
826        if ($left === null) {
827            return null;
828        }
829        for ($i = 1; $i < count($parts); $i += 2) {
830            $op = $parts[$i] === '+' ? CalcOp::Add : CalcOp::Sub;
831            $right = $this->parseCalcProduct($parts[$i + 1] ?? []);
832            if ($right === null) {
833                return null;
834            }
835            $left = new CalcBinary($left, $op, $right);
836        }
837        return $left;
838    }
839
840    /** @param list<Token> $tokens */
841    private function parseCalcProduct(array $tokens): ?CalcExpression
842    {
843        $tokens = self::trimWhitespace($tokens);
844        if ($tokens === []) {
845            return null;
846        }
847        $parts = [];
848        $current = [];
849        $depth = 0;
850        foreach ($tokens as $t) {
851            if ($depth === 0 && $t instanceof DelimToken && ($t->value === '*' || $t->value === '/')) {
852                $parts[] = self::trimWhitespace($current);
853                $parts[] = $t->value;
854                $current = [];
855                continue;
856            }
857            $cls = $t::class;
858            if (str_ends_with($cls, '\\LeftParenToken') || $t instanceof FunctionToken) {
859                $depth++;
860            } elseif (str_ends_with($cls, '\\RightParenToken')) {
861                if ($depth > 0) {
862                    $depth--;
863                }
864            }
865            $current[] = $t;
866        }
867        $parts[] = self::trimWhitespace($current);
868
869        $left = $this->parseCalcValue($parts[0]);
870        if ($left === null) {
871            return null;
872        }
873        for ($i = 1; $i < count($parts); $i += 2) {
874            $op = $parts[$i] === '*' ? CalcOp::Mul : CalcOp::Div;
875            $right = $this->parseCalcValue($parts[$i + 1] ?? []);
876            if ($right === null) {
877                return null;
878            }
879            $left = new CalcBinary($left, $op, $right);
880        }
881        return $left;
882    }
883
884    /** @param list<Token> $tokens */
885    private function parseCalcValue(array $tokens): ?CalcExpression
886    {
887        $tokens = self::trimWhitespace($tokens);
888        if ($tokens === []) {
889            return null;
890        }
891        // Parenthesised expression â€” strip a balanced outer pair.
892        $head = $tokens[0];
893        $last = $tokens[count($tokens) - 1];
894        if ($this->isMatchingParenWrap($tokens)) {
895            return $this->parseCalcSum(array_slice($tokens, 1, count($tokens) - 2));
896        }
897        // Inline math function â€” e.g. nested calc, min, max.
898        if ($head instanceof FunctionToken && $last instanceof RightParenToken && count($tokens) >= 2) {
899            $inner = array_slice($tokens, 1, count($tokens) - 2);
900            $name = strtolower($head->name);
901            $func = CalcFunction::tryFrom($name);
902            if ($func === CalcFunction::Calc) {
903                return $this->parseCalcSum($inner);
904            }
905            if ($func !== null) {
906                $groups = self::splitTopLevel($inner, CommaToken::class);
907                $args = [];
908                foreach ($groups as $g) {
909                    $expr = $this->parseCalcSum(self::trimWhitespace($g));
910                    if ($expr === null) {
911                        return null;
912                    }
913                    $args[] = $expr;
914                }
915                return new CalcFunc($func, $args);
916            }
917            return null;
918        }
919        // Single primitive value.
920        if (count($tokens) === 1) {
921            $value = $this->parseSingle($tokens);
922            if ($value instanceof Number || $value instanceof Integer
923                || $value instanceof Length || $value instanceof Percentage
924                || $value instanceof Angle
925            ) {
926                return new CalcLeaf($value);
927            }
928        }
929        return null;
930    }
931
932    /** @param list<Token> $tokens */
933    private function isMatchingParenWrap(array $tokens): bool
934    {
935        if (count($tokens) < 2) {
936            return false;
937        }
938        $head = $tokens[0];
939        $last = $tokens[count($tokens) - 1];
940        if (!str_ends_with($head::class, '\\LeftParenToken')) {
941            return false;
942        }
943        if (!str_ends_with($last::class, '\\RightParenToken')) {
944            return false;
945        }
946        // Walk to verify the leading paren matches the trailing one (not
947        // a separate nested pair).
948        $depth = 0;
949        $matched = -1;
950        foreach ($tokens as $i => $t) {
951            if (str_ends_with($t::class, '\\LeftParenToken') || $t instanceof FunctionToken) {
952                $depth++;
953            } elseif (str_ends_with($t::class, '\\RightParenToken')) {
954                $depth--;
955                if ($depth === 0) {
956                    $matched = $i;
957                    break;
958                }
959            }
960        }
961        return $matched === count($tokens) - 1;
962    }
963
964    // ============================================================
965    // linear-gradient / radial-gradient
966    // ============================================================
967    /** @param list<Token> $tokens */
968    private function parseLinearGradient(array $tokens, bool $repeating): ?LinearGradient
969    {
970        $tokens = self::trimWhitespace($tokens);
971        $groups = self::splitTopLevel($tokens, CommaToken::class);
972        if (count($groups) < 2) {
973            return null;
974        }
975        // First group may be the angle/side spec, or directly a colour stop.
976        $first = self::trimWhitespace($groups[0]);
977        $angleDeg = 180.0; // default: top â†’ bottom
978        $stopGroups = $groups;
979        $maybeAngle = $this->parseLinearAngleHeader($first);
980        if ($maybeAngle !== null) {
981            $angleDeg = $maybeAngle;
982            $stopGroups = array_slice($groups, 1);
983        }
984        $stops = [];
985        foreach ($stopGroups as $g) {
986            $stop = $this->parseGradientStop($g);
987            if ($stop === null) {
988                return null;
989            }
990            $stops[] = $stop;
991        }
992        if (count($stops) < 2) {
993            return null;
994        }
995        return new LinearGradient($angleDeg, $stops, $repeating);
996    }
997
998    /**
999     * Parse `<angle>` or `to <side-or-corner>` into degrees, or null if the
1000     * first comma-group is actually a colour stop.
1001     *
1002     * @param list<Token> $tokens
1003     */
1004    private function parseLinearAngleHeader(array $tokens): ?float
1005    {
1006        $tokens = self::trimWhitespace($tokens);
1007        if ($tokens === []) {
1008            return null;
1009        }
1010        $head = $tokens[0];
1011        // Direct angle: e.g. `45deg`, `0.25turn`.
1012        if ($head instanceof DimensionToken) {
1013            $unit = AngleUnit::tryFrom(strtolower($head->unit));
1014            if ($unit !== null) {
1015                return $unit->toDegrees($head->value);
1016            }
1017        }
1018        // `to <side> [<side>]`.
1019        if ($head instanceof IdentToken && strtolower($head->value) === 'to') {
1020            $sides = [];
1021            for ($i = 1; $i < count($tokens); $i++) {
1022                if ($tokens[$i] instanceof WhitespaceToken) {
1023                    continue;
1024                }
1025                if ($tokens[$i] instanceof IdentToken) {
1026                    $sides[] = strtolower($tokens[$i]->value);
1027                }
1028            }
1029            return self::sidesToAngle($sides);
1030        }
1031        return null;
1032    }
1033
1034    /** @param list<string> $sides */
1035    private static function sidesToAngle(array $sides): float
1036    {
1037        // Per spec table.
1038        sort($sides);
1039        $key = implode(' ', $sides);
1040        return match ($key) {
1041            'top' => 0.0,
1042            'right top', 'top right' => 45.0,
1043            'right' => 90.0,
1044            'bottom right', 'right bottom' => 135.0,
1045            'bottom' => 180.0,
1046            'bottom left', 'left bottom' => 225.0,
1047            'left' => 270.0,
1048            'left top', 'top left' => 315.0,
1049            default => 180.0,
1050        };
1051    }
1052
1053    /** @param list<Token> $tokens */
1054    private function parseGradientStop(array $tokens): ?GradientStop
1055    {
1056        $tokens = self::trimWhitespace($tokens);
1057        if ($tokens === []) {
1058            return null;
1059        }
1060        // Split by whitespace: <color> [<position>]
1061        $parts = self::splitOnWhitespace($tokens);
1062        if (count($parts) === 0) {
1063            return null;
1064        }
1065        $colorValue = $this->parseSingle($parts[0]);
1066        if (!$colorValue instanceof Color) {
1067            return null;
1068        }
1069        $position = null;
1070        if (count($parts) >= 2) {
1071            $posVal = $this->parseSingle($parts[1]);
1072            if ($posVal instanceof Length || $posVal instanceof Percentage) {
1073                $position = $posVal;
1074            }
1075        }
1076        return new GradientStop($colorValue, $position);
1077    }
1078
1079    /** @param list<Token> $tokens */
1080    private function parseRadialGradient(array $tokens, bool $repeating): ?RadialGradient
1081    {
1082        $tokens = self::trimWhitespace($tokens);
1083        $groups = self::splitTopLevel($tokens, CommaToken::class);
1084        if (count($groups) < 2) {
1085            return null;
1086        }
1087        // First group MAY be shape/size [at position]; otherwise it's a stop.
1088        $first = self::trimWhitespace($groups[0]);
1089        $shape = GradientShape::Ellipse;
1090        $sizeX = null;
1091        $sizeY = null;
1092        $centerX = null;
1093        $centerY = null;
1094        $stopGroups = $groups;
1095        if ($this->isRadialHeader($first)) {
1096            [$shape, $sizeX, $sizeY, $centerX, $centerY] = $this->parseRadialHeader($first);
1097            $stopGroups = array_slice($groups, 1);
1098        }
1099        $stops = [];
1100        foreach ($stopGroups as $g) {
1101            $stop = $this->parseGradientStop($g);
1102            if ($stop === null) {
1103                return null;
1104            }
1105            $stops[] = $stop;
1106        }
1107        if (count($stops) < 2) {
1108            return null;
1109        }
1110        return new RadialGradient($shape, $sizeX, $sizeY, $centerX, $centerY, $stops, $repeating);
1111    }
1112
1113    /** @param list<Token> $tokens */
1114    private function isRadialHeader(array $tokens): bool
1115    {
1116        foreach ($tokens as $t) {
1117            if ($t instanceof IdentToken
1118                && in_array(strtolower($t->value), ['circle', 'ellipse', 'at', 'closest-side', 'closest-corner', 'farthest-side', 'farthest-corner'], true)
1119            ) {
1120                return true;
1121            }
1122        }
1123        return false;
1124    }
1125
1126    /**
1127     * @param list<Token> $tokens
1128     * @return array{0: GradientShape, 1: ?Length, 2: ?Length, 3: ?Length, 4: ?Length}
1129     */
1130    private function parseRadialHeader(array $tokens): array
1131    {
1132        $shape = GradientShape::Ellipse;
1133        $sizeX = null;
1134        $sizeY = null;
1135        $centerX = null;
1136        $centerY = null;
1137        $atIdx = null;
1138        foreach ($tokens as $i => $t) {
1139            if ($t instanceof IdentToken && strtolower($t->value) === 'at') {
1140                $atIdx = $i;
1141                break;
1142            }
1143        }
1144        $headerPart = $atIdx !== null ? array_slice($tokens, 0, $atIdx) : $tokens;
1145        foreach (self::splitOnWhitespace($headerPart) as $piece) {
1146            $piece = self::trimWhitespace($piece);
1147            if ($piece === []) {
1148                continue;
1149            }
1150            $head = $piece[0];
1151            if ($head instanceof IdentToken) {
1152                $name = strtolower($head->value);
1153                if ($name === 'circle') {
1154                    $shape = GradientShape::Circle;
1155                } elseif ($name === 'ellipse') {
1156                    $shape = GradientShape::Ellipse;
1157                }
1158                // closest-side etc. are sizing keywords; ignored for now.
1159            } elseif ($head instanceof DimensionToken) {
1160                $val = $this->parseSingle($piece);
1161                if ($val instanceof Length) {
1162                    if ($sizeX === null) {
1163                        $sizeX = $val;
1164                    } else {
1165                        $sizeY = $val;
1166                    }
1167                }
1168            }
1169        }
1170        if ($atIdx !== null) {
1171            $positionPart = array_slice($tokens, $atIdx + 1);
1172            $positions = [];
1173            foreach (self::splitOnWhitespace($positionPart) as $piece) {
1174                $piece = self::trimWhitespace($piece);
1175                if ($piece === []) {
1176                    continue;
1177                }
1178                $val = $this->parseSingle($piece);
1179                if ($val instanceof Length) {
1180                    $positions[] = $val;
1181                }
1182            }
1183            $centerX = $positions[0] ?? null;
1184            $centerY = $positions[1] ?? null;
1185        }
1186        return [$shape, $sizeX, $sizeY, $centerX, $centerY];
1187    }
1188
1189    // ============================================================
1190    // Token-stream utilities
1191    // ============================================================
1192
1193    /** @param list<Token> $tokens
1194     *  @return list<Token>
1195     */
1196    private static function trimWhitespace(array $tokens): array
1197    {
1198        $start = 0;
1199        $end = count($tokens) - 1;
1200        while ($start <= $end && ($tokens[$start] instanceof WhitespaceToken || $tokens[$start] instanceof EofToken)) {
1201            $start++;
1202        }
1203        while ($end >= $start && ($tokens[$end] instanceof WhitespaceToken || $tokens[$end] instanceof EofToken)) {
1204            $end--;
1205        }
1206        return array_slice($tokens, $start, $end - $start + 1);
1207    }
1208
1209    /**
1210     * Split a flat token list at top-level instances of $separator (i.e.
1211     * outside any nested parens / brackets).
1212     *
1213     * @param list<Token> $tokens
1214     * @param class-string<Token> $separator
1215     * @return list<list<Token>>
1216     */
1217    private static function splitTopLevel(array $tokens, string $separator): array
1218    {
1219        $groups = [];
1220        $current = [];
1221        $depth = 0;
1222        foreach ($tokens as $t) {
1223            $cls = $t::class;
1224            if ($depth === 0 && $cls === $separator) {
1225                $groups[] = $current;
1226                $current = [];
1227                continue;
1228            }
1229            if (str_ends_with($cls, '\\LeftParenToken')
1230                || str_ends_with($cls, '\\LeftBracketToken')
1231                || str_ends_with($cls, '\\LeftBraceToken')
1232                || $t instanceof FunctionToken
1233            ) {
1234                $depth++;
1235            } elseif (str_ends_with($cls, '\\RightParenToken')
1236                || str_ends_with($cls, '\\RightBracketToken')
1237                || str_ends_with($cls, '\\RightBraceToken')
1238            ) {
1239                if ($depth > 0) {
1240                    $depth--;
1241                }
1242            }
1243            $current[] = $t;
1244        }
1245        $groups[] = $current;
1246        return $groups;
1247    }
1248
1249    /**
1250     * Like {@see splitTopLevel} but splits on a specific `DelimToken` value
1251     * (typically `/` for the slash-shorthand pattern).
1252     *
1253     * @param list<Token> $tokens
1254     * @return list<list<Token>>
1255     */
1256    private static function splitTopLevelDelim(array $tokens, string $delim): array
1257    {
1258        $groups = [];
1259        $current = [];
1260        $depth = 0;
1261        foreach ($tokens as $t) {
1262            if ($depth === 0 && $t instanceof DelimToken && $t->value === $delim) {
1263                $groups[] = $current;
1264                $current = [];
1265                continue;
1266            }
1267            if ($t instanceof LeftParenToken
1268                || $t instanceof LeftBracketToken
1269                || $t instanceof LeftBraceToken
1270                || $t instanceof FunctionToken
1271            ) {
1272                $depth++;
1273            } elseif ($t instanceof RightParenToken
1274                || $t instanceof RightBracketToken
1275                || $t instanceof RightBraceToken
1276            ) {
1277                if ($depth > 0) {
1278                    $depth--;
1279                }
1280            }
1281            $current[] = $t;
1282        }
1283        $groups[] = $current;
1284        return $groups;
1285    }
1286
1287    /** @param list<Token> $tokens
1288     *  @return list<list<Token>>
1289     */
1290    private static function splitOnWhitespace(array $tokens): array
1291    {
1292        $groups = [];
1293        $current = [];
1294        $depth = 0;
1295        foreach ($tokens as $t) {
1296            if ($depth === 0 && $t instanceof WhitespaceToken) {
1297                if ($current !== []) {
1298                    $groups[] = $current;
1299                    $current = [];
1300                }
1301                continue;
1302            }
1303            $cls = $t::class;
1304            if (str_ends_with($cls, '\\LeftParenToken')
1305                || str_ends_with($cls, '\\LeftBracketToken')
1306                || str_ends_with($cls, '\\LeftBraceToken')
1307                || $t instanceof FunctionToken
1308            ) {
1309                $depth++;
1310            } elseif (str_ends_with($cls, '\\RightParenToken')
1311                || str_ends_with($cls, '\\RightBracketToken')
1312                || str_ends_with($cls, '\\RightBraceToken')
1313            ) {
1314                if ($depth > 0) {
1315                    $depth--;
1316                }
1317            }
1318            $current[] = $t;
1319        }
1320        if ($current !== []) {
1321            $groups[] = $current;
1322        }
1323        return $groups;
1324    }
1325}