Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 = str_replace('\\', '/', $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);
Expand Down
Loading