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

MyIntervals / PHP-CSS-Parser / 17176929358

23 Aug 2025 03:07PM UTC coverage: 59.658% (+0.06%) from 59.595%
17176929358

push

github

web-flow
[BUGFIX] Use the safe regexp functions in `ParserState` (#1370)

Part of #1168

0 of 7 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

1118 of 1874 relevant lines covered (59.66%)

25.34 hits per line

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

0.0
/src/Parsing/ParserState.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\Parsing;
6

7
use Sabberworm\CSS\Comment\Comment;
8
use Sabberworm\CSS\Settings;
9

10
use function Safe\iconv;
11
use function Safe\preg_match;
12
use function Safe\preg_split;
13

14
/**
15
 * @internal since 8.7.0
16
 */
17
class ParserState
18
{
19
    /**
20
     * @var null
21
     */
22
    public const EOF = null;
23

24
    /**
25
     * @var Settings
26
     */
27
    private $parserSettings;
28

29
    /**
30
     * @var string
31
     */
32
    private $text;
33

34
    /**
35
     * @var array<int, string>
36
     */
37
    private $characters;
38

39
    /**
40
     * @var int<0, max>
41
     */
42
    private $currentPosition = 0;
43

44
    /**
45
     * will only be used if the CSS does not contain an `@charset` declaration
46
     *
47
     * @var string
48
     */
49
    private $charset;
50

51
    /**
52
     * @var int<1, max> $lineNumber
53
     */
54
    private $lineNumber;
55

56
    /**
57
     * @param string $text the complete CSS as text (i.e., usually the contents of a CSS file)
58
     * @param int<1, max> $lineNumber
59
     */
60
    public function __construct(string $text, Settings $parserSettings, int $lineNumber = 1)
×
61
    {
62
        $this->parserSettings = $parserSettings;
×
63
        $this->text = $text;
×
64
        $this->lineNumber = $lineNumber;
×
65
        $this->setCharset($this->parserSettings->getDefaultCharset());
×
66
    }
×
67

68
    /**
69
     * Sets the charset to be used if the CSS does not contain an `@charset` declaration.
70
     */
UNCOV
71
    public function setCharset(string $charset): void
×
72
    {
73
        $this->charset = $charset;
×
74
        $this->characters = $this->strsplit($this->text);
×
75
    }
×
76

77
    /**
78
     * @return int<1, max>
79
     */
80
    public function currentLine(): int
×
81
    {
82
        return $this->lineNumber;
×
83
    }
84

85
    /**
86
     * @return int<0, max>
87
     */
88
    public function currentColumn(): int
×
89
    {
90
        return $this->currentPosition;
×
91
    }
92

93
    public function getSettings(): Settings
×
94
    {
95
        return $this->parserSettings;
×
96
    }
97

98
    public function anchor(): Anchor
×
99
    {
100
        return new Anchor($this->currentPosition, $this);
×
101
    }
102

103
    /**
104
     * @param int<0, max> $position
105
     */
106
    public function setPosition(int $position): void
×
107
    {
108
        $this->currentPosition = $position;
×
109
    }
×
110

111
    /**
112
     * @return non-empty-string
113
     *
114
     * @throws UnexpectedTokenException
115
     */
116
    public function parseIdentifier(bool $ignoreCase = true): string
×
117
    {
118
        if ($this->isEnd()) {
×
119
            throw new UnexpectedEOFException('', '', 'identifier', $this->lineNumber);
×
120
        }
121
        $result = $this->parseCharacter(true);
×
122
        if ($result === null) {
×
123
            throw new UnexpectedTokenException('', $this->peek(5), 'identifier', $this->lineNumber);
×
124
        }
125
        $character = null;
×
126
        while (!$this->isEnd() && ($character = $this->parseCharacter(true)) !== null) {
×
NEW
127
            if (preg_match('/[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_-]/Sux', $character) !== 0) {
×
128
                $result .= $character;
×
129
            } else {
130
                $result .= '\\' . $character;
×
131
            }
132
        }
133
        if ($ignoreCase) {
×
134
            $result = $this->strtolower($result);
×
135
        }
136

137
        return $result;
×
138
    }
139

140
    /**
141
     * @throws UnexpectedEOFException
142
     * @throws UnexpectedTokenException
143
     */
144
    public function parseCharacter(bool $isForIdentifier): ?string
×
145
    {
146
        if ($this->peek() === '\\') {
×
147
            $this->consume('\\');
×
148
            if ($this->comes('\\n') || $this->comes('\\r')) {
×
149
                return '';
×
150
            }
NEW
151
            if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
×
152
                return $this->consume(1);
×
153
            }
154
            $hexCodePoint = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
×
155
            if ($this->strlen($hexCodePoint) < 6) {
×
156
                // Consume whitespace after incomplete unicode escape
NEW
157
                if (preg_match('/\\s/isSu', $this->peek()) !== 0) {
×
158
                    if ($this->comes('\\r\\n')) {
×
159
                        $this->consume(2);
×
160
                    } else {
161
                        $this->consume(1);
×
162
                    }
163
                }
164
            }
165
            $codePoint = \intval($hexCodePoint, 16);
×
166
            $utf32EncodedCharacter = '';
×
167
            for ($i = 0; $i < 4; ++$i) {
×
168
                $utf32EncodedCharacter .= \chr($codePoint & 0xff);
×
169
                $codePoint = $codePoint >> 8;
×
170
            }
NEW
171
            return iconv('utf-32le', $this->charset, $utf32EncodedCharacter);
×
172
        }
173
        if ($isForIdentifier) {
×
174
            $peek = \ord($this->peek());
×
175
            // Ranges: a-z A-Z 0-9 - _
176
            if (
177
                ($peek >= 97 && $peek <= 122)
×
178
                || ($peek >= 65 && $peek <= 90)
×
179
                || ($peek >= 48 && $peek <= 57)
×
180
                || ($peek === 45)
×
181
                || ($peek === 95)
×
182
                || ($peek > 0xa1)
×
183
            ) {
184
                return $this->consume(1);
×
185
            }
186
        } else {
187
            return $this->consume(1);
×
188
        }
189

190
        return null;
×
191
    }
192

193
    /**
194
     * @return list<Comment>
195
     *
196
     * @throws UnexpectedEOFException
197
     * @throws UnexpectedTokenException
198
     */
199
    public function consumeWhiteSpace(): array
×
200
    {
201
        $comments = [];
×
202
        do {
NEW
203
            while (preg_match('/\\s/isSu', $this->peek()) === 1) {
×
204
                $this->consume(1);
×
205
            }
206
            if ($this->parserSettings->usesLenientParsing()) {
×
207
                try {
208
                    $comment = $this->consumeComment();
×
209
                } catch (UnexpectedEOFException $e) {
×
210
                    $this->currentPosition = \count($this->characters);
×
211
                    break;
×
212
                }
213
            } else {
214
                $comment = $this->consumeComment();
×
215
            }
216
            if ($comment instanceof Comment) {
×
217
                $comments[] = $comment;
×
218
            }
219
        } while ($comment instanceof Comment);
×
220

221
        return $comments;
×
222
    }
223

224
    /**
225
     * @param non-empty-string $string
226
     */
227
    public function comes(string $string, bool $caseInsensitive = false): bool
×
228
    {
229
        $peek = $this->peek(\strlen($string));
×
230

231
        return ($peek !== '') && $this->streql($peek, $string, $caseInsensitive);
×
232
    }
233

234
    /**
235
     * @param int<1, max> $length
236
     * @param int<0, max> $offset
237
     */
238
    public function peek(int $length = 1, int $offset = 0): string
×
239
    {
240
        $offset += $this->currentPosition;
×
241
        if ($offset >= \count($this->characters)) {
×
242
            return '';
×
243
        }
244

245
        return $this->substr($offset, $length);
×
246
    }
247

248
    /**
249
     * @param string|int<1, max> $value
250
     *
251
     * @throws UnexpectedEOFException
252
     * @throws UnexpectedTokenException
253
     */
254
    public function consume($value = 1): string
×
255
    {
256
        if (\is_string($value)) {
×
257
            $numberOfLines = \substr_count($value, "\n");
×
258
            $length = $this->strlen($value);
×
259
            if (!$this->streql($this->substr($this->currentPosition, $length), $value)) {
×
260
                throw new UnexpectedTokenException(
×
261
                    $value,
×
262
                    $this->peek(\max($length, 5)),
×
263
                    'literal',
×
264
                    $this->lineNumber
×
265
                );
266
            }
267

268
            $this->lineNumber += $numberOfLines;
×
269
            $this->currentPosition += $this->strlen($value);
×
270
            $result = $value;
×
271
        } else {
272
            if ($this->currentPosition + $value > \count($this->characters)) {
×
273
                throw new UnexpectedEOFException((string) $value, $this->peek(5), 'count', $this->lineNumber);
×
274
            }
275

276
            $result = $this->substr($this->currentPosition, $value);
×
277
            $numberOfLines = \substr_count($result, "\n");
×
278
            $this->lineNumber += $numberOfLines;
×
279
            $this->currentPosition += $value;
×
280
        }
281

282
        return $result;
×
283
    }
284

285
    /**
286
     * @param string $expression
287
     * @param int<1, max>|null $maximumLength
288
     *
289
     * @throws UnexpectedEOFException
290
     * @throws UnexpectedTokenException
291
     */
292
    public function consumeExpression(string $expression, ?int $maximumLength = null): string
×
293
    {
294
        $matches = null;
×
295
        $input = ($maximumLength !== null) ? $this->peek($maximumLength) : $this->inputLeft();
×
NEW
296
        if (preg_match($expression, $input, $matches, PREG_OFFSET_CAPTURE) !== 1) {
×
297
            throw new UnexpectedTokenException($expression, $this->peek(5), 'expression', $this->lineNumber);
×
298
        }
299

300
        return $this->consume($matches[0][0]);
×
301
    }
302

303
    /**
304
     * @return Comment|false
305
     */
306
    public function consumeComment()
×
307
    {
308
        $lineNumber = $this->lineNumber;
×
309
        $comment = null;
×
310

311
        if ($this->comes('/*')) {
×
312
            $this->consume(1);
×
313
            $comment = '';
×
314
            while (($char = $this->consume(1)) !== '') {
×
315
                $comment .= $char;
×
316
                if ($this->comes('*/')) {
×
317
                    $this->consume(2);
×
318
                    break;
×
319
                }
320
            }
321
        }
322

323
        // We skip the * which was included in the comment.
324
        return \is_string($comment) ? new Comment(\substr($comment, 1), $lineNumber) : false;
×
325
    }
326

327
    public function isEnd(): bool
×
328
    {
329
        return $this->currentPosition >= \count($this->characters);
×
330
    }
331

332
    /**
333
     * @param list<string|self::EOF>|string|self::EOF $stopCharacters
334
     * @param array<int, Comment> $comments
335
     *
336
     * @throws UnexpectedEOFException
337
     * @throws UnexpectedTokenException
338
     */
339
    public function consumeUntil(
×
340
        $stopCharacters,
341
        bool $includeEnd = false,
342
        bool $consumeEnd = false,
343
        array &$comments = []
344
    ): string {
345
        $stopCharacters = \is_array($stopCharacters) ? $stopCharacters : [$stopCharacters];
×
346
        $consumedCharacters = '';
×
347
        $start = $this->currentPosition;
×
348

349
        while (!$this->isEnd()) {
×
350
            $character = $this->consume(1);
×
351
            if (\in_array($character, $stopCharacters, true)) {
×
352
                if ($includeEnd) {
×
353
                    $consumedCharacters .= $character;
×
354
                } elseif (!$consumeEnd) {
×
355
                    $this->currentPosition -= $this->strlen($character);
×
356
                }
357
                return $consumedCharacters;
×
358
            }
359
            $consumedCharacters .= $character;
×
360
            $comment = $this->consumeComment();
×
361
            if ($comment instanceof Comment) {
×
362
                $comments[] = $comment;
×
363
            }
364
        }
365

366
        if (\in_array(self::EOF, $stopCharacters, true)) {
×
367
            return $consumedCharacters;
×
368
        }
369

370
        $this->currentPosition = $start;
×
371
        throw new UnexpectedEOFException(
×
372
            'One of ("' . \implode('","', $stopCharacters) . '")',
×
373
            $this->peek(5),
×
374
            'search',
×
375
            $this->lineNumber
×
376
        );
377
    }
378

379
    private function inputLeft(): string
×
380
    {
381
        return $this->substr($this->currentPosition, -1);
×
382
    }
383

384
    public function streql(string $string1, string $string2, bool $caseInsensitive = true): bool
×
385
    {
386
        return $caseInsensitive
×
387
            ? ($this->strtolower($string1) === $this->strtolower($string2))
×
388
            : ($string1 === $string2);
×
389
    }
390

391
    /**
392
     * @param int<1, max> $numberOfCharacters
393
     */
394
    public function backtrack(int $numberOfCharacters): void
×
395
    {
396
        $this->currentPosition -= $numberOfCharacters;
×
397
    }
×
398

399
    /**
400
     * @return int<0, max>
401
     */
402
    public function strlen(string $string): int
×
403
    {
404
        return $this->parserSettings->hasMultibyteSupport()
×
405
            ? \mb_strlen($string, $this->charset)
×
406
            : \strlen($string);
×
407
    }
408

409
    /**
410
     * @param int<0, max> $offset
411
     */
412
    private function substr(int $offset, int $length): string
×
413
    {
414
        if ($length < 0) {
×
415
            $length = \count($this->characters) - $offset + $length;
×
416
        }
417
        if ($offset + $length > \count($this->characters)) {
×
418
            $length = \count($this->characters) - $offset;
×
419
        }
420
        $result = '';
×
421
        while ($length > 0) {
×
422
            $result .= $this->characters[$offset];
×
423
            $offset++;
×
424
            $length--;
×
425
        }
426

427
        return $result;
×
428
    }
429

430
    /**
431
     * @return ($string is non-empty-string ? non-empty-string : string)
432
     */
433
    private function strtolower(string $string): string
×
434
    {
435
        return $this->parserSettings->hasMultibyteSupport()
×
436
            ? \mb_strtolower($string, $this->charset)
×
437
            : \strtolower($string);
×
438
    }
439

440
    /**
441
     * @return list<string>
442
     */
UNCOV
443
    private function strsplit(string $string): array
×
444
    {
445
        if ($this->parserSettings->hasMultibyteSupport()) {
×
446
            if ($this->streql($this->charset, 'utf-8')) {
×
NEW
447
                $result = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
×
448
            } else {
449
                $length = \mb_strlen($string, $this->charset);
×
450
                $result = [];
×
451
                for ($i = 0; $i < $length; ++$i) {
×
452
                    $result[] = \mb_substr($string, $i, 1, $this->charset);
×
453
                }
454
            }
455
        } else {
456
            $result = ($string !== '') ? \str_split($string) : [];
×
457
        }
458

459
        return $result;
×
460
    }
461
}
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

© 2025 Coveralls, Inc