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

MyIntervals / PHP-CSS-Parser / 13433077716

20 Feb 2025 10:22AM UTC coverage: 51.654%. First build
13433077716

Pull #964

github

web-flow
Merge cc5192161 into d7d9128f0
Pull Request #964: [TASK] Use native type declarations in `OutputFormatter`

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

968 of 1874 relevant lines covered (51.65%)

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

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

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

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

35
    /**
36
     * @var int
37
     */
38
    private $currentPosition = 0;
39

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

47
    /**
48
     * @var int
49
     */
50
    private $length;
51

52
    /**
53
     * @var int
54
     */
55
    private $lineNumber;
56

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

69
    /**
70
     * Sets the charset to be used if the CSS does not contain an `@charset` declaration.
71
     *
72
     * @throws SourceException if the charset is UTF-8 and the content has invalid byte sequences
73
     */
74
    public function setCharset(string $charset): void
×
75
    {
76
        $this->charset = $charset;
×
77
        $this->characters = $this->strsplit($this->text);
×
78
        $this->length = \count($this->characters);
×
79
    }
×
80

81
    /**
82
     * @return int
83
     */
84
    public function currentLine()
×
85
    {
86
        return $this->lineNumber;
×
87
    }
88

89
    /**
90
     * @return int
91
     */
92
    public function currentColumn()
×
93
    {
94
        return $this->currentPosition;
×
95
    }
96

97
    /**
98
     * @return Settings
99
     */
100
    public function getSettings()
×
101
    {
102
        return $this->parserSettings;
×
103
    }
104

105
    public function anchor(): Anchor
×
106
    {
107
        return new Anchor($this->currentPosition, $this);
×
108
    }
109

110
    /**
111
     * @param int $position
112
     */
113
    public function setPosition($position): void
×
114
    {
115
        $this->currentPosition = $position;
×
116
    }
×
117

118
    /**
119
     * @param bool $ignoreCase
120
     *
121
     * @return string
122
     *
123
     * @throws UnexpectedTokenException
124
     */
125
    public function parseIdentifier($ignoreCase = true)
×
126
    {
127
        if ($this->isEnd()) {
×
128
            throw new UnexpectedEOFException('', '', 'identifier', $this->lineNumber);
×
129
        }
130
        $result = $this->parseCharacter(true);
×
131
        if ($result === null) {
×
132
            throw new UnexpectedTokenException('', $this->peek(5), 'identifier', $this->lineNumber);
×
133
        }
134
        $character = null;
×
135
        while (!$this->isEnd() && ($character = $this->parseCharacter(true)) !== null) {
×
136
            if (\preg_match('/[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_-]/Sux', $character)) {
×
137
                $result .= $character;
×
138
            } else {
139
                $result .= '\\' . $character;
×
140
            }
141
        }
142
        if ($ignoreCase) {
×
143
            $result = $this->strtolower($result);
×
144
        }
145
        return $result;
×
146
    }
147

148
    /**
149
     * @param bool $isForIdentifier
150
     *
151
     * @return string|null
152
     *
153
     * @throws UnexpectedEOFException
154
     * @throws UnexpectedTokenException
155
     */
156
    public function parseCharacter($isForIdentifier)
×
157
    {
158
        if ($this->peek() === '\\') {
×
159
            if (
160
                $isForIdentifier && $this->parserSettings->bLenientParsing
×
161
                && ($this->comes('\\0') || $this->comes('\\9'))
×
162
            ) {
163
                // Non-strings can contain \0 or \9 which is an IE hack supported in lenient parsing.
164
                return null;
×
165
            }
166
            $this->consume('\\');
×
167
            if ($this->comes('\\n') || $this->comes('\\r')) {
×
168
                return '';
×
169
            }
170
            if (\preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
×
171
                return $this->consume(1);
×
172
            }
173
            $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
×
174
            if ($this->strlen($sUnicode) < 6) {
×
175
                // Consume whitespace after incomplete unicode escape
176
                if (\preg_match('/\\s/isSu', $this->peek())) {
×
177
                    if ($this->comes('\\r\\n')) {
×
178
                        $this->consume(2);
×
179
                    } else {
180
                        $this->consume(1);
×
181
                    }
182
                }
183
            }
184
            $iUnicode = \intval($sUnicode, 16);
×
185
            $sUtf32 = '';
×
186
            for ($i = 0; $i < 4; ++$i) {
×
187
                $sUtf32 .= \chr($iUnicode & 0xff);
×
188
                $iUnicode = $iUnicode >> 8;
×
189
            }
190
            return \iconv('utf-32le', $this->charset, $sUtf32);
×
191
        }
192
        if ($isForIdentifier) {
×
193
            $peek = \ord($this->peek());
×
194
            // Ranges: a-z A-Z 0-9 - _
195
            if (
196
                ($peek >= 97 && $peek <= 122)
×
197
                || ($peek >= 65 && $peek <= 90)
×
198
                || ($peek >= 48 && $peek <= 57)
×
199
                || ($peek === 45)
×
200
                || ($peek === 95)
×
201
                || ($peek > 0xa1)
×
202
            ) {
203
                return $this->consume(1);
×
204
            }
205
        } else {
206
            return $this->consume(1);
×
207
        }
208
        return null;
×
209
    }
