├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------