diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cf815..b4bdcc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Enh #163: Explicitly import classes, functions, and constants in "use" section (@mspirkov) - Bug #164: Fix missing items in stack trace HTML output when handling a PHP error (@vjik) - Bug #166: Fix broken link to error handling guide (@vjik) +- New #171: Add `$traceFileMap` parameter to `HtmlRenderer` for mapping file paths in trace links (@WarLikeLaux) ## 4.3.2 January 09, 2026 diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index b2e9b09..0507c2c 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -50,6 +50,7 @@ use function preg_replace; use function preg_replace_callback; use function preg_split; +use function rtrim; use function str_replace; use function str_starts_with; use function stripos; @@ -158,6 +159,9 @@ final class HtmlRenderer implements ThrowableRendererInterface * ); * } * ``` + * @param array $traceFileMap Map of file path prefixes for trace display and links. Keys are + * original path prefixes (e.g. container paths), values are replacement prefixes (e.g. host machine paths). + * Example: `['/app' => '/home/user/project']` maps `/app/src/index.php` to `/home/user/project/src/index.php`. * * @psalm-param array{ * template?: string, @@ -176,6 +180,7 @@ public function __construct( ?int $maxTraceLines = null, ?string $traceHeaderLine = null, string|Closure|null $traceLink = null, + public readonly array $traceFileMap = [], ) { $this->markdownParser = new GithubMarkdown(); $this->markdownParser->html5 = true; @@ -749,7 +754,7 @@ private function renderCallStackItem( } return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [ - 'file' => $file, + 'file' => $file !== null ? $this->mapFilePath($file) : null, 'line' => $line, 'class' => $class, 'function' => $function, @@ -856,6 +861,33 @@ private function getVendorPaths(): array return $this->vendorPaths; } + private function mapFilePath(string $file): string + { + foreach ($this->traceFileMap as $from => $to) { + $normalizedFrom = rtrim($from, '/\\'); + $normalizedTo = rtrim($to, '/\\'); + + if ($normalizedFrom === '') { + if ($from !== '' && str_starts_with($file, $from)) { + return $normalizedTo . $file; + } + + continue; + } + + if ( + $file === $normalizedFrom + || str_starts_with($file, $normalizedFrom . '/') + || str_starts_with($file, $normalizedFrom . '\\') + ) { + $fromLength = strlen($normalizedFrom); + + return $normalizedTo . substr($file, $fromLength); + } + } + return $file; + } + /** * @psalm-param string|TraceLinkClosure|null $traceLink * @psalm-return TraceLinkClosure diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 430f3b6..ca95b56 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -600,6 +600,81 @@ public function testTraceLinkGenerator( $this->assertSame($expected, $link); } + public static function dataMapFilePath(): iterable + { + yield 'prefix match' => [ + ['/app' => '/local'], + '/app/src/index.php', + '/local/src/index.php', + ]; + yield 'no match' => [ + ['/other' => '/local'], + '/app/src/index.php', + '/app/src/index.php', + ]; + yield 'first match wins' => [ + ['/app' => '/first', '/app/src' => '/second'], + '/app/src/index.php', + '/first/src/index.php', + ]; + yield 'partial prefix should not match' => [ + ['/app' => '/local'], + '/application/src/index.php', + '/application/src/index.php', + ]; + yield 'prefix with trailing slash' => [ + ['/app/' => '/local/'], + '/app/src/index.php', + '/local/src/index.php', + ]; + yield 'exact match' => [ + ['/app' => '/local'], + '/app', + '/local', + ]; + yield 'windows separator' => [ + ['C:\\project' => 'D:\\project'], + 'C:\\project\\src\\index.php', + 'D:\\project\\src\\index.php', + ]; + yield 'empty source prefix is ignored' => [ + ['' => '/mapped', '/app' => '/local'], + '/app/src/index.php', + '/local/src/index.php', + ]; + yield 'root prefix' => [ + ['/' => '/mapped'], + '/app/src/index.php', + '/mapped/app/src/index.php', + ]; + yield 'empty map' => [ + [], + '/app/src/index.php', + '/app/src/index.php', + ]; + } + + #[DataProvider('dataMapFilePath')] + public function testMapFilePath(array $traceFileMap, string $file, string $expected): void + { + $renderer = new HtmlRenderer(traceFileMap: $traceFileMap); + $result = $this->invokeMethod($renderer, 'mapFilePath', ['file' => $file]); + $this->assertSame($expected, $result); + } + + public function testTraceFileMapAppliedInCallStack(): void + { + $renderer = new HtmlRenderer( + traceLink: 'phpstorm://open?file={file}&line={line}', + traceFileMap: [__DIR__ => '/mapped/path'], + ); + + $result = $renderer->renderCallStack(new RuntimeException('test')); + + $this->assertStringContainsString(' class="trace-link">/mapped/path', $result); + $this->assertStringContainsString('href="phpstorm://open?file=/mapped/path', $result); + } + private function createServerRequestMock(): ServerRequestInterface { $serverRequestMock = $this->createMock(ServerRequestInterface::class);