• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MyIntervals / PHP-CSS-Parser / 13888006503

16 Mar 2025 10:28PM UTC coverage: 55.929% (-0.1%) from 56.036%
13888006503

push

github

web-flow
[TASK] Avoid the deprecated `__toString()` in tests (#1180)

Moving some tests to functional tests and splitting them up
will come in later changes for #1057.

1047 of 1872 relevant lines covered (55.93%)

12.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.19
/src/Value/Color.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\Value;
6

7
use Sabberworm\CSS\OutputFormat;
8
use Sabberworm\CSS\Parsing\ParserState;
9
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
10
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
11

12
/**
13
 * `Color's can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of
14
 * ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form.
15
 */
16
class Color extends CSSFunction
17
{
18
    /**
19
     * @param array<array-key, Value|string> $colorValues
20
     * @param int<0, max> $lineNumber
21
     */
22
    public function __construct(array $colorValues, int $lineNumber = 0)
62✔
23
    {
24
        parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
62✔
25
    }
62✔
26

27
    /**
28
     * @throws UnexpectedEOFException
29
     * @throws UnexpectedTokenException
30
     *
31
     * @internal since V8.8.0
32
     */
33
    public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
121✔
34
    {
35
        return
36
            $parserState->comes('#')
121✔
37
                ? self::parseHexColor($parserState)
23✔
38
                : self::parseColorFunction($parserState);
113✔
39
    }
40

41
    /**
42
     * @throws UnexpectedEOFException
43
     * @throws UnexpectedTokenException
44
     */
45
    private static function parseHexColor(ParserState $parserState): CSSFunction
23✔
46
    {
47
        $parserState->consume('#');
23✔
48
        $hexValue = $parserState->parseIdentifier(false);
23✔
49
        if ($parserState->strlen($hexValue) === 3) {
22✔
50
            $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2];
6✔
51
        } elseif ($parserState->strlen($hexValue) === 4) {
18✔
52
            $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2]
3✔
53
                . $hexValue[3] . $hexValue[3];
3✔
54
        }
55

56
        if ($parserState->strlen($hexValue) === 8) {
22✔
57
            $colorValues = [
58
                'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
4✔
59
                'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
4✔
60
                'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
4✔
61
                'a' => new Size(
4✔
62
                    \round(self::mapRange(\intval($hexValue[6] . $hexValue[7], 16), 0, 255, 0, 1), 2),
4✔
63
                    null,
4✔
64
                    true,
4✔
65
                    $parserState->currentLine()
4✔
66
                ),
67
            ];
68
        } elseif ($parserState->strlen($hexValue) === 6) {
19✔
69
            $colorValues = [
70
                'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
12✔
71
                'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
12✔
72
                'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
12✔
73
            ];
74
        } else {
75
            throw new UnexpectedTokenException(
9✔
76
                'Invalid hex color value',
9✔
77
                $hexValue,
78
                'custom',
9✔
79
                $parserState->currentLine()
9✔
80
            );
81
        }
82

83
        return new Color($colorValues, $parserState->currentLine());
15✔
84
    }
85

86
    /**
87
     * @throws UnexpectedEOFException
88
     * @throws UnexpectedTokenException
89
     */
90
    private static function parseColorFunction(ParserState $parserState): CSSFunction
102✔
91
    {
92
        $colorValues = [];
102✔
93

94
        $colorMode = $parserState->parseIdentifier(true);
102✔
95
        $parserState->consumeWhiteSpace();
102✔
96
        $parserState->consume('(');
102✔
97

98
        // CSS Color Module Level 4 says that `rgb` and `rgba` are now aliases; likewise `hsl` and `hsla`.
99
        // So, attempt to parse with the `a`, and allow for it not being there.
100
        switch ($colorMode) {
102✔
101
            case 'rgb':
102✔
102
                $colorModeForParsing = 'rgba';
49✔
103
                $mayHaveOptionalAlpha = true;
49✔
104
                break;
49✔
105
            case 'hsl':
56✔
106
                $colorModeForParsing = 'hsla';
26✔
107
                $mayHaveOptionalAlpha = true;
26✔
108
                break;
26✔
109
            case 'rgba':
32✔
110
                // This is handled identically to the following case.
111
            case 'hsla':
5✔
112
                $colorModeForParsing = $colorMode;
32✔
113
                $mayHaveOptionalAlpha = true;
32✔
114
                break;
32✔
115
            default:
116
                $colorModeForParsing = $colorMode;
×
117
                $mayHaveOptionalAlpha = false;
×
118
        }
119

120
        $containsVar = false;
102✔
121
        $containsNone = false;
102✔
122
        $isLegacySyntax = false;
102✔
123
        $expectedArgumentCount = $parserState->strlen($colorModeForParsing);
102✔
124
        for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) {
102✔
125
            $parserState->consumeWhiteSpace();
102✔
126
            $valueKey = $colorModeForParsing[$argumentIndex];
102✔
127
            if ($parserState->comes('var')) {
102✔
128
                $colorValues[$valueKey] = CSSFunction::parseIdentifierOrFunction($parserState);
31✔
129
                $containsVar = true;
31✔
130
            } elseif (!$isLegacySyntax && $parserState->comes('none')) {
101✔
131
                $colorValues[$valueKey] = $parserState->parseIdentifier();
10✔
132
                $containsNone = true;
10✔
133
            } else {
134
                $colorValues[$valueKey] = Size::parse($parserState, true);
101✔
135
            }
136

137
            // This must be done first, to consume comments as well, so that the `comes` test will work.
138
            $parserState->consumeWhiteSpace();
102✔
139

140
            // With a `var` argument, the function can have fewer arguments.
141
            // And as of CSS Color Module Level 4, the alpha argument is optional.
142
            $canCloseNow =
143
                $containsVar
102✔
144
                || ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2);
