493 lines
16 KiB
PHP
493 lines
16 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Symfony package.
|
|
*
|
|
* (c) Fabien Potencier <fabien@symfony.com>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Symfony\Component\Console\Formatter;
|
|
|
|
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
|
use Symfony\Component\Console\Helper\Helper;
|
|
|
|
use function Symfony\Component\String\b;
|
|
|
|
/**
|
|
* Formatter class for console output.
|
|
*
|
|
* @author Konstantin Kudryashov <ever.zet@gmail.com>
|
|
* @author Roland Franssen <franssen.roland@gmail.com>
|
|
*/
|
|
class OutputFormatter implements WrappableOutputFormatterInterface
|
|
{
|
|
private const ANSI_PATTERN = '\\x1B(?:\\[[0-9;:]*m|\\]8;[^;]*;[^\\x1B]*\\x1B\\\\)';
|
|
private const ANSI_REGEX = '/'.self::ANSI_PATTERN.'/';
|
|
|
|
private array $styles = [];
|
|
private OutputFormatterStyleStack $styleStack;
|
|
private array $charWidths = [];
|
|
|
|
public function __clone()
|
|
{
|
|
$this->styleStack = clone $this->styleStack;
|
|
foreach ($this->styles as $key => $value) {
|
|
$this->styles[$key] = clone $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escapes "<" and ">" special chars in given text.
|
|
*/
|
|
public static function escape(string $text): string
|
|
{
|
|
$text = str_replace(['<', '>'], ['\\<', '\\>'], $text);
|
|
|
|
return self::escapeTrailingBackslash($text);
|
|
}
|
|
|
|
/**
|
|
* Escapes trailing "\" in given text.
|
|
*
|
|
* @internal
|
|
*/
|
|
public static function escapeTrailingBackslash(string $text): string
|
|
{
|
|
if (str_ends_with($text, '\\')) {
|
|
$len = \strlen($text);
|
|
$text = rtrim($text, '\\');
|
|
$text = str_replace("\0", '', $text);
|
|
$text .= str_repeat("\0", $len - \strlen($text));
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* Initializes console output formatter.
|
|
*
|
|
* @param OutputFormatterStyleInterface[] $styles Array of "name => FormatterStyle" instances
|
|
*/
|
|
public function __construct(
|
|
private bool $decorated = false,
|
|
array $styles = [],
|
|
) {
|
|
$this->setStyle('error', new OutputFormatterStyle('white', 'red'));
|
|
$this->setStyle('info', new OutputFormatterStyle('green'));
|
|
$this->setStyle('comment', new OutputFormatterStyle('yellow'));
|
|
$this->setStyle('question', new OutputFormatterStyle('black', 'cyan'));
|
|
|
|
foreach ($styles as $name => $style) {
|
|
$this->setStyle($name, $style);
|
|
}
|
|
|
|
$this->styleStack = new OutputFormatterStyleStack();
|
|
}
|
|
|
|
public function setDecorated(bool $decorated): void
|
|
{
|
|
$this->decorated = $decorated;
|
|
}
|
|
|
|
public function isDecorated(): bool
|
|
{
|
|
return $this->decorated;
|
|
}
|
|
|
|
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
|
|
{
|
|
$this->styles[strtolower($name)] = $style;
|
|
}
|
|
|
|
public function hasStyle(string $name): bool
|
|
{
|
|
return isset($this->styles[strtolower($name)]);
|
|
}
|
|
|
|
public function getStyle(string $name): OutputFormatterStyleInterface
|
|
{
|
|
if (!$this->hasStyle($name)) {
|
|
throw new InvalidArgumentException(\sprintf('Undefined style: "%s".', $name));
|
|
}
|
|
|
|
return $this->styles[strtolower($name)];
|
|
}
|
|
|
|
public function format(?string $message): ?string
|
|
{
|
|
return $this->formatAndWrap($message, 0);
|
|
}
|
|
|
|
public function formatAndWrap(?string $message, int $width): string
|
|
{
|
|
if (null === $message) {
|
|
return '';
|
|
}
|
|
|
|
$offset = 0;
|
|
$output = '';
|
|
$openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
|
|
$closeTagRegex = '[a-z][^<>]*+';
|
|
$currentLineLength = 0;
|
|
$lineBreak = false;
|
|
preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE);
|
|
foreach ($matches[0] as $i => $match) {
|
|
$pos = $match[1];
|
|
$text = $match[0];
|
|
|
|
if (0 != $pos && '\\' == $message[$pos - 1]) {
|
|
continue;
|
|
}
|
|
|
|
// tags are delimited by "<" and ">", which are ASCII, so a tag offset can never
|
|
// fall inside a multibyte sequence: slicing by bytes is safe here
|
|
$output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset), $width, $currentLineLength, $lineBreak);
|
|
$offset = $pos + \strlen($text);
|
|
|
|
// opening tag?
|
|
if ($open = '/' !== $text[1]) {
|
|
$tag = $matches[1][$i][0];
|
|
} else {
|
|
$tag = $matches[3][$i][0] ?? '';
|
|
}
|
|
|
|
if (!$open && !$tag) {
|
|
// </>
|
|
$this->styleStack->pop();
|
|
} elseif (null === $style = $this->createStyleFromString($tag)) {
|
|
$output .= $this->applyCurrentStyle($text, $width, $currentLineLength, $lineBreak);
|
|
} elseif ($open) {
|
|
$this->styleStack->push($style);
|
|
} else {
|
|
$this->styleStack->pop($style);
|
|
}
|
|
}
|
|
|
|
$output .= $this->applyCurrentStyle(substr($message, $offset), $width, $currentLineLength, $lineBreak);
|
|
|
|
if ($lineBreak) {
|
|
$output .= "\n";
|
|
}
|
|
|
|
if ($width && str_contains($output, "\r")) {
|
|
// keep "\r\n" line breaks together when ANSI codes end the line
|
|
$output = preg_replace('/\r((?:'.self::ANSI_PATTERN.')++)(?=\n|$)/', "\$1\r", $output);
|
|
}
|
|
|
|
return str_replace("\0", '\\', $output);
|
|
}
|
|
|
|
public function getStyleStack(): OutputFormatterStyleStack
|
|
{
|
|
return $this->styleStack;
|
|
}
|
|
|
|
/**
|
|
* Tries to create new style instance from string.
|
|
*/
|
|
private function createStyleFromString(string $string): ?OutputFormatterStyleInterface
|
|
{
|
|
if (isset($this->styles[$lowerString = strtolower($string)])) {
|
|
return $this->styles[$lowerString];
|
|
}
|
|
|
|
if (!preg_match_all('/([^=]+)=([^;]+)(;|$)/', $string, $matches, \PREG_SET_ORDER)) {
|
|
return null;
|
|
}
|
|
|
|
$style = new OutputFormatterStyle();
|
|
foreach ($matches as $match) {
|
|
array_shift($match);
|
|
$match[0] = strtolower($match[0]);
|
|
|
|
if ('fg' == $match[0]) {
|
|
$style->setForeground(strtolower($match[1]));
|
|
} elseif ('bg' == $match[0]) {
|
|
$style->setBackground(strtolower($match[1]));
|
|
} elseif ('href' === $match[0]) {
|
|
$url = preg_replace('{\\\\([<>])}', '$1', $match[1]);
|
|
$style->setHref($url);
|
|
} elseif ('options' === $match[0]) {
|
|
preg_match_all('([^,;]+)', strtolower($match[1]), $options);
|
|
$options = array_shift($options);
|
|
foreach ($options as $option) {
|
|
$style->setOption($option);
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $style;
|
|
}
|
|
|
|
/**
|
|
* Applies current style from stack to text, if must be applied.
|
|
*/
|
|
private function applyCurrentStyle(string $text, int $width, int &$currentLineLength, bool &$lineBreak): string
|
|
{
|
|
if ('' === $text) {
|
|
return '';
|
|
}
|
|
|
|
if (!$width || str_contains($text, "\e") && '' === preg_replace(self::ANSI_REGEX, '', $text)) {
|
|
$text = str_replace(['\\<', '\\>'], ['<', '>'], $text);
|
|
|
|
return $this->isDecorated() ? $this->styleStack->getCurrent()->apply($text) : $text;
|
|
}
|
|
|
|
$text = str_replace(['\\<', '\\>'], ['<', '>'], $text);
|
|
$prefix = '';
|
|
$visibleText = str_contains($text, "\e") ? preg_replace(self::ANSI_REGEX, '', $text) : $text;
|
|
|
|
if ($currentLineLength >= $width) {
|
|
// the current line is full: drop the whitespace that follows and break the line before the next character
|
|
if (str_contains($text, "\e")) {
|
|
$text = preg_replace_callback('/^(?:[\\x00\\x09\\x0B\\x0D\\x20]|'.self::ANSI_PATTERN.')++/', static function ($match) {
|
|
preg_match_all(self::ANSI_REGEX, $match[0], $ansi);
|
|
|
|
return implode('', $ansi[0]);
|
|
}, $text);
|
|
$visibleText = ltrim($visibleText, " \t\r\0\x0B");
|
|
} else {
|
|
$text = $visibleText = ltrim($text, " \t\r\0\x0B");
|
|
}
|
|
|
|
// whitespace at the end of the message still breaks the line
|
|
if ($lineBreak = '' === $visibleText) {
|
|
return '' === $text || !$this->isDecorated() ? $text : $this->styleStack->getCurrent()->apply($text);
|
|
}
|
|
|
|
if ("\n" !== $visibleText[0]) {
|
|
$prefix = "\n";
|
|
}
|
|
|
|
$currentLineLength = 0;
|
|
}
|
|
|
|
$trailingNewlines = substr($visibleText, \strlen(rtrim($visibleText, "\r\n")));
|
|
|
|
if ($currentLineLength) {
|
|
$lines = explode("\n", $text, 2);
|
|
$eol = "\n";
|
|
if (isset($lines[1]) && str_ends_with($lines[0], "\r")) {
|
|
$lines[0] = substr($lines[0], 0, -1);
|
|
$eol = "\r\n";
|
|
}
|
|
$i = $width - $currentLineLength;
|
|
if ($widths = $this->getWidths($lines[0])) {
|
|
$room = $i;
|
|
$i = \count($widths);
|
|
foreach ($widths as $j => $charWidth) {
|
|
if (0 > $room -= $charWidth) {
|
|
$i = $j;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$prefix = ($i ? $this->substr($lines[0], 0, $i) : '')."\n";
|
|
$text = $i ? $this->substr($lines[0], $i) : $lines[0];
|
|
|
|
if (isset($lines[1])) {
|
|
// $prefix may contain the full first line in which the \n is already a part of $prefix.
|
|
if ('' !== $text) {
|
|
$text .= $eol;
|
|
} else {
|
|
$prefix = substr($prefix, 0, -1).$eol;
|
|
}
|
|
|
|
$text .= $lines[1];
|
|
}
|
|
}
|
|
|
|
$text = $prefix.$this->addLineBreaks($text, $width);
|
|
if (str_contains($text, "\e")) {
|
|
$parts = preg_split('/('.self::ANSI_PATTERN.')/', $text, -1, \PREG_SPLIT_DELIM_CAPTURE);
|
|
$sequences = '';
|
|
for ($i = \count($parts) - 1; $i >= 0; --$i) {
|
|
if ($i % 2) {
|
|
$sequences = $parts[$i].$sequences;
|
|
} elseif ('' !== $parts[$i] = rtrim($parts[$i], "\r\n")) {
|
|
break;
|
|
}
|
|
}
|
|
$text = implode('', \array_slice($parts, 0, $i + 1)).$sequences;
|
|
} else {
|
|
$text = rtrim($text, "\r\n");
|
|
}
|
|
$text .= $trailingNewlines;
|
|
|
|
$lines = explode("\n", $text);
|
|
|
|
foreach ($lines as $i => $line) {
|
|
$lineLength = array_sum($this->getWidths($line)) ?: Helper::length(str_contains($line, "\e") ? preg_replace(self::ANSI_REGEX, '', $line) : $line);
|
|
$currentLineLength = 0 === $i ? $currentLineLength + $lineLength : $lineLength;
|
|
}
|
|
|
|
foreach ($lines as $i => $line) {
|
|
$lines[$i] = $this->isDecorated() ? $this->styleStack->getCurrent()->apply($line) : $line;
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
private function addLineBreaks(string $text, int $width): string
|
|
{
|
|
$encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8';
|
|
$widths = $this->getWidths($text);
|
|
|
|
if (!$widths && !str_contains($text, "\e") && !str_contains($text, "\r\n")) {
|
|
return b($text)->toUnicodeString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding);
|
|
}
|
|
|
|
$chars = [];
|
|
$mask = $pending = '';
|
|
foreach ($this->getTokens($text) as $token) {
|
|
if (preg_match(self::ANSI_REGEX, $token)) {
|
|
$pending .= $token;
|
|
continue;
|
|
}
|
|
$chars[] = [$pending, $token];
|
|
$pending = '';
|
|
$mask .= "\n" === $token || "\r\n" === $token ? '#' : (' ' === $token ? ' ' : '?');
|
|
}
|
|
|
|
if (!$chars) {
|
|
return $pending;
|
|
}
|
|
|
|
$wrapped = rtrim($mask, '#');
|
|
$mask = ($widths ? $this->wordwrap($wrapped, $width, $widths) : wordwrap($wrapped, $width, '#', true)).substr($mask, \strlen($wrapped));
|
|
$result = '';
|
|
$j = 0;
|
|
foreach (str_split($mask) as $char) {
|
|
if ('#' === $char) {
|
|
$eol = "\n";
|
|
if (isset($chars[$j]) && ("\n" === $chars[$j][1] || "\r\n" === $chars[$j][1] || ' ' === $chars[$j][1])) {
|
|
$eol = ' ' === $chars[$j][1] ? "\n" : $chars[$j][1];
|
|
$result .= $chars[$j++][0];
|
|
}
|
|
$result .= $eol;
|
|
} else {
|
|
$result .= implode('', $chars[$j++]);
|
|
}
|
|
}
|
|
|
|
return $result.$pending;
|
|
}
|
|
|
|
private function substr(string $text, int $from, ?int $length = null): string
|
|
{
|
|
if (!str_contains($text, "\e")) {
|
|
return Helper::substr($text, $from, $length);
|
|
}
|
|
|
|
$result = '';
|
|
$position = 0;
|
|
foreach ($this->getTokens($text) as $token) {
|
|
$isAnsi = preg_match(self::ANSI_REGEX, $token);
|
|
if ($position >= $from && (null === $length || $position < $from + $length || $isAnsi && $position === $from + $length)
|
|
&& (!$isAnsi || !$from || $position > $from)
|
|
) {
|
|
$result .= $token;
|
|
}
|
|
if (!$isAnsi) {
|
|
++$position;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function getTokens(string $text): iterable
|
|
{
|
|
$encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8';
|
|
foreach (preg_split('/('.self::ANSI_PATTERN.')/', $text, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY) as $part) {
|
|
if (preg_match(self::ANSI_REGEX, $part)) {
|
|
yield $part;
|
|
continue;
|
|
}
|
|
foreach (b($part)->toUnicodeString($encoding)->chunk() as $char) {
|
|
yield (string) $char->toByteString($encoding);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the number of columns of each visible character, or an empty array when none uses more than one column.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
private function getWidths(string $text): array
|
|
{
|
|
if (!preg_match('/[^\x00-\x{10FF}]/u', $text)) {
|
|
return [];
|
|
}
|
|
|
|
$widths = [];
|
|
$wide = false;
|
|
foreach ($this->getTokens($text) as $token) {
|
|
if (!preg_match(self::ANSI_REGEX, $token)) {
|
|
$widths[] = $charWidth = 2 < \strlen($token) ? ($this->charWidths[$token] ??= Helper::width($token)) : 1;
|
|
$wide = $wide || 1 < $charWidth;
|
|
}
|
|
}
|
|
|
|
return $wide ? $widths : [];
|
|
}
|
|
|
|
/**
|
|
* Wraps a mask like wordwrap() does when cutting long words, but using the number of columns of each character.
|
|
*
|
|
* @param list<int> $widths
|
|
*/
|
|
private function wordwrap(string $mask, int $width, array $widths): string
|
|
{
|
|
$result = '';
|
|
$start = $lastSpace = $lineWidth = 0;
|
|
|
|
for ($i = 0, $length = \strlen($mask); $i < $length; ++$i) {
|
|
if ('#' === $mask[$i]) {
|
|
$result .= substr($mask, $start, $i + 1 - $start);
|
|
$start = $lastSpace = $i + 1;
|
|
$lineWidth = 0;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (' ' === $mask[$i]) {
|
|
if ($lineWidth >= $width) {
|
|
$result .= substr($mask, $start, $i - $start).'#';
|
|
$start = $i + 1;
|
|
$lineWidth = 0;
|
|
} else {
|
|
++$lineWidth;
|
|
}
|
|
$lastSpace = $i;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($lineWidth + $widths[$i] > $width && $start < $lastSpace) {
|
|
$result .= substr($mask, $start, $lastSpace - $start).'#';
|
|
$start = $lastSpace = $lastSpace + 1;
|
|
$lineWidth = array_sum(\array_slice($widths, $start, $i - $start));
|
|
}
|
|
|
|
// cut long words, and also before a wide character that still doesn't fit after breaking at the last space
|
|
if ($lineWidth && $lineWidth + $widths[$i] > $width) {
|
|
$result .= substr($mask, $start, $i - $start).'#';
|
|
$start = $lastSpace = $i;
|
|
$lineWidth = 0;
|
|
}
|
|
|
|
$lineWidth += $widths[$i];
|
|
}
|
|
|
|
return $result.substr($mask, $start);
|
|
}
|
|
}
|