Skip to content

Commit 5edfa27

Browse files
fchris82theofidry
authored andcommitted
Console output formatter improvement
Current SymfonyStyle can cause some problems: - can cut format tags, - cut/split URLs, the user can't click on them in the terminal I fixed the problem and added or modified some tests. This is a simple, fast, and working solution with a regular expression that won't wrap in the middle of a formatting tag or a link. The last one is optional, see the doc change. Co-authored-by: Théo FIDRY <[email protected]>
1 parent 3c9120d commit 5edfa27

File tree

10 files changed

+247
-17
lines changed

10 files changed

+247
-17
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ CHANGELOG
1212

1313
* Add support to display table vertically when calling setVertical()
1414
* Add method `__toString()` to `InputInterface`
15+
* Added `OutputWrapperInterface` and `OutputWrapper` to allow modifying your
16+
wrapping strategy in `SymfonyStyle` or in other `OutputStyle`. Eg: you can
17+
switch off to wrap URLs.
1518
* Deprecate `Command::$defaultName` and `Command::$defaultDescription`, use the `AsCommand` attribute instead
1619
* Add suggested values for arguments and options in input definition, for input completion
1720
* Add `$resumeAt` parameter to `ProgressBar#start()`, so that one can easily 'resume' progress on longer tasks, and still get accurate `getEstimate()` and `getRemaining()` results.

Formatter/OutputFormatter.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Console\Formatter;
1313

1414
use Symfony\Component\Console\Exception\InvalidArgumentException;
15+
use Symfony\Component\Console\Helper\OutputWrapperInterface;
1516

1617
/**
1718
* Formatter class for console output.
@@ -144,8 +145,8 @@ public function formatAndWrap(?string $message, int $width)
144145

145146
$offset = 0;
146147
$output = '';
147-
$openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
148-
$closeTagRegex = '[a-z][^<>]*+';
148+
$openTagRegex = OutputWrapperInterface::TAG_OPEN_REGEX_SEGMENT;
149+
$closeTagRegex = OutputWrapperInterface::TAG_CLOSE_REGEX_SEGMENT;
149150
$currentLineLength = 0;
150151
preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE);
151152
foreach ($matches[0] as $i => $match) {
@@ -161,7 +162,7 @@ public function formatAndWrap(?string $message, int $width)
161162
$offset = $pos + \strlen($text);
162163

163164
// opening tag?
164-
if ($open = '/' != $text[1]) {
165+
if ($open = ('/' !== $text[1])) {
165166
$tag = $matches[1][$i][0];
166167
} else {
167168
$tag = $matches[3][$i][0] ?? '';

Helper/OutputWrapper.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
/**
15+
* Simple output wrapper for "tagged outputs" instead of wordwrap(). This solution is based on a StackOverflow
16+
* answer: https://stackoverflow.com/a/20434776/1476819 from user557597 (alias SLN).
17+
*
18+
* (?:
19+
* # -- Words/Characters
20+
* ( # (1 start)
21+
* (?> # Atomic Group - Match words with valid breaks
22+
* .{1,16} # 1-N characters
23+
* # Followed by one of 4 prioritized, non-linebreak whitespace
24+
* (?: # break types:
25+
* (?<= [^\S\r\n] ) # 1. - Behind a non-linebreak whitespace
26+
* [^\S\r\n]? # ( optionally accept an extra non-linebreak whitespace )
27+
* | (?= \r? \n ) # 2. - Ahead a linebreak
28+
* | $ # 3. - EOS
29+
* | [^\S\r\n] # 4. - Accept an extra non-linebreak whitespace
30+
* )
31+
* ) # End atomic group
32+
* |
33+
* .{1,16} # No valid word breaks, just break on the N'th character
34+
* ) # (1 end)
35+
* (?: \r? \n )? # Optional linebreak after Words/Characters
36+
* |
37+
* # -- Or, Linebreak
38+
* (?: \r? \n | $ ) # Stand alone linebreak or at EOS
39+
* )
40+
*
41+
* @author Krisztián Ferenczi <[email protected]>
42+
*
43+
* @see https://stackoverflow.com/a/20434776/1476819
44+
*/
45+
final class OutputWrapper implements OutputWrapperInterface
46+
{
47+
private const URL_PATTERN = 'https?://\S+';
48+
49+
private bool $allowCutUrls = false;
50+
51+
public function isAllowCutUrls(): bool
52+
{
53+
return $this->allowCutUrls;
54+
}
55+
56+
public function setAllowCutUrls(bool $allowCutUrls)
57+
{
58+
$this->allowCutUrls = $allowCutUrls;
59+
60+
return $this;
61+
}
62+
63+
public function wrap(string $text, int $width, string $break = "\n"): string
64+
{
65+
if (!$width) {
66+
return $text;
67+
}
68+
69+
$tagPattern = sprintf('<(?:(?:%s)|/(?:%s)?)>', OutputWrapperInterface::TAG_OPEN_REGEX_SEGMENT, OutputWrapperInterface::TAG_CLOSE_REGEX_SEGMENT);
70+
$limitPattern = "{1,$width}";
71+
$patternBlocks = [$tagPattern];
72+
if (!$this->allowCutUrls) {
73+
$patternBlocks[] = self::URL_PATTERN;
74+
}
75+
$patternBlocks[] = '.';
76+
$blocks = implode('|', $patternBlocks);
77+
$rowPattern = "(?:$blocks)$limitPattern";
78+
$pattern = sprintf('#(?:((?>(%1$s)((?<=[^\S\r\n])[^\S\r\n]?|(?=\r?\n)|$|[^\S\r\n]))|(%1$s))(?:\r?\n)?|(?:\r?\n|$))#imux', $rowPattern);
79+
$output = rtrim(preg_replace($pattern, '\\1'.$break, $text), $break);
80+
81+
return str_replace(' '.$break, $break, $output);
82+
}
83+
}

