1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-11-08 07:52:39 +01:00

[Wilds] Update "arc shell-complete" for toolsets

Summary:
Ref T13098. Major changes:

`arc shell-complete` now installs itself to your `~/.profile`. Running `arc shell-complete` again will update the hook and update the completion rules.

Completion rules work for all toolsets, so you can install once and then autocomplete in `arc`, `phage`, etc.

This code supports other shells in theory, and I developed most of it with ZSH support next to the Bash support. However, while actually testing ZSH support, I couldn't get it to even slightly work and found myself falling down a very, very deep rabbit hole of ZSH being entirely written in bash script and `${0🅰️h}` being a legitimate script construction. The existing ZSH support comes from one guy in 2012 and also does not work for me on `master`, so I ultimately removed it. Open to restoring it but I wasn't able to figure it out in 10 minutes of Googling and I'm not convinced it's worth 11 minutes of Googling.

I left a few rough edges here with notes on how to improve/fix them, but the basics all work.

Test Plan:
- Ran `arc shell-complete` under various stages of `~/.profile`, couldn't get it to do anything bad.
- Ran `arc lib<tab>`, `arc shell-complete --curr<tab>`, etc. Got sensible suggestions and completions.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13098

Differential Revision: https://secure.phabricator.com/D19700
This commit is contained in:
epriestley 2018-09-21 13:09:20 -07:00
parent b6f93a46d7
commit 50dfc9cc41
7 changed files with 613 additions and 156 deletions

6
.gitignore vendored
View file

