1
0
Fork 0
mirror of https://we.phorge.it/source/arcanist.git synced 2024-11-22 06:42:41 +01:00

Port "arc shell-complete" to Toolsets

Summary: Depends on D20996. Ref T13395. Ports the updated version of this workflow from "experimental".

Test Plan: Ran `arc shell-complete` to install the hook, then shell-completed commands.

Maniphest Tasks: T13395

Differential Revision: https://secure.phabricator.com/D20997
This commit is contained in:
epriestley 2020-02-14 08:52:23 -08:00
parent 70c6045c7f
commit 8cd79d38af
4 changed files with 661 additions and 202 deletions

View file

@ -402,7 +402,7 @@ phutil_register_library_map(array(
'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php',
'ArcanistSetting' => 'configuration/ArcanistSetting.php', 'ArcanistSetting' => 'configuration/ArcanistSetting.php',
'ArcanistSettings' => 'configuration/ArcanistSettings.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php',
'ArcanistShellCompleteWorkflow' => 'workflow/ArcanistShellCompleteWorkflow.php', 'ArcanistShellCompleteWorkflow' => 'toolset/workflow/ArcanistShellCompleteWorkflow.php',
'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php',
'ArcanistSlownessXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSlownessXHPASTLinterRule.php', 'ArcanistSlownessXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSlownessXHPASTLinterRule.php',
'ArcanistSlownessXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSlownessXHPASTLinterRuleTestCase.php', 'ArcanistSlownessXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistSlownessXHPASTLinterRuleTestCase.php',

View file

@ -0,0 +1,656 @@
<?php
final class ArcanistShellCompleteWorkflow
extends ArcanistWorkflow {
public function supportsToolset(ArcanistToolset $toolset) {
return true;
}
public function getWorkflowName() {
return 'shell-complete';
}
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.
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 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<tab>" 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<tab>" 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<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) {
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<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($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 '<compgen:file>';
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;
}
}

View file

@ -1,201 +0,0 @@
<?php
/**
* Powers shell-completion scripts.
*/
final class ArcanistShellCompleteWorkflow extends ArcanistWorkflow {
public function getWorkflowName() {
return 'shell-complete';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**shell-complete** __--current__ __N__ -- [__argv__]
EOTEXT
);
}
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
Supports: bash, etc.
Implements shell completion. To use shell completion, source the
appropriate script from 'resources/shell/' in your .shellrc.
EOTEXT
);
}
public function getArguments() {
return array(
'current' => 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;
}
}
}
}

View file

@ -2275,4 +2275,8 @@ abstract class ArcanistWorkflow extends Phobject {
return $this->getToolset()->getToolsetKey(); return $this->getToolset()->getToolsetKey();
} }
final public function getConfig($key) {
return $this->getConfigurationSourceList()->getConfig($key);
}
} }