diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php index 81f86cde..6fe5bc66 100644 --- a/src/parser/argument/PhutilArgumentParser.php +++ b/src/parser/argument/PhutilArgumentParser.php @@ -1,989 +1,1012 @@ setTagline('make an new dog') * $args->setSynopsis(<<parse( * array( * 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 extends Phobject { private $bin; private $argv; private $specs = array(); private $results = array(); private $parsed; private $tagline; private $synopsis; private $workflows; + private $helpWorkflows; private $showHelp; private $requireArgumentTerminator = false; private $sawTerminator = false; const PARSE_ERROR_CODE = 77; private static $traceModeEnabled = false; /* -( 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); } /** * Parse and consume a list of arguments, removing them from the argument * vector but leaving unparsed arguments for later consumption. You can * retrieve 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}. * @param bool Require flags appear before any non-flag arguments. * @return this * @task parse */ public function parsePartial(array $specs, $initial_only = false) { return $this->parseInternal($specs, false, $initial_only); } /** * @return this */ private function parseInternal( array $specs, $correct_spelling, $initial_only) { $specs = PhutilArgumentSpecification::newSpecsFromList($specs); $this->mergeSpecs($specs); // Wildcard arguments have a name like "argv", but we don't want to // parse a corresponding flag like "--argv". Filter them out before // building a list of available flags. $non_wildcard = array(); foreach ($specs as $spec_key => $spec) { if ($spec->getWildcard()) { continue; } $non_wildcard[$spec_key] = $spec; } $specs_by_name = mpull($non_wildcard, null, 'getName'); $specs_by_short = mpull($non_wildcard, null, 'getShortAlias'); unset($specs_by_short[null]); $argv = $this->argv; $len = count($argv); $is_initial = true; for ($ii = 0; $ii < $len; $ii++) { $arg = $argv[$ii]; $map = null; $options = null; if (!is_string($arg)) { // Non-string argument; pass it through as-is. } else if ($arg == '--') { // This indicates "end of flags". $this->sawTerminator = true; 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; $options = array_keys($specs_by_name); } else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) { $pre = '-'; $arg = substr($arg, 1); $map = $specs_by_short; } else { $is_initial = false; } if ($map) { $val = null; $parts = explode('=', $arg, 2); if (count($parts) == 2) { list($arg, $val) = $parts; } // Try to correct flag spelling for full flags, to allow users to make // minor mistakes. if ($correct_spelling && $options && !isset($map[$arg])) { $corrections = PhutilArgumentSpellingCorrector::newFlagCorrector() ->correctSpelling($arg, $options); $should_autocorrect = $this->shouldAutocorrect(); if (count($corrections) == 1 && $should_autocorrect) { $corrected = head($corrections); $this->logMessage( tsprintf( "%s\n", pht( '(Assuming "%s" is the British spelling of "%s".)', $pre.$arg, $pre.$corrected))); $arg = $corrected; } } if (isset($map[$arg])) { if ($initial_only && !$is_initial) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" appears after the first non-flag argument. '. 'This special argument must appear before other arguments.', "{$pre}{$arg}")); } $spec = $map[$arg]; unset($argv[$ii]); $param_name = $spec->getParamName(); if ($val !== null) { if ($param_name === null) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" does not take a parameter.', "{$pre}{$arg}")); } } else { if ($param_name !== null) { if ($ii + 1 < $len) { $val = $argv[$ii + 1]; unset($argv[$ii + 1]); $ii++; } else { throw new PhutilArgumentUsageException( pht( 'Argument "%s" requires a parameter.', "{$pre}{$arg}")); } } else { $val = true; } } if (!$spec->getRepeatable()) { if (array_key_exists($spec->getName(), $this->results)) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" was provided twice.', "{$pre}{$arg}")); } } $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( pht( 'Argument "%s" conflicts with argument "%s"%s', "{$pre}{$arg}", "--{$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->parseInternal($specs, true, false); // If we have remaining unconsumed arguments other than a single "--", // fail. $argv = $this->filterWildcardArgv($this->argv); if ($argv) { throw new PhutilArgumentUsageException( pht( 'Unrecognized argument "%s".', head($argv))); } if ($this->getRequireArgumentTerminator()) { if (!$this->sawTerminator) { throw new ArcanistMissingArgumentTerminatorException(); } } 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 { return $this->parseFull($specs); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(self::PARSE_ERROR_CODE); } } /** * 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(self::PARSE_ERROR_CODE); } } /** * 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'); // Clear out existing workflows. We need to do this to permit the // construction of sub-workflows. $this->workflows = array(); foreach ($workflows as $workflow) { $name = $workflow->getName(); if ($name === null) { throw new PhutilArgumentSpecificationException( pht('Workflow has no name!')); } if (isset($this->workflows[$name])) { throw new PhutilArgumentSpecificationException( pht("Two workflows with name '%s!", $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(pht('No workflow selected.')); } } $flow = array_shift($argv); if (empty($this->workflows[$flow])) { $corrected = PhutilArgumentSpellingCorrector::newCommandCorrector() ->correctSpelling($flow, array_keys($this->workflows)); $should_autocorrect = $this->shouldAutocorrect(); if (count($corrected) == 1 && $should_autocorrect) { $corrected = head($corrected); $this->logMessage( tsprintf( "%s\n", pht( '(Assuming "%s" is the British spelling of "%s".)', $flow, $corrected))); $flow = $corrected; } else { - $this->raiseUnknownWorkflow($flow, $corrected); + if (!$this->showHelp) { + $this->raiseUnknownWorkflow($flow, $corrected); + } } } - $workflow = $this->workflows[$flow]; + $workflow = idx($this->workflows, $flow); if ($this->showHelp) { // Make "cmd flow --help" behave like "cmd help flow", not "cmd help". $help_flow = idx($this->workflows, 'help'); if ($help_flow) { if ($help_flow !== $workflow) { $workflow = $help_flow; $argv = array($flow); // Prevent parse() from dumping us back out to standard help. $this->showHelp = false; } } else { $this->printHelpAndExit(); } } + if (!$workflow) { + $this->raiseUnknownWorkflow($flow, $corrected); + } + $this->argv = array_values($argv); if ($workflow->shouldParsePartial()) { $this->parsePartial($workflow->getArguments()); } else { $this->parse($workflow->getArguments()); } if ($workflow->isExecutable()) { $workflow->setArgv($this); $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' => pht('Trace command execution and show service calls.'), 'standard' => true, ), array( 'name' => 'no-ansi', 'help' => pht( 'Disable ANSI terminal codes, printing plain text with '. 'no color or style.'), 'conflicts' => array( 'ansi' => null, ), 'standard' => true, ), array( 'name' => 'ansi', 'help' => pht( "Use formatting even in environments which probably ". "don't support it."), 'standard' => true, ), array( 'name' => 'xprofile', 'param' => 'profile', 'help' => pht( 'Profile script execution and write results to a file.'), 'standard' => true, ), array( 'name' => 'help', 'short' => 'h', 'help' => pht('Show this help.'), 'standard' => true, ), array( 'name' => 'show-standard-options', 'help' => pht( 'Show every option, including standard options like this one.'), 'standard' => true, ), array( 'name' => 'recon', 'help' => pht('Start in remote console mode.'), 'standard' => true, ), )); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(self::PARSE_ERROR_CODE); } if ($this->getArg('trace')) { PhutilServiceProfiler::installEchoListener(); self::$traceModeEnabled = true; } if ($this->getArg('no-ansi')) { PhutilConsoleFormatter::disableANSI(true); } if ($this->getArg('ansi')) { PhutilConsoleFormatter::disableANSI(false); } if ($this->getArg('help')) { $this->showHelp = true; } $xprofile = $this->getArg('xprofile'); if ($xprofile) { if (!function_exists('xhprof_enable')) { throw new Exception( pht('To use "--xprofile", you must install XHProf.')); } xhprof_enable(0); register_shutdown_function(array($this, 'shutdownProfiler')); } $recon = $this->getArg('recon'); if ($recon) { $remote_console = PhutilConsole::newRemoteConsole(); $remote_console->beginRedirectOut(); PhutilConsole::setConsole($remote_console); } else if ($this->getArg('trace')) { $server = new PhutilConsoleServer(); $server->setEnableLog(true); $console = PhutilConsole::newConsoleForServer($server); PhutilConsole::setConsole($console); } return $this; } /* -( Reading Arguments )-------------------------------------------------- */ public function getArg($name) { if (empty($this->specs[$name])) { throw new PhutilArgumentSpecificationException( pht('No specification exists for argument "%s"!', $name)); } if (idx($this->results, $name) !== null) { return $this->results[$name]; } return $this->specs[$name]->getDefault(); } public function getUnconsumedArgumentVector() { return $this->argv; } public function setUnconsumedArgumentVector(array $argv) { $this->argv = $argv; return $this; } public function setWorkflows($workflows) { $workflows = mpull($workflows, null, 'getName'); $this->workflows = $workflows; return $this; } + public function setHelpWorkflows(array $help_workflows) { + $help_workflows = mpull($help_workflows, null, 'getName'); + $this->helpWorkflows = $help_workflows; + return $this; + } + public function getWorkflows() { return $this->workflows; } /* -( Command Help )------------------------------------------------------- */ public function setRequireArgumentTerminator($require) { $this->requireArgumentTerminator = $require; return $this; } public function getRequireArgumentTerminator() { return $this->requireArgumentTerminator; } 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(self::PARSE_ERROR_CODE); } public function renderHelp() { $out = array(); $more = array(); if ($this->bin) { $out[] = $this->format('**%s**', pht('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('**%s**', pht('SYNOPSIS')); $out[] = $this->indent(6, $this->synopsis); $out[] = null; } - if ($this->workflows) { + $workflows = $this->helpWorkflows; + if ($workflows === null) { + $workflows = $this->workflows; + } + + if ($workflows) { $has_help = false; $out[] = $this->format('**%s**', pht('WORKFLOWS')); $out[] = null; - $flows = $this->workflows; + $flows = $workflows; ksort($flows); foreach ($flows as $workflow) { if ($workflow->getName() == 'help') { $has_help = true; } $out[] = $this->renderWorkflowHelp( $workflow->getName(), $show_details = false); } if ($has_help) { $more[] = pht( 'Use **%s** __command__ for a detailed command reference.', 'help'); } } $specs = $this->renderArgumentSpecs($this->specs); if ($specs) { $out[] = $this->format('**%s**', pht('OPTION REFERENCE')); $out[] = null; $out[] = $specs; } // If we have standard options but no --show-standard-options, print out // a quick hint about it. if (!empty($this->specs['show-standard-options']) && !$this->getArg('show-standard-options')) { $more[] = pht( 'Use __%s__ to show additional options.', '--show-standard-options'); } $out[] = null; if ($more) { foreach ($more as $hint) { $out[] = $this->indent(0, $hint); } $out[] = null; } return implode("\n", $out); } public function renderWorkflowHelp( $workflow_name, $show_details = false) { $out = array(); $indent = ($show_details ? 0 : 6); - $workflow = idx($this->workflows, strtolower($workflow_name)); + $workflows = $this->helpWorkflows; + if ($workflows === null) { + $workflows = $this->workflows; + } + + $workflow = idx($workflows, strtolower($workflow_name)); if (!$workflow) { $out[] = $this->indent( $indent, pht('There is no **%s** workflow.', $workflow_name)); } else { $out[] = $this->indent($indent, $workflow->getExamples()); $out[] = $this->indent($indent, $workflow->getSynopsis()); if ($show_details) { $full_help = $workflow->getHelp(); if ($full_help) { $out[] = null; $out[] = $this->indent($indent, $full_help); } $specs = $this->renderArgumentSpecs($workflow->getArguments()); if ($specs) { $out[] = null; $out[] = $specs; } } } $out[] = null; return implode("\n", $out); } public function printUsageException(PhutilArgumentUsageException $ex) { $message = tsprintf( "**%s** %B\n", pht('Usage Exception:'), $ex->getMessage()); $this->logMessage($message); } private function logMessage($message) { fwrite(STDERR, $message); } /* -( Internals )---------------------------------------------------------- */ private function filterWildcardArgv(array $argv) { foreach ($argv as $key => $value) { if ($value == '--') { unset($argv[$key]); break; } else if ( is_string($value) && !strncmp($value, '-', 1) && strlen($value) > 1) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" is unrecognized. Use "%s" to indicate '. 'the end of flags.', $value, '--')); } } 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( pht( 'Two argument specifications have the same name ("%s").', $name)); } $short = $spec->getShortAlias(); if ($short) { if (isset($short_map[$short])) { throw new PhutilArgumentSpecificationException( pht( 'Two argument specifications have the same short alias ("%s").', $short)); } $short_map[$short] = $spec; } if ($spec->getWildcard()) { if ($wildcard) { throw new PhutilArgumentSpecificationException( pht( '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( pht( 'Argument "%s" conflicts with unspecified argument "%s".', $name, $conflict)); } if ($conflict == $name) { throw new PhutilArgumentSpecificationException( pht( 'Argument "%s" conflicts with itself!', $name)); } } } } private function renderArgumentSpecs(array $specs) { foreach ($specs as $key => $spec) { if ($spec->getWildcard()) { unset($specs[$key]); } } $out = array(); $no_standard_options = !empty($this->specs['show-standard-options']) && !$this->getArg('show-standard-options'); $specs = msort($specs, 'getName'); foreach ($specs as $spec) { if ($spec->getStandard() && $no_standard_options) { // If this is a standard argument and the user didn't pass // --show-standard-options, skip it. continue; } $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; } return implode("\n", $out); } 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); } /** * @phutil-external-symbol function xhprof_disable */ public function shutdownProfiler() { $data = xhprof_disable(); $data = json_encode($data); Filesystem::writeFile($this->getArg('xprofile'), $data); } public static function isTraceModeEnabled() { return self::$traceModeEnabled; } private function raiseUnknownWorkflow($flow, array $maybe) { if ($maybe) { sort($maybe); $maybe_list = id(new PhutilConsoleList()) ->setWrap(false) ->setBullet(null) ->addItems($maybe) ->drawConsoleString(); $message = tsprintf( "%B\n%B", pht( 'Invalid command "%s". Did you mean:', $flow), $maybe_list); } else { $names = mpull($this->workflows, 'getName'); sort($names); $message = tsprintf( '%B', pht( 'Invalid command "%s". Valid commands are: %s.', $flow, implode(', ', $names))); } if (isset($this->workflows['help'])) { $binary = basename($this->bin); $message = tsprintf( "%B\n%s", $message, pht( 'For details on available commands, run "%s".', "{$binary} help")); } throw new PhutilArgumentUsageException($message); } private function shouldAutocorrect() { return !phutil_is_noninteractive(); } } diff --git a/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php b/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php index 7257f68f..d464e1cf 100644 --- a/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php +++ b/src/parser/argument/workflow/PhutilHelpArgumentWorkflow.php @@ -1,97 +1,45 @@ runtime = $runtime; - return $this; - } - - public function getRuntime() { - return $this->runtime; - } - protected function didConstruct() { $this->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'); - $runtime = $this->getRuntime(); - if ($runtime) { - $toolset = $runtime->getToolset(); - if ($toolset->getToolsetKey() === 'arc') { - $workflows = $args->getWorkflows(); - - $legacy = array(); - - $legacy[] = new ArcanistCloseRevisionWorkflow(); - $legacy[] = new ArcanistCommitWorkflow(); - $legacy[] = new ArcanistCoverWorkflow(); - $legacy[] = new ArcanistDiffWorkflow(); - $legacy[] = new ArcanistExportWorkflow(); - $legacy[] = new ArcanistGetConfigWorkflow(); - $legacy[] = new ArcanistSetConfigWorkflow(); - $legacy[] = new ArcanistInstallCertificateWorkflow(); - $legacy[] = new ArcanistLandWorkflow(); - $legacy[] = new ArcanistLintersWorkflow(); - $legacy[] = new ArcanistLintWorkflow(); - $legacy[] = new ArcanistListWorkflow(); - $legacy[] = new ArcanistPatchWorkflow(); - $legacy[] = new ArcanistPasteWorkflow(); - $legacy[] = new ArcanistTasksWorkflow(); - $legacy[] = new ArcanistTodoWorkflow(); - $legacy[] = new ArcanistUnitWorkflow(); - $legacy[] = new ArcanistWhichWorkflow(); - - foreach ($legacy as $workflow) { - // If this workflow has been updated but not removed from the list - // above yet, just skip it. - if ($workflow instanceof ArcanistArcWorkflow) { - continue; - } - - $workflows[] = $workflow->newLegacyPhutilWorkflow(); - } - - $args->setWorkflows($workflows); - } - } - if (!$with) { $args->printHelpAndExit(); } else { foreach ($with as $thing) { echo phutil_console_format( "**%s**\n\n", pht('%s WORKFLOW', strtoupper($thing))); echo $args->renderWorkflowHelp($thing, $show_flags = true); echo "\n"; } exit(PhutilArgumentParser::PARSE_ERROR_CODE); } } } diff --git a/src/runtime/ArcanistRuntime.php b/src/runtime/ArcanistRuntime.php index bed9e5cc..07a6ce1b 100644 --- a/src/runtime/ArcanistRuntime.php +++ b/src/runtime/ArcanistRuntime.php @@ -1,872 +1,912 @@ checkEnvironment(); } catch (Exception $ex) { echo "CONFIGURATION ERROR\n\n"; echo $ex->getMessage(); echo "\n\n"; return 1; } PhutilTranslator::getInstance() ->setLocale(PhutilLocale::loadLocale('en_US')) ->setTranslations(PhutilTranslation::getTranslationMapForLocale('en_US')); $log = new ArcanistLogEngine(); $this->logEngine = $log; try { return $this->executeCore($argv); } catch (ArcanistConduitException $ex) { $log->writeError(pht('CONDUIT'), $ex->getMessage()); } catch (PhutilArgumentUsageException $ex) { $log->writeError(pht('USAGE EXCEPTION'), $ex->getMessage()); } catch (ArcanistUserAbortException $ex) { $log->writeError(pht('---'), $ex->getMessage()); } return 1; } private function executeCore(array $argv) { $log = $this->getLogEngine(); $config_args = array( array( 'name' => 'library', 'param' => 'path', 'help' => pht('Load a library.'), 'repeat' => true, ), array( 'name' => 'config', 'param' => 'key=value', 'repeat' => true, 'help' => pht('Specify a runtime configuration value.'), ), array( 'name' => 'config-file', 'param' => 'path', 'repeat' => true, 'help' => pht( 'Load one or more configuration files. If this flag is provided, '. 'the system and user configuration files are ignored.'), ), ); $args = id(new PhutilArgumentParser($argv)) ->parseStandardArguments(); // If we can test whether STDIN is a TTY, and it isn't, require that "--" // appear in the argument list. This is intended to make it very hard to // write unsafe scripts on top of Arcanist. if (phutil_is_noninteractive()) { $args->setRequireArgumentTerminator(true); } $is_trace = $args->getArg('trace'); $log->setShowTraceMessages($is_trace); $log->writeTrace(pht('ARGV'), csprintf('%Ls', $argv)); // We're installing the signal handler after parsing "--trace" so that it // can emit debugging messages. This means there's a very small window at // startup where signals have no special handling, but we couldn't really // route them or do anything interesting with them anyway. $this->installSignalHandler(); $args->parsePartial($config_args, true); $config_engine = $this->loadConfiguration($args); $config = $config_engine->newConfigurationSourceList(); $this->loadLibraries($config_engine, $config, $args); // Now that we've loaded libraries, we can validate configuration. // Do this before continuing since configuration can impact other // behaviors immediately and we want to catch any issues right away. $config->setConfigOptions($config_engine->newConfigOptionsMap()); $config->validateConfiguration($this); $toolset = $this->newToolset($argv); $this->setToolset($toolset); $args->parsePartial($toolset->getToolsetArguments()); $workflows = $this->newWorkflows($toolset); $this->workflows = $workflows; $conduit_engine = $this->newConduitEngine($config, $args); $this->conduitEngine = $conduit_engine; $phutil_workflows = array(); foreach ($workflows as $key => $workflow) { $workflow ->setRuntime($this) ->setConfigurationEngine($config_engine) ->setConfigurationSourceList($config) ->setConduitEngine($conduit_engine); $phutil_workflows[$key] = $workflow->newPhutilWorkflow(); } $unconsumed_argv = $args->getUnconsumedArgumentVector(); if (!$unconsumed_argv) { // TOOLSETS: This means the user just ran "arc" or some other top-level // toolset without any workflow argument. We should give them a summary // of the toolset, a list of workflows, and a pointer to "arc help" for // more details. // A possible exception is "arc --help", which should perhaps pass // through and act like "arc help". throw new PhutilArgumentUsageException(pht('Choose a workflow!')); } $alias_effects = id(new ArcanistAliasEngine()) ->setRuntime($this) ->setToolset($toolset) ->setWorkflows($workflows) ->setConfigurationSourceList($config) ->resolveAliases($unconsumed_argv); foreach ($alias_effects as $alias_effect) { if ($alias_effect->getType() === ArcanistAliasEffect::EFFECT_SHELL) { return $this->executeShellAlias($alias_effect); } } $result_argv = $this->applyAliasEffects($alias_effects, $unconsumed_argv); $args->setUnconsumedArgumentVector($result_argv); // TOOLSETS: Some day, stop falling through to the old "arc" runtime. + $help_workflows = $this->getHelpWorkflows($phutil_workflows); + $args->setHelpWorkflows($help_workflows); + try { return $args->parseWorkflowsFull($phutil_workflows); } catch (ArcanistMissingArgumentTerminatorException $terminator_exception) { $log->writeHint( pht('USAGE'), pht( '"%s" is being run noninteractively, but the argument list is '. 'missing "--" to indicate end of flags.', $toolset->getToolsetKey())); $log->writeHint( pht('USAGE'), pht( 'When running noninteractively, you MUST provide "--" to all '. 'commands (even if they take no arguments).')); $log->writeHint( pht('USAGE'), tsprintf( '%s <__%s__>', pht('Learn More:'), 'https://phurl.io/u/noninteractive')); throw new PhutilArgumentUsageException( pht('Missing required "--" in argument list.')); } catch (PhutilArgumentUsageException $usage_exception) { // TODO: This is very, very hacky; we're trying to let errors like // "you passed the wrong arguments" through but fall back to classic // mode if the workflow itself doesn't exist. if (!preg_match('/invalid command/i', $usage_exception->getMessage())) { throw $usage_exception; } } $arcanist_root = phutil_get_library_root('arcanist'); $arcanist_root = dirname($arcanist_root); $bin = $arcanist_root.'/scripts/arcanist.php'; $err = phutil_passthru( 'php -f %R -- %Ls', $bin, array_slice($argv, 1)); return $err; } /** * Perform some sanity checks against the possible diversity of PHP builds in * the wild, like very old versions and builds that were compiled with flags * that exclude core functionality. */ private function checkEnvironment() { // NOTE: We don't have phutil_is_windows() yet here. $is_windows = (DIRECTORY_SEPARATOR != '/'); // NOTE: There's a hard PHP version check earlier, in "init-script.php". if ($is_windows) { $need_functions = array( 'curl_init' => array('builtin-dll', 'php_curl.dll'), ); } else { $need_functions = array( 'curl_init' => array( 'text', "You need to install the cURL PHP extension, maybe with ". "'apt-get install php5-curl' or 'yum install php53-curl' or ". "something similar.", ), 'json_decode' => array('flag', '--without-json'), ); } $problems = array(); $config = null; $show_config = false; foreach ($need_functions as $fname => $resolution) { if (function_exists($fname)) { continue; } static $info; if ($info === null) { ob_start(); phpinfo(INFO_GENERAL); $info = ob_get_clean(); $matches = null; if (preg_match('/^Configure Command =>\s*(.*?)$/m', $info, $matches)) { $config = $matches[1]; } } list($what, $which) = $resolution; if ($what == 'flag' && strpos($config, $which) !== false) { $show_config = true; $problems[] = sprintf( 'The build of PHP you are running was compiled with the configure '. 'flag "%s", which means it does not support the function "%s()". '. 'This function is required for Arcanist to run. Install a standard '. 'build of PHP or rebuild it without this flag. You may also be '. 'able to build or install the relevant extension separately.', $which, $fname); continue; } if ($what == 'builtin-dll') { $problems[] = sprintf( 'The build of PHP you are running does not have the "%s" extension '. 'enabled. Edit your php.ini file and uncomment the line which '. 'reads "extension=%s".', $which, $which); continue; } if ($what == 'text') { $problems[] = $which; continue; } $problems[] = sprintf( 'The build of PHP you are running is missing the required function '. '"%s()". Rebuild PHP or install the extension which provides "%s()".', $fname, $fname); } if ($problems) { if ($show_config) { $problems[] = "PHP was built with this configure command:\n\n{$config}"; } $problems = implode("\n\n", $problems); throw new Exception($problems); } } private function loadConfiguration(PhutilArgumentParser $args) { $engine = id(new ArcanistConfigurationEngine()) ->setArguments($args); $working_copy = ArcanistWorkingCopy::newFromWorkingDirectory(getcwd()); $engine->setWorkingCopy($working_copy); $this->workingCopy = $working_copy; $working_copy ->getRepositoryAPI() ->setRuntime($this); return $engine; } private function loadLibraries( ArcanistConfigurationEngine $engine, ArcanistConfigurationSourceList $config, PhutilArgumentParser $args) { $sources = array(); $cli_libraries = $args->getArg('library'); if ($cli_libraries) { $sources = array(); foreach ($cli_libraries as $cli_library) { $sources[] = array( 'type' => 'flag', 'library-source' => $cli_library, ); } } else { $items = $config->getStorageValueList('load'); foreach ($items as $item) { foreach ($item->getValue() as $library_path) { $sources[] = array( 'type' => 'config', 'config-source' => $item->getConfigurationSource(), 'library-source' => $library_path, ); } } } foreach ($sources as $spec) { $library_source = $spec['library-source']; switch ($spec['type']) { case 'flag': $description = pht('runtime --library flag'); break; case 'config': $config_source = $spec['config-source']; $description = pht( 'Configuration (%s)', $config_source->getSourceDisplayName()); break; } $this->loadLibrary($engine, $library_source, $description); } } private function loadLibrary( ArcanistConfigurationEngine $engine, $location, $description) { // TODO: This is a legacy system that should be replaced with package // management. $log = $this->getLogEngine(); $working_copy = $engine->getWorkingCopy(); if ($working_copy) { $working_copy_root = $working_copy->getPath(); $working_directory = $working_copy->getWorkingDirectory(); } else { $working_copy_root = null; $working_directory = getcwd(); } // Try to resolve the library location. We look in several places, in // order: // // 1. Inside the working copy. This is for phutil libraries within the // project. For instance "library/src" will resolve to // "./library/src" if it exists. // 2. In the same directory as the working copy. This allows you to // check out a library alongside a working copy and reference it. // If we haven't resolved yet, "library/src" will try to resolve to // "../library/src" if it exists. // 3. Using normal libphutil resolution rules. Generally, this means // that it checks for libraries next to libphutil, then libraries // in the PHP include_path. // // Note that absolute paths will just resolve absolutely through rule (1). $resolved = false; // Check inside the working copy. This also checks absolute paths, since // they'll resolve absolute and just ignore the project root. if ($working_copy_root !== null) { $resolved_location = Filesystem::resolvePath( $location, $working_copy_root); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } // If we didn't find anything, check alongside the working copy. if (!$resolved) { $resolved_location = Filesystem::resolvePath( $location, dirname($working_copy_root)); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } } } // Look beside "arcanist/". This is rule (3) above. if (!$resolved) { $arcanist_root = phutil_get_library_root('arcanist'); $arcanist_root = dirname(dirname($arcanist_root)); $resolved_location = Filesystem::resolvePath( $location, $arcanist_root); if (Filesystem::pathExists($resolved_location)) { $location = $resolved_location; $resolved = true; } } $log->writeTrace( pht('LOAD'), pht('Loading library from "%s"...', $location)); $error = null; try { phutil_load_library($location); } catch (PhutilLibraryConflictException $ex) { if ($ex->getLibrary() != 'arcanist') { throw $ex; } // NOTE: If you are running `arc` against itself, we ignore the library // conflict created by loading the local `arc` library (in the current // working directory) and continue without loading it. // This means we only execute code in the `arcanist/` directory which is // associated with the binary you are running, whereas we would normally // execute local code. // This can make `arc` development slightly confusing if your setup is // especially bizarre, but it allows `arc` to be used in automation // workflows more easily. For some context, see PHI13. $executing_directory = dirname(dirname(__FILE__)); $log->writeWarn( pht('VERY META'), pht( 'You are running one copy of Arcanist (at path "%s") against '. 'another copy of Arcanist (at path "%s"). Code in the current '. 'working directory will not be loaded or executed.', $executing_directory, $working_directory)); } catch (PhutilBootloaderException $ex) { $log->writeError( pht('LIBRARY ERROR'), pht( 'Failed to load library at location "%s". This library '. 'is specified by "%s". Check that the library is up to date.', $location, $description)); $prompt = pht('Continue without loading library?'); if (!phutil_console_confirm($prompt)) { throw $ex; } } catch (Exception $ex) { $log->writeError( pht('LOAD ERROR'), pht( 'Failed to load library at location "%s". This library is '. 'specified by "%s". Check that the setting is correct and the '. 'library is located in the right place.', $location, $description)); $prompt = pht('Continue without loading library?'); if (!phutil_console_confirm($prompt)) { throw $ex; } } } private function newToolset(array $argv) { $binary = basename($argv[0]); $toolsets = ArcanistToolset::newToolsetMap(); if (!isset($toolsets[$binary])) { throw new PhutilArgumentUsageException( pht( 'Arcanist toolset "%s" is unknown. The Arcanist binary should '. 'be executed so that "argv[0]" identifies a supported toolset. '. 'Rename the binary or install the library that provides the '. 'desired toolset. Current available toolsets: %s.', $binary, implode(', ', array_keys($toolsets)))); } return $toolsets[$binary]; } private function newWorkflows(ArcanistToolset $toolset) { $workflows = id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistWorkflow') ->setContinueOnFailure(true) ->execute(); foreach ($workflows as $key => $workflow) { if (!$workflow->supportsToolset($toolset)) { unset($workflows[$key]); } } $map = array(); foreach ($workflows as $workflow) { $key = $workflow->getWorkflowName(); if (isset($map[$key])) { throw new Exception( pht( 'Two workflows ("%s" and "%s") both have the same name ("%s") '. 'and both support the current toolset ("%s", "%s"). Each '. 'workflow in a given toolset must have a unique name.', get_class($workflow), get_class($map[$key]), get_class($toolset), $toolset->getToolsetKey())); } $map[$key] = id(clone $workflow) ->setToolset($toolset); } return $map; } public function getWorkflows() { return $this->workflows; } public function getLogEngine() { return $this->logEngine; } private function applyAliasEffects(array $effects, array $argv) { assert_instances_of($effects, 'ArcanistAliasEffect'); $log = $this->getLogEngine(); $command = null; $arguments = null; foreach ($effects as $effect) { $message = $effect->getMessage(); if ($message !== null) { $log->writeHint(pht('ALIAS'), $message); } if ($effect->getCommand()) { $command = $effect->getCommand(); $arguments = $effect->getArguments(); } } if ($command !== null) { $argv = array_merge(array($command), $arguments); } return $argv; } private function installSignalHandler() { $log = $this->getLogEngine(); if (!function_exists('pcntl_signal')) { $log->writeTrace( pht('PCNTL'), pht( 'Unable to install signal handler, pcntl_signal() unavailable. '. 'Continuing without signal handling.')); return; } // NOTE: SIGHUP, SIGTERM and SIGWINCH are handled by "PhutilSignalRouter". // This logic is largely similar to the logic there, but more specific to // Arcanist workflows. pcntl_signal(SIGINT, array($this, 'routeSignal')); } public function routeSignal($signo) { switch ($signo) { case SIGINT: $this->routeInterruptSignal($signo); break; } } private function routeInterruptSignal($signo) { $log = $this->getLogEngine(); $last_interrupt = $this->lastInterruptTime; $now = microtime(true); $this->lastInterruptTime = $now; $should_exit = false; // If we received another SIGINT recently, always exit. This implements // "press ^C twice in quick succession to exit" regardless of what the // workflow may decide to do. $interval = 2; if ($last_interrupt !== null) { if ($now - $last_interrupt < $interval) { $should_exit = true; } } $handler = null; if (!$should_exit) { // Look for an interrupt handler in the current workflow stack. $stack = $this->getWorkflowStack(); foreach ($stack as $workflow) { if ($workflow->canHandleSignal($signo)) { $handler = $workflow; break; } } // If no workflow in the current execution stack can handle an interrupt // signal, just exit on the first interrupt. if (!$handler) { $should_exit = true; } } // It's common for users to ^C on prompts. Write a newline before writing // a response to the interrupt so the behavior is a little cleaner. This // also avoids lines that read "^C [ INTERRUPT ] ...". $log->writeNewline(); if ($should_exit) { $log->writeHint( pht('INTERRUPT'), pht('Interrupted by SIGINT (^C).')); exit(128 + $signo); } $log->writeHint( pht('INTERRUPT'), pht('Press ^C again to exit.')); $handler->handleSignal($signo); } public function pushWorkflow(ArcanistWorkflow $workflow) { $this->stack[] = $workflow; return $this; } public function popWorkflow() { if (!$this->stack) { throw new Exception(pht('Trying to pop an empty workflow stack!')); } return array_pop($this->stack); } public function getWorkflowStack() { return $this->stack; } public function getCurrentWorkflow() { return last($this->stack); } private function newConduitEngine( ArcanistConfigurationSourceList $config, PhutilArgumentParser $args) { try { $force_uri = $args->getArg('conduit-uri'); } catch (PhutilArgumentSpecificationException $ex) { $force_uri = null; } try { $force_token = $args->getArg('conduit-token'); } catch (PhutilArgumentSpecificationException $ex) { $force_token = null; } if ($force_uri !== null) { $conduit_uri = $force_uri; } else { $conduit_uri = $config->getConfig('phabricator.uri'); if ($conduit_uri === null) { // For now, read this older config from raw storage. There is currently // no definition of this option in the "toolsets" config list, and it // would be nice to get rid of it. $default_list = $config->getStorageValueList('default'); if ($default_list) { $conduit_uri = last($default_list)->getValue(); } } } if ($conduit_uri) { // Set the URI path to '/api/'. TODO: Originally, I contemplated letting // you deploy Phabricator somewhere other than the domain root, but ended // up never pursuing that. We should get rid of all "/api/" silliness // in things users are expected to configure. This is already happening // to some degree, e.g. "arc install-certificate" does it for you. $conduit_uri = new PhutilURI($conduit_uri); $conduit_uri->setPath('/api/'); $conduit_uri = phutil_string_cast($conduit_uri); } $engine = new ArcanistConduitEngine(); if ($conduit_uri !== null) { $engine->setConduitURI($conduit_uri); } // TODO: This isn't using "getConfig()" because we aren't defining a // real config entry for the moment. if ($force_token !== null) { $conduit_token = $force_token; } else { $hosts = array(); $hosts_list = $config->getStorageValueList('hosts'); foreach ($hosts_list as $hosts_config) { $hosts += $hosts_config->getValue(); } $host_config = idx($hosts, $conduit_uri, array()); $conduit_token = idx($host_config, 'token'); } if ($conduit_token !== null) { $engine->setConduitToken($conduit_token); } return $engine; } private function executeShellAlias(ArcanistAliasEffect $effect) { $log = $this->getLogEngine(); $message = $effect->getMessage(); if ($message !== null) { $log->writeHint(pht('SHELL ALIAS'), $message); } return phutil_passthru('%Ls', $effect->getArguments()); } public function getSymbolEngine() { if ($this->symbolEngine === null) { $this->symbolEngine = $this->newSymbolEngine(); } return $this->symbolEngine; } private function newSymbolEngine() { return id(new ArcanistSymbolEngine()) ->setWorkflow($this); } public function getHardpointEngine() { if ($this->hardpointEngine === null) { $this->hardpointEngine = $this->newHardpointEngine(); } return $this->hardpointEngine; } private function newHardpointEngine() { $engine = new ArcanistHardpointEngine(); $queries = ArcanistRuntimeHardpointQuery::getAllQueries(); foreach ($queries as $key => $query) { $queries[$key] = id(clone $query) ->setRuntime($this); } $engine->setQueries($queries); return $engine; } public function getViewer() { if (!$this->viewer) { $viewer = $this->getSymbolEngine() ->loadUserForSymbol('viewer()'); // TODO: Deal with anonymous stuff. if (!$viewer) { throw new Exception(pht('No viewer!')); } $this->viewer = $viewer; } return $this->viewer; } public function loadHardpoints($objects, $requests) { if (!is_array($objects)) { $objects = array($objects); } if (!is_array($requests)) { $requests = array($requests); } $engine = $this->getHardpointEngine(); $requests = $engine->requestHardpoints( $objects, $requests); // TODO: Wait for only the required requests. $engine->waitForRequests(array()); } public function getWorkingCopy() { return $this->workingCopy; } public function getConduitEngine() { return $this->conduitEngine; } public function setToolset($toolset) { $this->toolset = $toolset; return $this; } public function getToolset() { return $this->toolset; } + private function getHelpWorkflows(array $workflows) { + if ($this->getToolset()->getToolsetKey() === 'arc') { + $legacy = array(); + + $legacy[] = new ArcanistCloseRevisionWorkflow(); + $legacy[] = new ArcanistCommitWorkflow(); + $legacy[] = new ArcanistCoverWorkflow(); + $legacy[] = new ArcanistDiffWorkflow(); + $legacy[] = new ArcanistExportWorkflow(); + $legacy[] = new ArcanistGetConfigWorkflow(); + $legacy[] = new ArcanistSetConfigWorkflow(); + $legacy[] = new ArcanistInstallCertificateWorkflow(); + $legacy[] = new ArcanistLandWorkflow(); + $legacy[] = new ArcanistLintersWorkflow(); + $legacy[] = new ArcanistLintWorkflow(); + $legacy[] = new ArcanistListWorkflow(); + $legacy[] = new ArcanistPatchWorkflow(); + $legacy[] = new ArcanistPasteWorkflow(); + $legacy[] = new ArcanistTasksWorkflow(); + $legacy[] = new ArcanistTodoWorkflow(); + $legacy[] = new ArcanistUnitWorkflow(); + $legacy[] = new ArcanistWhichWorkflow(); + + foreach ($legacy as $workflow) { + // If this workflow has been updated but not removed from the list + // above yet, just skip it. + if ($workflow instanceof ArcanistArcWorkflow) { + continue; + } + + $workflows[] = $workflow->newLegacyPhutilWorkflow(); + } + } + + return $workflows; + } + } diff --git a/src/toolset/ArcanistWorkflowArgument.php b/src/toolset/ArcanistWorkflowArgument.php index 0c1ab602..9a936b33 100644 --- a/src/toolset/ArcanistWorkflowArgument.php +++ b/src/toolset/ArcanistWorkflowArgument.php @@ -1,74 +1,79 @@ key = $key; return $this; } public function getKey() { return $this->key; } public function setWildcard($wildcard) { $this->wildcard = $wildcard; return $this; } public function getWildcard() { return $this->wildcard; } public function getPhutilSpecification() { $spec = array( 'name' => $this->getKey(), ); if ($this->getWildcard()) { $spec['wildcard'] = true; } $parameter = $this->getParameter(); if ($parameter !== null) { $spec['param'] = $parameter; } + $help = $this->getHelp(); + if ($help !== null) { + $spec['help'] = $help; + } + return $spec; } public function setHelp($help) { $this->help = $help; return $this; } public function getHelp() { return $this->help; } public function setParameter($parameter) { $this->parameter = $parameter; return $this; } public function getParameter() { return $this->parameter; } public function setIsPathArgument($is_path_argument) { $this->isPathArgument = $is_path_argument; return $this; } public function getIsPathArgument() { return $this->isPathArgument; } } diff --git a/src/toolset/workflow/ArcanistHelpWorkflow.php b/src/toolset/workflow/ArcanistHelpWorkflow.php index 8cda9ab2..e7ac38fc 100644 --- a/src/toolset/workflow/ArcanistHelpWorkflow.php +++ b/src/toolset/workflow/ArcanistHelpWorkflow.php @@ -1,19 +1,18 @@ setRuntime($this->getRuntime()); + return new PhutilHelpArgumentWorkflow(); } public function supportsToolset(ArcanistToolset $toolset) { return true; } }