Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
- Bug #166: Fix broken link to error handling guide (@vjik)
- Chg #170: Make all parameters of `ErrorException` constructor required (@vjik)
- New #171: Add `$traceFileMap` parameter to `HtmlRenderer` for mapping file paths in trace links (@WarLikeLaux)
- Enh #172: Improve closure rendering in stack traces (@WarLikeLaux)
- Bug #172: Keep items in stack traces when source is unavailable (@WarLikeLaux)
- Bug #172: Don't render non-positive line number in stack trace frame (@WarLikeLaux)

## 4.3.2 January 09, 2026

Expand Down
49 changes: 43 additions & 6 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ public function renderCallStack(Throwable $t, array $trace = []): string
$function = null;
if (!empty($traceItem['function']) && $traceItem['function'] !== 'unknown') {
$function = $traceItem['function'];
if (!str_contains($function, '{closure}')) {
if (!str_contains($function, '{closure')) {
try {
if ($class !== null && class_exists($class)) {
$parameters = (new ReflectionMethod($class, $function))->getParameters();
Expand Down Expand Up @@ -570,6 +570,34 @@ public function removeAnonymous(string $value): string
return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value;
}

/**
* Formats a trace function name for display.
*
* Handles PHP 8.4+ closure format `{closure:Context:line}` by extracting the definition context.
* For regular functions, prepends the class name when available.
*/
public function formatTraceFunctionName(?string $class, string $function): string
{
// PHP 8.4+: {closure:Context:line} - already contains full definition context.
if (preg_match('/^\{closure:(.+):(\d+)\}$/', $function, $matches)) {
return '{closure} ' . $matches[1] . ':' . $matches[2];
}

// PHP < 8.4 namespaced closure: Namespace\{closure} - strip redundant namespace.
if (str_contains($function, '\\{closure')) {
if ($class !== null && $class !== 'Closure') {
return $this->removeAnonymous($class) . '::{closure}';
}
return $function;
}

if ($class === null || $class === 'Closure') {
return $function;
}

return $this->removeAnonymous($class) . '::' . $function;
}

/**
* Extracts a user-facing description from throwable class PHPDoc.
*
Expand Down Expand Up @@ -745,12 +773,21 @@ private function renderCallStackItem(
if ($file !== null && $line !== null) {
$line--; // adjust line number from one-based to zero-based
$lines = @file($file);
if ($line < 0 || $lines === false || ($lineCount = count($lines)) < $line) {
return '';
if ($line < 0 || $lines === false) {
if ($line < 0) {
$line = null; // non-positive line has no valid source location to show
}
$lines = [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
$lineCount = count($lines);
if ($line <= $lineCount) {
$half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
$begin = $line - $half > 0 ? $line - $half : 0;
$end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
} else {
$lines = [];
}
}
$half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
$begin = $line - $half > 0 ? $line - $half : 0;
$end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
}

return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [
Expand Down
2 changes: 1 addition & 1 deletion templates/_call-stack-item.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<span class="function-info word-break">
<?php
echo $file === null ? "$index. " : '&mdash;&nbsp;';
$function = $class === null ? $function : "{$this->removeAnonymous($class)}::$function";
$function = $this->formatTraceFunctionName($class, $function);

echo '<span class="function">' . $this->htmlEncode($function) . '</span>';
echo '<span class="arguments">(';
Expand Down
Loading
Loading