├── screenshot.png ├── apigen.neon ├── screenshot2.png ├── .gitignore ├── src ├── PSR3CLI.php ├── PSR3CLIv3.php ├── Exception.php ├── CLI.php ├── Colors.php ├── Base.php ├── TableFormatter.php └── Options.php ├── .github └── workflows │ ├── apigen.yml │ └── test.yml ├── phpunit.xml ├── composer.json ├── examples ├── minimal.php ├── logging.php ├── simple.php ├── table.php └── complex.php ├── LICENSE ├── tests ├── LogLevelTest.php ├── OptionsTest.php └── TableFormatterTest.php └── README.md /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splitbrain/php-cli/HEAD/screenshot.png -------------------------------------------------------------------------------- /apigen.neon: -------------------------------------------------------------------------------- 1 | tree: Yes 2 | deprecated: Yes 3 | accessLevels: [public] 4 | todo: Yes 5 | -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splitbrain/php-cli/HEAD/screenshot2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea/ 3 | composer.phar 4 | vendor/ 5 | composer.lock 6 | apigen.phar 7 | docs/ 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /src/PSR3CLI.php: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/PSR3CLIv3.php: -------------------------------------------------------------------------------- 1 | logMessage($level, $message, $context); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "splitbrain/php-cli", 3 | "description": "Easy command line scripts for PHP with opt parsing and color output. No dependencies", 4 | "keywords": [ 5 | "cli", 6 | "console", 7 | "terminal", 8 | "command line", 9 | "getopt", 10 | "optparse", 11 | "argparse" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Andreas Gohr", 17 | "email": "andi@splitbrain.org" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=5.3.0" 22 | }, 23 | "suggest": { 24 | "psr/log": "Allows you to make the CLI available as PSR-3 logger" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^8" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "splitbrain\\phpcli\\": "src" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/minimal.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | setHelp('A very minimal example that does nothing but print a version'); 13 | $options->registerOption('version', 'print version', 'v'); 14 | } 15 | 16 | // implement your code 17 | protected function main(Options $options) 18 | { 19 | if ($options->getOpt('version')) { 20 | $this->info('1.0.0'); 21 | } else { 22 | echo $options->help(); 23 | } 24 | } 25 | } 26 | // execute it 27 | $cli = new Minimal(); 28 | $cli->run(); -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | name: PHP ${{ matrix.php-versions }} 8 | runs-on: ubuntu-latest 9 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 10 | 11 | strategy: 12 | matrix: 13 | php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] 14 | fail-fast: false 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-versions }} 24 | 25 | - name: Setup problem matchers 26 | run: | 27 | echo ::add-matcher::${{ runner.tool_cache }}/php.json 28 | echo ::add-matcher::${{ runner.tool_cache }}/phpunit.json 29 | 30 | - name: Setup Dependencies 31 | run: | 32 | composer update 33 | composer install 34 | - name: Run PHPUnit 35 | run: | 36 | ./vendor/bin/phpunit --verbose 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Andreas Gohr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class Exception extends \RuntimeException 15 | { 16 | const E_ANY = -1; // no error code specified 17 | const E_UNKNOWN_OPT = 1; //Unrecognized option 18 | const E_OPT_ARG_REQUIRED = 2; //Option requires argument 19 | const E_OPT_ARG_DENIED = 3; //Option not allowed argument 20 | const E_OPT_ABIGUOUS = 4; //Option abiguous 21 | const E_ARG_READ = 5; //Could not read argv 22 | 23 | /** 24 | * @param string $message The Exception message to throw. 25 | * @param int $code The Exception code 26 | * @param \Exception $previous The previous exception used for the exception chaining. 27 | */ 28 | public function __construct($message = "", $code = 0, \Exception $previous = null) 29 | { 30 | if (!$code) { 31 | $code = self::E_ANY; 32 | } 33 | parent::__construct($message, $code, $previous); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/logging.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | setHelp('A very minimal example that demos the logging'); 17 | } 18 | 19 | // implement your code 20 | protected function main(Options $options) 21 | { 22 | $this->debug('This is a debug message'); 23 | $this->info('This is a info message'); 24 | $this->notice('This is a notice message'); 25 | $this->success('This is a success message'); 26 | $this->warning('This is a warning message'); 27 | $this->error('This is a error message'); 28 | $this->critical('This is a critical message'); 29 | $this->alert('This is a alert message'); 30 | $this->emergency('This is a emergency message'); 31 | throw new \Exception('Exception will be caught, too'); 32 | } 33 | } 34 | 35 | // execute it 36 | $cli = new logging(); 37 | $cli->run(); -------------------------------------------------------------------------------- /examples/simple.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | setHelp('This is a simple example, not using any subcommands'); 23 | $options->registerOption('longflag', 'A flag that can also be set with a short option', 'l'); 24 | $options->registerOption('file', 'This option expects an argument.', 'f', 'filename'); 25 | $options->registerArgument('argument', 'Arguments can be required or optional. This one is optional', false); 26 | } 27 | 28 | /** 29 | * Your main program 30 | * 31 | * Arguments and options have been parsed when this is run 32 | * 33 | * @param Options $options 34 | * @return void 35 | */ 36 | protected function main(Options $options) 37 | { 38 | if ($options->getOpt('longflag')) { 39 | $this->info("longflag was set"); 40 | } else { 41 | $this->info("longflag was not set"); 42 | } 43 | 44 | if ($options->getOpt('file')) { 45 | $this->info("file was given as " . $options->getOpt('file')); 46 | } 47 | 48 | $this->info("Number of arguments: " . count($options->getArgs())); 49 | 50 | $this->success("main finished"); 51 | } 52 | } 53 | 54 | $cli = new Simple(); 55 | $cli->run(); -------------------------------------------------------------------------------- /examples/table.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | setHelp('This shows how the table formatter works by printing the current php.ini values'); 25 | } 26 | 27 | /** 28 | * Your main program 29 | * 30 | * Arguments and options have been parsed when this is run 31 | * 32 | * @param Options $options 33 | * @return void 34 | */ 35 | protected function main(Options $options) 36 | { 37 | $tf = new TableFormatter($this->colors); 38 | $tf->setBorder(' | '); // nice border between colmns 39 | 40 | // show a header 41 | echo $tf->format( 42 | array('*', '30%', '30%'), 43 | array('ini setting', 'global', 'local') 44 | ); 45 | 46 | // a line across the whole width 47 | echo str_pad('', $tf->getMaxWidth(), '-') . "\n"; 48 | 49 | // colored columns 50 | $ini = ini_get_all(); 51 | foreach ($ini as $val => $opts) { 52 | echo $tf->format( 53 | array('*', '30%', '30%'), 54 | array($val, $opts['global_value'], $opts['local_value']), 55 | array(Colors::C_CYAN, Colors::C_RED, Colors::C_GREEN) 56 | ); 57 | } 58 | } 59 | } 60 | 61 | $cli = new Table(); 62 | $cli->run(); -------------------------------------------------------------------------------- /examples/complex.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | setHelp('This example sets up additional subcommands using their own options'); 23 | $options->registerOption('longflag', 'This is a global flag that applies to all subcommands', 'l'); 24 | 25 | $options->registerCommand('foo', 'The foo command'); 26 | $options->registerCommand('bar', 'The bar command'); 27 | 28 | $options->registerOption('someflag', 'This is a flag only valid for the foo command', 's', false, 'foo'); 29 | $options->registerArgument('file', 'This argument is only required for the foo command', true, 'foo'); 30 | 31 | $options->registerOption('load', 'Another flag only for the bar command, requiring an argument', 'l', 'input', 32 | 'bar'); 33 | 34 | $options->registerCommand('compact', 'Display the help text in a more compact manner'); 35 | } 36 | 37 | /** 38 | * Your main program 39 | * 40 | * Arguments and options have been parsed when this is run 41 | * 42 | * @param Options $options 43 | * @return void 44 | */ 45 | protected function main(Options $options) 46 | { 47 | 48 | switch ($options->getCmd()) { 49 | case 'foo': 50 | $this->success('The foo command was called'); 51 | break; 52 | case 'bar': 53 | $this->success('The bar command was called'); 54 | break; 55 | case 'compact': 56 | $options->useCompactHelp(); 57 | echo $options->help(); 58 | exit; 59 | default: 60 | $this->error('No known command was called, we show the default help instead:'); 61 | echo $options->help(); 62 | exit; 63 | } 64 | 65 | $this->info('$options->getArgs():'); 66 | var_dump($options->getArgs()); 67 | 68 | } 69 | } 70 | 71 | $cli = new Complex(); 72 | $cli->run(); 73 | -------------------------------------------------------------------------------- /tests/LogLevelTest.php: -------------------------------------------------------------------------------- 1 | setLogLevel($level); 82 | foreach ($enabled as $e) { 83 | $this->assertTrue($cli->isLogLevelEnabled($e), "$e is not enabled but should be"); 84 | } 85 | foreach ($disabled as $d) { 86 | $this->assertFalse($cli->isLogLevelEnabled($d), "$d is enabled but should not be"); 87 | } 88 | } 89 | 90 | 91 | } 92 | -------------------------------------------------------------------------------- /tests/OptionsTest.php: -------------------------------------------------------------------------------- 1 | registerOption('exclude', 'exclude files', 'x', 'file'); 27 | 28 | $options->args = array($option, $value, $argument); 29 | $options->parseOptions(); 30 | 31 | $this->assertEquals($value, $options->getOpt('exclude')); 32 | $this->assertEquals(array($argument), $options->args); 33 | $this->assertFalse($options->getOpt('nothing')); 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function optionDataProvider() { 40 | return array( 41 | array('-x', 'foo', 'bang'), 42 | array('--exclude', 'foo', 'bang'), 43 | array('-x', 'foo-bar', 'bang'), 44 | array('--exclude', 'foo-bar', 'bang'), 45 | array('-x', 'foo', 'bang--bang'), 46 | array('--exclude', 'foo', 'bang--bang'), 47 | ); 48 | } 49 | 50 | function test_simplelong2() 51 | { 52 | $options = new Options(); 53 | $options->registerOption('exclude', 'exclude files', 'x', 'file'); 54 | 55 | $options->args = array('--exclude=foo', 'bang'); 56 | $options->parseOptions(); 57 | 58 | $this->assertEquals('foo', $options->getOpt('exclude')); 59 | $this->assertEquals(array('bang'), $options->args); 60 | $this->assertFalse($options->getOpt('nothing')); 61 | } 62 | 63 | function test_complex() 64 | { 65 | $options = new Options(); 66 | 67 | $options->registerOption('plugins', 'run on plugins only', 'p'); 68 | $options->registerCommand('status', 'display status info'); 69 | $options->registerOption('long', 'display long lines', 'l', false, 'status'); 70 | 71 | $options->args = array('-p', 'status', '--long', 'foo'); 72 | $options->parseOptions(); 73 | 74 | $this->assertEquals('status', $options->getCmd()); 75 | $this->assertTrue($options->getOpt('plugins')); 76 | $this->assertTrue($options->getOpt('long')); 77 | $this->assertEquals(array('foo'), $options->args); 78 | } 79 | 80 | function test_commandhelp() 81 | { 82 | $options = new Options(); 83 | $options->registerCommand('cmd', 'a command'); 84 | $this->assertStringContainsString('accepts a command as first parameter', $options->help()); 85 | 86 | $options->setCommandHelp('foooooobaar'); 87 | $this->assertStringNotContainsString('accepts a command as first parameter', $options->help()); 88 | $this->assertStringContainsString('foooooobaar', $options->help()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/CLI.php: -------------------------------------------------------------------------------- 1 | 11 | * @license MIT 12 | */ 13 | abstract class CLI extends Base 14 | { 15 | /** 16 | * System is unusable. 17 | * 18 | * @param string $message 19 | * @param array $context 20 | * 21 | * @return void 22 | */ 23 | public function emergency($message, array $context = array()) 24 | { 25 | $this->log('emergency', $message, $context); 26 | } 27 | 28 | /** 29 | * Action must be taken immediately. 30 | * 31 | * Example: Entire website down, database unavailable, etc. This should 32 | * trigger the SMS alerts and wake you up. 33 | * 34 | * @param string $message 35 | * @param array $context 36 | */ 37 | public function alert($message, array $context = array()) 38 | { 39 | $this->log('alert', $message, $context); 40 | } 41 | 42 | /** 43 | * Critical conditions. 44 | * 45 | * Example: Application component unavailable, unexpected exception. 46 | * 47 | * @param string $message 48 | * @param array $context 49 | */ 50 | public function critical($message, array $context = array()) 51 | { 52 | $this->log('critical', $message, $context); 53 | } 54 | 55 | /** 56 | * Runtime errors that do not require immediate action but should typically 57 | * be logged and monitored. 58 | * 59 | * @param string $message 60 | * @param array $context 61 | */ 62 | public function error($message, array $context = array()) 63 | { 64 | $this->log('error', $message, $context); 65 | } 66 | 67 | /** 68 | * Exceptional occurrences that are not errors. 69 | * 70 | * Example: Use of deprecated APIs, poor use of an API, undesirable things 71 | * that are not necessarily wrong. 72 | * 73 | * @param string $message 74 | * @param array $context 75 | */ 76 | public function warning($message, array $context = array()) 77 | { 78 | $this->log('warning', $message, $context); 79 | } 80 | 81 | 82 | 83 | /** 84 | * Normal but significant events. 85 | * 86 | * @param string $message 87 | * @param array $context 88 | */ 89 | public function notice($message, array $context = array()) 90 | { 91 | $this->log('notice', $message, $context); 92 | } 93 | 94 | /** 95 | * Interesting events. 96 | * 97 | * Example: User logs in, SQL logs. 98 | * 99 | * @param string $message 100 | * @param array $context 101 | */ 102 | public function info($message, array $context = array()) 103 | { 104 | $this->log('info', $message, $context); 105 | } 106 | 107 | /** 108 | * Detailed debug information. 109 | * 110 | * @param string $message 111 | * @param array $context 112 | */ 113 | public function debug($message, array $context = array()) 114 | { 115 | $this->log('debug', $message, $context); 116 | } 117 | 118 | /** 119 | * @param string $level 120 | * @param string $message 121 | * @param array $context 122 | */ 123 | public function log($level, $message, array $context = array()) 124 | { 125 | $this->logMessage($level, $message, $context); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Colors.php: -------------------------------------------------------------------------------- 1 | 11 | * @license MIT 12 | */ 13 | class Colors 14 | { 15 | // these constants make IDE autocompletion easier, but color names can also be passed as strings 16 | const C_RESET = 'reset'; 17 | const C_BLACK = 'black'; 18 | const C_DARKGRAY = 'darkgray'; 19 | const C_BLUE = 'blue'; 20 | const C_LIGHTBLUE = 'lightblue'; 21 | const C_GREEN = 'green'; 22 | const C_LIGHTGREEN = 'lightgreen'; 23 | const C_CYAN = 'cyan'; 24 | const C_LIGHTCYAN = 'lightcyan'; 25 | const C_RED = 'red'; 26 | const C_LIGHTRED = 'lightred'; 27 | const C_PURPLE = 'purple'; 28 | const C_LIGHTPURPLE = 'lightpurple'; 29 | const C_BROWN = 'brown'; 30 | const C_YELLOW = 'yellow'; 31 | const C_LIGHTGRAY = 'lightgray'; 32 | const C_WHITE = 'white'; 33 | 34 | // Regex pattern to match color codes 35 | const C_CODE_REGEX = "/(\33\[[0-9;]+m)/"; 36 | 37 | /** @var array known color names */ 38 | protected $colors = array( 39 | self::C_RESET => "\33[0m", 40 | self::C_BLACK => "\33[0;30m", 41 | self::C_DARKGRAY => "\33[1;30m", 42 | self::C_BLUE => "\33[0;34m", 43 | self::C_LIGHTBLUE => "\33[1;34m", 44 | self::C_GREEN => "\33[0;32m", 45 | self::C_LIGHTGREEN => "\33[1;32m", 46 | self::C_CYAN => "\33[0;36m", 47 | self::C_LIGHTCYAN => "\33[1;36m", 48 | self::C_RED => "\33[0;31m", 49 | self::C_LIGHTRED => "\33[1;31m", 50 | self::C_PURPLE => "\33[0;35m", 51 | self::C_LIGHTPURPLE => "\33[1;35m", 52 | self::C_BROWN => "\33[0;33m", 53 | self::C_YELLOW => "\33[1;33m", 54 | self::C_LIGHTGRAY => "\33[0;37m", 55 | self::C_WHITE => "\33[1;37m", 56 | ); 57 | 58 | /** @var bool should colors be used? */ 59 | protected $enabled = true; 60 | 61 | /** 62 | * Constructor 63 | * 64 | * Tries to disable colors for non-terminals 65 | */ 66 | public function __construct() 67 | { 68 | if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) { 69 | $this->enabled = false; 70 | return; 71 | } 72 | if (!getenv('TERM')) { 73 | $this->enabled = false; 74 | return; 75 | } 76 | if (getenv('NO_COLOR')) { // https://no-color.org/ 77 | $this->enabled = false; 78 | return; 79 | } 80 | } 81 | 82 | /** 83 | * enable color output 84 | */ 85 | public function enable() 86 | { 87 | $this->enabled = true; 88 | } 89 | 90 | /** 91 | * disable color output 92 | */ 93 | public function disable() 94 | { 95 | $this->enabled = false; 96 | } 97 | 98 | /** 99 | * @return bool is color support enabled? 100 | */ 101 | public function isEnabled() 102 | { 103 | return $this->enabled; 104 | } 105 | 106 | /** 107 | * Convenience function to print a line in a given color 108 | * 109 | * @param string $line the line to print, a new line is added automatically 110 | * @param string $color one of the available color names 111 | * @param resource $channel file descriptor to write to 112 | * 113 | * @throws Exception 114 | */ 115 | public function ptln($line, $color, $channel = STDOUT) 116 | { 117 | $this->set($color, $channel); 118 | fwrite($channel, rtrim($line) . "\n"); 119 | $this->reset($channel); 120 | } 121 | 122 | /** 123 | * Returns the given text wrapped in the appropriate color and reset code 124 | * 125 | * @param string $text string to wrap 126 | * @param string $color one of the available color names 127 | * @return string the wrapped string 128 | * @throws Exception 129 | */ 130 | public function wrap($text, $color) 131 | { 132 | return $this->getColorCode($color) . $text . $this->getColorCode('reset'); 133 | } 134 | 135 | /** 136 | * Gets the appropriate terminal code for the given color 137 | * 138 | * @param string $color one of the available color names 139 | * @return string color code 140 | * @throws Exception 141 | */ 142 | public function getColorCode($color) 143 | { 144 | if (!$this->enabled) { 145 | return ''; 146 | } 147 | if (!isset($this->colors[$color])) { 148 | throw new Exception("No such color $color"); 149 | } 150 | 151 | return $this->colors[$color]; 152 | } 153 | 154 | /** 155 | * Set the given color for consecutive output 156 | * 157 | * @param string $color one of the supported color names 158 | * @param resource $channel file descriptor to write to 159 | * @throws Exception 160 | */ 161 | public function set($color, $channel = STDOUT) 162 | { 163 | fwrite($channel, $this->getColorCode($color)); 164 | } 165 | 166 | /** 167 | * reset the terminal color 168 | * 169 | * @param resource $channel file descriptor to write to 170 | * 171 | * @throws Exception 172 | */ 173 | public function reset($channel = STDOUT) 174 | { 175 | $this->set('reset', $channel); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /tests/TableFormatterTest.php: -------------------------------------------------------------------------------- 1 | setMaxWidth($max); 72 | $tf->setBorder($border); 73 | 74 | $result = $tf->calculateColLengths($input); 75 | 76 | $this->assertEquals($max, array_sum($result) + (strlen($border) * (count($input) - 1))); 77 | $this->assertEquals($expect, $result); 78 | 79 | } 80 | 81 | /** 82 | * Check wrapping 83 | */ 84 | public function test_wrap() 85 | { 86 | $text = "this is a long string something\n" . 87 | "123456789012345678901234567890"; 88 | 89 | $expt = "this is a long\n" . 90 | "string\n" . 91 | "something\n" . 92 | "123456789012345\n" . 93 | "678901234567890"; 94 | 95 | $tf = new TableFormatter(); 96 | $this->assertEquals($expt, $tf->wordwrap($text, 15, "\n", true)); 97 | 98 | } 99 | 100 | public function test_length() 101 | { 102 | $text = "this is häppy ☺"; 103 | $expect = "$text |test"; 104 | 105 | $tf = new TableFormatter(); 106 | $tf->setBorder('|'); 107 | $result = $tf->format(array(20, '*'), array($text, 'test')); 108 | 109 | $this->assertEquals($expect, trim($result)); 110 | } 111 | 112 | public function test_colorlength() 113 | { 114 | $color = new Colors(); 115 | 116 | $text = 'this is ' . $color->wrap('green', Colors::C_GREEN); 117 | $expect = "$text |test"; 118 | 119 | $tf = new TableFormatter(); 120 | $tf->setBorder('|'); 121 | $result = $tf->format(array(20, '*'), array($text, 'test')); 122 | 123 | $this->assertEquals($expect, trim($result)); 124 | } 125 | 126 | public function test_onewrap() 127 | { 128 | $col1 = "test\nwrap"; 129 | $col2 = "test"; 130 | 131 | $expect = "test |test \n" . 132 | "wrap | \n"; 133 | 134 | $tf = new TableFormatter(); 135 | $tf->setMaxWidth(11); 136 | $tf->setBorder('|'); 137 | 138 | $result = $tf->format(array(5, '*'), array($col1, $col2)); 139 | $this->assertEquals($expect, $result); 140 | } 141 | 142 | /** 143 | * Test that colors are correctly applied when text is wrapping across lines. 144 | * 145 | * @dataProvider colorwrapProvider 146 | */ 147 | public function test_colorwrap($text, $expect) 148 | { 149 | $tf = new TableFormatter(); 150 | $tf->setMaxWidth(15); 151 | 152 | $this->assertEquals($expect, $tf->format(array('*'), array($text))); 153 | } 154 | 155 | /** 156 | * Data provider for test_colorwrap. 157 | * 158 | * @return array[] 159 | */ 160 | public function colorwrapProvider() 161 | { 162 | $color = new Colors(); 163 | $cyan = $color->getColorCode(Colors::C_CYAN); 164 | $reset = $color->getColorCode(Colors::C_RESET); 165 | $wrap = function ($str) use ($color) { 166 | return $color->wrap($str, Colors::C_CYAN); 167 | }; 168 | 169 | return array( 170 | 'color word line 1' => array( 171 | "This is ". $wrap("cyan") . " text wrapping", 172 | "This is {$cyan}cyan{$reset} \ntext wrapping \n", 173 | ), 174 | 'color word line 2' => array( 175 | "This is text ". $wrap("cyan") . " wrapping", 176 | "This is text \n{$cyan}cyan{$reset} wrapping \n", 177 | ), 178 | 'color across lines' => array( 179 | "This is ". $wrap("cyan text") . " wrapping", 180 | "This is {$cyan}cyan \ntext{$reset} wrapping \n", 181 | ), 182 | 'color across lines until end' => array( 183 | "This is ". $wrap("cyan text wrapping"), 184 | "This is {$cyan}cyan \n{$cyan}text wrapping{$reset} \n", 185 | ), 186 | ); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP-CLI 2 | 3 | PHP-CLI is a simple library that helps with creating nice looking command line scripts. 4 | 5 | It takes care of 6 | 7 | - **option parsing** 8 | - **help page generation** 9 | - **automatic width adjustment** 10 | - **colored output** 11 | - **optional PSR3 compatibility** 12 | 13 | It is lightweight and has **no 3rd party dependencies**. Note: this is for non-interactive scripts only. It has no readline or similar support. 14 | 15 | ## Installation 16 | 17 | Use composer: 18 | 19 | ```php composer.phar require splitbrain/php-cli``` 20 | 21 | ## Usage and Examples 22 | 23 | Minimal example: 24 | 25 | ```php 26 | #!/usr/bin/php 27 | setHelp('A very minimal example that does nothing but print a version'); 38 | $options->registerOption('version', 'print version', 'v'); 39 | } 40 | 41 | // implement your code 42 | protected function main(Options $options) 43 | { 44 | if ($options->getOpt('version')) { 45 | $this->info('1.0.0'); 46 | } else { 47 | echo $options->help(); 48 | } 49 | } 50 | } 51 | // execute it 52 | $cli = new Minimal(); 53 | $cli->run(); 54 | ``` 55 | 56 | ![Screenshot](screenshot.png) 57 | 58 | 59 | The basic usage is simple: 60 | 61 | - create a class and ``extend splitbrain\phpcli\CLI`` 62 | - implement the ```setup($options)``` method and register options, arguments, commands and set help texts 63 | - ``$options->setHelp()`` adds a general description 64 | - ``$options->registerOption()`` adds an option 65 | - ``$options->registerArgument()`` adds an argument 66 | - ``$options->registerCommand()`` adds a sub command 67 | - implement the ```main($options)``` method and do your business logic there 68 | - ``$options->getOpts`` lets you access set options 69 | - ``$options->getArgs()`` returns the remaining arguments after removing the options 70 | - ``$options->getCmd()`` returns the sub command the user used 71 | - instantiate your class and call ```run()``` on it 72 | 73 | More examples can be found in the examples directory. Please refer to the [API docs](https://splitbrain.github.io/php-cli/) 74 | for further info. 75 | 76 | ## Exceptions 77 | 78 | By default, the CLI class registers an exception handler and will print the exception's message to the end user and 79 | exit the programm with a non-zero exit code. You can disable this behaviour and catch all exceptions yourself by 80 | passing false to the constructor. 81 | 82 | You can use the provided ``splitbrain\phpcli\Exception`` to signal any problems within your main code yourself. The 83 | exception's code will be used as the exit code then. 84 | 85 | Stacktraces will be printed on log level `debug`. 86 | 87 | ## Colored output 88 | 89 | Colored output is handled through the ``Colors`` class. It tries to detect if a color terminal is available and only 90 | then uses terminal colors. You can always suppress colored output by passing ``--no-colors`` to your scripts. 91 | Disabling colors will also disable the emoticon prefixes. 92 | 93 | Simple colored log messages can be printed by you using the convinence methods ``success()`` (green), ``info()`` (cyan), 94 | ``error()`` (red) or ``fatal()`` (red). The latter will also exit the programm with a non-zero exit code. 95 | 96 | For more complex coloring you can access the color class through ``$this->colors`` in your script. The ``wrap()`` method 97 | is probably what you want to use. 98 | 99 | The table formatter allows coloring full columns. To use that mechanism pass an array of colors as third parameter to 100 | its ``format()`` method. Please note that you can not pass colored texts in the second parameters (text length calculation 101 | and wrapping will fail, breaking your texts). 102 | 103 | ## Table Formatter 104 | 105 | The ``TableFormatter`` class allows you to align texts in multiple columns. It tries to figure out the available 106 | terminal width on its own. It can be overwritten by setting a ``COLUMNS`` environment variable. 107 | 108 | The formatter is used through the ``format()`` method which expects at least two arrays: The first defines the column 109 | widths, the second contains the texts to fill into the columns. Between each column a border is printed (a single space 110 | by default). 111 | 112 | See the ``example/table.php`` for sample usage. 113 | 114 | Columns width can be given in three forms: 115 | 116 | - fixed width in characters by providing an integer (eg. ``15``) 117 | - precentages by provifing an integer and a percent sign (eg. ``25%``) 118 | - a single fluid "rest" column marked with an asterisk (eg. ``*``) 119 | 120 | When mixing fixed and percentage widths, percentages refer to the remaining space after all fixed columns have been 121 | assigned. 122 | 123 | Space for borders is automatically calculated. It is recommended to always have some relative (percentage) or a fluid 124 | column to adjust for different terminal widths. 125 | 126 | The table formatter is used for the automatic help screen accessible when calling your script with ``-h`` or ``--help``. 127 | 128 | ## PSR-3 Logging 129 | 130 | The CLI class is a fully PSR-3 compatible logger (printing colored log data to STDOUT and STDERR). This is useful when 131 | you call backend code from your CLI that expects a Logger instance to produce any sensible status output while running. 132 | 133 | If you need to pass a class implementing the `Psr\Log\LoggerInterface` you can do so by inheriting from one of the two provided classes implementing this interface instead of `splitbrain\phpcli\CLI`. 134 | 135 | * Use `splitbrain\phpcli\PSR3CLI` if you're using version 2 of PSR3 (PHP < 8.0) 136 | * Use `splitbrain\phpcli\PSR3CLIv3` if you're using version 3 of PSR3 (PHP >= 8.0) 137 | 138 | The resulting object then can be passed as the logger instance. The difference between the two is in adjusted method signatures (with appropriate type hinting) only. Be sure you have the suggested `psr/log` composer package installed when using these classes. 139 | 140 | Note: if your backend code calls for a PSR-3 logger but does not actually type check for the interface (AKA being LoggerAware only) you can also just pass an instance of `splitbrain\phpcli\CLI`. 141 | 142 | ## Log Levels 143 | 144 | You can adjust the verbosity of your CLI tool using the `--loglevel` parameter. Supported loglevels are the PSR-3 145 | loglevels and our own `success` level: 146 | 147 | * debug 148 | * info 149 | * notice 150 | * success (this is not defined in PSR-3) 151 | * warning 152 | * error 153 | * critical 154 | * alert 155 | * emergency 156 | 157 | ![Screenshot](screenshot2.png) 158 | 159 | Convenience methods for all log levels are available. Placeholder interpolation as described in PSR-3 is available, too. 160 | Messages from `warning` level onwards are printed to `STDERR` all below are printed to `STDOUT`. 161 | 162 | The default log level of your script can be set by overwriting the `$logdefault` member. 163 | 164 | See `example/logging.php` for an example. 165 | -------------------------------------------------------------------------------- /src/Base.php: -------------------------------------------------------------------------------- 1 | 13 | * @license MIT 14 | */ 15 | abstract class Base 16 | { 17 | /** @var string the executed script itself */ 18 | protected $bin; 19 | /** @var Options the option parser */ 20 | protected $options; 21 | /** @var Colors */ 22 | public $colors; 23 | 24 | /** @var array PSR-3 compatible loglevels and their prefix, color, output channel, enabled status */ 25 | protected $loglevel = array( 26 | 'debug' => array( 27 | 'icon' => '', 28 | 'color' => Colors::C_RESET, 29 | 'channel' => STDOUT, 30 | 'enabled' => true 31 | ), 32 | 'info' => array( 33 | 'icon' => 'ℹ ', 34 | 'color' => Colors::C_CYAN, 35 | 'channel' => STDOUT, 36 | 'enabled' => true 37 | ), 38 | 'notice' => array( 39 | 'icon' => '☛ ', 40 | 'color' => Colors::C_CYAN, 41 | 'channel' => STDOUT, 42 | 'enabled' => true 43 | ), 44 | 'success' => array( 45 | 'icon' => '✓ ', 46 | 'color' => Colors::C_GREEN, 47 | 'channel' => STDOUT, 48 | 'enabled' => true 49 | ), 50 | 'warning' => array( 51 | 'icon' => '⚠ ', 52 | 'color' => Colors::C_BROWN, 53 | 'channel' => STDERR, 54 | 'enabled' => true 55 | ), 56 | 'error' => array( 57 | 'icon' => '✗ ', 58 | 'color' => Colors::C_RED, 59 | 'channel' => STDERR, 60 | 'enabled' => true 61 | ), 62 | 'critical' => array( 63 | 'icon' => '☠ ', 64 | 'color' => Colors::C_LIGHTRED, 65 | 'channel' => STDERR, 66 | 'enabled' => true 67 | ), 68 | 'alert' => array( 69 | 'icon' => '✖ ', 70 | 'color' => Colors::C_LIGHTRED, 71 | 'channel' => STDERR, 72 | 'enabled' => true 73 | ), 74 | 'emergency' => array( 75 | 'icon' => '✘ ', 76 | 'color' => Colors::C_LIGHTRED, 77 | 'channel' => STDERR, 78 | 'enabled' => true 79 | ), 80 | ); 81 | 82 | /** @var string default log level */ 83 | protected $logdefault = 'info'; 84 | 85 | /** 86 | * constructor 87 | * 88 | * Initialize the arguments, set up helper classes and set up the CLI environment 89 | * 90 | * @param bool $autocatch should exceptions be catched and handled automatically? 91 | */ 92 | public function __construct($autocatch = true) 93 | { 94 | if ($autocatch) { 95 | set_exception_handler(array($this, 'fatal')); 96 | } 97 | $this->setLogLevel($this->logdefault); 98 | $this->colors = new Colors(); 99 | $this->options = new Options($this->colors); 100 | } 101 | 102 | /** 103 | * Register options and arguments on the given $options object 104 | * 105 | * @param Options $options 106 | * @return void 107 | * 108 | * @throws Exception 109 | */ 110 | abstract protected function setup(Options $options); 111 | 112 | /** 113 | * Your main program 114 | * 115 | * Arguments and options have been parsed when this is run 116 | * 117 | * @param Options $options 118 | * @return void 119 | * 120 | * @throws Exception 121 | */ 122 | abstract protected function main(Options $options); 123 | 124 | /** 125 | * Execute the CLI program 126 | * 127 | * Executes the setup() routine, adds default options, initiate the options parsing and argument checking 128 | * and finally executes main() - Each part is split into their own protected function below, so behaviour 129 | * can easily be overwritten 130 | * 131 | * @throws Exception 132 | */ 133 | public function run() 134 | { 135 | if ('cli' != php_sapi_name()) { 136 | throw new Exception('This has to be run from the command line'); 137 | } 138 | 139 | $this->setup($this->options); 140 | $this->registerDefaultOptions(); 141 | $this->parseOptions(); 142 | $this->handleDefaultOptions(); 143 | $this->setupLogging(); 144 | $this->checkArguments(); 145 | $this->execute(); 146 | } 147 | 148 | // region run handlers - for easier overriding 149 | 150 | /** 151 | * Add the default help, color and log options 152 | */ 153 | protected function registerDefaultOptions() 154 | { 155 | $this->options->registerOption( 156 | 'help', 157 | 'Display this help screen and exit immediately.', 158 | 'h' 159 | ); 160 | $this->options->registerOption( 161 | 'no-colors', 162 | 'Do not use any colors in output. Useful when piping output to other tools or files.' 163 | ); 164 | $this->options->registerOption( 165 | 'loglevel', 166 | 'Minimum level of messages to display. Default is ' . $this->colors->wrap($this->logdefault, Colors::C_CYAN) . '. ' . 167 | 'Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.', 168 | null, 169 | 'level' 170 | ); 171 | } 172 | 173 | /** 174 | * Handle the default options 175 | */ 176 | protected function handleDefaultOptions() 177 | { 178 | if ($this->options->getOpt('no-colors')) { 179 | $this->colors->disable(); 180 | } 181 | if ($this->options->getOpt('help')) { 182 | echo $this->options->help(); 183 | exit(0); 184 | } 185 | } 186 | 187 | /** 188 | * Handle the logging options 189 | */ 190 | protected function setupLogging() 191 | { 192 | $level = $this->options->getOpt('loglevel', $this->logdefault); 193 | $this->setLogLevel($level); 194 | } 195 | 196 | /** 197 | * Wrapper around the option parsing 198 | */ 199 | protected function parseOptions() 200 | { 201 | $this->options->parseOptions(); 202 | } 203 | 204 | /** 205 | * Wrapper around the argument checking 206 | */ 207 | protected function checkArguments() 208 | { 209 | $this->options->checkArguments(); 210 | } 211 | 212 | /** 213 | * Wrapper around main 214 | */ 215 | protected function execute() 216 | { 217 | $this->main($this->options); 218 | } 219 | 220 | // endregion 221 | 222 | // region logging 223 | 224 | /** 225 | * Set the current log level 226 | * 227 | * @param string $level 228 | */ 229 | public function setLogLevel($level) 230 | { 231 | if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); 232 | $enable = false; 233 | foreach (array_keys($this->loglevel) as $l) { 234 | if ($l == $level) $enable = true; 235 | $this->loglevel[$l]['enabled'] = $enable; 236 | } 237 | } 238 | 239 | /** 240 | * Check if a message with the given level should be logged 241 | * 242 | * @param string $level 243 | * @return bool 244 | */ 245 | public function isLogLevelEnabled($level) 246 | { 247 | if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); 248 | return $this->loglevel[$level]['enabled']; 249 | } 250 | 251 | /** 252 | * Exits the program on a fatal error 253 | * 254 | * @param \Exception|string $error either an exception or an error message 255 | * @param array $context 256 | */ 257 | public function fatal($error, array $context = array()) 258 | { 259 | $code = 0; 260 | if (is_object($error) && is_a($error, 'Exception')) { 261 | /** @var Exception $error */ 262 | $this->logMessage('debug', get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine()); 263 | $this->logMessage('debug', $error->getTraceAsString()); 264 | $code = $error->getCode(); 265 | $error = $error->getMessage(); 266 | 267 | } 268 | if (!$code) { 269 | $code = Exception::E_ANY; 270 | } 271 | 272 | $this->logMessage('critical', $error, $context); 273 | exit($code); 274 | } 275 | 276 | /** 277 | * Normal, positive outcome (This is not a PSR-3 level) 278 | * 279 | * @param string $string 280 | * @param array $context 281 | */ 282 | public function success($string, array $context = array()) 283 | { 284 | $this->logMessage('success', $string, $context); 285 | } 286 | 287 | /** 288 | * @param string $level 289 | * @param string $message 290 | * @param array $context 291 | */ 292 | protected function logMessage($level, $message, array $context = array()) 293 | { 294 | // unknown level is always an error 295 | if (!isset($this->loglevel[$level])) $level = 'error'; 296 | 297 | $info = $this->loglevel[$level]; 298 | if (!$this->isLogLevelEnabled($level)) return; // no logging for this level 299 | 300 | $message = $this->interpolate($message, $context); 301 | 302 | // when colors are wanted, we also add the icon 303 | if ($this->colors->isEnabled()) { 304 | $message = $info['icon'] . $message; 305 | } 306 | 307 | $this->colors->ptln($message, $info['color'], $info['channel']); 308 | } 309 | 310 | /** 311 | * Interpolates context values into the message placeholders. 312 | * 313 | * @param $message 314 | * @param array $context 315 | * @return string 316 | */ 317 | protected function interpolate($message, array $context = array()) 318 | { 319 | // build a replacement array with braces around the context keys 320 | $replace = array(); 321 | foreach ($context as $key => $val) { 322 | // check that the value can be casted to string 323 | if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { 324 | $replace['{' . $key . '}'] = $val; 325 | } 326 | } 327 | 328 | // interpolate replacement values into the message and return 329 | return strtr((string)$message, $replace); 330 | } 331 | 332 | // endregion 333 | } 334 | -------------------------------------------------------------------------------- /src/TableFormatter.php: -------------------------------------------------------------------------------- 1 | 11 | * @license MIT 12 | */ 13 | class TableFormatter 14 | { 15 | /** @var string border between columns */ 16 | protected $border = ' '; 17 | 18 | /** @var int the terminal width */ 19 | protected $max = 74; 20 | 21 | /** @var Colors for coloring output */ 22 | protected $colors; 23 | 24 | /** 25 | * TableFormatter constructor. 26 | * 27 | * @param Colors|null $colors 28 | */ 29 | public function __construct(Colors $colors = null) 30 | { 31 | // try to get terminal width 32 | $width = $this->getTerminalWidth(); 33 | if ($width) { 34 | $this->max = $width - 1; 35 | } 36 | 37 | if ($colors) { 38 | $this->colors = $colors; 39 | } else { 40 | $this->colors = new Colors(); 41 | } 42 | } 43 | 44 | /** 45 | * The currently set border (defaults to ' ') 46 | * 47 | * @return string 48 | */ 49 | public function getBorder() 50 | { 51 | return $this->border; 52 | } 53 | 54 | /** 55 | * Set the border. The border is set between each column. Its width is 56 | * added to the column widths. 57 | * 58 | * @param string $border 59 | */ 60 | public function setBorder($border) 61 | { 62 | $this->border = $border; 63 | } 64 | 65 | /** 66 | * Width of the terminal in characters 67 | * 68 | * initially autodetected 69 | * 70 | * @return int 71 | */ 72 | public function getMaxWidth() 73 | { 74 | return $this->max; 75 | } 76 | 77 | /** 78 | * Set the width of the terminal to assume (in characters) 79 | * 80 | * @param int $max 81 | */ 82 | public function setMaxWidth($max) 83 | { 84 | $this->max = $max; 85 | } 86 | 87 | /** 88 | * Tries to figure out the width of the terminal 89 | * 90 | * @return int terminal width, 0 if unknown 91 | */ 92 | protected function getTerminalWidth() 93 | { 94 | // from environment 95 | if (isset($_SERVER['COLUMNS'])) return (int)$_SERVER['COLUMNS']; 96 | 97 | // via tput 98 | $process = proc_open('tput cols', array( 99 | 1 => array('pipe', 'w'), 100 | 2 => array('pipe', 'w'), 101 | ), $pipes); 102 | $width = (int)stream_get_contents($pipes[1]); 103 | proc_close($process); 104 | 105 | return $width; 106 | } 107 | 108 | /** 109 | * Takes an array with dynamic column width and calculates the correct width 110 | * 111 | * Column width can be given as fixed char widths, percentages and a single * width can be given 112 | * for taking the remaining available space. When mixing percentages and fixed widths, percentages 113 | * refer to the remaining space after allocating the fixed width 114 | * 115 | * @param array $columns 116 | * @return int[] 117 | * @throws Exception 118 | */ 119 | protected function calculateColLengths($columns) 120 | { 121 | $idx = 0; 122 | $border = $this->strlen($this->border); 123 | $fixed = (count($columns) - 1) * $border; // borders are used already 124 | $fluid = -1; 125 | 126 | // first pass for format check and fixed columns 127 | foreach ($columns as $idx => $col) { 128 | // handle fixed columns 129 | if ((string)intval($col) === (string)$col) { 130 | $fixed += $col; 131 | continue; 132 | } 133 | // check if other colums are using proper units 134 | if (substr($col, -1) == '%') { 135 | continue; 136 | } 137 | if ($col == '*') { 138 | // only one fluid 139 | if ($fluid < 0) { 140 | $fluid = $idx; 141 | continue; 142 | } else { 143 | throw new Exception('Only one fluid column allowed!'); 144 | } 145 | } 146 | throw new Exception("unknown column format $col"); 147 | } 148 | 149 | $alloc = $fixed; 150 | $remain = $this->max - $alloc; 151 | 152 | // second pass to handle percentages 153 | foreach ($columns as $idx => $col) { 154 | if (substr($col, -1) != '%') { 155 | continue; 156 | } 157 | $perc = floatval($col); 158 | 159 | $real = (int)floor(($perc * $remain) / 100); 160 | 161 | $columns[$idx] = $real; 162 | $alloc += $real; 163 | } 164 | 165 | $remain = $this->max - $alloc; 166 | if ($remain < 0) { 167 | throw new Exception("Wanted column widths exceed available space"); 168 | } 169 | 170 | // assign remaining space 171 | if ($fluid < 0) { 172 | $columns[$idx] += ($remain); // add to last column 173 | } else { 174 | $columns[$fluid] = $remain; 175 | } 176 | 177 | return $columns; 178 | } 179 | 180 | /** 181 | * Displays text in multiple word wrapped columns 182 | * 183 | * @param int[] $columns list of column widths (in characters, percent or '*') 184 | * @param string[] $texts list of texts for each column 185 | * @param array $colors A list of color names to use for each column. use empty string for default 186 | * @return string 187 | * @throws Exception 188 | */ 189 | public function format($columns, $texts, $colors = array()) 190 | { 191 | $columns = $this->calculateColLengths($columns); 192 | 193 | $wrapped = array(); 194 | $maxlen = 0; 195 | 196 | foreach ($columns as $col => $width) { 197 | $wrapped[$col] = explode("\n", $this->wordwrap($texts[$col], $width, "\n", true)); 198 | $len = count($wrapped[$col]); 199 | if ($len > $maxlen) { 200 | $maxlen = $len; 201 | } 202 | 203 | } 204 | 205 | $last = count($columns) - 1; 206 | $out = ''; 207 | for ($i = 0; $i < $maxlen; $i++) { 208 | foreach ($columns as $col => $width) { 209 | if (isset($wrapped[$col][$i])) { 210 | $val = $wrapped[$col][$i]; 211 | } else { 212 | $val = ''; 213 | } 214 | $chunk = $this->pad($val, $width); 215 | if (isset($colors[$col]) && $colors[$col]) { 216 | $chunk = $this->colors->wrap($chunk, $colors[$col]); 217 | } 218 | $out .= $chunk; 219 | 220 | // border 221 | if ($col != $last) { 222 | $out .= $this->border; 223 | } 224 | } 225 | $out .= "\n"; 226 | } 227 | return $out; 228 | 229 | } 230 | 231 | /** 232 | * Pad the given string to the correct length 233 | * 234 | * @param string $string 235 | * @param int $len 236 | * @return string 237 | */ 238 | protected function pad($string, $len) 239 | { 240 | $strlen = $this->strlen($string); 241 | if ($strlen > $len) return $string; 242 | 243 | $pad = $len - $strlen; 244 | return $string . str_pad('', $pad, ' '); 245 | } 246 | 247 | /** 248 | * Measures char length in UTF-8 when possible 249 | * 250 | * @param $string 251 | * @return int 252 | */ 253 | protected function strlen($string) 254 | { 255 | // don't count color codes 256 | $string = preg_replace("/\33\\[\\d+(;\\d+)?m/", '', $string); 257 | 258 | if (function_exists('mb_strlen')) { 259 | return mb_strlen($string, 'utf-8'); 260 | } 261 | 262 | return strlen($string); 263 | } 264 | 265 | /** 266 | * @param string $string 267 | * @param int $start 268 | * @param int|null $length 269 | * @return string 270 | */ 271 | protected function substr($string, $start = 0, $length = null) 272 | { 273 | if (function_exists('mb_substr')) { 274 | return mb_substr($string, $start, $length); 275 | } else { 276 | // mb_substr() treats $length differently than substr() 277 | if ($length) { 278 | return substr($string, $start, $length); 279 | } else { 280 | return substr($string, $start); 281 | } 282 | } 283 | } 284 | 285 | /** 286 | * @param string $str 287 | * @param int $width 288 | * @param string $break 289 | * @param bool $cut 290 | * @return string 291 | * @link http://stackoverflow.com/a/4988494 292 | */ 293 | protected function wordwrap($str, $width = 75, $break = "\n", $cut = false) 294 | { 295 | $lines = explode($break, $str); 296 | $color_reset = $this->colors->getColorCode(Colors::C_RESET); 297 | foreach ($lines as &$line) { 298 | $line = rtrim($line); 299 | if ($this->strlen($line) <= $width) { 300 | continue; 301 | } 302 | $words = explode(' ', $line); 303 | $line = ''; 304 | $actual = ''; 305 | $color = ''; 306 | foreach ($words as $word) { 307 | if (preg_match_all(Colors::C_CODE_REGEX, $word, $color_codes) ) { 308 | # Word contains color codes 309 | foreach ($color_codes[0] as $code) { 310 | if ($code == $color_reset) { 311 | $color = ''; 312 | } else { 313 | # Remember color so we can reapply it after a line break 314 | $color = $code; 315 | } 316 | } 317 | } 318 | if ($this->strlen($actual . $word) <= $width) { 319 | $actual .= $word . ' '; 320 | } else { 321 | if ($actual != '') { 322 | $line .= rtrim($actual) . $break; 323 | } 324 | $actual = $color . $word; 325 | if ($cut) { 326 | while ($this->strlen($actual) > $width) { 327 | $line .= $this->substr($actual, 0, $width) . $break; 328 | $actual = $color . $this->substr($actual, $width); 329 | } 330 | } 331 | $actual .= ' '; 332 | } 333 | } 334 | $line .= trim($actual); 335 | } 336 | return implode($break, $lines); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 12 | * @license MIT 13 | */ 14 | class Options 15 | { 16 | /** @var array keeps the list of options to parse */ 17 | protected $setup; 18 | 19 | /** @var array store parsed options */ 20 | protected $options = array(); 21 | 22 | /** @var string current parsed command if any */ 23 | protected $command = ''; 24 | 25 | /** @var array passed non-option arguments */ 26 | protected $args = array(); 27 | 28 | /** @var string the executed script */ 29 | protected $bin; 30 | 31 | /** @var Colors for colored help output */ 32 | protected $colors; 33 | 34 | /** @var string newline used for spacing help texts */ 35 | protected $newline = "\n"; 36 | 37 | /** 38 | * Constructor 39 | * 40 | * @param Colors $colors optional configured color object 41 | * @throws Exception when arguments can't be read 42 | */ 43 | public function __construct(?Colors $colors = null) 44 | { 45 | if (!is_null($colors)) { 46 | $this->colors = $colors; 47 | } else { 48 | $this->colors = new Colors(); 49 | } 50 | 51 | $this->setup = array( 52 | '' => array( 53 | 'opts' => array(), 54 | 'args' => array(), 55 | 'help' => '', 56 | 'commandhelp' => 'This tool accepts a command as first parameter as outlined below:' 57 | ) 58 | ); // default command 59 | 60 | $this->args = $this->readPHPArgv(); 61 | $this->bin = basename(array_shift($this->args)); 62 | 63 | $this->options = array(); 64 | } 65 | 66 | /** 67 | * Gets the bin value 68 | */ 69 | public function getBin() 70 | { 71 | return $this->bin; 72 | } 73 | 74 | /** 75 | * Sets the help text for the tool itself 76 | * 77 | * @param string $help 78 | */ 79 | public function setHelp($help) 80 | { 81 | $this->setup['']['help'] = $help; 82 | } 83 | 84 | /** 85 | * Sets the help text for the tools commands itself 86 | * 87 | * @param string $help 88 | */ 89 | public function setCommandHelp($help) 90 | { 91 | $this->setup['']['commandhelp'] = $help; 92 | } 93 | 94 | /** 95 | * Use a more compact help screen with less new lines 96 | * 97 | * @param bool $set 98 | */ 99 | public function useCompactHelp($set = true) 100 | { 101 | $this->newline = $set ? '' : "\n"; 102 | } 103 | 104 | /** 105 | * Register the names of arguments for help generation and number checking 106 | * 107 | * This has to be called in the order arguments are expected 108 | * 109 | * @param string $arg argument name (just for help) 110 | * @param string $help help text 111 | * @param bool $required is this a required argument 112 | * @param string $command if theses apply to a sub command only 113 | * @throws Exception 114 | */ 115 | public function registerArgument($arg, $help, $required = true, $command = '') 116 | { 117 | if (!isset($this->setup[$command])) { 118 | throw new Exception("Command $command not registered"); 119 | } 120 | 121 | $this->setup[$command]['args'][] = array( 122 | 'name' => $arg, 123 | 'help' => $help, 124 | 'required' => $required 125 | ); 126 | } 127 | 128 | /** 129 | * This registers a sub command 130 | * 131 | * Sub commands have their own options and use their own function (not main()). 132 | * 133 | * @param string $command 134 | * @param string $help 135 | * @throws Exception 136 | */ 137 | public function registerCommand($command, $help) 138 | { 139 | if (isset($this->setup[$command])) { 140 | throw new Exception("Command $command already registered"); 141 | } 142 | 143 | $this->setup[$command] = array( 144 | 'opts' => array(), 145 | 'args' => array(), 146 | 'help' => $help 147 | ); 148 | 149 | } 150 | 151 | /** 152 | * Register an option for option parsing and help generation 153 | * 154 | * @param string $long multi character option (specified with --) 155 | * @param string $help help text for this option 156 | * @param string|null $short one character option (specified with -) 157 | * @param bool|string $needsarg does this option require an argument? give it a name here 158 | * @param string $command what command does this option apply to 159 | * @throws Exception 160 | */ 161 | public function registerOption($long, $help, $short = null, $needsarg = false, $command = '') 162 | { 163 | if (!isset($this->setup[$command])) { 164 | throw new Exception("Command $command not registered"); 165 | } 166 | 167 | $this->setup[$command]['opts'][$long] = array( 168 | 'needsarg' => $needsarg, 169 | 'help' => $help, 170 | 'short' => $short 171 | ); 172 | 173 | if ($short) { 174 | if (strlen($short) > 1) { 175 | throw new Exception("Short options should be exactly one ASCII character"); 176 | } 177 | 178 | $this->setup[$command]['short'][$short] = $long; 179 | } 180 | } 181 | 182 | /** 183 | * Checks the actual number of arguments against the required number 184 | * 185 | * Throws an exception if arguments are missing. 186 | * 187 | * This is run from CLI automatically and usually does not need to be called directly 188 | * 189 | * @throws Exception 190 | */ 191 | public function checkArguments() 192 | { 193 | $argc = count($this->args); 194 | 195 | $req = 0; 196 | foreach ($this->setup[$this->command]['args'] as $arg) { 197 | if (!$arg['required']) { 198 | break; 199 | } // last required arguments seen 200 | $req++; 201 | } 202 | 203 | if ($req > $argc) { 204 | throw new Exception("Not enough arguments", Exception::E_OPT_ARG_REQUIRED); 205 | } 206 | } 207 | 208 | /** 209 | * Parses the given arguments for known options and command 210 | * 211 | * The given $args array should NOT contain the executed file as first item anymore! The $args 212 | * array is stripped from any options and possible command. All found otions can be accessed via the 213 | * getOpt() function 214 | * 215 | * Note that command options will overwrite any global options with the same name 216 | * 217 | * This is run from CLI automatically and usually does not need to be called directly 218 | * 219 | * @throws Exception 220 | */ 221 | public function parseOptions() 222 | { 223 | $non_opts = array(); 224 | 225 | $argc = count($this->args); 226 | for ($i = 0; $i < $argc; $i++) { 227 | $arg = $this->args[$i]; 228 | 229 | // The special element '--' means explicit end of options. Treat the rest of the arguments as non-options 230 | // and end the loop. 231 | if ($arg == '--') { 232 | $non_opts = array_merge($non_opts, array_slice($this->args, $i + 1)); 233 | break; 234 | } 235 | 236 | // '-' is stdin - a normal argument 237 | if ($arg == '-') { 238 | $non_opts = array_merge($non_opts, array_slice($this->args, $i)); 239 | break; 240 | } 241 | 242 | // first non-option 243 | if ($arg[0] != '-') { 244 | $non_opts = array_merge($non_opts, array_slice($this->args, $i)); 245 | break; 246 | } 247 | 248 | // long option 249 | if (strlen($arg) > 1 && $arg[1] === '-') { 250 | $arg = explode('=', substr($arg, 2), 2); 251 | $opt = array_shift($arg); 252 | $val = array_shift($arg); 253 | 254 | if (!isset($this->setup[$this->command]['opts'][$opt])) { 255 | throw new Exception("No such option '$opt'", Exception::E_UNKNOWN_OPT); 256 | } 257 | 258 | // argument required? 259 | if ($this->setup[$this->command]['opts'][$opt]['needsarg']) { 260 | if (is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) { 261 | $val = $this->args[++$i]; 262 | } 263 | if (is_null($val)) { 264 | throw new Exception("Option $opt requires an argument", 265 | Exception::E_OPT_ARG_REQUIRED); 266 | } 267 | $this->options[$opt] = $val; 268 | } else { 269 | $this->options[$opt] = true; 270 | } 271 | 272 | continue; 273 | } 274 | 275 | // short option 276 | $opt = substr($arg, 1); 277 | if (!isset($this->setup[$this->command]['short'][$opt])) { 278 | throw new Exception("No such option $arg", Exception::E_UNKNOWN_OPT); 279 | } else { 280 | $opt = $this->setup[$this->command]['short'][$opt]; // store it under long name 281 | } 282 | 283 | // argument required? 284 | if ($this->setup[$this->command]['opts'][$opt]['needsarg']) { 285 | $val = null; 286 | if ($i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) { 287 | $val = $this->args[++$i]; 288 | } 289 | if (is_null($val)) { 290 | throw new Exception("Option $arg requires an argument", 291 | Exception::E_OPT_ARG_REQUIRED); 292 | } 293 | $this->options[$opt] = $val; 294 | } else { 295 | $this->options[$opt] = true; 296 | } 297 | } 298 | 299 | // parsing is now done, update args array 300 | $this->args = $non_opts; 301 | 302 | // if not done yet, check if first argument is a command and reexecute argument parsing if it is 303 | if (!$this->command && $this->args && isset($this->setup[$this->args[0]])) { 304 | // it is a command! 305 | $this->command = array_shift($this->args); 306 | $this->parseOptions(); // second pass 307 | } 308 | } 309 | 310 | /** 311 | * Get the value of the given option 312 | * 313 | * Please note that all options are accessed by their long option names regardless of how they were 314 | * specified on commandline. 315 | * 316 | * Can only be used after parseOptions() has been run 317 | * 318 | * @param mixed $option 319 | * @param bool|string $default what to return if the option was not set 320 | * @return bool|string|string[] 321 | */ 322 | public function getOpt($option = null, $default = false) 323 | { 324 | if ($option === null) { 325 | return $this->options; 326 | } 327 | 328 | if (isset($this->options[$option])) { 329 | return $this->options[$option]; 330 | } 331 | return $default; 332 | } 333 | 334 | /** 335 | * Return the found command if any 336 | * 337 | * @return string 338 | */ 339 | public function getCmd() 340 | { 341 | return $this->command; 342 | } 343 | 344 | /** 345 | * Get all the arguments passed to the script 346 | * 347 | * This will not contain any recognized options or the script name itself 348 | * 349 | * @return array 350 | */ 351 | public function getArgs() 352 | { 353 | return $this->args; 354 | } 355 | 356 | /** 357 | * Builds a help screen from the available options. You may want to call it from -h or on error 358 | * 359 | * @return string 360 | * 361 | * @throws Exception 362 | */ 363 | public function help() 364 | { 365 | $tf = new TableFormatter($this->colors); 366 | $text = ''; 367 | 368 | $hascommands = (count($this->setup) > 1); 369 | $commandhelp = $this->setup['']["commandhelp"]; 370 | 371 | foreach ($this->setup as $command => $config) { 372 | $hasopts = (bool)$this->setup[$command]['opts']; 373 | $hasargs = (bool)$this->setup[$command]['args']; 374 | 375 | // usage or command syntax line 376 | if (!$command) { 377 | $text .= $this->colors->wrap('USAGE:', Colors::C_BROWN); 378 | $text .= "\n"; 379 | $text .= ' ' . $this->bin; 380 | $mv = 2; 381 | } else { 382 | $text .= $this->newline; 383 | $text .= $this->colors->wrap(' ' . $command, Colors::C_PURPLE); 384 | $mv = 4; 385 | } 386 | 387 | if ($hasopts) { 388 | $text .= ' ' . $this->colors->wrap('', Colors::C_GREEN); 389 | } 390 | 391 | if (!$command && $hascommands) { 392 | $text .= ' ' . $this->colors->wrap(' ...', Colors::C_PURPLE); 393 | } 394 | 395 | foreach ($this->setup[$command]['args'] as $arg) { 396 | $out = $this->colors->wrap('<' . $arg['name'] . '>', Colors::C_CYAN); 397 | 398 | if (!$arg['required']) { 399 | $out = '[' . $out . ']'; 400 | } 401 | $text .= ' ' . $out; 402 | } 403 | $text .= $this->newline; 404 | 405 | // usage or command intro 406 | if ($this->setup[$command]['help']) { 407 | $text .= "\n"; 408 | $text .= $tf->format( 409 | array($mv, '*'), 410 | array('', $this->setup[$command]['help'] . $this->newline) 411 | ); 412 | } 413 | 414 | // option description 415 | if ($hasopts) { 416 | if (!$command) { 417 | $text .= "\n"; 418 | $text .= $this->colors->wrap('OPTIONS:', Colors::C_BROWN); 419 | } 420 | $text .= "\n"; 421 | foreach ($this->setup[$command]['opts'] as $long => $opt) { 422 | 423 | $name = ''; 424 | if ($opt['short']) { 425 | $name .= '-' . $opt['short']; 426 | if ($opt['needsarg']) { 427 | $name .= ' <' . $opt['needsarg'] . '>'; 428 | } 429 | $name .= ', '; 430 | } 431 | $name .= "--$long"; 432 | if ($opt['needsarg']) { 433 | $name .= ' <' . $opt['needsarg'] . '>'; 434 | } 435 | 436 | $text .= $tf->format( 437 | array($mv, '30%', '*'), 438 | array('', $name, $opt['help']), 439 | array('', 'green', '') 440 | ); 441 | $text .= $this->newline; 442 | } 443 | } 444 | 445 | // argument description 446 | if ($hasargs) { 447 | if (!$command) { 448 | $text .= "\n"; 449 | $text .= $this->colors->wrap('ARGUMENTS:', Colors::C_BROWN); 450 | } 451 | $text .= $this->newline; 452 | foreach ($this->setup[$command]['args'] as $arg) { 453 | $name = '<' . $arg['name'] . '>'; 454 | 455 | $text .= $tf->format( 456 | array($mv, '30%', '*'), 457 | array('', $name, $arg['help']), 458 | array('', 'cyan', '') 459 | ); 460 | } 461 | } 462 | 463 | // head line and intro for following command documentation 464 | if (!$command && $hascommands) { 465 | $text .= "\n"; 466 | $text .= $this->colors->wrap('COMMANDS:', Colors::C_BROWN); 467 | $text .= "\n"; 468 | $text .= $tf->format( 469 | array($mv, '*'), 470 | array('', $commandhelp) 471 | ); 472 | $text .= $this->newline; 473 | } 474 | } 475 | 476 | return $text; 477 | } 478 | 479 | /** 480 | * Safely read the $argv PHP array across different PHP configurations. 481 | * Will take care on register_globals and register_argc_argv ini directives 482 | * 483 | * @throws Exception 484 | * @return array the $argv PHP array or PEAR error if not registered 485 | */ 486 | private function readPHPArgv() 487 | { 488 | global $argv; 489 | if (!is_array($argv)) { 490 | if (!@is_array($_SERVER['argv'])) { 491 | if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) { 492 | throw new Exception( 493 | "Could not read cmd args (register_argc_argv=Off?)", 494 | Exception::E_ARG_READ 495 | ); 496 | } 497 | return $GLOBALS['HTTP_SERVER_VARS']['argv']; 498 | } 499 | return $_SERVER['argv']; 500 | } 501 | return $argv; 502 | } 503 | } 504 | 505 | --------------------------------------------------------------------------------