diff --git a/src/Command/CompletionsCommand.php b/src/Command/CompletionsCommand.php new file mode 100644 index 000000000..7b2b4525d --- /dev/null +++ b/src/Command/CompletionsCommand.php @@ -0,0 +1,62 @@ +setName('completions') + ->setDefinition([ + new CodeArgument('target', CodeArgument::OPTIONAL, 'PHP code to complete.'), + ]) + ->setDescription('List possible code completions for the input.') + ->setHelp( + <<<'HELP' +This command enables PsySH wrappers to obtain completions for the current +input, for the purpose of implementing their own completion UI. +HELP + ); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $target = $input->getArgument('target'); + if (!isset($target)) { + $target = ''; + } + + // n.b. All of the relevant parts of \Psy\Shell are protected + // or private, so getTabCompletions() itself is a Shell method. + $completions = $this->getApplication()->getTabCompletions($target); + + // Ouput the completion candidates as newline-separated text. + $str = \implode("\n", \array_filter($completions))."\n"; + $output->write($str, false, OutputInterface::OUTPUT_RAW); + + return 0; + } +} diff --git a/src/Input/CodeArgument.php b/src/Input/CodeArgument.php index a2189af7f..a8cdea93e 100644 --- a/src/Input/CodeArgument.php +++ b/src/Input/CodeArgument.php @@ -26,6 +26,11 @@ * parse function() { return "wheee\n"; } * * ... without having to put the code in a quoted string and escape everything. + * + * Certain trailing whitespace characters are exceptions. Trailing Spaces and + * tabs will be included in the argument value, but trailing newlines, carriage + * returns, vertical tabs, and nulls are trimmed from the command before the + * arguments are established. */ class CodeArgument extends InputArgument { diff --git a/src/Shell.php b/src/Shell.php index de678bbba..e41f6c242 100644 --- a/src/Shell.php +++ b/src/Shell.php @@ -22,6 +22,7 @@ use Psy\Formatter\TraceFormatter; use Psy\Input\ShellInput; use Psy\Input\SilentInput; +use Psy\TabCompletion\AutoCompleter; use Psy\TabCompletion\Matcher; use Psy\VarDumper\PresenterAware; use Symfony\Component\Console\Application; @@ -237,6 +238,24 @@ protected function getTabCompletionMatchers() @\trigger_error('getTabCompletionMatchers is no longer used', \E_USER_DEPRECATED); } + /** + * Get completion matches. + * + * @return array An array of completion matches for $input + */ + public function getTabCompletions(string $input) + { + $ac = $this->autoCompleter; + $word = ''; + $regexp = AutoCompleter::WORD_REGEXP; + $matches = []; + if (\preg_match($regexp, $input, $matches) === 1) { + $word = $matches[0]; + } + + return $ac->processCallback($word, null, ['line_buffer' => $input]); + } + /** * Gets the default command loop listeners. * @@ -909,7 +928,7 @@ protected function runCommand($input) throw new \InvalidArgumentException('Command not found: '.$input); } - $input = new ShellInput(\str_replace('\\', '\\\\', \rtrim($input, " \t\n\r\0\x0B;"))); + $input = new ShellInput(\str_replace('\\', '\\\\', \rtrim($input, "\n\r\0\x0B;"))); if ($input->hasParameterOption(['--help', '-h'])) { $helpCommand = $this->get('help'); diff --git a/src/TabCompletion/AutoCompleter.php b/src/TabCompletion/AutoCompleter.php index 13fcd39ac..edcdc40be 100644 --- a/src/TabCompletion/AutoCompleter.php +++ b/src/TabCompletion/AutoCompleter.php @@ -23,6 +23,84 @@ class AutoCompleter /** @var Matcher\AbstractMatcher[] */ protected $matchers; + /** + * The set of characters which separate completeable 'words', and + * therefore determines the precise $input word for which completion + * candidates should be generated (noting that each candidate must + * begin with the original $input text). + * + * PHP's readline support does not provide any control over the + * characters which constitute a word break for completion purposes, + * which means that we are restricted to the default -- being the + * value of GNU Readline's rl_basic_word_break_characters variable: + * + * The basic list of characters that signal a break between words + * for the completer routine. The default value of this variable + * is the characters which break words for completion in Bash: + * " \t\n\"\\’‘@$><=;|&{(". + * + * This limitation has several ramifications for PHP completion: + * + * 1. The namespace separator '\' introduces a word break, and so + * class name completion is on a per-namespace-component basis. + * When completing a namespaced class, the (incomplete) $input + * parameter (and hence the completion candidates we return) will + * not include the separator or preceding namespace components. + * + * 2. The double-colon (nekudotayim) operator '::' does NOT introduce + * a word break (as ':' is not a word break character), and so the + * $input parameter will include the preceding 'ClassName::' text + * (typically back to, but not including, a space character or + * namespace-separator '\'). Completion candidates for class + * attributes and methods must therefore include this same prefix. + * + * 3. The object operator '->' introduces a word break (as '>' is a + * word break character), so when completing an object attribute + * or method, $input will contain only the text following the + * operator, and therefore (unlike '::') the completion candidates + * we return must NOT include the preceding object and operator. + * + * 4. '$' is a word break character, and so completion for variable + * names does not include the leading '$'. The $input parameter + * contains only the text following the '$' and therefore the + * candidates we return must do likewise... + * + * 5. ...Except when we are returning ALL variables (amongst other + * things) as candidates for completing the empty string '', in + * which case we DO need to include the '$' character in each of + * our candidates, because it was not already present in the text. + * (Note that $input will be '' when we are completing either '' + * or '$', so we need to distinguish between those two cases.) + * + * 6. Only a sub-set of other PHP operators constitute (or end with) + * word breaks, and so inconsistent behaviour can be expected if + * operators are used without surrounding whitespace to ensure a + * word break has occurred. + * + * Operators which DO break words include: '>' '<' '<<' '>>' '<>' + * '=' '==' '===' '!=' '!==' '>=' '<=' '<=>' '->' '|' '||' '&' '&&' + * '+=' '-=' '*=' '/=' '.=' '%=' '&=' '|=' '^=' '>>=' '<<=' '??=' + * + * Operators which do NOT break words include: '!' '+' '-' '*' '/' + * '++' '--' '**' '%' '.' '~' '^' '??' '? :' '::' + * + * E.g.: although "foo()+bar()" is valid PHP, we would be unable + * to use completion to obtain the function name "bar" in that + * situation, as the $input string would actually begin with ")+" + * and the Matcher in question would not be returning candidates + * with that prefix. + * + * @see self::processCallback() + * @see \Psy\Shell::getTabCompletions() + */ + const WORD_BREAK_CHARS = " \t\n\"\\’‘@$><=;|&{("; + + /** + * A regular expression based on WORD_BREAK_CHARS which will match the + * completable word at the end of the string. + */ + const WORD_REGEXP = "/[^ \t\n\"\\\\’‘@$><=;|&{(]*$/"; + /** * Register a tab completion Matcher. * @@ -42,7 +120,13 @@ public function activate() } /** - * Handle readline completion. + * Handle readline completion for the $input parameter (word). + * + * @see WORD_BREAK_CHARS + * + * @TODO: Post-process the completion candidates returned by each + * Matcher to ensure that they use the common prefix established by + * the $input parameter. * * @param string $input Readline current word * @param int $index Current word index @@ -64,11 +148,44 @@ public function processCallback($input, $index, $info = []) $tokens = \token_get_all('matchers as $matcher) { if ($matcher->hasMatched($tokens)) { diff --git a/src/TabCompletion/Matcher/AbstractContextAwareMatcher.php b/src/TabCompletion/Matcher/AbstractContextAwareMatcher.php index f7da443b9..0c748590c 100644 --- a/src/TabCompletion/Matcher/AbstractContextAwareMatcher.php +++ b/src/TabCompletion/Matcher/AbstractContextAwareMatcher.php @@ -56,10 +56,25 @@ protected function getVariable($var) /** * Get all variables in the current Context. * + * The '$' prefix for each variable name is not included by default. + * + * @param bool $dollarPrefix Whether to prefix '$' to each name + * * @return array */ - protected function getVariables() + protected function getVariables($dollarPrefix = false) { - return $this->context->getAll(); + $variables = $this->context->getAll(); + if (!$dollarPrefix) { + return $variables; + } else { + // Add '$' prefix to each name. + $newvars = []; + foreach ($variables as $name => $value) { + $newvars['$'.$name] = $value; + } + + return $newvars; + } } } diff --git a/src/TabCompletion/Matcher/AbstractMatcher.php b/src/TabCompletion/Matcher/AbstractMatcher.php index fe66f5fca..fc3ec1c6e 100644 --- a/src/TabCompletion/Matcher/AbstractMatcher.php +++ b/src/TabCompletion/Matcher/AbstractMatcher.php @@ -45,6 +45,14 @@ abstract class AbstractMatcher /** * Check whether this matcher can provide completions for $tokens. * + * The final token is the 'word' to be completed. If the input + * did not end in a valid identifier prefix then the final token + * will be an empty string. + * + * All whitespace tokens have been removed from the $tokens array. + * + * @see AutoCompleter::processCallback(). + * * @param array $tokens Tokenized readline input * * @return bool @@ -55,21 +63,35 @@ public function hasMatched(array $tokens) } /** - * Get current readline input word. + * Get the input word to be completed, based on the tokenised input. * - * @param array $tokens Tokenized readline input (see token_get_all) + * Note that this may not be identical to the word which readline needs to + * complete (see AutoCompleter::WORD_BREAK_CHARS), and so Matchers must + * take care to return candidate values that match what readline wants. * - * @return string + * We return the string value of the final token if it is valid, and false + * if that token is invalid. By default, the token is valid if it is + * valid prefix (including '') for a PHP identifier. + * + * @param array $tokens Tokenized readline input (see token_get_all) + * @param array|null $validTokens Acceptable tokens + * + * @return string|bool */ - protected function getInput(array $tokens) + protected function getInput(array $tokens, array $validTokens = null) { - $var = ''; - $firstToken = \array_pop($tokens); - if (self::tokenIs($firstToken, self::T_STRING)) { - $var = $firstToken[1]; + $token = \array_pop($tokens); + $input = \is_array($token) ? $token[1] : $token; + + if (isset($validTokens)) { + if (self::hasToken($validTokens, $token)) { + return $input; + } + } elseif (self::tokenIsValidIdentifier($token, true)) { + return $input; } - return $var; + return false; } /** @@ -81,13 +103,22 @@ protected function getInput(array $tokens) */ protected function getNamespaceAndClass($tokens) { - $class = ''; - while (self::hasToken( - [self::T_NS_SEPARATOR, self::T_STRING], - $token = \array_pop($tokens) - )) { + $validTokens = [ + self::T_NS_SEPARATOR, + self::T_STRING, + ]; + + $token = \array_pop($tokens); + if (!self::hasToken($validTokens, $token) + && !self::tokenIsValidIdentifier($token, true) + ) { + return ''; + } + $class = \is_array($token) ? $token[1] : $token; + + while (self::hasToken($validTokens, $token = \array_pop($tokens))) { if (self::needCompleteClass($token)) { - continue; + break; } $class = $token[1].$class; @@ -116,7 +147,7 @@ abstract public function getMatches(array $tokens, array $info = []); */ public static function startsWith($prefix, $word) { - return \preg_match(\sprintf('#^%s#', $prefix), $word); + return \preg_match(\sprintf('#^%s#', \preg_quote($prefix)), $word); } /** @@ -139,7 +170,10 @@ public static function hasSyntax($token, $syntax = self::VAR_SYNTAX) } /** - * Check whether $token type is $which. + * Check whether $token is of type $which. + * + * $which may either be a token type name (e.g. self::T_VARIABLE), + * or a literal string token (e.g. '+'). * * @param mixed $token A PHP token (see token_get_all) * @param string $which A PHP token type @@ -148,11 +182,11 @@ public static function hasSyntax($token, $syntax = self::VAR_SYNTAX) */ public static function tokenIs($token, $which) { - if (!\is_array($token)) { - return false; + if (\is_array($token)) { + $token = \token_name($token[0]); } - return \token_name($token[0]) === $which; + return $token === $which; } /** @@ -164,20 +198,80 @@ public static function tokenIs($token, $which) */ public static function isOperator($token) { - if (!\is_string($token)) { + if (!\is_string($token) || $token === '') { return false; } return \strpos(self::MISC_OPERATORS, $token) !== false; } + /** + * Check whether $token is a valid prefix for a PHP identifier. + * + * @param mixed $token A PHP token (see token_get_all) + * @param bool $allowEmpty Whether an empty string is valid + * + * @return bool + */ + public static function tokenIsValidIdentifier($token, $allowEmpty = false) + { + // See AutoCompleter::processCallback() regarding the '' token. + if ($token === '') { + return $allowEmpty; + } + + return self::hasSyntax($token, self::CONSTANT_SYNTAX); + } + + /** + * Check whether $token 'separates' PHP expressions, meaning that + * whatever follows is a new expression. + * + * Separators include the initial T_OPEN_TAG token, and ";". + * + * @param mixed $token A PHP token (see token_get_all) + * + * @return bool + */ + public static function tokenIsExpressionDelimiter($token) + { + return $token === ';' || self::tokenIs($token, self::T_OPEN_TAG); + } + + /** + * Used both to test $tokens[1] (i.e. following T_OPEN_TAG) to + * see whether it's a PsySH introspection command, and also by + * self::getNamespaceAndClass() to prevent these commands from + * being considered part of the namespace (which could happen + * on account of all the whitespace tokens having been removed + * from the tokens array by AutoCompleter::processCallback(). + */ public static function needCompleteClass($token) { - return \in_array($token[1], ['doc', 'ls', 'show']); + // PsySH introspection commands. + $commands = [ + 'completions', + 'dir', + 'doc', + 'dump', + 'ls', + 'man', + 'parse', + 'rtfm', + 'show', + 'sudo', + 'throw-up', + 'timeit', + ]; + + return \in_array($token[1], $commands); } /** - * Check whether $token type is present in $coll. + * Check whether $token has a type which is present in $coll. + * + * $coll may include a mixture of token type names (e.g. self::T_VARIABLE), + * and literal string tokens (e.g. '+'). * * @param array $coll A list of token types * @param mixed $token A PHP token (see token_get_all) @@ -186,10 +280,10 @@ public static function needCompleteClass($token) */ public static function hasToken(array $coll, $token) { - if (!\is_array($token)) { - return false; + if (\is_array($token)) { + $token = \token_name($token[0]); } - return \in_array(\token_name($token[0]), $coll); + return \in_array($token, $coll, true); } } diff --git a/src/TabCompletion/Matcher/ClassAttributesMatcher.php b/src/TabCompletion/Matcher/ClassAttributesMatcher.php index 5ecd4cf8b..cee37f6a9 100644 --- a/src/TabCompletion/Matcher/ClassAttributesMatcher.php +++ b/src/TabCompletion/Matcher/ClassAttributesMatcher.php @@ -27,14 +27,18 @@ class ClassAttributesMatcher extends AbstractMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); - if (self::tokenIs($firstToken, self::T_STRING)) { - // second token is the nekudotayim operator - \array_pop($tokens); - } + + // Second token is the nekudotayim operator '::'. + \array_pop($tokens); $class = $this->getNamespaceAndClass($tokens); + $chunks = \explode('\\', $class); + $className = \array_pop($chunks); try { $reflection = new \ReflectionClass($class); @@ -52,19 +56,19 @@ function ($var) { \array_keys($reflection->getConstants()) ); + // We have no control over the word-break characters used by + // Readline's completion, and ':' isn't included in that set, + // which means the $input which AutoCompleter::processCallback() + // is completing includes the preceding "ClassName::" text, and + // therefore the candidate strings we are returning must do + // likewise. return \array_map( - function ($name) use ($class) { - $chunks = \explode('\\', $class); - $className = \array_pop($chunks); - + function ($name) use ($className) { return $className.'::'.$name; }, - \array_filter( - $vars, - function ($var) use ($input) { - return AbstractMatcher::startsWith($input, $var); - } - ) + \array_filter($vars, function ($var) use ($input) { + return AbstractMatcher::startsWith($input, $var); + }) ); } @@ -76,10 +80,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '::'. switch (true) { - case self::tokenIs($prevToken, self::T_DOUBLE_COLON) && self::tokenIs($token, self::T_STRING): - case self::tokenIs($token, self::T_DOUBLE_COLON): - return true; + case self::tokenIs($prevToken, self::T_DOUBLE_COLON): + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/ClassMethodDefaultParametersMatcher.php b/src/TabCompletion/Matcher/ClassMethodDefaultParametersMatcher.php index d88cb69b6..d5861c62d 100644 --- a/src/TabCompletion/Matcher/ClassMethodDefaultParametersMatcher.php +++ b/src/TabCompletion/Matcher/ClassMethodDefaultParametersMatcher.php @@ -11,8 +11,16 @@ namespace Psy\TabCompletion\Matcher; +/** + * A class method tab completion Matcher. + * + * This provides completions for all parameters of the specifed method. + */ class ClassMethodDefaultParametersMatcher extends AbstractDefaultParametersMatcher { + /** + * {@inheritdoc} + */ public function getMatches(array $tokens, array $info = []) { $openBracket = \array_pop($tokens); @@ -39,8 +47,12 @@ public function getMatches(array $tokens, array $info = []) return []; } + /** + * {@inheritdoc} + */ public function hasMatched(array $tokens) { + // Valid following '::METHOD('. $openBracket = \array_pop($tokens); if ($openBracket !== '(') { @@ -49,7 +61,7 @@ public function hasMatched(array $tokens) $functionName = \array_pop($tokens); - if (!self::tokenIs($functionName, self::T_STRING)) { + if (!self::tokenIsValidIdentifier($functionName)) { return false; } diff --git a/src/TabCompletion/Matcher/ClassMethodsMatcher.php b/src/TabCompletion/Matcher/ClassMethodsMatcher.php index d980766dd..14ed0774b 100644 --- a/src/TabCompletion/Matcher/ClassMethodsMatcher.php +++ b/src/TabCompletion/Matcher/ClassMethodsMatcher.php @@ -27,14 +27,18 @@ class ClassMethodsMatcher extends AbstractMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); - if (self::tokenIs($firstToken, self::T_STRING)) { - // second token is the nekudotayim operator - \array_pop($tokens); - } + + // Second token is the nekudotayim operator '::'. + \array_pop($tokens); $class = $this->getNamespaceAndClass($tokens); + $chunks = \explode('\\', $class); + $className = \array_pop($chunks); try { $reflection = new \ReflectionClass($class); @@ -52,11 +56,14 @@ public function getMatches(array $tokens, array $info = []) return $method->getName(); }, $methods); + // We have no control over the word-break characters used by + // Readline's completion, and ':' isn't included in that set, + // which means the $input which AutoCompleter::processCallback() + // is completing includes the preceding "ClassName::" text, and + // therefore the candidate strings we are returning must do + // likewise. return \array_map( - function ($name) use ($class) { - $chunks = \explode('\\', $class); - $className = \array_pop($chunks); - + function ($name) use ($className) { return $className.'::'.$name; }, \array_filter($methods, function ($method) use ($input) { @@ -73,10 +80,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '::'. switch (true) { - case self::tokenIs($prevToken, self::T_DOUBLE_COLON) && self::tokenIs($token, self::T_STRING): - case self::tokenIs($token, self::T_DOUBLE_COLON): - return true; + case self::tokenIs($prevToken, self::T_DOUBLE_COLON): + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/ClassNamesMatcher.php b/src/TabCompletion/Matcher/ClassNamesMatcher.php index 7b17830dd..94eed1a0f 100644 --- a/src/TabCompletion/Matcher/ClassNamesMatcher.php +++ b/src/TabCompletion/Matcher/ClassNamesMatcher.php @@ -29,7 +29,6 @@ public function getMatches(array $tokens, array $info = []) if (\strlen($class) > 0 && $class[0] === '\\') { $class = \substr($class, 1, \strlen($class)); } - $quotedClass = \preg_quote($class); return \array_map( function ($className) use ($class) { @@ -41,8 +40,8 @@ function ($className) use ($class) { }, \array_filter( \array_merge(\get_declared_classes(), \get_declared_interfaces()), - function ($className) use ($quotedClass) { - return AbstractMatcher::startsWith($quotedClass, $className); + function ($className) use ($class) { + return AbstractMatcher::startsWith($class, $className); } ) ); @@ -55,20 +54,28 @@ public function hasMatched(array $tokens) { $token = \array_pop($tokens); $prevToken = \array_pop($tokens); - - $blacklistedTokens = [ - self::T_INCLUDE, self::T_INCLUDE_ONCE, self::T_REQUIRE, self::T_REQUIRE_ONCE, + $prevTokenBlacklist = [ + self::T_INCLUDE, + self::T_INCLUDE_ONCE, + self::T_REQUIRE, + self::T_REQUIRE_ONCE, + self::T_OBJECT_OPERATOR, + self::T_DOUBLE_COLON, ]; switch (true) { - case self::hasToken([$blacklistedTokens], $token): - case self::hasToken([$blacklistedTokens], $prevToken): - case \is_string($token) && $token === '$': + // Previous token (blacklist). + case self::hasToken($prevTokenBlacklist, $prevToken): + return false; + // Current token (blacklist). + case $token === '$': return false; - case self::hasToken([self::T_NEW, self::T_OPEN_TAG, self::T_NS_SEPARATOR, self::T_STRING], $prevToken): - case self::hasToken([self::T_NEW, self::T_OPEN_TAG, self::T_NS_SEPARATOR], $token): - case self::hasToken([self::T_OPEN_TAG, self::T_VARIABLE], $token): - case self::isOperator($token): + // Previous token. + case self::tokenIsExpressionDelimiter($prevToken): + case self::hasToken([self::T_NEW, self::T_NS_SEPARATOR], $prevToken): + return self::tokenIsValidIdentifier($token, true); + // Current token (whitelist). + case self::tokenIsValidIdentifier($token, true): return true; } diff --git a/src/TabCompletion/Matcher/CommandsMatcher.php b/src/TabCompletion/Matcher/CommandsMatcher.php index bdeb45d4c..a7d1d79e0 100644 --- a/src/TabCompletion/Matcher/CommandsMatcher.php +++ b/src/TabCompletion/Matcher/CommandsMatcher.php @@ -87,6 +87,9 @@ protected function matchCommand($name) public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } return \array_filter($this->commands, function ($command) use ($input) { return AbstractMatcher::startsWith($input, $command); @@ -101,11 +104,12 @@ public function hasMatched(array $tokens) /* $openTag */ \array_shift($tokens); $command = \array_shift($tokens); + // Valid for completion only if this was the only token. switch (true) { - case self::tokenIs($command, self::T_STRING) && - !$this->isCommand($command[1]) && - $this->matchCommand($command[1]) && - empty($tokens): + case empty($command): + case empty($tokens) && + self::tokenIsValidIdentifier($command, true) && + $this->matchCommand($command[1]): return true; } diff --git a/src/TabCompletion/Matcher/ConstantsMatcher.php b/src/TabCompletion/Matcher/ConstantsMatcher.php index 178adf8c2..4703d48f0 100644 --- a/src/TabCompletion/Matcher/ConstantsMatcher.php +++ b/src/TabCompletion/Matcher/ConstantsMatcher.php @@ -25,10 +25,13 @@ class ConstantsMatcher extends AbstractMatcher */ public function getMatches(array $tokens, array $info = []) { - $const = $this->getInput($tokens); + $input = $this->getInput($tokens); + if ($input === false) { + return []; + } - return \array_filter(\array_keys(\get_defined_constants()), function ($constant) use ($const) { - return AbstractMatcher::startsWith($const, $constant); + return \array_filter(\array_keys(\get_defined_constants()), function ($constant) use ($input) { + return AbstractMatcher::startsWith($input, $constant); }); } @@ -39,13 +42,19 @@ public function hasMatched(array $tokens) { $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + $prevTokenBlacklist = [ + self::T_NEW, + self::T_NS_SEPARATOR, + self::T_OBJECT_OPERATOR, + self::T_DOUBLE_COLON, + ]; switch (true) { - case self::tokenIs($prevToken, self::T_NEW): - case self::tokenIs($prevToken, self::T_NS_SEPARATOR): + // Previous token (blacklist). + case self::hasToken($prevTokenBlacklist, $prevToken): return false; - case self::hasToken([self::T_OPEN_TAG, self::T_STRING], $token): - case self::isOperator($token): + // Current token (whitelist). + case self::tokenIsValidIdentifier($token, true): return true; } diff --git a/src/TabCompletion/Matcher/FunctionDefaultParametersMatcher.php b/src/TabCompletion/Matcher/FunctionDefaultParametersMatcher.php index e1277c2ee..2f5f820b3 100644 --- a/src/TabCompletion/Matcher/FunctionDefaultParametersMatcher.php +++ b/src/TabCompletion/Matcher/FunctionDefaultParametersMatcher.php @@ -11,8 +11,16 @@ namespace Psy\TabCompletion\Matcher; +/** + * A function parameter tab completion Matcher. + * + * This provides completions for all parameters of the specifed function. + */ class FunctionDefaultParametersMatcher extends AbstractDefaultParametersMatcher { + /** + * {@inheritdoc} + */ public function getMatches(array $tokens, array $info = []) { \array_pop($tokens); // open bracket @@ -30,8 +38,12 @@ public function getMatches(array $tokens, array $info = []) return $this->getDefaultParameterCompletion($parameters); } + /** + * {@inheritdoc} + */ public function hasMatched(array $tokens) { + // Valid following 'FUNCTION('. $openBracket = \array_pop($tokens); if ($openBracket !== '(') { @@ -40,7 +52,7 @@ public function hasMatched(array $tokens) $functionName = \array_pop($tokens); - if (!self::tokenIs($functionName, self::T_STRING)) { + if (!self::tokenIsValidIdentifier($functionName)) { return false; } diff --git a/src/TabCompletion/Matcher/FunctionsMatcher.php b/src/TabCompletion/Matcher/FunctionsMatcher.php index 1f6e6dbb5..a4b0d9f8e 100644 --- a/src/TabCompletion/Matcher/FunctionsMatcher.php +++ b/src/TabCompletion/Matcher/FunctionsMatcher.php @@ -25,13 +25,16 @@ class FunctionsMatcher extends AbstractMatcher */ public function getMatches(array $tokens, array $info = []) { - $func = $this->getInput($tokens); + $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $functions = \get_defined_functions(); $allFunctions = \array_merge($functions['user'], $functions['internal']); - return \array_filter($allFunctions, function ($function) use ($func) { - return AbstractMatcher::startsWith($func, $function); + return \array_filter($allFunctions, function ($function) use ($input) { + return AbstractMatcher::startsWith($input, $function); }); } @@ -42,12 +45,19 @@ public function hasMatched(array $tokens) { $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + $prevTokenBlacklist = [ + self::T_NEW, + self::T_NS_SEPARATOR, + self::T_OBJECT_OPERATOR, + self::T_DOUBLE_COLON, + ]; switch (true) { - case self::tokenIs($prevToken, self::T_NEW): + // Previous token (blacklist). + case self::hasToken($prevTokenBlacklist, $prevToken): return false; - case self::hasToken([self::T_OPEN_TAG, self::T_STRING], $token): - case self::isOperator($token): + // Current token (whitelist). + case self::tokenIsValidIdentifier($token, true): return true; } diff --git a/src/TabCompletion/Matcher/KeywordsMatcher.php b/src/TabCompletion/Matcher/KeywordsMatcher.php index 393674c62..bf8657d5e 100644 --- a/src/TabCompletion/Matcher/KeywordsMatcher.php +++ b/src/TabCompletion/Matcher/KeywordsMatcher.php @@ -57,6 +57,9 @@ public function isKeyword($keyword) public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } return \array_filter($this->keywords, function ($keyword) use ($input) { return AbstractMatcher::startsWith($input, $keyword); @@ -70,13 +73,22 @@ public function hasMatched(array $tokens) { $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + $prevTokenBlacklist = [ + self::T_NEW, + self::T_NS_SEPARATOR, + self::T_OBJECT_OPERATOR, + self::T_DOUBLE_COLON, + ]; switch (true) { - case self::hasToken([self::T_OPEN_TAG, self::T_VARIABLE], $token): -// case is_string($token) && $token === '$': - case self::hasToken([self::T_OPEN_TAG, self::T_VARIABLE], $prevToken) && - self::tokenIs($token, self::T_STRING): - case self::isOperator($token): + // Previous token (blacklist). + case self::hasToken($prevTokenBlacklist, $prevToken): + return false; + // Previous token. + case self::tokenIsExpressionDelimiter($prevToken): + return self::tokenIsValidIdentifier($token, true); + // Current token (whitelist). + case self::tokenIsValidIdentifier($token, true): return true; } diff --git a/src/TabCompletion/Matcher/MongoClientMatcher.php b/src/TabCompletion/Matcher/MongoClientMatcher.php index f38836d02..e65877c08 100644 --- a/src/TabCompletion/Matcher/MongoClientMatcher.php +++ b/src/TabCompletion/Matcher/MongoClientMatcher.php @@ -26,6 +26,9 @@ class MongoClientMatcher extends AbstractContextAwareMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); if (self::tokenIs($firstToken, self::T_STRING)) { @@ -60,10 +63,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '->'. switch (true) { - case self::tokenIs($token, self::T_OBJECT_OPERATOR): case self::tokenIs($prevToken, self::T_OBJECT_OPERATOR): - return true; + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/MongoDatabaseMatcher.php b/src/TabCompletion/Matcher/MongoDatabaseMatcher.php index a0d4d49c7..458174df8 100644 --- a/src/TabCompletion/Matcher/MongoDatabaseMatcher.php +++ b/src/TabCompletion/Matcher/MongoDatabaseMatcher.php @@ -26,6 +26,9 @@ class MongoDatabaseMatcher extends AbstractContextAwareMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); if (self::tokenIs($firstToken, self::T_STRING)) { @@ -56,10 +59,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '->'. switch (true) { - case self::tokenIs($token, self::T_OBJECT_OPERATOR): case self::tokenIs($prevToken, self::T_OBJECT_OPERATOR): - return true; + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/ObjectAttributesMatcher.php b/src/TabCompletion/Matcher/ObjectAttributesMatcher.php index 8b3d29057..6adae7035 100644 --- a/src/TabCompletion/Matcher/ObjectAttributesMatcher.php +++ b/src/TabCompletion/Matcher/ObjectAttributesMatcher.php @@ -29,12 +29,15 @@ class ObjectAttributesMatcher extends AbstractContextAwareMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); - if (self::tokenIs($firstToken, self::T_STRING)) { - // second token is the object operator - \array_pop($tokens); - } + + // Second token is the object operator '->'. + \array_pop($tokens); + $objectToken = \array_pop($tokens); if (!\is_array($objectToken)) { return []; @@ -67,10 +70,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '->'. switch (true) { - case self::tokenIs($token, self::T_OBJECT_OPERATOR): case self::tokenIs($prevToken, self::T_OBJECT_OPERATOR): - return true; + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/ObjectMethodDefaultParametersMatcher.php b/src/TabCompletion/Matcher/ObjectMethodDefaultParametersMatcher.php index 08bab8bc9..d75a84260 100644 --- a/src/TabCompletion/Matcher/ObjectMethodDefaultParametersMatcher.php +++ b/src/TabCompletion/Matcher/ObjectMethodDefaultParametersMatcher.php @@ -11,8 +11,16 @@ namespace Psy\TabCompletion\Matcher; +/** + * An object method parameter tab completion Matcher. + * + * This provides completions for all parameters of the specifed method. + */ class ObjectMethodDefaultParametersMatcher extends AbstractDefaultParametersMatcher { + /** + * {@inheritdoc} + */ public function getMatches(array $tokens, array $info = []) { $openBracket = \array_pop($tokens); @@ -46,8 +54,12 @@ public function getMatches(array $tokens, array $info = []) return []; } + /** + * {@inheritdoc} + */ public function hasMatched(array $tokens) { + // Valid following '->METHOD('. $openBracket = \array_pop($tokens); if ($openBracket !== '(') { @@ -56,7 +68,7 @@ public function hasMatched(array $tokens) $functionName = \array_pop($tokens); - if (!self::tokenIs($functionName, self::T_STRING)) { + if (!self::tokenIsValidIdentifier($functionName)) { return false; } diff --git a/src/TabCompletion/Matcher/ObjectMethodsMatcher.php b/src/TabCompletion/Matcher/ObjectMethodsMatcher.php index 2a1d22407..ee26a36f7 100644 --- a/src/TabCompletion/Matcher/ObjectMethodsMatcher.php +++ b/src/TabCompletion/Matcher/ObjectMethodsMatcher.php @@ -29,12 +29,15 @@ class ObjectMethodsMatcher extends AbstractContextAwareMatcher public function getMatches(array $tokens, array $info = []) { $input = $this->getInput($tokens); + if ($input === false) { + return []; + } $firstToken = \array_pop($tokens); - if (self::tokenIs($firstToken, self::T_STRING)) { - // second token is the object operator - \array_pop($tokens); - } + + // Second token is the object operator '->'. + \array_pop($tokens); + $objectToken = \array_pop($tokens); if (!\is_array($objectToken)) { return []; @@ -69,10 +72,10 @@ public function hasMatched(array $tokens) $token = \array_pop($tokens); $prevToken = \array_pop($tokens); + // Valid following '->'. switch (true) { - case self::tokenIs($token, self::T_OBJECT_OPERATOR): case self::tokenIs($prevToken, self::T_OBJECT_OPERATOR): - return true; + return self::tokenIsValidIdentifier($token, true); } return false; diff --git a/src/TabCompletion/Matcher/VariablesMatcher.php b/src/TabCompletion/Matcher/VariablesMatcher.php index f2438d3d1..9f0949ed1 100644 --- a/src/TabCompletion/Matcher/VariablesMatcher.php +++ b/src/TabCompletion/Matcher/VariablesMatcher.php @@ -20,16 +20,42 @@ */ class VariablesMatcher extends AbstractContextAwareMatcher { + /** + * {@inheritdoc} + */ + protected function getInput(array $tokens, array $t_valid = null) + { + return parent::getInput($tokens, [self::T_VARIABLE, '$', '']); + } + /** * {@inheritdoc} */ public function getMatches(array $tokens, array $info = []) { - $var = \str_replace('$', '', $this->getInput($tokens)); + $input = $this->getInput($tokens); + if ($input === false) { + return []; + } + + // '$' is a readline completion word-break character (refer to + // AutoCompleter::WORD_BREAK_CHARS), and so the completion + // candidates we generate must not include the leading '$' -- + // *unless* we are completing an empty string, in which case + // the '$' is required. + if ($input === '') { + $dollarPrefix = true; + } else { + $dollarPrefix = false; + $input = \str_replace('$', '', $input); + } - return \array_filter(\array_keys($this->getVariables()), function ($variable) use ($var) { - return AbstractMatcher::startsWith($var, $variable); - }); + return \array_filter( + \array_keys($this->getVariables($dollarPrefix)), + function ($variable) use ($input) { + return AbstractMatcher::startsWith($input, $variable); + } + ); } /** @@ -38,11 +64,21 @@ public function getMatches(array $tokens, array $info = []) public function hasMatched(array $tokens) { $token = \array_pop($tokens); + $prevToken = \array_pop($tokens); + $prevTokenBlacklist = [ + self::T_NEW, + self::T_NS_SEPARATOR, + self::T_OBJECT_OPERATOR, + self::T_DOUBLE_COLON, + ]; switch (true) { - case self::hasToken([self::T_OPEN_TAG, self::T_VARIABLE], $token): - case \is_string($token) && $token === '$': - case self::isOperator($token): + // Previous token (blacklist). + case self::hasToken($prevTokenBlacklist, $prevToken): + return false; + // Current token (whitelist). + case self::tokenIs($token, self::T_VARIABLE): + case \in_array($token, ['', '$'], true): return true; }