Add trait for handling errors in applications

master 3.15.0
Julien Rosset 1 week ago
parent 70a2fb7de4
commit eec4c1f0c1

@ -0,0 +1,361 @@
<?php
namespace jrosset\CliProgram;
use jrosset\MbstringExtended;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
use Throwable;
/**
* Errors handling in an application
*/
trait TApplicationErrorHandling {
/**
* Register the error handler
*
* @param OutputInterface $output The console output
*
* @return void
*/
protected function registerErrorHandling (OutputInterface $output): void {
set_error_handler(
function (int $errorLevel, string $message, string $file, int $line) use ($output): bool {
return $this->handleError($output, $errorLevel, $message, $file, $line);
}
);
set_exception_handler(
function (Throwable $exception) use ($output): void {
$this->handleException($output, $exception);
}
);
}
/**
* Handle an error
*
* @param OutputInterface $output The console output
* @param int $errorLevel The error level
* @param string $message The message
* @param string $file The error file
* @param int $line The error line
*
* @return bool Was the error handled ?
*/
protected function handleError (OutputInterface $output, int $errorLevel, string $message, string $file, int $line): bool {
//region Check if this error level should be handled
if (!(error_reporting() & $errorLevel)) {
return false;
}
//endregion
//region Get the rendering output and tag, based on the error level
switch ($errorLevel) {
case E_ERROR:
case E_CORE_ERROR:
case E_COMPILE_ERROR:
case E_USER_ERROR:
case E_RECOVERABLE_ERROR:
case E_PARSE:
$renderOutput = CliHelper::getErrorOutput($output);
$renderTag = 'error';
break;
case E_WARNING:
case E_CORE_WARNING:
case E_COMPILE_WARNING:
case E_USER_WARNING:
case E_DEPRECATED;
case E_USER_DEPRECATED:
$renderOutput = $output;
$renderTag = 'comment';
break;
case E_NOTICE:
case E_STRICT:
default:
$renderOutput = $output;
$renderTag = null;
break;
}
//endregion
//region Get the "title" for the error level
$title = match ($errorLevel) {
E_ERROR => 'PHP Error',
E_CORE_ERROR => 'PHP Core Error',
E_COMPILE_ERROR => 'PHP Compilation Error',
E_USER_ERROR => 'User Error',
E_RECOVERABLE_ERROR => 'Recoverable Error',
E_PARSE => 'Parse Error',
E_WARNING => 'PHP Warning',
E_CORE_WARNING => 'PHP Core Warning',
E_COMPILE_WARNING => 'PHP Compilation Warning',
E_USER_WARNING => 'User Warning',
E_DEPRECATED => 'PHP Deprecated',
E_USER_DEPRECATED => 'User Deprecated',
E_NOTICE => 'PHP Notice',
E_STRICT => 'PHP Strict Notice',
default => 'Error',
};
//endregion
//region Get the traces
$traces = [];
$firstTraceFound = false;
foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $trace) {
//region Skip the firsts traces, until we find the real file and line
if (!$firstTraceFound) {
$traceFile = $trace['file'] ?? '';
$traceLine = $trace['line'] ?? '';
if ($traceFile === $file && $traceLine === $line) {
$firstTraceFound = true;
}
continue;
}
//endregion
$traces[] = $trace;
}
//region Add the current file and line to the trace
array_unshift($traces, [
'function' => '',
'file' => $file ? : 'n/a',
'line' => $line ? : 'n/a',
'args' => [],
]);
//endregion
//endregion
//region Render the error
$this->renderError(
$renderOutput,
$title,
$message,
$file,
$line,
$traces,
$renderTag
);
//endregion
return true;
}
/**
* Handle an exception
*
* @param OutputInterface $output The console output
* @param Throwable $exception The exception
*
* @return void
*/
protected function handleException (OutputInterface $output, Throwable $exception): void {
do {
$traces = $exception->getTrace();
array_unshift($traces, [
'function' => '',
'file' => $exception->getFile() ? : 'n/a',
'line' => $exception->getLine() ? : 'n/a',
'args' => [],
]);
$message = MbstringExtended::trim($exception->getMessage());
if ($message === '' || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
$class = get_debug_type($exception);
$title = sprintf('%s%s', $class, 0 !== ($code = $exception->getCode()) ? ' (' . $code . ')' : '');
}
else {
$title = '';
}
$this->renderError(
$output,
$title,
$message,
$exception->getFile(),
$exception->getLine(),
$traces
);
} while ($exception = $exception->getPrevious());
}
/**
* Render an error
*
* @param OutputInterface $output The application output
* @param string $title The error title
* @param string $message The error message
* @param string $file The error file
* @param int $line The error line
* @param array{file:string,line:int,class:string,type:string,function:string} $traces The error traces
* @param string|null $renderTag The redering tag, or null for no tag
*
* @return void
*/
public function renderError (
OutputInterface $output,
string $title,
string $message,
string $file,
int $line,
array $traces,
?string $renderTag = 'error'
): void {
$title = MbstringExtended::trim($title);
$message = MbstringExtended::trim($message);
if ($title !== '') {
$title = sprintf(' [%s] ', $title);
$len = Helper::width($title);
}
else {
$len = 0;
}
if (str_contains($message, "@anonymous\0")) {
$message = preg_replace_callback(
'/[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/',
function ($match) {
return class_exists($match[0], false)
? (get_parent_class($match[0])
? : key(class_implements($match[0]))
? : 'class'
) . '@anonymous'
: $match[0];
},
$message
);
}
$width = PHP_INT_MAX;
if (property_exists($this, 'terminal') && $this->terminal instanceof Terminal && $this->terminal->getWidth()) {
$width = $this->terminal->getWidth() - 1;
}
$lines = [];
foreach ($message !== '' ? preg_split('/\r?\n/', $message) : [] as $messageLine) {
foreach ($this->tApplicationErrorHandling__splitStringByWidth($messageLine, $width - 4) as $messageSubline) {
// pre-format lines to get the right string length
$lineLength = Helper::width($messageSubline) + 4;
$lines[] = [$messageSubline, $lineLength];
$len = max($lineLength, $len);
}
}
$messages = [];
if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
$messages[] = sprintf('<comment>%s</comment>', OutputFormatter::escape(sprintf('In %s line %s:', basename($file) ? : 'n/a', $line ? : 'n/a')));
}
$messages[] = $emptyLine = $this->tApplicationErrorHandling__wrapInTag(str_repeat(' ', $len), $renderTag);
if ($title !== '') {
$messages[] = $this->tApplicationErrorHandling__wrapInTag(
sprintf(
'%s%s',
$title,
str_repeat(' ', max(0, $len - Helper::width($title)))
),
$renderTag
);
}
foreach ($lines as $line) {
$messages[] = $this->tApplicationErrorHandling__wrapInTag(
sprintf(
' %s %s',
OutputFormatter::escape($line[0]),
str_repeat(' ', $len - $line[1])
),
$renderTag
);
}
$messages[] = $emptyLine;
$messages[] = '';
$output->writeln($messages, OutputInterface::VERBOSITY_QUIET);
if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
$output->writeln('<comment>Exception trace:</comment>', OutputInterface::VERBOSITY_QUIET);
foreach ($traces as $trace) {
$class = $trace['class'] ?? '';
$type = $trace['type'] ?? '';
$function = $trace['function'] ?? '';
$file = $trace['file'] ?? 'n/a';
$line = $trace['line'] ?? 'n/a';
$output->writeln(
sprintf(
' %s%s at <info>%s:%s</info>',
$class,
$function
? $type . $function . '()'
: '',
$file,
$line
),
OutputInterface::VERBOSITY_QUIET
);
}
$output->writeln('', OutputInterface::VERBOSITY_QUIET);
}
}
/**
* Split a string by width
*
* str_split is not suitable for multi-byte characters, we should use preg_split to get char array properly.
* <br>additionally, array_slice() is not enough as some character has doubled width.
* <br>we need a function to split string not by character count but by string width
*
* @param string $string The string to split
* @param int $width The width of each line
*
* @return string[] The split string
*/
private function tApplicationErrorHandling__splitStringByWidth (string $string, int $width): array {
if (false === $encoding = mb_detect_encoding($string, null, true)) {
return str_split($string, $width);
}
$utf8String = mb_convert_encoding($string, 'utf8', $encoding);
$lines = [];
$line = '';
$offset = 0;
while (preg_match('/.{1,10000}/u', $utf8String, $match, 0, $offset)) {
$offset += strlen($match[0]);
foreach (preg_split('//u', $match[0]) as $char) {
// test if $char could be appended to current line
if (mb_strwidth($line . $char, 'utf8') <= $width) {
$line .= $char;
continue;
}
// if not, push current line to array and make new line
$lines[] = str_pad($line, $width);
$line = $char;
}
}
$lines[] = count($lines) ? str_pad($line, $width) : $line;
mb_convert_variables($encoding, 'utf8', $lines);
return $lines;
}
/**
* Wrap a text in a tag
*
* @param string $string The text to wrap
* @param string|null $tag The tag to wrap the text in, or null to not wrap the text
*
* @return string The wrapped text
*/
private function tApplicationErrorHandling__wrapInTag (string $string, ?string $tag): string {
if ($tag === null) {
return $string;
}
return sprintf('<%s>%s</%s>', $tag, $string, $tag);
}
}
Loading…
Cancel
Save