Skip to content

Commit e55f890

Browse files
committed
[path] Fix phpDocumentor template runtime fallbacks
1 parent c8df8d6 commit e55f890

7 files changed

Lines changed: 155 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Keep global `dev-tools:sync` runs machine-independent by removing only deprecated DevTools-managed Composer GrumPHP default-path metadata while wiring packaged Git hooks to prefer a project-local `grumphp.yml` and otherwise use the active packaged DevTools config path resolved at sync time (#288)
13-
- Complete global runtime binary fallback support for phpdoc, phpunit, and phpmetrics commands (#297)
13+
- Complete global runtime fallback support for phpdoc, phpunit, and phpmetrics commands, including packaged phpDocumentor templates used by `docs` and `wiki` (#297)
1414

1515
## [1.24.2] - 2026-04-30
1616

src/Console/Command/DocsCommand.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ final class DocsCommand extends Command
5858
use HasJsonOption;
5959
use LogsCommandResults;
6060

61+
/**
62+
* @var string the default phpDocumentor template path relative to the consumer project
63+
*/
64+
private const string DEFAULT_TEMPLATE = 'vendor/fast-forward/phpdoc-bootstrap-template';
65+
6166
/**
6267
* Creates a new DocsCommand instance.
6368
*
@@ -115,7 +120,7 @@ protected function configure(): void
115120
name: 'template',
116121
mode: InputOption::VALUE_OPTIONAL,
117122
description: 'Path to the template directory for the generated HTML documentation.',
118-
default: 'vendor/fast-forward/phpdoc-bootstrap-template',
123+
default: self::DEFAULT_TEMPLATE,
119124
);
120125
}
121126

@@ -137,6 +142,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
137142
$source = $this->filesystem->getAbsolutePath($input->getOption('source'));
138143
$target = $this->filesystem->getAbsolutePath($input->getOption('target'));
139144
$cacheDir = $this->filesystem->getAbsolutePath($input->getOption('cache-dir'));
145+
$template = (string) $input->getOption('template');
146+
147+
if (self::DEFAULT_TEMPLATE === $template) {
148+
$template = DevToolsPathResolver::getPreferredVendorPath(self::DEFAULT_TEMPLATE);
149+
}
140150

141151
$this->logger->info('Generating API documentation...', [
142152
'input' => $input,
@@ -151,7 +161,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
151161
$config = $this->createPhpDocumentorConfig(
152162
source: $source,
153163
target: $target,
154-
template: $input->getOption('template'),
164+
template: $template,
155165
cacheDir: $cacheEnabled ? $cacheDir : sys_get_temp_dir(),
156166
);
157167

src/Console/Command/WikiCommand.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ final class WikiCommand extends Command
5555
use HasJsonOption;
5656
use LogsCommandResults;
5757

58+
/**
59+
* @var string the default phpDocumentor Markdown template path relative to the consumer project
60+
*/
61+
private const string DEFAULT_TEMPLATE = 'vendor/saggre/phpdocumentor-markdown/themes/markdown';
62+
5863
/**
5964
* Creates a new WikiCommand instance.
6065
*
@@ -139,7 +144,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
139144
$processBuilder = $this->processBuilder
140145
->withArgument('--ansi')
141146
->withArgument('--visibility', 'public,protected')
142-
->withArgument('--template', 'vendor/saggre/phpdocumentor-markdown/themes/markdown')
147+
->withArgument('--template', DevToolsPathResolver::getPreferredVendorPath(self::DEFAULT_TEMPLATE))
143148
->withArgument('--title', $this->composer->getDescription())
144149
->withArgument('--target', $target);
145150

src/Path/DevToolsPathResolver.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ public static function getRuntimeToolBinaryPath(string $binary, string $packageP
128128
return Path::join($packagePath, 'vendor', 'bin', $binary);
129129
}
130130

131+
/**
132+
* Returns the active Composer vendor path for the current DevTools installation mode.
133+
*
134+
* Relative vendor paths MAY be passed either with or without a leading
135+
* `vendor/` prefix.
136+
*
137+
* @param string $path the vendor-relative path to resolve
138+
* @param string $packagePath an optional package root path; defaults to the current package root
139+
*/
140+
public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string
141+
{
142+
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
143+
$vendorPath = self::normalizeVendorRelativePath($path);
144+
145+
if (self::isInstalledAsDependency($packagePath)) {
146+
return Path::canonicalize(Path::join($packagePath, '..', '..', $vendorPath));
147+
}
148+
149+
return Path::join($packagePath, 'vendor', $vendorPath);
150+
}
151+
131152
/**
132153
* Returns the preferred tooling binary path for the active project and DevTools runtime.
133154
*
@@ -154,6 +175,33 @@ public static function getPreferredToolBinaryPath(
154175
return self::getRuntimeToolBinaryPath($binary, $packagePath);
155176
}
156177

178+
/**
179+
* Returns the preferred Composer vendor path for the active project and DevTools runtime.
180+
*
181+
* Consumer projects SHOULD take precedence when they provide the requested
182+
* vendor path locally. If the path is absent locally, the method MUST fall
183+
* back to the active DevTools runtime vendor path.
184+
*
185+
* @param string $path the vendor-relative path to resolve
186+
* @param string $projectPath an optional project root path; defaults to the working project root
187+
* @param string $packagePath an optional package root path; defaults to the current package root
188+
*/
189+
public static function getPreferredVendorPath(
190+
string $path,
191+
string $projectPath = '',
192+
string $packagePath = '',
193+
): string {
194+
$projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath;
195+
$vendorPath = self::normalizeVendorRelativePath($path);
196+
$projectVendorPath = Path::join($projectPath, 'vendor', $vendorPath);
197+
198+
if (file_exists($projectVendorPath)) {
199+
return $projectVendorPath;
200+
}
201+
202+
return self::getRuntimeVendorPath($vendorPath, $packagePath);
203+
}
204+
157205
/**
158206
* Detects whether the provided path belongs to an installed vendor copy of DevTools.
159207
*
@@ -175,4 +223,20 @@ public static function isRepositoryCheckout(string $packagePath = ''): bool
175223
{
176224
return ! self::isInstalledAsDependency($packagePath);
177225
}
226+
227+
/**
228+
* Normalizes a path relative to the Composer vendor root.
229+
*
230+
* @param string $path the vendor-relative path to normalize
231+
*/
232+
private static function normalizeVendorRelativePath(string $path): string
233+
{
234+
$path = Path::canonicalize($path);
235+
236+
if (str_starts_with($path, 'vendor/')) {
237+
return substr($path, 7);
238+
}
239+
240+
return $path;
241+
}
178242
}

tests/Console/Command/DocsCommandTest.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,14 @@ protected function setUp(): void
132132
]);
133133
$this->composer->getName()
134134
->willReturn('fast-forward/dev-tools');
135-
$this->renderer->render('phpdocumentor.xml', Argument::type('array'))->willReturn('<phpdocumentor />');
135+
$this->renderer->render(
136+
'phpdocumentor.xml',
137+
Argument::that(
138+
static fn(array $context): bool => DevToolsPathResolver::getPreferredVendorPath(
139+
'vendor/fast-forward/phpdoc-bootstrap-template'
140+
) === $context['template']
141+
)
142+
)->willReturn('<phpdocumentor />');
136143
$this->processBuilder->withArgument(Argument::any())->willReturn($this->processBuilder->reveal());
137144
$this->processBuilder->withArgument(Argument::any(), Argument::any())->willReturn(
138145
$this->processBuilder->reveal()

tests/Console/Command/WikiCommandTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ protected function setUp(): void
130130
#[Test]
131131
public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void
132132
{
133+
$this->processBuilder->withArgument(
134+
'--template',
135+
DevToolsPathResolver::getPreferredVendorPath('vendor/saggre/phpdocumentor-markdown/themes/markdown')
136+
)
137+
->willReturn($this->processBuilder->reveal())
138+
->shouldBeCalled();
133139
$this->processBuilder->withArgument(
134140
'--cache-folder',
135141
ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC)

tests/Path/DevToolsPathResolverTest.php

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ public function itWillExposeCanonicalPackagePaths(): void
5151
\dirname(__DIR__, 2) . '/vendor/bin/ecs',
5252
DevToolsPathResolver::getRuntimeToolBinaryPath('ecs')
5353
);
54+
self::assertSame(
55+
\dirname(__DIR__, 2) . '/vendor/fast-forward/phpdoc-bootstrap-template',
56+
DevToolsPathResolver::getRuntimeVendorPath('vendor/fast-forward/phpdoc-bootstrap-template')
57+
);
5458
self::assertSame(
5559
\dirname(__DIR__, 2) . '/resources/phpdocumentor.xml',
5660
DevToolsPathResolver::getResourcesPath('phpdocumentor.xml')
@@ -93,7 +97,7 @@ public function itWillDetectWhetherDevToolsRunsFromVendorOrRepositoryCheckout():
9397
* @return void
9498
*/
9599
#[Test]
96-
public function itWillResolveRuntimeAutoloadPathsForRepositoryAndDependencyInstalls(): void
100+
public function itWillResolveRuntimeAutoloadAndVendorPathsForRepositoryAndDependencyInstalls(): void
97101
{
98102
self::assertSame(
99103
'/workspaces/dev-tools/vendor/autoload.php',
@@ -103,6 +107,13 @@ public function itWillResolveRuntimeAutoloadPathsForRepositoryAndDependencyInsta
103107
'/workspaces/project/vendor/autoload.php',
104108
DevToolsPathResolver::getRuntimeAutoloadPath('/workspaces/project/vendor/fast-forward/dev-tools')
105109
);
110+
self::assertSame(
111+
'/workspaces/project/vendor/saggre/phpdocumentor-markdown/themes/markdown',
112+
DevToolsPathResolver::getRuntimeVendorPath(
113+
'vendor/saggre/phpdocumentor-markdown/themes/markdown',
114+
'/workspaces/project/vendor/fast-forward/dev-tools'
115+
)
116+
);
106117
}
107118

108119
/**
@@ -175,4 +186,50 @@ public function itWillFallbackToRuntimeToolBinariesWhenTheProjectDoesNotProvideT
175186
)
176187
);
177188
}
189+
190+
/**
191+
* @return void
192+
*/
193+
#[Test]
194+
public function itWillPreferProjectVendorPathsWhenTheyExist(): void
195+
{
196+
$projectPath = sys_get_temp_dir() . '/dev-tools-vendor-path-resolver-' . bin2hex(random_bytes(4));
197+
$vendorPath = $projectPath . '/vendor/saggre/phpdocumentor-markdown/themes/markdown';
198+
199+
mkdir($vendorPath, 0o777, true);
200+
201+
try {
202+
self::assertSame(
203+
$vendorPath,
204+
DevToolsPathResolver::getPreferredVendorPath(
205+
'vendor/saggre/phpdocumentor-markdown/themes/markdown',
206+
$projectPath,
207+
'/Users/example/.composer/vendor/fast-forward/dev-tools'
208+
)
209+
);
210+
} finally {
211+
rmdir($vendorPath);
212+
rmdir($projectPath . '/vendor/saggre/phpdocumentor-markdown/themes');
213+
rmdir($projectPath . '/vendor/saggre/phpdocumentor-markdown');
214+
rmdir($projectPath . '/vendor/saggre');
215+
rmdir($projectPath . '/vendor');
216+
rmdir($projectPath);
217+
}
218+
}
219+
220+
/**
221+
* @return void
222+
*/
223+
#[Test]
224+
public function itWillFallbackToRuntimeVendorPathsWhenTheProjectDoesNotProvideThem(): void
225+
{
226+
self::assertSame(
227+
'/Users/example/.composer/vendor/fast-forward/phpdoc-bootstrap-template',
228+
DevToolsPathResolver::getPreferredVendorPath(
229+
'vendor/fast-forward/phpdoc-bootstrap-template',
230+
'/workspaces/project',
231+
'/Users/example/.composer/vendor/fast-forward/dev-tools'
232+
)
233+
);
234+
}
178235
}

0 commit comments

Comments
 (0)