Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
PhpScanner
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
5 / 5
31
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
8
 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%
30 / 30
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 Celemas\Verba\Tool;
6
7use PhpToken;
8
9/**
10 * Extracts `__`, `__n`, `__d`, and `__dn` calls from PHP source (including
11 * Boiler templates) by walking the token stream — no parser, no regex. Only
12 * literal string arguments are captured; a dynamic id, domain, 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
34            if (!$token->is(T_STRING) || !array_key_exists($token->text, self::CALLS)) {
35                continue;
36            }
37
38            $before = $this->step($tokens, $i, -1);
39
40            if (
41                $before !== null
42                && $tokens[$before]->is([
43                    T_OBJECT_OPERATOR,
44                    T_NULLSAFE_OBJECT_OPERATOR,
45                    T_DOUBLE_COLON,
46                    T_FUNCTION,
47                ])
48            ) {
49                continue;
50            }
51
52            $open = $this->step($tokens, $i, 1);
53
54            if ($open === null || $tokens[$open]->text !== '(') {
55                continue;
56            }
57
58            [$args, $end] = $this->arguments($tokens, $open);
59            $this->emit($token->text, $args, $file . ':' . $token->line);
60            $i = $end;
61        }
62    }
63
64    /**
65     * Index of the next significant token from $from in direction $dir.
66     *
67     * @param list<PhpToken> $tokens
68     */
69    private function step(array $tokens, int $from, int $dir): ?int
70    {
71        for ($i = $from + $dir; array_key_exists($i, $tokens); $i += $dir) {
72            if (!$tokens[$i]->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {
73                return $i;
74            }
75        }
76
77        return null;
78    }
79
80    /**
81     * Parses the argument list starting at the opening paren. Each argument is
82     * its literal string value, or null when it is not a single string literal.
83     *
84     * @param list<PhpToken> $tokens
85     * @return array{list<?string>, int}
86     */
87    private function arguments(array $tokens, int $open): array
88    {
89        $args = [];
90        $current = [];
91        $depth = 0;
92        $count = count($tokens);
93        $end = $count - 1;
94
95        for ($i = $open; $i < $count; $i++) {
96            $token = $tokens[$i];
97            $text = $token->text;
98
99            if ($token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {
100                continue;
101            }
102
103            if ($text === '(' || $text === '[' || $text === '{') {
104                if ($depth > 0) {
105                    $current[] = $token;
106                }
107
108                $depth++;
109
110                continue;
111            }
112
113            if ($text === ')' || $text === ']' || $text === '}') {
114                $depth--;
115
116                if ($depth === 0) {
117                    if ($current !== [] || $args !== []) {
118                        $args[] = $this->literal($current);
119                    }
120
121                    $end = $i;
122
123                    break;
124                }
125
126                $current[] = $token;
127
128                continue;
129            }
130
131            if ($text === ',' && $depth === 1) {
132                $args[] = $this->literal($current);
133                $current = [];
134
135                continue;
136            }
137
138            $current[] = $token;
139        }
140
141        return [$args, $end];
142    }
143
144    /**
145     * @param list<PhpToken> $tokens
146     */
147    private function literal(array $tokens): ?string
148    {
149        if (count($tokens) !== 1 || !$tokens[0]->is(T_CONSTANT_ENCAPSED_STRING)) {
150            return null;
151        }
152
153        $text = $tokens[0]->text;
154        $inner = substr($text, 1, -1);
155
156        if ($text[0] === "'") {
157            return str_replace(['\\\\', "\\'"], ['\\', "'"], $inner);
158        }
159
160        return stripcslashes($inner);
161    }
162}