102✔
145
            if ($canCloseNow && $parserState->comes(')')) {
102✔
146
                break;
80✔
147
            }
148

149
            // "Legacy" syntax is comma-delimited, and does not allow the `none` keyword.
150
            // "Modern" syntax is space-delimited, with `/` as alpha delimiter.
151
            // They cannot be mixed.
152
            if ($argumentIndex === 0 && !$containsNone) {
101✔
153
                // An immediate closing parenthesis is not valid.
154
                if ($parserState->comes(')')) {
97✔
155
                    throw new UnexpectedTokenException(
4✔
156
                        'Color function with no arguments',
4✔
157
                        '',
4✔
158
                        'custom',
4✔
159
                        $parserState->currentLine()
4✔
160
                    );
161
                }
162
                $isLegacySyntax = $parserState->comes(',');
93✔
163
            }
164

165
            if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) {
97✔
166
                $parserState->consume(',');
52✔
167
            }
168

169
            // In the "modern" syntax, the alpha value must be delimited with `/`.
170
            if (!$isLegacySyntax) {
97✔
171
                if ($containsVar) {
45✔
172
                    // If the `var` substitution encompasses more than one argument,
173
                    // the alpha deliminator may come at any time.
174
                    if ($parserState->comes('/')) {
9✔
175
                        $parserState->consume('/');
9✔
176
                    }
177
                } elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') {
40✔
178
                    // Alpha value is the next expected argument.
179
                    // Since a closing parenthesis was not found, a `/` separator is now required.
180
                    $parserState->consume('/');
17✔
181
                }
182
            }
183
        }
184
        $parserState->consume(')');
84✔
185

186
        return
187
            $containsVar
80✔
188
                ? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine())
31✔
189
                : new Color($colorValues, $parserState->currentLine());
80✔
190
    }
191

192
    private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
4✔
193
    {
194
        $fromRange = $fromMax - $fromMin;
4✔
195
        $toRange = $toMax - $toMin;
4✔
196
        $multiplier = $toRange / $fromRange;
4✔
197
        $newValue = $value - $fromMin;
4✔
198
        $newValue *= $multiplier;
4✔
199
        return $newValue + $toMin;
4✔
200
    }
201

202
    /**
203
     * @return array<array-key, Value|string>
204
     */
205
    public function getColor()
2✔
206
    {
207
        return $this->components;
2✔
208
    }
209

210
    /**
211
     * @param array<array-key, Value|string> $colorValues
212
     */
213
    public function setColor(array $colorValues): void
×
214
    {
215
        $this->setName(\implode('', \array_keys($colorValues)));
×
216
        $this->components = $colorValues;
×
217
    }
×
218

219
    /**
220
     * @return string
221
     */
222
    public function getColorDescription()
1✔
223
    {
224
        return $this->getName();
1✔
225
    }
226

227
    /**
228
     * @deprecated in V8.8.0, will be removed in V9.0.0. Use `render` instead.
229
     */
230
    public function __toString(): string
×
231
    {
232
        return $this->render(new OutputFormat());
×
233
    }
234

235
    public function render(OutputFormat $outputFormat): string
59✔
236
    {
237
        if ($this->shouldRenderAsHex($outputFormat)) {
59✔
238
            return $this->renderAsHex();
15✔
239
        }
240

241
        if ($this->shouldRenderInModernSyntax()) {
48✔
242
            return $this->renderInModernSyntax($outputFormat);
17✔
243
        }
244

245
        return parent::render($outputFormat);
31✔
246
    }
247

248
    private function shouldRenderAsHex(OutputFormat $outputFormat): bool
59✔
249
    {
250
        return
251
            $outputFormat->usesRgbHashNotation()
59✔
252
            && $this->getRealName() === 'rgb'
59✔
253
            && $this->allComponentsAreNumbers();
59✔
254
    }
255

256
    /**
257
     * The function name is a concatenation of the array keys of the components, which is passed to the constructor.
258
     * However, this can be changed by calling {@see CSSFunction::setName},
259
     * so is not reliable in situations where it's necessary to determine the function name based on the components.
260
     */
