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

MyIntervals / PHP-CSS-Parser / 29003822848

09 Jul 2026 08:09AM UTC coverage: 71.517% (-1.1%) from 72.604%
29003822848

Pull #1484

github

web-flow
Merge fe2e76b0f into 3316f7374
Pull Request #1484: Remove `thecodingmachine/safe` dependency

25 of 78 new or added lines in 12 files covered. (32.05%)

7 existing lines in 5 files now uncovered.

1622 of 2268 relevant lines covered (71.52%)

37.55 hits per line

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

53.15
/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
/**
11
 * @internal since 8.7.0
12
 */
13
class ParserState
14
{
15
    public const EOF = null;
16

17
    /**
18
     * @var Settings
19
     */
20
    private $parserSettings;
21

22
    /**
23
     * @var string
24
     */
25
    private $text;
26

27
    /**
28
     * @var array<int, string>
29
     */
30
    private $characters;
31

32
    /**
33
     * @var int<0, max>
34
     */
35
    private $currentPosition = 0;
36

37
    /**
38
     * will only be used if the CSS does not contain an `@charset` declaration
39
     *
40
     * @var string
41
     */
42
    private $charset;
43

44
    /**
45
     * @var int<1, max> $lineNumber
46
     */
47
    private $lineNumber;
48

49
    /**
50
     * @param string $text the complete CSS as text (i.e., usually the contents of a CSS file)
51
     * @param int<1, max> $lineNumber
52
     */
53
    public function __construct(string $text, Settings $parserSettings, int $lineNumber = 1)
104✔
54
    {
55
        $this->parserSettings = $parserSettings;
104✔
56
        $this->text = $text;
104✔
57
        $this->lineNumber = $lineNumber;
104✔
58
        $this->setCharset($this->parserSettings->getDefaultCharset());
104✔
59
    }
103✔
60

61
    /**
62
     * Sets the charset to be used if the CSS does not contain an `@charset` declaration.
63
     */
64
    public function setCharset(string $charset): void
104✔
65
    {
66
        $this->charset = $charset;
104✔
67
        $this->characters = $this->strsplit($this->text);
104✔
68
    }
103✔
69

70
    /**
71
     * @return int<1, max>
72
     */
73
    public function currentLine(): int
1✔
74
    {
75
        return $this->lineNumber;
1✔
76
    }
77

78
    /**
79
     * @return int<0, max>
80
     */
81
    public function currentColumn(): int
×
82
    {
83
        return $this->currentPosition;
×
84
    }
85

86
    public function getSettings(): Settings
×
87
    {
88
        return $this->parserSettings;
×
89
    }
90

91
    public function anchor(): Anchor
×
92
    {
93
        return new Anchor($this->currentPosition, $this);
×
94
    }
95

96
    /**
97
     * @param int<0, max> $position
98
     */
99
    public function setPosition(int $position): void
×
100
    {
101
        $this->currentPosition = $position;
×
102
    }
×
103

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

134
        return $result;
×
135
    }
136

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

202
        return null;
×
203
    }
204

205
    /**
206
     * Consumes whitespace and/or comments until the next non-whitespace character that isn't a slash opening a comment.
207
     *
208
     * @param list<Comment> $comments Any comments consumed will be appended to this array.
209
     *
210
     * @return string the whitespace consumed, without the comments
211
     *
212
     * @throws UnexpectedEOFException
213
     * @throws UnexpectedTokenException
214
     *
215
     * @phpstan-impure
216
     * This method may change the state of the object by advancing the internal position;
217
     * it does not simply 'get' a value.
218
     */
219
    public function consumeWhiteSpace(array &$comments = []): string
92✔
220
    {
221
        $consumed = '';
92✔
222
        do {
223
            while (true) {
92✔
224
                /** @phpstan-ignore theCodingMachineSafe.function */
225
                $whitespaceCheck = \preg_match('/\\s/isSu', $this->peek());
92✔
226
                if ($whitespaceCheck === false) {
92✔
227
                    throw new \RuntimeException('Unexpected error');
1✔
228
                }
229
                if ($whitespaceCheck !== 1) {
91✔
230
                    break;
91✔
231
                }
232
                $consumed .= $this->consume(1);
68✔
233
            }
234
            if ($this->parserSettings->usesLenientParsing()) {
91✔
235
                try {
236
                    $comment = $this->consumeComment();
91✔
237
                } catch (UnexpectedEOFException $e) {
×
238
                    $this->currentPosition = \count($this->characters);
×
239
                    break;
91✔
240
                }
241
            } else {
242
                $comment = $this->consumeComment();
×
243
            }
244
            if ($comment instanceof Comment) {
91✔
245
                $comments[] = $comment;
49✔
246
            }
247
        } while ($comment instanceof Comment);
91✔
248

249
        return $consumed;
91✔
250
    }
251

252
    /**
253
     * @param non-empty-string $string
254
     */
