diff --git a/packages/guides-cli/src/Command/Run.php b/packages/guides-cli/src/Command/Run.php index 3c6b67102..2e4087a19 100644 --- a/packages/guides-cli/src/Command/Run.php +++ b/packages/guides-cli/src/Command/Run.php @@ -13,6 +13,7 @@ use Monolog\Logger; use phpDocumentor\Guides\Cli\Logger\SpyProcessor; use phpDocumentor\Guides\Compiler\CompilerContext; +use phpDocumentor\Guides\Event\PostRenderDocument; use phpDocumentor\Guides\Handlers\CompileDocumentsCommand; use phpDocumentor\Guides\Handlers\ParseDirectoryCommand; use phpDocumentor\Guides\Handlers\ParseFileCommand; @@ -29,6 +30,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\EventDispatcher\EventDispatcher; use function array_pop; use function count; @@ -49,6 +51,7 @@ public function __construct( private readonly Logger $logger, private readonly ThemeManager $themeManager, private readonly SettingsManager $settingsManager, + private readonly EventDispatcher $eventDispatcher, ) { parent::__construct('run'); @@ -223,10 +226,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $outputFormats = $settings->getOutputFormats(); - $progressBar = null; - if ($output instanceof ConsoleOutputInterface && $settings->isShowProgressBar()) { - $progressBar = new ProgressBar($output->section()); + $progressBar = new ProgressBar($output->section(), count($documents)); + $this->eventDispatcher->addListener( + PostRenderDocument::class, + static function (PostRenderDocument $event) use ($progressBar): void { + $progressBar->advance(); + }, + ); } foreach ($outputFormats as $format) { @@ -234,7 +241,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int new RenderCommand( $format, $documents, - $progressBar === null ? $documents : $progressBar->iterate($documents), $sourceFileSystem, $destinationFileSystem, $projectNode, diff --git a/packages/guides/src/Handlers/RenderCommand.php b/packages/guides/src/Handlers/RenderCommand.php index 2c49268fa..80f6be9eb 100644 --- a/packages/guides/src/Handlers/RenderCommand.php +++ b/packages/guides/src/Handlers/RenderCommand.php @@ -7,22 +7,29 @@ use League\Flysystem\FilesystemInterface; use phpDocumentor\Guides\Nodes\DocumentNode; use phpDocumentor\Guides\Nodes\ProjectNode; +use phpDocumentor\Guides\Renderer\DocumentListIterator; +use phpDocumentor\Guides\Renderer\DocumentTreeIterator; final class RenderCommand { - /** - * @param DocumentNode[] $documentArray - * @param iterable $documentIterator - */ + private DocumentListIterator $documentIterator; + + /** @param DocumentNode[] $documentArray */ public function __construct( private readonly string $outputFormat, private readonly array $documentArray, - private readonly iterable $documentIterator, private readonly FilesystemInterface $origin, private readonly FilesystemInterface $destination, private readonly ProjectNode $projectNode, private readonly string $destinationPath = '/', ) { + $this->documentIterator = new DocumentListIterator( + new DocumentTreeIterator( + [$this->projectNode->getRootDocumentEntry()], + $this->documentArray, + ), + $this->documentArray, + ); } public function getOutputFormat(): string @@ -36,8 +43,7 @@ public function getDocumentArray(): array return $this->documentArray; } - /** @return iterable $documentIterator */ - public function getDocumentIterator(): iterable + public function getDocumentIterator(): DocumentListIterator { return $this->documentIterator; } diff --git a/packages/guides/src/RenderContext.php b/packages/guides/src/RenderContext.php index 342f0e5c6..1547f60ca 100644 --- a/packages/guides/src/RenderContext.php +++ b/packages/guides/src/RenderContext.php @@ -29,6 +29,8 @@ class RenderContext /** @var DocumentNode[] */ private array $allDocuments; + private Renderer\DocumentListIterator $iterator; + private function __construct( private readonly string $destinationPath, private readonly string|null $currentFileName, @@ -77,6 +79,14 @@ public function withDocument(DocumentNode $documentNode): self ); } + public function withIterator(Renderer\DocumentListIterator $iterator): self + { + $that = clone $this; + $that->iterator = $iterator; + + return $that; + } + /** @param DocumentNode[] $allDocumentNodes */ public static function forProject( ProjectNode $projectNode, @@ -192,4 +202,9 @@ public function getOutputFormat(): string { return $this->outputFormat; } + + public function getIterator(): Renderer\DocumentListIterator + { + return $this->iterator; + } } diff --git a/packages/guides/src/Renderer/BaseTypeRenderer.php b/packages/guides/src/Renderer/BaseTypeRenderer.php index a7b8c75d7..b7c0deda1 100644 --- a/packages/guides/src/Renderer/BaseTypeRenderer.php +++ b/packages/guides/src/Renderer/BaseTypeRenderer.php @@ -17,19 +17,20 @@ public function __construct(protected readonly CommandBus $commandBus) public function render(RenderCommand $renderCommand): void { - foreach ($renderCommand->getDocumentIterator() as $document) { + $context = RenderContext::forProject( + $renderCommand->getProjectNode(), + $renderCommand->getDocumentArray(), + $renderCommand->getOrigin(), + $renderCommand->getDestination(), + $renderCommand->getDestinationPath(), + $renderCommand->getOutputFormat(), + )->withIterator($renderCommand->getDocumentIterator()); + + foreach ($context->getIterator() as $document) { $this->commandBus->handle( new RenderDocumentCommand( $document, - RenderContext::forDocument( - $document, - $renderCommand->getDocumentArray(), - $renderCommand->getOrigin(), - $renderCommand->getDestination(), - $renderCommand->getDestinationPath(), - $renderCommand->getOutputFormat(), - $renderCommand->getProjectNode(), - ), + $context->withDocument($document), ), ); } diff --git a/packages/guides/src/Renderer/DocumentListIterator.php b/packages/guides/src/Renderer/DocumentListIterator.php new file mode 100644 index 000000000..e8e46a67a --- /dev/null +++ b/packages/guides/src/Renderer/DocumentListIterator.php @@ -0,0 +1,123 @@ + */ +final class DocumentListIterator implements Iterator +{ + /** @var WeakReference|null */ + private WeakReference|null $previousDocument; + + /** @var WeakReference|null */ + private WeakReference|null $nextDocument; + + /** @var WeakMap */ + private WeakMap $unseenDocuments; + + /** @var Iterator */ + private Iterator $innerIterator; + + /** @param DocumentNode[] $documents */ + public function __construct( + DocumentTreeIterator $iterator, + array $documents, + ) { + $this->unseenDocuments = new WeakMap(); + $this->previousDocument = null; + $this->nextDocument = null; + $this->innerIterator = new AppendIterator(); + $this->innerIterator->append( + new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST), + ); + $this->innerIterator->append($this->unseenIterator()); + foreach ($documents as $document) { + $this->unseenDocuments[$document] = true; + } + } + + public function next(): void + { + if ($this->innerIterator->valid()) { + $this->previousDocument = WeakReference::create($this->current()); + } else { + $this->previousDocument = null; + } + + if ($this->nextDocument === null) { + $this->innerIterator->next(); + } + + $this->nextDocument = null; + } + + public function previousNode(): DocumentNode|null + { + return $this->previousDocument?->get(); + } + + public function valid(): bool + { + if ($this->nextDocument !== null) { + return true; + } + + return $this->innerIterator->valid(); + } + + public function nextNode(): DocumentNode|null + { + $this->innerIterator->next(); + + if ($this->innerIterator->valid()) { + $this->nextDocument = WeakReference::create($this->current()); + } + + return $this->nextDocument?->get(); + } + + public function current(): mixed + { + $document = $this->innerIterator->current(); + if ($document instanceof DocumentNode) { + $this->unseenDocuments[$document] = false; + } + + return $document; + } + + public function key(): mixed + { + return $this->innerIterator->key(); + } + + public function rewind(): void + { + foreach ($this->unseenDocuments as $document => $seen) { + $this->unseenDocuments[$document] = true; + } + + $this->innerIterator->rewind(); + } + + /** @return Generator */ + private function unseenIterator(): Generator + { + foreach ($this->unseenDocuments as $document => $seen) { + if ($seen === false) { + continue; + } + + yield $document; + } + } +} diff --git a/packages/guides/src/Renderer/DocumentTreeIterator.php b/packages/guides/src/Renderer/DocumentTreeIterator.php new file mode 100644 index 000000000..9ed59e64f --- /dev/null +++ b/packages/guides/src/Renderer/DocumentTreeIterator.php @@ -0,0 +1,73 @@ + + */ +final class DocumentTreeIterator implements RecursiveIterator +{ + private int $position = 0; + + /** + * @param DocumentEntryNode[] $levelNodes + * @param DocumentNode[] $documents + */ + public function __construct( + private readonly array $levelNodes, + private readonly array $documents, + ) { + } + + public function current(): DocumentNode + { + foreach ($this->documents as $document) { + if ($document->getDocumentEntry() === $this->levelNodes[$this->position]) { + return $document; + } + } + + throw new LogicException('Could not find document for node'); + } + + public function next(): void + { + ++$this->position; + } + + public function key(): int + { + return $this->position; + } + + public function valid(): bool + { + return isset($this->levelNodes[$this->position]); + } + + public function rewind(): void + { + $this->position = 0; + } + + public function hasChildren(): bool + { + return empty($this->levelNodes[$this->position]->getChildren()) === false; + } + + public function getChildren(): self|null + { + return new self($this->levelNodes[$this->position]->getChildren(), $this->documents); + } +} diff --git a/packages/guides/src/Renderer/LatexRenderer.php b/packages/guides/src/Renderer/LatexRenderer.php index 729f5bdca..c990f0f22 100644 --- a/packages/guides/src/Renderer/LatexRenderer.php +++ b/packages/guides/src/Renderer/LatexRenderer.php @@ -25,7 +25,7 @@ public function render(RenderCommand $renderCommand): void $renderCommand->getDestination(), $renderCommand->getDestinationPath(), 'tex', - ); + )->withIterator($renderCommand->getDocumentIterator()); $context->getDestination()->put( $renderCommand->getDestinationPath() . '/index.tex', @@ -34,7 +34,7 @@ public function render(RenderCommand $renderCommand): void 'structure/project.tex.twig', [ 'project' => $projectNode, - 'documents' => $renderCommand->getDocumentIterator(), + 'documents' => $context->getIterator(), ], ), ); diff --git a/packages/guides/tests/unit/Renderer/DocumentListIteratorTest.php b/packages/guides/tests/unit/Renderer/DocumentListIteratorTest.php new file mode 100644 index 000000000..f1d3d08be --- /dev/null +++ b/packages/guides/tests/unit/Renderer/DocumentListIteratorTest.php @@ -0,0 +1,94 @@ +entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + $result = []; + foreach ($iterator as $document) { + $result[] = $document; + $iterator->nextNode(); + } + + self::assertSame(self::documentsToTitle($this->flatDocumentList), self::documentsToTitle($result)); + self::assertNull($iterator->nextNode()); + self::assertNull($iterator->previousNode()); + } + + public function testPreviousStepsBackAtSameLevel(): void + { + $iterator = new DocumentListIterator( + new DocumentTreeIterator([$this->entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + + $iterator->next(); // 1 + $iterator->next(); // 1.1 + $iterator->next(); // 1.1.1 + + self::assertSame('1.1.2', $iterator->current()->getTitle()?->toString()); + self::assertSame('1.1.1', $iterator->previousNode()?->getTitle()?->toString()); + } + + public function testNextStepsAtSameLevel(): void + { + $iterator = new DocumentListIterator( + new DocumentTreeIterator([$this->entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + + $iterator->next(); // 1 + $iterator->next(); // 1.1 + + self::assertSame('1.1.1', $iterator->current()->getTitle()?->toString()); + self::assertSame('1.1.2', $iterator->nextNode()?->getTitle()?->toString()); + } + + public function testPreviousStepsBackToLevelAbove(): void + { + $iterator = new DocumentListIterator( + new DocumentTreeIterator([$this->entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + + $iterator->next(); // 1 + $iterator->next(); // 1.1 + + self::assertSame('1.1.1', $iterator->current()->getTitle()?->toString()); + self::assertSame('1.1', $iterator->previousNode()?->getTitle()?->toString()); + } + + public function testPreviousStepsBackToDeepestLevelInPreviousNode(): void + { + $iterator = new DocumentListIterator( + new DocumentTreeIterator([$this->entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + + $iterator->next(); // 1 + $iterator->next(); // 1.1 + $iterator->next(); // 1.1.1 + $iterator->next(); // 1.1.2 + + self::assertSame('2', $iterator->current()->getTitle()?->toString()); + self::assertSame('1.1.2', $iterator->previousNode()?->getTitle()?->toString()); + } + + public function testPreviousReturnsNullWhenNoPrevious(): void + { + $iterator = new DocumentListIterator( + new DocumentTreeIterator([$this->entry1, $this->entry2], $this->randomOrderedDocuments), + $this->randomOrderedDocuments, + ); + + self::assertNull($iterator->previousNode()?->getTitle()?->toString()); + } +} diff --git a/packages/guides/tests/unit/Renderer/DocumentTreeIteratorTest.php b/packages/guides/tests/unit/Renderer/DocumentTreeIteratorTest.php new file mode 100644 index 000000000..d91c89124 --- /dev/null +++ b/packages/guides/tests/unit/Renderer/DocumentTreeIteratorTest.php @@ -0,0 +1,25 @@ +entry1, $this->entry2], $this->randomOrderedDocuments); + $result = []; + foreach (new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST) as $doc) { + assert($doc instanceof DocumentNode); + $result[] = $doc; + } + + self::assertSame(self::documentsToTitle($this->flatDocumentList), self::documentsToTitle($result)); + } +} diff --git a/packages/guides/tests/unit/Renderer/IteratorTestCase.php b/packages/guides/tests/unit/Renderer/IteratorTestCase.php new file mode 100644 index 000000000..013aebda4 --- /dev/null +++ b/packages/guides/tests/unit/Renderer/IteratorTestCase.php @@ -0,0 +1,98 @@ +entry1, $doc1] = $this->createDocumentEntryAndNode('1.rst', '1'); + [$entry1_1, $doc1_1] = $this->createDocumentEntryAndNode('1/1.rst', '1.1'); + [$entry1_1_1, $doc1_1_1] = $this->createDocumentEntryAndNode('1/1/1.rst', '1.1.1'); + [$entry1_1_2, $doc1_1_2] = $this->createDocumentEntryAndNode('1/1/2.rst', '1.1.2'); + [$this->entry2, $doc2] = $this->createDocumentEntryAndNode('2.rst', '2'); + [$entry2_1, $doc2_1] = $this->createDocumentEntryAndNode('2/1.rst', '2.1'); + [$entry2_1_1, $doc2_1_1] = $this->createDocumentEntryAndNode('2/1/1.rst', '2.1.1'); + [$entry2_1_2, $doc2_1_2] = $this->createDocumentEntryAndNode('2/1/2.rst', '2.1.2'); + [$entry2_2, $doc2_2] = $this->createDocumentEntryAndNode('2/2.rst', '2.2'); + [$entry2_3, $doc2_3] = $this->createDocumentEntryAndNode('2/3.rst', '2.3'); + [$orphant_entry, $orphant] = $this->createDocumentEntryAndNode('orphant.rst', 'orphant'); + + + $this->entry1->addChild($entry1_1); + $entry1_1->addChild($entry1_1_1); + $entry1_1->addChild($entry1_1_2); + $this->entry2->addChild($entry2_1); + $entry2_1->addChild($entry2_1_1); + $entry2_1->addChild($entry2_1_2); + $this->entry2->addChild($entry2_2); + $this->entry2->addChild($entry2_3); + + $this->flatDocumentList = [ + $doc1, + $doc1_1, + $doc1_1_1, + $doc1_1_2, + $doc2, + $doc2_1, + $doc2_1_1, + $doc2_1_2, + $doc2_2, + $doc2_3, + $orphant, + ]; + + //We shuffle the array, because input order should not matter + $this->randomOrderedDocuments = $this->flatDocumentList; + shuffle($this->randomOrderedDocuments); + } + + /** @return array{DocumentEntryNode, DocumentNode} */ + private function createDocumentEntryAndNode(string $fileName, string $title): array + { + $titleNode = $this->createTitle($title); + $subDocumentEntry = new DocumentEntryNode($fileName, $titleNode); + $subDocumentNode = new DocumentNode(md5($title), $fileName); + $subDocumentNode->setValue([new SectionNode($titleNode)]); + $subDocumentNode->setDocumentEntry($subDocumentEntry); + + return [$subDocumentEntry, $subDocumentNode]; + } + + private function createTitle(string $title): TitleNode + { + return new TitleNode(InlineCompoundNode::getPlainTextInlineNode($title), 1, '1'); + } + + /** + * @param DocumentNode[] $result + * + * @return (string|null)[] + */ + protected static function documentsToTitle(array $result): array + { + return array_map(static fn (DocumentNode $doc) => $doc->getTitle()?->toString(), $result); + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9e09f9140..19d28e63c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -44,4 +44,8 @@ */tests/*/*.php + + + */tests/unit/Renderer/IteratorTestCase.php + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 169922199..a6555e5de 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -20,11 +20,6 @@ parameters: count: 1 path: packages/guides-cli/src/Command/Run.php - - - message: "#^Parameter \\#1 \\$iterable of method Symfony\\\\Component\\\\Console\\\\Helper\\\\ProgressBar\\:\\:iterate\\(\\) expects iterable, mixed given\\.$#" - count: 1 - path: packages/guides-cli/src/Command/Run.php - - message: "#^Parameter \\#1 \\$outputFormats of method phpDocumentor\\\\Guides\\\\Settings\\\\ProjectSettings\\:\\:setOutputFormats\\(\\) expects array\\, mixed given\\.$#" count: 1 @@ -37,7 +32,7 @@ parameters: - message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" - count: 1 + count: 2 path: packages/guides-cli/src/Command/Run.php - @@ -45,11 +40,6 @@ parameters: count: 1 path: packages/guides-cli/src/Command/Run.php - - - message: "#^Parameter \\#3 \\$documentIterator of class phpDocumentor\\\\Guides\\\\Handlers\\\\RenderCommand constructor expects iterable\\, mixed given\\.$#" - count: 1 - path: packages/guides-cli/src/Command/Run.php - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:ignoreExtraKeys\\(\\)\\.$#" count: 1 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 037b5551c..7365e56a1 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -50,4 +50,9 @@ [new SectionNode(TitleNode::emptyNode())] + + + [new SectionNode($titleNode)] + +