From aaeb98275c20fde77c1f005211b62a97f1dc3da8 Mon Sep 17 00:00:00 2001 From: Matthew Weier O'Phinney Date: Mon, 12 Sep 2016 12:36:13 -0500 Subject: [PATCH] new feature: ConsoleHelper Provides the following: - Console output using `STDOUT` for meaningful, expected output - Console output using `STDERR` for error messages - Ensures any line breaks are converted to `PHP_EOL` - Optionally provides console color escape sequences to provide context, which means: - Detecting whether or not the console supports colors in the first place - Providing appropriate escape sequences to produce color --- doc/book/console-helper.md | 126 +++++++++++++++++++++++++++++ mkdocs.yml | 2 + src/ConsoleHelper.php | 158 +++++++++++++++++++++++++++++++++++++ test/ConsoleHelperTest.php | 148 ++++++++++++++++++++++++++++++++++ 4 files changed, 434 insertions(+) create mode 100644 doc/book/console-helper.md create mode 100644 src/ConsoleHelper.php create mode 100644 test/ConsoleHelperTest.php diff --git a/doc/book/console-helper.md b/doc/book/console-helper.md new file mode 100644 index 000000000..0a275667e --- /dev/null +++ b/doc/book/console-helper.md @@ -0,0 +1,126 @@ +# Console Helper + +Writing one-off scripts or vendor binaries for a package is often problematic: + +- You need to parse arguments manually. +- You need to send output to the console in a meaningful fashion: + - Using `STDOUT` for meaningful, expected output + - Using `STDERR` for error messages + - Ensuring any line breaks are converted to `PHP_EOL` + - Optionally, using console colors to provide context, which means: + - Detecting whether or not the console supports colors in the first place + - Providing appropriate escape sequences to produce color + +`Zend\Stdlib\ConsoleHelper` helps to address the second major bullet point and +all beneath it in a minimal fashion. + +## Usage + +Typical usage is to instantiate a `ConsoleHelper`, and call one of its methods: + +```php +use Zend\Stdlib\ConsoleHelper; + +$helper = new ConsoleHelper(); +$helper->writeLine('This is output'); +``` + +You can optionally pass a PHP stream resource to the constructor, which will be +used to determine whether or not color support is available: + +```php +$helper = new ConsoleHelper($stream); +``` + +By default, it assumes `STDOUT`, and tests against that. + +## Available methods + +`ConsoleHelper` provides the following methods. + +### colorize + +- `colorize(string $string) : string` + +`colorize()` accepts a formatted string, and will then apply ANSI color +sequences to them, if color support is detected. + +The following sequences are currently supported: + +- `...` will apply a green color sequence around the provided text. +- `...` will apply a red color sequence around the provided text. + +You may mix multiple sequences within the same stream. + +### write + +- `write(string $string, bool $colorize = true, resource $stream = STDOUT) : void` + +Emits the provided `$string` to the provided `$stream` (which defaults to +`STDOUT` if not provided). Any EOL sequences are convered to `PHP_EOL`. If +`$colorize` is `true`, the string is first passed to `colorize()` as well. + +### writeline + +- `writeLine(string $string, bool $colorize = true, resource $stream = STDOUT) : void` + +Same as `write()`, except it also appends a `PHP_EOL` sequence to the `$string`. + +### writeErrorMessage + +- `writeErrorMessage(string $message)` + +Wraps `$message` in an `` sequence, and passes it to +`writeLine()`, using `STDERR` as the `$stream`. + +## Example + +Below is an example class that accepts an argument list, and determines how and +what to emit. + +```php +namespace Foo; + +use Zend\Stdlib\ConsoleHelper; + +class HelloWorld +{ + private $helper; + + public function __construct(ConsoleHelper $helper = null) + { + $this->helper = $helper ?: new ConsoleHelper(); + } + + public function __invoke(array $args) + { + if (! count($args)) { + $this->helper->writeErrorMessage('Missing arguments!'); + return; + } + + if (count($args) > 1) { + $this->helper->writeErrorMessage('Too many arguments!'); + return; + } + + $target = array_shift($args); + + $this->helper->writeLine(sprintf( + 'Hello %s', + $target + )); + } +} +``` + +## When to upgrade + +`ConsoleHelper` is deliberately simple, and assumes that your primary need for +console tooling is for output considerations. + +If you need to parse complex argument strings, we recommend using +[zend-console](https://docs.zendframework.com/zend-console/)/[zf-console](https://github.com/zfcampus/zf-console) +or [symfony/console](http://symfony.com/doc/current/components/console.html), +as these packages provide those capabilities, as well as far more colorization +and console feature detection facilities. diff --git a/mkdocs.yml b/mkdocs.yml index bf87a22ad..89f84200d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,6 +2,8 @@ docs_dir: doc/book site_dir: doc/html pages: - index.md + - Reference: + - "Console Helper": console-helper.md - Migration: migration.md site_name: zend-stdlib site_description: Zend\Stdlib diff --git a/src/ConsoleHelper.php b/src/ConsoleHelper.php new file mode 100644 index 000000000..79a65c803 --- /dev/null +++ b/src/ConsoleHelper.php @@ -0,0 +1,158 @@ +message`, + * `message`) + * - Write output to a specified stream, optionally with colorization. + * - Write a line of output to a specified stream, optionally with + * colorization, using the system EOL sequence.. + * - Write an error message to STDERR. + * + * Colorization will only occur when expected sequences are discovered, and + * then, only if the console terminal allows it. + * + * Essentially, provides the bare minimum to allow you to provide messages to + * the current console. + */ +class ConsoleHelper +{ + const COLOR_GREEN = "\033[32m"; + const COLOR_RED = "\033[31m"; + const COLOR_RESET = "\033[0m"; + + const HIGHLIGHT_INFO = 'info'; + const HIGHLIGHT_ERROR = 'error'; + + private $highlightMap = [ + self::HIGHLIGHT_INFO => self::COLOR_GREEN, + self::HIGHLIGHT_ERROR => self::COLOR_RED, + ]; + + /** + * @var string Exists only for testing. + */ + private $eol = PHP_EOL; + + /** + * @var resource Exists only for testing. + */ + private $stderr = STDERR; + + /** + * @var bool + */ + private $supportsColor; + + /** + * @param resource $resource + */ + public function __construct($resource = STDOUT) + { + $this->supportsColor = $this->detectColorCapabilities($resource); + } + + /** + * Colorize a string for use with the terminal. + * + * Takes strings formatted as `string` and formats them per the + * $highlightMap; if color support is disabled, simply removes the formatting + * tags. + * + * @param string $string + * @return string + */ + public function colorize($string) + { + $reset = $this->supportsColor ? self::COLOR_RESET : ''; + foreach ($this->highlightMap as $key => $color) { + $pattern = sprintf('#<%s>(.*?)#s', $key, $key); + $color = $this->supportsColor ? $color : ''; + $string = preg_replace($pattern, $color . '$1' . $reset, $string); + } + return $string; + } + + /** + * @param string $string + * @param bool $colorize Whether or not to colorize the string + * @param resource $resource Defaults to STDOUT + * @return void + */ + public function write($string, $colorize = true, $resource = STDOUT) + { + if ($colorize) { + $string = $this->colorize($string); + } + + $string = $this->formatNewlines($string); + + fwrite($resource, $string); + } + + /** + * @param string $string + * @param bool $colorize Whether or not to colorize the line + * @param resource $resource Defaults to STDOUT + * @return void + */ + public function writeLine($string, $colorize = true, $resource = STDOUT) + { + $this->write($string . $this->eol, $colorize, $resource); + } + + /** + * Emit an error message. + * + * Wraps the message in ``, and passes it to `writeLine()`, + * using STDERR as the resource; emits an additional empty line when done, + * also to STDERR. + * + * @param string $message + * @return void + */ + public function writeErrorMessage($message) + { + $this->writeLine(sprintf('%s', $message), true, $this->stderr); + $this->writeLine('', false, $this->stderr); + } + + /** + * @param resource $resource + * @return bool + */ + private function detectColorCapabilities($resource = STDOUT) + { + if ('\\' === DIRECTORY_SEPARATOR) { + // Windows + return false !== getenv('ANSICON') + || 'ON' === getenv('ConEmuANSI') + || 'xterm' === getenv('TERM'); + } + + return function_exists('posix_isatty') && posix_isatty($resource); + } + + /** + * Ensure newlines are appropriate for the current terminal. + * + * @param string + * @return string + */ + private function formatNewlines($string) + { + $string = str_replace($this->eol, "\0PHP_EOL\0", $string); + $string = preg_replace("/(\r\n|\n|\r)/", $this->eol, $string); + return str_replace("\0PHP_EOL\0", $this->eol, $string); + } +} diff --git a/test/ConsoleHelperTest.php b/test/ConsoleHelperTest.php new file mode 100644 index 000000000..ee72f0c1b --- /dev/null +++ b/test/ConsoleHelperTest.php @@ -0,0 +1,148 @@ +helper = new ConsoleHelper(); + } + + public function disableColorSupport() + { + $r = new ReflectionProperty($this->helper, 'supportsColor'); + $r->setAccessible(true); + $r->setValue($this->helper, false); + } + + public function enableColorSupport() + { + $r = new ReflectionProperty($this->helper, 'supportsColor'); + $r->setAccessible(true); + $r->setValue($this->helper, true); + } + + public function overrideEolSequence($newSequence) + { + $r = new ReflectionProperty($this->helper, 'eol'); + $r->setAccessible(true); + $r->setValue($this->helper, $newSequence); + } + + public function overrideStderrResource($stderr) + { + $r = new ReflectionProperty($this->helper, 'stderr'); + $r->setAccessible(true); + $r->setValue($this->helper, $stderr); + } + + public function retrieveStreamContents($stream) + { + rewind($stream); + $contents = ''; + while (! feof($stream)) { + $contents .= fread($stream, 4096); + } + return $contents; + } + + public function testCanColorizeInfoString() + { + $string = ' -h|--help This help message'; + $this->enableColorSupport(); + $colorized = $this->helper->colorize($string); + + $this->assertEquals(" \033[32m-h|--help\033[0m This help message", $colorized); + } + + public function testCanColorizeErrorString() + { + $string = 'NOT OK An error occurred'; + $this->enableColorSupport(); + $colorized = $this->helper->colorize($string); + + $this->assertEquals("\033[31mNOT OK\033[0m An error occurred", $colorized); + } + + public function testCanColorizeMixedStrings() + { + $this->enableColorSupport(); + $string = "NOT OK\n\nUsage: foo"; + $colorized = $this->helper->colorize($string); + + $this->assertContains("\033[31mNOT OK\033[0m", $colorized, 'Colorized error string not found'); + $this->assertContains("\033[32mUsage:\033[0m", $colorized, 'Colorized info string not found'); + } + + public function testColorizationWillReplaceTagsWithEmptyStringsWhenColorSupportIsNotDetected() + { + $this->disableColorSupport(); + $string = "NOT OK\n\nUsage: foo"; + $colorized = $this->helper->colorize($string); + + $this->assertNotContains("\033[31m", $colorized, 'Colorized error string discovered'); + $this->assertNotContains("\033[32m", $colorized, 'Colorized info string discovered'); + $this->assertNotContains("\033[0m", $colorized, 'Color reset sequence discovered'); + $this->assertNotRegexp("/<\/?error>/", $colorized, 'Error template string discovered'); + $this->assertNotRegexp("/<\/?info>/", $colorized, 'Info template string discovered'); + } + + public function testWriteFormatsLinesToPhpEolSequenceAndWritesToProvidedStream() + { + $this->overrideEolSequence("\r\n"); + $string = "foo bar\nbaz bat"; + $stream = fopen('php://temp', 'w+'); + + $this->helper->write($string, false, $stream); + + $contents = $this->retrieveStreamContents($stream); + $this->assertContains("\r\n", $contents); + } + + public function testWriteWillColorizeOutputIfRequested() + { + $this->enableColorSupport(); + $string = 'foo bar'; + $stream = fopen('php://temp', 'w+'); + + $this->helper->write($string, true, $stream); + + $contents = $this->retrieveStreamContents($stream); + $this->assertContains("\033[32mbar\033[0m", $contents); + } + + public function testWriteLineAppendsPhpEolSequenceToString() + { + $this->overrideEolSequence("\r\n"); + $string = 'foo bar'; + $stream = fopen('php://temp', 'w+'); + + $this->helper->writeLine($string, false, $stream); + + $contents = $this->retrieveStreamContents($stream); + $this->assertRegexp("/bar\r\n$/", $contents); + } + + public function testWriteErrorMessageWritesColorizedOutputToStderr() + { + $stderr = fopen('php://temp', 'w+'); + $this->overrideStderrResource($stderr); + $this->enableColorSupport(); + $this->overrideEolSequence("\r\n"); + + $this->helper->writeErrorMessage('an error occurred'); + + $contents = $this->retrieveStreamContents($stderr); + $this->assertEquals("\033[31man error occurred\033[0m\r\n\r\n", $contents); + } +}