Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
253 / 253
100.00% covered (success)
100.00%
20 / 20
CRAP
100.00% covered (success)
100.00%
1 / 1
JavascriptScanner
100.00% covered (success)
100.00%
253 / 253
100.00% covered (success)
100.00%
20 / 20
124
100.00% covered (success)
100.00%
1 / 1
 extensions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scanCode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 scanVue
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 lineAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collect
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
15
 identifier
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 isFunctionDeclaration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 arguments
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
13
 literal
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
12
 scanTemplate
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 skipString
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 skipRegion
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 skipBraces
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 startsRegex
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
10
 skipRegex
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 skipTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 skipTrivia
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 skipSpace
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 isNameStart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isNamePart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7/**
8 * Extracts Verba's eight translation calls from JavaScript source across a
9 * range of dialects — `.js`, `.ts`, `.jsx`, `.tsx`, `.svelte`, and `.vue` — with
10 * one shared character lexer that skips strings, template literals, and
11 * comments. Only literal string arguments are captured.
12 *
13 * The JS dialects and `.svelte` are lexed whole (quoted strings are skipped, so
14 * a literal attribute like `title="Delete"` is not mistaken for a call). In
15 * `.vue`, `<script>` blocks are lexed the same way while the template treats
16 * quoted attribute values as expression context, so `:title="__('Save')"` is
17 * found. HTML comments are always skipped. Template-literal interpolations
18 * are scanned as code while their raw text is skipped.
19 *
20 * @api
21 */
22final class JavascriptScanner extends FileScanner
23{
24    #[\Override]
25    protected function extensions(): array
26    {
27        return ['js', 'ts', 'jsx', 'tsx', 'svelte', 'vue'];
28    }
29
30    #[\Override]
31    protected function scanCode(string $code, string $file): void
32    {
33        if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'vue') {
34            $this->scanVue($code, $file);
35
36            return;
37        }
38
39        $this->collect($code, $file, 0, false);
40    }
41
42    private function scanVue(string $code, string $file): void
43    {
44        $offset = 0;
45        preg_match_all(
46            '#<script\b[^>]*>(.*?)</script>#is',
47            $code,
48            $matches,
49            PREG_OFFSET_CAPTURE | PREG_SET_ORDER,
50        );
51
52        foreach ($matches as $match) {
53            $whole = $match[0][0];
54            $start = $match[0][1];
55            $inner = $match[1][0];
56            $innerStart = $match[1][1];
57
58            $this->collect(
59                substr($code, $offset, $start - $offset),
60                $file,
61                $this->lineAt($code, $offset),
62                true,
63            );
64            $this->collect($inner, $file, $this->lineAt($code, $innerStart), false);
65            $offset = $start + strlen($whole);
66        }
67
68        $this->collect(substr($code, $offset), $file, $this->lineAt($code, $offset), true);
69    }
70
71    private function lineAt(string $code, int $offset): int
72    {
73        return substr_count($code, "\n", 0, $offset);
74    }
75
76    /**
77     * Walks $code finding calls. String literals are skipped unless $transparent
78     * (a markup region where quotes delimit attributes, not JS strings).
79     */
80    private function collect(string $code, string $file, int $lineBase, bool $transparent): void
81    {
82        $length = strlen($code);
83        $i = 0;
84
85        while ($i < $length) {
86            $char = $code[$i];
87
88            if (substr($code, $i, 4) === '<!--') {
89                $i = $this->skipTo($code, $i + 4, '-->');
90
91                continue;
92            }
93
94            if (!$transparent && substr($code, $i, 2) === '//') {
95                $i = $this->skipTo($code, $i + 2, "\n");
96
97                continue;
98            }
99
100            if (!$transparent && substr($code, $i, 2) === '/*') {
101                $i = $this->skipTo($code, $i + 2, '*/');
102
103                continue;
104            }
105
106            if (!$transparent && $char === '/' && $this->startsRegex($code, $i)) {
107                $i = $this->skipRegex($code, $i);
108
109                continue;
110            }
111
112            if ($char === '`') {
113                $i = $this->scanTemplate($code, $i, $file, $lineBase);
114
115                continue;
116            }
117
118            if (!$transparent && ($char === '"' || $char === "'")) {
119                $i = $this->skipString($code, $i, $char);
120
121                continue;
122            }
123
124            if (!$this->isNameStart($char)) {
125                $i++;
126
127                continue;
128            }
129
130            $i = $this->identifier($code, $i, $file, $lineBase);
131        }
132    }
133
134    /**
135     * Reads the identifier at $start and, when it is one of the call names used
136     * as a function, records it. Returns the index to continue from.
137     */
138    private function identifier(string $code, int $start, string $file, int $lineBase): int
139    {
140        $length = strlen($code);
141        $end = $start;
142
143        while ($end < $length && $this->isNamePart($code[$end])) {
144            $end++;
145        }
146
147        $name = substr($code, $start, $end - $start);
148        $prev = $start > 0 ? $code[$start - 1] : '';
149
150        if (!array_key_exists($name, self::CALLS) || $prev === '.' || $this->isNamePart($prev)) {
151            return $end;
152        }
153
154        if ($this->isFunctionDeclaration($code, $start)) {
155            return $end;
156        }
157
158        $open = $this->skipTrivia($code, $end);
159
160        if ($open >= $length || $code[$open] !== '(') {
161            return $end;
162        }
163
164        $args = $this->arguments($code, $open);
165        $this->emit($name, $args, $file . ':' . ($lineBase + substr_count($code, "\n", 0, $start) + 1));
166
167        return $end;
168    }
169
170    private function isFunctionDeclaration(string $code, int $start): bool
171    {
172        return preg_match('/\bfunction\s*\*?\s*$/', substr($code, 0, $start)) === 1;
173    }
174
175    /**
176     * @return list<?string>
177     */
178    private function arguments(string $code, int $open): array
179    {
180        $length = strlen($code);
181        $i = $open + 1;
182        $start = $i;
183        $depth = 0;
184        $args = [];
185
186        while ($i < $length) {
187            $char = $code[$i];
188
189            $after = $this->skipRegion($code, $i);
190
191            if ($after !== null) {
192                $i = $after;
193
194                continue;
195            }
196
197            if ($char === '(' || $char === '[' || $char === '{') {
198                $depth++;
199                $i++;
200
201                continue;
202            }
203
204            if ($char === ')' && $depth === 0) {
205                $args[] = $this->literal(substr($code, $start, $i - $start));
206
207                return $args;
208            }
209
210            if ($char === ')' || $char === ']' || $char === '}') {
211                $depth--;
212                $i++;
213
214                continue;
215            }
216
217            if ($char === ',' && $depth === 0) {
218                $args[] = $this->literal(substr($code, $start, $i - $start));
219                $i++;
220                $start = $i;
221
222                continue;
223            }
224
225            $i++;
226        }
227
228        return $args;
229    }
230
231    private function literal(string $raw): ?string
232    {
233        $length = strlen($raw);
234        $i = $this->skipTrivia($raw, 0);
235
236        if ($i >= $length) {
237            return null;
238        }
239
240        $quote = $raw[$i];
241
242        if ($quote !== '"' && $quote !== "'" && $quote !== '`') {
243            return null;
244        }
245
246        $value = '';
247
248        for ($i++; $i < $length; $i++) {
249            $char = $raw[$i];
250
251            if ($char === '\\') {
252                $value .= JavascriptEscape::decode($raw, $i);
253
254                continue;
255            }
256
257            if ($quote === '`' && $char === '$' && ($raw[$i + 1] ?? '') === '{') {
258                return null;
259            }
260
261            if ($char === $quote) {
262                return $this->skipTrivia($raw, $i + 1) === $length ? $value : null;
263            }
264
265            $value .= $char;
266        }
267
268        // The argument parser only passes balanced segments, so a quoted arg
269        // always closes above.
270        // @codeCoverageIgnoreStart
271        return null;
272
273        // @codeCoverageIgnoreEnd
274    }
275
276    private function scanTemplate(string $code, int $i, string $file, int $lineBase): int
277    {
278        $length = strlen($code);
279
280        for ($i++; $i < $length; $i++) {
281            $char = $code[$i];
282
283            if ($char === '\\') {
284                $i++;
285
286                continue;
287            }
288
289            if ($char === '$' && ($code[$i + 1] ?? '') === '{') {
290                $start = $i + 2;
291                $after = $this->skipBraces($code, $i + 1);
292                $end = ($code[$after - 1] ?? '') === '}' ? $after - 1 : $after;
293
294                $this->collect(
295                    substr($code, $start, $end - $start),
296                    $file,
297                    $lineBase + substr_count($code, "\n", 0, $start),
298                    false,
299                );
300                $i = $after - 1;
301
302                continue;
303            }
304
305            if ($char === '`') {
306                return $i + 1;
307            }
308        }
309
310        return $length;
311    }
312
313    private function skipString(string $code, int $i, string $quote): int
314    {
315        $length = strlen($code);
316
317        for ($i++; $i < $length; $i++) {
318            $char = $code[$i];
319
320            if ($char === '\\') {
321                $i++;
322
323                continue;
324            }
325
326            if ($quote === '`' && $char === '$' && ($code[$i + 1] ?? '') === '{') {
327                $i = $this->skipBraces($code, $i + 1) - 1;
328
329                continue;
330            }
331
332            if ($char === $quote) {
333                return $i + 1;
334            }
335        }
336
337        return $length;
338    }
339
340    private function skipRegion(string $code, int $i): ?int
341    {
342        $pair = substr($code, $i, 2);
343
344        if ($pair === '//') {
345            return $this->skipTo($code, $i + 2, "\n");
346        }
347
348        if ($pair === '/*') {
349            return $this->skipTo($code, $i + 2, '*/');
350        }
351
352        $char = $code[$i];
353
354        if ($char === '/' && $this->startsRegex($code, $i)) {
355            return $this->skipRegex($code, $i);
356        }
357
358        return $char === '"' || $char === "'" || $char === '`'
359            ? $this->skipString($code, $i, $char)
360            : null;
361    }
362
363    private function skipBraces(string $code, int $i): int
364    {
365        $length = strlen($code);
366        $depth = 0;
367
368        while ($i < $length) {
369            $char = $code[$i];
370
371            $after = $this->skipRegion($code, $i);
372
373            if ($after !== null) {
374                $i = $after;
375
376                continue;
377            }
378
379            if ($char === '{') {
380                $depth++;
381                $i++;
382
383                continue;
384            }
385
386            if ($char === '}') {
387                $depth--;
388                $i++;
389
390                if ($depth === 0) {
391                    return $i;
392                }
393
394                continue;
395            }
396
397            $i++;
398        }
399
400        return $length;
401    }
402
403    private function startsRegex(string $code, int $i): bool
404    {
405        for ($j = $i - 1; $j >= 0; $j--) {
406            $prev = $code[$j];
407
408            if ($prev === ' ' || $prev === "\t" || $prev === "\n" || $prev === "\r") {
409                continue;
410            }
411
412            if (in_array(
413                $prev,
414                ['(', '[', '{', '=', ',', ':', ';', '!', '&', '|', '?', '+', '-', '*', '~', '%', '^', '<', '>'],
415                true,
416            )) {
417                return true;
418            }
419
420            if ($this->isNamePart($prev)) {
421                $end = $j + 1;
422
423                while ($j >= 0 && $this->isNamePart($code[$j])) {
424                    $j--;
425                }
426
427                return in_array(
428                    substr($code, $j + 1, $end - $j - 1),
429                    [
430                        'case',
431                        'delete',
432                        'instanceof',
433                        'of',
434                        'return',
435                        'throw',
436                        'typeof',
437                        'void',
438                        'yield',
439                    ],
440                    true,
441                );
442            }
443
444            return false;
445        }
446
447        return true;
448    }
449
450    private function skipRegex(string $code, int $i): int
451    {
452        $length = strlen($code);
453        $inClass = false;
454
455        for ($i++; $i < $length; $i++) {
456            $char = $code[$i];
457
458            if ($char === '\\') {
459                $i++;
460
461                continue;
462            }
463
464            if ($char === '[') {
465                $inClass = true;
466
467                continue;
468            }
469
470            if ($char === ']') {
471                $inClass = false;
472
473                continue;
474            }
475
476            if (($char === "\n" || $char === "\r") && !$inClass) {
477                return $i;
478            }
479
480            if ($char === '/' && !$inClass) {
481                $i++;
482
483                while ($i < $length && ctype_alpha($code[$i])) {
484                    $i++;
485                }
486
487                return $i;
488            }
489        }
490
491        return $length;
492    }
493
494    private function skipTo(string $code, int $from, string $needle): int
495    {
496        $at = strpos($code, $needle, $from);
497
498        return $at === false ? strlen($code) : $at + strlen($needle);
499    }
500
501    private function skipTrivia(string $code, int $i): int
502    {
503        $length = strlen($code);
504
505        while ($i < $length) {
506            $i = $this->skipSpace($code, $i);
507
508            if (substr($code, $i, 2) === '//') {
509                $i = $this->skipTo($code, $i + 2, "\n");
510
511                continue;
512            }
513
514            if (substr($code, $i, 2) === '/*') {
515                $i = $this->skipTo($code, $i + 2, '*/');
516
517                continue;
518            }
519
520            return $i;
521        }
522
523        return $i;
524    }
525
526    private function skipSpace(string $code, int $i): int
527    {
528        $length = strlen($code);
529
530        while (
531            $i < $length
532            && ($code[$i] === ' ' || $code[$i] === "\t" || $code[$i] === "\n" || $code[$i] === "\r")
533        ) {
534            $i++;
535        }
536
537        return $i;
538    }
539
540    private function isNameStart(string $char): bool
541    {
542        return $char === '_' || $char === '$' || ctype_alpha($char);
543    }
544
545    private function isNamePart(string $char): bool
546    {
547        return $this->isNameStart($char) || ctype_digit($char);
548    }
549}