Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[WIP] [Console] Pretty word wrapping #30590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
CHANGELOG
=========

* Refactor word wrapping with WordWrapperHelper

4.2.0
-----

Expand All @@ -24,7 +26,7 @@ CHANGELOG

* `OutputFormatter` throws an exception when unknown options are used
* removed `QuestionHelper::setInputStream()/getInputStream()`
* removed `Application::getTerminalWidth()/getTerminalHeight()` and
* removed `Application::getTerminalWidth()/getTerminalHeight()` and
`Application::setTerminalDimensions()/getTerminalDimensions()`
* removed `ConsoleExceptionEvent`
* removed `ConsoleEvents::EXCEPTION`
Expand All @@ -50,7 +52,7 @@ CHANGELOG
with value optional explicitly passed empty
* added console.error event to catch exceptions thrown by other listeners
* deprecated console.exception event in favor of console.error
* added ability to handle `CommandNotFoundException` through the
* added ability to handle `CommandNotFoundException` through the
`console.error` event
* deprecated default validation in `SymfonyQuestionHelper::ask`

Expand All @@ -66,7 +68,7 @@ CHANGELOG
-----

* added truncate method to FormatterHelper
* added setColumnWidth(s) method to Table
* added setColumnWidth(s) method to Table

2.8.3
-----
Expand Down
98 changes: 52 additions & 46 deletions src/Symfony/Component/Console/Formatter/OutputFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@
namespace Symfony\Component\Console\Formatter;

use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Helper\WordWrapperHelper;

/**
* Formatter class for console output.
*
* @author Konstantin Kudryashov <[email protected]>
* @author Roland Franssen <[email protected]>
* @author Krisztián Ferenczi <[email protected]>
*/
class OutputFormatter implements WrappableOutputFormatterInterface
{
private $decorated;
private $styles = [];
private $styleStack;
private $defaultWrapCutOption = WordWrapperHelper::CUT_LONG_WORDS | WordWrapperHelper::CUT_FILL_UP_MISSING;

/**
* Escapes "<" special char in given text.
Expand Down Expand Up @@ -127,23 +131,36 @@ public function getStyle($name)
}

/**
* {@inheritdoc}
* @return int
*/
public function format($message)
public function getDefaultWrapCutOption(): int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really needed is it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A protected property that you can only write? It looks a little bit bad concept. A property what you can only read, it could be justified, but a property without reading option, not really.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im skeptical about the setter too :)

setters/getters can live independently IMHO, it's not needed for every property by definition or so. If we dont have reason to add a getter/settter i would avoid it (it introduces extra state).

In this case i'd prefer passing the cut options to formatAndWrap(), i dont see a real reason to control this default behavior.

{
return $this->formatAndWrap((string) $message, 0);
return $this->defaultWrapCutOption;
}

/**
* @param int $defaultWrapCutOption
*
* @return $this
*
* @see WordWrapperHelper
*/
public function setDefaultWrapCutOption(int $defaultWrapCutOption)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, where do we use it? I tend to prefer having a $cutOptions as 3rd argument of formatAndWrap(), which makes it a clear new feature for 4.3

{
$this->defaultWrapCutOption = $defaultWrapCutOption;

return $this;
}

/**
* {@inheritdoc}
*/
public function formatAndWrap(string $message, int $width)
public function format($message)
{
$message = (string) $message;
$offset = 0;
$output = '';
$tagRegex = '[a-z][a-z0-9,_=;-]*+';
$currentLineLength = 0;
preg_match_all("#<(($tagRegex) | /($tagRegex)?)>#ix", $message, $matches, PREG_OFFSET_CAPTURE);
preg_match_all(Helper::getFormatTagRegexPattern(), $message, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $i => $match) {
$pos = $match[1];
$text = $match[0];
Expand All @@ -153,7 +170,7 @@ public function formatAndWrap(string $message, int $width)
}

// add the text up to the next tag
$output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset), $output, $width, $currentLineLength);
$output .= $this->applyCurrentStyle(substr($message, $offset, $pos - $offset));
$offset = $pos + \strlen($text);

// opening tag?
Expand All @@ -166,16 +183,16 @@ public function formatAndWrap(string $message, int $width)
if (!$open && !$tag) {
// </>
$this->styleStack->pop();
} elseif (false === $style = $this->createStyleFromString($tag)) {
$output .= $this->applyCurrentStyle($text, $output, $width, $currentLineLength);
} elseif (false === $style = $this->createStyleFromString(strtolower($tag))) {
$output .= $this->applyCurrentStyle($text);
} elseif ($open) {
$this->styleStack->push($style);
} else {
$this->styleStack->pop($style);
}
}

$output .= $this->applyCurrentStyle(substr($message, $offset), $output, $width, $currentLineLength);
$output .= $this->applyCurrentStyle(substr($message, $offset));

if (false !== strpos($output, "\0")) {
return strtr($output, ["\0" => '\\', '\\<' => '<']);
Expand All @@ -184,6 +201,22 @@ public function formatAndWrap(string $message, int $width)
return str_replace('\\<', '<', $output);
}

