summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--_test/tests/inc/cli_options.test.php56
-rw-r--r--inc/cli.php590
-rw-r--r--inc/load.php4
3 files changed, 650 insertions, 0 deletions
diff --git a/_test/tests/inc/cli_options.test.php b/_test/tests/inc/cli_options.test.php
new file mode 100644
index 000000000..1d121d7ce
--- /dev/null
+++ b/_test/tests/inc/cli_options.test.php
@@ -0,0 +1,56 @@
+<?php
+
+class cli_options extends DokuWikiTest {
+
+ function test_simpleshort() {
+ $options = new DokuCLI_Options();
+ $options->registerOption('exclude', 'exclude files', 'x', true);
+
+ $args = array('-x', 'foo', 'bang');
+ $options->parseOptions($args);
+
+ $this->assertEquals('foo', $options->getOpt('exclude'));
+ $this->assertEquals(array('bang'), $args);
+ $this->assertFalse($options->getOpt('nothing'));
+ }
+
+ function test_simplelong1() {
+ $options = new DokuCLI_Options();
+ $options->registerOption('exclude', 'exclude files', 'x', true);
+
+ $args = array('--exclude', 'foo', 'bang');
+ $options->parseOptions($args);
+
+ $this->assertEquals('foo', $options->getOpt('exclude'));
+ $this->assertEquals(array('bang'), $args);
+ $this->assertFalse($options->getOpt('nothing'));
+ }
+
+ function test_simplelong2() {
+ $options = new DokuCLI_Options();
+ $options->registerOption('exclude', 'exclude files', 'x', true);
+
+ $args = array('--exclude=foo', 'bang');
+ $options->parseOptions($args);
+
+ $this->assertEquals('foo', $options->getOpt('exclude'));
+ $this->assertEquals(array('bang'), $args);
+ $this->assertFalse($options->getOpt('nothing'));
+ }
+
+ function test_complex() {
+ $options = new DokuCLI_Options();
+
+ $options->registerOption('plugins', 'run on plugins only', 'p');
+ $options->registerCommand('status', 'display status info');
+ $options->registerOption('long', 'display long lines', 'l', false, 'status');
+
+ $args = array('-p', 'status', '--long', 'foo');
+ $options->parseOptions($args);
+
+ $this->assertEquals('status', $options->getCmd());
+ $this->assertTrue($options->getOpt('plugins'));
+ $this->assertTrue($options->getOpt('long'));
+ $this->assertEquals(array('foo'), $args);
+ }
+} \ No newline at end of file
diff --git a/inc/cli.php b/inc/cli.php
new file mode 100644
index 000000000..2827ae233
--- /dev/null
+++ b/inc/cli.php
@@ -0,0 +1,590 @@
+<?php
+
+/**
+ * Class DokuCLI
+ *
+ * All DokuWiki commandline scripts should inherit from this class and implement the abstract methods.
+ *
+ * @author Andreas Gohr <andi@splitbrain.org>
+ */
+abstract class DokuCLI {
+ /** @var string the executed script itself */
+ protected $bin;
+ /** @var array list of non-option arguments */
+ protected $args;
+ /** @var DokuCLI_Options the option parser */
+ protected $options;
+ /** @var DokuCLI_Colors */
+ public $colors;
+
+ /**
+ * constructor
+ *
+ * Initialize the arguments, set up helper classes and set up the CLI environment
+ */
+ public function __construct() {
+ set_exception_handler(array($this, 'fatal'));
+
+ $this->args = $this->readPHPArgv();
+ $this->bin = basename(array_shift($this->args));
+
+ $this->options = new DokuCLI_Options();
+ $this->colors = new DokuCLI_Colors();
+ }
+
+ /**
+ * Register options and arguments on the given $options object
+ *
+ * @param DokuCLI_Options $options
+ * @return void
+ */
+ abstract protected function setup(DokuCLI_Options $options);
+
+ /**
+ * Your main program
+ *
+ * Arguments and options have been parsed when this is run
+ *
+ * @param DokuCLI_Options $options
+ * @param array $args
+ * @return void
+ */
+ abstract protected function main(DokuCLI_Options $options, &$args);
+
+ /**
+ * Execute the CLI program
+ *
+ * Executes the setup() routine, adds default options, initiate the options parsing and argument checking
+ * and finally executes main()
+ */
+ public function run() {
+ if('cli' != php_sapi_name()) throw new DokuCLI_Exception('This has to be run from the command line');
+
+ // setup
+ $this->setup($this->options);
+ $this->options->registerOption(
+ 'no-colors',
+ 'Do not use any colors in output. Useful when piping output to other tools or files.'
+ );
+ $this->options->registerOption(
+ 'help',
+ 'Display this help screen and exit immeadiately.',
+ 'h'
+ );
+
+ // parse
+ $this->options->parseOptions($this->args);
+
+ // handle defaults
+ if($this->options->getOpt('no-colors')) {
+ $this->colors->disable();
+ }
+ if($this->options->getOpt('help')) {
+ $this->options->help($this->bin);
+ exit(0);
+ }
+
+ // check arguments
+ $this->options->checkArguments($this->args);
+
+ // execute
+ $this->main($this->options, $this->args);
+ }
+
+ /**
+ * Exits the program on a fatal error
+ *
+ * @param Exception|string $error either an exception or an error message
+ */
+ public function fatal($error) {
+ $code = 0;
+ if(is_a($error, 'Exception')) {
+ /** @var Exception $error */
+ $code = $error->getCode();
+ $error = $error->getMessage();
+ }
+ if(!$code) $code = DokuCLI_Exception::E_ANY;
+
+ $this->colors->ptln($error, 'red');
+ exit($code);
+ }
+
+ /**
+ * Print an error message
+ *
+ * @param $string
+ */
+ public function error($string) {
+ $this->colors->ptln("E: $string", 'red');
+ }
+
+ /**
+ * Print a success message
+ *
+ * @param $string
+ */
+ public function success($string) {
+ $this->colors->ptln("S: $string", 'green');
+ }
+
+ /**
+ * Print an info message
+ *
+ * @param $string
+ */
+ public function info($string) {
+ $this->colors->ptln("I: $string", 'cyan');
+ }
+
+ /**
+ * Safely read the $argv PHP array across different PHP configurations.
+ * Will take care on register_globals and register_argc_argv ini directives
+ *
+ * @throws DokuCLI_Exception
+ * @return array the $argv PHP array or PEAR error if not registered
+ */
+ private function readPHPArgv() {
+ global $argv;
+ if(!is_array($argv)) {
+ if(!@is_array($_SERVER['argv'])) {
+ if(!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
+ throw new DokuCLI_Exception(
+ "Could not read cmd args (register_argc_argv=Off?)",
+ DOKU_CLI_OPTS_ARG_READ
+ );
+ }
+ return $GLOBALS['HTTP_SERVER_VARS']['argv'];
+ }
+ return $_SERVER['argv'];
+ }
+ return $argv;
+ }
+}
+
+/**
+ * Class DokuCLI_Colors
+ *
+ * Handles color output on (Linux) terminals
+ *
+ * @author Andreas Gohr <andi@splitbrain.org>
+ */
+class DokuCLI_Colors {
+ /** @var array known color names */
+ protected $colors = array(
+ 'reset' => "\33[0m",
+ 'black' => "\33[0;30m",
+ 'darkgray' => "\33[1;30m",
+ 'blue' => "\33[0;34m",
+ 'lightblue' => "\33[1;34m",
+ 'green' => "\33[0;32m",
+ 'lightgreen' => "\33[1;32m",
+ 'cyan' => "\33[0;36m",
+ 'lightcyan' => "\33[1;36m",
+ 'red' => "\33[0;31m",
+ 'lightred' => "\33[1;31m",
+ 'purple' => "\33[0;35m",
+ 'lightpurple' => "\33[1;35m",
+ 'brown' => "\33[0;33m",
+ 'yellow' => "\33[1;33m",
+ 'lightgray' => "\33[0;37m",
+ 'white' => "\33[1;37m",
+ );
+
+ /** @var bool should colors be used? */
+ protected $enabled = true;
+
+ /**
+ * Constructor
+ *
+ * Tries to disable colors for non-terminals
+ */
+ public function __construct() {
+ if(function_exists('posix_isatty') && !posix_isatty(STDOUT)) {
+ $this->enabled = false;
+ return;
+ }
+ if(!getenv('TERM')) {
+ $this->enabled = false;
+ return;
+ }
+ }
+
+ /**
+ * enable color output
+ */
+ public function enable() {
+ $this->enabled = true;
+ }
+
+ /**
+ * disable color output
+ */
+ public function disable() {
+ $this->enabled = false;
+ }
+
+ /**
+ * Convenience function to print a line in a given color
+ *
+ * @param $line
+ * @param $color
+ */
+ public function ptln($line, $color) {
+ $this->set($color);
+ echo rtrim($line) . "\n";
+ $this->reset();
+ }
+
+ /**
+ * Set the given color for consecutive output
+ *
+ * @param string $color one of the supported color names
+ * @throws DokuCLI_Exception
+ */
+ public function set($color) {
+ if(!$this->enabled) return;
+ if(!isset($this->colors[$color])) throw new DokuCLI_Exception("No such color $color");
+ echo $this->colors[$color];
+ }
+
+ /**
+ * reset the terminal color
+ */
+ public function reset() {
+ $this->set('reset');
+ }
+}
+
+/**
+ * Class DokuCLI_Options
+ *
+ * Parses command line options passed to the CLI script. Allows CLI scripts to easily register all accepted options and
+ * commands and even generates a help text from this setup.
+ *
+ * @author Andreas Gohr <andi@splitbrain.org>
+ */
+class DokuCLI_Options {
+ /** @var array $setup keeps the list of options to parse */
+ protected $setup;
+
+ /** @var array $options store parsed options */
+ protected $options;
+
+ protected $command = '';
+
+ public function __construct() {
+ $this->setup = array(
+ '' => array(
+ 'opts' => array(),
+ 'args' => array(),
+ 'help' => ''
+ )
+ ); // default command
+
+ $this->options = array();
+ }
+
+ /**
+ * Sets the help text for the tool itself
+ *
+ * @param string $help
+ */
+ public function setHelp($help) {
+ $this->setup['']['help'] = $help;
+ }
+
+ /**
+ * Register the names of arguments for help generation and number checking
+ *
+ * This has to be called in the order arguments are expected
+ *
+ * @param string $arg argument name (just for help)
+ * @param string $help help text
+ * @param bool $required is this a required argument
+ * @param string $command if theses apply to a sub command only
+ * @throws DokuCLI_Exception
+ */
+ public function registerArgument($arg, $help, $required = true, $command = '') {
+ if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
+
+ $this->setup[$command]['args'][] = array(
+ 'name' => $arg,
+ 'help' => $help,
+ 'required' => $required
+ );
+ }
+
+ /**
+ * This registers a sub command
+ *
+ * Sub commands have their own options and use their own function (not main())
+ *
+ * @param string $command
+ * @param string $help
+ * @throws DokuCLI_Exception
+ */
+ public function registerCommand($command, $help) {
+ if(isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command already registered");
+
+ $this->setup[$command] = array(
+ 'opts' => array(),
+ 'args' => array(),
+ 'help' => $help
+ );
+
+ }
+
+ /**
+ * Register an option for option parsing and help generation
+ *
+ * @param string $long multi character option (specified with --)
+ * @param string $help help text for this option
+ * @param string|null $short one character option (specified with -)
+ * @param bool $needsarg does this option require an argument?
+ * @param string $command what command does this option apply to
+ * @throws DokuCLI_Exception
+ */
+ public function registerOption($long, $help, $short = null, $needsarg = false, $command = '') {
+ if(!isset($this->setup[$command])) throw new DokuCLI_Exception("Command $command not registered");
+
+ $this->setup[$command]['opts'][$long] = array(
+ 'needsarg' => $needsarg,
+ 'help' => $help,
+ 'short' => $short
+ );
+
+ if($short) {
+ if(strlen($short) > 1) throw new DokuCLI_Exception("Short options should be exactly one ASCII character");
+
+ $this->setup[$command]['short'][$short] = $long;
+ }
+ }
+
+ /**
+ * Checks the actual number of arguments against the required number
+ *
+ * Throws an exception if arguments are missing. Called from parseOptions()
+ *
+ * @param array $args
+ * @throws DokuCLI_Exception
+ */
+ public function checkArguments($args) {
+ $argc = count($args);
+
+ $req = 0;
+ foreach($this->setup[$this->command]['args'] as $arg) {
+ if(!$args['required']) break; // last required arguments seen
+ $req++;
+ }
+
+ if($req > $argc) throw new DokuCLI_Exception("Not enough arguments", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
+ }
+
+ /**
+ * Parses the given arguments for known options and command
+ *
+ * The given $args array should NOT contain the executed file as first item anymore! The $args
+ * array is stripped from any options and possible command. All found otions can be accessed via the
+ * getOpt() function
+ *
+ * Note that command options will overwrite any global options with the same name
+ *
+ * @param array $args
+ * @throws DokuCLI_Exception
+ */
+ public function parseOptions(&$args) {
+ $non_opts = array();
+
+ $argc = count($args);
+ for($i = 0; $i < $argc; $i++) {
+ $arg = $args[$i];
+
+ // The special element '--' means explicit end of options. Treat the rest of the arguments as non-options
+ // and end the loop.
+ if($arg == '--') {
+ $non_opts = array_merge($non_opts, array_slice($args, $i + 1));
+ break;
+ }
+
+ // '-' is stdin - a normal argument
+ if($arg == '-') {
+ $non_opts = array_merge($non_opts, array_slice($args, $i));
+ break;
+ }
+
+ // first non-option
+ if($arg{0} != '-') {
+ $non_opts = array_merge($non_opts, array_slice($args, $i));
+ break;
+ }
+
+ // long option
+ if(strlen($arg) > 1 && $arg{1} == '-') {
+ list($opt, $val) = explode('=', substr($arg, 2), 2);
+
+ if(!isset($this->setup[$this->command]['opts'][$opt])) {
+ throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
+ }
+
+ // argument required?
+ if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
+ if(is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $args[$i + 1])) {
+ $val = $args[++$i];
+ }
+ if(is_null($val)) {
+ throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
+ }
+ $this->options[$opt] = $val;
+ } else {
+ $this->options[$opt] = true;
+ }
+
+ continue;
+ }
+
+ // short option
+ $opt = substr($arg, 1);
+ if(!isset($this->setup[$this->command]['short'][$opt])) {
+ throw new DokuCLI_Exception("No such option $arg", DokuCLI_Exception::E_UNKNOWN_OPT);
+ } else {
+ $opt = $this->setup[$this->command]['short'][$opt]; // store it under long name
+ }
+
+ // argument required?
+ if($this->setup[$this->command]['opts'][$opt]['needsarg']) {
+ $val = null;
+ if($i + 1 < $argc && !preg_match('/^--?[\w]/', $args[$i + 1])) {
+ $val = $args[++$i];
+ }
+ if(is_null($val)) {
+ throw new DokuCLI_Exception("Option $arg requires an argument", DokuCLI_Exception::E_OPT_ARG_REQUIRED);
+ }
+ $this->options[$opt] = $val;
+ } else {
+ $this->options[$opt] = true;
+ }
+ }
+
+ // parsing is now done, update args array
+ $args = $non_opts;
+
+ // if not done yet, check if first argument is a command and reexecute argument parsing if it is
+ if(!$this->command && $args && isset($this->setup[$args[0]])) {
+ // it is a command!
+ $this->command = array_shift($args);
+ $this->parseOptions($args); // second pass
+ }
+ }
+
+ /**
+ * Get the value of the given option
+ *
+ * Please note that all options are accessed by their long option names regardless of how they were
+ * specified on commandline.
+ *
+ * Can only be used after parseOptions() has been run
+ *
+ * @param $option
+ * @return mixed
+ */
+ public function getOpt($option) {
+ if(isset($this->options[$option])) return $this->options[$option];
+ return false;
+ }
+
+ /**
+ * Return the found command if any
+ *
+ * @return string
+ */
+ public function getCmd() {
+ return $this->command;
+ }
+
+ /**
+ * Builds a help screen from the available options. You may want to call it from -h or on error
+ *
+ * @param string $bin name of the script itself
+ * @return string
+ */
+ public function help($bin) {
+ $text = '';
+
+ $hascommands = (count($this->setup) > 1);
+ foreach($this->setup as $command => $config) {
+ $hasopts = (bool) $this->setup[$command]['opts'];
+ $hasargs = (bool) $this->setup[$command]['args'];
+
+ if(!$command) {
+ $text .= 'USAGE: ' . $bin;
+ } else {
+ $text .= "\n$command";
+ }
+
+ if($hasopts) $text .= ' <OPTIONS>';
+ foreach($this->setup[$command]['args'] as $arg) {
+ if($arg['required']) {
+ $text .= ' <' . $arg['name'] . '>';
+ } else {
+ $text .= ' [<' . $arg['name'] . '>]';
+ }
+ }
+ $text .= "\n\n";
+
+ if($this->setup[$command]['help']) {
+ $text .= ' ' . $this->setup[$command]['help'] . "\n\n";
+ }
+
+ if($hasopts) {
+ $text .= " OPTIONS\n\n";
+
+ foreach($this->setup[$command]['opts'] as $long => $opt) {
+
+ $name = "--$long";
+ if($opt['short']) $name = '-' . $opt['short'] . ' ' . $name;
+ if($opt['needsarg']) $name .= ' <arg>';
+
+ $text .= sprintf(" %-15s %s\n", $name, $opt['help']);
+
+ }
+ }
+
+ if($hasargs) {
+ $text .= " ARGUMENTS\n\n";
+ foreach($this->setup[$command]['args'] as $arg) {
+ $name = '<' . $arg['name'] . '>';
+ $text .= sprintf(" %-15s %s\n", $name, $arg['help']);
+ }
+ }
+
+ if($command == '' && $hascommands){
+ $text .= "\n\nThis tool accepts a command as first parameter as outlined below:\n";
+ }
+ }
+
+ return $text;
+ }
+
+}
+
+/**
+ * Class DokuCLI_Exception
+ *
+ * The code is used as exit code for the CLI tool. This should probably be extended. Many cases just fall back to the
+ * E_ANY code.
+ *
+ * @author Andreas Gohr <andi@splitbrain.org>
+ */
+class DokuCLI_Exception extends Exception {
+ const E_ANY = -1; // no error code specified
+ const E_UNKNOWN_OPT = 1; //Unrecognized option
+ const E_OPT_ARG_REQUIRED = 2; //Option requires argument
+ const E_OPT_ARG_DENIED = 3; //Option not allowed argument
+ const E_OPT_ABIGUOUS = 4; //Option abiguous
+ const E_ARG_READ = 5; //Could not read argv
+
+ public function __construct($message = "", $code = 0, Exception $previous = null) {
+ if(!$code) $code = DokuCLI_Exception::E_ANY;
+ parent::__construct($message, $code, $previous);
+ }
+}
diff --git a/inc/load.php b/inc/load.php
index f1deffe19..ac2812a0b 100644
--- a/inc/load.php
+++ b/inc/load.php
@@ -102,6 +102,10 @@ function load_autoload($name){
'Doku_Renderer_xhtmlsummary' => DOKU_INC.'inc/parser/xhtmlsummary.php',
'Doku_Renderer_metadata' => DOKU_INC.'inc/parser/metadata.php',
+ 'DokuCLI' => DOKU_INC.'inc/cli.php',
+ 'DokuCLI_Options' => DOKU_INC.'inc/cli.php',
+ 'DokuCLI_Colors' => DOKU_INC.'inc/cli.php',
+
);
if(isset($classes[$name])){