From 99aaed07834e5db7a29df85936b93bf638c0b3f4 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Fri, 27 Mar 2026 01:12:01 +0600 Subject: [PATCH 1/4] Add `$traceFileMap` parameter to `HtmlRenderer` --- CHANGELOG.md | 1 + src/Renderer/HtmlRenderer.php | 37 +++++++++++++- tests/Renderer/HtmlRendererTest.php | 75 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) 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..3ccf040 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,36 @@ 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 === '') { + continue; + } + + if (str_starts_with($file, $from)) { + return $normalizedTo . $file; + } + + continue; + } + + $fromLength = strlen($normalizedFrom); + if ( + $file === $normalizedFrom + || str_starts_with($file, $normalizedFrom . '/') + || str_starts_with($file, $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..eb85a3f 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('/mapped/path/', $result); + $this->assertStringContainsString('phpstorm://open?file=/mapped/path/', $result); + } + private function createServerRequestMock(): ServerRequestInterface { $serverRequestMock = $this->createMock(ServerRequestInterface::class); From 68b75f5d29c6e49b2911e8e6c09b7358db8b9529 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Fri, 27 Mar 2026 01:25:58 +0600 Subject: [PATCH 2/4] Simplify trace file path mapping branch --- src/Renderer/HtmlRenderer.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 3ccf040..bf651d9 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -868,11 +868,7 @@ private function mapFilePath(string $file): string $normalizedTo = rtrim($to, '/\\'); if ($normalizedFrom === '') { - if ($from === '') { - continue; - } - - if (str_starts_with($file, $from)) { + if ($from !== '' && str_starts_with($file, $from)) { return $normalizedTo . $file; } From 500ea125c9046fa809ce8eacd6fea94cd2548c58 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 10:43:10 +0600 Subject: [PATCH 3/4] Fix #171: Address review comments --- src/Renderer/HtmlRenderer.php | 3 ++- tests/Renderer/HtmlRendererTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index bf651d9..0507c2c 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -875,12 +875,13 @@ private function mapFilePath(string $file): string continue; } - $fromLength = strlen($normalizedFrom); if ( $file === $normalizedFrom || str_starts_with($file, $normalizedFrom . '/') || str_starts_with($file, $normalizedFrom . '\\') ) { + $fromLength = strlen($normalizedFrom); + return $normalizedTo . substr($file, $fromLength); } } diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index eb85a3f..50878d6 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -669,7 +669,7 @@ public function testTraceFileMapAppliedInCallStack(): void traceFileMap: [__DIR__ => '/mapped/path'], ); - $result = $renderer->renderCallStack(new RuntimeException('test')); + $result = str_replace('\\', '/', $renderer->renderCallStack(new RuntimeException('test'))); $this->assertStringContainsString('/mapped/path/', $result); $this->assertStringContainsString('phpstorm://open?file=/mapped/path/', $result); From 67093515e69c293a26976e565dc5ee5c9edd157b Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 2 Apr 2026 18:17:09 +0300 Subject: [PATCH 4/4] fix test --- tests/Renderer/HtmlRendererTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 50878d6..ca95b56 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -669,10 +669,10 @@ public function testTraceFileMapAppliedInCallStack(): void traceFileMap: [__DIR__ => '/mapped/path'], ); - $result = str_replace('\\', '/', $renderer->renderCallStack(new RuntimeException('test'))); + $result = $renderer->renderCallStack(new RuntimeException('test')); - $this->assertStringContainsString('/mapped/path/', $result); - $this->assertStringContainsString('phpstorm://open?file=/mapped/path/', $result); + $this->assertStringContainsString(' class="trace-link">/mapped/path', $result); + $this->assertStringContainsString('href="phpstorm://open?file=/mapped/path', $result); } private function createServerRequestMock(): ServerRequestInterface