255
    public function comes(string $string, bool $caseInsensitive = false): bool
97✔
256
    {
257
        $peek = $this->peek(\strlen($string));
97✔
258

259
        return ($peek !== '') && $this->streql($peek, $string, $caseInsensitive);
97✔
260
    }
261

262
    /**
263
     * @param int<1, max> $length
264
     * @param int<0, max> $offset
265
     */
266
    public function peek(int $length = 1, int $offset = 0): string
100✔
267
    {
268
        $offset += $this->currentPosition;
100✔
269
        if ($offset >= \count($this->characters)) {
100✔
270
            return '';
19✔
271
        }
272

273
        return $this->substr($offset, $length);
99✔
274
    }
275

276
    /**
277
     * @param string|int<1, max> $value
278
     *
279
     * @throws UnexpectedEOFException
280
     * @throws UnexpectedTokenException
281
     */
282
    public function consume($value = 1): string
90✔
283
    {
284
        if (\is_string($value)) {
90✔
285
            $numberOfLines = \substr_count($value, "\n");
×
286
            $length = $this->strlen($value);
×
287
            if (!$this->streql($this->substr($this->currentPosition, $length), $value)) {
×
288
                throw new UnexpectedTokenException(
×
289
                    $value,
×
290
                    $this->peek(\max($length, 5)),
×
291
                    'literal',
×
292
                    $this->lineNumber
×
293
                );
294
            }
295

296
            $this->lineNumber += $numberOfLines;
×
297
            $this->currentPosition += $this->strlen($value);
×
298
            $result = $value;
×
299
        } else {
300
            if ($this->currentPosition + $value > \count($this->characters)) {
90✔
301
                throw new UnexpectedEOFException((string) $value, $this->peek(5), 'count', $this->lineNumber);
×
302
            }
303

304
            $result = $this->substr($this->currentPosition, $value);
90✔
305
            $numberOfLines = \substr_count($result, "\n");
90✔
306
            $this->lineNumber += $numberOfLines;
90✔
307
            $this->currentPosition += $value;
90✔
308
        }
309

310
        return $result;
90✔
311
    }
312

313
    /**
314
     * If the possibly-expected next content is next, consume it.
315
     *
316
     * @param non-empty-string $nextContent
317
     *
318
     * @return bool whether the possibly-expected content was found and consumed
319
     */
320
    public function consumeIfComes(string $nextContent): bool
5✔
321
    {
322
        $length = $this->strlen($nextContent);
5✔
323
        if (!$this->streql($this->substr($this->currentPosition, $length), $nextContent)) {
5✔
324
            return false;
2✔
325
        }
326

327
        $numberOfLines = \substr_count($nextContent, "\n");
3✔
328
        $this->lineNumber += $numberOfLines;
3✔
329
        $this->currentPosition += $this->strlen($nextContent);
3✔
330

331
        return true;
3✔
332
    }
333

334
    /**
335
     * @param string $expression
336
     * @param int<1, max>|null $maximumLength
337
     *
338
     * @throws UnexpectedEOFException
339
     * @throws UnexpectedTokenException
340
     */
341
    public function consumeExpression(string $expression, ?int $maximumLength = null): string
×
342
    {
343
        $matches = null;
×
344
        $input = ($maximumLength !== null) ? $this->peek($maximumLength) : $this->inputLeft();
×
345
        /** @phpstan-ignore theCodingMachineSafe.function */
NEW
346
        if (\preg_match($expression, $input, $matches, PREG_OFFSET_CAPTURE) !== 1) {
×
UNCOV
347
            throw new UnexpectedTokenException($expression, $this->peek(5), 'expression', $this->lineNumber);
×
348
        }
349

350
        return $this->consume($matches[0][0]);
×
351
    }
352

353
    /**
354
     * @return Comment|false
355
     */
356
    public function consumeComment()
97✔
357
    {
358
        $lineNumber = $this->lineNumber;
97✔
359
        $comment = null;
97✔
360

361
        if ($this->comes('/*')) {
97✔
362
            $this->consume(1);
55✔
363
            $comment = '';
55✔
364
            while (($char = $this->consume(1)) !== '') {
55✔
365
                $comment .= $char;
55✔
366
                if ($this->comes('*/')) {
55✔
367
                    $this->consume(2);
55✔
368
                    break;
55✔
369
                }
370
            }
371
        }
372

373
        // We skip the * which was included in the comment.
374
        return \is_string($comment) ? new Comment(\substr($comment, 1), $lineNumber) : false;
97✔
375
    }
376

377
    public function isEnd(): bool
6✔
378
    {
379
        return $this->currentPosition >= \count($this->characters);
6✔
380
    }
381

382
    /**
383
     * @param list<string|self::EOF>|string|self::EOF $stopCharacters
384
     * @param list<Comment> $comments
385
     *
386
     * @throws UnexpectedEOFException
387
     * @throws UnexpectedTokenException
388
     */
