diff --git a/packages/documentator/src/Processor/Contracts/Task.php b/packages/documentator/src/Processor/Contracts/Task.php new file mode 100644 index 00000000..d1af1d03 --- /dev/null +++ b/packages/documentator/src/Processor/Contracts/Task.php @@ -0,0 +1,28 @@ + + */ + public function getExtensions(): array; + + /** + * Performs action on the `$file`. + * + * Each returned value will be treated as a dependency of the task. It will + * be resolved relative to the directory where the `$file` located, + * processed, and then send back into the generator. + * + * @return Generator|bool + */ + public function __invoke(Directory $root, File $file): Generator|bool; +} diff --git a/packages/documentator/src/Processor/Exceptions/CircularDependency.php b/packages/documentator/src/Processor/Exceptions/CircularDependency.php new file mode 100644 index 00000000..a8d87f61 --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/CircularDependency.php @@ -0,0 +1,60 @@ + $stack + */ + public function __construct( + protected Directory $root, + protected readonly File $target, + protected readonly File $dependency, + protected readonly array $stack, + Throwable $previous = null, + ) { + parent::__construct( + sprintf( + <<<'MESSAGE' + Circular Dependency detected: + + %2$s + ! %1$s + + (root: `%3$s`) + MESSAGE, + $this->dependency->getRelativePath($this->root), + '* '.implode("\n* ", array_map(fn ($f) => $f->getRelativePath($this->root), $this->stack)), + $this->root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } + + public function getTarget(): File { + return $this->target; + } + + public function getDependency(): File { + return $this->dependency; + } + + /** + * @return list + */ + public function getStack(): array { + return $this->stack; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/FileDependencyNotFound.php b/packages/documentator/src/Processor/Exceptions/FileDependencyNotFound.php new file mode 100644 index 00000000..f37f171a --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/FileDependencyNotFound.php @@ -0,0 +1,37 @@ +root->getPath(), $this->path), + $this->target->getRelativePath($this->root), + $this->root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } + + public function getPath(): string { + return $this->path; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/FileSaveFailed.php b/packages/documentator/src/Processor/Exceptions/FileSaveFailed.php new file mode 100644 index 00000000..6da9ad8b --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/FileSaveFailed.php @@ -0,0 +1,34 @@ +target->getRelativePath($this->root), + $this->root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } + + public function getTarget(): File { + return $this->target; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/FileTaskFailed.php b/packages/documentator/src/Processor/Exceptions/FileTaskFailed.php new file mode 100644 index 00000000..9e0ab5d9 --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/FileTaskFailed.php @@ -0,0 +1,41 @@ +target->getRelativePath($this->root), + $this->task::class, + $this->root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } + + public function getTarget(): File { + return $this->target; + } + + public function getTask(): Task { + return $this->task; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/ProcessingFailed.php b/packages/documentator/src/Processor/Exceptions/ProcessingFailed.php new file mode 100644 index 00000000..644c8b4e --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/ProcessingFailed.php @@ -0,0 +1,27 @@ +root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/ProcessorError.php b/packages/documentator/src/Processor/Exceptions/ProcessorError.php new file mode 100644 index 00000000..3289239f --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/ProcessorError.php @@ -0,0 +1,9 @@ +path)) { + throw new InvalidArgumentException( + sprintf( + 'Path must be normalized, `%s` given.', + $this->path, + ), + ); + } + + if (!Path::isAbsolute($this->path)) { + throw new InvalidArgumentException( + sprintf( + 'Path must be absolute, `%s` given.', + $this->path, + ), + ); + } + + if (!is_dir($this->path)) { + throw new InvalidArgumentException( + sprintf( + 'The `%s` is not a directory.', + $this->path, + ), + ); + } + } + + public function getPath(?string $path = null): string { + return $path !== null ? Path::getPath($this->path, $path) : $this->path; + } + + public function getName(): string { + return basename($this->path); + } + + public function isWritable(): bool { + return $this->writable && is_writable($this->path); + } + + public function getFile(SplFileInfo|File|string $path): ?File { + // Object? + if ($path instanceof SplFileInfo) { + $path = $path->getPathname(); + } elseif ($path instanceof File) { + $path = $path->getPath(); + } else { + // empty + } + + // File? + $path = $this->getPath($path); + + if (!is_file($path)) { + return null; + } + + // Create + $writable = $this->writable && $this->isInside($path); + $file = new File($path, $writable); + + return $file; + } + + public function getDirectory(SplFileInfo|self|File|string $path): ?self { + // Object? + if ($path instanceof SplFileInfo) { + $path = $path->getPathname(); + } elseif ($path instanceof File) { + $path = dirname($path->getPath()); + } elseif ($path instanceof self) { + $path = $path->getPath(); + } else { + // empty + } + + // Self? + if ($path === '.' || $path === '') { + return $this; + } + + // Directory? + $path = $this->getPath($path); + + if (!is_dir($path)) { + return null; + } + + // Create + $writable = $this->writable && $this->isInside($path); + $dir = $this->path !== $path + ? new self($path, $writable) + : $this; + + return $dir; + } + + public function isInside(SplFileInfo|File|self|string $path): bool { + $path = match (true) { + $path instanceof SplFileInfo => $this->getPath($path->getPathname()), + is_object($path) => $path->getPath(), + default => $this->getPath($path), + }; + $inside = $path !== $this->path + && str_starts_with($path, $this->path); + + return $inside; + } + + /** + * @param array|string|null $patterns {@see Finder::name()} + * @param array|string|int|null $depth {@see Finder::depth()} + * @param array|string|null $exclude {@see Finder::notPath()} + * + * @return Iterator + */ + public function getFilesIterator( + array|string|null $patterns = null, + array|string|int|null $depth = null, + array|string|null $exclude = null, + ): Iterator { + yield from $this->getIterator($this->getFile(...), $patterns, $depth, $exclude); + } + + /** + * @param array|string|null $patterns {@see Finder::name()} + * @param array|string|int|null $depth {@see Finder::depth()} + * @param array|string|null $exclude {@see Finder::notPath()} + * + * @return Iterator + */ + public function getDirectoriesIterator( + array|string|null $patterns = null, + array|string|int|null $depth = null, + array|string|null $exclude = null, + ): Iterator { + yield from $this->getIterator($this->getDirectory(...), $patterns, $depth, $exclude); + } + + /** + * @template T of object + * + * @param Closure(SplFileInfo): ?T $factory + * @param array|string|null $patterns {@see Finder::name()} + * @param array|string|int|null $depth {@see Finder::depth()} + * @param array|string|null $exclude {@see Finder::notPath()} + * + * @return Iterator + */ + protected function getIterator( + Closure $factory, + array|string|null $patterns = null, + array|string|int|null $depth = null, + array|string|null $exclude = null, + ): Iterator { + $finder = Finder::create() + ->ignoreVCSIgnored(true) + ->exclude('node_modules') + ->exclude('vendor') + ->in($this->path) + ->sortByName(true); + + if ($patterns !== null) { + $finder = $finder->name($patterns); + } + + if ($depth !== null) { + $finder = $finder->depth($depth); + } + + if ($exclude !== null) { + $finder = $finder->notPath($exclude); + } + + foreach ($finder as $info) { + $item = $factory($info); + + if ($item !== null) { + yield $item; + } + } + + yield from []; + } + + public function getRelativePath(self|File $root): string { + $root = $root instanceof File ? dirname($root->getPath()) : $root->getPath(); + $path = Path::getRelativePath($root, $this->path); + + return $path; + } + + #[Override] + public function __toString(): string { + return $this->getPath(); + } +} diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest.php b/packages/documentator/src/Processor/FileSystem/DirectoryTest.php new file mode 100644 index 00000000..7c414e79 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest.php @@ -0,0 +1,305 @@ +getPath()); + self::assertEquals("{$path}", $directory->getPath()); + } + + public function testConstructNotNormalized(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Path must be normalized, `/../path` given.'); + + new Directory('/../path', false); + } + + public function testConstructNotAbsolute(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Path must be absolute, `../path` given.'); + + new Directory('../path', false); + } + + public function testConstructNotDirectory(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage(sprintf('The `%s` is not a directory.', Path::normalize(__FILE__))); + + new Directory(Path::normalize(__FILE__), false); + } + + public function testIsInside(): void { + $path = __FILE__; + $file = new File(Path::normalize($path), false); + $splFile = new SplFileInfo($path); + $directory = new Directory(Path::normalize(__DIR__), true); + $splDirectory = new SplFileInfo(Path::join(__DIR__, 'abc')); + + self::assertTrue($directory->isInside($path)); + self::assertTrue($directory->isInside($file)); + self::assertFalse($directory->isInside(__DIR__)); + self::assertFalse($directory->isInside($directory)); + self::assertTrue($directory->isInside('./file.txt')); + self::assertFalse($directory->isInside('./../file.txt')); + self::assertTrue($directory->isInside('./path/../file.txt')); + self::assertTrue($directory->isInside($splFile)); + self::assertTrue($directory->isInside($splDirectory)); + } + + public function testGetFile(): void { + $directory = new Directory(Path::normalize(__DIR__), false); + $readonly = $directory->getFile(__FILE__); + $relative = $directory->getFile(basename(__FILE__)); + $notfound = $directory->getFile('not found'); + $writable = new Directory(Path::normalize(__DIR__), true); + $internal = $writable->getFile(basename(__FILE__)); + $external = $writable->getFile('../Processor.php'); + $file = new File(Path::normalize(__FILE__), false); + $fromFile = $writable->getFile($file); + $splFile = new SplFileInfo($file->getPath()); + $fromSplFile = $writable->getFile($splFile); + + self::assertNotNull($readonly); + self::assertFalse($readonly->isWritable()); + self::assertEquals(Path::normalize(__FILE__), $readonly->getPath()); + + self::assertNotNull($relative); + self::assertFalse($relative->isWritable()); + self::assertEquals(Path::normalize(__FILE__), $relative->getPath()); + + self::assertNull($notfound); + + self::assertNotNull($internal); + self::assertTrue($internal->isWritable()); + self::assertEquals(Path::normalize(__FILE__), $internal->getPath()); + + self::assertNotNull($external); + self::assertFalse($external->isWritable()); + self::assertEquals(Path::getPath(__FILE__, '../Processor.php'), $external->getPath()); + + self::assertNotNull($fromFile); + self::assertFalse($file->isWritable()); + self::assertTrue($fromFile->isWritable()); + self::assertEquals($file->getPath(), $fromFile->getPath()); + self::assertEquals(Path::normalize(__FILE__), $fromFile->getPath()); + + self::assertNotNull($fromSplFile); + self::assertFalse($file->isWritable()); + self::assertTrue($fromSplFile->isWritable()); + self::assertEquals($file->getPath(), $fromSplFile->getPath()); + self::assertEquals(Path::normalize(__FILE__), $fromSplFile->getPath()); + } + + public function testGetDirectory(): void { + // Prepare + $directory = new Directory(Path::getPath(__DIR__, '..'), false); + $writable = new Directory(Path::getPath(__DIR__, '..'), true); + + // Self + self::assertSame($directory, $directory->getDirectory('')); + self::assertSame($directory, $directory->getDirectory('.')); + self::assertSame($directory, $directory->getDirectory($directory->getPath())); + + // Readonly + $readonly = $directory->getDirectory(__DIR__); + + self::assertNotNull($readonly); + self::assertFalse($readonly->isWritable()); + self::assertEquals(Path::normalize(__DIR__), $readonly->getPath()); + + // Relative + $relative = $directory->getDirectory(basename(__DIR__)); + + self::assertNotNull($relative); + self::assertFalse($relative->isWritable()); + self::assertEquals(Path::normalize(__DIR__), $relative->getPath()); + + // Not directory + $notDirectory = $directory->getDirectory('not directory'); + + self::assertNull($notDirectory); + + // Internal + $internal = $writable->getDirectory(basename(__DIR__)); + + self::assertNotNull($internal); + self::assertTrue($internal->isWritable()); + self::assertEquals(Path::normalize(__DIR__), $internal->getPath()); + + // External + $external = $writable->getDirectory('../Testing'); + + self::assertNotNull($external); + self::assertFalse($external->isWritable()); + self::assertEquals(Path::getPath(__DIR__, '../../Testing'), $external->getPath()); + + // From file + $fromFile = $writable->getDirectory(new File(Path::normalize(__FILE__), true)); + + self::assertNotNull($fromFile); + self::assertTrue($fromFile->isWritable()); + self::assertEquals(Path::normalize(__DIR__), $fromFile->getPath()); + + // From SplFileInfo + $spl = new SplFileInfo(__DIR__); + $fromSpl = $writable->getDirectory($spl); + + self::assertNotNull($fromSpl); + self::assertTrue($fromSpl->isWritable()); + self::assertEquals(Path::normalize($spl->getPathname()), $fromSpl->getPath()); + + // From Directory + $directory = new Directory(Path::normalize(__DIR__), false); + $fromDirectory = $writable->getDirectory($directory); + + self::assertNotNull($fromDirectory); + self::assertFalse($directory->isWritable()); + self::assertTrue($fromDirectory->isWritable()); + self::assertEquals($directory->getPath(), $fromDirectory->getPath()); + } + + public function testGetFilesIterator(): void { + $root = Path::normalize(self::getTestData()->path('')); + $directory = new Directory($root, false); + $map = static function (File $file) use ($root): string { + return Path::getRelativePath($root, $file->getPath()); + }; + + self::assertEquals( + [ + 'a/a.html', + 'a/a.txt', + 'a/a/aa.txt', + 'a/b/ab.txt', + 'b/a/ba.txt', + 'b/b.html', + 'b/b.txt', + 'b/b/bb.txt', + 'c.html', + 'c.txt', + ], + array_map($map, iterator_to_array($directory->getFilesIterator())), + ); + + self::assertEquals( + [ + 'a/a.html', + 'b/b.html', + 'c.html', + ], + array_map($map, iterator_to_array($directory->getFilesIterator('*.html'))), + ); + + self::assertEquals( + [ + 'c.html', + 'c.txt', + ], + array_map($map, iterator_to_array($directory->getFilesIterator(depth: 0))), + ); + + self::assertEquals( + [ + 'c.html', + ], + array_map($map, iterator_to_array($directory->getFilesIterator('*.html', 0))), + ); + + self::assertEquals( + [ + 'a/a.html', + 'b/b.html', + 'c.html', + ], + array_map($map, iterator_to_array($directory->getFilesIterator(exclude: ['#.*?\.txt$#']))), + ); + } + + public function testGetDirectoriesIterator(): void { + $root = Path::normalize(self::getTestData()->path('')); + $directory = new Directory($root, false); + $map = static function (Directory $directory) use ($root): string { + return Path::getRelativePath($root, $directory->getPath()); + }; + + self::assertEquals( + [ + 'a', + 'a/a', + 'a/b', + 'b', + 'b/a', + 'b/b', + ], + array_map($map, iterator_to_array($directory->getDirectoriesIterator())), + ); + + self::assertEquals( + [ + 'a', + 'b', + ], + array_map($map, iterator_to_array($directory->getDirectoriesIterator(depth: 0))), + ); + + self::assertEquals( + [ + 'a', + 'b', + 'b/a', + 'b/b', + ], + array_map($map, iterator_to_array($directory->getDirectoriesIterator(exclude: '#^a/[^/]*?$#'))), + ); + + self::assertEquals( + [ + 'a', + 'a/b', + 'b', + 'b/b', + ], + array_map($map, iterator_to_array($directory->getDirectoriesIterator(exclude: '#^[^/]*?/a$#'))), + ); + } + + public function testGetRelativePath(): void { + $path = Path::normalize(self::getTestData()->path('a/a.txt')); + $file = new File(Path::normalize(__FILE__), false); + $internal = new Directory(dirname($path), false); + $directory = new Directory(Path::normalize(__DIR__), true); + + self::assertEquals('DirectoryTest/a', $internal->getRelativePath($directory)); + self::assertEquals('DirectoryTest/a', $internal->getRelativePath($file)); + } + + public function testGetPath(): void { + $file = Path::normalize(__FILE__); + $path = Path::normalize(__DIR__); + $directory = new Directory($path, true); + + self::assertEquals($path, $directory->getPath()); + self::assertEquals($file, $directory->getPath($file)); + self::assertEquals($file, $directory->getPath(basename($file))); + } +} diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/.hidden b/packages/documentator/src/Processor/FileSystem/DirectoryTest/.hidden new file mode 100644 index 00000000..136c05e0 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/.hidden @@ -0,0 +1 @@ +hidden diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.html b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.html new file mode 100644 index 00000000..6b673e88 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.html @@ -0,0 +1,2 @@ + +a diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.txt new file mode 100644 index 00000000..78981922 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a.txt @@ -0,0 +1 @@ +a diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a/aa.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a/aa.txt new file mode 100644 index 00000000..e61ef7b9 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/a/aa.txt @@ -0,0 +1 @@ +aa diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/b/ab.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/b/ab.txt new file mode 100644 index 00000000..81bf3969 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/a/b/ab.txt @@ -0,0 +1 @@ +ab diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/a/ba.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/a/ba.txt new file mode 100644 index 00000000..86ae29c8 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/a/ba.txt @@ -0,0 +1 @@ +ba diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.html b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.html new file mode 100644 index 00000000..61780798 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.html @@ -0,0 +1 @@ +b diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.txt new file mode 100644 index 00000000..61780798 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b.txt @@ -0,0 +1 @@ +b diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b/bb.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b/bb.txt new file mode 100644 index 00000000..e0b3f1b0 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/b/b/bb.txt @@ -0,0 +1 @@ +bb diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.html b/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.html new file mode 100644 index 00000000..f2ad6c76 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.html @@ -0,0 +1 @@ +c diff --git a/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.txt b/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.txt new file mode 100644 index 00000000..f2ad6c76 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/DirectoryTest/c.txt @@ -0,0 +1 @@ +c diff --git a/packages/documentator/src/Processor/FileSystem/File.php b/packages/documentator/src/Processor/FileSystem/File.php new file mode 100644 index 00000000..06718c11 --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/File.php @@ -0,0 +1,108 @@ +path)) { + throw new InvalidArgumentException( + sprintf( + 'Path must be normalized, `%s` given.', + $this->path, + ), + ); + } + + if (!Path::isAbsolute($this->path)) { + throw new InvalidArgumentException( + sprintf( + 'Path must be absolute, `%s` given.', + $this->path, + ), + ); + } + + if (!is_file($this->path)) { + throw new InvalidArgumentException( + sprintf( + 'The `%s` is not a file.', + $this->path, + ), + ); + } + } + + public function getPath(): string { + return $this->path; + } + + public function getName(): string { + return pathinfo($this->path, PATHINFO_BASENAME); + } + + public function getExtension(): string { + return pathinfo($this->path, PATHINFO_EXTENSION); + } + + public function isWritable(): bool { + return $this->writable && is_writable($this->path); + } + + public function getContent(): string { + if ($this->content === null) { + $this->content = (string) file_get_contents($this->path); + } + + return $this->content; + } + + public function setContent(string $content): static { + $this->content = $content; + + return $this; + } + + public function save(): bool { + // Changed? + if ($this->content === null) { + return true; + } + + // Save + return $this->isWritable() + && file_put_contents($this->path, $this->content) !== false; + } + + public function getRelativePath(Directory|self $root): string { + $root = $root instanceof self ? dirname($root->getPath()) : $root->getPath(); + $path = Path::getRelativePath($root, $this->path); + + return $path; + } + + #[Override] + public function __toString(): string { + return $this->getPath(); + } +} diff --git a/packages/documentator/src/Processor/FileSystem/FileTest.php b/packages/documentator/src/Processor/FileSystem/FileTest.php new file mode 100644 index 00000000..1672d28f --- /dev/null +++ b/packages/documentator/src/Processor/FileSystem/FileTest.php @@ -0,0 +1,105 @@ +getPath()); + self::assertEquals("{$path}", $file->getPath()); + self::assertEquals('php', $file->getExtension()); + self::assertEquals('FileTest.php', $file->getName()); + } + + public function testConstructNotNormalized(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Path must be normalized, `/../file.txt` given.'); + + new File('/../file.txt', false); + } + + public function testConstructNotAbsolute(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Path must be absolute, `../file.txt` given.'); + + new File('../file.txt', false); + } + + public function testConstructNotFile(): void { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage(sprintf('The `%s` is not a file.', Path::normalize(__DIR__))); + + new File(Path::normalize(Path::normalize(__DIR__)), false); + } + + public function testGetContent(): void { + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, false); + + self::assertEquals(__FILE__, $file->getContent()); + self::assertNotFalse(file_put_contents($temp, __DIR__)); + self::assertEquals(__DIR__, file_get_contents($temp)); + self::assertEquals(__FILE__, $file->getContent()); + } + + public function testSetContent(): void { + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, false); + + self::assertEquals(__FILE__, $file->getContent()); + self::assertNotFalse(file_put_contents($temp, __DIR__)); + self::assertSame($file, $file->setContent(__METHOD__)); + self::assertEquals(__DIR__, file_get_contents($temp)); + self::assertEquals(__METHOD__, $file->getContent()); + } + + public function testSave(): void { + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, true); + + self::assertTrue($file->save()); // because no changes + + self::assertSame($file, $file->setContent(__METHOD__)); + + self::assertTrue($file->save()); + + self::assertEquals(__METHOD__, file_get_contents($temp)); + } + + public function testSaveReadonly(): void { + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, false); + + self::assertTrue($file->save()); // because no changes + + self::assertSame($file, $file->setContent(__METHOD__)); + + self::assertFalse($file->save()); + + self::assertEquals(__FILE__, file_get_contents($temp)); + } + + public function testGetRelativePath(): void { + $internal = new File(Path::normalize(__FILE__), false); + $directory = new Directory(Path::normalize(__DIR__), true); + + self::assertEquals(basename(__FILE__), $internal->getRelativePath($directory)); + self::assertEquals(basename(__FILE__), $internal->getRelativePath($internal)); + } +} diff --git a/packages/documentator/src/Processor/Processor.php b/packages/documentator/src/Processor/Processor.php new file mode 100644 index 00000000..f5b04150 --- /dev/null +++ b/packages/documentator/src/Processor/Processor.php @@ -0,0 +1,219 @@ +> + */ + private array $tasks = []; + + public function __construct() { + // empty + } + + public function task(Task $task): static { + foreach (array_unique($task->getExtensions()) as $ext) { + if (!isset($this->tasks[$ext])) { + $this->tasks[$ext] = []; + } + + if (!in_array($task, $this->tasks[$ext], true)) { + $this->tasks[$ext][] = $task; + } + } + + return $this; + } + + /** + * @param array|string|null $exclude glob(s) to exclude. + * @param Closure(string $path, ?bool $result, float $duration): void|null $listener + */ + public function run(string $path, array|string|null $exclude = null, ?Closure $listener = null): void { + $extensions = array_map(static fn ($e) => "*.{$e}", array_keys($this->tasks)); + $processed = []; + $exclude = array_map(Glob::toRegex(...), (array) $exclude); + $root = new Directory($path, true); + + try { + foreach ($root->getFilesIterator(patterns: $extensions, exclude: $exclude) as $file) { + $this->runFile($root, $file, $exclude, $listener, $processed, [], []); + } + } catch (ProcessorError $exception) { + throw $exception; + } catch (Exception $exception) { + throw new ProcessingFailed($root, $exception); + } + } + + /** + * @param array $exclude + * @param Closure(string $path, ?bool $result, float $duration): void|null $listener + * @param array $processed + * @param array $resolved + * @param array $stack + */ + private function runFile( + Directory $root, + File $file, + array $exclude, + ?Closure $listener, + array &$processed, + array $resolved, + array $stack, + ): ?float { + // Prepare + $start = microtime(true); + $fileKey = $file->getPath(); + + if (isset($processed[$fileKey])) { + return null; + } + + // Tasks? + $tasks = $this->tasks[$file->getExtension()] ?? []; + $filePath = $file->getRelativePath($root); + $processed[$fileKey] = true; + + if (!$tasks) { + if ($listener) { + $listener($filePath, null, microtime(true) - $start); + } + + return null; + } + + // Excluded? + if ($exclude) { + $excluded = false; + + foreach ($exclude as $regexp) { + if (preg_match($regexp, $filePath)) { + $excluded = true; + break; + } + } + + if ($excluded) { + if ($listener) { + $listener($filePath, null, microtime(true) - $start); + } + + return null; + } + } + + // Process + $paused = 0; + $directory = dirname($file->getPath()); + $stack[$fileKey] = $file; + + try { + foreach ($tasks as $task) { + try { + $result = false; + $generator = $task($root, $file); + + if ($generator instanceof Generator) { + while ($generator->valid()) { + // Resolve + $path = $generator->current(); + $path = match (true) { + $path instanceof SplFileInfo => $path->getPathname(), + $path instanceof File => $path->getPath(), + default => $path, + }; + $dependency = $root->getFile(Path::getPath($directory, $path)); + $dependencyKey = $dependency?->getPath(); + + if (!$dependency) { + throw new FileDependencyNotFound($root, $file, $path); + } + + // Circular? + if (isset($stack[$dependencyKey])) { + throw new CircularDependency($root, $file, $dependency, array_values($stack)); + } + + // Resolved? + $dependency = $resolved[$dependencyKey] ?? $dependency; + $resolved[$dependencyKey] = $dependency; + + // Processable? + if (!isset($processed[$dependencyKey]) && $root->isInside($dependency)) { + $paused += (float) $this->runFile( + $root, + $dependency, + $exclude, + $listener, + $processed, + $resolved, + $stack, + ); + } + + // Continue + $generator->send($dependency); + } + + $result = $generator->getReturn(); + } else { + $result = $generator; + } + + if ($result !== true) { + throw new FileTaskFailed($root, $file, $task); + } + } catch (ProcessorError $exception) { + throw $exception; + } catch (Exception $exception) { + throw new FileTaskFailed($root, $file, $task, $exception); + } + } + + if (!$file->save()) { + throw new FileSaveFailed($root, $file); + } + } catch (Exception $exception) { + throw $exception; + } finally { + $duration = microtime(true) - $start - $paused; + + if ($listener) { + $listener($filePath, !isset($exception), $duration); + } + } + + // Reset + unset($stack[$fileKey]); + + // Return + return $duration; + } +} diff --git a/packages/documentator/src/Processor/ProcessorTest.php b/packages/documentator/src/Processor/ProcessorTest.php new file mode 100644 index 00000000..de7ec9ce --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest.php @@ -0,0 +1,316 @@ +shouldReceive('getExtensions') + ->once() + ->andReturns(['php']); + + $taskA = new class() implements Task { + /** + * @var array + */ + public array $processed = []; + + /** + * @inheritDoc + */ + #[Override] + public function getExtensions(): array { + return ['htm']; + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Directory $root, File $file): bool { + $this->processed[] = $file->getRelativePath($root); + + return true; + } + }; + $taskB = new class() implements Task { + /** + * @var array}> + */ + public array $processed = []; + + /** + * @inheritDoc + */ + #[Override] + public function getExtensions(): array { + return ['txt', 'md']; + } + + /** + * @return Generator + */ + #[Override] + public function __invoke(Directory $root, File $file): Generator { + $resolved = []; + $dependencies = match ($file->getName()) { + 'a.txt' => [ + '../b/b/bb.txt', + '../c.txt', + '../c.html', + 'excluded.txt', + ], + 'bb.txt' => [ + '../../b/a/ba.txt', + '../../c.txt', + '../../../../../README.md', + ], + default => [ + // empty + ], + }; + + foreach ($dependencies as $dependency) { + $resolved[$dependency] = yield $dependency; + } + + $this->processed[] = [ + $file->getRelativePath($root), + array_map( + static function (?File $file) use ($root): ?string { + return $file?->getRelativePath($root); + }, + $resolved, + ), + ]; + + return true; + } + }; + + $root = Path::normalize(self::getTestData()->path('')); + $count = 0; + $events = []; + + (new Processor()) + ->task($mock) + ->task($taskA) + ->task($taskB) + ->run( + $root, + ['excluded.txt', '**/**/excluded.txt'], + static function (string $path, ?bool $result) use (&$count, &$events): void { + $events[$path] = $result; + $count++; + }, + ); + + self::assertEquals( + [ + 'b/a/ba.txt' => true, + 'c.txt' => true, + 'b/b/bb.txt' => true, + 'a/a.txt' => true, + 'a/a/aa.txt' => true, + 'a/b/ab.txt' => true, + 'b/b.txt' => true, + 'c.htm' => true, + 'c.html' => null, + 'a/excluded.txt' => null, + ], + $events, + ); + self::assertCount($count, $events); + self::assertEquals( + [ + 'c.htm', + ], + $taskA->processed, + ); + self::assertEquals( + [ + [ + 'b/a/ba.txt', + [], + ], + [ + 'c.txt', + [], + ], + [ + 'b/b/bb.txt', + [ + '../../b/a/ba.txt' => 'b/a/ba.txt', + '../../c.txt' => 'c.txt', + '../../../../../README.md' => '../../../README.md', + ], + ], + [ + 'a/a.txt', + [ + '../b/b/bb.txt' => 'b/b/bb.txt', + '../c.txt' => 'c.txt', + '../c.html' => 'c.html', + 'excluded.txt' => 'a/excluded.txt', + ], + ], + [ + 'a/a/aa.txt', + [], + ], + [ + 'a/b/ab.txt', + [], + ], + [ + 'b/b.txt', + [], + ], + ], + $taskB->processed, + ); + } + + public function testRunFileNotFound(): void { + $task = new class() implements Task { + /** + * @inheritDoc + */ + #[Override] + public function getExtensions(): array { + return ['txt']; + } + + /** + * @return Generator + */ + #[Override] + public function __invoke(Directory $root, File $file): Generator { + yield '404.html'; + + return true; + } + }; + + $root = Path::normalize(self::getTestData()->path('')); + + self::expectException(FileDependencyNotFound::class); + self::expectExceptionMessage("Dependency `404.html` of `a/a.txt` not found (root: `{$root}`)."); + + (new Processor()) + ->task($task) + ->run($root); + } + + public function testRunCircularDependency(): void { + $task = new class() implements Task { + /** + * @inheritDoc + */ + #[Override] + public function getExtensions(): array { + return ['txt']; + } + + /** + * @return Generator + */ + #[Override] + public function __invoke(Directory $root, File $file): Generator { + match ($file->getName()) { + 'a.txt' => yield '../b/b.txt', + 'b.txt' => yield '../b/a/ba.txt', + 'ba.txt' => yield '../../c.txt', + 'c.txt' => yield 'a/a.txt', + default => null, + }; + + return true; + } + }; + + $root = Path::normalize(self::getTestData()->path('')); + + self::expectException(CircularDependency::class); + self::expectExceptionMessage( + <<task($task) + ->run($root); + } + + public function testRunCircularDependencySelf(): void { + $task = new class() implements Task { + /** + * @inheritDoc + */ + #[Override] + public function getExtensions(): array { + return ['txt']; + } + + /** + * @return Generator + */ + #[Override] + public function __invoke(Directory $root, File $file): Generator { + match ($file->getName()) { + 'c.txt' => yield 'c.txt', + default => null, + }; + + return true; + } + }; + + $root = Path::normalize(self::getTestData()->path('')); + + self::expectException(CircularDependency::class); + self::expectExceptionMessage( + <<task($task) + ->run($root); + } +} diff --git a/packages/documentator/src/Processor/ProcessorTest/.hidden b/packages/documentator/src/Processor/ProcessorTest/.hidden new file mode 100644 index 00000000..136c05e0 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/.hidden @@ -0,0 +1 @@ +hidden diff --git a/packages/documentator/src/Processor/ProcessorTest/a/a.html b/packages/documentator/src/Processor/ProcessorTest/a/a.html new file mode 100644 index 00000000..6b673e88 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/a/a.html @@ -0,0 +1,2 @@ + +a diff --git a/packages/documentator/src/Processor/ProcessorTest/a/a.txt b/packages/documentator/src/Processor/ProcessorTest/a/a.txt new file mode 100644 index 00000000..78981922 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/a/a.txt @@ -0,0 +1 @@ +a diff --git a/packages/documentator/src/Processor/ProcessorTest/a/a/aa.txt b/packages/documentator/src/Processor/ProcessorTest/a/a/aa.txt new file mode 100644 index 00000000..e61ef7b9 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/a/a/aa.txt @@ -0,0 +1 @@ +aa diff --git a/packages/documentator/src/Processor/ProcessorTest/a/a/excluded.txt b/packages/documentator/src/Processor/ProcessorTest/a/a/excluded.txt new file mode 100644 index 00000000..e69de29b diff --git a/packages/documentator/src/Processor/ProcessorTest/a/b/ab.txt b/packages/documentator/src/Processor/ProcessorTest/a/b/ab.txt new file mode 100644 index 00000000..81bf3969 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/a/b/ab.txt @@ -0,0 +1 @@ +ab diff --git a/packages/documentator/src/Processor/ProcessorTest/a/excluded.txt b/packages/documentator/src/Processor/ProcessorTest/a/excluded.txt new file mode 100644 index 00000000..e69de29b diff --git a/packages/documentator/src/Processor/ProcessorTest/b/a/ba.txt b/packages/documentator/src/Processor/ProcessorTest/b/a/ba.txt new file mode 100644 index 00000000..86ae29c8 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/b/a/ba.txt @@ -0,0 +1 @@ +ba diff --git a/packages/documentator/src/Processor/ProcessorTest/b/b.html b/packages/documentator/src/Processor/ProcessorTest/b/b.html new file mode 100644 index 00000000..61780798 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/b/b.html @@ -0,0 +1 @@ +b diff --git a/packages/documentator/src/Processor/ProcessorTest/b/b.txt b/packages/documentator/src/Processor/ProcessorTest/b/b.txt new file mode 100644 index 00000000..61780798 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/b/b.txt @@ -0,0 +1 @@ +b diff --git a/packages/documentator/src/Processor/ProcessorTest/b/b/bb.txt b/packages/documentator/src/Processor/ProcessorTest/b/b/bb.txt new file mode 100644 index 00000000..e0b3f1b0 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/b/b/bb.txt @@ -0,0 +1 @@ +bb diff --git a/packages/documentator/src/Processor/ProcessorTest/c.htm b/packages/documentator/src/Processor/ProcessorTest/c.htm new file mode 100644 index 00000000..f2ad6c76 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/c.htm @@ -0,0 +1 @@ +c diff --git a/packages/documentator/src/Processor/ProcessorTest/c.html b/packages/documentator/src/Processor/ProcessorTest/c.html new file mode 100644 index 00000000..f2ad6c76 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/c.html @@ -0,0 +1 @@ +c diff --git a/packages/documentator/src/Processor/ProcessorTest/c.txt b/packages/documentator/src/Processor/ProcessorTest/c.txt new file mode 100644 index 00000000..f2ad6c76 --- /dev/null +++ b/packages/documentator/src/Processor/ProcessorTest/c.txt @@ -0,0 +1 @@ +c diff --git a/packages/documentator/src/Processor/ProcessorTest/excluded.txt b/packages/documentator/src/Processor/ProcessorTest/excluded.txt new file mode 100644 index 00000000..e69de29b