Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 33 additions & 1 deletion src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,6 +159,9 @@ final class HtmlRenderer implements ThrowableRendererInterface
* );
* }
* ```
* @param array<string, string> $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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/Renderer/HtmlRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading