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

MyIntervals / PHP-CSS-Parser / 21303916669

23 Jan 2026 10:55PM UTC coverage: 70.732% (-0.006%) from 70.738%
21303916669

Pull #1477

github

web-flow
Merge c8662b867 into 21bb0eb9f
Pull Request #1477: [TASK] Have `comsumeWhiteSpace()` return the consumed

6 of 12 new or added lines in 4 files covered. (50.0%)

34 existing lines in 1 file now uncovered.

1421 of 2009 relevant lines covered (70.73%)

27.58 hits per line

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

48.04
/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
    public const EOF = null;
20

21
    /**
22
     * @var Settings
23
     */
24
    private $parserSettings;
25

26
    /**
27
     * @var string
28
     */
29
    private $text;
30

31
    /**
32
     * @var array<int, string>
33
     */
34
    private $characters;
35

36
    /**
37
     * @var int<0, max>
38
     */
39
    private $currentPosition = 0;
40

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

48
    /**
49
     * @var int<1, max> $lineNumber
50
     */
51
    private $lineNumber;
52

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

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

74
    /**
75
     * @return int<1, max>
76
     */
77
    public function currentLine(): int
1✔
78
    {
79
        return $this->lineNumber;
1✔
80
    }
81

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

90
    public function getSettings(): Settings
×
91
    {
92
        return $this->parserSettings;
×
93
    }
94

95
    public function anchor(): Anchor
×
96
    {
97
        return new Anchor($this->currentPosition, $this);
×
98
    }
99

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

108
    /**
109
     * @return non-empty-string
110
     *
111
     * @throws UnexpectedTokenException
112
     */
113
    public function parseIdentifier(bool $ignoreCase = true): string
×
114
    {
115
        if ($this->isEnd()) {
×
116
            throw new UnexpectedEOFException('', '', 'identifier', $this->lineNumber);
×
117
        }
118
        $result = $this->parseCharacter(true);
×
119
        if ($result === null) {
×
120
            throw new UnexpectedTokenException('', $this->peek(5), 'identifier', $this->lineNumber);
×
121
        }
122
        $character = null;
×
123
        while (!$this->isEnd() && ($character = $this->parseCharacter(true)) !== null) {
×
124
            if (preg_match('/[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_-]/Sux', $character) !== 0) {
×
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
            if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
×
149
                return $this->consume(1);
×
150
            }
151
            $hexCodePoint = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
×
152
            if ($this->strlen($hexCodePoint) < 6) {
×
153
                // Consume whitespace after incomplete unicode escape
154
                if (preg_match('/\\s/isSu', $this->peek()) !== 0) {
×
155
                    if ($this->comes('\\r\\n')) {
×
156
                        $this->consume(2);
×
157
                    } else {
158
                        $this->consume(1);
×
159
                    }
160
                }
161
            }
162
            $codePoint = \intval($hexCodePoint, 16);
×
163
            $utf32EncodedCharacter = '';
×
164
            for ($i = 0; $i < 4; ++$i) {
×
165
                $utf32EncodedCharacter .= \chr($codePoint & 0xff);
×
166
                $codePoint = $codePoint >> 8;
×
167
            }
168
            return iconv('utf-32le', $this->charset, $utf32EncodedCharacter);
×
169
        }
170
        if ($isForIdentifier) {
×
171
            $peek = \ord($this->peek());
×
172
            // Ranges: a-z A-Z 0-9 - _
173
            if (
174
                ($peek >= 97 && $peek <= 122)
×
175
                || ($peek >= 65 && $peek <= 90)
×
176
                || ($peek >= 48 && $peek <= 57)
×
177
                || ($peek === 45)
×
178
                || ($peek === 95)
×
179
                || ($peek > 0xa1)
×
180
            ) {
181
                return $this->consume(1);
×
182
            }
183
        } else {
184
            return $this->consume(1);
×
185
        }
186

187
        return null;
×
188
    }
189

190
    /**
191
     * @param list<Comment> $comments
192
     *
193
     * @throws UnexpectedEOFException
194
     * @throws UnexpectedTokenException
195
     *
196
     * @phpstan-impure
197
     * This method may change the state of the object by advancing the internal position;
198
     * it does not simply 'get' a value.
199
     */
NEW
UNCOV
200
    public function consumeWhiteSpace(array &$comments = []): string