Helper/OutputWrapperInterface.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Helper;
13+
14+
interface OutputWrapperInterface
15+
{
16+
public const TAG_OPEN_REGEX_SEGMENT = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
17+
public const TAG_CLOSE_REGEX_SEGMENT = '[a-z][^<>]*+';
18+
19+
/**
20+
* @param positive-int|0 $width
21+
*/
22+
public function wrap(string $text, int $width, string $break = "\n"): string;
23+
}

Style/SymfonyStyle.php

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use Symfony\Component\Console\Exception\RuntimeException;
1616
use Symfony\Component\Console\Formatter\OutputFormatter;
1717
use Symfony\Component\Console\Helper\Helper;
18+
use Symfony\Component\Console\Helper\OutputWrapper;
19+
use Symfony\Component\Console\Helper\OutputWrapperInterface;
1820
use Symfony\Component\Console\Helper\ProgressBar;
1921
use Symfony\Component\Console\Helper\SymfonyQuestionHelper;
2022
use Symfony\Component\Console\Helper\Table;
@@ -44,18 +46,32 @@ class SymfonyStyle extends OutputStyle
4446
private ProgressBar $progressBar;
4547
private int $lineLength;
4648
private TrimmedBufferOutput $bufferedOutput;
49+
private OutputWrapperInterface $outputWrapper;
4750

48-
public function __construct(InputInterface $input, OutputInterface $output)
51+
public function __construct(InputInterface $input, OutputInterface $output, OutputWrapperInterface $outputWrapper = null)
4952
{
5053
$this->input = $input;
5154
$this->bufferedOutput = new TrimmedBufferOutput(\DIRECTORY_SEPARATOR === '\\' ? 4 : 2, $output->getVerbosity(), false, clone $output->getFormatter());
5255
// Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not.
5356
$width = (new Terminal())->getWidth() ?: self::MAX_LINE_LENGTH;
5457
$this->lineLength = min($width - (int) (\DIRECTORY_SEPARATOR === '\\'), self::MAX_LINE_LENGTH);
58+
$this->outputWrapper = $outputWrapper ?: new OutputWrapper();
5559

5660
parent::__construct($this->output = $output);
5761
}
5862

