From 15689bb805cbe63e151145540d985427324d8b1d Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 23 Jan 2026 13:30:37 +0100 Subject: [PATCH 1/3] refactor: use file path comparison instead of object identity Replace object identity comparison (===) with file path comparison when matching DocumentEntryNode instances. The file path is the natural unique identifier for a document entry. This change: - Makes the code more robust to serialization/deserialization (cached objects create new instances) - Aligns with how SectionEntryNode comparison already works - Enables future optimizations that may cache or recreate entry objects - Is a prerequisite for O(1) lookup caching in ProjectNode Changed locations: - GlobalMenuPass: root document lookup - ToctreeValidationPass: root document check - RenderContext: getDocumentNodeForEntry() - DocumentTreeIterator: current() No behavior change - documents are still matched by their file path, which was always the semantic intent. --- packages/guides/src/Compiler/Passes/GlobalMenuPass.php | 3 ++- packages/guides/src/Compiler/Passes/ToctreeValidationPass.php | 3 ++- packages/guides/src/RenderContext.php | 3 ++- packages/guides/src/Renderer/DocumentTreeIterator.php | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/guides/src/Compiler/Passes/GlobalMenuPass.php b/packages/guides/src/Compiler/Passes/GlobalMenuPass.php index dac74b564..cbe9f4b18 100644 --- a/packages/guides/src/Compiler/Passes/GlobalMenuPass.php +++ b/packages/guides/src/Compiler/Passes/GlobalMenuPass.php @@ -62,8 +62,9 @@ public function run(array $documents, CompilerContextInterface $compilerContext) } $rootDocument = null; + $rootFile = $rootDocumentEntry->getFile(); foreach ($documents as $document) { - if ($document->getDocumentEntry() === $rootDocumentEntry) { + if ($document->getDocumentEntry()->getFile() === $rootFile) { $rootDocument = $document; break; } diff --git a/packages/guides/src/Compiler/Passes/ToctreeValidationPass.php b/packages/guides/src/Compiler/Passes/ToctreeValidationPass.php index 75a69bb9b..ec93f9774 100644 --- a/packages/guides/src/Compiler/Passes/ToctreeValidationPass.php +++ b/packages/guides/src/Compiler/Passes/ToctreeValidationPass.php @@ -68,6 +68,7 @@ public function run(array $documents, CompilerContextInterface $compilerContext) public function isMissingInToctree(DocumentEntryNode $documentEntry, ProjectNode $projectNode): bool { - return $documentEntry->getParent() === null && $documentEntry !== $projectNode->getRootDocumentEntry(); + return $documentEntry->getParent() === null + && $documentEntry->getFile() !== $projectNode->getRootDocumentEntry()->getFile(); } } diff --git a/packages/guides/src/RenderContext.php b/packages/guides/src/RenderContext.php index bc8e52b64..5d797667e 100644 --- a/packages/guides/src/RenderContext.php +++ b/packages/guides/src/RenderContext.php @@ -222,8 +222,9 @@ public function getProjectNode(): ProjectNode public function getDocumentNodeForEntry(DocumentEntryNode $entryNode): DocumentNode { + $file = $entryNode->getFile(); foreach ($this->allDocuments as $child) { - if ($child->getDocumentEntry() === $entryNode) { + if ($child->getDocumentEntry()->getFile() === $file) { return $child; } } diff --git a/packages/guides/src/Renderer/DocumentTreeIterator.php b/packages/guides/src/Renderer/DocumentTreeIterator.php index 30266415d..d2904dc5c 100644 --- a/packages/guides/src/Renderer/DocumentTreeIterator.php +++ b/packages/guides/src/Renderer/DocumentTreeIterator.php @@ -41,8 +41,9 @@ public function __construct( public function current(): DocumentNode { + $file = $this->levelNodes[$this->position]->getFile(); foreach ($this->documents as $document) { - if ($document->getDocumentEntry() === $this->levelNodes[$this->position]) { + if ($document->getDocumentEntry()->getFile() === $file) { return $document; } } From 945944c833437ad9e2ea1af3eca15ed9e8ca9e44 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 23 Jan 2026 13:31:23 +0100 Subject: [PATCH 2/3] perf: add O(1) lookup caching for ProjectNode document entries Replace O(n) iteration with O(1) hash-based lookups for document entry retrieval in ProjectNode. Changes: - Cache root document entry for instant getRootDocumentEntry() - Use direct hash lookup in getDocumentEntry() by file path - Properly rekey array in setDocumentEntries() via addDocumentEntry() - Invalidate cache on reset() - Update type annotation to array Performance impact: - getRootDocumentEntry(): O(n) -> O(1) - getDocumentEntry(): O(n) -> O(1) This optimization is enabled by the previous commit's refactoring from object identity to file path comparison, which ensures lookups work correctly even with cached/deserialized document entries. --- packages/guides/src/Nodes/ProjectNode.php | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/guides/src/Nodes/ProjectNode.php b/packages/guides/src/Nodes/ProjectNode.php index 7690c434c..2afce43e2 100644 --- a/packages/guides/src/Nodes/ProjectNode.php +++ b/packages/guides/src/Nodes/ProjectNode.php @@ -47,7 +47,10 @@ final class ProjectNode extends CompoundNode /** @var array> */ private array $internalLinkTargets = []; - /** @var DocumentEntryNode[] */ + /** Cached root document entry for O(1) lookup */ + private DocumentEntryNode|null $rootDocumentEntry = null; + + /** @var array */ private array $documentEntries = []; private DateTimeImmutable $lastRendered; @@ -182,6 +185,11 @@ public function getAllInternalTargets(): array public function addDocumentEntry(DocumentEntryNode $documentEntry): void { + // Cache root for O(1) lookup + if ($documentEntry->isRoot()) { + $this->rootDocumentEntry = $documentEntry; + } + $this->documentEntries[$documentEntry->getFile()] = $documentEntry; } @@ -193,10 +201,8 @@ public function getAllDocumentEntries(): array public function getRootDocumentEntry(): DocumentEntryNode { - foreach ($this->documentEntries as $documentEntry) { - if ($documentEntry->isRoot()) { - return $documentEntry; - } + if ($this->rootDocumentEntry !== null) { + return $this->rootDocumentEntry; } throw new Exception('No root document entry was found'); @@ -205,10 +211,9 @@ public function getRootDocumentEntry(): DocumentEntryNode /** @throws DocumentEntryNotFound */ public function getDocumentEntry(string $file): DocumentEntryNode { - foreach ($this->documentEntries as $documentEntry) { - if ($documentEntry->getFile() === $file) { - return $documentEntry; - } + // O(1) lookup by file path + if (isset($this->documentEntries[$file])) { + return $this->documentEntries[$file]; } throw new DocumentEntryNotFound('No document Entry found for file ' . $file); @@ -217,7 +222,12 @@ public function getDocumentEntry(string $file): DocumentEntryNode /** @param DocumentEntryNode[] $documentEntries */ public function setDocumentEntries(array $documentEntries): void { - $this->documentEntries = $documentEntries; + $this->documentEntries = []; + $this->rootDocumentEntry = null; + + foreach ($documentEntries as $entry) { + $this->addDocumentEntry($entry); + } } public function findDocumentEntry(string $filePath): DocumentEntryNode|null @@ -228,6 +238,7 @@ public function findDocumentEntry(string $filePath): DocumentEntryNode|null public function reset(): void { $this->documentEntries = []; + $this->rootDocumentEntry = null; } public function getLastRendered(): DateTimeImmutable From a5715462d49638949a70ae82765162a9d120d1e1 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 23 Jan 2026 13:31:46 +0100 Subject: [PATCH 3/3] test: adapt FunctionalTest to use withIsRoot() for root document Update FunctionalTest to mark the document as root using withIsRoot(true) before compilation, rather than pre-creating a DocumentEntryNode that gets overwritten by the compiler. This is the correct approach because: - The compiler creates DocumentEntryNode during compilation - Pre-creating an entry that gets replaced is misleading - withIsRoot(true) signals intent to the compiler Removed unused imports: DocumentEntryNode, TitleNode --- tests/Functional/FunctionalTest.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php index f0e5d53df..bcac5797f 100644 --- a/tests/Functional/FunctionalTest.php +++ b/tests/Functional/FunctionalTest.php @@ -25,10 +25,8 @@ use phpDocumentor\Guides\Compiler\Compiler; use phpDocumentor\Guides\Compiler\CompilerContext; use phpDocumentor\Guides\NodeRenderers\NodeRenderer; -use phpDocumentor\Guides\Nodes\DocumentTree\DocumentEntryNode; use phpDocumentor\Guides\Nodes\Node; use phpDocumentor\Guides\Nodes\ProjectNode; -use phpDocumentor\Guides\Nodes\TitleNode; use phpDocumentor\Guides\Parser; use phpDocumentor\Guides\RenderContext; use phpDocumentor\Guides\Settings\ProjectSettings; @@ -110,13 +108,11 @@ public function testFunctional( $parser = $this->getContainer()->get(Parser::class); assert($parser instanceof Parser); - $document = $parser->parse($rst); - $documentEntry = new DocumentEntryNode($document->getFilePath(), $document->getTitle() ?? TitleNode::fromString(''), true); + $document = $parser->parse($rst)->withIsRoot(true); $compiler = $this->getContainer()->get(Compiler::class); assert($compiler instanceof Compiler); $projectNode = new ProjectNode(); - $projectNode->setDocumentEntries([$documentEntry]); $compiler->run([$document], new CompilerContext($projectNode)); $inputFilesystem = FlySystemAdapter::createFromFileSystem(new Filesystem(new InMemoryFilesystemAdapter()));