diff --git a/.travis.yml b/.travis.yml index 889365a..24f651d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,14 @@ php: - 5.4 - 5.5 - 5.6 + - 7.0 + - 7.1 -script: phpunit +before_script: + - php -m + - php --info | grep -i 'intl\|icu\|pcre' + +script: phpunit --debug notifications: email: diff --git a/lib/cli/Colors.php b/lib/cli/Colors.php index 3efe8ed..6b0a64d 100644 --- a/lib/cli/Colors.php +++ b/lib/cli/Colors.php @@ -91,7 +91,7 @@ static public function color($color) { $colors = array(); foreach (array('color', 'style', 'background') as $type) { - $code = @$color[$type]; + $code = $color[$type]; if (isset(self::$_colors[$type][$code])) { $colors[] = self::$_colors[$type][$code]; } @@ -115,18 +115,17 @@ static public function color($color) { static public function colorize($string, $colored = null) { $passed = $string; - if (isset(self::$_string_cache[md5($passed)]['colorized'])) { - return self::$_string_cache[md5($passed)]['colorized']; - } - if (!self::shouldColorize($colored)) { - $colors = self::getColors(); - $search = array_keys( $colors ); - $return = str_replace( $search, '', $string ); - self::cacheString($passed, $return, $colored); + $return = self::decolorize( $passed, 2 /*keep_encodings*/ ); + self::cacheString($passed, $return); return $return; } + $md5 = md5($passed); + if (isset(self::$_string_cache[$md5]['colorized'])) { + return self::$_string_cache[$md5]['colorized']; + } + $string = str_replace('%%', '%¾', $string); foreach (self::getColors() as $key => $value) { @@ -134,7 +133,7 @@ static public function colorize($string, $colored = null) { } $string = str_replace('%¾', '%', $string); - self::cacheString($passed, $string, $colored); + self::cacheString($passed, $string); return $string; } @@ -143,15 +142,22 @@ static public function colorize($string, $colored = null) { * Remove color information from a string. * * @param string $string A string with color information. + * @param int $keep Optional. If the 1 bit is set, color tokens (eg "%n") won't be stripped. If the 2 bit is set, color encodings (ANSI escapes) won't be stripped. Default 0. * @return string A string with color information removed. */ - static public function decolorize($string) { - // Get rid of color tokens if they exist - $string = str_replace(array_keys(self::getColors()), '', $string); + static public function decolorize( $string, $keep = 0 ) { + if ( ! ( $keep & 1 ) ) { + // Get rid of color tokens if they exist + $string = str_replace('%%', '%¾', $string); + $string = str_replace(array_keys(self::getColors()), '', $string); + $string = str_replace('%¾', '%', $string); + } - // Remove color encoding if it exists - foreach (self::getColors() as $key => $value) { - $string = str_replace(self::color($value), '', $string); + if ( ! ( $keep & 2 ) ) { + // Remove color encoding if it exists + foreach (self::getColors() as $key => $value) { + $string = str_replace(self::color($value), '', $string); + } } return $string; @@ -162,13 +168,13 @@ static public function decolorize($string) { * * @param string $passed The original string before colorization. * @param string $colorized The string after running through self::colorize. - * @param string $colored The string without any color information. + * @param string $deprecated Optional. Not used. Default null. */ - static public function cacheString($passed, $colorized, $colored) { + static public function cacheString( $passed, $colorized, $deprecated = null ) { self::$_string_cache[md5($passed)] = array( 'passed' => $passed, 'colorized' => $colorized, - 'decolorized' => self::decolorize($passed) + 'decolorized' => self::decolorize($passed), // Not very useful but keep for BC. ); } @@ -179,41 +185,36 @@ static public function cacheString($passed, $colorized, $colored) { * @return int */ static public function length($string) { - if (isset(self::$_string_cache[md5($string)]['decolorized'])) { - $test_string = self::$_string_cache[md5($string)]['decolorized']; - } else { - $test_string = self::decolorize($string); - } - - return safe_strlen($test_string); + return safe_strlen( self::decolorize( $string ) ); } /** - * Return the width (length in characters) of the string without color codes. + * Return the width (length in characters) of the string without color codes if enabled. * - * @param string $string the string to measure + * @param string $string The string to measure. + * @param bool $pre_colorized Optional. Set if the string is pre-colorized. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. * @return int */ - static public function width($string) { - $md5 = md5($string); - if (isset(self::$_string_cache[$md5]['decolorized'])) { - $test_string = self::$_string_cache[$md5]['decolorized']; - } else { - $test_string = self::decolorize($string); - } - - return strwidth($test_string); + static public function width( $string, $pre_colorized = false, $encoding = false ) { + return strwidth( $pre_colorized || self::shouldColorize() ? self::decolorize( $string, $pre_colorized ? 1 /*keep_tokens*/ : 0 ) : $string, $encoding ); } /** * Pad the string to a certain display length. * - * @param string $string the string to pad - * @param integer $length the display length + * @param string $string The string to pad. + * @param int $length The display length. + * @param bool $pre_colorized Optional. Set if the string is pre-colorized. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. * @return string */ - static public function pad($string, $length) { - return safe_str_pad( $string, $length ); + static public function pad( $string, $length, $pre_colorized = false, $encoding = false ) { + $real_length = self::width( $string, $pre_colorized, $encoding ); + $diff = strlen( $string ) - $real_length; + $length += $diff; + + return str_pad( $string, $length ); } /** @@ -230,7 +231,7 @@ static public function getColors() { '%p' => array('color' => 'magenta'), '%m' => array('color' => 'magenta'), '%c' => array('color' => 'cyan'), - '%w' => array('color' => 'grey'), + '%w' => array('color' => 'white'), '%k' => array('color' => 'black'), '%n' => array('color' => 'reset'), '%Y' => array('color' => 'yellow', 'style' => 'bright'), @@ -240,7 +241,7 @@ static public function getColors() { '%P' => array('color' => 'magenta', 'style' => 'bright'), '%M' => array('color' => 'magenta', 'style' => 'bright'), '%C' => array('color' => 'cyan', 'style' => 'bright'), - '%W' => array('color' => 'grey', 'style' => 'bright'), + '%W' => array('color' => 'white', 'style' => 'bright'), '%K' => array('color' => 'black', 'style' => 'bright'), '%N' => array('color' => 'reset', 'style' => 'bright'), '%3' => array('background' => 'yellow'), @@ -249,11 +250,11 @@ static public function getColors() { '%1' => array('background' => 'red'), '%5' => array('background' => 'magenta'), '%6' => array('background' => 'cyan'), - '%7' => array('background' => 'grey'), + '%7' => array('background' => 'white'), '%0' => array('background' => 'black'), '%F' => array('style' => 'blink'), '%U' => array('style' => 'underline'), - '%8' => array('style' => 'inverse'), + '%8' => array('style' => 'reverse'), '%9' => array('style' => 'bright'), '%_' => array('style' => 'bright') ); diff --git a/lib/cli/Shell.php b/lib/cli/Shell.php index 7b3f9bb..1f49dde 100755 --- a/lib/cli/Shell.php +++ b/lib/cli/Shell.php @@ -49,8 +49,16 @@ static public function columns() { } } } else { - if ( getenv( 'TERM' ) ) { - $columns = (int) exec( '/usr/bin/env tput cols' ); + if ( ! ( $columns = (int) getenv( 'COLUMNS' ) ) ) { + if ( getenv( 'TERM' ) ) { + $size = exec( '/usr/bin/env stty size 2>/dev/null' ); + if ( '' !== $size && preg_match( '/[0-9]+ ([0-9]+)/', $size, $matches ) ) { + $columns = (int) $matches[1]; + } + if ( ! $columns ) { + $columns = (int) exec( '/usr/bin/env tput cols 2>/dev/null' ); + } + } } } } diff --git a/lib/cli/Table.php b/lib/cli/Table.php index ccbfa8f..ee7f42a 100644 --- a/lib/cli/Table.php +++ b/lib/cli/Table.php @@ -102,7 +102,7 @@ public function setRenderer(Renderer $renderer) { */ protected function checkRow(array $row) { foreach ($row as $column => $str) { - $width = Colors::shouldColorize() ? Colors::width($str) : strwidth($str); + $width = Colors::width( $str, $this->isAsciiPreColorized( $column ) ); if (!isset($this->_width[$column]) || $width > $this->_width[$column]) { $this->_width[$column] = $width; } @@ -228,4 +228,30 @@ public function setRows(array $rows) { public function countRows() { return count($this->_rows); } + + /** + * Set whether items in an Ascii table are pre-colorized. + * + * @param bool|array $precolorized A boolean to set all columns in the table as pre-colorized, or an array of booleans keyed by column index (number) to set individual columns as pre-colorized. + * @see cli\Ascii::setPreColorized() + */ + public function setAsciiPreColorized( $pre_colorized ) { + if ( $this->_renderer instanceof Ascii ) { + $this->_renderer->setPreColorized( $pre_colorized ); + } + } + + /** + * Is a column in an Ascii table pre-colorized? + * + * @param int $column Column index to check. + * @return bool True if whole Ascii table is marked as pre-colorized, or if the individual column is pre-colorized; else false. + * @see cli\Ascii::isPreColorized() + */ + private function isAsciiPreColorized( $column ) { + if ( $this->_renderer instanceof Ascii ) { + return $this->_renderer->isPreColorized( $column ); + } + return false; + } } diff --git a/lib/cli/cli.php b/lib/cli/cli.php index ebda153..7725ada 100755 --- a/lib/cli/cli.php +++ b/lib/cli/cli.php @@ -153,57 +153,160 @@ function menu( $items, $default = null, $title = 'Choose an item' ) { } /** - * Attempts an encoding-safe way of getting string length. If mb_string extensions aren't - * installed, falls back to basic strlen if no encoding is present + * Attempts an encoding-safe way of getting string length. If intl extension or PCRE with '\X' or mb_string extension aren't + * available, falls back to basic strlen. * - * @param string The string to check - * @return int Numeric value that represents the string's length + * @param string $str The string to check. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return int Numeric value that represents the string's length */ -function safe_strlen( $str ) { - if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_encoding' ) ) { - $length = mb_strlen( $str, mb_detect_encoding( $str ) ); - } else { - // iconv will return PHP notice if non-ascii characters are present in input string - $str = iconv( 'ASCII' , 'ASCII', $str ); +function safe_strlen( $str, $encoding = false ) { + // Allow for selective testings - "1" bit set tests grapheme_strlen(), "2" preg_match_all( '/\X/u' ), "4" mb_strlen(), "other" strlen(). + $test_safe_strlen = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); - $length = strlen( $str ); + // Assume UTF-8 if no encoding given - `grapheme_strlen()` will return null if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && null !== ( $length = grapheme_strlen( $str ) ) ) { + if ( ! $test_safe_strlen || ( $test_safe_strlen & 1 ) ) { + return $length; + } } - - return $length; + // Assume UTF-8 if no encoding given - `preg_match_all()` will return false if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() && false !== ( $length = preg_match_all( '/\X/u', $str, $dummy /*needed for PHP 5.3*/ ) ) ) { + if ( ! $test_safe_strlen || ( $test_safe_strlen & 2 ) ) { + return $length; + } + } + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_strlen' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $str, null, true /*strict*/ ); + } + $length = mb_strlen( $str, $encoding ); + if ( 'UTF-8' === $encoding ) { + // Subtract combining characters. + $length -= preg_match_all( get_unicode_regexs( 'm' ), $str, $dummy /*needed for PHP 5.3*/ ); + } + if ( ! $test_safe_strlen || ( $test_safe_strlen & 4 ) ) { + return $length; + } + } + return strlen( $str ); } /** - * Attempts an encoding-safe way of getting a substring. If mb_string extensions aren't - * installed, falls back to ascii substring if no encoding is present + * Attempts an encoding-safe way of getting a substring. If intl extension or PCRE with '\X' or mb_string extension aren't + * available, falls back to substr(). * - * @param string $str The input string - * @param int $start The starting position of the substring - * @param boolean $length Maximum length of the substring - * @return string Substring of string specified by start and length parameters + * @param string $str The input string. + * @param int $start The starting position of the substring. + * @param int|bool|null $length Optional, unless $is_width is set. Maximum length of the substring. Default false. Negative not supported. + * @param int|bool $is_width Optional. If set and encoding is UTF-8, $length (which must be specified) is interpreted as spacing width. Default false. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return bool|string False if given unsupported args, otherwise substring of string specified by start and length parameters */ -function safe_substr( $str, $start, $length = false ) { - if ( function_exists( 'mb_substr' ) && function_exists( 'mb_detect_encoding' ) ) { - $substr = mb_substr( $str, $start, $length, mb_detect_encoding( $str ) ); - } else { - // iconv will return PHP notice if non-ascii characters are present in input string - $str = iconv( 'ASCII' , 'ASCII', $str ); - - $substr = substr( $str, $start, $length ); +function safe_substr( $str, $start, $length = false, $is_width = false, $encoding = false ) { + // Negative $length or $is_width and $length not specified not supported. + if ( $length < 0 || ( $is_width && ( null === $length || false === $length ) ) ) { + return false; } + $have_safe_strlen = false; + // PHP 5.3 substr takes false as full length, PHP > 5.3 takes null - for compat. do `safe_strlen()`. + if ( null === $length || false === $length ) { + $length = safe_strlen( $str, $encoding ); + $have_safe_strlen = true; + } + + // Allow for selective testings - "1" bit set tests grapheme_substr(), "2" preg_match( '/\X/' ), "4" mb_substr(), "8" substr(). + $test_safe_substr = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); - return $substr; + // Assume UTF-8 if no encoding given - `grapheme_substr()` will return false (not null like `grapheme_strlen()`) if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && false !== ( $try = grapheme_substr( $str, $start, $length ) ) ) { + if ( ! $test_safe_substr || ( $test_safe_substr & 1 ) ) { + return $is_width ? _safe_substr_eaw( $try, $length ) : $try; + } + } + // Assume UTF-8 if no encoding given - `preg_match()` will return false if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() ) { + if ( $start < 0 ) { + $start = max( $start + ( $have_safe_strlen ? $length : safe_strlen( $str, $encoding ) ), 0 ); + } + if ( $start ) { + if ( preg_match( '/^\X{' . $start . '}(\X{0,' . $length . '})/u', $str, $matches ) ) { + if ( ! $test_safe_substr || ( $test_safe_substr & 2 ) ) { + return $is_width ? _safe_substr_eaw( $matches[1], $length ) : $matches[1]; + } + } + } else { + if ( preg_match( '/^\X{0,' . $length . '}/u', $str, $matches ) ) { + if ( ! $test_safe_substr || ( $test_safe_substr & 2 ) ) { + return $is_width ? _safe_substr_eaw( $matches[0], $length ) : $matches[0]; + } + } + } + } + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_substr' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $str, null, true /*strict*/ ); + } + // Bug: not adjusting for combining chars. + $try = mb_substr( $str, $start, $length, $encoding ); + if ( 'UTF-8' === $encoding && $is_width ) { + $try = _safe_substr_eaw( $try, $length ); + } + if ( ! $test_safe_substr || ( $test_safe_substr & 4 ) ) { + return $try; + } + } + return substr( $str, $start, $length ); +} + +/** + * Internal function used by `safe_substr()` to adjust for East Asian double-width chars. + * + * @return string + */ +function _safe_substr_eaw( $str, $length ) { + // Set the East Asian Width regex. + $eaw_regex = get_unicode_regexs( 'eaw' ); + + // If there's any East Asian double-width chars... + if ( preg_match( $eaw_regex, $str ) ) { + // Note that if the length ends in the middle of a double-width char, the char is excluded, not included. + + // See if it's all EAW. + if ( preg_match_all( $eaw_regex, $str, $dummy /*needed for PHP 5.3*/ ) === $length ) { + // Just halve the length so (rounded down to a minimum of 1). + $str = mb_substr( $str, 0, max( (int) ( $length / 2 ), 1 ), 'UTF-8' ); + } else { + // Explode string into an array of UTF-8 chars. Based on core `_mb_substr()` in "wp-includes/compat.php". + $chars = preg_split( '/([\x00-\x7f\xc2-\xf4][^\x00-\x7f\xc2-\xf4]*)/', $str, $length + 1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); + $cnt = min( count( $chars ), $length ); + $width = $length; + + for ( $length = 0; $length < $cnt && $width > 0; $length++ ) { + $width -= preg_match( $eaw_regex, $chars[ $length ] ) ? 2 : 1; + } + // Round down to a minimum of 1. + if ( $width < 0 && $length > 1 ) { + $length--; + } + return join( '', array_slice( $chars, 0, $length ) ); + } + } + return $str; } /** * An encoding-safe way of padding string length for display * - * @param string $string The string to pad - * @param int $length The length to pad it to + * @param string $string The string to pad. + * @param int $length The length to pad it to. + * @param string|bool $encoding Optional. The encoding of the string. Default false. * @return string */ -function safe_str_pad( $string, $length ) { - $cleaned_string = Colors::shouldColorize() ? Colors::decolorize( $string ) : $string; - $real_length = strwidth( $cleaned_string ); +function safe_str_pad( $string, $length, $encoding = false ) { + $real_length = strwidth( $string, $encoding ); $diff = strlen( $string ) - $real_length; $length += $diff; @@ -213,34 +316,34 @@ function safe_str_pad( $string, $length ) { /** * Get width of string, ie length in characters, taking into account multi-byte and mark characters for UTF-8, and multi-byte for non-UTF-8. * - * @param string The string to check - * @return int The string's width. + * @param string $string The string to check. + * @param string|bool $encoding Optional. The encoding of the string. Default false. + * @return int The string's width. */ -function strwidth( $string ) { - static $eaw_regex; // East Asian Width regex. Characters that count as 2 characters as they're "wide" or "fullwidth". See http://www.unicode.org/reports/tr11/tr11-19.html - static $m_regex; // Mark characters regex (Unicode property "M") - mark combining "Mc", mark enclosing "Me" and mark non-spacing "Mn" chars that should be ignored for spacing purposes. - if ( null === $eaw_regex ) { - // Load both regexs generated from Unicode data. - require __DIR__ . '/unicode/regex.php'; - } +function strwidth( $string, $encoding = false ) { + // Set the East Asian Width and Mark regexs. + list( $eaw_regex, $m_regex ) = get_unicode_regexs(); // Allow for selective testings - "1" bit set tests grapheme_strlen(), "2" preg_match_all( '/\X/u' ), "4" mb_strwidth(), "other" safe_strlen(). $test_strwidth = getenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); - // Assume UTF-8 - `grapheme_strlen()` will return null if given non-UTF-8 string. - if ( function_exists( 'grapheme_strlen' ) && null !== ( $width = grapheme_strlen( $string ) ) ) { + // Assume UTF-8 if no encoding given - `grapheme_strlen()` will return null if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_icu() && null !== ( $width = grapheme_strlen( $string ) ) ) { if ( ! $test_strwidth || ( $test_strwidth & 1 ) ) { return $width + preg_match_all( $eaw_regex, $string, $dummy /*needed for PHP 5.3*/ ); } } - // Assume UTF-8 - `preg_match_all()` will return false if given non-UTF-8 string (or if PCRE UTF-8 mode is unavailable). - if ( false !== ( $width = preg_match_all( '/\X/u', $string, $dummy /*needed for PHP 5.3*/ ) ) ) { + // Assume UTF-8 if no encoding given - `preg_match_all()` will return false if given non-UTF-8 string. + if ( ( ! $encoding || 'UTF-8' === $encoding ) && can_use_pcre_x() && false !== ( $width = preg_match_all( '/\X/u', $string, $dummy /*needed for PHP 5.3*/ ) ) ) { if ( ! $test_strwidth || ( $test_strwidth & 2 ) ) { return $width + preg_match_all( $eaw_regex, $string, $dummy /*needed for PHP 5.3*/ ); } } - if ( function_exists( 'mb_strwidth' ) && function_exists( 'mb_detect_encoding' ) ) { - $encoding = mb_detect_encoding( $string, null, true /*strict*/ ); + // Legacy encodings and old PHPs will reach here. + if ( function_exists( 'mb_strwidth' ) && ( $encoding || function_exists( 'mb_detect_encoding' ) ) ) { + if ( ! $encoding ) { + $encoding = mb_detect_encoding( $string, null, true /*strict*/ ); + } $width = mb_strwidth( $string, $encoding ); if ( 'UTF-8' === $encoding ) { // Subtract combining characters. @@ -250,5 +353,64 @@ function strwidth( $string ) { return $width; } } - return safe_strlen( $string ); + return safe_strlen( $string, $encoding ); +} + +/** + * Returns whether ICU is modern enough not to flake out. + * + * @return bool + */ +function can_use_icu() { + static $can_use_icu = null; + + if ( null === $can_use_icu ) { + $can_use_icu = defined( 'INTL_ICU_VERSION' ) && version_compare( INTL_ICU_VERSION, '49.1', '>=' ) && function_exists( 'grapheme_strlen' ) && function_exists( 'grapheme_substr' ); + } + + return $can_use_icu; +} + +/** + * Returns whether PCRE Unicode extended grapheme cluster '\X' is available for use. + * + * @return bool + */ +function can_use_pcre_x() { + static $can_use_pcre_x = null; + + if ( null === $can_use_pcre_x ) { + // '\X' introduced (as Unicde extended grapheme cluster) in PCRE 8.32 - see https://vcs.pcre.org/pcre/code/tags/pcre-8.32/ChangeLog?view=markup line 53. + // Older versions of PCRE were bundled with PHP <= 5.3.23 & <= 5.4.13. + $pcre_version = substr( PCRE_VERSION, 0, strspn( PCRE_VERSION, '0123456789.' ) ); // Remove any trailing date stuff. + $can_use_pcre_x = version_compare( $pcre_version, '8.32', '>=' ) && false !== @preg_match( '/\X/u', '' ); + } + + return $can_use_pcre_x; +} + +/** + * Get the regexs generated from Unicode data. + * + * @param string $idx Optional. Return a specific regex only. Default null. + * @return array|string Returns keyed array if not given $idx or $idx doesn't exist, otherwise the specific regex string. + */ +function get_unicode_regexs( $idx = null ) { + static $eaw_regex; // East Asian Width regex. Characters that count as 2 characters as they're "wide" or "fullwidth". See http://www.unicode.org/reports/tr11/tr11-19.html + static $m_regex; // Mark characters regex (Unicode property "M") - mark combining "Mc", mark enclosing "Me" and mark non-spacing "Mn" chars that should be ignored for spacing purposes. + if ( null === $eaw_regex ) { + // Load both regexs generated from Unicode data. + require __DIR__ . '/unicode/regex.php'; + } + + if ( null !== $idx ) { + if ( 'eaw' === $idx ) { + return $eaw_regex; + } + if ( 'm' === $idx ) { + return $m_regex; + } + } + + return array( $eaw_regex, $m_regex, ); } diff --git a/lib/cli/table/Ascii.php b/lib/cli/table/Ascii.php index 9d9bbf9..d59729b 100644 --- a/lib/cli/table/Ascii.php +++ b/lib/cli/table/Ascii.php @@ -27,6 +27,7 @@ class Ascii extends Renderer { ); protected $_border = null; protected $_constraintWidth = null; + protected $_pre_colorized = false; /** * Set the widths of each column in the table. @@ -39,8 +40,8 @@ public function setWidths(array $widths) { $this->_constraintWidth = (int) Shell::columns(); } $col_count = count( $widths ); - $col_borders_count = $col_count * strlen( $this->_characters['border'] ); - $table_borders_count = strlen( $this->_characters['border'] ) * 1; + $col_borders_count = $col_count ? ( ( $col_count - 1 ) * strlen( $this->_characters['border'] ) ) : 0; + $table_borders_count = strlen( $this->_characters['border'] ) * 2; $col_padding_count = $col_count * strlen( $this->_characters['padding'] ) * 2; $max_width = $this->_constraintWidth - $col_borders_count - $table_borders_count - $col_padding_count; @@ -62,11 +63,11 @@ public function setWidths(array $widths) { foreach( $widths as &$width ) { if ( in_array( $width, $resize_widths ) ) { $width = $avg + $avg_extra_width; - $extra_width = $extra_width - $avg_extra_width; array_shift( $resize_widths ); // Last item gets the cake if ( empty( $resize_widths ) ) { - $width = $width + $extra_width; + $width = 0; // Zero it so not in sum. + $width = $max_width - array_sum( $widths ); } } } @@ -130,20 +131,21 @@ public function row( array $row ) { foreach( $row as $col => $value ) { - $value = str_replace( PHP_EOL, ' ', $value ); + $value = str_replace( array( "\r\n", "\n" ), ' ', $value ); $col_width = $this->_widths[ $col ]; - $original_val_width = Colors::shouldColorize() ? Colors::width( $value ) : \cli\strwidth( $value ); - if ( $original_val_width > $col_width ) { - $row[ $col ] = \cli\safe_substr( $value, 0, $col_width ); - $value = \cli\safe_substr( $value, $col_width, $original_val_width ); + $encoding = function_exists( 'mb_detect_encoding' ) ? mb_detect_encoding( $value, null, true /*strict*/ ) : false; + $original_val_width = Colors::width( $value, self::isPreColorized( $col ), $encoding ); + if ( $col_width && $original_val_width > $col_width ) { + $row[ $col ] = \cli\safe_substr( $value, 0, $col_width, true /*is_width*/, $encoding ); + $value = \cli\safe_substr( $value, \cli\safe_strlen( $row[ $col ], $encoding ), null /*length*/, false /*is_width*/, $encoding ); $i = 0; do { - $extra_value = \cli\safe_substr( $value, 0, $col_width ); - $val_width = \cli\strwidth( $extra_value ); + $extra_value = \cli\safe_substr( $value, 0, $col_width, true /*is_width*/, $encoding ); + $val_width = Colors::width( $extra_value, self::isPreColorized( $col ), $encoding ); if ( $val_width ) { $extra_rows[ $col ][] = $extra_value; - $value = \cli\safe_substr( $value, $col_width, $original_val_width ); + $value = \cli\safe_substr( $value, \cli\safe_strlen( $extra_value, $encoding ), null /*length*/, false /*is_width*/, $encoding ); $i++; if ( $i > $extra_row_count ) { $extra_row_count = $i; @@ -188,6 +190,31 @@ public function row( array $row ) { } private function padColumn($content, $column) { - return $this->_characters['padding'] . Colors::pad($content, $this->_widths[$column]) . $this->_characters['padding']; + return $this->_characters['padding'] . Colors::pad( $content, $this->_widths[ $column ], $this->isPreColorized( $column ) ) . $this->_characters['padding']; + } + + /** + * Set whether items are pre-colorized. + * + * @param bool|array $colorized A boolean to set all columns in the table as pre-colorized, or an array of booleans keyed by column index (number) to set individual columns as pre-colorized. + */ + public function setPreColorized( $pre_colorized ) { + $this->_pre_colorized = $pre_colorized; + } + + /** + * Is a column pre-colorized? + * + * @param int $column Column index to check. + * @return bool True if whole table is marked as pre-colorized, or if the individual column is pre-colorized; else false. + */ + public function isPreColorized( $column ) { + if ( is_bool( $this->_pre_colorized ) ) { + return $this->_pre_colorized; + } + if ( is_array( $this->_pre_colorized ) && isset( $this->_pre_colorized[ $column ] ) ) { + return $this->_pre_colorized[ $column ]; + } + return false; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9c72a0c..31af8b9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,6 +2,13 @@ require dirname( dirname( __FILE__ ) ) . '/lib/cli/cli.php'; +/** + * Compatibility with PHPUnit 6+ + */ +if ( class_exists( 'PHPUnit\Runner\Version' ) ) { + require_once dirname( __FILE__ ) . '/phpunit6-compat.php'; +} + function cli_autoload( $className ) { $className = ltrim($className, '\\'); $fileName = ''; @@ -20,4 +27,4 @@ function cli_autoload( $className ) { require dirname( dirname( __FILE__ ) ) . '/lib/' . $fileName; } -spl_autoload_register( 'cli_autoload' ); \ No newline at end of file +spl_autoload_register( 'cli_autoload' ); diff --git a/tests/phpunit6-compat.php b/tests/phpunit6-compat.php new file mode 100644 index 0000000..73d41cf --- /dev/null +++ b/tests/phpunit6-compat.php @@ -0,0 +1,19 @@ +=' ) ) { + + class_alias( 'PHPUnit\Framework\TestCase', 'PHPUnit_Framework_TestCase' ); + class_alias( 'PHPUnit\Framework\Exception', 'PHPUnit_Framework_Exception' ); + class_alias( 'PHPUnit\Framework\ExpectationFailedException', 'PHPUnit_Framework_ExpectationFailedException' ); + class_alias( 'PHPUnit\Framework\Error\Notice', 'PHPUnit_Framework_Error_Notice' ); + class_alias( 'PHPUnit\Framework\Error\Warning', 'PHPUnit_Framework_Error_Warning' ); + class_alias( 'PHPUnit\Framework\Test', 'PHPUnit_Framework_Test' ); + class_alias( 'PHPUnit\Framework\Warning', 'PHPUnit_Framework_Warning' ); + class_alias( 'PHPUnit\Framework\AssertionFailedError', 'PHPUnit_Framework_AssertionFailedError' ); + class_alias( 'PHPUnit\Framework\TestSuite', 'PHPUnit_Framework_TestSuite' ); + class_alias( 'PHPUnit\Framework\TestListener', 'PHPUnit_Framework_TestListener' ); + class_alias( 'PHPUnit\Util\GlobalState', 'PHPUnit_Util_GlobalState' ); + class_alias( 'PHPUnit\Util\Getopt', 'PHPUnit_Util_Getopt' ); + +} diff --git a/tests/test-cli.php b/tests/test-cli.php index 063c768..6ea4674 100644 --- a/tests/test-cli.php +++ b/tests/test-cli.php @@ -1,5 +1,7 @@ assertEquals( 22, strlen( \cli\Colors::pad( \cli\Colors::colorize( "%Gx%n", true ), 11 ))); // colorized `x` string - $this->assertEquals( 23, strlen( \cli\Colors::pad( \cli\Colors::colorize( "%Góra%n", true ), 11 ))); // colorized `óra` string + // Colors enabled. + Colors::enable( true ); + + $x = Colors::colorize( '%Gx%n', true ); // colorized `x` string + $ora = Colors::colorize( "%Góra%n", true ); // colorized `óra` string + + $this->assertSame( 22, strlen( Colors::pad( $x, 11 ) ) ); + $this->assertSame( 22, strlen( Colors::pad( $x, 11, false /*pre_colorized*/ ) ) ); + $this->assertSame( 22, strlen( Colors::pad( $x, 11, true /*pre_colorized*/ ) ) ); + + $this->assertSame( 23, strlen( Colors::pad( $ora, 11 ) ) ); // +1 for two-byte "ó". + $this->assertSame( 23, strlen( Colors::pad( $ora, 11, false /*pre_colorized*/ ) ) ); + $this->assertSame( 23, strlen( Colors::pad( $ora, 11, true /*pre_colorized*/ ) ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + $this->assertSame( 20, strlen( Colors::pad( $x, 20 ) ) ); + $this->assertSame( 20, strlen( Colors::pad( $x, 20, false /*pre_colorized*/ ) ) ); + $this->assertSame( 31, strlen( Colors::pad( $x, 20, true /*pre_colorized*/ ) ) ); + + $this->assertSame( 21, strlen( Colors::pad( $ora, 20 ) ) ); // +1 for two-byte "ó". + $this->assertSame( 21, strlen( Colors::pad( $ora, 20, false /*pre_colorized*/ ) ) ); + $this->assertSame( 32, strlen( Colors::pad( $ora, 20, true /*pre_colorized*/ ) ) ); } function test_encoded_substr() { @@ -55,6 +80,157 @@ function test_encoded_substr() { $this->assertEquals( \cli\safe_substr( \cli\Colors::pad( 'óra', 6), 0, 2 ), 'ór' ); $this->assertEquals( \cli\safe_substr( \cli\Colors::pad( '日本語', 6), 0, 2 ), '日本' ); + $this->assertSame( 'el', \cli\safe_substr( Colors::pad( 'hello', 6 ), 1, 2 ) ); + + $this->assertSame( 'a ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 2, 2 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 5, 2 ) ); + + $this->assertSame( '本語', \cli\safe_substr( Colors::pad( '日本語', 8 ), 1, 2 ) ); + $this->assertSame( '語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), 2, 2 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -1 ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -1, 2 ) ); + $this->assertSame( '語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), -3, 3 ) ); + } + + function test_various_substr() { + // Save. + $test_safe_substr = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + if ( function_exists( 'mb_detect_order' ) ) { + $mb_detect_order = mb_detect_order(); + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + + // Latin, kana, Latin, Latin combining, Thai combining, Hangul. + $str = 'lムnöม้p를'; + + if ( \cli\can_use_icu() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=1' ); // Tests grapheme_substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, 0, 4 ) ); + $this->assertSame( 'lムnöม้', \cli\safe_substr( $str, 0, 5 ) ); + $this->assertSame( 'lムnöม้p', \cli\safe_substr( $str, 0, 6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 7 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 8 ) ); + $this->assertSame( '를', \cli\safe_substr( $str, -1 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $str, -2 ) ); + $this->assertSame( 'ม้p를', \cli\safe_substr( $str, -3 ) ); + $this->assertSame( 'öม้p를', \cli\safe_substr( $str, -4 ) ); + $this->assertSame( 'öม้p', \cli\safe_substr( $str, -4, 3 ) ); + $this->assertSame( 'nö', \cli\safe_substr( $str, -5, 2 ) ); + $this->assertSame( 'ム', \cli\safe_substr( $str, -6, 1 ) ); + $this->assertSame( 'ムnöม้p를', \cli\safe_substr( $str, -6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -7 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, -7, 4 ) ); + // $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -8 ) ); // grapheme_substr() returns false on this. + } + + if ( \cli\can_use_pcre_x() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=2' ); // Tests preg_match( '/\X/u' ). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, 0, 4 ) ); + $this->assertSame( 'lムnöม้', \cli\safe_substr( $str, 0, 5 ) ); + $this->assertSame( 'lムnöม้p', \cli\safe_substr( $str, 0, 6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 7 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, 0, 8 ) ); + $this->assertSame( '를', \cli\safe_substr( $str, -1 ) ); + $this->assertSame( 'p를', \cli\safe_substr( $str, -2 ) ); + $this->assertSame( 'ม้p를', \cli\safe_substr( $str, -3 ) ); + $this->assertSame( 'öม้p를', \cli\safe_substr( $str, -4 ) ); + $this->assertSame( 'öม้p', \cli\safe_substr( $str, -4, 3 ) ); + $this->assertSame( 'nö', \cli\safe_substr( $str, -5, 2 ) ); + $this->assertSame( 'ム', \cli\safe_substr( $str, -6, 1 ) ); + $this->assertSame( 'ムnöม้p를', \cli\safe_substr( $str, -6 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -7 ) ); + $this->assertSame( 'lムnö', \cli\safe_substr( $str, -7, 4 ) ); + $this->assertSame( 'lムnöม้p를', \cli\safe_substr( $str, -8 ) ); + } + + if ( function_exists( 'mb_substr' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=4' ); // Tests mb_substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( 'lム', \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( 'lムn', \cli\safe_substr( $str, 0, 3 ) ); + $this->assertSame( 'lムno', \cli\safe_substr( $str, 0, 4 ) ); // Wrong. + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=8' ); // Tests substr(). + $this->assertSame( '', \cli\safe_substr( $str, 0, 0 ) ); + $this->assertSame( 'l', \cli\safe_substr( $str, 0, 1 ) ); + $this->assertSame( "l\xe3", \cli\safe_substr( $str, 0, 2 ) ); // Corrupt. + + // Non-UTF-8 - both grapheme_substr() and preg_match( '/\X/u' ) will fail. + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' ); + + if ( function_exists( 'mb_substr' ) && function_exists( 'mb_detect_order' ) ) { + // Latin-1 + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $str = "\xe0b\xe7"; // "àbç" in ISO-8859-1 + $this->assertSame( "\xe0b", \cli\safe_substr( $str, 0, 2 ) ); + $this->assertSame( "\xe0b", mb_substr( $str, 0, 2, 'ISO-8859-1' ) ); + } + + // Restore. + putenv( false == $test_safe_substr ? 'PHP_CLI_TOOLS_TEST_SAFE_SUBSTR' : "PHP_CLI_TOOLS_TEST_SAFE_SUBSTR=$test_safe_substr" ); + if ( function_exists( 'mb_detect_order' ) ) { + mb_detect_order( $mb_detect_order ); + } + } + + function test_is_width_encoded_substr() { + + $this->assertSame( 'he', \cli\safe_substr( Colors::pad( 'hello', 6 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( 'ór', \cli\safe_substr( Colors::pad( 'óra', 6 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( '日', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 2, true /*is_width*/ ) ); + $this->assertSame( '日', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 3, true /*is_width*/ ) ); + $this->assertSame( '日本', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 4, true /*is_width*/ ) ); + $this->assertSame( '日本語', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 6, true /*is_width*/ ) ); + $this->assertSame( '日本語 ', \cli\safe_substr( Colors::pad( '日本語', 8 ), 0, 7, true /*is_width*/ ) ); + + $this->assertSame( 'el', \cli\safe_substr( Colors::pad( 'hello', 6 ), 1, 2, true /*is_width*/ ) ); + + $this->assertSame( 'a ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 2, 2, true /*is_width*/ ) ); + $this->assertSame( ' ', \cli\safe_substr( Colors::pad( 'óra', 6 ), 5, 2, true /*is_width*/ ) ); + + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 0, 0, true /*is_width*/ ) ); + $this->assertSame( '1', \cli\safe_substr( '1日4本語90', 0, 1, true /*is_width*/ ) ); + $this->assertSame( '1', \cli\safe_substr( '1日4本語90', 0, 2, true /*is_width*/ ) ); + $this->assertSame( '1日', \cli\safe_substr( '1日4本語90', 0, 3, true /*is_width*/ ) ); + $this->assertSame( '1日4', \cli\safe_substr( '1日4本語90', 0, 4, true /*is_width*/ ) ); + $this->assertSame( '1日4', \cli\safe_substr( '1日4本語90', 0, 5, true /*is_width*/ ) ); + $this->assertSame( '1日4本', \cli\safe_substr( '1日4本語90', 0, 6, true /*is_width*/ ) ); + $this->assertSame( '1日4本', \cli\safe_substr( '1日4本語90', 0, 7, true /*is_width*/ ) ); + $this->assertSame( '1日4本語', \cli\safe_substr( '1日4本語90', 0, 8, true /*is_width*/ ) ); + $this->assertSame( '1日4本語9', \cli\safe_substr( '1日4本語90', 0, 9, true /*is_width*/ ) ); + $this->assertSame( '1日4本語90', \cli\safe_substr( '1日4本語90', 0, 10, true /*is_width*/ ) ); + $this->assertSame( '1日4本語90', \cli\safe_substr( '1日4本語90', 0, 11, true /*is_width*/ ) ); + + $this->assertSame( '日', \cli\safe_substr( '1日4本語90', 1, 2, true /*is_width*/ ) ); + $this->assertSame( '日4', \cli\safe_substr( '1日4本語90', 1, 3, true /*is_width*/ ) ); + $this->assertSame( '4本語9', \cli\safe_substr( '1日4本語90', 2, 6, true /*is_width*/ ) ); + + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 1, true /*is_width*/ ) ); + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 2, true /*is_width*/ ) ); + $this->assertSame( '本', \cli\safe_substr( '1日4本語90', 3, 3, true /*is_width*/ ) ); + $this->assertSame( '本語', \cli\safe_substr( '1日4本語90', 3, 4, true /*is_width*/ ) ); + $this->assertSame( '本語9', \cli\safe_substr( '1日4本語90', 3, 5, true /*is_width*/ ) ); + + $this->assertSame( '0', \cli\safe_substr( '1日4本語90', 6, 1, true /*is_width*/ ) ); + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 7, 1, true /*is_width*/ ) ); + $this->assertSame( '', \cli\safe_substr( '1日4本語90', 6, 0, true /*is_width*/ ) ); + + $this->assertSame( '0', \cli\safe_substr( '1日4本語90', -1, 3, true /*is_width*/ ) ); + $this->assertSame( '90', \cli\safe_substr( '1日4本語90', -2, 3, true /*is_width*/ ) ); + $this->assertSame( '語9', \cli\safe_substr( '1日4本語90', -3, 3, true /*is_width*/ ) ); + $this->assertSame( '本語9', \cli\safe_substr( '1日4本語90', -4, 5, true /*is_width*/ ) ); } function test_colorized_string_length() { @@ -63,8 +239,31 @@ function test_colorized_string_length() { } function test_colorized_string_width() { - $this->assertEquals( \cli\Colors::width( \cli\Colors::colorize( '%Gx%n', true ) ), 1 ); - $this->assertEquals( \cli\Colors::width( \cli\Colors::colorize( '%G日%n', true ) ), 2 ); // Double-width char. + // Colors enabled. + Colors::enable( true ); + + $x = Colors::colorize( '%Gx%n', true ); + $dw = Colors::colorize( '%G日%n', true ); // Double-width char. + + $this->assertSame( 1, Colors::width( $x ) ); + $this->assertSame( 1, Colors::width( $x, false /*pre_colorized*/ ) ); + $this->assertSame( 1, Colors::width( $x, true /*pre_colorized*/ ) ); + + $this->assertSame( 2, Colors::width( $dw ) ); + $this->assertSame( 2, Colors::width( $dw, false /*pre_colorized*/ ) ); + $this->assertSame( 2, Colors::width( $dw, true /*pre_colorized*/ ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + $this->assertSame( 12, Colors::width( $x ) ); + $this->assertSame( 12, Colors::width( $x, false /*pre_colorized*/ ) ); + $this->assertSame( 1, Colors::width( $x, true /*pre_colorized*/ ) ); + + $this->assertSame( 13, Colors::width( $dw ) ); + $this->assertSame( 13, Colors::width( $dw, false /*pre_colorized*/ ) ); + $this->assertSame( 2, Colors::width( $dw, true /*pre_colorized*/ ) ); } function test_colorize_string_is_colored() { @@ -116,6 +315,58 @@ function test_string_cache() { $this->assertEquals( $test_cache, $real_cache[ md5( $string_with_color ) ] ); } + function test_string_cache_colorize() { + $string = 'x'; + $string_with_color = '%k' . $string; + $colorized_string = "\033[30m$string"; + + // Colors enabled. + Colors::enable( true ); + + // Ensure colorization works + $this->assertSame( $colorized_string, Colors::colorize( $string_with_color ) ); + $this->assertSame( $colorized_string, Colors::colorize( $string_with_color ) ); + + // Colors disabled. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + // Ensure it doesn't come from the cache. + $this->assertSame( $string, Colors::colorize( $string_with_color ) ); + $this->assertSame( $string, Colors::colorize( $string_with_color ) ); + + // Check that escaped % isn't stripped on putting into cache. + $string = 'x%%n'; + $string_with_color = '%k' . $string; + $this->assertSame( 'x%n', Colors::colorize( $string_with_color ) ); + $this->assertSame( 'x%n', Colors::colorize( $string_with_color ) ); + } + + function test_decolorize() { + // Colors enabled. + Colors::enable( true ); + + $string = '%kx%%n%n'; + $colorized_string = Colors::colorize( $string ); + $both_string = '%gfoo' . $colorized_string . 'bar%%%n'; + + $this->assertSame( 'x%n', Colors::decolorize( $string ) ); + $this->assertSame( 'x', Colors::decolorize( $colorized_string ) ); + $this->assertSame( 'fooxbar%', Colors::decolorize( $both_string ) ); + + $this->assertSame( $string, Colors::decolorize( $string, 1 /*keep_tokens*/ ) ); + $this->assertSame( 'x%n', Colors::decolorize( $colorized_string, 1 /*keep_tokens*/ ) ); + $this->assertSame( '%gfoox%nbar%%%n', Colors::decolorize( $both_string, 1 /*keep_tokens*/ ) ); + + $this->assertSame( 'x%n', Colors::decolorize( $string, 2 /*keep_encodings*/ ) ); + $this->assertSame( 'x', Colors::decolorize( $colorized_string, 2 /*keep_encodings*/ ) ); + $this->assertSame( 'fooxbar%', Colors::decolorize( $both_string, 2 /*keep_encodings*/ ) ); + + $this->assertSame( $string, Colors::decolorize( $string, 3 /*noop*/ ) ); + $this->assertSame( $colorized_string, Colors::decolorize( $colorized_string, 3 /*noop*/ ) ); + $this->assertSame( $both_string, Colors::decolorize( $both_string, 3 /*noop*/ ) ); + } + function test_strwidth() { // Save. $test_strwidth = getenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); @@ -130,7 +381,7 @@ function test_strwidth() { // 4 characters, one a double-width Han = 5 spacing chars, with 2 combining chars. Adapted from http://unicode.org/faq/char_combmark.html#7 (combining acute accent added after "a"). $str = "a\xCC\x81\xE0\xA4\xA8\xE0\xA4\xBF\xE4\xBA\x9C\xF0\x90\x82\x83"; - if ( function_exists( 'grapheme_strlen' ) ) { + if ( \cli\can_use_icu() ) { $this->assertSame( 5, \cli\strwidth( $str ) ); // Tests grapheme_strlen(). putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=2' ); // Test preg_match_all( '/\X/u' ). $this->assertSame( 5, \cli\strwidth( $str ) ); @@ -145,8 +396,10 @@ function test_strwidth() { } putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=8' ); // Test safe_strlen(). - if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { - $this->assertSame( 6, \cli\strwidth( $str ) ); // mb_strlen() - counts the 2 combining chars but not the double-width Han so out by 1. + if ( \cli\can_use_icu() || \cli\can_use_pcre_x() ) { + $this->assertSame( 4, \cli\strwidth( $str ) ); // safe_strlen() (correctly) does not account for double-width Han so out by 1. + } elseif ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + $this->assertSame( 4, \cli\strwidth( $str ) ); // safe_strlen() (correctly) does not account for double-width Han so out by 1. $this->assertSame( 6, mb_strlen( $str, 'UTF-8' ) ); } else { $this->assertSame( 16, \cli\strwidth( $str ) ); // strlen() - no. of bytes. @@ -158,7 +411,7 @@ function test_strwidth() { putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH' ); - if ( function_exists( 'grapheme_strlen' ) ) { + if ( \cli\can_use_icu() ) { $this->assertSame( 11, \cli\strwidth( $str ) ); // Tests grapheme_strlen(). putenv( 'PHP_CLI_TOOLS_TEST_STRWIDTH=2' ); // Test preg_match_all( '/\X/u' ). $this->assertSame( 11, \cli\strwidth( $str ) ); @@ -205,4 +458,59 @@ function test_strwidth() { mb_detect_order( $mb_detect_order ); } } + + function test_safe_strlen() { + // Save. + $test_safe_strlen = getenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + if ( function_exists( 'mb_detect_order' ) ) { + $mb_detect_order = mb_detect_order(); + } + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + + // UTF-8. + + // ASCII l, 3-byte kana, ASCII n, ASCII o + 2-byte combining umlaut, 6-byte Thai combining, ASCII, 3-byte Hangul. grapheme length 7, bytes 18. + $str = 'lムnöม้p를'; + + if ( \cli\can_use_icu() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); // Test grapheme_strlen(). + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + if ( \cli\can_use_pcre_x() ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=2' ); // Test preg_match_all( '/\X/u' ). + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + } + } elseif ( \cli\can_use_pcre_x() ) { + $this->assertSame( 7, \cli\safe_strlen( $str ) ); // Tests preg_match_all( '/\X/u' ). + } else { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=8' ); // Test strlen(). + $this->assertSame( 18, \cli\safe_strlen( $str ) ); // strlen() - no. of bytes. + $this->assertSame( 18, strlen( $str ) ); + } + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN=4' ); // Test mb_strlen(). + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $this->assertSame( 7, \cli\safe_strlen( $str ) ); + $this->assertSame( 9, mb_strlen( $str, 'UTF-8' ) ); // mb_strlen() - counts the 2 combining chars. + } + + // Non-UTF-8 - both grapheme_strlen() and preg_match_all( '/\X/u' ) will fail. + + putenv( 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' ); + + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_detect_order' ) ) { + // Latin-1 + mb_detect_order( array( 'UTF-8', 'ISO-8859-1' ) ); + $str = "\xe0b\xe7"; // "àbç" in ISO-8859-1 + $this->assertSame( 3, \cli\safe_strlen( $str ) ); // Test mb_strlen(). + $this->assertSame( 3, mb_strlen( $str, 'ISO-8859-1' ) ); + } + + // Restore. + putenv( false == $test_safe_strlen ? 'PHP_CLI_TOOLS_TEST_SAFE_STRLEN' : "PHP_CLI_TOOLS_TEST_SAFE_STRLEN=$test_safe_strlen" ); + if ( function_exists( 'mb_detect_order' ) ) { + mb_detect_order( $mb_detect_order ); + } + } } diff --git a/tests/test-colors.php b/tests/test-colors.php new file mode 100644 index 0000000..e7be7a0 --- /dev/null +++ b/tests/test-colors.php @@ -0,0 +1,30 @@ +assertSame( Colors::colorize( $str ), Colors::color( $color ) ); + if ( in_array( 'reset', $color ) ) { + $this->assertTrue( false !== strpos( $colored, '[0m' ) ); + } else { + $this->assertTrue( false === strpos( $colored, '[0m' ) ); + } + } + + function dataColors() { + $ret = array(); + foreach ( Colors::getColors() as $str => $color ) { + $ret[] = array( $str, $color ); + } + return $ret; + } +} diff --git a/tests/test-shell.php b/tests/test-shell.php index 702948c..3db9c02 100644 --- a/tests/test-shell.php +++ b/tests/test-shell.php @@ -22,6 +22,7 @@ function testColumns() { // No TERM should result in default 80. putenv( 'TERM' ); + putenv( 'COLUMNS=80' ); putenv( 'WP_CLI_TEST_IS_WINDOWS=0' ); $columns = cli\Shell::columns(); diff --git a/tests/test-table.php b/tests/test-table.php index 01340f8..685a531 100644 --- a/tests/test-table.php +++ b/tests/test-table.php @@ -1,11 +1,13 @@ addRow( array( 'author', 'the WordPress team' ) ); $out = $table->getDisplayLines(); - // "+ 1" accommodates "\n" $this->assertCount( 12, $out ); - $this->assertEquals( $constraint_width, strlen( $out[0] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[1] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[2] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[3] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[4] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[5] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[6] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[7] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[8] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[9] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[10] ) + 1 ); - $this->assertEquals( $constraint_width, strlen( $out[11] ) + 1 ); + $this->assertEquals( $constraint_width, strlen( $out[0] ) ); + $this->assertEquals( $constraint_width, strlen( $out[1] ) ); + $this->assertEquals( $constraint_width, strlen( $out[2] ) ); + $this->assertEquals( $constraint_width, strlen( $out[3] ) ); + $this->assertEquals( $constraint_width, strlen( $out[4] ) ); + $this->assertEquals( $constraint_width, strlen( $out[5] ) ); + $this->assertEquals( $constraint_width, strlen( $out[6] ) ); + $this->assertEquals( $constraint_width, strlen( $out[7] ) ); + $this->assertEquals( $constraint_width, strlen( $out[8] ) ); + $this->assertEquals( $constraint_width, strlen( $out[9] ) ); + $this->assertEquals( $constraint_width, strlen( $out[10] ) ); + $this->assertEquals( $constraint_width, strlen( $out[11] ) ); + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, strlen( $out[ $i ] ) ); + } + } + + public function test_column_value_too_long_with_multibytes() { + + $constraint_width = 80; + + $table = new cli\Table; + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Field', 'Value' ) ); + $table->addRow( array( '1この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。2この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。', 'こんにちは' ) ); + $table->addRow( array( 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', 'Hello' ) ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + } + + public function test_column_odd_single_width_with_double_width() { + + $dummy = new cli\Table; + $renderer = new cli\Table\Ascii; + + $strip_borders = function ( $a ) { + return array_map( function ( $v ) { + return substr( $v, 2, -2 ); + }, $a ); + }; + + $renderer->setWidths( array( 10 ) ); + + // 1 single-width, 6 double-width, 1 single-width, 2 double-width, 1 half-width, 2 double-width. + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 3, count( $result ) ); + $this->assertSame( '1あいうえ ', $result[0] ); // 1 single width, 4 double-width, space = 10. + $this->assertSame( 'おか2きくカ', $result[1] ); // 2 double-width, 1 single-width, 2 double-width, 1 half-width = 10. + $this->assertSame( 'けこ ', $result[2] ); // 2 double-width, 8 spaces = 10. + + // Minimum width 1. + + $renderer->setWidths( array( 1 ) ); + + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 13, count( $result ) ); + // Uneven rows. + $this->assertSame( '1', $result[0] ); + $this->assertSame( 'あ', $result[1] ); + + // Zero width does no wrapping. + + $renderer->setWidths( array( 0 ) ); + + $out = $renderer->row( array( '1あいうえおか2きくカけこ' ) ); + $result = $strip_borders( explode( "\n", $out ) ); + + $this->assertSame( 1, count( $result ) ); + } + + public function test_column_fullwidth_and_combining() { + + $constraint_width = 80; + + $table = new cli\Table; + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + $table->setHeaders( array( 'Field', 'Value' ) ); + $table->addRow( array( 'ID', 2151 ) ); + $table->addRow( array( 'post_author', 1 ) ); + $table->addRow( array( 'post_title', 'only-english-lorem-ipsum-dolor-sit-amet-consectetur-adipisicing-elit-sed-do-eiusmod-tempor-incididunt-ut-labore' ) ); + $table->addRow( array( 'post_content', + //'ให้รู้จัก ให้หาหนทางใหม่' . + '♫ มีอีกหลายต่อหลายคน เขาอดทนก็เพื่อรัก' . "\n" . + 'รักผลักดันให้รู้จัก ให้หาหนทางใหม่' . "\r\n" . + 'ฉันจะล้มตั้งหลายที ดีที่รักมาฉุดไว้' . "\r\n" . + 'รักสร้างสรรค์สิ่งมากมาย และหลอมละลายทุกหัวใจ' . "\r\n" . + 'จะมาร้ายดียังไง แต่ใจก็ยังต้องการ' . "\r\n" . + 'ในทุกๆ วัน โลกหมุนด้วยความรัก ♫' . "\n" . + 'ขอแสดงความยินดี งานแต่งพี่ Earn & Menn' ."\r\n" . + 'เที่ยวปายหน้าร้อน ก็เที่ยวได้เหมือนกันน่ะ' . "\r\n" . + ' ジョバンニはまっ赤になってうなずきました。けれどもいつかジョバンニの眼のなかには涙がいっぱいになりました。そうだ僕は知っていたのだ、もちろんカムパネルラも知っている。' ."\r\n" . + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore' . "\n" . + '' + ) ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 81; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + + $constraint_width = 200; + + $renderer = new cli\Table\Ascii; + $renderer->setConstraintWidth( $constraint_width ); + $table->setRenderer( $renderer ); + + $out = $table->getDisplayLines(); + for ( $i = 0; $i < count( $out ); $i++ ) { + $this->assertEquals( $constraint_width, \cli\strwidth( $out[$i] ) ); + } + } + + public function test_ascii_pre_colorized_widths() { + + Colors::enable( true ); + + $headers = array( 'package', 'version', 'result' ); + $items = array( + array( Colors::colorize( '%ygaa/gaa-kabes%n' ), 'dev-master', Colors::colorize( "%rx%n" ) ), + array( Colors::colorize( '%ygaa/gaa-log%n' ), '*', Colors::colorize( "%gok%n" ) ), + array( Colors::colorize( '%ygaa/gaa-nonsense%n' ), 'v3.0.11', Colors::colorize( "%rx%n" ) ), + array( Colors::colorize( '%ygaa/gaa-100%%new%n' ), 'v100%new', Colors::colorize( "%gok%n" ) ), + ); + + // Disable colorization, as `\WP_CLI\Formatter::show_table()` does for Ascii tables. + Colors::disable( true ); + $this->assertFalse( Colors::shouldColorize() ); + + // Account for colorization of columns 0 & 2. + + $table = new Table; + $renderer = new Ascii; + $table->setRenderer( $renderer ); + $table->setAsciiPreColorized( array( true, false, true ) ); + $table->setHeaders( $headers ); + $table->setRows( $items ); + + $out = $table->getDisplayLines(); + + // "+ 4" accommodates 3 borders and header. + $this->assertSame( 4 + 4, count( $out ) ); + + // Borders & header. + $this->assertSame( 42, strlen( $out[0] ) ); + $this->assertSame( 42, strlen( $out[1] ) ); + $this->assertSame( 42, strlen( $out[2] ) ); + $this->assertSame( 42, strlen( $out[7] ) ); + + // Data. + $this->assertSame( 60, strlen( $out[3] ) ); + $this->assertSame( 60, strlen( $out[4] ) ); + $this->assertSame( 60, strlen( $out[5] ) ); + $this->assertSame( 60, strlen( $out[6] ) ); + + // Don't account for colorization of columns 0 & 2. + + $table = new Table; + $renderer = new Ascii; + $table->setRenderer( $renderer ); + $table->setHeaders( $headers ); + $table->setRows( $items ); + + $out = $table->getDisplayLines(); + + // "+ 4" accommodates 3 borders and header. + $this->assertSame( 4 + 4, count( $out ) ); + + // Borders & header. + $this->assertSame( 56, strlen( $out[0] ) ); + $this->assertSame( 56, strlen( $out[1] ) ); + $this->assertSame( 56, strlen( $out[2] ) ); + $this->assertSame( 56, strlen( $out[7] ) ); + // Data. + $this->assertSame( 56, strlen( $out[3] ) ); + $this->assertSame( 56, strlen( $out[4] ) ); + $this->assertSame( 56, strlen( $out[5] ) ); + $this->assertSame( 56, strlen( $out[6] ) ); } -} \ No newline at end of file +}