Skip to content

Commit 0820bab

Browse files
authored
Render exception class PHPDoc description in HTML debug output (#167)
1 parent 32c8a4e commit 0820bab

14 files changed

+560
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 5.0.0 under development
44

5+
- Enh #104: Render exception class PHPDoc description with safe markdown links in HTML debug output (@dbuhonov)
56
- Chg #162: Replace deprecated `ThrowableResponseFactory` class usage to new one, and remove it (@vjik)
67
- Enh #163: Explicitly import classes, functions, and constants in "use" section (@mspirkov)
78
- Bug #164: Fix missing items in stack trace HTML output when handling a PHP error (@vjik)

src/Renderer/HtmlRenderer.php

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
1717
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
1818
use Yiisoft\Http\Header;
19+
use ReflectionClass;
1920
use ReflectionException;
2021
use ReflectionFunction;
2122
use ReflectionMethod;
@@ -45,15 +46,22 @@
4546
use function ob_implicit_flush;
4647
use function ob_start;
4748
use function realpath;
49+
use function preg_match;
50+
use function preg_replace;
51+
use function preg_replace_callback;
52+
use function preg_split;
4853
use function str_replace;
54+
use function str_starts_with;
4955
use function stripos;
5056
use function strlen;
5157
use function count;
5258
use function function_exists;
59+
use function trim;
5360

5461
use const DIRECTORY_SEPARATOR;
5562
use const ENT_QUOTES;
5663
use const EXTR_OVERWRITE;
64+
use const PREG_SPLIT_DELIM_CAPTURE;
5765

5866
/**
5967
* Formats throwable into HTML string.
@@ -204,10 +212,29 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E
204212

205213
public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData
206214
{
215+
$solution = null;
216+
$exceptionDescription = null;
217+
$displayThrowable = $t;
218+
219+
if ($t instanceof CompositeException) {
220+
$displayThrowable = $t->getFirstException();
221+
}
222+
223+
if ($displayThrowable instanceof FriendlyExceptionInterface) {
224+
$solution = $displayThrowable->getSolution();
225+
} else {
226+
$exceptionDescription = $this->getThrowableDescription($displayThrowable);
227+
}
228+
207229
return new ErrorData(
208230
$this->renderTemplate($this->verboseTemplate, [
209231
'request' => $request,
210232
'throwable' => $t,
233+
'displayThrowable' => $displayThrowable,
234+
'solution' => $solution,
235+
'exceptionClass' => $displayThrowable::class,
236+
'exceptionMessage' => $displayThrowable->getMessage(),
237+
'exceptionDescription' => $exceptionDescription,
211238
]),
212239
[Header::CONTENT_TYPE => self::CONTENT_TYPE],
213240
);
@@ -541,6 +568,111 @@ public function removeAnonymous(string $value): string
541568
return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value;
542569
}
543570

571+
/**
572+
* Extracts a user-facing description from throwable class PHPDoc.
573+
*
574+
* Takes only descriptive text before block tags, normalizes unsafe markup
575+
* into safe markdown/plain text and converts it into an HTML fragment
576+
* suitable for direct inclusion in the error template.
577+
* Inline {@see ...}/{@link ...} annotations are rendered as markdown links.
578+
*
579+
* The returned value is an HTML snippet (for example, containing <p>, <a>,
580+
* <code> elements) and is intended to be inserted into the template as-is,
581+
* without additional HTML-escaping.
582+
*
583+
* @return string|null HTML fragment describing the throwable, or null if no description is available.
584+
*/
585+
private function getThrowableDescription(Throwable $throwable): ?string
586+
{
587+
$docComment = (new ReflectionClass($throwable))->getDocComment();
588+
if ($docComment === false) {
589+
return null;
590+
}
591+
592+
$descriptionLines = [];
593+
foreach (preg_split('/\R/', $docComment) ?: [] as $line) {
594+
$line = trim($line);
595+
$line = preg_replace(
596+
['/^\/\*\*?/', '/\*\/$/', '/^\*\s?/'],
597+
'',
598+
$line,
599+
) ?? $line;
600+
$line = trim($line);
601+
602+
if ($line !== '' && str_starts_with($line, '@')) {
603+
break;
604+
}
605+
606+
$descriptionLines[] = $line;
607+
}
608+
609+
$description = trim(implode("\n", $descriptionLines));
610+
if ($description === '') {
611+
return null;
612+
}
613+
614+
$description = preg_replace_callback(
615+
'/\{@(?:see|link)\s+(?<target>[^\s}]+)(?:\s+(?<label>[^}]+))?}/i',
616+
static function (array $matches): string {
617+
$target = $matches['target'];
618+
$label = trim($matches['label'] ?? '');
619+
620+
if (preg_match('/^https?:\/\//i', $target) === 1) {
621+
$text = $label !== '' ? $label : $target;
622+
return '[' . $text . '](' . $target . ')';
623+
}
624+
625+
if ($label !== '') {
626+
return $label . ' (`' . $target . '`)';
627+
}
628+
629+
return '`' . $target . '`';
630+
},
631+
$description,
632+
) ?? $description;
633+
634+
$tokenPattern = '/^(?:`(?<code>[^`]+)`|(?<image>!)?\[(?<label>[^\]]+)]\((?<target>[^)]+)\))$/';
635+
$parts = preg_split(
636+
'/(!?\[[^]]+]\([^)]+\)|`[^`]+`)/',
637+
$description,
638+
-1,
639+
PREG_SPLIT_DELIM_CAPTURE,
640+
) ?: [];
641+
642+
$normalized = [];
643+
644+
foreach ($parts as $part) {
645+
if ($part === '') {
646+
continue;
647+
}
648+
649+
if (preg_match($tokenPattern, $part, $matches) !== 1) {
650+
$normalized[] = $this->htmlEncode($part);
651+
continue;
652+
}
653+
654+
if (($matches['code'] ?? '') !== '') {
655+
$normalized[] = '<code>' . $this->htmlEncode($matches['code']) . '</code>';
656+
continue;
657+
}
658+
659+
$label = $this->htmlEncode($matches['label']);
660+
$target = $matches['target'];
661+
$imageMarker = $matches['image'] ?? '';
662+
663+
if ($imageMarker === '' && preg_match('/^https?:\/\//i', $target) === 1) {
664+
$normalized[] = '[' . $label . '](' . $target . ')';
665+
continue;
666+
}
667+
668+
$normalized[] = $imageMarker . $label . ' (<code>' . $this->htmlEncode($target) . '</code>)';
669+
}
670+
671+
$normalized = trim(implode('', $normalized));
672+
673+
return $this->parseMarkdown($normalized);
674+
}
675+
544676
/**
545677
* Renders a template.
546678
*

templates/development.php

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
11
<?php
22

33
use Psr\Http\Message\ServerRequestInterface;
4-
use Yiisoft\ErrorHandler\CompositeException;
54
use Yiisoft\ErrorHandler\Exception\ErrorException;
65
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
76
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
87

98
/**
109
* @var ServerRequestInterface|null $request
1110
* @var Throwable $throwable
11+
* @var Throwable $displayThrowable
12+
* @var string|null $solution
13+
* @var string $exceptionClass
14+
* @var string $exceptionMessage
15+
* @var string|null $exceptionDescription
1216
*/
1317

1418
$theme = $_COOKIE['yii-exception-theme'] ?? '';
1519

16-
$originalException = $throwable;
17-
if ($throwable instanceof CompositeException) {
18-
$throwable = $throwable->getFirstException();
19-
}
20-
$solution = $throwable instanceof FriendlyExceptionInterface ? $throwable->getSolution() : null;
21-
$exceptionClass = get_class($throwable);
22-
$exceptionMessage = $throwable->getMessage();
23-
2420
/**
2521
* @var HtmlRenderer $this
2622
*/
@@ -32,7 +28,7 @@
3228
<meta charset="utf-8">
3329
<meta name="viewport" content="width=device-width, initial-scale=1">
3430
<title>
35-
<?= $this->htmlEncode($this->getThrowableName($throwable)) ?>
31+
<?= $this->htmlEncode($this->getThrowableName($displayThrowable)) ?>
3632
</title>
3733
<style>
3834
<?= file_get_contents(__DIR__ . '/development.css') ?>
@@ -79,25 +75,29 @@
7975
<div class="exception-card">
8076
<div class="exception-class">
8177
<?php
82-
if ($throwable instanceof FriendlyExceptionInterface): ?>
83-
<span><?= $this->htmlEncode($throwable->getName())?></span>
78+
if ($displayThrowable instanceof FriendlyExceptionInterface): ?>
79+
<span><?= $this->htmlEncode($displayThrowable->getName())?></span>
8480
&mdash;
8581
<?= $exceptionClass ?>
8682
<?php else: ?>
8783
<span><?= $exceptionClass ?></span>
8884
<?php endif ?>
89-
(Code #<?= $throwable->getCode() ?>)
85+
(Code #<?= $displayThrowable->getCode() ?>)
9086
</div>
9187

9288
<div class="exception-message">
9389
<?= nl2br($this->htmlEncode($exceptionMessage)) ?>
9490
</div>
9591

92+
<?php if ($exceptionDescription !== null): ?>
93+
<div class="exception-description solution"><?= $exceptionDescription ?></div>
94+
<?php endif ?>
95+
9696
<?php if ($solution !== null): ?>
9797
<div class="solution"><?= $this->parseMarkdown($solution) ?></div>
9898
<?php endif ?>
9999

100-
<?= $this->renderPreviousExceptions($originalException) ?>
100+
<?= $this->renderPreviousExceptions($throwable) ?>
101101

102102
<textarea id="clipboard"><?= $this->htmlEncode((string) $throwable) ?></textarea>
103103
<span id="copied">Copied!</span>
@@ -117,10 +117,10 @@ class="copy-clipboard"
117117
<main>
118118
<div class="call-stack">
119119
<?= $this->renderCallStack(
120-
$throwable,
121-
$originalException === $throwable && $originalException instanceof ErrorException
122-
? $originalException->getBacktrace()
123-
: $throwable->getTrace()
120+
$displayThrowable,
121+
$displayThrowable === $throwable && $throwable instanceof ErrorException
122+
? $throwable->getBacktrace()
123+
: $displayThrowable->getTrace(),
124124
) ?>
125125
</div>
126126
<?php if ($request && ($requestInfo = $this->renderRequest($request)) !== ''): ?>

0 commit comments

Comments
 (0)