@ -19,7 +19,6 @@
/support/xhpast/parser.yacc.output
/support/xhpast/node_names.hpp
/support/xhpast/xhpast
/support/xhpast/xhpast.exe
/src/parser/xhpast/bin/xhpast
## NOTE: Don't .gitignore these files! Even though they're build artifacts, we
@ -32,9 +31,8 @@
# This is an OS X build artifact.
/support/xhpast/xhpast.dSYM
# libphutil
/support/phutiltestlib/.phutil_module_cache
# This file overrides "default.pem" if present.
/resources/ssl/custom.pem
# Generated shell completion rules.
/support/shell/rules/*

View file

@ -77,5 +77,13 @@ final class ArcanistLogEngine
return $trace;
}
public function writeHint($label, $message) {
return $this->writeMessage(
$this->newMessage()
->setColor('cyan')
->setLabel($label)
->setMessage($message));
}
}

View file

@ -6,6 +6,8 @@ final class ArcanistWorkflowArgument
private $key;
private $help;
private $wildcard;
private $parameter;
private $isPathArgument;
public function setKey($key) {
$this->key = $key;
@ -34,6 +36,11 @@ final class ArcanistWorkflowArgument
$spec['wildcard'] = true;
}
$parameter = $this->getParameter();
if ($parameter !== null) {
$spec['param'] = $parameter;
}
return $spec;
}
@ -46,5 +53,23 @@ final class ArcanistWorkflowArgument
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;
}
}

View file

@ -1,203 +1,623 @@
<?php
/**
* Powers shell-completion scripts.
*/
final class ArcanistShellCompleteWorkflow extends ArcanistWorkflow {
public function supportsToolset(ArcanistToolset $toolset) {
return true;
}
final class ArcanistShellCompleteWorkflow
extends ArcanistWorkflow {
public function getWorkflowName() {
return 'shell-complete';
}
public function getWorkflowSynopses() {
return array(
pht('**shell-complete** __--current__ __N__ -- [__argv__]'),
);
}
public function getWorkflowInformation() {
$help = pht(<<<EOTEXT
Install shell completion so you can use the "tab" key to autocomplete
commands and flags in your shell for Arcanist toolsets and workflows.
public function getWorkflowHelp() {
return pht(<<<EOTEXT
Implements shell completion. To use shell completion, source the appropriate
script from 'resources/shell/' in your .shellrc.
The **bash** shell is supported.
**Installing Completion**
To install shell completion, run the command:
$ arc shell-complete
This will install shell completion into your current shell. After installing,
you may need to start a new shell (or open a new terminal window) to pick up
the updated configuration.
Once installed, completion should work across all Arcanist toolsets.
**Using Completion**
After completion is installed, use the "tab" key to automatically complete
workflows and flags. For example, if you type:
$ arc diff --draf<tab>
...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 getArguments() {
public function getWorkflowArguments() {
return array(
'current' => array(
'param' => 'cursor_position',
'paramtype' => 'int',
'help' => pht('Current term in the argument list being completed.'),
),
'*' => 'argv',
$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),
);
}
protected function shouldShellComplete() {
return false;
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();
}
public function run() {
$pos = $this->getArgument('current');
$argv = $this->getArgument('argv', array());
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?');
}
}
// TOOLSETS: Generalize prompting.
if (!phutil_console_confirm($prompt, false)) {
throw new PhutilArgumentUsageException(pht('Aborted.'));
}
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);
if ($pos === null) {
$pos = $this->getArgument('current');
if (!$pos) {
$pos = $argc - 1;
}
if ($pos > $argc) {
throw new ArcanistUsageException(
pht(
'Specified position is greater than the number of '.
'arguments provided.'));
'Argument position specified with "--current" ("%s") is greater '.
'than the number of arguments provided ("%s").',
new PhutilNumber($pos),
new PhutilNumber($argc)));
}
// 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;
$workflows = $this->getRuntime()->getWorkflows();
// 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);
// NOTE: This isn't quite right. For example, "arc --con<tab>" will
// try to autocomplete workflows named "--con", but it should actually
// autocomplete global flags and suggest "--config".
$vcs = $repository_api->getSourceControlSystemName();
}
$is_workflow = ($pos <= 1);
$arc_config = $this->getArcanistConfiguration();
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.
if ($pos <= 1) {
$workflows = $arc_config->buildAllWorkflows();
// 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<tab>" likely does want "shell-complete".
$complete = array();
foreach ($workflows as $name => $workflow) {
if (!$workflow->shouldShellComplete()) {
foreach ($workflows as $workflow) {
$complete[] = $workflow->getWorkflowName();
}
foreach ($this->getConfig('aliases') as $alias) {
if ($alias->getException()) {
continue;
}
$workflow->setArcanistConfiguration($this->getArcanistConfiguration());
$workflow->setConfigurationManager($this->getConfigurationManager());
if ($vcs || $workflow->requiresWorkingCopy()) {
$supported_vcs = $workflow->getSupportedRevisionControlSystems();
if (!in_array($vcs, $supported_vcs)) {
continue;
}
if ($alias->getToolset() !== $this->getToolsetKey()) {
continue;
}
$complete[] = $name;
$complete[] = $alias->getTrigger();
}
// Also permit autocompletion of "arc alias" commands.
$aliases = ArcanistAliasWorkflow::getAliases($configuration_manager);
foreach ($aliases as $key => $value) {
$complete[] = $key;
// Remove invalid possibilities. For example, if the user has typed
// "skun<tab>", it obviously can't complete to "zebra". We don't really
// need to do this filtering ourselves: the shell completion will
// automatically match things for us even if we emit impossible results.
// However, it's a little easier to debug the raw output if we clean it
// up here before printing it out.
$partial = $argv[$pos];
foreach ($complete as $key => $candidate) {
if (strncmp($partial, $candidate, strlen($partial))) {
unset($complete[$key]);
}
}
if ($complete) {
return $this->suggestStrings($complete);
} else {
return $this->suggestNothing();
}
echo implode(' ', $complete)."\n";
return 0;
} else {
$workflow = $arc_config->buildWorkflow($argv[1]);
// 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<tab>".
// TOOLSETS: It's possible the workflow isn't in position 1. The user
// may be running "arc --trace diff --dra<tab>", for example.
$workflow = idx($workflows, $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);
}
return $this->suggestNothing();
}
$arguments = $workflow->getArguments();
$arguments = $workflow->getWorkflowArguments();
$arguments = mpull($arguments, null, 'getKey');
$argument = null;
$prev = idx($argv, $pos - 1, null);
if (!strncmp($prev, '--', 2)) {
$prev = substr($prev, 2);
} else {
$prev = null;
$argument = idx($arguments, $prev);
}
if ($prev !== null &&
isset($arguments[$prev]) &&
isset($arguments[$prev]['param'])) {
// 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.
$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;
if ($argument && strlen($argument->getParameter())) {
if ($argument->getIsPathArgument()) {
return $this->suggestPaths();
} else {
return $this->suggestNothing();
}
$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;
// 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();
}
$current = idx($argv, $pos, '');
$matches = array();
if (strlen($current)) {
foreach ($flags as $possible) {
if (!strncmp($possible, $current, strlen($current))) {
$matches[] = $possible;
}
}
}
// 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<tab>" 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();
}
}
return $this->suggestStrings($matches);
}
}
private function suggestPaths() {
echo "FILE\n";
return 0;
}
private function suggestNothing() {
echo "ARGUMENT\n";
return 0;
}
private function suggestStrings(array $strings) {
echo implode(' ', $strings)."\n";
return 0;
}
}

View file

@ -0,0 +1,9 @@
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )"
# Try to generate the shell completion rules if they do not yet exist.
if [ ! -f "${SCRIPTDIR}/bash-rules.sh" ]; then
arc shell-complete --generate >/dev/null 2>/dev/null
fi;
# Source the shell completion rules.
source "${SCRIPTDIR}/../rules/bash-rules.sh"

View file

View file

@ -1,12 +1,9 @@
if [[ -n ${ZSH_VERSION-} ]]; then
autoload -U +X bashcompinit && bashcompinit
fi
_arc ()
_arcanist_complete_{{{BIN}}} ()
{
CUR="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=()
OPTS=$(echo | arc shell-complete --current ${COMP_CWORD} -- ${COMP_WORDS[@]})
CUR="${COMP_WORDS[COMP_CWORD]}"
OPTS=$(echo | {{{BIN}}} shell-complete --current ${COMP_CWORD} -- ${COMP_WORDS[@]} 2>/dev/null)
if [ $? -ne 0 ]; then
return $?
@ -23,4 +20,4 @@ _arc ()
COMPREPLY=( $(compgen -W "${OPTS}" -- ${CUR}) )
}
complete -F _arc -o filenames arc
complete -F _arcanist_complete_{{{BIN}}} -o filenames {{{BIN}}}