diff --git a/commands/core/archive.drush.inc b/commands/core/archive.drush.inc index dd2cbac..7b3465e 100644 --- a/commands/core/archive.drush.inc +++ b/commands/core/archive.drush.inc @@ -235,6 +235,16 @@ function drush_archive_dump($sites_subdirs = '@self') { } /** + * Command argument complete callback. + * + * @return + * List of site names/aliases for archival. + */ +function archive_archive_dump_complete() { + return array('values' => array_keys(_drush_sitealias_all_list())); +} + +/** * Command callback. Restore web site(s) from a site archive file. */ function drush_archive_restore($file, $site_id = NULL) { @@ -316,3 +326,24 @@ function drush_archive_restore($file, $site_id = NULL) { } return $destination; } + + +/** + * Command argument complete callback. + * + * @return + * Strong glob of files to complete on. + */ +function archive_archive_restore_complete() { + return array( + 'files' => array( + 'directories' => array( + 'pattern' => '*', + 'flags' => GLOB_ONLYDIR, + ), + 'tar' => array( + 'pattern' => '*.tar.gz', + ), + ), + ); +} diff --git a/commands/core/core.drush.inc b/commands/core/core.drush.inc index 676d447..fc8ab9d 100644 --- a/commands/core/core.drush.inc +++ b/commands/core/core.drush.inc @@ -535,18 +535,7 @@ function drush_core_cron() { * Command callback. Edit drushrc and alias files. */ function drush_core_config($filter = NULL) { - $rcs = $aliases = $drupal = array(); - drush_sitealias_load_all(); - if ($rcs = drush_get_context_options('context-path', TRUE)) { - $rcs = array_merge(array('drushrc' => '-- Drushrc --'), $rcs); - } - if ($aliases = drush_get_context('drush-alias-files')) { - $aliases = array_merge(array('aliases' => '-- Aliases --'), $aliases); - } - if ($site_root = drush_get_context('DRUSH_DRUPAL_SITE_ROOT')) { - $drupal = array_merge(array('drupal' => '-- Drupal --'), array(realpath($site_root . '/settings.php'), realpath(DRUPAL_ROOT . '/.htaccess'))); - } - $all = array_merge($rcs, $aliases, $drupal); + $all = drush_core_config_load(); // Apply any filter that was supplied. if ($filter) { @@ -570,6 +559,38 @@ function drush_core_config($filter = NULL) { } /** + * Command argument complete callback. + * + * @return + * Array of available configuration files for editing. + */ +function core_core_config_complete() { + return array('values' => drush_core_config_load(FALSE)); +} + +function drush_core_config_load($headers = TRUE) { + $rcs_header = $rcs = $aliases_header = $aliases = $drupal_header = $drupal = array(); + drush_sitealias_load_all(); + if ($rcs = drush_get_context_options('context-path', TRUE)) { + if ($headers) { + $rcs_header = array('drushrc' => '-- Drushrc --'); + } + } + if ($aliases = drush_get_context('drush-alias-files')) { + if ($headers) { + $aliases_header = array('aliases' => '-- Aliases --'); + } + } + if ($site_root = drush_get_context('DRUSH_DRUPAL_SITE_ROOT')) { + $drupal = array_merge(array(realpath($site_root . '/settings.php'), realpath(DRUPAL_ROOT . '/.htaccess'))); + if ($headers) { + $drupal_header = array('drupal' => '-- Drupal --'); + } + } + return array_merge($rcs_header, $rcs, $aliases_header, $aliases, $drupal_header, $drupal); +} + +/** * Command callback. Provides a birds-eye view of the current Drupal * installation. */ diff --git a/commands/core/topic.drush.inc b/commands/core/topic.drush.inc index cda52fe..e4c3d6e 100644 --- a/commands/core/topic.drush.inc +++ b/commands/core/topic.drush.inc @@ -72,6 +72,16 @@ function drush_topic_core_topic($topic_name = NULL) { } /** + * A command argument complete callback. + * + * @return + * Available topic keys. + */ +function topic_core_topic_complete() { + return array('values' => array_keys(drush_get_topics())); +} + +/** * Retrieve all defined topics */ function drush_get_topics() { diff --git a/drush.complete.sh b/drush.complete.sh new file mode 100644 index 0000000..0072e25 --- /dev/null +++ b/drush.complete.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# BASH completion script for Drush. +# +# Place this in your /etc/bash_completion.d/ directory or source it from your +# ~/.bash_completion file. + +# Ensure drush is available. +have drush || return + +# Completion function, uses the "drush complete" command to retrieve +# completions for a specific command line COMP_WORDS. +_drush_completion() { + # Set IFS to newline (locally), since we only use newline separators, and + # need to retain spaces (or not) after completions. + local IFS=$'\n' + # 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 -o nospace -F _drush_completion d dr drush drush.php diff --git a/includes/bootstrap.inc b/includes/bootstrap.inc index 07abe16..e75e6e0 100644 --- a/includes/bootstrap.inc +++ b/includes/bootstrap.inc @@ -440,9 +440,6 @@ function _drush_bootstrap_drush() { // Statically define a way to call drush again. define('DRUSH_COMMAND', drush_find_drush()); - $drush_info = drush_read_drush_info(); - define('DRUSH_VERSION', $drush_info['drush_version']); - // prime the CWD cache drush_cwd(); @@ -895,6 +892,7 @@ function drush_bootstrap_prepare() { require_once DRUSH_BASE_PATH . '/includes/cache.inc'; require_once DRUSH_BASE_PATH . '/includes/filesystem.inc'; require_once DRUSH_BASE_PATH . '/includes/dbtng.inc'; + require_once DRUSH_BASE_PATH . '/includes/complete.inc'; // Terminate immediately unless invoked as a command line script if (!drush_verify_cli()) { @@ -907,6 +905,9 @@ function drush_bootstrap_prepare() { die('Your command line PHP installation is too old. Drush requires at least PHP ' . DRUSH_MINIMUM_PHP . "\n"); } + $drush_info = drush_read_drush_info(); + define('DRUSH_VERSION', $drush_info['drush_version']); + define('DRUSH_REQUEST_TIME', microtime(TRUE)); drush_set_context('argc', $GLOBALS['argc']); @@ -918,8 +919,16 @@ 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') { + drush_complete(); + } + + // Process early global options such as --debug. _drush_bootstrap_global_options(); } diff --git a/includes/cache.inc b/includes/cache.inc index 717615b..0119b9d 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 f28d73c..6b5e256 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..b9f3c82 --- /dev/null +++ b/includes/complete.inc @@ -0,0 +1,559 @@ +. 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. + * + * For argument completions, commandfiles can implement + * COMMANDFILE_COMMAND_complete() returning an array of argument completions for + * that command. For example, return array('aardvark', 'aardwolf') will offer + * the words 'aardvark' and 'aardwolf', or will complete to 'aardwolf' if the + * letters 'aardw' are already present. Since command arguments are cached, + * commandfiles can bootstrap a site or perform other somewhat time consuming + * activities to retrieve the list of possible arguments. Commands can also + * clear the cache (or just the "arguments" cache for their command) when they + * the list of arguments has likely changed - see drush_complete_cache_clear(). + * + * Commandfiles can also return a special optional element in their array with + * the key 'files' that contains an array of patterns/flags for the glob() + * function. These are used to produce file and directory completions (the + * results of these are not cached, since this is a fast operation). + * See http://php.net/glob for details of valid patterns and flags. + * For example the following will complete the command arguments on all + * directories, as well as files ending in tar.gz: + * return array( + * 'files' => array( + * 'directories' => array( + * 'pattern' => '*', + * 'flags' => GLOB_ONLYDIR, + * ), + * 'tar' => array( + * 'pattern' => '*.tar.gz', + * ), + * ), + * ); + */ + +/** + * 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. + drush_set_option('debug', FALSE); + 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 have 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(); + $set_command_name = NULL; + if (isset($arguments[0]) && in_array($arguments[0] . ' ', drush_complete_get('command-names'))) { + $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)) { + $set_command_name = NULL; + } + + // Determine the word we are trying to complete, and if it is an option. + $last_word = end($argv); + $word_is_option = FALSE; + if (!empty($last_word) && $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, drush_complete_get('options'))); + } + else { + if (!$set_sitealias_name) { + // Include site alias completions. + $completions += drush_complete_match($last_word, drush_complete_get('site-aliases')); + } + // Include command completions. + $completions += drush_complete_match($last_word, drush_complete_get('command-names')); + } + } + else { + if ($last_word == $set_command_name) { + // The user typed a valid command name, include in in the completion list + // so they get a space inserted, confirming it is valid. + $completions[] = $set_command_name; + } + else if ($word_is_option) { + // Include command option completions. + $completions += drush_hyphenate_options(drush_complete_match($last_word, drush_complete_get('options', $set_command_name))); + } + else { + // Include command argument completions. + $argument_completion = drush_complete_get('arguments', $set_command_name); + if (isset($argument_completion['values'])) { + $completions += drush_complete_match($last_word, $argument_completion['values']); + } + if (isset($argument_completion['files'])) { + $completions += drush_complete_match_file($last_word, $argument_completion['files']); + } + } + } + + // Print the final output. + if (!empty($completions)) { + drush_print(implode("\n", $completions)); + } + + // We complete execution here, since our work is done, and continuing would + // trigger valid commands occurring 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. + * + * When using a complete script (i.e. pressing tab) the script passes in the + * entire command line as an argument, so argv looks something like this: + * array ( + * '0' => '/home/owen/workspace/drush/drush.php', + * '1' => 'complete', + * '2' => 'drush', + * '3' => 'COMMAND', + * '4' => '', + * '5' => '--php=/usr/bin/php', + * ); + * + * When calling "complete" manually (for testing) the script passes in the + * arguments more normally (although including the "complete" command, and not + * including any empty last argument), so argv looks like this: + * array ( + * '0' => '/home/owen/workspace/drush/drush.php', + * '1' => 'complete', + * '2' => 'COMMAND', + * '3' => '--php=/usr/bin/php', + * ); + * + * @return $args + * Array of arguments (argv), excluding the "complete" command: + * array ( + * '0' => 'drush', + * '1' => 'COMMAND', + * '2' => '', + * ); + */ +function drush_complete_process_argv() { + $argv = drush_get_context('argv'); + // Remove everything up to and including the "complete" command (running now). + $argv = array_slice($argv, array_search('complete', $argv) + 1); + // Remove the --php option at the end if exists (added by the "drush" shell + // script that is called when completion is requested). + if (substr(end($argv), 0, 6) == '--php=') { + array_pop($argv); + } + // Ensure the initial argument is "drush" so that drush_parse_args() works + // correctly. This is only neccessary when calling "complete" manually. + if (strpos(reset($argv), 'drush') === FALSE) { + array_unshift($argv, 'drush'); + } + drush_set_context('argv', $argv); + drush_set_command(NULL); + // Reparse arguments, site alias, and command. + drush_parse_args(); + drush_sitealias_check_arg(); + + // Return the new argv for easy reference. + 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 array $values + * Array of possible completion values to filter. + * + * @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, $values) { + // Using preg_grep appears to be faster that strpos with array_filter/loop. + return preg_grep('/^' . preg_quote($last_word, '/') . '/', $values); +} + +/** + * Retrieves the appropriate list of candidate file/directory completions, + * filtered by 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 array $files + * Array of file specs, each with a pattern and flags subarray. + * + * @return array + * Array of candidate file/directory completions that start with the same + * characters as the last word. If the last word is empty, return all + * candidates. + */ +function drush_complete_match_file($last_word, $files) { + $return = array(); + $firstchar = ''; + $full_paths = TRUE; + if (isset($last_word) && $last_word[0] == '~') { + // Complete does not do tilde expansion, so we do it here. + $parts = explode('/', $last_word); + // We shell out (unquoted) to expand the tilde. + drush_shell_exec('echo ' . $parts[0]); + $output = drush_shell_exec_output(); + $parts[0] = $output[0]; + $last_word = implode('/', $parts); + } + foreach ($files as $spec) { + // We always include GLOB_MARK, as an easy way to detect directories. + $flags = GLOB_MARK; + if (isset($spec['flags'])) { + $flags = $spec['flags'] | GLOB_MARK; + } + $listing = glob($last_word . $spec['pattern'], $flags); + foreach ($listing as $item) { + // Detect if the initial characters of the file/dirs to be listing differ. + // If they do, we return a list of just their names. If they all have the + // same first character we return full paths, to prevent the shell + // replacing the current path with just the matching character(s). + $char = $item[strrpos($last_word, '/') + 1]; + if (empty($firstchar)) { + $firstchar = $char; + } + else if ($firstchar !== $char) { + $full_paths = FALSE; + } + $return[] = $item; + } + } + // If we don't need to return full paths, shorten them appropriately. + if ($full_paths == FALSE) { + foreach ($return as $id => $item) { + $return[$id] = substr($return[$id], strrpos($last_word, '/') + 1); + } + } + // If we are returning a single item (which will become part of the final + // command), we need to use the full path, and we need to escape it + // appropriately. + if (count($return) == 1) { + // Escape shell metacharacters (we don't use escapeshellarg as it + // single quotes everything, even when unnecessary). + $item = preg_replace('/[ |&;()<>]/', "\\\\$0", $item); + if (substr($item, -1) !== '/') { + // Insert a space after files, since the argument is complete. + $item = $item . ' '; + } + $return = array($item); + } + return $return; +} + +/** + * 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(); + // Bootstrap to the site level (if possible) - commands may need to check + // the bootstrap level, and perhaps bootstrap higher in extraordinary 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(); + // TODO: Figure out where this dummy @0 alias is introduced. + unset($site_aliases['@0']); + $complete['site-aliases'] = array_keys($site_aliases); + $complete['options'] = array_keys(drush_get_global_options()); + + // We add a space following all completes. Eventually there may be some + // items (e.g. options that we know need values) where we don't add a space. + array_walk_recursive($complete, 'drush_complete_trailing_space'); + drush_complete_cache_set($complete); + return $complete; +} + +/** + * Helper callback function that adds a trailing space to completes in an array. + */ +function drush_complete_trailing_space(&$item, $key) { + if (!is_array($item)) { + $item = (string)$item . ' '; + } +} + +/** + * 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) { + // Bootstrap to the site level (if possible) - commands may need to check + // the bootstrap level, and perhaps bootstrap higher in extraordinary cases. + drush_bootstrap_max(DRUSH_BOOTSTRAP_DRUPAL_SITE); + $commands = drush_get_commands(); + $hook = str_replace("-", "_", $commands[$command]['command-hook']); + $result = drush_command_invoke_all($hook . '_complete'); + if (isset($result['values'])) { + // We add a space following all completes. Eventually there may be some + // items (e.g. comma separated arguments) where we don't add a space. + array_walk($result['values'], 'drush_complete_trailing_space'); + } + + $complete = array( + 'commands' => array( + $command => array( + 'arguments' => $result, + ) + ) + ); + 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); + } + } +} + +/** + * Clears completion caches. + * + * If called with no parameters the entire complete cache will be cleared. + * If called with just the $type parameter the global cache for that type will + * be cleared (in the site context, if any). If called with both $type and + * $command parameters the command cache of that type will be cleared (in the + * site context, if any). + * + * @param $type + * The completion type (optional). + * @param $command + * The command name (optional), if command specific cache is to be cleared. + * If specifying a command, $type is not optional. + */ +function drush_complete_cache_clear($type = NULL, $command = NULL) { + if ($type) { + drush_cache_clear_all(drush_complete_cache_cid($type, $command), 'complete'); + return; + } + // No type or command, so clear the entire complete cache. + drush_cache_clear_all('*', 'complete', TRUE); +} + +/** + * 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 global options. + $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..3413f46 --- /dev/null +++ b/tests/completeTest.php @@ -0,0 +1,101 @@ +setUpDrupal(2); + $env = key($sites); + $root = $this->webroot(); + // We copy our test command into our dev site, so we have a difference we + // can detect for cache correctness. We cannot use --include since complete + // deliberately avoids drush command dispatch. + mkdir("$root/sites/$env/modules"); + copy(dirname(__FILE__) . '/unit.drush.inc', "$root/sites/$env/modules/unit.drush.inc"); + // Clear the cache, so it finds our test command. + $this->drush('php-eval', array('drush_cache_clear_all();'), array(), '@' . $env); + + // Create a sample directory and file to test file/directory completion. + mkdir("aardvark"); + touch('aard wolf.tar.gz'); + + // Create directory for temporary debug logs. + mkdir(UNISH_SANDBOX . '/complete-debug'); + + // Test cache clearing for global cache, which should affect all + // environments. + $this->drush('php-eval', array('drush_complete_cache_clear();')); + $this->verifyComplete('@dev uni', 'uninstall', 'unit-invoke', FALSE); + $this->verifyComplete('uni', 'uninstall', 'uninstall', FALSE); + $this->verifyComplete('@dev uni', 'uninstall', 'unit-invoke'); + $this->verifyComplete('uni', 'uninstall', 'uninstall'); + // Test cache clearing for a completion type, which should be effective + // only for current environment. + $this->drush('php-eval', array('drush_complete_cache_clear("command-names");')); + $this->verifyComplete('@dev uni', 'uninstall', 'unit-invoke'); + $this->verifyComplete('uni', 'uninstall', 'uninstall', FALSE); + // Test cache clearing for a command specific completion type, which should + // be effective only for current environment. Prime caches first. + $this->verifyComplete('@dev topic docs-c', 'docs-configuration', 'docs-context'); + $this->verifyComplete('topic docs-c', 'docs-configuration', 'docs-context'); + $this->drush('php-eval', array('drush_complete_cache_clear("arguments", "topic");')); + $this->verifyComplete('@dev topic docs-c', 'docs-configuration', 'docs-context'); + $this->verifyComplete('topic docs-c', 'docs-configuration', 'docs-context', FALSE); + + // Test overall context sensitivity - all of these should be cache hits. + // Site alias alone. + $this->verifyComplete('@', '@test-complete', '@complete-dev'); + // Command alone. + $this->verifyComplete('d', 'drupal-directory', 'download'); + // Command with single result. + $this->verifyComplete('core-t', 'core-topic', 'core-topic'); + // Global option alone. + $this->verifyComplete('--n', '--no', '--nocolor'); + // Site alias + command. + $this->verifyComplete('@dev d', 'drupal-directory', 'download'); + // Site alias + command, should allow no further site aliases or commands. + $this->verifyComplete('@dev topic @', '', ''); + $this->verifyComplete('@dev topic topi', '', ''); + // Command + command option. + $this->verifyComplete('dl --', '--destination', '--gitsubmoduleaddparams'); + // Site alias + command + command option. + $this->verifyComplete('@dev dl --', '--destination', '--gitsubmoduleaddparams'); + // Command + argument. + $this->verifyComplete('topic docs-c', 'docs-configuration', 'docs-context'); + // Site alias + command + regular argument. + $this->verifyComplete('@dev topic docs-c', 'docs-configuration', 'docs-context'); + // Site alias + command + file/directory argument. + $this->verifyComplete('archive-restore aard', 'aardvark/', 'aard wolf.tar.gz'); + // Site alias + command + file/directory argument with quoting. + $this->verifyComplete('archive-restore aard\ w', 'aard\ wolf.tar.gz', 'aard\ wolf.tar.gz'); + } + + /** + * 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(UNISH_SANDBOX . '/complete-debug', '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