Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
154 / 154
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
FrontendScanner
100.00% covered (success)
100.00%
154 / 154
100.00% covered (success)
100.00%
15 / 15
85
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%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 identifier
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 arguments
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
15
 literal
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
12
 unescape
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 skipString
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 skipBraces
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 skipTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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 Celemas\Verba\Tool;
6
7/**
8 * Extracts `__`, `__n`, `__d`, and `__dn` calls from frontend 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. Calls inside template-literal
18 * interpolations are not extracted.
19 *
20 * @api
21 */
22final class FrontendScanner 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 (substr($code, $i, 2) === '//') {
95                $i = $this->skipTo($code, $i + 2, "\n");
96
97                continue;
98            }
99
100            if (substr($code, $i, 2) === '/*') {
101                $i = $this->skipTo($code, $i + 2, '*/');
102
103                continue;
104            }
105
106            if ($char === '`' || !$transparent && ($char === '"' || $char === "'")) {
107                $i = $this->skipString($code, $i, $char);
108
109                continue;
110            }
111
112            if (!$this->isNameStart($char)) {
113                $i++;
114
115                continue;
116            }
117
118            $i = $this->identifier($code, $i, $file, $lineBase);
119        }
120    }
121
122    /**
123     * Reads the identifier at $start and, when it is one of the call names used
124     * as a function, records it. Returns the index to continue from.
125     */
126    private function identifier(string $code, int $start, string $file, int $lineBase): int
127    {
128        $length = strlen($code);
129        $end = $start;
130
131        while ($end < $length && $this->isNamePart($code[$end])) {
132            $end++;
133        }
134
135        $name = substr($code, $start, $end - $start);
136        $prev = $start > 0 ? $code[$start - 1] : '';
137
138        if (!array_key_exists($name, self::CALLS) || $prev === '.' || $this->isNamePart($prev)) {
139            return $end;
140        }
141
142        $open = $this->skipSpace($code, $end);
143
144        if ($open >= $length || $code[$open] !== '(') {
145            return $end;
146        }
147
148        [$args, $after] = $this->arguments($code, $open);
149        $this->emit($name, $args, $file . ':' . ($lineBase + substr_count($code, "\n", 0, $start) + 1));
150
151        return $after;
152    }
153
154    /**
155     * @return array{list<?string>, int}
156     */
157    private function arguments(string $code, int $open): array
158    {
159        $length = strlen($code);
160        $i = $open + 1;
161        $start = $i;
162        $depth = 0;
163        $args = [];
164
165        while ($i < $length) {
166            $char = $code[$i];
167
168            if ($char === '"' || $char === "'" || $char === '`') {
169                $i = $this->skipString($code, $i, $char);
170
171                continue;
172            }
173
174            if ($char === '(' || $char === '[' || $char === '{') {
175                $depth++;
176                $i++;
177
178                continue;
179            }
180
181            if ($char === ')' && $depth === 0) {
182                $args[] = $this->literal(substr($code, $start, $i - $start));
183
184                return [$args, $i + 1];
185            }
186
187            if ($char === ')' || $char === ']' || $char === '}') {
188                $depth--;
189                $i++;
190
191                continue;
192            }
193
194            if ($char === ',' && $depth === 0) {
195                $args[] = $this->literal(substr($code, $start, $i - $start));
196                $i++;
197                $start = $i;
198
199                continue;
200            }
201
202            $i++;
203        }
204
205        return [$args, $length];
206    }
207
208    private function literal(string $raw): ?string
209    {
210        $raw = trim($raw);
211
212        if ($raw === '') {
213            return null;
214        }
215
216        $quote = $raw[0];
217
218        if ($quote !== '"' && $quote !== "'" && $quote !== '`') {
219            return null;
220        }
221
222        $length = strlen($raw);
223        $value = '';
224
225        for ($i = 1; $i < $length; $i++) {
226            $char = $raw[$i];
227
228            if ($char === '\\') {
229                $value .= $this->unescape($raw[$i + 1] ?? '');
230                $i++;
231
232                continue;
233            }
234
235            if ($quote === '`' && $char === '$' && ($raw[$i + 1] ?? '') === '{') {
236                return null;
237            }
238
239            if ($char === $quote) {
240                return $i === ($length - 1) ? $value : null;
241            }
242
243            $value .= $char;
244        }
245
246        // The argument parser only passes balanced segments, so a quoted arg
247        // always closes above.
248        // @codeCoverageIgnoreStart
249        return null;
250
251        // @codeCoverageIgnoreEnd
252    }
253
254    private function unescape(string $char): string
255    {
256        return match ($char) {
257            'n' => "\n",
258            't' => "\t",
259            'r' => "\r",
260            default => $char,
261        };
262    }
263
264    private function skipString(string $code, int $i, string $quote): int
265    {
266        $length = strlen($code);
267
268        for ($i++; $i < $length; $i++) {
269            $char = $code[$i];
270
271            if ($char === '\\') {
272                $i++;
273
274                continue;
275            }
276
277            if ($quote === '`' && $char === '$' && ($code[$i + 1] ?? '') === '{') {
278                $i = $this->skipBraces($code, $i + 1) - 1;
279
280                continue;
281            }
282
283            if ($char === $quote) {
284                return $i + 1;
285            }
286        }
287
288        return $length;
289    }
290
291    private function skipBraces(string $code, int $i): int
292    {
293        $length = strlen($code);
294        $depth = 0;
295
296        while ($i < $length) {
297            $char = $code[$i];
298
299            if ($char === '"' || $char === "'" || $char === '`') {
300                $i = $this->skipString($code, $i, $char);
301
302                continue;
303            }
304
305            if ($char === '{') {
306                $depth++;
307                $i++;
308
309                continue;
310            }
311
312            if ($char === '}') {
313                $depth--;
314                $i++;
315
316                if ($depth === 0) {
317                    return $i;
318                }
319
320                continue;
321            }
322
323            $i++;
324        }
325
326        return $length;
327    }
328
329    private function skipTo(string $code, int $from, string $needle): int
330    {
331        $at = strpos($code, $needle, $from);
332
333        return $at === false ? strlen($code) : $at + strlen($needle);
334    }
335
336    private function skipSpace(string $code, int $i): int
337    {
338        $length = strlen($code);
339
340        while (
341            $i < $length
342            && ($code[$i] === ' ' || $code[$i] === "\t" || $code[$i] === "\n" || $code[$i] === "\r")
343        ) {
344            $i++;
345        }
346
347        return $i;
348    }
349
350    private function isNameStart(string $char): bool
351    {
352        return $char === '_' || $char === '$' || ctype_alpha($char);
353    }
354
355    private function isNamePart(string $char): bool
356    {
357        return $this->isNameStart($char) || ctype_digit($char);
358    }
359}