210

211
    /**
212
     * @return array<int, Comment>|void
213
     *
214
     * @throws UnexpectedEOFException
215
     * @throws UnexpectedTokenException
216
     */
217
    public function consumeWhiteSpace(): array
×
218
    {
219
        $comments = [];
×
220
        do {
221
            while (\preg_match('/\\s/isSu', $this->peek()) === 1) {
×
222
                $this->consume(1);
×
223
            }
224
            if ($this->parserSettings->bLenientParsing) {
×
225
                try {
226
                    $oComment = $this->consumeComment();
×
227
                } catch (UnexpectedEOFException $e) {
×
228
                    $this->currentPosition = $this->length;
×
229
                    return $comments;
×
230
                }
231
            } else {
232
                $oComment = $this->consumeComment();
×
233
            }
234
            if ($oComment !== false) {
×
235
                $comments[] = $oComment;
×
236
            }
237
        } while ($oComment !== false);
×
238
        return $comments;
×
239
    }
240

241
    /**
242
     * @param string $sString
243
     * @param bool $bCaseInsensitive
244
     */
245
    public function comes($sString, $bCaseInsensitive = false): bool
×
246
    {
247
        $sPeek = $this->peek(\strlen($sString));
×
248
        return ($sPeek == '')
×
249
            ? false
×
250
            : $this->streql($sPeek, $sString, $bCaseInsensitive);
×
251
    }
252

253
    /**
254
     * @param int $length
255
     * @param int $offset
256
     */
257
    public function peek($length = 1, $offset = 0): string
×
258
    {
259
        $offset += $this->currentPosition;
×
260
        if ($offset >= $this->length) {
×
261
            return '';
×
262
        }
263
        return $this->substr($offset, $length);
×
264
    }
265

266
    /**
267
     * @param int $mValue
268
     *
269
     * @throws UnexpectedEOFException
270
     * @throws UnexpectedTokenException
271
     */
272
    public function consume($mValue = 1): string
×
273
    {
274
        if (\is_string($mValue)) {
×
275
            $iLineCount = \substr_count($mValue, "\n");
×
276
            $length = $this->strlen($mValue);
×
277
            if (!$this->streql($this->substr($this->currentPosition, $length), $mValue)) {
×
278
                throw new UnexpectedTokenException(
×
279
                    $mValue,
×
280
                    $this->peek(\max($length, 5)),
×
281
                    'literal',
×
282
                    $this->lineNumber
×
283
                );
284
            }
285
            $this->lineNumber += $iLineCount;
×
286
            $this->currentPosition += $this->strlen($mValue);
×
287
            return $mValue;
×
288
        } else {
289
            if ($this->currentPosition + $mValue > $this->length) {
×
290
                throw new UnexpectedEOFException((string) $mValue, $this->peek(5), 'count', $this->lineNumber);
×
291
            }
292
            $result = $this->substr($this->currentPosition, $mValue);
×
293
            $iLineCount = \substr_count($result, "\n");
×
294
            $this->lineNumber += $iLineCount;
×
295
            $this->currentPosition += $mValue;
×
296
            return $result;
×
297
        }
298
    }
299

300
    /**
301
     * @param string $mExpression
302
     * @param int|null $iMaxLength
303
     *
304
     * @throws UnexpectedEOFException
305
     * @throws UnexpectedTokenException
306
     */
307
    public function consumeExpression($mExpression, $iMaxLength = null): string
×
308
    {
309
        $aMatches = null;
×
310
        $sInput = $iMaxLength !== null ? $this->peek($iMaxLength) : $this->inputLeft();
×
311
        if (\preg_match($mExpression, $sInput, $aMatches, PREG_OFFSET_CAPTURE) === 1) {
×
312
            return $this->consume($aMatches[0][0]);
×
313
        }
314
        throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->lineNumber);
×
315
    }
316

317
    /**
318
     * @return Comment|false
319
     */
320
    public function consumeComment()
×
321
    {
322
        $mComment = false;
×
323
        if ($this->comes('/*')) {
×
324
            $lineNumber = $this->lineNumber;
×
325
            $this->consume(1);
×
326
            $mComment = '';
×
327
            while (($char = $this->consume(1)) !== '') {
×
328
                $mComment .= $char;
×
329
                if ($this->comes('*/')) {
×
330
                    $this->consume(2);
×
331
                    break;
×
332
                }
333
            }
334
        }
335

336
        if ($mComment !== false) {
×
337
            // We skip the * which was included in the comment.
338
            return new Comment(\substr($mComment, 1), $lineNumber);
×
339
        }
340

341
        return $mComment;
×
342
    }
343

344
    public function isEnd(): bool
×
345
    {
346
        return $this->currentPosition >= $this->length;
×
347
    }
348

349
    /**
350
     * @param array<array-key, string>|string $aEnd
351
     * @param string $bIncludeEnd
352
     * @param string $consumeEnd
353
     * @param array<int, Comment> $comments
354
     *
355
     * @throws UnexpectedEOFException
356
     * @throws UnexpectedTokenException
357
     */
