diff --git a/src/CliProgram/TApplicationErrorHandling.php b/src/CliProgram/TApplicationErrorHandling.php new file mode 100644 index 0000000..f1554fd --- /dev/null +++ b/src/CliProgram/TApplicationErrorHandling.php @@ -0,0 +1,361 @@ +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('%s', 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('Exception trace:', 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 %s:%s', + $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. + *
additionally, array_slice() is not enough as some character has doubled width. + *
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', $tag, $string, $tag); + } +} \ No newline at end of file