/**
* {@inheritdoc}
*/
public function formatAndWrap(string $message, int $width)
{
return $this->format($this->wordwrap($message, $width));
}

/**
* {@inheritdoc}
*/
public function wordwrap(string $message, int $width, int $cutOption = null): string
{
return WordWrapperHelper::wrap($message, $width, null === $cutOption ? $this->defaultWrapCutOption : $cutOption);
}

/**
* @return OutputFormatterStyleStack
*/
Expand All @@ -195,6 +228,8 @@ public function getStyleStack()
/**
* Tries to create new style instance from string.
*
* @param string $string
*
* @return OutputFormatterStyle|false False if string is not format string
*/
private function createStyleFromString(string $string)
Expand Down Expand Up @@ -233,46 +268,17 @@ private function createStyleFromString(string $string)
/**
* Applies current style from stack to text, if must be applied.
*/
private function applyCurrentStyle(string $text, string $current, int $width, int &$currentLineLength): string
private function applyCurrentStyle(string $text): string
{
if ('' === $text) {
return '';
}

if (!$width) {
return $this->isDecorated() ? $this->styleStack->getCurrent()->apply($text) : $text;
}

if (!$currentLineLength && '' !== $current) {
$text = ltrim($text);
}

if ($currentLineLength) {
$prefix = substr($text, 0, $i = $width - $currentLineLength)."\n";
$text = substr($text, $i);
} else {
$prefix = '';
}

preg_match('~(\\n)$~', $text, $matches);
$text = $prefix.preg_replace('~([^\\n]{'.$width.'})\\ *~', "\$1\n", $text);
$text = rtrim($text, "\n").($matches[1] ?? '');

if (!$currentLineLength && '' !== $current && "\n" !== substr($current, -1)) {
$text = "\n".$text;
}

$lines = explode("\n", $text);
if ($width === $currentLineLength = \strlen(end($lines))) {
$currentLineLength = 0;
}

if ($this->isDecorated()) {
if ($this->isDecorated() && \strlen($text) > 0) {
$lines = explode("\n", $text);
foreach ($lines as $i => $line) {
$lines[$i] = $this->styleStack->getCurrent()->apply($line);
}

return implode("\n", $lines);
}

return implode("\n", $lines);
return $text;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,23 @@
* Formatter interface for console output that supports word wrapping.
*
* @author Roland Franssen <[email protected]>
* @author Krisztián Ferenczi <[email protected]>
*/
interface WrappableOutputFormatterInterface extends OutputFormatterInterface
{
/**
* Formats a message according to the given styles, wrapping at `$width` (0 means no wrapping).
*/
public function formatAndWrap(string $message, int $width);

/**
* Separate word wrapping method.
*
* @param string $message
* @param int $width
* @param int|null $cutOption
*
* @return string
*/
public function wordwrap(string $message, int $width, int $cutOption = null): string;
Copy link
Contributor

@ro0NL ro0NL Mar 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new feature isnt it? To wrap a raw string with tags (unformatted still) ...

Do we really need it? As such it's a BC break, and it's the exact same reason we added a new WrappableOutputFormatterInterface in 4.2.

New features target master and thus would imply another interface. I think we should avoid that and solve the issue in formatAndWrap().

Which takes me to my 2nd point; this PR will parse the tags twice IIUC. My main motivation for "formatAndWrap" was to be able to handle it at once. I think we should patch it instead, relying on a helper if really needed for clarity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it also. I saw 3 ways:

  1. I add the CUT option parameter to the formatAndWrap() --> this causes BC break
  2. I create a new wrap function that only can wrap --> it doesn't cause BC break
  3. I leave the CUT option parameter --> it doesn't cause BC break, but it causes inflexible wrapping function

Maybe there is a 4. option: I remove wordwrap() (maybe the formatAndWrap() too), and if somebody want to wrap then call like here:

// Without wrapping
$output->getFormatter()->format($text);

// With wrapping
$output->getFormatter()->format(
    PrettyWordWrappingHelper::wordwrap($text, 120)
);

It would be clearer, but it supersede your WrappableOutputFormatterInterface because wrapping and formatting are really and fully separated (and helps to programmers changing it a custom wrapper if they want) I would prefer this.

this PR will parse the tags twice

Yes, it is true. This is a side effect, which I think is necessary. Formatting (styling?) and wrapping can enough complex separately, that is why I think we should handle it separately. It causes more maintenance and extendable code. And this will be used in command line, the performance isn't so important. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

option 5) Your "With wrapping" example to me equals formatter->formatAndWrap($text, 120) already. That's API in 4.2 we can fix, maybe even with an @internal WordwrapHelper, or public for 4.3/master.

option 6) deprecate WrappableOutputFormatterInterface in 4.3 and add the "best" API directly to OutputFormatterInterface using #28902 (which wasnt possible back then)

In general i think the wordwrap helper should be an implementation detail of the output formatter. But we need some consensus first to move forward. Given a lot of work is done here already, perhaps we should finish it :) The bug is real.

