diff --git a/src/toolset/workflow/ArcanistShellCompleteWorkflow.php b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php index 4fc1fb0a..f68d04a5 100644 --- a/src/toolset/workflow/ArcanistShellCompleteWorkflow.php +++ b/src/toolset/workflow/ArcanistShellCompleteWorkflow.php @@ -1,660 +1,666 @@ ...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() ->setSynopsis(pht('Install shell completion.')) ->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" into "%s".', $shell, $file_display)); 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. + // flag (or is entirely empty), try to autcomplete a wildcard argument + // if it has some kind of meaningful completion. For example, "arc lint + // READ" should autocomplete a file, and "arc lint " should + // suggest files in the current directory. - if (!$matches && $wildcard) { + if (!strlen($current) || !$matches) { + $try_paths = true; + } else { + $try_paths = false; + } + if ($try_paths && $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; } }