261
    private function getRealName(): string
59✔
262
    {
263
        return \implode('', \array_keys($this->components));
59✔
264
    }
265

266
    /**
267
     * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
268
     * If any component is not an instance of `Size`, the method will also return `false`.
269
     */
270
    private function allComponentsAreNumbers(): bool
26✔
271
    {
272
        foreach ($this->components as $component) {
26✔
273
            if (!($component instanceof Size) || $component->getUnit() !== null) {
26✔
274
                return false;
11✔
275
            }
276
        }
277

278
        return true;
15✔
279
    }
280

281
    /**
282
     * Note that this method assumes the following:
283
     * - The `components` array has keys for `r`, `g` and `b`;
284
     * - The values in the array are all instances of `Size`.
285
     *
286
     * Errors will be triggered or thrown if this is not the case.
287
     *
288
     * @return non-empty-string
289
     */
290
    private function renderAsHex(): string
15✔
291
    {
292
        $result = \sprintf(
15✔
293
            '%02x%02x%02x',
15✔
294
            $this->components['r']->getSize(),
15✔
295
            $this->components['g']->getSize(),
15✔
296
            $this->components['b']->getSize()
15✔
297
        );
298
        $canUseShortVariant = ($result[0] == $result[1]) && ($result[2] == $result[3]) && ($result[4] == $result[5]);
15✔
299

300
        return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
15✔
301
    }
302

303
    /**
304
     * The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s,
305
     * and does not allow `none` as any component value.
306
     *
307
     * The "legacy" and "modern" monikers are part of the formal W3C syntax.
308
     * See the following for more information:
309
     * - {@link
310
     *     https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#formal_syntax
311
     *     Description of the formal syntax for `rgb()` on MDN
312
     *   };
313
     * - {@link
314
     *     https://www.w3.org/TR/css-color-4/#rgb-functions
315
     *     The same in the CSS Color Module Level 4 W3C Candidate Recommendation Draft
316
     *   } (as of 13 February 2024, at time of writing).
317
     */
318
    private function shouldRenderInModernSyntax(): bool
48✔
319
    {
320
        if ($this->hasNoneAsComponentValue()) {
48✔
321
            return true;
8✔
322
        }
323

324
        if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) {
40✔
325
            return false;
15✔
326
        }
327

328
        $hasPercentage = false;
27✔
329
        $hasNumber = false;
27✔
330
        foreach ($this->components as $key => $value) {
27✔
331
            if ($key === 'a') {
27✔
332
                // Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
333
                // So it is not necessary to check it.  It's also always last, hence `break` rather than `continue`.
334
                break;
19✔
335
            }
336
            if (!($value instanceof Size)) {
27✔
337
                // Unexpected, unknown, or modified via the API
338
                return false;
×
339
            }
340
            $unit = $value->getUnit();
27✔
341
            // `switch` only does loose comparison
342
            if ($unit === null) {
27✔
343
                $hasNumber = true;
21✔
344
            } elseif ($unit === '%') {
15✔
345
                $hasPercentage = true;
15✔
346
            } else {
347
                // Invalid unit
348
                return false;
×
349
            }
350
        }
351

352
        return $hasPercentage && $hasNumber;
27✔
353
    }
354

355
    private function hasNoneAsComponentValue(): bool
48✔
356
    {
357
        return \in_array('none', $this->components, true);
48✔
358
    }
359

360
    /**
361
     * Some color functions, such as `rgb`,
362
     * may have a mixture of `percentage`, `number`, or possibly other types in their arguments.
363
     *
364
     * Note that this excludes the alpha component, which is treated separately.
365
     */
366
    private function colorFunctionMayHaveMixedValueTypes(string $function): bool
40✔
367
    {
368
        $functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba'];
40✔
369

370
        return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
40✔
371
    }
372

373
    /**
374
     * @return non-empty-string
375
     */
376
    private function renderInModernSyntax(OutputFormat $outputFormat): string
17✔
377
    {
378
        // Maybe not yet without alpha, but will be...
379
        $componentsWithoutAlpha = $this->components;
17✔
380
        \end($componentsWithoutAlpha);
17✔
381
        if (\key($componentsWithoutAlpha) === 'a') {
17✔
382
            $alpha = $this->components['a'];
5✔
383
            unset($componentsWithoutAlpha['a']);
5✔
384
        }
385

386
        $formatter = $outputFormat->getFormatter();
17✔
387
        $arguments = $formatter->implode(' ', $componentsWithoutAlpha);
17✔
388
        if (isset($alpha)) {
17✔
389
            $separator = $formatter->spaceBeforeListArgumentSeparator('/')
5✔
390
                . '/' . $formatter->spaceAfterListArgumentSeparator('/');
5✔
391
            $arguments = $formatter->implode($separator, [$arguments, $alpha]);
5✔
392
        }
393

394
        return $this->getName() . '(' . $arguments . ')';
17✔
395
    }
396
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc