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…
Reference in New Issue