Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
FileScanner
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
5 / 5
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scan
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 warnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extensions
n/a
0 / 0
n/a
0 / 0
0
 scanCode
n/a
0 / 0
n/a
0 / 0
0
 emit
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 files
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7use FilesystemIterator;
8use RecursiveDirectoryIterator;
9use RecursiveIteratorIterator;
10use SplFileInfo;
11
12/**
13 * Walks the configured roots and feeds each matching file to the concrete
14 * scanner. Files are visited in a stable, sorted order.
15 *
16 * @api
17 */
18abstract class FileScanner implements Scanner
19{
20    /**
21     * Argument indexes for each recognized call.
22     *
23     * @var array<string, array{domain: ?int, context: ?int, id: int, plural: ?int}>
24     */
25    protected const array CALLS = [
26        '__' => ['domain' => null, 'context' => null, 'id' => 0, 'plural' => null],
27        '__n' => ['domain' => null, 'context' => null, 'id' => 0, 'plural' => 1],
28        '__p' => ['domain' => null, 'context' => 0, 'id' => 1, 'plural' => null],
29        '__np' => ['domain' => null, 'context' => 0, 'id' => 1, 'plural' => 2],
30        '__d' => ['domain' => 0, 'context' => null, 'id' => 1, 'plural' => null],
31        '__dn' => ['domain' => 0, 'context' => null, 'id' => 1, 'plural' => 2],
32        '__dp' => ['domain' => 0, 'context' => 1, 'id' => 2, 'plural' => null],
33        '__dnp' => ['domain' => 0, 'context' => 1, 'id' => 2, 'plural' => 3],
34    ];
35
36    /** @var list<Message> */
37    protected array $messages = [];
38
39    /** @var list<string> */
40    protected array $warnings = [];
41
42    /**
43     * @param list<string> $roots Files or directories to scan.
44     */
45    public function __construct(
46        protected readonly array $roots,
47    ) {}
48
49    #[\Override]
50    public function scan(): array
51    {
52        $this->messages = [];
53        $this->warnings = [];
54
55        foreach ($this->files() as $file) {
56            $this->scanCode((string) file_get_contents($file), $file);
57        }
58
59        return $this->messages;
60    }
61
62    #[\Override]
63    public function warnings(): array
64    {
65        return $this->warnings;
66    }
67
68    /**
69     * File extensions (without dot) this scanner reads.
70     *
71     * @return list<string>
72     */
73    abstract protected function extensions(): array;
74
75    abstract protected function scanCode(string $code, string $file): void;
76
77    /**
78     * Records a discovered call, or a warning when its id, domain, context, or
79     * plural was not a literal string.
80     *
81     * @param list<?string> $args
82     */
83    protected function emit(string $name, array $args, string $location): void
84    {
85        $call = self::CALLS[$name];
86        $id = $args[$call['id']] ?? null;
87
88        if ($id === null) {
89            $this->warnings[] = "Non-literal message id in {$name}() at {$location}";
90
91            return;
92        }
93
94        $domain = null;
95
96        if ($call['domain'] !== null) {
97            $domain = $args[$call['domain']] ?? null;
98
99            if ($domain === null) {
100                $this->warnings[] = "Non-literal domain in {$name}() at {$location}";
101
102                return;
103            }
104        }
105
106        $context = null;
107
108        if ($call['context'] !== null) {
109            $context = $args[$call['context']] ?? null;
110
111            if ($context === null) {
112                $this->warnings[] = "Non-literal context in {$name}() at {$location}";
113
114                return;
115            }
116        }
117
118        $plural = null;
119
120        if ($call['plural'] !== null) {
121            $plural = $args[$call['plural']] ?? null;
122
123            if ($plural === null) {
124                $this->warnings[] = "Non-literal plural in {$name}() at {$location}";
125
126                return;
127            }
128        }
129
130        $this->messages[] = new Message($domain, $id, $plural, [$location], $context);
131    }
132
133    /**
134     * @return list<string>
135     */
136    private function files(): array
137    {
138        $found = [];
139
140        foreach ($this->roots as $root) {
141            if (is_file($root)) {
142                $found[] = $root;
143
144                continue;
145            }
146
147            if (!is_dir($root)) {
148                continue;
149            }
150
151            $tree = new RecursiveIteratorIterator(
152                new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
153            );
154
155            /** @var mixed $entry */
156            foreach ($tree as $entry) {
157                if (
158                    !$entry instanceof SplFileInfo
159                    || !$entry->isFile()
160                    || !in_array(strtolower($entry->getExtension()), $this->extensions(), true)
161                ) {
162                    continue;
163                }
164
165                $found[] = $entry->getPathname();
166            }
167        }
168
169        sort($found);
170
171        return $found;
172    }
173}