358
    public function consumeUntil($aEnd, $bIncludeEnd = false, $consumeEnd = false, array &$comments = []): string
×
359
    {
360
        $aEnd = \is_array($aEnd) ? $aEnd : [$aEnd];
×
361
        $out = '';
×
362
        $start = $this->currentPosition;
×
363

364
        while (!$this->isEnd()) {
×
365
            $char = $this->consume(1);
×
366
            if (\in_array($char, $aEnd, true)) {
×
367
                if ($bIncludeEnd) {
×
368
                    $out .= $char;
×
369
                } elseif (!$consumeEnd) {
×
370
                    $this->currentPosition -= $this->strlen($char);
×
371
                }
372
                return $out;
×
373
            }
374
            $out .= $char;
×
375
            if ($comment = $this->consumeComment()) {
×
376
                $comments[] = $comment;
×
377
            }
378
        }
379

380
        if (\in_array(self::EOF, $aEnd, true)) {
×
381
            return $out;
×
382
        }
383

384
        $this->currentPosition = $start;
×
385
        throw new UnexpectedEOFException(
×
386
            'One of ("' . \implode('","', $aEnd) . '")',
×
387
            $this->peek(5),
×
388
            'search',
×
389
            $this->lineNumber
×
390
        );
391
    }
392

393
    private function inputLeft(): string
×
394
    {
395
        return $this->substr($this->currentPosition, -1);
×
396
    }
397

398
    /**
399
     * @param string $sString1
400
     * @param string $sString2
401
     * @param bool $bCaseInsensitive
402
     */
403
    public function streql($sString1, $sString2, $bCaseInsensitive = true): bool
×
404
    {
405
        if ($bCaseInsensitive) {
×
406
            return $this->strtolower($sString1) === $this->strtolower($sString2);
×
407
        } else {
408
            return $sString1 === $sString2;
×
409
        }
410
    }
411

412
    /**
413
     * @param int $iAmount
414
     */
415
    public function backtrack($iAmount): void
×
416
    {
417
        $this->currentPosition -= $iAmount;
×
418
    }
×
419

420
    /**
421
     * @param string $sString
422
     */
423
    public function strlen($sString): int
×
424
    {
425
        if ($this->parserSettings->bMultibyteSupport) {
×
426
            return \mb_strlen($sString, $this->charset);
×
427
        } else {
428
            return \strlen($sString);
×
429
        }
430
    }
431

432
    /**
433
     * @param int $iStart
434
     * @param int $length
435
     */
436
    private function substr($iStart, $length): string
×
437
    {
438
        if ($length < 0) {
×
439
            $length = $this->length - $iStart + $length;
×
440
        }
441
        if ($iStart + $length > $this->length) {
×
442
            $length = $this->length - $iStart;
×
443
        }
444
        $result = '';
×
445
        while ($length > 0) {
×
446
            $result .= $this->characters[$iStart];
×
447
            $iStart++;
×
448
            $length--;
×
449
        }
450
        return $result;
×
451
    }
452

453
    /**
454
     * @param string $sString
455
     */
456
    private function strtolower($sString): string
×
457
    {
458
        if ($this->parserSettings->bMultibyteSupport) {
×
459
            return \mb_strtolower($sString, $this->charset);
×
460
        } else {
461
            return \strtolower($sString);
×
462
        }
463
    }
464

465
    /**
466
     * @param string $sString
467
     *
468
     * @return array<int, string>
469
     *
470
     * @throws SourceException if the charset is UTF-8 and the string contains invalid byte sequences
471
     */
472
    private function strsplit($sString)
×
473
    {
474
        if ($this->parserSettings->bMultibyteSupport) {
×
475
            if ($this->streql($this->charset, 'utf-8')) {
×
476
                $result = \preg_split('//u', $sString, -1, PREG_SPLIT_NO_EMPTY);
×
477
                if (!\is_array($result)) {
×
478
                    throw new SourceException('`preg_split` failed with error ' . \preg_last_error());
×
479
                }
480
                return $result;
×
481
            } else {
482
                $length = \mb_strlen($sString, $this->charset);
×
483
                $result = [];
×
484
                for ($i = 0; $i < $length; ++$i) {
×
485
                    $result[] = \mb_substr($sString, $i, 1, $this->charset);
×
486
                }
487
                return $result;
×
488
            }
489
        } else {
490
            if ($sString === '') {
×
491
                return [];
×
492
            } else {
493
                return \str_split($sString);
×
494
            }
495
        }
496
    }
497

498
    /**
499
     * @param string $sString
500
     * @param string $sNeedle
501
     * @param int $offset
502
     *
503
     * @return int|false
504
     */
505
    private function strpos($sString, $sNeedle, $offset)
×
506
    {
507
        if ($this->parserSettings->bMultibyteSupport) {
×
508
            return \mb_strpos($sString, $sNeedle, $offset, $this->charset);
×
509
        } else {
510
            return \strpos($sString, $sNeedle, $offset);
×
511
        }
512
    }
513
}
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