×
201
    {
NEW
UNCOV
202
        $consumed = '';
×
203
        do {
204
            while (preg_match('/\\s/isSu', $this->peek()) === 1) {
×
NEW
UNCOV
205
                $consumed .= $this->consume(1);
×
206
            }
UNCOV
207
            if ($this->parserSettings->usesLenientParsing()) {
×
208
                try {
209
                    $comment = $this->consumeComment();
×
UNCOV
210
                } catch (UnexpectedEOFException $e) {
×
211
                    $this->currentPosition = \count($this->characters);
×
UNCOV
212
                    break;
×
213
                }
214
            } else {
215
                $comment = $this->consumeComment();
×
216
            }
UNCOV
217
            if ($comment instanceof Comment) {
×
UNCOV
218
                $comments[] = $comment;
×
219
            }
UNCOV
220
        } while ($comment instanceof Comment);
×
221

NEW
222
        return $consumed;
×
223
    }
224

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

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

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

246
        return $this->substr($offset, $length);
8✔
247
    }
248

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

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

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

283
        return $result;
6✔
284
    }
285

286
    /**
287
     * If the possibly-expected next content is next, consume it.
288
     *
289
     * @param non-empty-string $nextContent
290
     *
291
     * @return bool whether the possibly-expected content was found and consumed
292
     */
293
    public function consumeIfComes(string $nextContent): bool
5✔
294
    {
295
        $length = $this->strlen($nextContent);
5✔
296
        if (!$this->streql($this->substr($this->currentPosition, $length), $nextContent)) {
5✔
297
            return false;
2✔
298
        }
299

300
        $numberOfLines = \substr_count($nextContent, "\n");
3✔
301
        $this->lineNumber += $numberOfLines;
3✔
302
        $this->currentPosition += $this->strlen($nextContent);
3✔
303

304
        return true;
3✔
305
    }
306

307
    /**
308
     * @param string $expression
309
     * @param int<1, max>|null $maximumLength
310
     *
311
     * @throws UnexpectedEOFException
312
     * @throws UnexpectedTokenException
313
     */
UNCOV
314
    public function consumeExpression(string $expression, ?int $maximumLength = null): string
×
315
    {
UNCOV
316
        $matches = null;
×
UNCOV
317
        $input = ($maximumLength !== null) ? $this->peek($maximumLength) : $this->inputLeft();
×
318
        if (preg_match($expression, $input, $matches, PREG_OFFSET_CAPTURE) !== 1) {
×
UNCOV
319
            throw new UnexpectedTokenException($expression, $this->peek(5), 'expression', $this->lineNumber);
×
320
        }
321

322
        return $this->consume($matches[0][0]);
×
323
    }
324

325
    /**
326
     * @return Comment|false
327
     */
328
    public function consumeComment()
6✔
329
    {
330
        $lineNumber = $this->lineNumber;
6✔
331
        $comment = null;
6✔
332

333
        if ($this->comes('/*')) {
6✔
334
            $this->consume(1);
6✔
335
            $comment = '';
6✔
336
            while (($char = $this->consume(1)) !== '') {
6✔
337
                $comment .= $char;
6✔
338
                if ($this->comes('*/')) {
6✔
339
                    $this->consume(2);
6✔
340
                    break;
6✔
341
                }
342
            }
343
        }
344

345
        // We skip the * which was included in the comment.
346
        return \is_string($comment) ? new Comment(\substr($comment, 1), $lineNumber) : false;
6✔
347
    }
348

349
    public function isEnd(): bool
6✔
350
    {
351
        return $this->currentPosition >= \count($this->characters);
6✔
352
    }
353

354
    /**
355
     * @param list<string|self::EOF>|string|self::EOF $stopCharacters
356
     * @param list<Comment> $comments
357
     *
358
     * @throws UnexpectedEOFException
359
     * @throws UnexpectedTokenException
360
     */
