diff --git a/packages/documentator/src/Processor/Contracts/Dependency.php b/packages/documentator/src/Processor/Contracts/Dependency.php index c8a36604..24d4c47d 100644 --- a/packages/documentator/src/Processor/Contracts/Dependency.php +++ b/packages/documentator/src/Processor/Contracts/Dependency.php @@ -2,17 +2,17 @@ namespace LastDragon_ru\LaraASP\Documentator\Processor\Contracts; -use Iterator; use LastDragon_ru\LaraASP\Documentator\Processor\Exceptions\DependencyNotFound; use LastDragon_ru\LaraASP\Documentator\Processor\FileSystem\Directory; use LastDragon_ru\LaraASP\Documentator\Processor\FileSystem\File; use LastDragon_ru\LaraASP\Documentator\Processor\FileSystem\FileSystem; use Stringable; +use Traversable; /** * Task dependency (= another file). * - * @template TValue of Iterator|Directory|File|null + * @template TValue of Traversable|Directory|File|null */ interface Dependency extends Stringable { /** diff --git a/packages/documentator/src/Processor/Contracts/Metadata.php b/packages/documentator/src/Processor/Contracts/Metadata.php new file mode 100644 index 00000000..fbc25e00 --- /dev/null +++ b/packages/documentator/src/Processor/Contracts/Metadata.php @@ -0,0 +1,19 @@ +|Directory|File|null + * @template TValue of Traversable|Directory|File|null * * @implements Dependency */ diff --git a/packages/documentator/src/Processor/Exceptions/FileMetadataError.php b/packages/documentator/src/Processor/Exceptions/FileMetadataError.php new file mode 100644 index 00000000..890cd3fa --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/FileMetadataError.php @@ -0,0 +1,34 @@ + + */ + protected readonly Metadata $metadata, + Throwable $previous, + ) { + parent::__construct('', $previous); + } + + public function getTarget(): File { + return $this->target; + } + + /** + * @return Metadata<*> + */ + public function getMetadata(): Metadata { + return $this->metadata; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/FileMetadataFailed.php b/packages/documentator/src/Processor/Exceptions/FileMetadataFailed.php new file mode 100644 index 00000000..3afd88d2 --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/FileMetadataFailed.php @@ -0,0 +1,47 @@ + + */ + protected readonly Metadata $metadata, + ?Throwable $previous = null, + ) { + parent::__construct( + sprintf( + 'Failed to retrieve `%s` metadata for `%s` file (root: `%s`).', + $this->metadata::class, + $this->target->getRelativePath($this->root), + $this->root->getPath(), + ), + $previous, + ); + } + + public function getRoot(): Directory { + return $this->root; + } + + public function getTarget(): File { + return $this->target; + } + + /** + * @return Metadata<*> + */ + public function getMetadata(): Metadata { + return $this->metadata; + } +} diff --git a/packages/documentator/src/Processor/Exceptions/MetadataError.php b/packages/documentator/src/Processor/Exceptions/MetadataError.php new file mode 100644 index 00000000..ae89bf70 --- /dev/null +++ b/packages/documentator/src/Processor/Exceptions/MetadataError.php @@ -0,0 +1,7 @@ +current(); $resolved = $dependency($this->fs, $this->root, $file); - if ($resolved instanceof Iterator) { - $resolved = new ExecutorIterator($dependency, $resolved, $this->runDependency(...)); + if ($resolved instanceof Traversable) { + $resolved = new ExecutorTraversable($dependency, $resolved, $this->runDependency(...)); } else { $paused += $this->runDependency($dependency, $resolved); } $generator->send($resolved); - if ($resolved instanceof ExecutorIterator) { + if ($resolved instanceof ExecutorTraversable) { $paused += $resolved->getDuration(); } } @@ -143,6 +146,13 @@ private function runFile(File $file): float { if ($result !== true) { throw new FileTaskFailed($this->root, $file, $task); } + } catch (FileMetadataError $exception) { + throw new FileMetadataFailed( + $this->root, + $exception->getTarget(), + $exception->getMetadata(), + $exception->getPrevious(), + ); } catch (ProcessorError $exception) { throw $exception; } catch (Exception $exception) { @@ -150,7 +160,7 @@ private function runFile(File $file): float { } } - if (!$file->save()) { + if (!$this->fs->save($file)) { throw new FileSaveFailed($this->root, $file); } } catch (Throwable $exception) { diff --git a/packages/documentator/src/Processor/ExecutorIterator.php b/packages/documentator/src/Processor/ExecutorTraversable.php similarity index 79% rename from packages/documentator/src/Processor/ExecutorIterator.php rename to packages/documentator/src/Processor/ExecutorTraversable.php index 1a2063f5..66ac93bb 100644 --- a/packages/documentator/src/Processor/ExecutorIterator.php +++ b/packages/documentator/src/Processor/ExecutorTraversable.php @@ -4,12 +4,12 @@ use Closure; use Generator; -use Iterator; use IteratorAggregate; use LastDragon_ru\LaraASP\Documentator\Processor\Contracts\Dependency; use LastDragon_ru\LaraASP\Documentator\Processor\FileSystem\Directory; use LastDragon_ru\LaraASP\Documentator\Processor\FileSystem\File; use Override; +use Traversable; /** * @internal @@ -19,20 +19,20 @@ * * @implements IteratorAggregate */ -class ExecutorIterator implements IteratorAggregate { +class ExecutorTraversable implements IteratorAggregate { private float $duration = 0; public function __construct( /** - * @var Dependency> + * @var Dependency> */ private readonly Dependency $dependency, /** - * @var Iterator + * @var Traversable */ - private readonly Iterator $resolved, + private readonly Traversable $resolved, /** - * @var Closure(Dependency>, TValue): float + * @var Closure(Dependency>, TValue): float */ private readonly Closure $handler, ) { diff --git a/packages/documentator/src/Processor/FileSystem/File.php b/packages/documentator/src/Processor/FileSystem/File.php index 06718c11..f6889bfa 100644 --- a/packages/documentator/src/Processor/FileSystem/File.php +++ b/packages/documentator/src/Processor/FileSystem/File.php @@ -2,14 +2,17 @@ namespace LastDragon_ru\LaraASP\Documentator\Processor\FileSystem; +use Exception; use InvalidArgumentException; use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Processor\Contracts\Metadata; +use LastDragon_ru\LaraASP\Documentator\Processor\Exceptions\FileMetadataError; use Override; use Stringable; +use function array_key_exists; use function dirname; use function file_get_contents; -use function file_put_contents; use function is_file; use function is_writable; use function pathinfo; @@ -20,6 +23,12 @@ class File implements Stringable { private ?string $content = null; + private bool $modified = false; + + /** + * @var array>, mixed> + */ + private array $metadata = []; public function __construct( private readonly string $path, @@ -69,6 +78,10 @@ public function isWritable(): bool { return $this->writable && is_writable($this->path); } + public function isModified(): bool { + return $this->modified; + } + public function getContent(): string { if ($this->content === null) { $this->content = (string) file_get_contents($this->path); @@ -78,20 +91,32 @@ public function getContent(): string { } public function setContent(string $content): static { - $this->content = $content; + if ($this->content !== $content) { + $this->content = $content; + $this->modified = true; + $this->metadata = []; + } return $this; } - public function save(): bool { - // Changed? - if ($this->content === null) { - return true; + /** + * @template T + * + * @param Metadata $metadata + * + * @return T + */ + public function getMetadata(Metadata $metadata): mixed { + if (!array_key_exists($metadata::class, $this->metadata)) { + try { + $this->metadata[$metadata::class] = $metadata($this); + } catch (Exception $exception) { + throw new FileMetadataError($this, $metadata, $exception); + } } - // Save - return $this->isWritable() - && file_put_contents($this->path, $this->content) !== false; + return $this->metadata[$metadata::class]; } public function getRelativePath(Directory|self $root): string { diff --git a/packages/documentator/src/Processor/FileSystem/FileSystem.php b/packages/documentator/src/Processor/FileSystem/FileSystem.php index a72c7208..385d7b88 100644 --- a/packages/documentator/src/Processor/FileSystem/FileSystem.php +++ b/packages/documentator/src/Processor/FileSystem/FileSystem.php @@ -8,6 +8,7 @@ use Symfony\Component\Finder\Finder; use function dirname; +use function file_put_contents; use function is_dir; use function is_file; @@ -151,4 +152,9 @@ protected function getIterator( yield from []; } + + public function save(File $file): bool { + return !$file->isModified() + || ($file->isWritable() && file_put_contents($file->getPath(), $file->getContent()) !== false); + } } diff --git a/packages/documentator/src/Processor/FileSystem/FileSystemTest.php b/packages/documentator/src/Processor/FileSystem/FileSystemTest.php index 674ab47d..3403c014 100644 --- a/packages/documentator/src/Processor/FileSystem/FileSystemTest.php +++ b/packages/documentator/src/Processor/FileSystem/FileSystemTest.php @@ -9,6 +9,7 @@ use function array_map; use function basename; +use function file_get_contents; use function iterator_to_array; /** @@ -236,4 +237,33 @@ public function testGetDirectoriesIterator(): void { array_map($map, iterator_to_array($fs->getDirectoriesIterator($directory, exclude: '#^[^/]*?/a$#'))), ); } + + + public function testSave(): void { + $fs = new FileSystem(); + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, true); + + self::assertTrue($fs->save($file)); // because no changes + + self::assertSame($file, $file->setContent(__METHOD__)); + + self::assertTrue($fs->save($file)); + + self::assertEquals(__METHOD__, file_get_contents($temp)); + } + + public function testSaveReadonly(): void { + $fs = new FileSystem(); + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, false); + + self::assertTrue($fs->save($file)); // because no changes + + self::assertSame($file, $file->setContent(__METHOD__)); + + self::assertFalse($fs->save($file)); + + self::assertEquals(__FILE__, file_get_contents($temp)); + } } diff --git a/packages/documentator/src/Processor/FileSystem/FileTest.php b/packages/documentator/src/Processor/FileSystem/FileTest.php index 1672d28f..f3501588 100644 --- a/packages/documentator/src/Processor/FileSystem/FileTest.php +++ b/packages/documentator/src/Processor/FileSystem/FileTest.php @@ -4,9 +4,12 @@ use InvalidArgumentException; use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Processor\Contracts\Metadata; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use Override; use PHPUnit\Framework\Attributes\CoversClass; +use function array_shift; use function basename; use function file_get_contents; use function file_put_contents; @@ -59,40 +62,32 @@ public function testGetContent(): void { } public function testSetContent(): void { - $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); - $file = new File($temp, false); + $temp = Path::normalize(self::getTempFile(__FILE__)->getPathname()); + $file = new File($temp, false); + $meta = new class([1, 2]) implements Metadata { + public function __construct( + /** + * @var list + */ + private array $value, + ) { + // empty + } + + #[Override] + public function __invoke(File $file): mixed { + return array_shift($this->value); + } + }; + $current = $file->getMetadata($meta); self::assertEquals(__FILE__, $file->getContent()); + self::assertSame($current, $file->getMetadata($meta)); 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)); + self::assertNotEquals($current, $file->getMetadata($meta)); } public function testGetRelativePath(): void { diff --git a/packages/documentator/src/Processor/Metadata/Markdown.php b/packages/documentator/src/Processor/Metadata/Markdown.php new file mode 100644 index 00000000..2e685f15 --- /dev/null +++ b/packages/documentator/src/Processor/Metadata/Markdown.php @@ -0,0 +1,24 @@ + + */ +class Markdown implements Metadata { + public function __construct() { + // empty + } + + #[Override] + public function __invoke(File $file): mixed { + return $file->getExtension() === 'md' + ? new Document($file->getContent(), $file->getPath()) + : null; + } +} diff --git a/packages/documentator/src/Processor/Metadata/PhpClass.php b/packages/documentator/src/Processor/Metadata/PhpClass.php new file mode 100644 index 00000000..0368c99c --- /dev/null +++ b/packages/documentator/src/Processor/Metadata/PhpClass.php @@ -0,0 +1,69 @@ + + */ +class PhpClass implements Metadata { + public function __construct() { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(File $file): mixed { + $class = null; + $content = $file->getContent(); + $resolver = new NameResolver(); + + try { + $stmts = $this->parse($resolver, $content); + $finder = new NodeFinder(); + $class = $finder->findFirst($stmts, static function (Node $node): bool { + return $node instanceof ClassLike; + }); + } catch (Error) { + // not a php file + } + + return $class instanceof ClassLike + ? new class ($class, $resolver->getNameContext()) { + public function __construct( + public readonly ClassLike $class, + public readonly NameContext $context, + ) { + // empty + } + } + : null; + } + + /** + * @return array + */ + private function parse(NameResolver $resolver, string $content): array { + $traverser = new NodeTraverser(); + $traverser->addVisitor($resolver); + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $stmts = (array) $parser->parse($content); + $stmts = $traverser->traverse($stmts); + + return $stmts; + } +} diff --git a/packages/documentator/src/Processor/Metadata/PhpClassTest.php b/packages/documentator/src/Processor/Metadata/PhpClassTest.php new file mode 100644 index 00000000..2a8337e7 --- /dev/null +++ b/packages/documentator/src/Processor/Metadata/PhpClassTest.php @@ -0,0 +1,30 @@ + + */ +class PhpDocBlock implements Metadata { + public function __construct( + protected readonly PhpClass $class, + ) { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(File $file): mixed { + // Class? + $class = $file->getMetadata($this->class); + + if (!$class) { + return null; + } + + // Parse + $content = (new PhpDoc($class->class->getDocComment()?->getText()))->getText(); + $content = $this->preprocess($class->context, $content); + $content = trim($content); + + if (!$content) { + return new Document('', $file->getPath()); + } + + // Create + return new Document($content, $file->getPath()); + } + + private function preprocess(NameContext $context, string $string): string { + return (string) preg_replace_callback( + pattern : '/\{@(?:see|link)\s+(?P[^}\s\/:]+)(?:::(?P[^(]+\(\)))?\s?\}/imu', + callback: static function (array $matches) use ($context): string { + $class = (string) $context->getResolvedClassName(new Name($matches['class'])); + $method = $matches['method'] ?? null; + $result = $method + ? "`{$class}::{$method}`" + : "`{$class}`"; + + return $result; + }, + subject : $string, + flags : PREG_UNMATCHED_AS_NULL, + ); + } +} diff --git a/packages/documentator/src/Processor/Metadata/PhpDocBlockTest.php b/packages/documentator/src/Processor/Metadata/PhpDocBlockTest.php new file mode 100644 index 00000000..24bf190c --- /dev/null +++ b/packages/documentator/src/Processor/Metadata/PhpDocBlockTest.php @@ -0,0 +1,75 @@ +getPathname()), + false, + ); + $factory = new PhpDocBlock(new PhpClass()); + $metadata = $factory($file); + + self::assertNotNull($metadata); + self::assertEquals( + <<<'MARKDOWN' + Description. + + Summary `stdClass` and `LastDragon_ru\LaraASP\Documentator\Processor\Metadata\PhpClass`. + MARKDOWN, + (string) $metadata, + ); + } + + public function testInvokeEmpty(): void { + $file = new File(Path::normalize(__FILE__), false); + $factory = new PhpDocBlock(new PhpClass()); + $metadata = $factory($file); + + self::assertNotNull($metadata); + self::assertTrue($metadata->isEmpty()); + } + + public function testInvokeNotPhp(): void { + $file = new File(Path::normalize(__FILE__), false); + $factory = new PhpDocBlock( + new class() extends PhpClass { + #[Override] + public function __invoke(File $file): mixed { + return null; + } + }, + ); + $metadata = $factory($file); + + self::assertNull($metadata); + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0d369b61..7e18b431 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -6,7 +6,7 @@ parameters: path: packages/core/src/Observer/DispatcherTest.php - - message: "#^Parameter \\#1 \\$dependency of class LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\ExecutorIterator constructor expects LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\Contracts\\\\Dependency\\\\>, LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\Contracts\\\\Dependency\\<\\*\\> given\\.$#" + message: "#^Parameter \\#1 \\$dependency of class LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\ExecutorTraversable constructor expects LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\Contracts\\\\Dependency\\\\>, LastDragon_ru\\\\LaraASP\\\\Documentator\\\\Processor\\\\Contracts\\\\Dependency\\<\\*\\> given\\.$#" count: 1 path: packages/documentator/src/Processor/Executor.php