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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- 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)
- Enh #172: Improve closure rendering in stack traces (@WarLikeLaux)
- Bug #172: Keep items in stack traces when source is unavailable (@WarLikeLaux)

## 4.3.2 January 09, 2026

Expand Down
46 changes: 40 additions & 6 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,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 @@ -565,6 +565,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 @@ -740,12 +768,18 @@ 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) {
$lines = [];
} else {
$lineCount = count($lines);
if ($line < $lineCount) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ($line < $lineCount) {
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