From 56c155e77c7a5e541644186f196c2edcfca05842 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Fri, 27 Mar 2026 23:17:26 +0600 Subject: [PATCH 1/5] Improve closure rendering in stack traces --- CHANGELOG.md | 1 + src/Renderer/HtmlRenderer.php | 41 ++- templates/_call-stack-item.php | 2 +- tests/Renderer/HtmlRendererTest.php | 272 +++++++++++++++++- tests/Support/FileLevelClosureLoader.php | 15 + .../Support/NamespacedClosureTraceFixture.php | 25 ++ .../Support/file_level_closure_exception.php | 15 + 7 files changed, 349 insertions(+), 22 deletions(-) create mode 100644 tests/Support/FileLevelClosureLoader.php create mode 100644 tests/Support/NamespacedClosureTraceFixture.php create mode 100644 tests/Support/file_level_closure_exception.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cf815..a829ba9 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) +- Enh #172: Improve closure rendering in stack traces and keep items when source is unavailable (@WarLikeLaux) ## 4.3.2 January 09, 2026 diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index b2e9b09..db589c9 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -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(); @@ -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. * @@ -740,12 +768,13 @@ 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 && ($lineCount = count($lines)) > $line) { + $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', [ diff --git a/templates/_call-stack-item.php b/templates/_call-stack-item.php index 0f18b31..915a416 100644 --- a/templates/_call-stack-item.php +++ b/templates/_call-stack-item.php @@ -62,7 +62,7 @@ removeAnonymous($class)}::$function"; + $function = $this->formatTraceFunctionName($class, $function); echo '' . $this->htmlEncode($function) . ''; echo '('; diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 430f3b6..11ddd5f 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\WithoutErrorHandler; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use Closure; use ReflectionClass; use ReflectionObject; use RuntimeException; @@ -22,35 +23,41 @@ use Yiisoft\ErrorHandler\Tests\Support\TestHelper; use Yiisoft\ErrorHandler\Tests\Support\TestInlineCodeDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestLeadingMarkdownLinkDocBlockException; +use Yiisoft\ErrorHandler\Tests\Support\NamespacedClosureTraceFixture; use Yiisoft\ErrorHandler\Tests\Support\TestOwaspFilterEvasionDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestParenthesizedMarkdownDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestQueryStringDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestUnsafeDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestUnsafeMarkdownDocBlockException; +use stdClass; use function dirname; use function file_exists; use function file_put_contents; use function fopen; +use function Yiisoft\ErrorHandler\Tests\Support\loadFileLevelClosureException; use function unlink; use function sprintf; +use function count; use const DIRECTORY_SEPARATOR; +use const PHP_VERSION_ID; + +require_once dirname(__DIR__) . '/Support/FileLevelClosureLoader.php'; final class HtmlRendererTest extends TestCase { - private const CUSTOM_SETTING = [ - 'verboseTemplate' => __DIR__ . '/test-template-verbose.php', - 'template' => __DIR__ . '/test-template-non-verbose.php', - ]; + private array $temporaryFiles = []; protected function tearDown(): void { - foreach (self::CUSTOM_SETTING as $template) { + foreach ($this->temporaryFiles as $template) { if (file_exists($template)) { unlink($template); } } + + $this->temporaryFiles = []; } public function testNonVerboseOutput(): void @@ -312,10 +319,11 @@ public function testVerboseOutputRendersThrowableDescriptionLinksWithParentheses public function testNonVerboseOutputWithCustomTemplate(): void { + $settings = $this->createCustomSetting(); $templateFileContents = 'getMessage();?>'; - $this->createTestTemplate(self::CUSTOM_SETTING['template'], $templateFileContents); + $this->createTestTemplate($settings['template'], $templateFileContents); - $renderer = new HtmlRenderer(self::CUSTOM_SETTING); + $renderer = new HtmlRenderer($settings); $exceptionMessage = 'exception-test-message'; $exception = new RuntimeException($exceptionMessage); @@ -325,10 +333,11 @@ public function testNonVerboseOutputWithCustomTemplate(): void public function testVerboseOutputWithCustomTemplate(): void { + $settings = $this->createCustomSetting(); $templateFileContents = 'getMessage();?>'; - $this->createTestTemplate(self::CUSTOM_SETTING['verboseTemplate'], $templateFileContents); + $this->createTestTemplate($settings['verboseTemplate'], $templateFileContents); - $renderer = new HtmlRenderer(self::CUSTOM_SETTING); + $renderer = new HtmlRenderer($settings); $exceptionMessage = 'exception-test-message'; $exception = new RuntimeException($exceptionMessage); @@ -347,8 +356,9 @@ public function testRenderTemplateThrowsExceptionWhenTemplateFileNotExists(): vo public function testRenderTemplateThrowsExceptionWhenFailureInTemplate(): void { - $this->createTestTemplate(self::CUSTOM_SETTING['verboseTemplate'], ''); - $renderer = new HtmlRenderer(self::CUSTOM_SETTING); + $settings = $this->createCustomSetting(); + $this->createTestTemplate($settings['verboseTemplate'], ''); + $renderer = new HtmlRenderer($settings); $exceptionMessage = 'Template error.'; $exception = new RuntimeException($exceptionMessage); @@ -359,11 +369,12 @@ public function testRenderTemplateThrowsExceptionWhenFailureInTemplate(): void public function testRenderPreviousExceptions(): void { + $settings = $this->createCustomSetting(); $previousExceptionMessage = 'Test Previous Exception.'; $exception = new RuntimeException('Some error.', 0, new Exception($previousExceptionMessage)); $templateFileContents = 'renderPreviousExceptions($throwable); ?>'; - $this->createTestTemplate(self::CUSTOM_SETTING['verboseTemplate'], $templateFileContents); - $renderer = new HtmlRenderer(self::CUSTOM_SETTING); + $this->createTestTemplate($settings['verboseTemplate'], $templateFileContents); + $renderer = new HtmlRenderer($settings); $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); $this->assertStringContainsString($previousExceptionMessage, (string) $errorData); @@ -371,7 +382,7 @@ public function testRenderPreviousExceptions(): void public function testRenderCallStack(): void { - $renderer = new HtmlRenderer(self::CUSTOM_SETTING); + $renderer = new HtmlRenderer(); $this->setVendorPaths($renderer, [dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'vendor']); $this->assertStringContainsString( @@ -401,7 +412,8 @@ static function (int $code, string $message) use (&$errorMessage) { ]); restore_error_handler(); - $this->assertSame('', $result); + $this->assertStringContainsString('not-exist', $result); + $this->assertStringContainsString('call-stack-item', $result); $this->assertSame('file(not-exist): Failed to open stream: No such file or directory', $errorMessage); } @@ -421,6 +433,25 @@ public function testRenderCallStackWithErrorException(): void $this->assertStringContainsString('5. ', $result); } + public function testRenderCallStackItemDoesNotRenderSourceCodeWhenLineIsOutsideFileRange(): void + { + $line = count(file(__FILE__)) + 1; + $result = $this->invokeMethod(new HtmlRenderer(), 'renderCallStackItem', [ + 'file' => __FILE__, + 'line' => $line, + 'class' => null, + 'function' => null, + 'args' => [], + 'index' => 1, + 'isVendorFile' => false, + 'reflectionParameters' => [], + ]); + + $this->assertStringContainsString(__FILE__, $result); + $this->assertStringContainsString('at line ' . $line, $result); + $this->assertStringNotContainsString('element-code-wrap', $result); + } + public function testRenderRequest(): void { $renderer = new HtmlRenderer(); @@ -600,6 +631,148 @@ public function testTraceLinkGenerator( $this->assertSame($expected, $link); } + public function testRenderCallStackWithMethodClosure(): void + { + $renderer = new HtmlRenderer(); + $exception = $this->createMethodClosureException(); + $traceItem = $exception->getTrace()[0]; + + $this->assertArrayHasKey('file', $traceItem); + $this->assertArrayHasKey('line', $traceItem); + $this->assertSame(self::class, $traceItem['class']); + $this->assertStringContainsString('{closure', $traceItem['function']); + + $result = $renderer->renderCallStack($exception, $exception->getTrace()); + + $this->assertStringContainsString('{closure}', $result); + $this->assertStringNotContainsString(self::class . '::' . $traceItem['function'], $result); + + if (PHP_VERSION_ID >= 80400) { + $this->assertMatchesRegularExpression( + '/\{closure\}\s+' . preg_quote(self::class, '/') . '::createMethodClosureException\(\):\d+/', + $result, + ); + return; + } + + $this->assertStringContainsString(self::class . '::{closure}', $result); + } + + public function testRenderCallStackWithBoundClosure(): void + { + $renderer = new HtmlRenderer(); + $exception = $this->createBoundClosureException(); + $traceItem = $exception->getTrace()[0]; + + $this->assertSame('Closure', $traceItem['class']); + $this->assertStringContainsString('{closure', $traceItem['function']); + + $result = $renderer->renderCallStack($exception, $exception->getTrace()); + $itemResult = $this->invokeMethod($renderer, 'renderCallStackItem', [ + $traceItem['file'] ?? null, + $traceItem['line'] ?? null, + $traceItem['class'] ?? null, + $traceItem['function'] ?? null, + $traceItem['args'] ?? [], + 2, + false, + [], + ]); + + $this->assertStringContainsString('{closure}', $result); + $this->assertStringContainsString('{closure}', $itemResult); + $this->assertStringNotContainsString('Closure::{closure}', $itemResult); + + if (PHP_VERSION_ID >= 80400) { + $this->assertMatchesRegularExpression('/\{closure\}\s+.+::createBoundClosureException\(\):\d+/', $result); + return; + } + + $this->assertStringContainsString('Yiisoft\\ErrorHandler\\Tests\\Renderer\\{closure}', $itemResult); + } + + public function testRenderCallStackWithInternalFunctionClosure(): void + { + $renderer = new HtmlRenderer(); + $exception = $this->createInternalFunctionClosureException(); + $traceItem = $exception->getTrace()[0]; + + $this->assertArrayNotHasKey('file', $traceItem); + $this->assertArrayNotHasKey('line', $traceItem); + $this->assertSame(self::class, $traceItem['class']); + $this->assertStringContainsString('{closure', $traceItem['function']); + + $result = $renderer->renderCallStack($exception, $exception->getTrace()); + $itemResult = $this->invokeMethod($renderer, 'renderCallStackItem', [ + $traceItem['file'] ?? null, + $traceItem['line'] ?? null, + $traceItem['class'] ?? null, + $traceItem['function'] ?? null, + $traceItem['args'] ?? [], + 2, + false, + [], + ]); + + $this->assertStringContainsString('{closure}', $result); + $this->assertStringNotContainsString(self::class . '::' . $traceItem['function'], $result); + $this->assertStringContainsString('{closure}', $itemResult); + $this->assertStringNotContainsString('element-code-wrap', $itemResult); + + if (PHP_VERSION_ID >= 80400) { + $this->assertMatchesRegularExpression( + '/\{closure\}\s+' . preg_quote(self::class, '/') . '::createInternalFunctionClosureException\(\):\d+/', + $result, + ); + return; + } + + $this->assertStringContainsString(self::class . '::{closure}', $result); + } + + public function testRenderCallStackWithNamespacedClosureOnPhpBelow84(): void + { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped('PHP < 8.4 specific behavior.'); + } + + $renderer = new HtmlRenderer(); + $exception = NamespacedClosureTraceFixture::createException(); + $traceItem = $exception->getTrace()[0]; + + $this->assertSame(NamespacedClosureTraceFixture::class, $traceItem['class']); + $this->assertSame('Yiisoft\\ErrorHandler\\Tests\\Support\\{closure}', $traceItem['function']); + + $result = $renderer->renderCallStack($exception, $exception->getTrace()); + + $this->assertStringContainsString(NamespacedClosureTraceFixture::class . '::{closure}', $result); + $this->assertStringNotContainsString( + NamespacedClosureTraceFixture::class . '::' . $traceItem['function'], + $result, + ); + } + + public function testRenderCallStackWithFileLevelClosureOnPhp84Plus(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('PHP 8.4+ specific behavior.'); + } + + $renderer = new HtmlRenderer(); + $exception = loadFileLevelClosureException(); + $traceItem = $exception->getTrace()[0]; + + $this->assertArrayNotHasKey('class', $traceItem); + $this->assertStringContainsString('{closure:', $traceItem['function']); + + $result = $renderer->renderCallStack($exception, $exception->getTrace()); + + $this->assertMatchesRegularExpression( + '#\{closure\}\s+.+/tests/Support/file_level_closure_exception\.php:\d+#', + $result, + ); + } + private function createServerRequestMock(): ServerRequestInterface { $serverRequestMock = $this->createMock(ServerRequestInterface::class); @@ -640,6 +813,57 @@ private function createServerRequestMock(): ServerRequestInterface return $serverRequestMock; } + private function createMethodClosureException(): RuntimeException + { + $closure = function (): void { + throw new RuntimeException('test'); + }; + + try { + $closure(); + } catch (RuntimeException $e) { + return $e; + } + + $this->fail('Expected exception from method closure.'); + throw new RuntimeException('Unreachable.'); + } + + private function createInternalFunctionClosureException(): RuntimeException + { + $closure = function (int $value): void { + throw new RuntimeException((string) $value); + }; + + try { + array_map($closure, [1]); + } catch (RuntimeException $e) { + return $e; + } + + $this->fail('Expected exception from closure called via internal function.'); + throw new RuntimeException('Unreachable.'); + } + + private function createBoundClosureException(): RuntimeException + { + $closure = function (): void { + throw new RuntimeException('test'); + }; + + $boundClosure = Closure::bind($closure, new stdClass(), null); + $this->assertInstanceOf(Closure::class, $boundClosure); + + try { + $boundClosure(); + } catch (RuntimeException $e) { + return $e; + } + + $this->fail('Expected exception from Closure scope closure.'); + throw new RuntimeException('Unreachable.'); + } + private function createTestTemplate(string $path, string $templateContents): void { if (!file_put_contents($path, $templateContents)) { @@ -647,6 +871,24 @@ private function createTestTemplate(string $path, string $templateContents): voi } } + private function createCustomSetting(): array + { + $verboseTemplate = tempnam(sys_get_temp_dir(), 'verbose-template-'); + $template = tempnam(sys_get_temp_dir(), 'template-'); + + if ($verboseTemplate === false || $template === false) { + throw new RuntimeException('Unable to create temporary template paths.'); + } + + $this->temporaryFiles[] = $verboseTemplate; + $this->temporaryFiles[] = $template; + + return [ + 'verboseTemplate' => $verboseTemplate, + 'template' => $template, + ]; + } + private function invokeMethod(object $object, string $method, array $args = []) { $reflection = new ReflectionObject($object); diff --git a/tests/Support/FileLevelClosureLoader.php b/tests/Support/FileLevelClosureLoader.php new file mode 100644 index 0000000..c13e595 --- /dev/null +++ b/tests/Support/FileLevelClosureLoader.php @@ -0,0 +1,15 @@ + Date: Fri, 27 Mar 2026 23:24:31 +0600 Subject: [PATCH 2/5] Add unit tests for `formatTraceFunctionName()` --- tests/Renderer/HtmlRendererTest.php | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 11ddd5f..32b9ac4 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -631,6 +631,47 @@ public function testTraceLinkGenerator( $this->assertSame($expected, $link); } + public static function dataFormatTraceFunctionName(): iterable + { + yield 'regular function without class' => [ + null, 'array_map', 'array_map', + ]; + yield 'regular method' => [ + 'Foo', 'bar', 'Foo::bar', + ]; + yield 'old closure without class' => [ + null, '{closure}', '{closure}', + ]; + yield 'old closure with class' => [ + 'Foo', '{closure}', 'Foo::{closure}', + ]; + yield 'bound closure' => [ + 'Closure', '{closure}', '{closure}', + ]; + yield 'namespaced closure with class' => [ + 'Yiisoft\\Yii\\Gii\\Gii', 'Yiisoft\\Yii\\Gii\\{closure}', 'Yiisoft\\Yii\\Gii\\Gii::{closure}', + ]; + yield 'namespaced closure without class' => [ + null, 'Yiisoft\\Yii\\Gii\\{closure}', 'Yiisoft\\Yii\\Gii\\{closure}', + ]; + yield 'php84 closure in method' => [ + 'Foo', '{closure:Foo::bar():4}', '{closure} Foo::bar():4', + ]; + yield 'php84 closure in file' => [ + null, '{closure:/app/src/index.php:12}', '{closure} /app/src/index.php:12', + ]; + yield 'php84 nested closure' => [ + null, '{closure:{closure:/app/index.php:5}:8}', '{closure} {closure:/app/index.php:5}:8', + ]; + } + + #[DataProvider('dataFormatTraceFunctionName')] + public function testFormatTraceFunctionName(?string $class, string $function, string $expected): void + { + $renderer = new HtmlRenderer(); + $this->assertSame($expected, $renderer->formatTraceFunctionName($class, $function)); + } + public function testRenderCallStackWithMethodClosure(): void { $renderer = new HtmlRenderer(); From f3418fd20223eca99a35eaee050194a78c402d7c Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Fri, 27 Mar 2026 23:29:49 +0600 Subject: [PATCH 3/5] Fix regex in file-level closure test for Windows paths --- tests/Renderer/HtmlRendererTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 32b9ac4..cce20c0 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -809,7 +809,7 @@ public function testRenderCallStackWithFileLevelClosureOnPhp84Plus(): void $result = $renderer->renderCallStack($exception, $exception->getTrace()); $this->assertMatchesRegularExpression( - '#\{closure\}\s+.+/tests/Support/file_level_closure_exception\.php:\d+#', + '#\{closure\}\s+.+[/\\\\]tests[/\\\\]Support[/\\\\]file_level_closure_exception\.php:\d+#', $result, ); } From 323604af3617216007c9ed4529a433ac7d062a50 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Fri, 27 Mar 2026 23:32:56 +0600 Subject: [PATCH 4/5] Update changelog prefix to Bug --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a829ba9..bb4fe13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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) -- Enh #172: Improve closure rendering in stack traces and keep items when source is unavailable (@WarLikeLaux) +- Bug #172: Fix closure rendering in stack traces and keep items when source is unavailable (@WarLikeLaux) ## 4.3.2 January 09, 2026 From cbb1aa4790b3c0a971f92afdf5c74907d38f8b07 Mon Sep 17 00:00:00 2001 From: WarLikeLaux Date: Sun, 29 Mar 2026 10:33:21 +0600 Subject: [PATCH 5/5] Fix #172: Address review comments --- CHANGELOG.md | 3 +- src/Renderer/HtmlRenderer.php | 15 ++++--- tests/Renderer/HtmlRendererTest.php | 41 +++++++++++-------- .../Support/NamespacedClosureTraceFixture.php | 2 +- .../Support/file_level_closure_exception.php | 2 +- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4fe13..8435377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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) -- Bug #172: Fix closure rendering in stack traces and keep items when source is unavailable (@WarLikeLaux) +- 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 diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index db589c9..8f9eb36 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -768,12 +768,17 @@ 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) { - $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 { + if ($line < 0 || $lines === false) { $lines = []; + } 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 = []; + } } } diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index cce20c0..60de51e 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -452,6 +452,25 @@ public function testRenderCallStackItemDoesNotRenderSourceCodeWhenLineIsOutsideF $this->assertStringNotContainsString('element-code-wrap', $result); } + public function testRenderCallStackItemRendersSourceCodeForLastLineInFile(): void + { + $line = count(file(__FILE__)); + $result = $this->invokeMethod(new HtmlRenderer(), 'renderCallStackItem', [ + 'file' => __FILE__, + 'line' => $line, + 'class' => null, + 'function' => null, + 'args' => [], + 'index' => 1, + 'isVendorFile' => false, + 'reflectionParameters' => [], + ]); + + $this->assertStringContainsString(__FILE__, $result); + $this->assertStringContainsString('at line ' . $line, $result); + $this->assertStringContainsString('element-code-wrap', $result); + } + public function testRenderRequest(): void { $renderer = new HtmlRenderer(); @@ -669,7 +688,7 @@ public static function dataFormatTraceFunctionName(): iterable public function testFormatTraceFunctionName(?string $class, string $function, string $expected): void { $renderer = new HtmlRenderer(); - $this->assertSame($expected, $renderer->formatTraceFunctionName($class, $function)); + $this->assertSame($expected, $this->invokeMethod($renderer, 'formatTraceFunctionName', [$class, $function])); } public function testRenderCallStackWithMethodClosure(): void @@ -685,9 +704,6 @@ public function testRenderCallStackWithMethodClosure(): void $result = $renderer->renderCallStack($exception, $exception->getTrace()); - $this->assertStringContainsString('{closure}', $result); - $this->assertStringNotContainsString(self::class . '::' . $traceItem['function'], $result); - if (PHP_VERSION_ID >= 80400) { $this->assertMatchesRegularExpression( '/\{closure\}\s+' . preg_quote(self::class, '/') . '::createMethodClosureException\(\):\d+/', @@ -720,10 +736,6 @@ public function testRenderCallStackWithBoundClosure(): void [], ]); - $this->assertStringContainsString('{closure}', $result); - $this->assertStringContainsString('{closure}', $itemResult); - $this->assertStringNotContainsString('Closure::{closure}', $itemResult); - if (PHP_VERSION_ID >= 80400) { $this->assertMatchesRegularExpression('/\{closure\}\s+.+::createBoundClosureException\(\):\d+/', $result); return; @@ -755,9 +767,6 @@ public function testRenderCallStackWithInternalFunctionClosure(): void [], ]); - $this->assertStringContainsString('{closure}', $result); - $this->assertStringNotContainsString(self::class . '::' . $traceItem['function'], $result); - $this->assertStringContainsString('{closure}', $itemResult); $this->assertStringNotContainsString('element-code-wrap', $itemResult); if (PHP_VERSION_ID >= 80400) { @@ -804,7 +813,6 @@ public function testRenderCallStackWithFileLevelClosureOnPhp84Plus(): void $traceItem = $exception->getTrace()[0]; $this->assertArrayNotHasKey('class', $traceItem); - $this->assertStringContainsString('{closure:', $traceItem['function']); $result = $renderer->renderCallStack($exception, $exception->getTrace()); @@ -866,8 +874,7 @@ private function createMethodClosureException(): RuntimeException return $e; } - $this->fail('Expected exception from method closure.'); - throw new RuntimeException('Unreachable.'); + $this->fail('Method closure did not throw RuntimeException.'); } private function createInternalFunctionClosureException(): RuntimeException @@ -882,8 +889,7 @@ private function createInternalFunctionClosureException(): RuntimeException return $e; } - $this->fail('Expected exception from closure called via internal function.'); - throw new RuntimeException('Unreachable.'); + $this->fail('Closure called via internal function did not throw RuntimeException.'); } private function createBoundClosureException(): RuntimeException @@ -901,8 +907,7 @@ private function createBoundClosureException(): RuntimeException return $e; } - $this->fail('Expected exception from Closure scope closure.'); - throw new RuntimeException('Unreachable.'); + $this->fail('Bound closure did not throw RuntimeException.'); } private function createTestTemplate(string $path, string $templateContents): void diff --git a/tests/Support/NamespacedClosureTraceFixture.php b/tests/Support/NamespacedClosureTraceFixture.php index 8a62b48..8844a73 100644 --- a/tests/Support/NamespacedClosureTraceFixture.php +++ b/tests/Support/NamespacedClosureTraceFixture.php @@ -20,6 +20,6 @@ public static function createException(): RuntimeException return $e; } - throw new RuntimeException('Unreachable.'); + throw new RuntimeException('Namespaced closure did not throw RuntimeException.'); } } diff --git a/tests/Support/file_level_closure_exception.php b/tests/Support/file_level_closure_exception.php index 1bdb002..6133009 100644 --- a/tests/Support/file_level_closure_exception.php +++ b/tests/Support/file_level_closure_exception.php @@ -12,4 +12,4 @@ return $e; } -throw new RuntimeException('Unreachable.'); +throw new RuntimeException('File-level closure did not throw RuntimeException.');