this PR will parse the tags twice

Yes, it is true. This is a side effect, which I think is necessary.

You know best :) so far i thought it seemed reasonably possible ... but it's also still flawed currently 😅 I need to look closer at this new approach to see how it differs.

Copy link
Contributor Author

@fchris82 fchris82 Mar 22, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... I'm thinking about it ...

}
21 changes: 21 additions & 0 deletions src/Symfony/Component/Console/Helper/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
*/
abstract class Helper implements HelperInterface
{
const FORMAT_TAG_REGEX = '[a-z][a-z0-9,_=;-]*+';

protected $helperSet = null;

/**
Expand Down Expand Up @@ -123,6 +125,25 @@ public static function strlenWithoutDecoration(OutputFormatterInterface $formatt
return self::strlen(self::removeDecoration($formatter, $string));
}

/**
* Sometimes we need to find the format tags. This regex "placeholders":
* - \\0 --> full open or close tag
* - \\1 --> tag "inside"
* - \\2 --> only tag name.
*
* | \\0 | \\1 | \\2 |
* | ---------- | -------- | ------- |
* | <comment> | comment | comment |
* | </comment> | /comment | comment |
* | </> | / | (empty) |
*
* @return string
*/
public static function getFormatTagRegexPattern(): string
{
return sprintf('{<((%1$s)|/(%1$s)?)>}ix', self::FORMAT_TAG_REGEX);
}

public static function removeDecoration(OutputFormatterInterface $formatter, $string)
{
$isDecorated = $formatter->isDecorated();
Expand Down
69 changes: 67 additions & 2 deletions src/Symfony/Component/Console/Helper/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class Table
*/
private $columnWidths = [];
private $columnMaxWidths = [];
private $columnWordWrapCutOption = [];
private $defaultColumnWordWrapCutOption = WordWrapperHelper::CUT_LONG_WORDS;

private static $styles;

Expand Down Expand Up @@ -228,13 +230,72 @@ public function setColumnWidths(array $widths)
*
* @return $this
*/
public function setColumnMaxWidth(int $columnIndex, int $width): self
public function setColumnMaxWidth(int $columnIndex, int $width, int $cutOption = null): self
{
if (!$this->output->getFormatter() instanceof WrappableOutputFormatterInterface) {
throw new \LogicException(sprintf('Setting a maximum column width is only supported when using a "%s" formatter, got "%s".', WrappableOutputFormatterInterface::class, \get_class($this->output->getFormatter())));
}

$this->columnMaxWidths[$columnIndex] = $width;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would use [$width, $cutOption] to make it clear this belongs together.

$this->columnWordWrapCutOption[$columnIndex] = $cutOption;

return $this;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure we need all this extra API, i would keep it out at first.

/**
* Sets the cut options of a column.
*
* @param int $columnIndex
* @param int $cutOptions
*
* @return $this
*/
public function setColumnWordWrapCutOption(int $columnIndex, int $cutOptions): self
{
$this->columnWordWrapCutOption[$columnIndex] = $cutOptions;

return $this;
}

/**
* @param int $columnIndex
*
* @return int
*/
public function getColumnWordWrapCutOption(int $columnIndex): int
{
if (!\array_key_exists($columnIndex, $this->columnWordWrapCutOption)
|| null === $this->columnWordWrapCutOption[$columnIndex]
) {
return $this->defaultColumnWordWrapCutOption;
}

return $this->columnWordWrapCutOption[$columnIndex];
}

/**
* @param int $defaultColumnWordWrapCutOption
*
* @return $this
*/
public function setDefaultColumnWordWrapCutOption(int $defaultColumnWordWrapCutOption): self
{
$this->defaultColumnWordWrapCutOption = $defaultColumnWordWrapCutOption;

return $this;
}

/**
* @param array $columnWordWrapCutOptions
*
* @return $this
*/
public function setColumnsWordWrapCutOptions(array $columnWordWrapCutOptions): self
{
$this->columnWordWrapCutOption = [];
foreach ($columnWordWrapCutOptions as $columnIndex => $columnOption) {
$this->setColumnWordWrapCutOption($columnIndex, $columnOption);
}

return $this;
}
Expand Down Expand Up @@ -523,7 +584,11 @@ private function buildTableRows($rows)
// Remove any new line breaks and replace it with a new line
foreach ($rows[$rowKey] as $column => $cell) {
if (isset($this->columnMaxWidths[$column]) && Helper::strlenWithoutDecoration($formatter, $cell) > $this->columnMaxWidths[$column]) {
$cell = $formatter->formatAndWrap($cell, $this->columnMaxWidths[$column]);
$cell = $formatter->format($formatter->wordwrap(
$cell,
$this->columnMaxWidths[$column],
$this->getColumnWordWrapCutOption($column)
));
}
if (!strstr($cell, "\n")) {
continue;
Expand Down
Loading