diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -402,7 +402,7 @@ 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSetting' => 'configuration/ArcanistSetting.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php', - 'ArcanistShellCompleteWorkflow' => 'workflow/ArcanistShellCompleteWorkflow.php', + 'ArcanistShellCompleteWorkflow' => 'toolset/workflow/ArcanistShellCompleteWorkflow.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', 'ArcanistSlownessXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSlownessXHPASTLinterRule.php', 'ArcanistSlownessXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSlownessXHPASTLinterRuleTestCase.php', diff --git a/src/toolset/workflow/ArcanistShellCompleteWorkflow.php b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php new file mode 100644 --- /dev/null +++ b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php @@ -0,0 +1,656 @@ + + +...your shell should automatically expand the flag to: + + $ arc diff --draft + +**Updating Completion** + +To update shell completion, run the same command: + + $ arc shell-complete + +You can update shell completion without reinstalling it by running: + + $ arc shell-complete --generate + +You may need to update shell completion if: + + - you install new Arcanist toolsets; or + - you move the Arcanist directory; or + - you upgrade Arcanist and the new version fixes shell completion bugs. +EOTEXT +); + + return $this->newWorkflowInformation() + ->setHelp($help); + } + + public function getWorkflowArguments() { + return array( + $this->newWorkflowArgument('current') + ->setParameter('cursor-position') + ->setHelp( + pht( + 'Internal. Current term in the argument list being completed.')), + $this->newWorkflowArgument('generate') + ->setHelp( + pht( + 'Regenerate shell completion rules, without installing any '. + 'configuration.')), + $this->newWorkflowArgument('shell') + ->setParameter('shell-name') + ->setHelp( + pht( + 'Install completion support for a particular shell.')), + $this->newWorkflowArgument('argv') + ->setWildcard(true), + ); + } + + public function runWorkflow() { + $log = $this->getLogEngine(); + + $argv = $this->getArgument('argv'); + + $is_generate = $this->getArgument('generate'); + $is_shell = (bool)strlen($this->getArgument('shell')); + $is_current = $this->getArgument('current'); + + if ($argv) { + $should_install = false; + $should_generate = false; + + if ($is_generate) { + throw new PhutilArgumentUsageException( + pht( + 'You can not use "--generate" when completing arguments.')); + } + + if ($is_shell) { + throw new PhutilArgumentUsageException( + pht( + 'You can not use "--shell" when completing arguments.')); + } + + } else if ($is_generate) { + $should_install = false; + $should_generate = true; + + if ($is_current) { + throw new PhutilArgumentUsageException( + pht( + 'You can not use "--current" when generating rules.')); + } + + if ($is_shell) { + throw new PhutilArgumentUsageException( + pht( + 'The flags "--generate" and "--shell" are mutually exclusive. '. + 'The "--shell" flag selects which shell to install support for, '. + 'but the "--generate" suppresses installation.')); + } + + } else { + $should_install = true; + $should_generate = true; + + if ($is_current) { + throw new PhutilArgumentUsageException( + pht( + 'You can not use "--current" when installing support.')); + } + } + + if ($should_install) { + $this->runInstall(); + } + + if ($should_generate) { + $this->runGenerate(); + } + + if ($should_install || $should_generate) { + $log->writeHint( + pht('NOTE'), + pht( + 'You may need to open a new terminal window or launch a new shell '. + 'before the changes take effect.')); + return 0; + } + + $this->runAutocomplete(); + } + + protected function newPrompts() { + return array( + $this->newPrompt('arc.shell-complete.install') + ->setDescription( + pht( + 'Confirms writing to to "~/.profile" (or another similar file) '. + 'to install shell completion.')), + ); + } + + private function runInstall() { + $log = $this->getLogEngine(); + + $shells = array( + array( + 'key' => 'bash', + 'path' => '/bin/bash', + 'file' => '.profile', + 'source' => 'hooks/bash-completion.sh', + ), + ); + $shells = ipull($shells, null, 'key'); + + $shell = $this->getArgument('shell'); + if (!$shell) { + $shell = $this->detectShell($shells); + } else { + $shell = $this->selectShell($shells, $shell); + } + + $spec = $shells[$shell]; + $file = $spec['file']; + $home = getenv('HOME'); + + if (!strlen($home)) { + throw new PhutilArgumentUsageException( + pht( + 'The "HOME" environment variable is not defined, so this workflow '. + 'can not identify where to install shell completion.')); + } + + $file_path = getenv('HOME').'/'.$file; + $file_display = '~/'.$file; + + if (Filesystem::pathExists($file_path)) { + $file_path = Filesystem::resolvePath($file_path); + $data = Filesystem::readFile($file_path); + $is_new = false; + } else { + $data = ''; + $is_new = true; + } + + $line = csprintf( + 'source %R # arcanist-shell-complete', + $this->getShellPath($spec['source'])); + + $matches = null; + $replace = preg_match( + '/(\s*\n)?[^\n]+# arcanist-shell-complete\s*(\n\s*)?/', + $data, + $matches, + PREG_OFFSET_CAPTURE); + + $log->writeSuccess( + pht('INSTALL'), + pht('Installing shell completion support for "%s".', $shell)); + + if ($replace) { + $replace_pos = $matches[0][1]; + $replace_line = $matches[0][0]; + $replace_len = strlen($replace_line); + $replace_display = trim($replace_line); + + if ($replace_pos === 0) { + $new_line = $line."\n"; + } else { + $new_line = "\n\n".$line."\n"; + } + + $new_data = substr_replace($data, $new_line, $replace_pos, $replace_len); + + if ($new_data === $data) { + // If we aren't changing anything in the file, just skip the write + // completely. + $needs_write = false; + + $log->writeStatus( + pht('SKIP'), + pht('Shell completion for "%s" is already installed.', $shell)); + + return; + } + + echo tsprintf( + "%s\n\n %s\n\n%s\n\n %s\n", + pht( + 'To update shell completion support for "%s", your existing '. + '"%s" file will be modified. This line will be removed:', + $shell, + $file_display), + $replace_display, + pht('This line will be added:'), + $line); + + $prompt = pht('Rewrite this file?'); + } else { + if ($is_new) { + $new_data = $line."\n"; + + echo tsprintf( + "%s\n\n %s\n", + pht( + 'To install shell completion support for "%s", a new "%s" file '. + 'will be created with this content:', + $shell, + $file_display), + $line); + + $prompt = pht('Create this file?'); + } else { + $new_data = rtrim($data)."\n\n".$line."\n"; + + echo tsprintf( + "%s\n\n %s\n", + pht( + 'To install shell completion support for "%s", this line will be '. + 'added to your existing "%s" file:', + $shell, + $file_display), + $line); + + $prompt = pht('Append to this file?'); + } + } + + $this->getPrompt('arc.shell-complete.install') + ->setQuery($prompt) + ->execute(); + + Filesystem::writeFile($file_path, $new_data); + + $log->writeSuccess( + pht('INSTALLED'), + pht( + 'Installed shell completion support for "%s" to "%s".', + $shell, + $file_display)); + } + + private function selectShell(array $shells, $shell_arg) { + foreach ($shells as $shell) { + if ($shell['key'] === $shell_arg) { + return $shell_arg; + } + } + + throw new PhutilArgumentUsageException( + pht( + 'The shell "%s" is not supported. Supported shells are: %s.', + $shell_arg, + implode(', ', ipull($shells, 'key')))); + } + + private function detectShell(array $shells) { + // NOTE: The "BASH_VERSION" and "ZSH_VERSION" shell variables are not + // passed to subprocesses, so we can't inspect them to figure out which + // shell launched us. If we could figure this out in some other way, it + // would be nice to do so. + + // Instead, just look at "SHELL" (the user's startup shell). + + $log = $this->getLogEngine(); + + $detected = array(); + $log->writeStatus( + pht('DETECT'), + pht('Detecting current shell...')); + + $shell_env = getenv('SHELL'); + if (!strlen($shell_env)) { + $log->writeWarning( + pht('SHELL'), + pht( + 'The "SHELL" environment variable is not defined, so it can '. + 'not be used to detect the shell to install rules for.')); + } else { + $found = false; + foreach ($shells as $shell) { + if ($shell['path'] !== $shell_env) { + continue; + } + + $found = true; + $detected[] = $shell['key']; + + $log->writeSuccess( + pht('SHELL'), + pht( + 'The "SHELL" environment variable has value "%s", so the '. + 'target shell was detected as "%s".', + $shell_env, + $shell['key'])); + } + + if (!$found) { + $log->writeStatus( + pht('SHELL'), + pht( + 'The "SHELL" environment variable does not match any recognized '. + 'shell.')); + } + } + + if (!$detected) { + throw new PhutilArgumentUsageException( + pht( + 'Unable to detect any supported shell, so autocompletion rules '. + 'can not be installed. Use "--shell" to select a shell.')); + } else if (count($detected) > 1) { + throw new PhutilArgumentUsageException( + pht( + 'Multiple supported shells were detected. Unable to determine '. + 'which shell to install autocompletion rules for. Use "--shell" '. + 'to select a shell.')); + } + + return head($detected); + } + + private function runGenerate() { + $log = $this->getLogEngine(); + + $toolsets = ArcanistToolset::newToolsetMap(); + + $log->writeStatus( + pht('GENERATE'), + pht('Generating shell completion rules...')); + + $shells = array('bash'); + foreach ($shells as $shell) { + + $rules = array(); + foreach ($toolsets as $toolset) { + $rules[] = $this->newCompletionRules($toolset, $shell); + } + $rules = implode("\n", $rules); + + $rules_path = $this->getShellPath('rules/'.$shell.'-rules.sh'); + + // If a write wouldn't change anything, skip the write. This allows + // "arc shell-complete" to work if "arcanist/" is on a read-only NFS + // filesystem or something unusual like that. + + $skip_write = false; + if (Filesystem::pathExists($rules_path)) { + $current = Filesystem::readFile($rules_path); + if ($current === $rules) { + $skip_write = true; + } + } + + if ($skip_write) { + $log->writeStatus( + pht('SKIP'), + pht( + 'Rules are already up to date for "%s" in: %s', + $shell, + Filesystem::readablePath($rules_path))); + } else { + Filesystem::writeFile($rules_path, $rules); + $log->writeStatus( + pht('RULES'), + pht( + 'Wrote updated completion rules for "%s" to: %s.', + $shell, + Filesystem::readablePath($rules_path))); + } + } + } + + private function newCompletionRules(ArcanistToolset $toolset, $shell) { + $template_path = $this->getShellPath('templates/'.$shell.'-template.sh'); + $template = Filesystem::readFile($template_path); + + $variables = array( + 'BIN' => $toolset->getToolsetKey(), + ); + + foreach ($variables as $key => $value) { + $template = str_replace('{{{'.$key.'}}}', $value, $template); + } + + return $template; + } + + private function getShellPath($to_file = null) { + $arc_root = dirname(phutil_get_library_root('arcanist')); + return $arc_root.'/support/shell/'.$to_file; + } + + private function runAutocomplete() { + $argv = $this->getArgument('argv'); + $argc = count($argv); + + $pos = $this->getArgument('current'); + if (!$pos) { + $pos = $argc - 1; + } + + if ($pos >= $argc) { + throw new ArcanistUsageException( + pht( + 'Argument position specified with "--current" ("%s") is greater '. + 'than the number of arguments provided ("%s").', + new PhutilNumber($pos), + new PhutilNumber($argc))); + } + + $workflows = $this->getRuntime()->getWorkflows(); + + // NOTE: This isn't quite right. For example, "arc --con" will + // try to autocomplete workflows named "--con", but it should actually + // autocomplete global flags and suggest "--config". + + $is_workflow = ($pos <= 1); + + if ($is_workflow) { + // NOTE: There was previously some logic to try to filter impossible + // workflows out of the completion list based on the VCS in the current + // working directory: for example, if you're in an SVN working directory, + // "arc a" is unlikely to complete to "arc amend" because "amend" does + // not support SVN. It's not clear this logic is valuable, but we could + // consider restoring it if good use cases arise. + + // TOOLSETS: Restore the ability for workflows to opt out of shell + // completion. It is exceptionally unlikely that users want to shell + // complete obscure or internal workflows, like "arc shell-complete" + // itself. Perhaps a good behavior would be to offer these as + // completions if they are the ONLY available completion, since a user + // who has typed "arc shell-comp" likely does want "shell-complete". + + $complete = array(); + foreach ($workflows as $workflow) { + $complete[] = $workflow->getWorkflowName(); + } + + foreach ($this->getConfig('aliases') as $alias) { + if ($alias->getException()) { + continue; + } + + if ($alias->getToolset() !== $this->getToolsetKey()) { + continue; + } + + $complete[] = $alias->getTrigger(); + } + + $partial = $argv[$pos]; + $complete = $this->getMatches($complete, $partial); + + if ($complete) { + return $this->suggestStrings($complete); + } else { + return $this->suggestNothing(); + } + + } else { + // TOOLSETS: We should resolve aliases before picking a workflow, so + // that if you alias "arc draft" to "arc diff --draft", we can suggest + // other "diff" flags when you type "arc draft --q". + + // TOOLSETS: It's possible the workflow isn't in position 1. The user + // may be running "arc --trace diff --dra", for example. + + $workflow = idx($workflows, $argv[1]); + if (!$workflow) { + return $this->suggestNothing(); + } + + $arguments = $workflow->getWorkflowArguments(); + $arguments = mpull($arguments, null, 'getKey'); + $current = idx($argv, $pos, ''); + + $argument = null; + $prev = idx($argv, $pos - 1, null); + if (!strncmp($prev, '--', 2)) { + $prev = substr($prev, 2); + $argument = idx($arguments, $prev); + } + + // If the last argument was a "--thing" argument, test if "--thing" is + // a parameterized argument. If it is, the next argument should be a + // parameter. + + if ($argument && strlen($argument->getParameter())) { + if ($argument->getIsPathArgument()) { + return $this->suggestPaths($current); + } else { + return $this->suggestNothing(); + } + + // TOOLSETS: We can allow workflows and arguments to provide a specific + // list of completeable values, like the "--shell" argument for this + // workflow. + } + + $flags = array(); + $wildcard = null; + foreach ($arguments as $argument) { + if ($argument->getWildcard()) { + $wildcard = $argument; + continue; + } + + $flags[] = '--'.$argument->getKey(); + } + + $matches = $this->getMatches($flags, $current); + + // If whatever the user is completing does not match the prefix of any + // flag, try to autcomplete a wildcard argument if it has some kind of + // meaningful completion. For example, "arc lint READ" should + // autocomplete a file. + + if (!$matches && $wildcard) { + + // TOOLSETS: There was previously some very questionable support for + // autocompleting branches here. This could be moved into Arguments + // and Workflows. + + if ($wildcard->getIsPathArgument()) { + return $this->suggestPaths($current); + } + } + + // TODO: If a command has only one flag, like "--json", don't suggest + // it if the user hasn't typed anything or has only typed "--". + + // TODO: Don't suggest "--flag" arguments which aren't repeatable if + // they are already present in the argument list. + + return $this->suggestStrings($matches); + } + } + + private function suggestPaths($prefix) { + // NOTE: We are returning a directive to the bash script to run "compgen" + // for us rather than running it ourselves. If we run: + // + // compgen -A file -- %s + // + // ...from this context, it fails (exits with error code 1 and no output) + // if the prefix is "foo\ ", on my machine. See T9116 for some dicussion. + echo ''; + return 0; + } + + private function suggestNothing() { + return $this->suggestStrings(array()); + } + + private function suggestStrings(array $strings) { + sort($strings); + echo implode("\n", $strings); + return 0; + } + + private function getMatches(array $candidates, $prefix) { + $matches = array(); + + if (strlen($prefix)) { + foreach ($candidates as $possible) { + if (!strncmp($possible, $prefix, strlen($prefix))) { + $matches[] = $possible; + } + } + + // If we matched nothing, try a case-insensitive match. + if (!$matches) { + foreach ($candidates as $possible) { + if (!strncasecmp($possible, $prefix, strlen($prefix))) { + $matches[] = $possible; + } + } + } + } else { + $matches = $candidates; + } + + return $matches; + } + +} diff --git a/src/workflow/ArcanistShellCompleteWorkflow.php b/src/workflow/ArcanistShellCompleteWorkflow.php deleted file mode 100644 --- a/src/workflow/ArcanistShellCompleteWorkflow.php +++ /dev/null @@ -1,201 +0,0 @@ - array( - 'param' => 'cursor_position', - 'paramtype' => 'int', - 'help' => pht('Current term in the argument list being completed.'), - ), - '*' => 'argv', - ); - } - - protected function shouldShellComplete() { - return false; - } - - public function run() { - $pos = $this->getArgument('current'); - $argv = $this->getArgument('argv', array()); - $argc = count($argv); - if ($pos === null) { - $pos = $argc - 1; - } - - if ($pos > $argc) { - throw new ArcanistUsageException( - pht( - 'Specified position is greater than the number of '. - 'arguments provided.')); - } - - // Determine which revision control system the working copy uses, so we - // can filter out commands and flags which aren't supported. If we can't - // figure it out, just return all flags/commands. - $vcs = null; - - // We have to build our own because if we requiresWorkingCopy() we'll throw - // if we aren't in a .arcconfig directory. We probably still can't do much, - // but commands can raise more detailed errors. - $configuration_manager = $this->getConfigurationManager(); - $working_copy = ArcanistWorkingCopyIdentity::newFromPath(getcwd()); - if ($working_copy->getVCSType()) { - $configuration_manager->setWorkingCopyIdentity($working_copy); - $repository_api = ArcanistRepositoryAPI::newAPIFromConfigurationManager( - $configuration_manager); - - $vcs = $repository_api->getSourceControlSystemName(); - } - - $arc_config = $this->getArcanistConfiguration(); - - if ($pos <= 1) { - $workflows = $arc_config->buildAllWorkflows(); - - $complete = array(); - foreach ($workflows as $name => $workflow) { - if (!$workflow->shouldShellComplete()) { - continue; - } - - $workflow->setArcanistConfiguration($this->getArcanistConfiguration()); - $workflow->setConfigurationManager($this->getConfigurationManager()); - - if ($vcs || $workflow->requiresWorkingCopy()) { - $supported_vcs = $workflow->getSupportedRevisionControlSystems(); - if (!in_array($vcs, $supported_vcs)) { - continue; - } - } - - $complete[] = $name; - } - - // Also permit autocompletion of "arc alias" commands. - $aliases = ArcanistAliasWorkflow::getAliases($configuration_manager); - foreach ($aliases as $key => $value) { - $complete[] = $key; - } - - echo implode(' ', $complete)."\n"; - return 0; - } else { - $workflow = $arc_config->buildWorkflow($argv[1]); - if (!$workflow) { - list($new_command, $new_args) = ArcanistAliasWorkflow::resolveAliases( - $argv[1], - $arc_config, - array_slice($argv, 2), - $configuration_manager); - if ($new_command) { - $workflow = $arc_config->buildWorkflow($new_command); - } - if (!$workflow) { - return 1; - } else { - $argv = array_merge( - array($argv[0]), - array($new_command), - $new_args); - } - } - - $arguments = $workflow->getArguments(); - - $prev = idx($argv, $pos - 1, null); - if (!strncmp($prev, '--', 2)) { - $prev = substr($prev, 2); - } else { - $prev = null; - } - - if ($prev !== null && - isset($arguments[$prev]) && - isset($arguments[$prev]['param'])) { - - $type = idx($arguments[$prev], 'paramtype'); - switch ($type) { - case 'file': - echo "FILE\n"; - break; - case 'complete': - echo implode(' ', $workflow->getShellCompletions($argv))."\n"; - break; - default: - echo "ARGUMENT\n"; - break; - } - return 0; - } else { - $output = array(); - foreach ($arguments as $argument => $spec) { - if ($argument == '*') { - continue; - } - if ($vcs && - isset($spec['supports']) && - !in_array($vcs, $spec['supports'])) { - continue; - } - $output[] = '--'.$argument; - } - - $cur = idx($argv, $pos, ''); - $any_match = false; - - if (strlen($cur)) { - foreach ($output as $possible) { - if (!strncmp($possible, $cur, strlen($cur))) { - $any_match = true; - } - } - } - - if (!$any_match && isset($arguments['*'])) { - // TODO: This is mega hacktown but something else probably breaks - // if we use a rich argument specification; fix it when we move to - // PhutilArgumentParser since everything will need to be tested then - // anyway. - if ($arguments['*'] == 'branch' && isset($repository_api)) { - $branches = $repository_api->getAllBranches(); - $branches = ipull($branches, 'name'); - $output = $branches; - } else { - $output = array('FILE'); - } - } - - echo implode(' ', $output)."\n"; - - return 0; - } - } - } - -} diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -2275,4 +2275,8 @@ return $this->getToolset()->getToolsetKey(); } + final public function getConfig($key) { + return $this->getConfigurationSourceList()->getConfig($key); + } + }