diff --git a/scripts/example/calculator.php b/scripts/example/calculator.php new file mode 100755 index 0000000..d755579 --- /dev/null +++ b/scripts/example/calculator.php @@ -0,0 +1,81 @@ +#!/usr/bin/env php +setTagline('simple calculator example'); +$args->setSynopsis(<<setName('add') + ->setExamples('**add** __n__ ...') + ->setSynopsis('Compute the sum of a list of numbers.') + ->setArguments( + array( + array( + 'name' => 'numbers', + 'wildcard' => true, + ), + )); + +$mul_workflow = id(new PhutilArgumentWorkflow()) + ->setName('mul') + ->setExamples('**mul** __n__ ...') + ->setSynopsis('Compute the product of a list of numbers.') + ->setArguments( + array( + array( + 'name' => 'numbers', + 'wildcard' => true, + ), + )); + +$flow = $args->parseWorkflows( + array( + $add_workflow, + $mul_workflow, + new PhutilHelpArgumentWorkflow(), + )); + +$nums = $args->getArg('numbers'); +if (empty($nums)) { + echo "You must provide one or more numbers!\n"; + exit(1); +} + +foreach ($nums as $num) { + if (!is_numeric($num)) { + echo "Number '{$num}' is not numeric!\n"; + exit(1); + } +} + +switch ($flow->getName()) { + case 'add': + echo array_sum($nums)."\n"; + break; + case 'mul': + echo array_product($nums)."\n"; + break; +} diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 303bcee..affc36a 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,269 +1,272 @@ array( 'AASTNode' => 'parser/aast/api/node', 'AASTNodeList' => 'parser/aast/api/list', 'AASTToken' => 'parser/aast/api/token', 'AASTTree' => 'parser/aast/api/tree', 'AbstractDirectedGraph' => 'utils/abstractgraph', 'AbstractDirectedGraphTestCase' => 'utils/abstractgraph/__tests__', 'BaseHTTPFuture' => 'future/http/base', 'CommandException' => 'future/exec', 'ConduitClient' => 'conduit/client', 'ConduitClientException' => 'conduit/client', 'ConduitFuture' => 'conduit/client', 'ExecFuture' => 'future/exec', 'ExecFutureTestCase' => 'future/exec/__tests__', 'FileFinder' => 'filesystem/filefinder', 'FileList' => 'filesystem/filelist', 'Filesystem' => 'filesystem', 'FilesystemException' => 'filesystem', 'Future' => 'future', 'FutureIterator' => 'future', 'FutureProxy' => 'future/proxy', 'HTTPFuture' => 'future/http/http', 'HTTPFutureResponseStatus' => 'future/http/status/base', 'HTTPFutureResponseStatusCURL' => 'future/http/status/curl', 'HTTPFutureResponseStatusHTTP' => 'future/http/status/http', 'HTTPFutureResponseStatusParse' => 'future/http/status/parse', 'HTTPFutureResponseStatusTransport' => 'future/http/status/transport', 'HTTPSFuture' => 'future/http/https', 'ImmediateFuture' => 'future/immediate', 'LinesOfALargeFile' => 'filesystem/linesofalargefile', 'MFilterTestHelper' => 'utils/__tests__', 'PhutilAWSEC2Future' => 'future/aws/ec2', 'PhutilAWSException' => 'future/aws/exception', 'PhutilAWSFuture' => 'future/aws/base', 'PhutilAggregateException' => 'error/aggregate', 'PhutilArgumentParser' => 'parser/argument/parser', 'PhutilArgumentParserException' => 'parser/argument/exception/base', 'PhutilArgumentParserTestCase' => 'parser/argument/parser/__tests__', 'PhutilArgumentSpecification' => 'parser/argument/spec', 'PhutilArgumentSpecificationException' => 'parser/argument/exception/specification', 'PhutilArgumentSpecificationTestCase' => 'parser/argument/spec/__tests__', 'PhutilArgumentUsageException' => 'parser/argument/exception/usage', + 'PhutilArgumentWorkflow' => 'parser/argument/workflow/base', 'PhutilConsoleFormatter' => 'console', 'PhutilConsoleStdinNotInteractiveException' => 'console/exception', 'PhutilConsoleWrapTestCase' => 'console/__tests__', 'PhutilDaemon' => 'daemon/base', 'PhutilDaemonOverseer' => 'daemon/overseer', 'PhutilDefaultSyntaxHighlighter' => 'markup/syntax/highlighter/default', 'PhutilDefaultSyntaxHighlighterEngine' => 'markup/syntax/engine/default', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'markup/syntax/highlighter/pygments/future', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'markup/syntax/engine/default/__tests__', 'PhutilDeferredLog' => 'filesystem/deferredlog', 'PhutilDeferredLogTestCase' => 'filesystem/deferredlog/__tests__', 'PhutilDivinerSyntaxHighlighter' => 'markup/syntax/highlighter/diviner', 'PhutilDocblockParser' => 'parser/docblock', 'PhutilDocblockParserTestCase' => 'parser/docblock/__tests__', 'PhutilEmailAddress' => 'parser/emailaddress', 'PhutilEmailAddressTestCase' => 'parser/emailaddress/__tests__', 'PhutilErrorHandler' => 'error', 'PhutilEvent' => 'events/event', 'PhutilEventConstants' => 'events/constant/base', 'PhutilEventEngine' => 'events/engine', 'PhutilEventListener' => 'events/listener', 'PhutilEventType' => 'events/constant/type', 'PhutilExcessiveServiceCallsDaemon' => 'daemon/torture/excessiveservicecalls', 'PhutilFatalDaemon' => 'daemon/torture/fatal', 'PhutilHangForeverDaemon' => 'daemon/torture/hangforever', + 'PhutilHelpArgumentWorkflow' => 'parser/argument/workflow/help', 'PhutilInteractiveEditor' => 'console/editor', 'PhutilJSON' => 'parser/json', 'PhutilJSONTestCase' => 'parser/json/__tests__', 'PhutilLanguageGuesser' => 'parser/languageguesser', 'PhutilLanguageGuesserTestCase' => 'parser/languageguesser/__tests__', 'PhutilMarkupEngine' => 'markup/engine', 'PhutilMarkupTestCase' => 'markup/__tests__', 'PhutilMissingSymbolException' => 'symbols/exception/missing', 'PhutilNiceDaemon' => 'daemon/torture/nice', 'PhutilProcessGroupDaemon' => 'daemon/torture/processgroup', 'PhutilPygmentsSyntaxHighlighter' => 'markup/syntax/highlighter/pygments', 'PhutilRainbowSyntaxHighlighter' => 'markup/syntax/highlighter/rainbow', 'PhutilReadableSerializer' => 'readableserializer', 'PhutilRemarkupBlockStorage' => 'markup/engine/remarkup/blockstorage', 'PhutilRemarkupEngine' => 'markup/engine/remarkup', 'PhutilRemarkupEngineBlockRule' => 'markup/engine/remarkup/blockrule/base', 'PhutilRemarkupEngineRemarkupCodeBlockRule' => 'markup/engine/remarkup/blockrule/remarkupcode', 'PhutilRemarkupEngineRemarkupDefaultBlockRule' => 'markup/engine/remarkup/blockrule/remarkupdefault', 'PhutilRemarkupEngineRemarkupHeaderBlockRule' => 'markup/engine/remarkup/blockrule/remarkupheader', 'PhutilRemarkupEngineRemarkupInlineBlockRule' => 'markup/engine/remarkup/blockrule/remarkupinline', 'PhutilRemarkupEngineRemarkupListBlockRule' => 'markup/engine/remarkup/blockrule/remarkuplist', 'PhutilRemarkupEngineRemarkupLiteralBlockRule' => 'markup/engine/remarkup/blockrule/remarkupliteral', 'PhutilRemarkupEngineRemarkupNoteBlockRule' => 'markup/engine/remarkup/blockrule/remarkupnote', 'PhutilRemarkupEngineRemarkupQuotesBlockRule' => 'markup/engine/remarkup/blockrule/remarkupquotes', 'PhutilRemarkupEngineTestCase' => 'markup/engine/remarkup/__tests__', 'PhutilRemarkupRule' => 'markup/engine/remarkup/markuprule/base', 'PhutilRemarkupRuleBold' => 'markup/engine/remarkup/markuprule/bold', 'PhutilRemarkupRuleDel' => 'markup/engine/remarkup/markuprule/del', 'PhutilRemarkupRuleEscapeHTML' => 'markup/engine/remarkup/markuprule/escapehtml', 'PhutilRemarkupRuleEscapeRemarkup' => 'markup/engine/remarkup/markuprule/escaperemarkup', 'PhutilRemarkupRuleHyperlink' => 'markup/engine/remarkup/markuprule/hyperlink', 'PhutilRemarkupRuleItalic' => 'markup/engine/remarkup/markuprule/italics', 'PhutilRemarkupRuleLinebreaks' => 'markup/engine/remarkup/markuprule/linebreaks', 'PhutilRemarkupRuleMonospace' => 'markup/engine/remarkup/markuprule/monospace', 'PhutilSaturateStdoutDaemon' => 'daemon/torture/saturatestdout', 'PhutilServiceProfiler' => 'serviceprofiler', 'PhutilSimpleOptions' => 'parser/simpleoptions', 'PhutilSimpleOptionsTestCase' => 'parser/simpleoptions/__tests__', 'PhutilSymbolLoader' => 'symbols', 'PhutilSyntaxHighlighter' => 'markup/syntax/highlighter/base', 'PhutilSyntaxHighlighterEngine' => 'markup/syntax/engine/base', 'PhutilSyntaxHighlighterException' => 'markup/syntax/highlighter/exception', 'PhutilTortureTestDaemon' => 'daemon/torture/base', 'PhutilURI' => 'parser/uri', 'PhutilURITestCase' => 'parser/uri/__tests__', 'PhutilUTF8TestCase' => 'utils/__tests__', 'PhutilUtilsTestCase' => 'utils/__tests__', 'PhutilXHPASTSyntaxHighlighter' => 'markup/syntax/highlighter/xhpast', 'PhutilXHPASTSyntaxHighlighterTestCase' => 'markup/syntax/highlighter/xhpast/__tests__', 'TempFile' => 'filesystem/tempfile', 'TestAbstractDirectedGraph' => 'utils/abstractgraph/__tests__', 'XHPASTNode' => 'parser/xhpast/api/node', 'XHPASTSyntaxErrorException' => 'parser/xhpast/api/exception', 'XHPASTToken' => 'parser/xhpast/api/token', 'XHPASTTree' => 'parser/xhpast/api/tree', 'XHPASTTreeTestCase' => 'parser/xhpast/api/tree/__tests__', ), 'function' => array( 'Futures' => 'future', 'array_mergev' => 'utils', 'array_select_keys' => 'utils', 'assert_instances_of' => 'utils', 'coalesce' => 'utils', 'csprintf' => 'xsprintf/csprintf', 'exec_manual' => 'future/exec', 'execx' => 'future/exec', 'head' => 'utils', 'head_key' => 'utils', 'hsprintf' => 'markup', 'id' => 'utils', 'idx' => 'utils', 'ifilter' => 'utils', 'igroup' => 'utils', 'ipull' => 'utils', 'isort' => 'utils', 'jsprintf' => 'xsprintf/jsprintf', 'last' => 'utils', 'last_key' => 'utils', 'mfilter' => 'utils', 'mgroup' => 'utils', 'mpull' => 'utils', 'msort' => 'utils', 'newv' => 'utils', 'nonempty' => 'utils', 'phlog' => 'error', 'phutil_console_confirm' => 'console', 'phutil_console_format' => 'console', 'phutil_console_prompt' => 'console', 'phutil_console_require_tty' => 'console', 'phutil_console_wrap' => 'console', 'phutil_deprecated' => 'moduleutils', 'phutil_error_listener_example' => 'error', 'phutil_escape_html' => 'markup', 'phutil_escape_uri' => 'markup', 'phutil_escape_uri_path_component' => 'markup', 'phutil_get_library_name_for_root' => 'moduleutils', 'phutil_get_library_root' => 'moduleutils', 'phutil_get_library_root_for_path' => 'moduleutils', 'phutil_is_utf8' => 'utils', 'phutil_passthru' => 'future/exec', 'phutil_render_tag' => 'markup', 'phutil_unescape_uri_path_component' => 'markup', 'phutil_utf8_hard_wrap_html' => 'utils', 'phutil_utf8_shorten' => 'utils', 'phutil_utf8_strlen' => 'utils', 'phutil_utf8ize' => 'utils', 'phutil_utf8v' => 'utils', 'vcsprintf' => 'xsprintf/csprintf', 'vjsprintf' => 'xsprintf/jsprintf', 'xhp_parser_node_constants' => 'parser/xhpast/constants', 'xhpast_get_binary_path' => 'parser/xhpast/bin', 'xhpast_get_build_instructions' => 'parser/xhpast/bin', 'xhpast_get_parser_future' => 'parser/xhpast/bin', 'xhpast_is_available' => 'parser/xhpast/bin', 'xhpast_parser_token_constants' => 'parser/xhpast/constants', 'xsprintf' => 'xsprintf', 'xsprintf_callback_example' => 'xsprintf', 'xsprintf_command' => 'xsprintf/csprintf', 'xsprintf_javascript' => 'xsprintf/jsprintf', ), 'requires_class' => array( 'AbstractDirectedGraphTestCase' => 'ArcanistPhutilTestCase', 'BaseHTTPFuture' => 'Future', 'ConduitFuture' => 'FutureProxy', 'ExecFuture' => 'Future', 'ExecFutureTestCase' => 'ArcanistPhutilTestCase', 'FutureProxy' => 'Future', 'HTTPFuture' => 'BaseHTTPFuture', 'HTTPFutureResponseStatusCURL' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatusHTTP' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatusParse' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatusTransport' => 'HTTPFutureResponseStatus', 'HTTPSFuture' => 'BaseHTTPFuture', 'ImmediateFuture' => 'Future', 'PhutilAWSEC2Future' => 'PhutilAWSFuture', 'PhutilAWSFuture' => 'FutureProxy', 'PhutilArgumentParserTestCase' => 'ArcanistPhutilTestCase', 'PhutilArgumentSpecificationException' => 'PhutilArgumentParserException', 'PhutilArgumentSpecificationTestCase' => 'ArcanistPhutilTestCase', 'PhutilArgumentUsageException' => 'PhutilArgumentParserException', 'PhutilConsoleWrapTestCase' => 'ArcanistPhutilTestCase', 'PhutilDefaultSyntaxHighlighterEngine' => 'PhutilSyntaxHighlighterEngine', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'FutureProxy', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'ArcanistPhutilTestCase', 'PhutilDeferredLogTestCase' => 'ArcanistPhutilTestCase', 'PhutilDocblockParserTestCase' => 'ArcanistPhutilTestCase', 'PhutilEmailAddressTestCase' => 'ArcanistPhutilTestCase', 'PhutilEventType' => 'PhutilEventConstants', 'PhutilExcessiveServiceCallsDaemon' => 'PhutilTortureTestDaemon', 'PhutilFatalDaemon' => 'PhutilTortureTestDaemon', 'PhutilHangForeverDaemon' => 'PhutilTortureTestDaemon', + 'PhutilHelpArgumentWorkflow' => 'PhutilArgumentWorkflow', 'PhutilJSONTestCase' => 'ArcanistPhutilTestCase', 'PhutilLanguageGuesserTestCase' => 'ArcanistPhutilTestCase', 'PhutilMarkupTestCase' => 'ArcanistPhutilTestCase', 'PhutilNiceDaemon' => 'PhutilTortureTestDaemon', 'PhutilProcessGroupDaemon' => 'PhutilTortureTestDaemon', 'PhutilRemarkupEngine' => 'PhutilMarkupEngine', 'PhutilRemarkupEngineRemarkupCodeBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupDefaultBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupHeaderBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupInlineBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupListBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupLiteralBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupNoteBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineRemarkupQuotesBlockRule' => 'PhutilRemarkupEngineBlockRule', 'PhutilRemarkupEngineTestCase' => 'ArcanistPhutilTestCase', 'PhutilRemarkupRuleBold' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleDel' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleEscapeHTML' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleEscapeRemarkup' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleHyperlink' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleItalic' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleLinebreaks' => 'PhutilRemarkupRule', 'PhutilRemarkupRuleMonospace' => 'PhutilRemarkupRule', 'PhutilSaturateStdoutDaemon' => 'PhutilTortureTestDaemon', 'PhutilSimpleOptionsTestCase' => 'ArcanistPhutilTestCase', 'PhutilTortureTestDaemon' => 'PhutilDaemon', 'PhutilURITestCase' => 'ArcanistPhutilTestCase', 'PhutilUTF8TestCase' => 'ArcanistPhutilTestCase', 'PhutilUtilsTestCase' => 'ArcanistPhutilTestCase', 'PhutilXHPASTSyntaxHighlighterTestCase' => 'ArcanistPhutilTestCase', 'TestAbstractDirectedGraph' => 'AbstractDirectedGraph', 'XHPASTNode' => 'AASTNode', 'XHPASTToken' => 'AASTToken', 'XHPASTTree' => 'AASTTree', 'XHPASTTreeTestCase' => 'ArcanistPhutilTestCase', ), 'requires_interface' => array( ), )); diff --git a/src/parser/argument/parser/PhutilArgumentParser.php b/src/parser/argument/parser/PhutilArgumentParser.php index 818dc38..1eb5036 100644 --- a/src/parser/argument/parser/PhutilArgumentParser.php +++ b/src/parser/argument/parser/PhutilArgumentParser.php @@ -1,424 +1,709 @@ setTagline('make an new dog') + * $args->setSynopsis(<<parse( + * array( + * 'name' => 'name', + * 'param' => 'dogname', + * 'default' => 'Rover', + * 'help' => 'Set the dog's name. By default, the dog will be '. + * 'named "Rover".', + * ), + * array( + * 'name' => 'big', + * 'short' => 'b', + * 'help' => 'If set, create a large dog.', + * )); + * + * $dog_name = $args->getArg('name'); + * $dog_size = $args->getArg('big') ? 'big' : 'small'; + * + * // ... etc ... + * + * (For detailed documentation on supported keys in argument specifications, + * see @{class:PhutilArgumentSpecification}.) + * + * This will handle argument parsing, and generate appropriate usage help if + * the user provides an unsupported flag. @{class:PhutilArgumentParser} also + * supports some builtin "standard" arguments: + * + * $args->parseStandardArguments(); + * + * See @{method:parseStandardArguments} for details. Notably, this includes + * a "--help" flag, and an "--xprofile" flag for profiling command-line scripts. + * + * Normally, when the parser encounters an unknown flag, it will exit with + * an error. However, you can use @{method:parsePartial} to consume only a + * set of flags: + * + * $args->parsePartial($spec_list); + * + * This allows you to parse some flags before making decisions about other + * parsing, or share some flags across scripts. The builtin standard arguments + * are implemented in this way. + * + * There is also builtin support for "workflows", which allow you to build a + * script that operates in several modes (e.g., by accepting commands like + * `install`, `upgrade`, etc), like `arc` does. For detailed documentation on + * workflows, see @{class:PhutilArgumentWorkflow}. + * + * @task parse Parsing Arguments + * @task read Reading Arguments + * @task help Command Help + * @task internal Internals + */ final class PhutilArgumentParser { private $bin; private $argv; private $specs = array(); private $results = array(); private $parsed; private $tagline; private $synopsis; + private $workflows; + private $showHelp; + +/* -( Parsing Arguments )-------------------------------------------------- */ + + + /** + * Build a new parser. Generally, you start a script with: + * + * $args = new PhutilArgumentParser($argv); + * + * @param list Argument vector to parse, generally the $argv global. + * @task parse + */ public function __construct(array $argv) { $this->bin = $argv[0]; $this->argv = array_slice($argv, 1); } - public function parsePartial(array $specs) { - foreach ($specs as $key => $spec) { - if (is_array($spec)) { - $specs[$key] = PhutilArgumentSpecification::newQuickSpec( - $spec); - } - } + /** + * Parse and consume a list of arguments, removing them from the argument + * vector but leaving unparsed arguments for later consumption. You can + * retreive unconsumed arguments directly with + * @{method:getUnconsumedArgumentVector}. Doing a partial parse can make it + * easier to share common flags across scripts or workflows. + * + * @param list List of argument specs, see + * @{class:PhutilArgumentSpecification}. + * @return this + * @task parse + */ + public function parsePartial(array $specs) { + $specs = PhutilArgumentSpecification::newSpecsFromList($specs); $this->mergeSpecs($specs); $specs_by_name = mpull($specs, null, 'getName'); $specs_by_short = mpull($specs, null, 'getShortAlias'); unset($specs_by_short[null]); $argv = $this->argv; $len = count($argv); for ($ii = 0; $ii < $len; $ii++) { $arg = $argv[$ii]; $map = null; if ($arg == '--') { // This indicates "end of flags". break; } else if ($arg == '-') { // This is a normal argument (e.g., stdin). continue; } else if (!strncmp('--', $arg, 2)) { $pre = '--'; $arg = substr($arg, 2); $map = $specs_by_name; } else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) { $pre = '-'; $arg = substr($arg, 1); $map = $specs_by_short; } if ($map) { $val = null; $parts = explode('=', $arg, 2); if (count($parts) == 2) { list($arg, $val) = $parts; } if (isset($map[$arg])) { $spec = $map[$arg]; unset($argv[$ii]); $param_name = $spec->getParamName(); if ($val !== null) { if ($param_name === null) { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' does not take a parameter."); } } else { if ($param_name !== null) { if ($ii + 1 < $len) { $val = $argv[$ii + 1]; unset($argv[$ii + 1]); $ii++; } else { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' requires a parameter."); } } else { $val = true; } } if (!$spec->getRepeatable()) { if (array_key_exists($spec->getName(), $this->results)) { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' was provided twice."); } } $conflicts = $spec->getConflicts(); foreach ($conflicts as $conflict => $reason) { if (array_key_exists($conflict, $this->results)) { if (!is_string($reason) || !strlen($reason)) { $reason = '.'; } else { $reason = ': '.$reason.'.'; } throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' conflicts with argument ". "'--{$conflict}'{$reason}"); } } if ($spec->getRepeatable()) { if ($spec->getParamName() === null) { if (empty($this->results[$spec->getName()])) { $this->results[$spec->getName()] = 0; } $this->results[$spec->getName()]++; } else { $this->results[$spec->getName()][] = $val; } } else { $this->results[$spec->getName()] = $val; } } } } foreach ($specs as $spec) { if ($spec->getWildcard()) { $this->results[$spec->getName()] = $this->filterWildcardArgv($argv); $argv = array(); break; } } $this->argv = array_values($argv); return $this; } + + /** + * Parse and consume a list of arguments, throwing an exception if there is + * anything left unconsumed. This is like @{method:parsePartial}, but raises + * a {class:PhutilArgumentUsageException} if there are leftovers. + * + * Normally, you would call @{method:parse} instead, which emits a + * user-friendly error. You can also use @{method:printUsageException} to + * render the exception in a user-friendly way. + * + * @param list List of argument specs, see + * @{class:PhutilArgumentSpecification}. + * @return this + * @task parse + */ public function parseFull(array $specs) { $this->parsePartial($specs); if (count($this->argv)) { $arg = head($this->argv); throw new PhutilArgumentUsageException( "Unrecognized argument '{$arg}'."); } + if ($this->showHelp) { + $this->printHelpAndExit(); + } + return $this; } + + /** + * Parse and consume a list of arguments, raising a user-friendly error if + * anything remains. See also @{method:parseFull} and @{method:parsePartial}. + * + * @param list List of argument specs, see + * @{class:PhutilArgumentSpecification}. + * @return this + * @task parse + */ public function parse(array $specs) { try { - $this->parseFull($specs); + return $this->parseFull($specs); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(77); } } + + /** + * Parse and execute workflows, raising a user-friendly error if anything + * remains. See also @{method:parseWorkflowsFull}. + * + * See @{class:PhutilArgumentWorkflow} for details on using workflows. + * + * @param list List of argument specs, see + * @{class:PhutilArgumentSpecification}. + * @return this + * @task parse + */ + public function parseWorkflows(array $workflows) { + try { + return $this->parseWorkflowsFull($workflows); + } catch (PhutilArgumentUsageException $ex) { + $this->printUsageException($ex); + exit(77); + } + } + + + /** + * Select a workflow. For commands that may operate in several modes, like + * `arc`, the modes can be split into "workflows". Each workflow specifies + * the arguments it accepts. This method takes a list of workflows, selects + * the chosen workflow, parses its arguments, and either executes it (if it + * is executable) or returns it for handling. + * + * See @{class:PhutilArgumentWorkflow} for details on using workflows. + * + * @param list List of @{class:PhutilArgumentWorkflow}s. + * @return PhutilArgumentWorkflow|no Returns the chosen workflow if it is + * not executable, or executes it and + * exits with a return code if it is. + * @task parse + */ + public function parseWorkflowsFull(array $workflows) { + assert_instances_of($workflows, 'PhutilArgumentWorkflow'); + + foreach ($workflows as $workflow) { + $name = $workflow->getName(); + + if ($name === null) { + throw new PhutilArgumentSpecificationException( + "Workflow has no name!"); + } + + if (isset($this->workflows[$name])) { + throw new PhutilArgumentSpecificationException( + "Two workflows with name '{$name}!"); + } + + $this->workflows[$name] = $workflow; + } + + $argv = $this->argv; + if (empty($argv)) { + // TODO: this is kind of hacky / magical. + if (isset($this->workflows['help'])) { + $argv = array('help'); + } else { + throw new PhutilArgumentUsageException( + "No workflow selected."); + } + } + + $flow = array_shift($argv); + $flow = strtolower($flow); + + if (empty($this->workflows[$flow])) { + throw new PhutilArgumentUsageException( + "Invalid workflow '{$flow}'."); + } + + $workflow = $this->workflows[$flow]; + + if ($this->showHelp) { + $this->printHelpAndExit(); + } + + $this->argv = array_values($argv); + $this->parse($workflow->getArguments()); + + if ($workflow->isExecutable()) { + $err = $workflow->execute($this); + exit($err); + } else { + return $workflow; + } + } + + + /** + * Parse "standard" arguments and apply their effects: + * + * --trace Enable service call tracing. + * --no-ansi Disable ANSI color/style sequences. + * --xprofile Write out an XHProf profile. + * --help Show help. + * + * @return this + * + * @phutil-external-symbol function xhprof_enable + */ + public function parseStandardArguments() { + try { + $this->parsePartial( + array( + array( + 'name' => 'trace', + 'help' => 'Trace command execution and show service calls.', + ), + array( + 'name' => 'no-ansi', + 'help' => 'Disable ANSI terminal codes, printing plain text with '. + 'no color or style.', + ), + array( + 'name' => 'xprofile', + 'param' => 'profile', + 'help' => 'Profile script execution and write results to a file.', + ), + array( + 'name' => 'help', + 'short' => 'h', + 'help' => 'Show this help.', + ), + )); + } catch (PhutilArgumentUsageException $ex) { + $this->printUsageException($ex); + exit(77); + } + + if ($this->getArg('trace')) { + PhutilServiceProfiler::installEchoListener(); + } + + if ($this->getArg('no-ansi')) { + PhutilConsoleFormatter::disableANSI(true); + } + + if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) { + PhutilConsoleFormatter::disableANSI(true); + } + + if ($this->getArg('help')) { + $this->showHelp = true; + } + + $xprofile = $this->getArg('xprofile'); + if ($xprofile) { + if (!function_exists('xhprof_enable')) { + throw new Exception("To use '--xprofile', you must install XHProf."); + } + + xhprof_enable(0); + register_shutdown_function(array($this, 'shutdownProfiler')); + } + + return $this; + } + + +/* -( Reading Arguments )-------------------------------------------------- */ + + public function getArg($name) { if (empty($this->specs[$name])) { throw new PhutilArgumentSpecificationException( "No specification exists for argument '{$name}'!"); } if (idx($this->results, $name) !== null) { return $this->results[$name]; } return $this->specs[$name]->getDefault(); } public function getUnconsumedArgumentVector() { return $this->argv; } + +/* -( Command Help )------------------------------------------------------- */ + + + public function setSynopsis($synopsis) { + $this->synopsis = $synopsis; + return $this; + } + + public function setTagline($tagline) { + $this->tagline = $tagline; + return $this; + } + + public function printHelpAndExit() { + echo $this->renderHelp(); + exit(77); + } + + public function renderHelp() { + $out = array(); + + if ($this->bin) { + $out[] = $this->format('**NAME**'); + $name = $this->indent(6, '**%s**', basename($this->bin)); + if ($this->tagline) { + $name .= $this->format(' - '.$this->tagline); + } + $out[] = $name; + $out[] = null; + } + + if ($this->synopsis) { + $out[] = $this->format('**SYNOPSIS**'); + $out[] = $this->indent(6, $this->synopsis); + $out[] = null; + } + + if ($this->workflows) { + $has_help = false; + $out[] = $this->format('**WORKFLOWS**'); + $out[] = null; + $flows = $this->workflows; + ksort($flows); + foreach ($flows as $workflow) { + if ($workflow->getName() == 'help') { + $has_help = true; + } + $out[] = $this->renderWorkflowHelp( + $workflow->getName(), + $show_flags = false); + } + if ($has_help) { + $out[] = $this->indent( + 6, + "Use **help** __command__ for a detailed command reference.\n"); + } + } + + $specs = $this->renderArgumentSpecs($this->specs); + if ($specs) { + $out[] = $this->format('**OPTION REFERENCE**'); + $out[] = null; + $out[] = $specs; + } + + $out[] = null; + + return implode("\n", $out); + } + + public function renderWorkflowHelp( + $workflow_name, + $show_flags = false) { + + $out = array(); + + $workflow = idx($this->workflows, strtolower($workflow_name)); + if (!$workflow) { + $out[] = $this->indent( + 6, + "There is no **{$workflow_name}** workflow."); + } else { + $out[] = $this->indent(6, $workflow->getExamples()); + $out[] = $this->indent(6, $workflow->getSynopsis()); + if ($show_flags) { + $specs = $this->renderArgumentSpecs($workflow->getArguments()); + if ($specs) { + $out[] = null; + $out[] = $specs; + } + } + } + + $out[] = null; + + return implode("\n", $out); + } + + public function printUsageException(PhutilArgumentUsageException $ex) { + file_put_contents( + 'php://stderr', + $this->format('**Usage Exception:** '.$ex->getMessage()."\n")); + } + + +/* -( Internals )---------------------------------------------------------- */ + + private function filterWildcardArgv(array $argv) { foreach ($argv as $key => $value) { if ($value == '--') { unset($argv[$key]); break; } else if (!strncmp($value, '-', 1) && strlen($value) > 1) { throw new PhutilArgumentUsageException( "Argument '{$value}' is unrecognized. Use '--' to indicate the ". "end of flags."); } } return array_values($argv); } private function mergeSpecs(array $specs) { $short_map = mpull($this->specs, null, 'getShortAlias'); unset($short_map[null]); $wildcard = null; foreach ($this->specs as $spec) { if ($spec->getWildcard()) { $wildcard = $spec; break; } } foreach ($specs as $spec) { $spec->validate(); $name = $spec->getName(); if (isset($this->specs[$name])) { throw new PhutilArgumentSpecificationException( "Two argument specifications have the same name ('{$name}')."); } $short = $spec->getShortAlias(); if ($short) { if (isset($short_map[$short])) { throw new PhutilArgumentSpecificationException( "Two argument specifications have the same short alias ". "('{$short}')."); } $short_map[$short] = $spec; } if ($spec->getWildcard()) { if ($wildcard) { throw new PhutilArgumentSpecificationException( "Two argument specifications are marked as wildcard arguments. ". "You can have a maximum of one wildcard argument."); } else { $wildcard = $spec; } } $this->specs[$name] = $spec; } foreach ($this->specs as $name => $spec) { foreach ($spec->getConflicts() as $conflict => $reason) { if (empty($this->specs[$conflict])) { throw new PhutilArgumentSpecificationException( "Argument '{$name}' conflicts with unspecified argument ". "'{$conflict}'."); } if ($conflict == $name) { throw new PhutilArgumentSpecificationException( "Argument '{$name}' conflicts with itself!"); } } } } - public function setSynopsis($synopsis) { - $this->synopsis = $synopsis; - return $this; - } - - public function setTagline($tagline) { - $this->tagline = $tagline; - return $this; - } - - public function renderHelp() { - $out = array(); - - if ($this->bin) { - $out[] = $this->format('**NAME**'); - $name = $this->indent(6, '**%s**', basename($this->bin)); - if ($this->tagline) { - $name .= $this->format(' - '.$this->tagline); - } - $out[] = $name; - $out[] = null; - } - - if ($this->synopsis) { - $out[] = $this->format('**SYNOPSIS**'); - $out[] = $this->indent(6, $this->synopsis); - $out[] = null; - } - - $specs = $this->specs; + private function renderArgumentSpecs(array $specs) { foreach ($specs as $key => $spec) { if ($spec->getWildcard()) { unset($specs[$key]); } } - if ($specs) { - $out[] = $this->format('**OPTION REFERENCE**'); - $out[] = null; - $specs = msort($specs, 'getName'); - foreach ($specs as $spec) { - $name = $this->indent(6, '__--%s__', $spec->getName()); - $short = null; - if ($spec->getShortAlias()) { - $short = $this->format(', __-%s__', $spec->getShortAlias()); - } - if ($spec->getParamName()) { - $param = $this->format(' __%s__', $spec->getParamName()); - $name .= $param; - if ($short) { - $short .= $param; - } + $out = array(); + + $specs = msort($specs, 'getName'); + foreach ($specs as $spec) { + $name = $this->indent(6, '__--%s__', $spec->getName()); + $short = null; + if ($spec->getShortAlias()) { + $short = $this->format(', __-%s__', $spec->getShortAlias()); + } + if ($spec->getParamName()) { + $param = $this->format(' __%s__', $spec->getParamName()); + $name .= $param; + if ($short) { + $short .= $param; } - $out[] = $name.$short; - $out[] = $this->indent(10, $spec->getHelp()); - $out[] = null; } + $out[] = $name.$short; + $out[] = $this->indent(10, $spec->getHelp()); + $out[] = null; } - - $out[] = null; - return implode("\n", $out); } - public function printHelpAndExit() { - echo $this->renderHelp(); - exit(77); - } - private function format($str /*, ... */) { $args = func_get_args(); return call_user_func_array( 'phutil_console_format', $args); } private function indent($level, $str /*, ... */) { $args = func_get_args(); $args = array_slice($args, 1); $text = call_user_func_array(array($this, 'format'), $args); return phutil_console_wrap($text, $level); } - /** - * Parse "standard" arguments and apply their effects: - * - * --trace Enable service call tracing. - * --no-ansi Disable ANSI color/style sequences. - * --xprofile Write out an XHProf profile. - * - * @return this - * - * @phutil-external-symbol function xhprof_enable - */ - public function parseStandardArguments() { - try { - $this->parsePartial( - array( - array( - 'name' => 'trace', - 'help' => 'Trace command execution and show service calls.', - ), - array( - 'name' => 'no-ansi', - 'help' => 'Disable ANSI terminal codes, printing plain text with '. - 'no color or style.', - ), - array( - 'name' => 'xprofile', - 'param' => 'profile', - 'help' => 'Profile script execution and write results to a file.', - ), - )); - } catch (PhutilArgumentUsageException $ex) { - $this->printUsageException($ex); - exit(77); - } - - if ($this->getArg('trace')) { - PhutilServiceProfiler::installEchoListener(); - } - - if ($this->getArg('no-ansi')) { - PhutilConsoleFormatter::disableANSI(true); - } - - if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) { - PhutilConsoleFormatter::disableANSI(true); - } - - $xprofile = $this->getArg('xprofile'); - if ($xprofile) { - if (!function_exists('xhprof_enable')) { - throw new Exception("To use '--xprofile', you must install XHProf."); - } - - xhprof_enable(0); - register_shutdown_function(array($this, 'shutdownProfiler')); - } - - return $this; - } - /** * @phutil-external-symbol function xhprof_disable */ public function shutdownProfiler() { $data = xhprof_disable(); $data = serialize($data); Filesystem::writeFile($this->getArg('xprofile'), $data); } - public function printUsageException(PhutilArgumentUsageException $ex) { - file_put_contents( - 'php://stderr', - $this->format('**Usage Exception:** '.$ex->getMessage()."\n")); - } - } diff --git a/src/parser/argument/spec/PhutilArgumentSpecification.php b/src/parser/argument/spec/PhutilArgumentSpecification.php index c33aa6b..ab51ed1 100644 --- a/src/parser/argument/spec/PhutilArgumentSpecification.php +++ b/src/parser/argument/spec/PhutilArgumentSpecification.php @@ -1,250 +1,260 @@ 'verbose', * 'short' => 'v', * )); * * Recognized keys and equivalent verbose methods are: * * name setName() * help setHelp() * short setShortAlias() * param setParamName() * default setDefault() * conflicts setConflicts() * wildcard setWildcard() * repeat setRepeatable() * * @param dict Dictionary of quick parameter definitions. * @return PhutilArgumentSpecification Constructed argument specification. */ public static function newQuickSpec(array $spec) { $recognized_keys = array( 'name', 'help', 'short', 'param', 'default', 'conflicts', 'wildcard', 'repeat', ); $unrecognized = array_diff_key( $spec, array_fill_keys($recognized_keys, true)); foreach ($unrecognized as $key => $ignored) { throw new PhutilArgumentSpecificationException( "Unrecognized key '{$key}' in argument specification. Recognized keys ". "are: ".implode(', ', $recognized_keys).'.'); } $obj = new PhutilArgumentSpecification(); foreach ($spec as $key => $value) { switch ($key) { case 'name': $obj->setName($value); break; case 'help': $obj->setHelp($value); break; case 'short': $obj->setShortAlias($value); break; case 'param': $obj->setParamName($value); break; case 'default': $obj->setDefault($value); break; case 'conflicts': $obj->setConflicts($value); break; case 'wildcard': $obj->setWildcard($value); break; case 'repeat': $obj->setRepeatable($value); break; } } $obj->validate(); return $obj; } + public static function newSpecsFromList(array $specs) { + foreach ($specs as $key => $spec) { + if (is_array($spec)) { + $specs[$key] = PhutilArgumentSpecification::newQuickSpec( + $spec); + } + } + return $specs; + } + public function setName($name) { self::validateName($name); $this->name = $name; return $this; } private static function validateName($name) { if (!preg_match('/^[a-z0-9][a-z0-9-]*$/', $name)) { throw new PhutilArgumentSpecificationException( "Argument names may only contain a-z, 0-9 and -, and must be ". "at least one character long. '{$name}' is invalid."); } } public function getName() { return $this->name; } public function setHelp($help) { $this->help = $help; return $this; } public function getHelp() { return $this->help; } public function setShortAlias($short_alias) { self::validateShortAlias($short_alias); $this->shortAlias = $short_alias; return $this; } private static function validateShortAlias($alias) { if (strlen($alias) !== 1) { throw new PhutilArgumentSpecificationException( "Argument short aliases must be exactly one character long. ". "'{$alias}' is invalid."); } if (!preg_match('/^[a-zA-Z0-9]$/', $alias)) { throw new PhutilArgumentSpecificationException( "Argument short aliases may only be in a-z, A-Z and 0-9. ". "'{$alias}' is invalid."); } } public function getShortAlias() { return $this->shortAlias; } public function setParamName($param_name) { $this->paramName = $param_name; return $this; } public function getParamName() { return $this->paramName; } public function setDefault($default) { $this->default = $default; return $this; } public function getDefault() { if ($this->getParamName() === null) { if ($this->getRepeatable()) { return 0; } else { return false; } } else { if ($this->getRepeatable()) { return array(); } else { return $this->default; } } } public function setConflicts(array $conflicts) { $this->conflicts = $conflicts; return $this; } public function getConflicts() { return $this->conflicts; } public function setWildcard($wildcard) { $this->wildcard = $wildcard; return $this; } public function getWildcard() { return $this->wildcard; } public function setRepeatable($repeatable) { $this->repeatable = $repeatable; return $this; } public function getRepeatable() { return $this->repeatable; } public function validate() { if ($this->name === null) { throw new PhutilArgumentSpecificationException( "Argument specification MUST have a 'name'."); } if ($this->getWildcard()) { if ($this->getParamName()) { throw new PhutilArgumentSpecificationException( "Wildcard arguments may not specify a parameter."); } if ($this->getRepeatable()) { throw new PhutilArgumentSpecificationException( "Wildcard arguments may not be repeatable."); } } if ($this->default !== null) { if ($this->getRepeatable()) { throw new PhutilArgumentSpecificationException( "Repeatable arguments may not have a default (always array() for ". "arguments which accept a parameter, or 0 for arguments which do ". "not)."); } else if ($this->getParamName() === null) { throw new PhutilArgumentSpecificationException( "Flag arguments may not have a default (always false)."); } } } } diff --git a/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php b/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php new file mode 100644 index 0000000..1e06e7c --- /dev/null +++ b/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php @@ -0,0 +1,156 @@ +setTagline('simple calculator example'); + * $args->setSynopsis(<<setName('add') + * ->setExamples('**add** __n__ ...') + * ->setSynopsis('Compute the sum of a list of numbers.') + * ->setArguments( + * array( + * array( + * 'name' => 'numbers', + * 'wildcard' => true, + * ), + * )); + * + * $mul_workflow = id(new PhutilArgumentWorkflow()) + * ->setName('mul') + * ->setExamples('**mul** __n__ ...') + * ->setSynopsis('Compute the product of a list of numbers.') + * ->setArguments( + * array( + * array( + * 'name' => 'numbers', + * 'wildcard' => true, + * ), + * )); + * + * $flow = $args->parseWorkflows( + * array( + * $add_workflow, + * $mul_workflow, + * new PhutilHelpArgumentWorkflow(), + * )); + * + * $nums = $args->getArg('numbers'); + * if (empty($nums)) { + * echo "You must provide one or more numbers!\n"; + * exit(1); + * } + * + * foreach ($nums as $num) { + * if (!is_numeric($num)) { + * echo "Number '{$num}' is not numeric!\n"; + * exit(1); + * } + * } + * + * switch ($flow->getName()) { + * case 'add': + * echo array_sum($nums)."\n"; + * break; + * case 'mul': + * echo array_product($nums)."\n"; + * break; + * } + * + * You can also subclass this class and return `true` from + * @{method:isExecutable}. In this case, the parser will automatically select + * your workflow when the user invokes it. + * + * @stable + * @concrete-extensible + */ +class PhutilArgumentWorkflow { + + private $name; + private $synopsis; + private $specs = array(); + private $examples; + + final public function __construct() { + $this->didConstruct(); + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + final public function setExamples($examples) { + $this->examples = $examples; + return $this; + } + + final public function getExamples() { + if (!$this->examples) { + return "**".$this->name."**"; + } + return $this->examples; + } + + final public function setSynopsis($synopsis) { + $this->synopsis = $synopsis; + return $this; + } + + final public function getSynopsis() { + return $this->synopsis; + } + + final public function setArguments(array $specs) { + $specs = PhutilArgumentSpecification::newSpecsFromList($specs); + $this->specs = $specs; + return $this; + } + + final public function getArguments() { + return $this->specs; + } + + protected function didConstruct() { + return null; + } + + public function isExecutable() { + return false; + } + + public function execute(PhutilArgumentParser $args) { + throw new Exception("This workflow isn't executable!"); + } + +} diff --git a/src/parser/argument/workflow/base/__init__.php b/src/parser/argument/workflow/base/__init__.php new file mode 100644 index 0000000..506801a --- /dev/null +++ b/src/parser/argument/workflow/base/__init__.php @@ -0,0 +1,12 @@ +setName('help'); + $this->setExamples(<<setSynopsis(<<setArguments( + array( + array( + 'name' => 'help-with-what', + 'wildcard' => true, + ))); + } + + public function isExecutable() { + return true; + } + + public function execute(PhutilArgumentParser $args) { + $with = $args->getArg('help-with-what'); + + if (!$with) { + $args->printHelpAndExit(); + } else { + foreach ($with as $thing) { + echo phutil_console_format( + "**%s WORKFLOW**\n\n", + strtoupper($thing)); + echo $args->renderWorkflowHelp($thing, $show_flags = true); + echo "\n"; + } + exit(77); + } + } + +} diff --git a/src/parser/argument/workflow/help/__init__.php b/src/parser/argument/workflow/help/__init__.php new file mode 100644 index 0000000..f01d5fd --- /dev/null +++ b/src/parser/argument/workflow/help/__init__.php @@ -0,0 +1,13 @@ +