361
    public function consumeUntil(
6✔
362
        $stopCharacters,
363
        bool $includeEnd = false,
364
        bool $consumeEnd = false,
365
        array &$comments = []
366
    ): string {
367
        $stopCharacters = \is_array($stopCharacters) ? $stopCharacters : [$stopCharacters];
6✔
368
        $consumedCharacters = '';
6✔
369
        $start = $this->currentPosition;
6✔
370

371
        $comments = \array_merge($comments, $this->consumeComments());
6✔
372
        while (!$this->isEnd()) {
6✔
373
            $character = $this->consume(1);
6✔
374
            if (\in_array($character, $stopCharacters, true)) {
6✔
375
                if ($includeEnd) {
6✔
UNCOV
376
                    $consumedCharacters .= $character;
×
377
                } elseif (!$consumeEnd) {
6✔
378
                    $this->currentPosition -= $this->strlen($character);
6✔
379
                }
380
                return $consumedCharacters;
6✔
381
            }
382
            $consumedCharacters .= $character;
6✔
383
            $comments = \array_merge($comments, $this->consumeComments());
6✔
384
        }
385

UNCOV
386
        if (\in_array(self::EOF, $stopCharacters, true)) {
×
UNCOV
387
            return $consumedCharacters;
×
388
        }
389

390
        $this->currentPosition = $start;
×
391
        throw new UnexpectedEOFException(
×
UNCOV
392
            'One of ("' . \implode('","', $stopCharacters) . '")',
×
UNCOV
393
            $this->peek(5),
×
394
            'search',
×
395
            $this->lineNumber
×
396
        );
397
    }
398

399
    private function inputLeft(): string
×
400
    {
UNCOV
401
        return $this->substr($this->currentPosition, -1);
×
402
    }
403

404
    public function streql(string $string1, string $string2, bool $caseInsensitive = true): bool
11✔
405
    {
406
        return $caseInsensitive
11✔
407
            ? ($this->strtolower($string1) === $this->strtolower($string2))
11✔
408
            : ($string1 === $string2);
11✔
409
    }
410

411
    /**
412
     * @param int<1, max> $numberOfCharacters
413
     */
UNCOV
414
    public function backtrack(int $numberOfCharacters): void
×
415
    {
UNCOV
416
        $this->currentPosition -= $numberOfCharacters;
×
UNCOV
417
    }
×
418

419
    /**
420
     * @return int<0, max>
421
     */
422
    public function strlen(string $string): int
11✔
423
    {
424
        return $this->parserSettings->hasMultibyteSupport()
11✔
425
            ? \mb_strlen($string, $this->charset)
11✔
426
            : \strlen($string);
11✔
427
    }
428

429
    /**
430
     * @param int<0, max> $offset
431
     */
432
    private function substr(int $offset, int $length): string
11✔
433
    {
434
        if ($length < 0) {
11✔
UNCOV
435
            $length = \count($this->characters) - $offset + $length;
×
436
        }
437
        if ($offset + $length > \count($this->characters)) {
11✔
438
            $length = \count($this->characters) - $offset;
6✔
439
        }
440
        $result = '';
11✔
441
        while ($length > 0) {
11✔
442
            $result .= $this->characters[$offset];
11✔
443
            $offset++;
11✔
444
            $length--;
11✔
445
        }
446

447
        return $result;
11✔
448
    }
449

450
    /**
451
     * @return ($string is non-empty-string ? non-empty-string : string)
452
     */
453
    private function strtolower(string $string): string
11✔
454
    {
455
        return $this->parserSettings->hasMultibyteSupport()
11✔
456
            ? \mb_strtolower($string, $this->charset)
11✔
457
            : \strtolower($string);
11✔
458
    }
459

460
    /**
461
     * @return list<string>
462
     */
463
    private function strsplit(string $string): array
11✔
464
    {
465
        if ($this->parserSettings->hasMultibyteSupport()) {
11✔
466
            if ($this->streql($this->charset, 'utf-8')) {
11✔
467
                $result = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
11✔
468
            } else {
UNCOV
469
                $length = \mb_strlen($string, $this->charset);
×
UNCOV
470
                $result = [];
×
471
                for ($i = 0; $i < $length; ++$i) {
11✔
UNCOV
472
                    $result[] = \mb_substr($string, $i, 1, $this->charset);
×
473
                }
474
            }
475
        } else {
476
            $result = ($string !== '') ? \str_split($string) : [];
×
477
        }
478

479
        return $result;
11✔
480
    }
481

482
    /**
483
     * @return list<Comment>
484
     */
485
    private function consumeComments(): array
6✔
486
    {
487
        $comments = [];
6✔
488

489
        while (true) {
6✔
490
            $comment = $this->consumeComment();
6✔
491
            if ($comment instanceof Comment) {
6✔
492
                $comments[] = $comment;
6✔
493
            } else {
494
                return $comments;
6✔
495
            }
496
        }
UNCOV
497
    }
×
498
}
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