From 0adf582e8ea70b3dbd01bb2bddc7f685297c4d99 Mon Sep 17 00:00:00 2001 From: Jaapio Date: Fri, 17 Nov 2023 17:08:05 +0100 Subject: [PATCH] !!![FEATURE] add document iterator The newly added iterators do iterate the toc tree to find the order of rendering of documents. This is required to have single page outputs in the correct order. The downside of this new approach is that only documents that are in a toc-tree are rendered. --- packages/guides-cli/src/Command/Run.php | 14 +- .../guides/src/Handlers/RenderCommand.php | 20 ++- packages/guides/src/RenderContext.php | 15 +++ .../guides/src/Renderer/BaseTypeRenderer.php | 21 +-- .../src/Renderer/DocumentListIterator.php | 123 ++++++++++++++++++ .../src/Renderer/DocumentTreeIterator.php | 73 +++++++++++ .../guides/src/Renderer/LatexRenderer.php | 4 +- .../Renderer/DocumentListIteratorTest.php | 94 +++++++++++++ .../Renderer/DocumentTreeIteratorTest.php | 25 ++++ .../tests/unit/Renderer/IteratorTestCase.php | 98 ++++++++++++++ phpcs.xml.dist | 4 + phpstan-baseline.neon | 12 +- psalm-baseline.xml | 5 + 13 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 packages/guides/src/Renderer/DocumentListIterator.php create mode 100644 packages/guides/src/Renderer/DocumentTreeIterator.php create mode 100644 packages/guides/tests/unit/Renderer/DocumentListIteratorTest.php create mode 100644 packages/guides/tests/unit/Renderer/DocumentTreeIteratorTest.php create mode 100644 packages/guides/tests/unit/Renderer/IteratorTestCase.php 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)] + +