1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Core\ExceptionRenderer;
6:
7: use Atk4\Core\Exception;
8: use Atk4\Core\TraitUtil;
9: use Atk4\Core\TranslatableTrait;
10: use Atk4\Core\Translator\ITranslatorAdapter;
11: use Atk4\Core\Translator\Translator;
12:
13: /**
14: * @phpstan-consistent-constructor
15: */
16: abstract class RendererAbstract
17: {
18: use TranslatableTrait;
19:
20: public \Throwable $exception;
21:
22: public ?\Throwable $parentException;
23:
24: public string $output = '';
25:
26: public ?ITranslatorAdapter $adapter;
27:
28: public function __construct(\Throwable $exception, ITranslatorAdapter $adapter = null, \Throwable $parentException = null)
29: {
30: $this->exception = $exception;
31: $this->parentException = $parentException;
32: $this->adapter = $adapter;
33: }
34:
35: abstract protected function processHeader(): void;
36:
37: abstract protected function processParams(): void;
38:
39: abstract protected function processSolutions(): void;
40:
41: abstract protected function processStackTrace(): void;
42:
43: abstract protected function processStackTraceInternal(): void;
44:
45: abstract protected function processPreviousException(): void;
46:
47: protected function processAll(): void
48: {
49: $this->processHeader();
50: $this->processParams();
51: $this->processSolutions();
52: $this->processStackTrace();
53: $this->processPreviousException();
54: }
55:
56: #[\Override]
57: public function __toString(): string
58: {
59: try {
60: $this->processAll();
61:
62: return $this->output;
63: } catch (\Throwable $e) {
64: // fallback if Exception occurred during rendering
65: return '!! ATK4 CORE ERROR - EXCEPTION RENDER FAILED: '
66: . get_class($this->exception)
67: . ($this->exception->getCode() !== 0 ? '(' . $this->exception->getCode() . ')' : '')
68: . ': ' . $this->exception->getMessage() . ' !!';
69: }
70: }
71:
72: /**
73: * @param array<string, string> $tokens
74: */
75: protected function replaceTokens(string $text, array $tokens): string
76: {
77: return str_replace(array_keys($tokens), array_values($tokens), $text);
78: }
79:
80: /**
81: * @param array<string, mixed> $frame
82: *
83: * @return array<string, mixed>
84: */
85: protected function parseStackTraceFrame(array $frame): array
86: {
87: $parsed = [
88: 'line' => (string) ($frame['line'] ?? ''),
89: 'file' => (string) ($frame['file'] ?? ''),
90: 'class' => $frame['class'] ?? null,
91: 'object' => $frame['object'] ?? null,
92: 'function' => $frame['function'] ?? null,
93: 'args' => $frame['args'] ?? [],
94: 'class_formatted' => null,
95: 'object_formatted' => null,
96: ];
97:
98: try {
99: $parsed['file_rel'] = $this->makeRelativePath($parsed['file']);
100: } catch (Exception $e) {
101: $parsed['file_rel'] = $parsed['file'];
102: }
103:
104: if ($parsed['class'] !== null) {
105: $parsed['class_formatted'] = str_replace("\0", ' ', $this->tryRelativizePathsInString($parsed['class']));
106: }
107:
108: if ($parsed['object'] !== null) {
109: $parsed['object_formatted'] = TraitUtil::hasTrackableTrait($parsed['object'])
110: ? get_object_vars($parsed['object'])['name'] ?? $parsed['object']->shortName
111: : str_replace("\0", ' ', $this->tryRelativizePathsInString(get_class($parsed['object'])));
112: }
113:
114: return $parsed;
115: }
116:
117: /**
118: * @param mixed $val
119: */
120: public static function toSafeString($val, bool $allowNl = false, int $maxDepth = 2): string
121: {
122: if ($val instanceof \Closure) {
123: return 'closure';
124: } elseif (is_object($val)) {
125: return get_class($val) . (TraitUtil::hasTrackableTrait($val) ? ' (' . (get_object_vars($val)['name'] ?? $val->shortName) . ')' : '');
126: } elseif (is_resource($val)) {
127: return 'resource';
128: } elseif (is_scalar($val) || $val === null) {
129: $out = json_encode($val, \JSON_UNESCAPED_SLASHES | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR);
130: $out = preg_replace('~\\\\"~', '"', preg_replace('~^"|"$~s', '\'', $out)); // use single quotes
131: $out = preg_replace('~\\\\{2}~s', '$1', $out); // unescape backslashes
132: if ($allowNl) {
133: $out = preg_replace('~(\\\\r)?\\\\n|\\\\r~s', "\n", $out); // unescape new lines
134: }
135:
136: return $out;
137: }
138:
139: if ($maxDepth === 0) {
140: return '...';
141: }
142:
143: $out = '[';
144: $suppressKeys = array_is_list($val);
145: foreach ($val as $k => $v) {
146: $kSafe = static::toSafeString($k);
147: $vSafe = static::toSafeString($v, $allowNl, $maxDepth - 1);
148:
149: if ($allowNl) {
150: $out .= "\n" . ' ' . ($suppressKeys ? '' : $kSafe . ': ') . preg_replace('~(?<=\n)~', ' ', $vSafe);
151: } else {
152: $out .= ($suppressKeys ? '' : $kSafe . ': ') . $vSafe;
153: }
154:
155: if ($k !== array_key_last($val)) {
156: $out .= $allowNl ? ',' : ', ';
157: }
158: }
159: $out .= ($allowNl && count($val) > 0 ? "\n" : '') . ']';
160:
161: return $out;
162: }
163:
164: protected function getExceptionTitle(): string
165: {
166: return $this->exception instanceof Exception
167: ? $this->exception->getCustomExceptionTitle()
168: : 'Critical Error';
169: }
170:
171: protected function getExceptionMessage(): string
172: {
173: $msg = $this->exception->getMessage();
174: $msg = $this->tryRelativizePathsInString($msg);
175: $msg = $this->_($msg);
176:
177: return $msg;
178: }
179:
180: /**
181: * Returns stack trace and reindex it from the first call. If shortening is allowed,
182: * shorten the stack trace if it starts with the parent one.
183: *
184: * @return array<int|'self', array<string, mixed>>
185: */
186: protected function getStackTrace(bool $shorten): array
187: {
188: $custTraceFx = static function (\Throwable $ex) {
189: $trace = $ex->getTrace();
190:
191: return count($trace) > 0 ? array_combine(range(count($trace) - 1, 0, -1), $trace) : [];
192: };
193:
194: $trace = $custTraceFx($this->exception);
195: $parentTrace = $shorten && $this->parentException !== null ? $custTraceFx($this->parentException) : [];
196:
197: $bothAtk = $this->exception instanceof Exception && $this->parentException instanceof Exception;
198: $c = min(count($trace), count($parentTrace));
199: for ($i = 0; $i < $c; ++$i) {
200: $cv = $this->parseStackTraceFrame($trace[$i]);
201: $pv = $this->parseStackTraceFrame($parentTrace[$i]);
202:
203: if ($cv['line'] === $pv['line']
204: && $cv['file'] === $pv['file']
205: && $cv['class'] === $pv['class']
206: && (!$bothAtk || $cv['object'] === $pv['object'])
207: && $cv['function'] === $pv['function']
208: && (!$bothAtk || $cv['args'] === $pv['args'])
209: ) {
210: unset($trace[$i]);
211: } else {
212: break;
213: }
214: }
215:
216: // display location as another stack trace call
217: $trace = ['self' => [
218: 'line' => $this->exception->getLine(),
219: 'file' => $this->exception->getFile(),
220: ]] + $trace;
221:
222: return $trace;
223: }
224:
225: /**
226: * @param array<string, mixed> $parameters
227: */
228: public function _(string $message, array $parameters = [], string $domain = null, string $locale = null): string
229: {
230: return $this->adapter
231: ? $this->adapter->_($message, $parameters, $domain, $locale)
232: : Translator::instance()->_($message, $parameters, $domain, $locale);
233: }
234:
235: protected function getVendorDirectory(): string
236: {
237: $loaderFile = realpath((new \ReflectionClass(\Composer\Autoload\ClassLoader::class))->getFileName());
238: $coreDir = realpath(dirname(__DIR__, 2) . '/');
239: if (str_starts_with($loaderFile, $coreDir . \DIRECTORY_SEPARATOR)) { // this repo is main project
240: return realpath(dirname($loaderFile, 2) . '/');
241: }
242:
243: return realpath(dirname(__DIR__, 4) . '/');
244: }
245:
246: protected function makeRelativePath(string $path): string
247: {
248: $pathReal = $path === '' ? false : realpath($path);
249: if ($pathReal === false) {
250: throw new Exception('Path not found');
251: }
252:
253: $filePathArr = explode(\DIRECTORY_SEPARATOR, ltrim($pathReal, '/\\'));
254: $vendorRootArr = explode(\DIRECTORY_SEPARATOR, ltrim($this->getVendorDirectory(), '/\\'));
255: if ($filePathArr[0] !== $vendorRootArr[0]) {
256: return implode('/', $filePathArr);
257: }
258:
259: array_pop($vendorRootArr); // assume parent directory as project directory
260: while (isset($filePathArr[0]) && isset($vendorRootArr[0]) && $filePathArr[0] === $vendorRootArr[0]) {
261: array_shift($filePathArr);
262: array_shift($vendorRootArr);
263: }
264:
265: return (count($vendorRootArr) > 0 ? str_repeat('../', count($vendorRootArr)) : '') . implode('/', $filePathArr);
266: }
267:
268: protected function tryRelativizePathsInString(string $str): string
269: {
270: $str = preg_replace_callback('~(?<!\w)(?:[/\\\\]|[a-z]:)\w?+[^:"\',;]*?\.php(?!\w)~i', function ($matches) {
271: try {
272: return $this->makeRelativePath($matches[0]);
273: } catch (Exception $e) {
274: return $matches[0];
275: }
276: }, $str);
277:
278: return $str;
279: }
280: }
281: