Skip to content

Commit 5c7a7aa

Browse files
committed
feat: add custom exception page
1 parent a7b3cbc commit 5c7a7aa

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2966
-53
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"Tempest\\Cryptography\\Tests\\": "packages/cryptography/tests",
213213
"Tempest\\Database\\Tests\\": "packages/database/tests",
214214
"Tempest\\DateTime\\Tests\\": "packages/datetime/tests",
215+
"Tempest\\Debug\\Tests\\": "packages/debug/tests",
215216
"Tempest\\EventBus\\Tests\\": "packages/event-bus/tests",
216217
"Tempest\\Generation\\Tests\\": "packages/generation/tests",
217218
"Tempest\\HttpClient\\Tests\\": "packages/http-client/tests",

packages/core/src/FrameworkKernel.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public static function boot(
4949
?string $internalStorage = null,
5050
): self {
5151
if (! defined('TEMPEST_START')) {
52-
define('TEMPEST_START', value: hrtime(true));
52+
define('TEMPEST_START', value: hrtime(as_number: true));
5353
}
5454

5555
return new self(

packages/debug/composer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,10 @@
1818
"files": [
1919
"src/functions.php"
2020
]
21+
},
22+
"autoload-dev": {
23+
"psr-4": {
24+
"Tempest\\Debug\\Tests\\": "tests"
25+
}
2126
}
2227
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Tempest\Debug\Stacktrace;
4+
5+
use Tempest\Support\Json;
6+
7+
final readonly class Argument
8+
{
9+
public function __construct(
10+
public string|int $name,
11+
public string $compact,
12+
public ?string $json,
13+
) {}
14+
15+
public static function make(string|int $name, mixed $value): self
16+
{
17+
return new self(
18+
name: $name,
19+
compact: self::serializeToCompactString($value),
20+
json: self::serialize($value),
21+
);
22+
}
23+
24+
private static function serializeToCompactString(mixed $value): string
25+
{
26+
return match (true) {
27+
is_null($value) => 'null',
28+
is_bool($value) => sprintf('bool<%s>', $value ? 'true' : 'false'),
29+
is_int($value) => (string) $value,
30+
is_float($value) => (string) $value,
31+
is_string($value) => mb_strlen($value) > 50
32+
? sprintf('string<%s>', mb_strlen($value))
33+
: sprintf('"%s"', $value),
34+
is_array($value) => sprintf('array<%s>', count($value)),
35+
is_object($value) => sprintf('object<%s>', $value::class),
36+
is_resource($value) => 'resource',
37+
default => get_debug_type($value),
38+
};
39+
}
40+
41+
private static function serialize(mixed $value): ?string
42+
{
43+
$serialized = Json\encode($value, pretty: true);
44+
45+
if ($serialized === '{}') {
46+
return null;
47+
}
48+
49+
return $serialized;
50+
}
51+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Debug\Stacktrace;
6+
7+
final readonly class CodeSnippet
8+
{
9+
/**
10+
* @param array<int,string> $lines
11+
*/
12+
public function __construct(
13+
public array $lines,
14+
public int $highlightedLine,
15+
) {}
16+
17+
public function getStartLine(): int
18+
{
19+
return array_key_first($this->lines) ?? 0;
20+
}
21+
22+
public function getEndLine(): int
23+
{
24+
return array_key_last($this->lines) ?? 0;
25+
}
26+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Debug\Stacktrace;
6+
7+
use ReflectionFunction;
8+
use ReflectionMethod;
9+
use ReflectionParameter;
10+
11+
use function Tempest\Support\Path\to_relative_path;
12+
13+
final class Frame
14+
{
15+
/**
16+
* @param array<Argument> $arguments
17+
*/
18+
public function __construct(
19+
private(set) string $file,
20+
private(set) int $line,
21+
private(set) ?string $class,
22+
private(set) ?string $function,
23+
private(set) ?string $type,
24+
private(set) bool $isVendor,
25+
private(set) ?CodeSnippet $snippet,
26+
private(set) string $absoluteFile,
27+
private(set) string $relativeFile,
28+
private(set) array $arguments,
29+
private(set) int $index,
30+
) {}
31+
32+
public static function fromArray(array $frame, int $contextLines = 5, ?string $rootPath = null, int $index = 1): self
33+
{
34+
$absoluteFile = $frame['file'] ?? '';
35+
$line = $frame['line'] ?? 0;
36+
$isVendor = self::isVendorFile($absoluteFile, $rootPath);
37+
$snippet = null;
38+
39+
if ($absoluteFile && $line && ! $isVendor && file_exists($absoluteFile)) {
40+
$snippet = self::extractCodeSnippet($absoluteFile, $line, $contextLines);
41+
}
42+
43+
return new self(
44+
file: $absoluteFile,
45+
line: $line,
46+
class: $frame['class'] ?? null,
47+
function: $frame['function'] ?? null,
48+
type: $frame['type'] ?? null,
49+
isVendor: $isVendor,
50+
snippet: $snippet,
51+
absoluteFile: $absoluteFile,
52+
relativeFile: $rootPath ? to_relative_path($rootPath, $absoluteFile) : $absoluteFile,
53+
arguments: self::extractArguments($frame),
54+
index: $index,
55+
);
56+
}
57+
58+
/**
59+
* @return array<Argument>
60+
*/
61+
public static function extractArguments(array $frame): array
62+
{
63+
if (! isset($frame['args']) || ! is_array($frame['args'])) {
64+
return [];
65+
}
66+
67+
$arguments = $frame['args'];
68+
$parameterNames = [];
69+
70+
try {
71+
$reflection = isset($frame['class'], $frame['function'])
72+
? new ReflectionMethod(objectOrMethod: $frame['class'], method: $frame['function'])
73+
: new ReflectionFunction(function: $frame['function']);
74+
75+
$parameterNames = array_map(
76+
callback: fn (ReflectionParameter $param) => $param->getName(),
77+
array: $reflection->getParameters(),
78+
);
79+
} catch (\Throwable) {
80+
// @mago-expect lint:no-empty-catch-clause
81+
}
82+
83+
$result = [];
84+
foreach ($arguments as $index => $value) {
85+
$result[] = Argument::make(
86+
name: $parameterNames[$index] ?? $index,
87+
value: $value,
88+
);
89+
}
90+
91+
return $result;
92+
}
93+
94+
public static function isVendorFile(string $file, ?string $rootPath = null): bool
95+
{
96+
if ($file === '') {
97+
return false;
98+
}
99+
100+
if ($rootPath !== null) {
101+
return ! str_starts_with(
102+
haystack: str_replace('\\', '/', $file),
103+
needle: str_replace('\\', '/', $rootPath),
104+
);
105+
}
106+
107+
return str_contains($file, '/vendor/') || str_contains($file, '\\vendor\\');
108+
}
109+
110+
public static function extractCodeSnippet(string $file, int $line, int $contextLines): ?CodeSnippet
111+
{
112+
$fileLines = file($file, FILE_IGNORE_NEW_LINES);
113+
114+
if ($fileLines === false) {
115+
return null;
116+
}
117+
118+
$startLine = max(1, $line - $contextLines);
119+
$endLine = min(count($fileLines), $line + $contextLines);
120+
$lines = [];
121+
122+
for ($i = $startLine; $i <= $endLine; $i++) {
123+
$lines[$i] = $fileLines[$i - 1];
124+
}
125+
126+
if ($lines === []) {
127+
return null;
128+
}
129+
130+
return new CodeSnippet(
131+
lines: $lines,
132+
highlightedLine: $line,
133+
);
134+
}
135+
136+
public function getMethodName(): string
137+
{
138+
if (! $this->class) {
139+
return $this->function ?? '';
140+
}
141+
142+
$type = match ($this->type) {
143+
'::' => '::',
144+
'->' => '->',
145+
default => '',
146+
};
147+
148+
return $this->class . $type . ($this->function ?? '');
149+
}
150+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Debug\Stacktrace;
6+
7+
use Throwable;
8+
9+
use function Tempest\Support\Path\to_relative_path;
10+
11+
final class Stacktrace
12+
{
13+
/**
14+
* @param array<int, Frame> $frames
15+
*/
16+
public array $applicationFrames {
17+
get => array_values(array_filter(
18+
array: $this->frames,
19+
callback: fn (Frame $frame) => ! $frame->isVendor,
20+
));
21+
}
22+
23+
/**
24+
* @param array<int, Frame> $frames
25+
*/
26+
public array $vendorFrames {
27+
get => array_values(array_filter(
28+
array: $this->frames,
29+
callback: fn (Frame $frame) => $frame->isVendor,
30+
));
31+
}
32+
33+
/**
34+
* @param array<int, Frame> $frames
35+
*/
36+
public function __construct(
37+
private(set) string $message,
38+
private(set) string $exceptionClass,
39+
private(set) array $frames,
40+
private(set) string $file,
41+
private(set) int $line,
42+
private(set) string $absoluteFile,
43+
private(set) string $relativeFile,
44+
) {}
45+
46+
public static function fromThrowable(Throwable $throwable, int $contextLines = 5, ?string $rootPath = null): self
47+
{
48+
$frames = [];
49+
$trace = $throwable->getTrace();
50+
$firstTraceFrame = $trace[0] ?? null;
51+
$snippet = null;
52+
53+
$exceptionFile = $throwable->getFile();
54+
$exceptionLine = $throwable->getLine();
55+
$isVendor = Frame::isVendorFile($exceptionFile, $rootPath);
56+
57+
if ($exceptionFile && $exceptionLine && ! $isVendor && file_exists($exceptionFile)) {
58+
$snippet = Frame::extractCodeSnippet($exceptionFile, $exceptionLine, $contextLines);
59+
}
60+
61+
$absoluteExceptionFile = $exceptionFile;
62+
$relativeExceptionFile = $rootPath ? to_relative_path($rootPath, $exceptionFile) : $exceptionFile;
63+
$arguments = $firstTraceFrame ? Frame::extractArguments($firstTraceFrame) : [];
64+
65+
$frames[] = new Frame(
66+
file: $exceptionFile,
67+
line: $exceptionLine,
68+
class: $firstTraceFrame['class'] ?? null,
69+
function: $firstTraceFrame['function'] ?? null,
70+
type: $firstTraceFrame['type'] ?? null,
71+
isVendor: $isVendor,
72+
snippet: $snippet,
73+
absoluteFile: $absoluteExceptionFile,
74+
relativeFile: $relativeExceptionFile,
75+
arguments: $arguments,
76+
index: 1,
77+
);
78+
79+
foreach (array_slice($trace, 1) as $i => $frame) {
80+
$frames[] = Frame::fromArray($frame, $contextLines, $rootPath, $i + 2);
81+
}
82+
83+
$absoluteFile = $throwable->getFile();
84+
$relativeFile = $rootPath ? to_relative_path($rootPath, $absoluteFile) : $absoluteFile;
85+
86+
return new self(
87+
message: $throwable->getMessage(),
88+
exceptionClass: $throwable::class,
89+
frames: $frames,
90+
file: $throwable->getFile(),
91+
line: $throwable->getLine(),
92+
absoluteFile: $absoluteFile,
93+
relativeFile: $relativeFile,
94+
);
95+
}
96+
}

0 commit comments

Comments
 (0)