Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.04% covered (success)
91.04%
325 / 357
59.26% covered (warning)
59.26%
16 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tokenizer
91.04% covered (success)
91.04%
325 / 357
59.26% covered (warning)
59.26%
16 / 27
217.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 tokenize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 nextToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 step
93.38% covered (success)
93.38%
127 / 136
0.00% covered (danger)
0.00%
0 / 1
50.72
 consumeComments
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 consumeString
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
10.06
 consumeIdentLikeToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 consumeUrlToken
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
15
 consumeRemnantsOfBadUrl
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 consumeIdentSequence
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 consumeNumericToken
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 consumeNumber
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
16.06
 consumeEscape
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
14.17
 isValidEscape
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 wouldStartIdentSequence
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
8.05
 wouldStartNumber
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 preprocess
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 peek
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 advance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 emit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWhitespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isDigit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isHexDigit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
 isLetter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 isIdentStartCodePoint
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 isIdentCodePoint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isNonPrintable
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
7.14
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css;
6
7use Phpdftk\Css\Token\AtKeywordToken;
8use Phpdftk\Css\Token\BadStringToken;
9use Phpdftk\Css\Token\BadUrlToken;
10use Phpdftk\Css\Token\CdcToken;
11use Phpdftk\Css\Token\CdoToken;
12use Phpdftk\Css\Token\ColonToken;
13use Phpdftk\Css\Token\CommaToken;
14use Phpdftk\Css\Token\DelimToken;
15use Phpdftk\Css\Token\DimensionToken;
16use Phpdftk\Css\Token\EofToken;
17use Phpdftk\Css\Token\FunctionToken;
18use Phpdftk\Css\Token\HashToken;
19use Phpdftk\Css\Token\HashTokenType;
20use Phpdftk\Css\Token\IdentToken;
21use Phpdftk\Css\Token\LeftBraceToken;
22use Phpdftk\Css\Token\LeftBracketToken;
23use Phpdftk\Css\Token\LeftParenToken;
24use Phpdftk\Css\Token\NumberToken;
25use Phpdftk\Css\Token\NumberTokenType;
26use Phpdftk\Css\Token\PercentageToken;
27use Phpdftk\Css\Token\RightBraceToken;
28use Phpdftk\Css\Token\RightBracketToken;
29use Phpdftk\Css\Token\RightParenToken;
30use Phpdftk\Css\Token\SemicolonToken;
31use Phpdftk\Css\Token\StringToken;
32use Phpdftk\Css\Token\Token;
33use Phpdftk\Css\Token\UrlToken;
34use Phpdftk\Css\Token\WhitespaceToken;
35
36/**
37 * CSS Syntax Module 3 tokenizer (§4). Walks the preprocessed input
38 * character-by-character, dispatching by the next character into one of the
39 * "consume X" sub-procedures.
40 *
41 * Preprocessing per §3.3 is applied at construction: CR / CRLF / FF → LF;
42 * NULL → U+FFFD (handled inline during consume). Comments are stripped
43 * (not emitted as tokens).
44 *
45 * Both `tokenize()` (returns full array) and `nextToken()` (streaming) are
46 * provided, mirroring the html tokenizer's contract so downstream parsers
47 * can drive either way.
48 */
49final class Tokenizer
50{
51    /** @var list<string> input as an array of UTF-8 single-codepoint strings */
52    private array $chars;
53    private int $length;
54    private int $pos = 0;
55    /** @var list<Token> */
56    private array $emitted = [];
57    private int $emittedCursor = 0;
58    private bool $done = false;
59
60    public function __construct(string $input)
61    {
62        $normalised = $this->preprocess($input);
63        $this->chars = $normalised === '' ? [] : (mb_str_split($normalised, 1, 'UTF-8') ?: []);
64        $this->length = count($this->chars);
65    }
66
67    /** @return list<Token> */
68    public function tokenize(): array
69    {
70        while (!$this->done) {
71            $this->step();
72        }
73        return $this->emitted;
74    }
75
76    public function nextToken(): ?Token
77    {
78        while ($this->emittedCursor >= count($this->emitted) && !$this->done) {
79            $this->step();
80        }
81        if ($this->emittedCursor < count($this->emitted)) {
82            return $this->emitted[$this->emittedCursor++];
83        }
84        return null;
85    }
86
87    private function step(): void
88    {
89        // Comments are stripped here so the dispatch below doesn't see them.
90        $this->consumeComments();
91        $c = $this->peek(0);
92        if ($c === null) {
93            $this->emit(new EofToken());
94            $this->done = true;
95            return;
96        }
97        if (self::isWhitespace($c)) {
98            while (($next = $this->peek(0)) !== null && self::isWhitespace($next)) {
99                $this->advance();
100            }
101            $this->emit(new WhitespaceToken());
102            return;
103        }
104        if ($c === '"' || $c === "'") {
105            $this->advance();
106            $this->emit($this->consumeString($c));
107            return;
108        }
109        if ($c === '#') {
110            // Hash if followed by ident-code-point or escape; else Delim('#').
111            $next = $this->peek(1);
112            if ($next !== null && (self::isIdentCodePoint($next) || $this->isValidEscape(1))) {
113                $this->advance(); // consume '#'
114                $type = $this->wouldStartIdentSequence(0) ? HashTokenType::Id : HashTokenType::Unrestricted;
115                $name = $this->consumeIdentSequence();
116                $this->emit(new HashToken($name, $type));
117                return;
118            }
119            $this->advance();
120            $this->emit(new DelimToken('#'));
121            return;
122        }
123        if ($c === '(') {
124            $this->advance();
125            $this->emit(new LeftParenToken());
126            return;
127        }
128        if ($c === ')') {
129            $this->advance();
130            $this->emit(new RightParenToken());
131            return;
132        }
133        if ($c === '+' || $c === '.') {
134            if ($this->wouldStartNumber(0)) {
135                $this->emit($this->consumeNumericToken());
136                return;
137            }
138            $this->advance();
139            $this->emit(new DelimToken($c));
140            return;
141        }
142        if ($c === ',') {
143            $this->advance();
144            $this->emit(new CommaToken());
145            return;
146        }
147        if ($c === '-') {
148            if ($this->wouldStartNumber(0)) {
149                $this->emit($this->consumeNumericToken());
150                return;
151            }
152            if ($this->peek(1) === '-' && $this->peek(2) === '>') {
153                $this->advance();
154                $this->advance();
155                $this->advance();
156                $this->emit(new CdcToken());
157                return;
158            }
159            if ($this->wouldStartIdentSequence(0)) {
160                $this->emit($this->consumeIdentLikeToken());
161                return;
162            }
163            $this->advance();
164            $this->emit(new DelimToken('-'));
165            return;
166        }
167        if ($c === ':') {
168            $this->advance();
169            $this->emit(new ColonToken());
170            return;
171        }
172        if ($c === ';') {
173            $this->advance();
174            $this->emit(new SemicolonToken());
175            return;
176        }
177        if ($c === '<') {
178            if ($this->peek(1) === '!' && $this->peek(2) === '-' && $this->peek(3) === '-') {
179                $this->advance();
180                $this->advance();
181                $this->advance();
182                $this->advance();
183                $this->emit(new CdoToken());
184                return;
185            }
186            // XHTML CDATA section opener: `<![CDATA[`. CSS Syntax doesn't
187            // tokenise these but browsers ignore the brackets when parsing
188            // an XHTML stylesheet, so consume + emit nothing — the inner
189            // body tokenises as normal CSS.
190            if (
191                $this->peek(1) === '!'
192                && $this->peek(2) === '['
193                && $this->peek(3) === 'C'
194                && $this->peek(4) === 'D'
195                && $this->peek(5) === 'A'
196                && $this->peek(6) === 'T'
197                && $this->peek(7) === 'A'
198                && $this->peek(8) === '['
199            ) {
200                for ($i = 0; $i < 9; $i++) {
201                    $this->advance();
202                }
203                return;
204            }
205            $this->advance();
206            $this->emit(new DelimToken('<'));
207            return;
208        }
209        if ($c === '@') {
210            if ($this->wouldStartIdentSequence(1)) {
211                $this->advance(); // consume '@'
212                $name = $this->consumeIdentSequence();
213                $this->emit(new AtKeywordToken($name));
214                return;
215            }
216            $this->advance();
217            $this->emit(new DelimToken('@'));
218            return;
219        }
220        if ($c === '[') {
221            $this->advance();
222            $this->emit(new LeftBracketToken());
223            return;
224        }
225        if ($c === ']') {
226            // XHTML CDATA section closer: `]]>`. Mirror the opener — eat
227            // the three characters silently so the stylesheet body parses.
228            if ($this->peek(1) === ']' && $this->peek(2) === '>') {
229                $this->advance();
230                $this->advance();
231                $this->advance();
232                return;
233            }
234            $this->advance();
235            $this->emit(new RightBracketToken());
236            return;
237        }
238        if ($c === '\\') {
239            if ($this->isValidEscape(0)) {
240                $this->emit($this->consumeIdentLikeToken());
241                return;
242            }
243            $this->advance();
244            $this->emit(new DelimToken('\\'));
245            return;
246        }
247        if ($c === '{') {
248            $this->advance();
249            $this->emit(new LeftBraceToken());
250            return;
251        }
252        if ($c === '}') {
253            $this->advance();
254            $this->emit(new RightBraceToken());
255            return;
256        }
257        if (self::isDigit($c)) {
258            $this->emit($this->consumeNumericToken());
259            return;
260        }
261        if (self::isIdentStartCodePoint($c)) {
262            $this->emit($this->consumeIdentLikeToken());
263            return;
264        }
265        $this->advance();
266        $this->emit(new DelimToken($c));
267    }
268
269    // ============================================================
270    // Sub-procedures
271    // ============================================================
272
273    private function consumeComments(): void
274    {
275        while ($this->peek(0) === '/' && $this->peek(1) === '*') {
276            $this->advance();
277            $this->advance();
278            while (true) {
279                $c = $this->peek(0);
280                if ($c === null) {
281                    return; // EOF inside comment is a parse error per spec; we just stop.
282                }
283                if ($c === '*' && $this->peek(1) === '/') {
284                    $this->advance();
285                    $this->advance();
286                    break;
287                }
288                $this->advance();
289            }
290        }
291    }
292
293    private function consumeString(string $terminator): Token
294    {
295        $buf = '';
296        while (true) {
297            $c = $this->peek(0);
298            if ($c === null) {
299                return new StringToken($buf); // EOF — parse error per spec; return what we have.
300            }
301            if ($c === $terminator) {
302                $this->advance();
303                return new StringToken($buf);
304            }
305            if ($c === "\n") {
306                return new BadStringToken();
307            }
308            if ($c === '\\') {
309                if ($this->peek(1) === null) {
310                    $this->advance();
311                    continue;
312                }
313                if ($this->peek(1) === "\n") {
314                    $this->advance();
315                    $this->advance();
316                    continue;
317                }
318                $buf .= $this->consumeEscape();
319                continue;
320            }
321            $buf .= $c;
322            $this->advance();
323        }
324    }
325
326    private function consumeIdentLikeToken(): Token
327    {
328        $name = $this->consumeIdentSequence();
329        if (strcasecmp($name, 'url') === 0 && $this->peek(0) === '(') {
330            $this->advance(); // consume '('
331            // Skip leading whitespace.
332            while (($next = $this->peek(0)) !== null && self::isWhitespace($next)) {
333                $this->advance();
334            }
335            // If quote follows, it's a function-call url() with a string arg.
336            $n = $this->peek(0);
337            if ($n === '"' || $n === "'") {
338                return new FunctionToken($name);
339            }
340            return $this->consumeUrlToken();
341        }
342        if ($this->peek(0) === '(') {
343            $this->advance();
344            return new FunctionToken($name);
345        }
346        return new IdentToken($name);
347    }
348
349    private function consumeUrlToken(): Token
350    {
351        $buf = '';
352        while (true) {
353            $c = $this->peek(0);
354            if ($c === null) {
355                return new UrlToken($buf);
356            }
357            if ($c === ')') {
358                $this->advance();
359                return new UrlToken($buf);
360            }
361            if (self::isWhitespace($c)) {
362                while (($next = $this->peek(0)) !== null && self::isWhitespace($next)) {
363                    $this->advance();
364                }
365                if ($this->peek(0) === ')') {
366                    $this->advance();
367                    return new UrlToken($buf);
368                }
369                if ($this->peek(0) === null) {
370                    return new UrlToken($buf);
371                }
372                $this->consumeRemnantsOfBadUrl();
373                return new BadUrlToken();
374            }
375            if ($c === '"' || $c === "'" || $c === '(' || self::isNonPrintable($c)) {
376                $this->consumeRemnantsOfBadUrl();
377                return new BadUrlToken();
378            }
379            if ($c === '\\') {
380                if ($this->isValidEscape(0)) {
381                    $buf .= $this->consumeEscape();
382                    continue;
383                }
384                $this->consumeRemnantsOfBadUrl();
385                return new BadUrlToken();
386            }
387            $buf .= $c;
388            $this->advance();
389        }
390    }
391
392    private function consumeRemnantsOfBadUrl(): void
393    {
394        while (true) {
395            $c = $this->peek(0);
396            if ($c === null || $c === ')') {
397                if ($c === ')') {
398                    $this->advance();
399                }
400                return;
401            }
402            if ($c === '\\' && $this->isValidEscape(0)) {
403                $this->consumeEscape();
404                continue;
405            }
406            $this->advance();
407        }
408    }
409
410    private function consumeIdentSequence(): string
411    {
412        $out = '';
413        while (true) {
414            $c = $this->peek(0);
415            if ($c === null) {
416                return $out;
417            }
418            if (self::isIdentCodePoint($c)) {
419                $out .= $c;
420                $this->advance();
421                continue;
422            }
423            if ($this->isValidEscape(0)) {
424                $out .= $this->consumeEscape();
425                continue;
426            }
427            return $out;
428        }
429    }
430
431    private function consumeNumericToken(): Token
432    {
433        [$value, $type] = $this->consumeNumber();
434        if ($this->wouldStartIdentSequence(0)) {
435            $unit = $this->consumeIdentSequence();
436            return new DimensionToken($value, $unit, $type);
437        }
438        if ($this->peek(0) === '%') {
439            $this->advance();
440            return new PercentageToken($value);
441        }
442        return new NumberToken($value, $type);
443    }
444
445    /** @return array{0: float, 1: NumberTokenType} */
446    private function consumeNumber(): array
447    {
448        $type = NumberTokenType::Integer;
449        $buf = '';
450        $c = $this->peek(0);
451        if ($c === '+' || $c === '-') {
452            $buf .= $c;
453            $this->advance();
454        }
455        while (self::isDigit($this->peek(0) ?? '')) {
456            $buf .= $this->peek(0);
457            $this->advance();
458        }
459        if ($this->peek(0) === '.' && self::isDigit($this->peek(1) ?? '')) {
460            $buf .= $this->peek(0) . $this->peek(1);
461            $this->advance();
462            $this->advance();
463            $type = NumberTokenType::Number;
464            while (self::isDigit($this->peek(0) ?? '')) {
465                $buf .= $this->peek(0);
466                $this->advance();
467            }
468        }
469        $c = $this->peek(0);
470        $next = $this->peek(1);
471        $next2 = $this->peek(2);
472        if (($c === 'e' || $c === 'E')
473            && (self::isDigit($next ?? '')
474                || (($next === '+' || $next === '-') && self::isDigit($next2 ?? '')))
475        ) {
476            $buf .= $this->peek(0);
477            $this->advance();
478            if ($this->peek(0) === '+' || $this->peek(0) === '-') {
479                $buf .= $this->peek(0);
480                $this->advance();
481            }
482            $type = NumberTokenType::Number;
483            while (self::isDigit($this->peek(0) ?? '')) {
484                $buf .= $this->peek(0);
485                $this->advance();
486            }
487        }
488        return [(float) $buf, $type];
489    }
490
491    private function consumeEscape(): string
492    {
493        // Caller has positioned us on '\\'; advance past it.
494        $this->advance();
495        $c = $this->peek(0);
496        if ($c === null) {
497            return "\u{FFFD}";
498        }
499        if (self::isHexDigit($c)) {
500            $hex = '';
501            for ($i = 0; $i < 6; $i++) {
502                $n = $this->peek(0);
503                if ($n === null || !self::isHexDigit($n)) {
504                    break;
505                }
506                $hex .= $n;
507                $this->advance();
508            }
509            $next = $this->peek(0);
510            if ($next !== null && self::isWhitespace($next)) {
511                $this->advance();
512            }
513            $cp = (int) hexdec($hex);
514            if ($cp === 0 || $cp > 0x10FFFF || ($cp >= 0xD800 && $cp <= 0xDFFF)) {
515                return "\u{FFFD}";
516            }
517            return mb_chr($cp, 'UTF-8') ?: "\u{FFFD}";
518        }
519        $this->advance();
520        return $c;
521    }
522
523    // ============================================================
524    // Lookahead helpers (CSS Syntax 3 §4.3.8 / §4.3.9)
525    // ============================================================
526
527    private function isValidEscape(int $offset): bool
528    {
529        if ($this->peek($offset) !== '\\') {
530            return false;
531        }
532        return $this->peek($offset + 1) !== "\n";
533    }
534
535    private function wouldStartIdentSequence(int $offset): bool
536    {
537        $c1 = $this->peek($offset);
538        if ($c1 === '-') {
539            $c2 = $this->peek($offset + 1);
540            if ($c2 !== null && (self::isIdentStartCodePoint($c2) || $c2 === '-')) {
541                return true;
542            }
543            return $this->isValidEscape($offset + 1);
544        }
545        if ($c1 !== null && self::isIdentStartCodePoint($c1)) {
546            return true;
547        }
548        if ($c1 === '\\') {
549            return $this->isValidEscape($offset);
550        }
551        return false;
552    }
553
554    private function wouldStartNumber(int $offset): bool
555    {
556        $c1 = $this->peek($offset);
557        if ($c1 === '+' || $c1 === '-') {
558            $c2 = $this->peek($offset + 1);
559            if (self::isDigit($c2 ?? '')) {
560                return true;
561            }
562            if ($c2 === '.') {
563                $c3 = $this->peek($offset + 2);
564                return self::isDigit($c3 ?? '');
565            }
566            return false;
567        }
568        if ($c1 === '.') {
569            return self::isDigit($this->peek($offset + 1) ?? '');
570        }
571        return $c1 !== null && self::isDigit($c1);
572    }
573
574    // ============================================================
575    // I/O helpers
576    // ============================================================
577
578    private function preprocess(string $input): string
579    {
580        $input = str_replace(["\r\n", "\r", "\f"], "\n", $input);
581        return str_replace("\0", "\u{FFFD}", $input);
582    }
583
584    private function peek(int $offset): ?string
585    {
586        $i = $this->pos + $offset;
587        return $i < $this->length ? $this->chars[$i] : null;
588    }
589
590    private function advance(): void
591    {
592        $this->pos++;
593    }
594
595    private function emit(Token $t): void
596    {
597        $this->emitted[] = $t;
598    }
599
600    // ============================================================
601    // Character classification
602    // ============================================================
603
604    private static function isWhitespace(string $c): bool
605    {
606        return $c === ' ' || $c === "\t" || $c === "\n";
607    }
608
609    private static function isDigit(string $c): bool
610    {
611        return $c >= '0' && $c <= '9';
612    }
613
614    private static function isHexDigit(string $c): bool
615    {
616        return self::isDigit($c) || ($c >= 'A' && $c <= 'F') || ($c >= 'a' && $c <= 'f');
617    }
618
619    private static function isLetter(string $c): bool
620    {
621        return ($c >= 'a' && $c <= 'z') || ($c >= 'A' && $c <= 'Z');
622    }
623
624    private static function isIdentStartCodePoint(string $c): bool
625    {
626        if ($c === '') {
627            return false;
628        }
629        if (self::isLetter($c) || $c === '_') {
630            return true;
631        }
632        return mb_ord($c, 'UTF-8') >= 0x80;
633    }
634
635    private static function isIdentCodePoint(string $c): bool
636    {
637        return self::isIdentStartCodePoint($c) || self::isDigit($c) || $c === '-';
638    }
639
640    private static function isNonPrintable(string $c): bool
641    {
642        $cp = mb_ord($c, 'UTF-8');
643        if ($cp === false) {
644            return false;
645        }
646        return ($cp >= 0x00 && $cp <= 0x08)
647            || $cp === 0x0B
648            || ($cp >= 0x0E && $cp <= 0x1F)
649            || $cp === 0x7F;
650    }
651}