diff --git a/include/cli/Log.php b/include/cli/Log.php index 297a7ae..e423e6b 100644 --- a/include/cli/Log.php +++ b/include/cli/Log.php @@ -1,271 +1,374 @@ . # Command line interface namespace cli; /** * Show log messages (when in CLI) + * + * This classed was initially designed as a static class, + * but we should slowly use it as an instance. */ class Log { /** * Normal information messages flag * * @var bool */ public static $INFO = true; /** * Verbose information messages flag * * @var bool */ public static $DEBUG = false; /** * Verbose sensitive information messages flag * * @var bool */ public static $SENSITIVE = false; /** * Format in use when we are in command line mode * * Order of arguments: * Date, Type, Message * * @var string */ public static $FORMAT_COMMAND_LINE = '[%1$s][%2$s] %3$s'; /** * Format in use when we are in webserver mode * * Order of arguments: * Date, Type, Message * * As default the Date is not printed because usually * Apache or Nginx already append it. * * @var string */ public static $FORMAT_WEBSERVER = '%2$s %3$s'; /** * Format used to eventually print dates in the log * * @var string */ public static $DATE_FORMAT = 'Y-m-d H:i:s'; /** * If defined, this file will be used to append log shit * * @var string */ public static $DEDICATED_FILEPATH = null; /** - * Maximum length of a message in command line + * Default maximum length of a message in command line * * If the message is largher than this, will be split in lines. * * NOTE: This does not take into consideration the prefix (date). + * + * @deprecated */ public static $CLI_MAX_MSG_LEN = 80; + /** + * Cache for the available terminal columns. + * + * @var int|false|null False means unitialized. Null means not available. Int means available. + */ + private static $CACHE_AVAILABLE_TERM_COLS = false; + /** * Show a warning * * Use it for errors that can be solved without interaction * * @param $message string * @param $args array arguments */ public static function warn( $message, $args = [] ) { self::log( 'WARN', $message, $args ); } /** * Show a debug information * * Use it to show actions under the hood * * @param $message string * @param $args array arguments */ public static function info( $message, $args = [] ) { if( self::$INFO ) { self::log( 'INFO', $message, $args ); } } /** * Show a debug information * * Use it to show actions under the hood * * @param $message string * @param $args array arguments */ public static function debug( $message, $args = [] ) { if( self::$DEBUG ) { self::log( 'DEBUG', $message, $args ); } } /** * Show an error * * @param $message string * @param $args array arguments */ public static function error( $message, $args = [] ) { self::log( 'ERROR', $message, $args ); } /** * Show a debug information message that contains sensitive informations * * @param $message_sensitive Message with sensitive informations * @param $message_unsensitive Message secure to be shown * @param $args array arguments */ public static function sensitive( $message_sensitive, $message_unsensitive, $args = [] ) { if( self::$DEBUG ) { if( self::$SENSITIVE ) { self::log( '!DEBUG!', $message_sensitive, $args ); } elseif( $message_unsensitive ) { self::log( '!DEBUG!', "$message_unsensitive [SENSITIVE DATA HIDDEN]", $args ); } } } /** * Show a message * * @param $type string * @param $message string * @param $args array arguments */ public static function log( $type, $message, $args = [] ) { // default arguments $args = array_replace( [ 'newline' => true, ], $args ); // check if we are in command line mode $cli = isset( $_SERVER['argv'] ); // are we in command line? $format = $cli ? self::$FORMAT_COMMAND_LINE : self::$FORMAT_WEBSERVER; // in command line print a nice format with a date $date = date( self::$DATE_FORMAT ); $message_formatted = self::formatMessageLine( $format, $date, $type, $message ); // eventually end with a newline if( $args['newline'] ) { $message_formatted .= "\n"; } // check if we have to write into a dedicated file (default to no) if( self::$DEDICATED_FILEPATH ) { // try to append something in the log file $status = file_put_contents( self::$DEDICATED_FILEPATH, $message_formatted, FILE_APPEND ); // no log no party if( $status === false ) { throw new \Exception( sprintf( "apologize Sir but we are very sad to note that we cannot write in your damn log file '%s'", self::$DEDICATED_FILEPATH ) ); } } else { // do not write into a dedicated file // check if we are in command line mode if( $cli ) { // in command line, just print everything to stdout // but break terminal lines - $lines = self::splitMessageInLines( $message ); + $lines = self::splitMessageInLines($message, $format, $date, $type); foreach( $lines as $i => $line ) { - - // after the first line just indent a bit so you understand they are not separated info - if( $i ) { - $line = " $line"; - } - - // print this line to standard output - echo self::formatMessageLine( $format, $date, $type, "$line\n" ); + // Print this line to standard output, as it fits the available space. + echo self::formatMessageLine($format, $date, $type, "$line\n", $i); } } else { // in a webserver, just print everything in the syslog error_log( $message_formatted ); } } } + /** + * Get the a cached number of terminal COLUMNS, or null if columns cannot be estimated. + * + * This is generally useful to print a decent terminal output, without exceding. + * + * @return int|null + */ + public static function availableTerminalColumns() { + if (self::$CACHE_AVAILABLE_TERM_COLS === false) { + self::$CACHE_AVAILABLE_TERM_COLS = self::parseAvailableTerminalColumns(); + } + return self::$CACHE_AVAILABLE_TERM_COLS; + } + + /** + * Force the unavailability of terminal columns. + */ + public static function doNotFillTerminalColumns() { + self::$CACHE_AVAILABLE_TERM_COLS = null; + } + + /** + * Parse the number of terminal COLUMNS at runtime, + * or null if columns cannot be estimated. + * + * This method should be considered internal and should not be used directly. + * See availableTerminalColumns() instead, which is cached. + * + * @return int|null + */ + public static function parseAvailableTerminalColumns() { + $cols = (int)getenv('COLUMNS'); + + if ($cols <= 1) { + $cols = null; + } + + if ($cols === null) { + try { + if (php_sapi_name() === 'cli') { + $cols = (int)@shell_exec('tput cols'); + if ($cols <= 1) { + $cols = null; + } + } + } catch(Throwable $e) { + // Silently ignore. Probably the shell is not available. + } + } + + return $cols; + } + /** * Format a command line message * + * @param string $format + * @param string $date + * @param string $type + * @param string $message + * @param int $line_n For multi-line messages, this is the line number, starting from zero. * @return string */ - private static function formatMessageLine( $format, $date, $type, $message ) { + private static function formatMessageLine(string $format, string $date, string $type, string $message, int $line_n = 0): string { + // For multi-line messages, indent a bit the consequent lines. + if ($line_n) { + $message = " {$message}"; + } return sprintf( $format, $date, $type, $message ); } /** - * Split a message in terminal lines + * Split a message in terminal lines. + * + * The lines are designed in a way that, even when prefixed with our log format, + * lines will still be contained in your small terminal horizontal space. + * + * @TODO: uniform the parameters with formatMessageLine(). * * @param string $message + * @param string $format + * @param string $date + * @param string $type * @return array */ - private static function splitMessageInLines( $message ) { + private static function splitMessageInLines(string $message, string $format, string $date, string $type) { $all = []; + // The message should fit your terminal. + // The format prefix must be considered to fit the terminal. + $terminal_columns = self::availableTerminalColumns(); + if ($terminal_columns) { + $message_prefix = self::formatMessageLine($format, $date, $type, ''); + $message_prefix_len = mb_strlen($message_prefix); + $terminal_columns -= $message_prefix_len; + if ($terminal_columns <= 10) { + $terminal_columns = null; + } + } + // split by lines $lines = explode( "\n", $message ); - foreach( $lines as $line ) { - + foreach( $lines as $i => $line ) { // try do not overlap in your terminal (for more cuteness) - foreach( str_split( $line, static::$CLI_MAX_MSG_LEN ) as $part ) { + $parts = null; + if ($terminal_columns) { + $parts = []; + + // The first line part can fit the whole terminal space. + $parts[] = mb_substr($line, 0, $terminal_columns); + + // Remaining line parts will be padded with an extra space. + $remaining_line = mb_substr($line, $terminal_columns); + $terminal_columns--; + $remaining_parts = mb_str_split($remaining_line, $terminal_columns); + $parts = array_merge($parts, $remaining_parts); + } else { + $parts = [ $line ]; + } + + foreach ($parts as $part) { // do not trim lines: spaces are sometime useful to indent better // $part = trim( $part ); if( $part ) { $all[] = $part; } } } return $all; } } diff --git a/phpunit/CliTest.php b/phpunit/CliTest.php index 1a58814..22085cd 100644 --- a/phpunit/CliTest.php +++ b/phpunit/CliTest.php @@ -1,100 +1,142 @@ addFlag( "luser", "l", "Check if you are a luser or not" ) ->addValued( "asd", "a", "Set your asd message", "FUCK YOU" ) ->addFlag( "lamer", "z", "Check if you are a lamer or not" ); // check the asd argument (unpresent, so get default) $asd = $options->get( 'asd' ); $this->assertEquals( $asd, "FUCK YOU" ); } /** * Test the bracket and glue */ public function testAddArgsWithoutDefaultButSuggestedLater() { $options = new \cli\Opts(); // register some dummy parameters $options->addValued( "asd", "a", "Set your asd message" ); // check the asd argument (unpresent, so get default) $asd = $options->get( 'asd', "DEFAULT ASD" ); $this->assertEquals( $asd, "DEFAULT ASD" ); } /** * Test the bracket and glue */ public function testAddArgsWithoutDefaultAtAll() { $options = new \cli\Opts(); // register some dummy parameters $options->addValued( "asd", "a", "Set your asd message" ); // check the asd argument (unpresent, so get default) $asd = $options->get( 'asd' ); $this->assertEquals( $asd, null ); } /** * Test the bracket and glue */ public function testAddArgsAndGetWithDefault() { $options = new \cli\Opts(); // register some dummy parameters $options->addFlag( "luser", "l", "Check if you are a luser or not" ); // NOTE: the getArg() is deprecated but should be available $asd = $options->getArg( 'luser', 'miao' ); $this->assertEquals( $asd, "miao" ); } /** * Test the bracket and glue */ public function testGetAll() { $options = new \cli\Opts(); // register some dummy parameters $options ->addFlag( "luser", "l", "Check if you are a luser or not" ) ->addValued( "asd", "a", "Set your asd message", "FUCK YOU" ) ->addFlag( "lamer", null, "Check if you are a lamer or not" ); // check the asd argument $all = $options->getAll(); $this->assertEquals( 3, count( $all ) ); } public function testCliShortcut() { $this->assertEquals( cli_options() instanceof \cli\Opts, true ); } + /** + * Test that terminal columns can be parsed. + */ + public function testCliTerminalColumns() { + putenv('COLUMNS=17'); + $columns = Log::parseAvailableTerminalColumns(); + $this->assertEquals(17, $columns); + + putenv('COLUMNS=18'); + $columns = Log::parseAvailableTerminalColumns(); + $this->assertEquals(18, $columns); + + // Test that in a small terminal the first line is capped exactly + // at that terminal limit, and the remaining lines are smaller than + // that. + putenv('COLUMNS=80'); + $columns = Log::availableTerminalColumns(); + $this->assertEquals(80, $columns); + $min_lines = 5; + $long_message = str_repeat('VeryLongString', $columns * $min_lines); + ob_start(); + Log::info($long_message); + $logged_content = ob_get_clean(); + $logged_content_lines = explode("\n", $logged_content); + $this->assertGreaterThan($min_lines, count($logged_content_lines), "Test lines are splitted"); + $first_line = array_shift($logged_content_lines); + $this->assertEquals($columns, mb_strlen($first_line), "Test first line fitting the whole terminal"); + foreach ($logged_content_lines as $logged_content_line) { + $this->assertTrue(mb_strlen($logged_content_line) <= $columns, "Test other lines not exceeding the space"); + } + + Log::doNotFillTerminalColumns(); + $long_message = str_repeat('VeryLongString', 1000); + ob_start(); + Log::info($long_message); + $logged_content = trim(ob_get_clean()); + $logged_content_lines = explode("\n", $logged_content); + $this->assertEquals(1, count($logged_content_lines), "Test single line to adhere options"); + } + }