diff --git a/drush.complete.sh b/drush.complete.sh new file mode 100644 index 0000000..94bf722 --- /dev/null +++ b/drush.complete.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# BASH completion script for Drush. +# +# Place this in your /etc/bash_completion.d/ directory or source it from your +# ~/.bash_completion file. + +# Completion function, uses the "drush complete" command to retrieve completions +# for a specific command line COMP_WORDS. +_drush_completion() { + # The '< /dev/null' is a work around for a bug in php libedit stdin handling. + # Note that libedit in place of libreadline in some distributions. See: + # https://bugs.launchpad.net/ubuntu/+source/php5/+bug/322214 + COMPREPLY=( $(drush complete "${COMP_WORDS[@]}" < /dev/null) ) +} + +# Register our completion function. We include common short aliases for Drush. +complete -F _drush_completion d dr drush drush.php diff --git a/includes/bootstrap.inc b/includes/bootstrap.inc index 07abe16..9c5c814 100644 --- a/includes/bootstrap.inc +++ b/includes/bootstrap.inc @@ -918,8 +918,17 @@ function drush_bootstrap_prepare() { drush_set_context('DRUSH_BOOTSTRAP_PHASE', DRUSH_BOOTSTRAP_NONE); - // We need some global options processed at this early stage. Namely --debug. - drush_parse_args(); + // We need some global options/arguments processed at this early stage. + $arguments = drush_parse_args(); + + // Because the complete argument is added by an autocomplete shell script, + // it should always be first. + if ($arguments[0] == 'complete') { + require_once DRUSH_BASE_PATH . '/includes/complete.inc'; + drush_complete(); + } + + // Process early global options such as --debug. _drush_bootstrap_global_options(); } diff --git a/includes/cache.inc b/includes/cache.inc index 794978d..de17f71 100644 --- a/includes/cache.inc +++ b/includes/cache.inc @@ -180,7 +180,8 @@ function drush_get_cid($prefix, $contexts = array(), $params = array()) { $cid = array(); foreach ($contexts as $context) { - if ($c = drush_get_context($context) && !empty($c)) { + $c = drush_get_context($context); + if (!empty($c)) { $cid[] = is_scalar($c) ? $c : serialize($c); } } diff --git a/includes/command.inc b/includes/command.inc index 98381be..470c2fc 100644 --- a/includes/command.inc +++ b/includes/command.inc @@ -342,9 +342,8 @@ function drush_parse_args() { $opt = substr($opt, 1); // Check if the current opt is in $arg_opts (= has to be followed by an argument). if ((in_array($opt, $arg_opts))) { - if (($args[$i+1] == NULL) || ($args[$i+1] == "") || ($args[$i + 1]{0} == "-")) { - drush_set_error('DRUSH_INVALID_INPUT', "Invalid input: -$opt needs to be followed by an argument."); - } + // Raising errors for missing option values should be handled by the + // bootstrap or specific command, so we no longer do this here. $options[$opt] = $args[$i + 1]; $i++; } @@ -374,6 +373,7 @@ function drush_parse_args() { drush_set_arguments($arguments); drush_set_context('cli', $options); + return $arguments; } /** diff --git a/includes/complete.inc b/includes/complete.inc new file mode 100644 index 0000000..96cff74 --- /dev/null +++ b/includes/complete.inc @@ -0,0 +1,368 @@ +. The shell completion scripts should call + * "drush complete ", where is the full command line, which we take + * as input and use to produce a list of possible completions for the + * current/next word, separated by newlines. Typically, when multiple + * completions are returned the shell will display them to the user in a concise + * format - but when a single completion is returned it will autocomplete. + * + * We provide completion for site aliases, commands, shell aliases, options, + * engines and arguments. Displaying all of these when the last word has no + * characters yet is not useful, as there are too many items. Instead we filter + * the possible completions based on context, in a similar way to git. + * For example: + * - We only display site aliases and commands if one is not already present. + * - We only display options if the user has already entered a hyphen. + * - We only display global options before a command is entered, and we only + * display command specific options after the command (Drush itself does not + * care about option placement, but this approach keeps things more concise). + * + * Below is typical output of complete in different situations. Tokens in square + * brackets are optional, and [word] will filter available options that start + * with the same characters, or display all listed options if empty. + * drush --[word] : Output global options + * drush [word] : Output aliases, local sites and commands + * drush [@alias] [word] : Output commands + * drush [@alias] command [word] : Output command specific arguments + * drush [@alias] command --[word] : Output command specific options + * + * Because the purpose of autocompletion is to make command line work efficient, + * it is very important that the list of completions is returned quickly. + * To do this, we call drush_complete() early in the Drush bootstrap, and + * implement a simple caching system. + * + * To generate the list of completions, we set up the Drush environment as if + * the command was called on it's own, parse the command using the standard + * Drush functions, bootstrap the site (if any) and collect available + * completions from various sources. Because this can be somewhat slow, we cache + * the results. The cache strategy aims to balance accuracy and responsiveness: + * - We cache global and command specific options globally, since these are + * almost always identical between sites - the cache can be used even if drush + * complete has never run on a site before and does not require any bootstrap. + * - We cache command names and aliases per site, since they can vary per-site. + * - We generate (and cache) everything except arguments at the same time, so + * subsequent completions on the site don't need any bootstrap. + * - We generate and cache arguments on-demand, since these can often be + * expensive to generate. Arguments are also cached per-site. + */ + +/** + * Produce autocomplete output. + * + * Determine context (is there a site-alias or command set, and are we trying + * to complete an option). Then produce a list of completions for the last word + * and output them separated by newlines. + */ +function drush_complete() { + // We use a distinct --complete-debug option to avoid unwanted debug messages + // being printed when users use this option for other purposes in the command + // they are trying to complete. + if (drush_get_option('complete-debug', FALSE)) { + drush_set_context('DRUSH_DEBUG', TRUE); + } + // Set up as if we were running the command, and attempt to parse. + $argv = drush_complete_process_argv(); + $set_sitealias = drush_sitealias_get_record('@self'); + $set_sitealias_name = NULL; + if (!empty($set_sitealias['name'])) { + $set_sitealias_name = $set_sitealias['name']; + } + // Arguments has now had site-aliases and options removed, so we take the + // first item as our command. We need to know if the command is valid, so that + // we know if we are supposed to complete an in-progress command name, or + // arguments for a command. We do this by checking against our per-site cache + // of command names (which will only bootstrap if the cache needs to be + // regenerated), rather than drush_parse_command() which always requires a + // site bootstrap. + $arguments = drush_get_arguments(); + if (isset($arguments[0]) && count(drush_complete_match($arguments[0], 'command-names')) == 1) { + $set_command_name = $arguments[0]; + } + // We unset the command if it is "help" but that is not explicitly found in + // args, since Drush sets the command to "help" if no command is specified, + // which prevents completion of global options. + if ($set_command_name == 'help' && !array_search('help', $argv)) { + unset($set_command_name); + } + + // Determine the word we are trying to complete, and if it is an option. + $last_word = end($argv); + if ($last_word[0] == '-') { + $word_is_option = TRUE; + $last_word = ltrim($last_word, '-'); + } + + $completions = array(); + + if (!$set_command_name) { + // We have no command yet. + if ($word_is_option) { + // Include global option completions. + $completions += drush_hyphenate_options(drush_complete_match($last_word, 'options')); + } + else { + if (!$set_sitealias_name) { + // Include site alias completions. + $completions += drush_complete_match($last_word, 'site-aliases'); + } + // Include command completions. + $completions += drush_complete_match($last_word, 'command-names'); + } + } + else { + if ($word_is_option) { + // Include command option completions. + $completions += drush_hyphenate_options(drush_complete_match($last_word, 'options', $set_command_name)); + } + else { + // Include command argument completions. + $completions += drush_complete_match($last_word, 'arguments', $set_command_name); + } + } + + // Print the final output. + drush_print(implode("\n", $completions)); + + // We complete execution here, since our work is done, and continuing would + // trigger valid commands occuring later in the bootstrap. + drush_bootstrap_finish(); + exit(1); +} + +/** + * This function resets the raw arguments so that Drush can parse the command as + * if it was run directly. + * + * @return $args + * Array of arguments (argv), excluding the "complete" command. + */ +function drush_complete_process_argv() { + $argv = drush_get_context('argv'); + // Remove the "complete" command (running now), as well as arguments added + // the the "drush" shell script. + $argv = preg_grep("/(drush(\.php)?$|^complete$|^--php)/", $argv, PREG_GREP_INVERT); + array_unshift($argv, 'drush'); + // Reindex the array and perform a partial reparsing. + $argv = array_values($argv); + drush_set_context('argv', $argv); + drush_set_command(NULL); + // Reparse arguments, site alias, and command. + drush_parse_args(); + drush_sitealias_check_arg(); + + return $argv; +} + +/** + * Retrieves the appropriate list of candidate completions, then filters this + * list using the last word that we are trying to complete. + * + * @param string $last_word + * The last word in the argument list (i.e. the subject of completion). + * @param string $type + * The type of completion to be checked (command_name, sitealias etc). + * @param string $command + * An optional command to check, if testing for command specific completions. + * + * @return array + * Array of candidate completions that start with the same characters as the + * last word. If the last word is empty, return all candidates. + */ +function drush_complete_match($last_word, $type, $command = NULL) { + // Using preg_grep appears to be faster that strpos with array_filter/loop. + return preg_grep('/^' . preg_quote($last_word, '/') . '/', drush_complete_get($type, $command)); +} + +/** + * Simple helper function to ensure options are properly hyphenated before we + * return them to the user (we match against the non-hyphenated versions + * internally). + * + * @param array $options + * Array of unhyphenated option names. + * + * @return array + * Array of hyphenated option names. + */ +function drush_hyphenate_options($options) { + foreach ($options as $key => $option) { + $options[$key] = '--' . ltrim($option, '--'); + } + return $options; +} + +/** + * Retrieves from cache, or generates a listing of completion candidates of a + * specific type (and optionally, command). + * + * @param string $type + * String indicating type of completions to return. + * See drush_complete_rebuild() for possible keys. + * @param string $command + * An optional command name if command specific completion is needed. + * + * @return array + * List of candidate completions. + */ +function drush_complete_get($type, $command = NULL) { + if (empty($command)) { + // Retrieve global items from a non-command specific cache, or rebuild cache + // if needed. + $cache = drush_cache_get(drush_complete_cache_cid($type), 'complete'); + if (isset($cache->data)) { + return $cache->data; + } + $complete = drush_complete_rebuild(); + return $complete[$type]; + } + // Retrieve items from a command specific cache. + $cache = drush_cache_get(drush_complete_cache_cid($type, $command), 'complete'); + if (isset($cache->data)) { + return $cache->data; + } + // Build argument cache - built only on demand. + if ($type == 'arguments') { + return drush_complete_rebuild_arguments($command); + } + // Rebuild cache of general command specific items. + $complete = drush_complete_rebuild(); + if (!empty($complete['commands'][$command][$type])) { + return $complete['commands'][$command][$type]; + } + return array(); +} + +/** + * Rebuild and cache completions for everything except command arguments. + * + * @return array + * Structured array of completion types, commands and candidate completions. + */ +function drush_complete_rebuild() { + $complete = array(); + // Generate completion list, skip if cache available. + // Bootstrap to the site level (if possible) - commands may need to check + // the bootstrap level, and perhaps bootstrap higher in extrodinary cases. + drush_bootstrap_max(DRUSH_BOOTSTRAP_DRUPAL_SITE); + $commands = drush_get_commands(); + foreach ($commands as $command_name => $command) { + // Add command options - we don't currently complete on option values (with + // the exception of engine names). + $options = array_keys($command['options']); + // Add engine types and sub-options from engines, if any. + // This could potentially be improved to only show options associated with + // an active engine. + foreach ($command['engines'] as $type => $description) { + $all_engines = drush_get_engines($type); + foreach ($all_engines as $name => $engine) { + $options[] = $name; + if (!empty($engine['sub-options'])) { + foreach ($engine['sub-options'] as $sub_option => $sub_option_values) { + $options = array_merge($options, array_keys($sub_option_values)); + } + } + } + } + $complete['commands'][$command_name]['options'] = $options; + } + // We treat shell aliases as commands for the purposes of completion. + $complete['command-names'] = array_merge(array_keys($commands), array_keys(drush_get_option('shell-aliases', array()))); + $site_aliases = _drush_sitealias_all_list(); + unset($site_aliases['@0']); + $complete['site-aliases'] = array_keys($site_aliases); + $complete['options'] = array_keys(drush_get_global_options()); + + drush_complete_cache_set($complete); + return $complete; +} + +/** + * Rebuild and cache completions for command arguments. + * + */ + +/** + * Rebuild and cache completions for command arguments. + * + * @param string $command + * A specific command to retrieve and cache arguments for. + * + * @return array + * Structured array of candidate completion arguments, keyed by the command. + */ +function drush_complete_rebuild_arguments($command) { + $complete = array(); + // Generate completion list, skip if cache available. + // Bootstrap to the site level (if possible) - commands may need to check + // the bootstrap level, and perhaps bootstrap higher in extrodinary cases. + drush_bootstrap_max(DRUSH_BOOTSTRAP_DRUPAL_SITE); + $commands = drush_get_commands(); + $hook = str_replace("-", "_", $commands[$command]['command-hook']); + // Commandfiles can implement drush_COMMAND_complete() returning + // an array of completions for that command. + $complete['commands'][$command]['arguments'] = drush_command_invoke_all($hook . '_complete'); + drush_complete_cache_set($complete); + return $complete['commands'][$command]['arguments']; +} + +/** + * Stores caches for completions. + * + * @param $complete + * A structured array of completions, keyed by type, including a 'commands' + * type that contains all commands with command specific completions keyed by + * type. The array does not need to include all types - used by + * drush_complete_rebuild_arguments(). + */ +function drush_complete_cache_set($complete) { + foreach ($complete as $type => $values) { + if ($type == 'commands') { + foreach ($values as $command_name => $command) { + foreach ($command as $command_type => $command_values) { + drush_cache_set(drush_complete_cache_cid($command_type, $command_name), $command_values, 'complete', DRUSH_CACHE_TEMPORARY); + } + } + } + else { + drush_cache_set(drush_complete_cache_cid($type), $values, 'complete', DRUSH_CACHE_TEMPORARY); + } + } +} + +/** + * Generate a cache id. + * + * @param $type + * The completion type. + * @param $command + * The command name (optional), if completions are command specific. + * + * @return string + * Cache id. + */ +function drush_complete_cache_cid($type, $command = NULL) { + // Everything is cached per-site, except options + $context = array(); + $root = NULL; + $site = NULL; + if ($type !== 'options') { + // For per-site caches (everything except options), we include the site root + // and uri/path in the cache id hash. These are quick to determine, and + // prevents a bootstrap to site just to get a validated root and URI. + // Because these are not validated, there is the possibility of cache misses + // but they should be rare, since sites are normally referred to the same + // way (e.g. a site alias, or using the current way), at least within a + // single command completion session. + $root = drush_get_option(array('r', 'root'), drush_locate_root()); + $site = drush_get_option(array('l', 'uri'), drush_site_path()); + } + return drush_get_cid('complete', array(), array($type, $command, $root, $site)); +} diff --git a/tests/completeTest.php b/tests/completeTest.php new file mode 100644 index 0000000..8c4d6ea --- /dev/null +++ b/tests/completeTest.php @@ -0,0 +1,82 @@ +setUpDrupal($env); + $root = $this->sites[$env]['root']; + $docroot = 'web'; + // Create a couple of aliases for D7 site to test. + $aliases['complete-dev'] = array( + 'root' => UNISH_SANDBOX . '/' . $docroot, + 'uri' => $env, + ); + // We use the same site, since we don't need a separate one to test aliases. + $aliases['complete-dev-alternate'] = array( + 'root' => UNISH_SANDBOX . '/' . $docroot, + 'uri' => $env, + ); + $contents = $this->file_aliases($aliases); + $alias_path = UNISH_SANDBOX . '/home/.drush/aliases.drushrc.php'; + file_put_contents($alias_path, $contents); + // We copy our test command into our dev site, so we have a difference we + // can detect for cache correctness. + mkdir("$root/sites/$env/modules"); + copy(dirname(__FILE__) . '/unit.drush.inc', "$root/sites/$env/modules/unit.drush.inc"); + + // Test completion generation differentiates site contexts, both on initial + // generation and when cached (checking it is retrieving the correct cache). + $this->verifyComplete('@complete-dev uni', 'uninstall', 'unit-invoke', FALSE); + $this->verifyComplete('uni', '', '', FALSE); + $this->verifyComplete('@complete-dev uni', 'uninstall', 'unit-invoke'); + $this->verifyComplete('uni', '', ''); + + // Test overall context sensitivity - all of these should be cache hits. + // Site alias alone. + $this->verifyComplete('@', '@complete-dev', '@complete-dev-alternate'); + // Command alone. + $this->verifyComplete('d', 'drupal-directory', 'download'); + // Global option alone. + $this->verifyComplete('--n', '--no', '--nocolor'); + // Site alias + command. + $this->verifyComplete('@complete-dev d', 'drupal-directory', 'download'); + // Command + command option. + $this->verifyComplete('dl --', '--destination', '--gitsubmoduleaddparams'); + // Site alias + command + command option. + $this->verifyComplete('@complete-dev dl --', '--destination', '--gitsubmoduleaddparams'); + + // TODO: Tests for arguments (which should generate a cache miss on first + // request, since they are lazy loaded). + } + + /** + * Helper function to call completion and make common checks. + * + * @param $command + * The command line to attempt to complete. + * @param $first + * String indicating the expected first completion suggestion. + * @param $last + * String indicating the expected last completion suggestion. + * @param bool $cache_hit + * Optional parameter, if TRUE or ommitted the debug log is checked to + * ensure a cache hit, if FALSE then a cache miss is checked for. + */ + function verifyComplete($command, $first, $last, $cache_hit = TRUE) { + // We capture debug output to a separate file, so we can check for cache + // hits/misses. + $debug_file = tempnam('/tmp', 'complete-debug'); + // We expect a return code of 1 so just call execute() directly. + $exec = sprintf('%s complete --complete-debug %s 2> %s', UNISH_DRUSH, $command, $debug_file); + $this->execute($exec, self::EXIT_ERROR); + $result = $this->getOutputAsList(); + $this->assertEquals(reset($result), $first); + $this->assertEquals(end($result), $last); + $cache = 'HIT'; + if (!$cache_hit) { + $cache = 'MISS'; + } + $this->assertTrue(strpos(file_get_contents($debug_file), 'Cache ' . $cache . ' cid') !== FALSE); + unlink($debug_file); + } +} \ No newline at end of file