packages/deriver/tests/Unit/FinderTest.php
1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit;
6
7use Deriver\Deriver\Sources\Path;
8use Deriver\Exception\InvalidInputException;
9use Deriver\Finder;
10use Deriver\Finder\DirectoryScan;
11use Deriver\Finder\PathPattern;
12use org\bovigo\vfs\vfsStream;
13use Override;
14use phpmock\Mock;
15use phpmock\MockBuilder;
16use PHPUnit\Framework\Attributes\CoversClass;
17use PHPUnit\Framework\Attributes\DataProvider;
18use PHPUnit\Framework\Attributes\PreserveGlobalState;
19use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
20use PHPUnit\Framework\Attributes\Small;
21use PHPUnit\Framework\Attributes\UsesClass;
22use PHPUnit\Framework\TestCase;
23
24#[CoversClass(Finder::class)]
25#[UsesClass(PathPattern::class)]
26#[UsesClass(DirectoryScan::class)]
27#[UsesClass(InvalidInputException::class)]
28#[Small]
29#[RunTestsInSeparateProcesses]
30#[PreserveGlobalState(false)]
31final class FinderTest extends TestCase
32{
33 /**
34 * @throws \phpmock\MockEnabledException
35 */
36 public function testFilesApplyIncludesAndExcludesWithoutReadingPhp(): void
37 {
38 $filesystem = vfsStream::setup('project', null, ['src' => ['nested' => [], 'vendor' => []]]);
39 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
40 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
41 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
42 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
43 $source = $filesystem;
44 $name = 'src/One.php';
45 $contents = '<?php throw new RuntimeException("never execute");';
46 $path = $source->url() . '/' . $name;
47 file_put_contents($path, $contents);
48 $one = $path;
49 $name1 = 'src/nested/Two.php';
50 $contents1 = 'not valid PHP';
51 $path1 = $source->url() . '/' . $name1;
52 file_put_contents($path1, $contents1);
53 $two = $path1;
54 $name2 = 'src/nested/TwoTest.php';
55 $contents2 = '<?php';
56 $path2 = $source->url() . '/' . $name2;
57 file_put_contents($path2, $contents2);
58 $name3 = 'src/vendor/Ignore.php';
59 $contents3 = '<?php';
60 $path3 = $source->url() . '/' . $name3;
61 file_put_contents($path3, $contents3);
62 $name4 = 'README.md';
63 $contents4 = 'text';
64 $path4 = $source->url() . '/' . $name4;
65 file_put_contents($path4, $contents4);
66 $finder = (new Finder())->in('/project')->include('src/**/*.php')->exclude('**/*Test.php', '**/vendor/**');
67 self::assertSame([$one, $two], $finder->files());
68 }
69 /**
70 * @throws \phpmock\MockEnabledException
71 */
72 public function testIncludeWildcardsRespectDirectoriesAndMatchZeroOrManyLevels(): void
73 {
74 $filesystem = vfsStream::setup('project', null, ['nested' => ['deep' => []]]);
75 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
76 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
77 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
78 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
79 $source = $filesystem;
80 $name = 'A1.php';
81 $contents = '';
82 $path = $source->url() . '/' . $name;
83 file_put_contents($path, $contents);
84 $top = $path;
85 $name1 = 'nested/A2.php';
86 $contents1 = '';
87 $path1 = $source->url() . '/' . $name1;
88 file_put_contents($path1, $contents1);
89 $nested = $path1;
90 $name2 = 'nested/deep/A3.php';
91 $contents2 = '';
92 $path2 = $source->url() . '/' . $name2;
93 file_put_contents($path2, $contents2);
94 $deep = $path2;
95 $name3 = 'A12.php';
96 $contents3 = '';
97 $path3 = $source->url() . '/' . $name3;
98 file_put_contents($path3, $contents3);
99 self::assertSame([$top], (new Finder())->in('/project')->include('A?.php')->files());
100 self::assertSame([$top, $nested, $deep], (new Finder())->in('/project')->include('**/A?.php')->files());
101 self::assertSame([$nested], (new Finder())->in('/project')->include('*/A?.php')->files());
102 }
103 /**
104 * @throws \phpmock\MockEnabledException
105 */
106 public function testExcludeWinsAfterCombiningIncludesRegardlessOfCallOrder(): void
107 {
108 $filesystem = vfsStream::setup('project', null, ['src' => []]);
109 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
110 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
111 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
112 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
113 $source = $filesystem;
114 $name = 'src/Keep.php';
115 $contents = '';
116 $path = $source->url() . '/' . $name;
117 file_put_contents($path, $contents);
118 $keep = $path;
119 $name1 = 'src/Drop.php';
120 $contents1 = '';
121 $path1 = $source->url() . '/' . $name1;
122 file_put_contents($path1, $contents1);
123 $name2 = 'extra.inc';
124 $contents2 = '';
125 $path2 = $source->url() . '/' . $name2;
126 file_put_contents($path2, $contents2);
127 $extra = $path2;
128 $finder = (new Finder())->in('/project')->exclude('**/Drop.php')->include('src/*.php')->include('extra.inc', 'src/Drop.php');
129 self::assertSame([$extra, $keep], $finder->files());
130 }
131 /**
132 * @throws \phpmock\MockEnabledException
133 */
134 public function testInCombinesOverlappingRootsIntoSortedUniqueAbsolutePaths(): void
135 {
136 $filesystem = vfsStream::setup('project', null, ['nested' => []]);
137 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
138 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
139 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
140 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
141 $source = $filesystem;
142 $name = 'z.php';
143 $contents = '';
144 $path = $source->url() . '/' . $name;
145 file_put_contents($path, $contents);
146 $z = $path;
147 $name1 = 'nested/a.php';
148 $contents1 = '';
149 $path1 = $source->url() . '/' . $name1;
150 file_put_contents($path1, $contents1);
151 $a = $path1;
152 self::assertSame([$a, $z], (new Finder())->in('/project' . '/.', '/project' . '/nested')->in('/project')->include('**/*.php')->files());
153 }
154 /**
155 * @throws \phpmock\MockEnabledException
156 */
157 public function testEnumerationDoesNotRequireReadAccessToFileContents(): void
158 {
159 $filesystem = vfsStream::setup('project');
160 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
161 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
162 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
163 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
164 $source = $filesystem;
165 $name = 'unreadable.php';
166 $contents = '<?php';
167 $path1 = $source->url() . '/' . $name;
168 file_put_contents($path1, $contents);
169 $path = $path1;
170 self::assertTrue(chmod($path, 00));
171 try {
172 self::assertSame([$path], (new Finder())->in('/project')->files());
173 } finally {
174 chmod($path, 0600);
175 }
176 }
177 /**
178 * @throws \phpmock\MockEnabledException
179 */
180 public function testDefaultsUseCurrentDirectoryAndDoNotImplicitlyExcludeFiles(): void
181 {
182 $filesystem = vfsStream::setup('project', null, ['vendor' => []]);
183 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
184 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
185 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
186 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
187 $source = $filesystem;
188 $name = '.hidden';
189 $contents = '';
190 $path = $source->url() . '/' . $name;
191 file_put_contents($path, $contents);
192 $hidden = $path;
193 $name1 = 'vendor/file.txt';
194 $contents1 = '';
195 $path1 = $source->url() . '/' . $name1;
196 file_put_contents($path1, $contents1);
197 $vendor = $path1;
198 self::assertSame([$hidden, $vendor], (new Finder())->files());
199 self::assertSame([], (new Finder())->include('**/*.php')->files());
200 }
201 /**
202 * @throws \phpmock\MockEnabledException
203 */
204 public function testRepeatedEnumerationSeesNewFiles(): void
205 {
206 $filesystem = vfsStream::setup('project');
207 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
208 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
209 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
210 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
211 $source = $filesystem;
212 $finder = (new Finder())->in('/project')->include('**/*.php');
213 self::assertSame([], $finder->files());
214 $name = 'new.php';
215 $contents = '';
216 $path1 = $source->url() . '/' . $name;
217 file_put_contents($path1, $contents);
218 $path = $path1;
219 self::assertSame([$path], $finder->files());
220 }
221 /**
222 * @throws \phpmock\MockEnabledException
223 */
224 #[DataProvider('providerInvalidRoots')]
225 public function testRejectsInvalidRoots(string $root): void
226 {
227 $filesystem = vfsStream::setup('project');
228 $url = static fn (string $path): string => 'vfs:/' . Path::normalize($path === '.' ? '/project' : $path);
229 (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir($url($path)))->build()->enable();
230 (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable($url($path)))->build()->enable();
231 (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction($url)->build()->enable();
232 $this->expectException(InvalidInputException::class);
233 (new Finder())->in($root);
234 }
235 /**
236 * @return iterable<string, array{string}>
237 */
238 public static function providerInvalidRoots(): iterable
239 {
240 yield 'missing' => [__DIR__ . '/absent-root'];
241 yield 'file' => [__FILE__];
242 yield 'wrapper' => ['file://' . __DIR__];
243 }
244 #[Override]
245 protected function tearDown(): void
246 {
247 Mock::disableAll();
248 parent::tearDown();
249 }
250}
251