63+
public function getOutputWrapper(): OutputWrapperInterface
64+
{
65+
return $this->outputWrapper;
66+
}
67+
68+
public function setOutputWrapper(OutputWrapperInterface $outputWrapper)
69+
{
70+
$this->outputWrapper = $outputWrapper;
71+
72+
return $this;
73+
}
74+
5975
/**
6076
* Formats a message as a block of text.
6177
*/
@@ -456,7 +472,7 @@ private function createBlock(iterable $messages, string $type = null, string $st
456472

457473
if (null !== $type) {
458474
$type = sprintf('[%s] ', $type);
459-
$indentLength = \strlen($type);
475+
$indentLength = Helper::width($type);
460476
$lineIndentation = str_repeat(' ', $indentLength);
461477
}
462478

@@ -466,12 +482,14 @@ private function createBlock(iterable $messages, string $type = null, string $st
466482
$message = OutputFormatter::escape($message);
467483
}
468484

469-
$decorationLength = Helper::width($message) - Helper::width(Helper::removeDecoration($this->getFormatter(), $message));
470-
$messageLineLength = min($this->lineLength - $prefixLength - $indentLength + $decorationLength, $this->lineLength);
471-
$messageLines = explode(\PHP_EOL, wordwrap($message, $messageLineLength, \PHP_EOL, true));
472-
foreach ($messageLines as $messageLine) {
473-
$lines[] = $messageLine;
474-
}
485+
$lines = array_merge(
486+
$lines,
487+
explode(\PHP_EOL, $this->outputWrapper->wrap(
488+
$message,
489+
$this->lineLength - $prefixLength - $indentLength,
490+
\PHP_EOL
491+
))
492+
);
475493

476494
if (\count($messages) > 1 && $key < \count($messages) - 1) {
477495
$lines[] = '';

Tests/Fixtures/Style/SymfonyStyle/command/command_13.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
$output->setDecorated(true);
1010
$output = new SymfonyStyle($input, $output);
1111
$output->comment(
12-
'Lorem ipsum dolor sit <comment>amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</comment> Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum'
12+
'Árvíztűrőtükörfúrógép 🎼 Lorem ipsum dolor sit <comment>💕 amet, consectetur adipisicing elit, sed do eiusmod tempor incididu labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</comment> Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum'
1313
);
1414
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
use Symfony\Component\Console\Input\InputInterface;
4+
use Symfony\Component\Console\Output\OutputInterface;
5+
use Symfony\Component\Console\Style\SymfonyStyle;
6+
7+
// ensure that nested tags have no effect on the color of the '//' prefix
8+
return function (InputInterface $input, OutputInterface $output) {
9+
$output->setDecorated(true);
10+
$output = new SymfonyStyle($input, $output);
11+
$output->block(
12+
'Árvíztűrőtükörfúrógép Lorem ipsum dolor sit <comment>amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</comment> Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum',
13+
'', // UTF-8 star!
14+
null,
15+
'<fg=default;bg=default> ║ </>', // UTF-8 double line!
16+
false,
17+
false
18+
);
19+
};
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

2-
 // Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore 
3-
 // magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo 
4-
 // consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla 
5-
 // pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
6-
 // est laborum
2+
 // Árvíztűrőtükörfúrógép 🎼 Lorem ipsum dolor sit 💕 amet, consectetur adipisicing elit, sed do eiusmod tempor incididu
3+
 // labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
4+
 // ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla 
5+
 // pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est
6+
 // laborum
77

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
2+
 ║ [★] Árvíztűrőtükörfúrógép Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt 
3+
 ║  ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut 
4+
 ║  aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu 
5+
 ║  fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
6+
 ║  anim id est laborum
7+

Tests/Helper/OutputWrapperTest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Tests\Helper;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Console\Helper\OutputWrapper;
16+
17+
class OutputWrapperTest extends TestCase
18+
{
19+
/**
20+
* @dataProvider textProvider
21+
*/
22+
public function testBasicWrap(string $text, int $width, ?bool $allowCutUrls, string $expected)
23+
{
24+
$wrapper = new OutputWrapper();
25+
if (\is_bool($allowCutUrls)) {
26+
$wrapper->setAllowCutUrls($allowCutUrls);
27+
}
28+
$result = $wrapper->wrap($text, $width);
29+
$this->assertEquals($expected, $result);
30+
}
31+
32+
public static function textProvider(): iterable
33+
{
34+
$baseTextWithUtf8AndUrl = 'Árvíztűrőtükörfúrógép https://github.com/symfony/symfony Lorem ipsum <comment>dolor</comment> sit amet, consectetur adipiscing elit. Praesent vestibulum nulla quis urna maximus porttitor. Donec ullamcorper risus at <error>libero ornare</error> efficitur.';
35+
36+
yield 'Default URL cut' => [
37+
$baseTextWithUtf8AndUrl,
38+
20,
39+
null,
40+
<<<'EOS'
41+
Árvíztűrőtükörfúrógé
42+
p https://github.com/symfony/symfony Lorem ipsum
43+
<comment>dolor</comment> sit amet,
44+
consectetur
45+
adipiscing elit.
46+
Praesent vestibulum
47+
nulla quis urna
48+
maximus porttitor.
49+
Donec ullamcorper
50+
risus at <error>libero
51+
ornare</error> efficitur.
52+
EOS,
53+
];
54+
55+
yield 'Allow URL cut' => [
56+
$baseTextWithUtf8AndUrl,
57+
20,
58+
true,
59+
<<<'EOS'
60+
Árvíztűrőtükörfúrógé
61+
p
62+
https://github.com/s
63+
ymfony/symfony Lorem
64+
ipsum <comment>dolor</comment> sit
65+
amet, consectetur
66+
adipiscing elit.
67+
Praesent vestibulum
68+
nulla quis urna
69+
maximus porttitor.
70+
Donec ullamcorper
71+
risus at <error>libero
72+
ornare</error> efficitur.
73+
EOS,
74+
];
75+
}
76+
}

0 commit comments

Comments
 (0)