389
    public function consumeUntil(
6✔
390
        $stopCharacters,
391
        bool $includeEnd = false,
392
        bool $consumeEnd = false,
393
        array &$comments = []
394
    ): string {
395
        $stopCharacters = \is_array($stopCharacters) ? $stopCharacters : [$stopCharacters];
6✔
396
        $consumedCharacters = '';
6✔
397
        $start = $this->currentPosition;
6✔
398

399
        $comments = \array_merge($comments, $this->consumeComments());
6✔
400
        while (!$this->isEnd()) {
6✔
401
            $character = $this->consume(1);
6✔
402
            if (\in_array($character, $stopCharacters, true)) {
6✔
403
                if ($includeEnd) {
6✔
404
                    $consumedCharacters .= $character;
×
405
                } elseif (!$consumeEnd) {
6✔
406
                    $this->currentPosition -= $this->strlen($character);
6✔
407
                }
408
                return $consumedCharacters;
6✔
409
            }
410
            $consumedCharacters .= $character;
6✔
411
            $comments = \array_merge($comments, $this->consumeComments());
6✔
412
        }
413

414
        if (\in_array(self::EOF, $stopCharacters, true)) {
×
415
            return $consumedCharacters;
×
416
        }
417

418
        $this->currentPosition = $start;
×
419
        throw new UnexpectedEOFException(
×
420
            'One of ("' . \implode('","', $stopCharacters) . '")',
×
421
            $this->peek(5),
×
422
            'search',
×
423
            $this->lineNumber
×
424
        );
425
    }
426

427
    private function inputLeft(): string
×
428
    {
429
        return $this->substr($this->currentPosition, -1);
×
430
    }
431

432
    public function streql(string $string1, string $string2, bool $caseInsensitive = true): bool
103✔
433
    {
434
        return $caseInsensitive
103✔
435
            ? ($this->strtolower($string1) === $this->strtolower($string2))
103✔
436
            : ($string1 === $string2);
103✔
437
    }
438

439
    /**
440
     * @param int<1, max> $numberOfCharacters
441
     */
442
    public function backtrack(int $numberOfCharacters): void
×
443
    {
444
        $this->currentPosition -= $numberOfCharacters;
×
445
    }
×
446

447
    /**
448
     * @return int<0, max>
449
     */
450
    public function strlen(string $string): int
11✔
451
    {
452
        return $this->parserSettings->hasMultibyteSupport()
11✔
453
            ? \mb_strlen($string, $this->charset)
11✔
454
            : \strlen($string);
11✔
455
    }
456

457
    /**
458
     * @param int<0, max> $offset
459
     */
460
    private function substr(int $offset, int $length): string
102✔
461
    {
462
        if ($length < 0) {
102✔
463
            $length = \count($this->characters) - $offset + $length;
×
464
        }
465
        if ($offset + $length > \count($this->characters)) {
102✔
466
            $length = \count($this->characters) - $offset;
54✔
467
        }
468
        $result = '';
102✔
469
        while ($length > 0) {
102✔
470
            $result .= $this->characters[$offset];
102✔
471
            $offset++;
102✔
472
            $length--;
102✔
473
        }
474

475
        return $result;
102✔
476
    }
477

478
    /**
479
     * @return ($string is non-empty-string ? non-empty-string : string)
480
     */
481
    private function strtolower(string $string): string
103✔
482
    {
483
        return $this->parserSettings->hasMultibyteSupport()
103✔
484
            ? \mb_strtolower($string, $this->charset)
103✔
485
            : \strtolower($string);
103✔
486
    }
487

488
    /**
489
     * @return list<string>
490
     */
491
    private function strsplit(string $string): array
104✔
492
    {
493
        if ($this->parserSettings->hasMultibyteSupport()) {
104✔
494
            if ($this->streql($this->charset, 'utf-8')) {
103✔
495
                /** @phpstan-ignore theCodingMachineSafe.function */
496
                $result = \preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
103✔
497
                if ($result === false) {
103✔
498
                    throw new \RuntimeException('Unexpected error');
103✔
499
                }
500
            } else {
501
                $length = \mb_strlen($string, $this->charset);
×
502
                $result = [];
×
503
                for ($i = 0; $i < $length; ++$i) {
102✔
504
                    $result[] = \mb_substr($string, $i, 1, $this->charset);
×
505
                }
506
            }
507
        } else {
508
            $result = ($string !== '') ? \str_split($string) : [];
1✔
509
        }
510

511
        return $result;
103✔
512
    }
513

514
    /**
515
     * @return list<Comment>
516
     */
517
    private function consumeComments(): array
6✔
518
    {
519
        $comments = [];
6✔
520

521
        while (true) {
6✔
522
            $comment = $this->consumeComment();
6✔
523
            if ($comment instanceof Comment) {
6✔
524
                $comments[] = $comment;
6✔
525
            } else {
526
                return $comments;
6✔
527
            }
528
        }
529
    }
×
530
}
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