Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
FileScanner
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
5 / 5
18
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%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 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 Celemas\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     * Call name => [domain arg index or null, id arg index, plural arg index or null].
22     *
23     * @var array<string, array{?int, int, ?int}>
24     */
25    protected const array CALLS = [
26        '__' => [null, 0, null],
27        '__n' => [null, 0, 1],
28        '__d' => [0, 1, null],
29        '__dn' => [0, 1, 2],
30    ];
31
32    /** @var list<Message> */
33    protected array $messages = [];
34
35    /** @var list<string> */
36    protected array $warnings = [];
37
38    /**
39     * @param list<string> $roots Files or directories to scan.
40     */
41    public function __construct(
42        protected readonly array $roots,
43    ) {}
44
45    #[\Override]
46    public function scan(): array
47    {
48        $this->messages = [];
49        $this->warnings = [];
50
51        foreach ($this->files() as $file) {
52            $this->scanCode((string) file_get_contents($file), $file);
53        }
54
55        return $this->messages;
56    }
57
58    #[\Override]
59    public function warnings(): array
60    {
61        return $this->warnings;
62    }
63
64    /**
65     * File extensions (without dot) this scanner reads.
66     *
67     * @return list<string>
68     */
69    abstract protected function extensions(): array;
70
71    abstract protected function scanCode(string $code, string $file): void;
72
73    /**
74     * Records a discovered call, or a warning when its id, domain, or plural was
75     * not a literal string.
76     *
77     * @param list<?string> $args
78     */
79    protected function emit(string $name, array $args, string $location): void
80    {
81        [$domainIndex, $idIndex, $pluralIndex] = self::CALLS[$name];
82
83        $id = $args[$idIndex] ?? null;
84
85        if ($id === null) {
86            $this->warnings[] = "Non-literal message id in {$name}() at {$location}";
87
88            return;
89        }
90
91        $domain = null;
92
93        if ($domainIndex !== null) {
94            $domain = $args[$domainIndex] ?? null;
95
96            if ($domain === null) {
97                $this->warnings[] = "Non-literal domain in {$name}() at {$location}";
98
99                return;
100            }
101        }
102
103        $plural = null;
104
105        if ($pluralIndex !== null) {
106            $plural = $args[$pluralIndex] ?? null;
107
108            if ($plural === null) {
109                $this->warnings[] = "Non-literal plural in {$name}() at {$location}";
110
111                return;
112            }
113        }
114
115        $this->messages[] = new Message($domain, $id, $plural, [$location]);
116    }
117
118    /**
119     * @return list<string>
120     */
121    private function files(): array
122    {
123        $found = [];
124
125        foreach ($this->roots as $root) {
126            if (is_file($root)) {
127                $found[] = $root;
128
129                continue;
130            }
131
132            if (!is_dir($root)) {
133                continue;
134            }
135
136            $tree = new RecursiveIteratorIterator(
137                new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
138            );
139
140            /** @var mixed $entry */
141            foreach ($tree as $entry) {
142                if (
143                    !$entry instanceof SplFileInfo
144                    || !$entry->isFile()
145                    || !in_array(strtolower($entry->getExtension()), $this->extensions(), true)
146                ) {
147                    continue;
148                }
149
150                $found[] = $entry->getPathname();
151            }
152        }
153
154        sort($found);
155
156        return $found;
157    }
158}