* @license MIT * * @link https://github.com/adhocore/cli */ class OutputHelper { protected Writer $writer; /** @var int Max width of command name */ protected int $maxCmdName = 0; public function __construct(Writer $writer = null) { $this->writer = $writer ?? new Writer; } /** * Print stack trace and error msg of an exception. */ public function printTrace(\Throwable $e): void { $eClass = \get_class($e); $this->writer->colors( "{$eClass} {$e->getMessage()}" . "(thrown in {$e->getFile()}:{$e->getLine()})" ); // @codeCoverageIgnoreStart if ($e instanceof Exception) { // Internal exception traces are not printed. return; } // @codeCoverageIgnoreEnd $traceStr = 'Stack Trace:'; foreach ($e->getTrace() as $i => $trace) { $trace += ['class' => '', 'type' => '', 'function' => '', 'file' => '', 'line' => '', 'args' => []]; $symbol = $trace['class'] . $trace['type'] . $trace['function']; $args = $this->stringifyArgs($trace['args']); $traceStr .= " $i) $symbol($args)"; if ('' !== $trace['file']) { $file = \realpath($trace['file']); $traceStr .= " at $file:{$trace['line']}"; } } $this->writer->colors($traceStr); } protected function stringifyArgs(array $args): string { $holder = []; foreach ($args as $arg) { $holder[] = $this->stringifyArg($arg); } return \implode(', ', $holder); } protected function stringifyArg($arg): string { if (\is_scalar($arg)) { return \var_export($arg, true); } if (\is_object($arg)) { return \method_exists($arg, '__toString') ? (string) $arg : \get_class($arg); } if (\is_array($arg)) { return '[' . $this->stringifyArgs($arg) . ']'; } return \gettype($arg); } /** * @param Argument[] $arguments * @param string $header * @param string $footer * * @return self */ public function showArgumentsHelp(array $arguments, string $header = '', string $footer = ''): self { $this->showHelp('Arguments', $arguments, $header, $footer); return $this; } /** * @param Option[] $options * @param string $header * @param string $footer * * @return self */ public function showOptionsHelp(array $options, string $header = '', string $footer = ''): self { $this->showHelp('Options', $options, $header, $footer); return $this; } /** * @param Command[] $commands * @param string $header * @param string $footer * * @return self */ public function showCommandsHelp(array $commands, string $header = '', string $footer = ''): self { $this->maxCmdName = $commands ? \max(\array_map(fn (Command $cmd) => \strlen($cmd->name()), $commands)) : 0; $this->showHelp('Commands', $commands, $header, $footer); return $this; } /** * Show help with headers and footers. */ protected function showHelp(string $for, array $items, string $header = '', string $footer = ''): void { if ($header) { $this->writer->bold($header, true); } $this->writer->eol()->boldGreen($for . ':', true); if (empty($items)) { $this->writer->bold(' (n/a)', true); return; } $space = 4; foreach ($this->sortItems($items, $padLen) as $item) { $name = $this->getName($item); $desc = \str_replace(["\r\n", "\n"], \str_pad("\n", $padLen + $space + 3), $item->desc()); $this->writer->bold(' ' . \str_pad($name, $padLen + $space)); $this->writer->comment($desc, true); } if ($footer) { $this->writer->eol()->yellow($footer, true); } } /** * Show usage examples of a Command. * * It replaces $0 with actual command name and properly pads ` ## ` segments. */ public function showUsage(string $usage): self { $usage = \str_replace('$0', $_SERVER['argv'][0] ?? '[cmd]', $usage); if (!\str_contains($usage, ' ## ')) { $this->writer->eol()->boldGreen('Usage Examples:', true)->colors($usage)->eol(); return $this; } $lines = \explode("\n", \str_replace(['', '', '', "\r\n"], "\n", $usage)); foreach ($lines as $i => &$pos) { if (false === $pos = \strrpos(\preg_replace('~~', '', $pos), ' ##')) { unset($lines[$i]); } } $maxlen = ($lines ? \max($lines) : 0) + 4; $usage = \preg_replace_callback('~ ## ~', function () use (&$lines, $maxlen) { return \str_pad('# ', $maxlen - \array_shift($lines), ' ', \STR_PAD_LEFT); }, $usage); $this->writer->eol()->boldGreen('Usage Examples:', true)->colors($usage)->eol(); return $this; } public function showCommandNotFound(string $attempted, array $available): self { $closest = []; foreach ($available as $cmd) { $lev = \levenshtein($attempted, $cmd); if ($lev > 0 || $lev < 5) { $closest[$cmd] = $lev; } } $this->writer->error("Command $attempted not found", true); if ($closest) { \asort($closest); $closest = \key($closest); $this->writer->bgRed("Did you mean $closest?", true); } return $this; } /** * Sort items by name. As a side effect sets max length of all names. * * @param Parameter[]|Command[] $items * @param int $max * * @return array */ protected function sortItems(array $items, &$max = 0): array { $max = \max(\array_map(fn ($item) => \strlen($this->getName($item)), $items)); \uasort($items, fn ($a, $b) => $a->name() <=> $b->name()); return $items; } /** * Prepare name for different items. * * @param Parameter|Command $item * * @return string */ protected function getName($item): string { $name = $item->name(); if ($item instanceof Command) { return \trim(\str_pad($name, $this->maxCmdName) . ' ' . $item->alias()); } return $this->label($item); } /** * Get parameter label for humans. */ protected function label(Parameter $item): string { $name = $item->name(); if ($item instanceof Option) { $name = $item->short() . '|' . $item->long(); } $variad = $item->variadic() ? '...' : ''; if ($item->required()) { return "<$name$variad>"; } return "[$name$variad]"; } }