Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
PhpScanner
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
6 / 6
35
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%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 callName
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 step
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7use PhpToken;
8
9/**
10 * Extracts Verba's eight translation calls from PHP source (including Boiler
11 * templates) by walking the token stream — no parser, no regex. Only literal
12 * string arguments are captured; a dynamic id, domain, context, or plural is
13 * reported as a warning and skipped.
14 *
15 * @api
16 */
17final class PhpScanner extends FileScanner
18{
19    #[\Override]
20    protected function extensions(): array
21    {
22        return ['php'];
23    }
24
25    #[\Override]
26    protected function scanCode(string $code, string $file): void
27    {
28        $tokens = PhpToken::tokenize($code);
29        $count = count($tokens);
30
31        for ($i = 0; $i < $count; $i++) {
32            $token = $tokens[$i];
33            $name = $this->callName($token);
34
35            if ($name === null) {
36                continue;
37            }
38
39            $before = $this->step($tokens, $i, -1);
40
41            if (
42                $before !== null
43                && $tokens[$before]->is([
44                    T_OBJECT_OPERATOR,
45                    T_NULLSAFE_OBJECT_OPERATOR,
46                    T_DOUBLE_COLON,
47                    T_FUNCTION,
48                ])
49            ) {
50                continue;
51            }
52
53            $open = $this->step($tokens, $i, 1);
54
55            if ($open === null || $tokens[$open]->text !== '(') {
56                continue;
57            }
58
59            $args = $this->arguments($tokens, $open);
60            $this->emit($name, $args, $file . ':' . $token->line);
61        }
62    }
63
64    private function callName(PhpToken $symbol): ?string
65    {
66        if ($symbol->is(T_STRING) && array_key_exists($symbol->text, self::CALLS)) {
67            return $symbol->text;
68        }
69
70        if ($symbol->is(T_NAME_FULLY_QUALIFIED)) {
71            $name = ltrim($symbol->text, '\\');
72
73            if (array_key_exists($name, self::CALLS)) {
74                return $name;
75            }
76        }
77
78        return null;
79    }
80
81    /**
82     * Index of the next significant token from $from in direction $dir.
83     *
84     * @param list<PhpToken> $tokens
85     */
86    private function step(array $tokens, int $from, int $dir): ?int
87    {
88        for ($i = $from + $dir; array_key_exists($i, $tokens); $i += $dir) {
89            if (!$tokens[$i]->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {
90                return $i;
91            }
92        }
93
94        return null;
95    }
96
97    /**
98     * Parses the argument list starting at the opening paren. Each argument is
99     * its literal string value, or null when it is not a single string literal.
100     *
101     * @param list<PhpToken> $tokens
102     * @return list<?string>
103     */
104    private function arguments(array $tokens, int $open): array
105    {
106        $args = [];
107        $current = [];
108        $depth = 0;
109        $count = count($tokens);
110
111        for ($i = $open; $i < $count; $i++) {
112            $token = $tokens[$i];
113            $text = $token->text;
114
115            if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {
116                continue;
117            }
118
119            if ($text === '(' || $text === '[' || $text === '{') {
120                if ($depth > 0) {
121                    $current[] = $token;
122                }
123
124                $depth++;
125
126                continue;
127            }
128
129            if ($text === ')' || $text === ']' || $text === '}') {
130                $depth--;
131
132                if ($depth === 0) {
133                    if ($current !== [] || $args !== []) {
134                        $args[] = $this->literal($current);
135                    }
136
137                    return $args;
138                }
139
140                $current[] = $token;
141
142                continue;
143            }
144
145            if ($text === ',' && $depth === 1) {
146                $args[] = $this->literal($current);
147                $current = [];
148
149                continue;
150            }
151
152            $current[] = $token;
153        }
154
155        return $args;
156    }
157
158    /**
159     * @param list<PhpToken> $tokens
160     */
161    private function literal(array $tokens): ?string
162    {
163        if (count($tokens) !== 1 || !$tokens[0]->is(T_CONSTANT_ENCAPSED_STRING)) {
164            return null;
165        }
166
167        $text = $tokens[0]->text;
168        $inner = substr($text, 1, -1);
169
170        if ($text[0] === "'") {
171            return str_replace(['\\\\', "\\'"], ['\\', "'"], $inner);
172        }
173
174        return stripcslashes($inner);
175    }
176}