merged branch jfsimon/master (PR #3613)

Commits
-------

2a90871 [Console] Removed previously introduced BC break.
90a2a6e [Console] Undecorated formatter must update style stack too.
bd7e01a [Console] Fixed output formatter test broken by new implementation.
a1add4b [Console] Updated output formatter to use style stack.
4f298dd [Console] Added formatter style stack.
93ffe54 [Console] Added getters to output formatter style (and its interface).
48e6b49 [Console] Updated formatter test to match styles bug fix.
ad334b6 [Console] Fixed empty style appliance.
31d5fe5 [Console] Fixed output formatter docblock.

Discussion
----------

[Console] Fixes formatter nested style appliance.

Bug fix: yes
Feature addition: no
Backwards compatibility break: no
Symfony2 tests pass: yes

When outputing styled text in the console, you sometimes face to a confusing behavior: style tags cannot be nested. If tou try something like `<fg=blue>Hello <fg=red>world</fg=red>!</fg=blue>`, the trailing `!` will not be styled.

This PR introduce a new FormatterOutputStyleStack to keep open/closed styles informations up-to-date. It slightly changes OutputFormatter implementation which no longer uses `OutputFormatterStyle::apply()` method, but the new `OutputFormatterStyle::getTerminalSequence()`.

**Question:** I don't une `OutputFormatterStyleInterface` but `OutputFormatterStyle` to type `OutputFormatterStyleStack` methods arguments (to avoid BC break on the interface). Do you think it's right?

**Notice:** I also needed to fix some tests broken by new implementation.

---------------------------------------------------------------------------

by stof at 2012-03-16T10:27:56Z

Adding new methods in an interface is a BC break for people implementing it

---------------------------------------------------------------------------

by jfsimon at 2012-03-16T10:33:21Z

@stof indeed... this is a problem, should I remove them? If I do so, I should use `OutputFormatterStyle` instead of the interface to type arguments in `OutputFormatterStyleStack` right?
This commit is contained in:
Fabien Potencier 2012-04-03 12:02:29 +02:00
commit 959158f9b9
5 changed files with 300 additions and 28 deletions

View File

@ -23,10 +23,11 @@ class OutputFormatter implements OutputFormatterInterface
/**
* The pattern to phrase the format.
*/
const FORMAT_PATTERN = '#<([a-z][a-z0-9_=;-]+)>(.*?)</\\1?>#is';
const FORMAT_PATTERN = '#<(/?)([a-z][a-z0-9_=;-]+)?>#is';
private $decorated;
private $styles = array();
private $styleStack;
/**
* Initializes console output formatter.
@ -48,6 +49,8 @@ class OutputFormatter implements OutputFormatterInterface
foreach ($styles as $name => $style) {
$this->setStyle($name, $style);
}
$this->styleStack = new OutputFormatterStyleStack();
}
/**
@ -144,21 +147,30 @@ class OutputFormatter implements OutputFormatterInterface
*/
private function replaceStyle($match)
{
if (!$this->isDecorated()) {
return $match[2];
// Special "</>" tag resets all styles.
if (!isset($match[2])) {
$this->styleStack->reset();
return $this->isDecorated() ? "\033[0m" : '';
}
if (isset($this->styles[strtolower($match[1])])) {
$style = $this->styles[strtolower($match[1])];
if (isset($this->styles[strtolower($match[2])])) {
$style = $this->styles[strtolower($match[2])];
} else {
$style = $this->createStyleFromString($match[1]);
$style = $this->createStyleFromString($match[2]);
if (false === $style) {
return $match[0];
return '';
}
}
return $style->apply($this->format($match[2]));
if ('/' === $match[1]) {
$this->styleStack->popStyle($style);
} else {
$this->styleStack->pushStyle($style);
}
return $this->isDecorated() ? $this->styleStack->getCurrentStyle()->getTerminalSequence() : '';
}
/**
@ -166,7 +178,7 @@ class OutputFormatter implements OutputFormatterInterface
*
* @param string $string
*
* @return Symfony\Component\Console\Format\FormatterStyle|Boolean false if string is not format string
* @return \Symfony\Component\Console\Formatter\OutputFormatterStyle|Boolean false if string is not format string
*/
private function createStyleFromString($string)
{

View File

@ -102,6 +102,22 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
$this->foreground = static::$availableForegroundColors[$color];
}
/**
* Gets style foreground color.
*
* @return string|null
*/
public function getForeground()
{
if (null === $this->foreground) {
return null;
}
$names = array_flip(static::$availableForegroundColors);
return $names[$this->foreground];
}
/**
* Sets style background color.
*
@ -130,6 +146,22 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
$this->background = static::$availableBackgroundColors[$color];
}
/**
* Gets style background color.
*
* @return string|null
*/
public function getBackground()
{
if (null === $this->background) {
return null;
}
$names = array_flip(static::$availableBackgroundColors);
return $names[$this->background];
}
/**
* Sets some specific style option.
*
@ -192,6 +224,23 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
}
}
/**
* Gets specific style options.
*
* @return array
*/
public function getOptions()
{
$names = array_flip(static::$availableOptions);
$options = array();
foreach ($this->options as $code) {
$options[] = $names[$code];
}
return $options;
}
/**
* Applies the style to a given text.
*
@ -200,6 +249,16 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
* @return string
*/
public function apply($text)
{
return sprintf("%s%s\033[0m", $this->getTerminalSequence(), $text);
}
/**
* Gets terminal colorization sequence.
*
* @return string
*/
public function getTerminalSequence()
{
$codes = array();
@ -212,7 +271,10 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface
if (count($this->options)) {
$codes = array_merge($codes, $this->options);
}
if (0 === count($codes)) {
$codes = array(0);
}
return sprintf("\033[%sm%s\033[0m", implode(';', $codes), $text);
return sprintf("\033[%sm", implode(';', $codes));
}
}

View File

@ -0,0 +1,136 @@
<?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;
/**
* @author: Jean-François Simon <contact@jfsimon.fr>
*/
class OutputFormatterStyleStack
{
/**
* @var array
*/
private $foregrounds;
/**
* @var array
*/
private $backgrounds;
/**
* @var array
*/
private $options;
/**
* Constructor.
*/
public function __construct()
{
$this->reset();
}
/**
* Resets stack (ie. empty internal arrays).
*/
public function reset()
{
$this->foregrounds = array();
$this->backgrounds = array();
$this->options = array();
}
/**
* Pushes a style in the stack.
*
* @param OutputFormatterStyle $style
*/
public function pushStyle(OutputFormatterStyle $style)
{
$foreground = $style->getForeground();
if (null !== $foreground) {
$this->foregrounds[] = $foreground;
}
$background = $style->getBackground();
if (null !== $background) {
$this->backgrounds[] = $background;
}
foreach ($style->getOptions() as $option) {
if (!isset($this->options[$option])) {
$this->options[$option] = 0;
}
$this->options[$option] += 1;
}
}
/**
* Pops a style from the stack.
*
* @param OutputFormatterStyle $style
*
* @throws \InvalidArgumentException When style tags incorrectly nested
*/
public function popStyle(OutputFormatterStyle $style)
{
$this->popArrayCode($this->foregrounds, $style->getForeground());
$this->popArrayCode($this->backgrounds, $style->getBackground());
foreach ($style->getOptions() as $option) {
if (!isset($this->options[$option])) {
throw new \InvalidArgumentException('Unexpected style "'.$option.'" option end.');
}
$this->options[$option] -= 1;
if (0 === $this->options[$option]) {
unset($this->options[$option]);
}
}
}
/**
* Computes current style with stacks top codes.
*
* @return OutputFormatterStyle
*/
public function getCurrentStyle()
{
return new OutputFormatterStyle(
end($this->foregrounds) ?: null,
end($this->backgrounds) ?: null,
array_keys($this->options)
);
}
/**
* Pops a color from a stack.
*
* @param array $stack An array of color names
* @param string $color A color name
*
* @throws \InvalidArgumentException When poped color is not the expected one
*/
private function popArrayCode(&$stack, $color)
{
if (null === $color) {
return;
}
$current = end($stack);
if ($current !== $color) {
throw new \InvalidArgumentException('Expected style "'.$current.'" color end but "'.$color.'" end found.');
}
array_pop($stack);
}
}

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Console\Tests\Formatter;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
class FormatterStyleTest extends \PHPUnit_Framework_TestCase
{
@ -43,7 +44,7 @@ class FormatterStyleTest extends \PHPUnit_Framework_TestCase
$formatter = new OutputFormatter(true);
$this->assertEquals(
"\033[37;41msome \033[32msome info\033[0m error\033[0m", $formatter->format('<error>some <info>some info</info> error</error>')
"\033[37;41msome \033[32;41msome info\033[37;41m error\033[0m", $formatter->format('<error>some <info>some info</info> error</error>')
);
}
@ -51,36 +52,23 @@ class FormatterStyleTest extends \PHPUnit_Framework_TestCase
{
$formatter = new OutputFormatter(true);
$style = $this->getMockBuilder('Symfony\Component\Console\Formatter\OutputFormatterStyleInterface')->getMock();
$style = new OutputFormatterStyle('red', 'blue');
$formatter->setStyle('test', $style);
$this->assertEquals($style, $formatter->getStyle('test'));
$this->assertNotEquals($style, $formatter->getStyle('info'));
$style
->expects($this->once())
->method('apply')
->will($this->returnValue('[STYLE_BEG]some custom msg[STYLE_END]'));
$this->assertEquals("[STYLE_BEG]some custom msg[STYLE_END]", $formatter->format('<test>some custom msg</test>'));
$this->assertEquals("\033[31;44msome custom msg\033[0m", $formatter->format('<test>some custom msg</test>'));
}
public function testRedefineStyle()
{
$formatter = new OutputFormatter(true);
$style = $this->getMockBuilder('Symfony\Component\Console\Formatter\OutputFormatterStyleInterface')
->getMock();
$style = new OutputFormatterStyle('red', 'blue');
$formatter->setStyle('info', $style);
$style
->expects($this->once())
->method('apply')
->will($this->returnValue('[STYLE_BEG]some custom msg[STYLE_END]'));
$this->assertEquals(
"[STYLE_BEG]some custom msg[STYLE_END]", $formatter->format('<info>some custom msg</info>')
);
$this->assertEquals("\033[31;44msome custom msg\033[0m", $formatter->format('<info>some custom msg</info>'));
}
public function testInlineStyle()

View File

@ -0,0 +1,74 @@
<?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\Tests\Component\Console\Formatter;
use Symfony\Component\Console\Formatter\OutputFormatterStyleStack;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
class OutputFormatterStyleStackTest extends \PHPUnit_Framework_TestCase
{
public function testPush()
{
$stack = new OutputFormatterStyleStack();
$stack->pushStyle(new OutputFormatterStyle('white'));
$stack->pushStyle(new OutputFormatterStyle(null, 'black'));
$stack->pushStyle(new OutputFormatterStyle(null, null, array('bold')));
$style = $stack->getCurrentStyle();
$this->assertEquals('white', $style->getForeground());
$this->assertEquals('black', $style->getBackground());
$this->assertEquals(array('bold'), $style->getOptions());
$stack->pushStyle(new OutputFormatterStyle('yellow', null, array('blink')));
$style = $stack->getCurrentStyle();
$this->assertEquals('yellow', $style->getForeground());
$this->assertEquals('black', $style->getBackground());
$this->assertEquals(array('bold', 'blink'), $style->getOptions());
}
public function testPop()
{
$stack = new OutputFormatterStyleStack();
$stack->pushStyle(new OutputFormatterStyle('white', 'black', array('blink', 'bold')));
$stack->pushStyle(new OutputFormatterStyle('yellow', 'blue'));
$style = $stack->getCurrentStyle();
$this->assertEquals('yellow', $style->getForeground());
$this->assertEquals('blue', $style->getBackground());
$this->assertEquals(array('blink', 'bold'), $style->getOptions());
$stack->popStyle(new OutputFormatterStyle(null, 'blue', array('blink')));
$style = $stack->getCurrentStyle();
$this->assertEquals('yellow', $style->getForeground());
$this->assertEquals('black', $style->getBackground());
$this->assertEquals(array('bold'), $style->getOptions());
$stack->popStyle(new OutputFormatterStyle('yellow', 'black', array('bold')));
$style = $stack->getCurrentStyle();
$this->assertEquals('white', $style->getForeground());
$this->assertEquals(null, $style->getBackground());
$this->assertEquals(array(), $style->getOptions());
}
/**
* @expectedException InvalidArgumentException
*/
public function testInvalidPop()
{
$stack = new OutputFormatterStyleStack();
$stack->pushStyle(new OutputFormatterStyle('white', 'black', array('blink', 'bold')));
$stack->popStyle(new OutputFormatterStyle('yellow'));
}
}