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