Обновление клиента (apps, 3rdparty, install)

This commit is contained in:
root
2026-03-16 08:42:57 +00:00
parent b8905de237
commit f390426546
3354 changed files with 505213 additions and 3 deletions
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Stephen Holdaway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,180 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface;
class Completion implements CompletionInterface
{
/**
* The type of input (option/argument) the completion should be run for
*
* @see CompletionInterface::ALL_TYPES
* @var string
*/
protected $type;
/**
* The command name the completion should be run for
*
* @see CompletionInterface::ALL_COMMANDS
* @var string|null
*/
protected $commandName;
/**
* The option/argument name the completion should be run for
*
* @var string
*/
protected $targetName;
/**
* Array of values to return, or a callback to generate completion results with
* The callback can be in any form accepted by call_user_func.
*
* @var callable|array
*/
protected $completion;
/**
* Create a Completion with the command name set to CompletionInterface::ALL_COMMANDS
*
* @deprecated - This will be removed in 1.0.0 as it is redundant and isn't any more concise than what it implements.
*
* @param string $targetName
* @param string $type
* @param array|callable $completion
* @return Completion
*/
public static function makeGlobalHandler($targetName, $type, $completion)
{
return new Completion(CompletionInterface::ALL_COMMANDS, $targetName, $type, $completion);
}
/**
* @param string $commandName
* @param string $targetName
* @param string $type
* @param array|callable $completion
*/
public function __construct($commandName, $targetName, $type, $completion)
{
$this->commandName = $commandName;
$this->targetName = $targetName;
$this->type = $type;
$this->completion = $completion;
}
/**
* Return the stored completion, or the results returned from the completion callback
*
* @return array
*/
public function run()
{
if ($this->isCallable()) {
return call_user_func($this->completion);
}
return $this->completion;
}
/**
* Get type of input (option/argument) the completion should be run for
*
* @see CompletionInterface::ALL_TYPES
* @return string|null
*/
public function getType()
{
return $this->type;
}
/**
* Set type of input (option/argument) the completion should be run for
*
* @see CompletionInterface::ALL_TYPES
* @param string|null $type
*/
public function setType($type)
{
$this->type = $type;
}
/**
* Get the command name the completion should be run for
*
* @see CompletionInterface::ALL_COMMANDS
* @return string|null
*/
public function getCommandName()
{
return $this->commandName;
}
/**
* Set the command name the completion should be run for
*
* @see CompletionInterface::ALL_COMMANDS
* @param string|null $commandName
*/
public function setCommandName($commandName)
{
$this->commandName = $commandName;
}
/**
* Set the option/argument name the completion should be run for
*
* @see setType()
* @return string
*/
public function getTargetName()
{
return $this->targetName;
}
/**
* Get the option/argument name the completion should be run for
*
* @see getType()
* @param string $targetName
*/
public function setTargetName($targetName)
{
$this->targetName = $targetName;
}
/**
* Return the array or callback configured for for the Completion
*
* @return array|callable
*/
public function getCompletion()
{
return $this->completion;
}
/**
* Set the array or callback to return/run when Completion is run
*
* @see run()
* @param array|callable $completion
*/
public function setCompletion($completion)
{
$this->completion = $completion;
}
/**
* Check if the configured completion value is a callback function
*
* @return bool
*/
public function isCallable()
{
return is_callable($this->completion);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion\Completion;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
interface CompletionAwareInterface
{
/**
* Return possible values for the named option
*
* @param string $optionName
* @param CompletionContext $context
* @return array
*/
public function completeOptionValues($optionName, CompletionContext $context);
/**
* Return possible values for the named argument
*
* @param string $argumentName
* @param CompletionContext $context
* @return array
*/
public function completeArgumentValues($argumentName, CompletionContext $context);
}
@@ -0,0 +1,48 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion\Completion;
interface CompletionInterface
{
// Sugar for indicating that a Completion should run for all command names and for all types
// Intended to avoid meaningless null parameters in the constructors of implementing classes
const ALL_COMMANDS = null;
const ALL_TYPES = null;
const TYPE_OPTION = 'option';
const TYPE_ARGUMENT = 'argument';
/**
* Return the type of input (option/argument) completion should be run for
*
* @see \Symfony\Component\Console\Command\Command::addArgument
* @see \Symfony\Component\Console\Command\Command::addOption
* @return string - one of the CompletionInterface::TYPE_* constants
*/
public function getType();
/**
* Return the name of the command completion should be run for
* If the return value is CompletionInterface::ALL_COMMANDS, the completion will be run for any command name
*
* @see \Symfony\Component\Console\Command\Command::setName
* @return string|null
*/
public function getCommandName();
/**
* Return the option/argument name the completion should be run for
* CompletionInterface::getType determines whether the target name refers to an option or an argument
*
* @return string
*/
public function getTargetName();
/**
* Execute the completion
*
* @return string[] - an array of possible completion values
*/
public function run();
}
@@ -0,0 +1,65 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion\Completion;
/**
* Shell Path Completion
*
* Defers completion to the calling shell's built-in path completion functionality.
*/
class ShellPathCompletion implements CompletionInterface
{
/**
* Exit code set up to trigger path completion in the completion hooks
* @see Stecman\Component\Symfony\Console\BashCompletion\HookFactory
*/
const PATH_COMPLETION_EXIT_CODE = 200;
protected $type;
protected $commandName;
protected $targetName;
public function __construct($commandName, $targetName, $type)
{
$this->commandName = $commandName;
$this->targetName = $targetName;
$this->type = $type;
}
/**
* @inheritdoc
*/
public function getType()
{
return $this->type;
}
/**
* @inheritdoc
*/
public function getCommandName()
{
return $this->commandName;
}
/**
* @inheritdoc
*/
public function getTargetName()
{
return $this->targetName;
}
/**
* Exit with a status code configured to defer completion to the shell
*
* @see \Stecman\Component\Symfony\Console\BashCompletion\HookFactory::$hooks
*/
public function run()
{
exit(self::PATH_COMPLETION_EXIT_CODE);
}
}
@@ -0,0 +1,240 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class CompletionCommand extends SymfonyCommand
{
/**
* @var CompletionHandler
*/
protected $handler;
protected function configure()
{
$this
->setName('_completion')
->setDefinition($this->createDefinition())
->setDescription('BASH completion hook.')
->setHelp(<<<END
To enable BASH completion, run:
<comment>eval `[program] _completion -g`</comment>.
Or for an alias:
<comment>eval `[program] _completion -g -p [alias]`</comment>.
END
);
// Hide this command from listing if supported
$this->setHidden(true);
}
/**
* {@inheritdoc}
*/
public function getNativeDefinition(): InputDefinition
{
return $this->createDefinition();
}
/**
* Ignore user-defined global options
*
* Any global options defined by user-code are meaningless to this command.
* Options outside of the core defaults are ignored to avoid name and shortcut conflicts.
*/
public function mergeApplicationDefinition(bool $mergeArgs = true): void
{
// Get current application options
$appDefinition = $this->getApplication()->getDefinition();
$originalOptions = $appDefinition->getOptions();
// Temporarily replace application options with a filtered list
$appDefinition->setOptions(
$this->filterApplicationOptions($originalOptions)
);
parent::mergeApplicationDefinition($mergeArgs);
// Restore original application options
$appDefinition->setOptions($originalOptions);
}
/**
* Reduce the passed list of options to the core defaults (if they exist)
*
* @param InputOption[] $appOptions
* @return InputOption[]
*/
protected function filterApplicationOptions(array $appOptions)
{
return array_filter($appOptions, function(InputOption $option) {
static $coreOptions = array(
'help' => true,
'quiet' => true,
'verbose' => true,
'version' => true,
'ansi' => true,
'no-ansi' => true,
'no-interaction' => true,
);
return isset($coreOptions[$option->getName()]);
});
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->handler = new CompletionHandler($this->getApplication());
$handler = $this->handler;
if ($input->getOption('generate-hook')) {
global $argv;
$program = $argv[0];
$factory = new HookFactory();
$alias = $input->getOption('program');
$multiple = (bool)$input->getOption('multiple');
if (!$alias) {
$alias = basename($program);
}
$hook = $factory->generateHook(
$input->getOption('shell-type') ?: $this->getShellType(),
$program,
$alias,
$multiple
);
$output->write($hook, true);
} else {
$handler->setContext(new EnvironmentCompletionContext());
// Get completion results
$results = $this->runCompletion();
// Escape results for the current shell
$shellType = $input->getOption('shell-type') ?: $this->getShellType();
foreach ($results as &$result) {
$result = $this->escapeForShell($result, $shellType);
}
$output->write($results, true);
}
return SymfonyCommand::SUCCESS;
}
/**
* Escape each completion result for the specified shell
*
* @param string $result - Completion results that should appear in the shell
* @param string $shellType - Valid shell type from HookFactory
* @return string
*/
protected function escapeForShell($result, $shellType)
{
switch ($shellType) {
// BASH requires special escaping for multi-word and special character results
// This emulates registering completion with`-o filenames`, without side-effects like dir name slashes
case 'bash':
$context = $this->handler->getContext();
$wordStart = substr($context->getRawCurrentWord(), 0, 1);
if ($wordStart == "'") {
// If the current word is single-quoted, escape any single quotes in the result
$result = str_replace("'", "\\'", $result);
} else if ($wordStart == '"') {
// If the current word is double-quoted, escape any double quotes in the result
$result = str_replace('"', '\\"', $result);
} else {
// Otherwise assume the string is unquoted and word breaks should be escaped
$result = preg_replace('/([\s\'"\\\\])/', '\\\\$1', $result);
}
// Escape output to prevent special characters being lost when passing results to compgen
return escapeshellarg($result);
// No transformation by default
default:
return $result;
}
}
/**
* Run the completion handler and return a filtered list of results
*
* @deprecated - This will be removed in 1.0.0 in favour of CompletionCommand::configureCompletion
*
* @return string[]
*/
protected function runCompletion()
{
$this->configureCompletion($this->handler);
return $this->handler->runCompletion();
}
/**
* Configure the CompletionHandler instance before it is run
*
* @param CompletionHandler $handler
*/
protected function configureCompletion(CompletionHandler $handler)
{
// Override this method to configure custom value completions
}
/**
* Determine the shell type for use with HookFactory
*
* @return string
*/
protected function getShellType()
{
if (!getenv('SHELL')) {
throw new \RuntimeException('Could not read SHELL environment variable. Please specify your shell type using the --shell-type option.');
}
return basename(getenv('SHELL'));
}
protected function createDefinition()
{
return new InputDefinition(array(
new InputOption(
'generate-hook',
'g',
InputOption::VALUE_NONE,
'Generate BASH code that sets up completion for this application.'
),
new InputOption(
'program',
'p',
InputOption::VALUE_REQUIRED,
"Program name that should trigger completion\n<comment>(defaults to the absolute application path)</comment>."
),
new InputOption(
'multiple',
'm',
InputOption::VALUE_NONE,
"Generated hook can be used for multiple applications."
),
new InputOption(
'shell-type',
null,
InputOption::VALUE_OPTIONAL,
'Set the shell type (zsh or bash). Otherwise this is determined automatically.'
),
));
}
}
@@ -0,0 +1,390 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
/**
* Command line context for completion
*
* Represents the current state of the command line that is being completed
*/
class CompletionContext
{
/**
* The current contents of the command line as a single string
*
* Bash equivalent: COMP_LINE
*
* @var string
*/
protected $commandLine;
/**
* The index of the user's cursor relative to the start of the command line.
*
* If the current cursor position is at the end of the current command,
* the value of this variable is equal to the length of $this->commandLine
*
* Bash equivalent: COMP_POINT
*
* @var int
*/
protected $charIndex = 0;
/**
* An array of the individual words in the current command line.
*
* This is not set until $this->splitCommand() is called, when it is populated by
* $commandLine exploded by $wordBreaks
*
* Bash equivalent: COMP_WORDS
*
* @var string[]|null
*/
protected $words = null;
/**
* Words from the currently command-line before quotes and escaping is processed
*
* This is indexed the same as $this->words, but in their raw input terms are in their input form, including
* quotes and escaping.
*
* @var string[]|null
*/
protected $rawWords = null;
/**
* The index in $this->words containing the word at the current cursor position.
*
* This is not set until $this->splitCommand() is called.
*
* Bash equivalent: COMP_CWORD
*
* @var int|null
*/
protected $wordIndex = null;
/**
* Characters that $this->commandLine should be split on to get a list of individual words
*
* Bash equivalent: COMP_WORDBREAKS
*
* @var string
*/
protected $wordBreaks = "= \t\n";
/**
* Set the whole contents of the command line as a string
*
* @param string $commandLine
*/
public function setCommandLine($commandLine)
{
$this->commandLine = $commandLine;
$this->reset();
}
/**
* Return the current command line verbatim as a string
*
* @return string
*/
public function getCommandLine()
{
return $this->commandLine;
}
/**
* Return the word from the command line that the cursor is currently in
*
* Most of the time this will be a partial word. If the cursor has a space before it,
* this will return an empty string, indicating a new word.
*
* @return string
*/
public function getCurrentWord()
{
if (isset($this->words[$this->wordIndex])) {
return $this->words[$this->wordIndex];
}
return '';
}
/**
* Return the unprocessed string for the word under the cursor
*
* This preserves any quotes and escaping that are present in the input command line.
*
* @return string
*/
public function getRawCurrentWord()
{
if (isset($this->rawWords[$this->wordIndex])) {
return $this->rawWords[$this->wordIndex];
}
return '';
}
/**
* Return a word by index from the command line
*
* @see $words, $wordBreaks
* @param int $index
* @return string
*/
public function getWordAtIndex($index)
{
if (isset($this->words[$index])) {
return $this->words[$index];
}
return '';
}
/**
* Get the contents of the command line, exploded into words based on the configured word break characters
*
* @see $wordBreaks, setWordBreaks
* @return array
*/
public function getWords()
{
if ($this->words === null) {
$this->splitCommand();
}
return $this->words;
}
/**
* Get the unprocessed/literal words from the command line
*
* This is indexed the same as getWords(), but preserves any quoting and escaping from the command line
*
* @return string[]
*/
public function getRawWords()
{
if ($this->rawWords === null) {
$this->splitCommand();
}
return $this->rawWords;
}
/**
* Get the index of the word the cursor is currently in
*
* @see getWords, getCurrentWord
* @return int
*/
public function getWordIndex()
{
if ($this->wordIndex === null) {
$this->splitCommand();
}
return $this->wordIndex;
}
/**
* Get the character index of the user's cursor on the command line
*
* This is in the context of the full command line string, so includes word break characters.
* Note that some shells can only provide an approximation for character index. Under ZSH for
* example, this will always be the character at the start of the current word.
*
* @return int
*/
public function getCharIndex()
{
return $this->charIndex;
}
/**
* Set the cursor position as a character index relative to the start of the command line
*
* @param int $index
*/
public function setCharIndex($index)
{
$this->charIndex = $index;
$this->reset();
}
/**
* Set characters to use as split points when breaking the command line into words
*
* This defaults to a sane value based on BASH's word break characters and shouldn't
* need to be changed unless your completions contain the default word break characters.
*
* @deprecated This is becoming an internal setting that doesn't make sense to expose publicly.
*
* @see wordBreaks
* @param string $charList - a single string containing all of the characters to break words on
*/
public function setWordBreaks($charList)
{
// Drop quotes from break characters - strings are handled separately to word breaks now
$this->wordBreaks = str_replace(array('"', '\''), '', $charList);;
$this->reset();
}
/**
* Split the command line into words using the configured word break characters
*
* @return string[]
*/
protected function splitCommand()
{
$tokens = $this->tokenizeString($this->commandLine);
foreach ($tokens as $token) {
if ($token['type'] != 'break') {
$this->words[] = $this->getTokenValue($token);
$this->rawWords[] = $token['value'];
}
// Determine which word index the cursor is inside once we reach it's offset
if ($this->wordIndex === null && $this->charIndex <= $token['offsetEnd']) {
$this->wordIndex = count($this->words) - 1;
if ($token['type'] == 'break') {
// Cursor is in the break-space after a word
// Push an empty word at the cursor to allow completion of new terms at the cursor, ignoring words ahead
$this->wordIndex++;
$this->words[] = '';
$this->rawWords[] = '';
continue;
}
if ($this->charIndex < $token['offsetEnd']) {
// Cursor is inside the current word - truncate the word at the cursor to complete on
// This emulates BASH completion's behaviour with COMP_CWORD
// Create a copy of the token with its value truncated
$truncatedToken = $token;
$relativeOffset = $this->charIndex - $token['offset'];
$truncatedToken['value'] = substr($token['value'], 0, $relativeOffset);
// Replace the current word with the truncated value
$this->words[$this->wordIndex] = $this->getTokenValue($truncatedToken);
$this->rawWords[$this->wordIndex] = $truncatedToken['value'];
}
}
}
// Cursor position is past the end of the command line string - consider it a new word
if ($this->wordIndex === null) {
$this->wordIndex = count($this->words);
$this->words[] = '';
$this->rawWords[] = '';
}
}
/**
* Return a token's value with escaping and quotes removed
*
* @see self::tokenizeString()
* @param array $token
* @return string
*/
protected function getTokenValue($token)
{
$value = $token['value'];
// Remove outer quote characters (or first quote if unclosed)
if ($token['type'] == 'quoted') {
$value = preg_replace('/^(?:[\'"])(.*?)(?:[\'"])?$/', '$1', $value);
}
// Remove escape characters
$value = preg_replace('/\\\\(.)/', '$1', $value);
return $value;
}
/**
* Break a string into words, quoted strings and non-words (breaks)
*
* Returns an array of unmodified segments of $string with offset and type information.
*
* @param string $string
* @return array as [ [type => string, value => string, offset => int], ... ]
*/
protected function tokenizeString($string)
{
// Map capture groups to returned token type
$typeMap = array(
'double_quote_string' => 'quoted',
'single_quote_string' => 'quoted',
'word' => 'word',
'break' => 'break',
);
// Escape every word break character including whitespace
// preg_quote won't work here as it doesn't understand the ignore whitespace flag ("x")
$breaks = preg_replace('/(.)/', '\\\$1', $this->wordBreaks);
$pattern = <<<"REGEX"
/(?:
(?P<double_quote_string>
"(\\\\.|[^\"\\\\])*(?:"|$)
) |
(?P<single_quote_string>
'(\\\\.|[^'\\\\])*(?:'|$)
) |
(?P<word>
(?:\\\\.|[^$breaks])+
) |
(?P<break>
[$breaks]+
)
)/x
REGEX;
$tokens = array();
if (!preg_match_all($pattern, $string, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
return $tokens;
}
foreach ($matches as $set) {
foreach ($set as $groupName => $match) {
// Ignore integer indices preg_match outputs (duplicates of named groups)
if (is_integer($groupName)) {
continue;
}
// Skip if the offset indicates this group didn't match
if ($match[1] === -1) {
continue;
}
$tokens[] = array(
'type' => $typeMap[$groupName],
'value' => $match[0],
'offset' => $match[1],
'offsetEnd' => $match[1] + strlen($match[0])
);
// Move to the next set (only one group should match per set)
continue;
}
}
return $tokens;
}
/**
* Reset the computed words so that $this->splitWords is forced to run again
*/
protected function reset()
{
$this->words = null;
$this->wordIndex = null;
}
}
@@ -0,0 +1,504 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionAwareInterface;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
class CompletionHandler
{
/**
* Application to complete for
* @var \Symfony\Component\Console\Application
*/
protected $application;
/**
* @var Command
*/
protected $command;
/**
* @var CompletionContext
*/
protected $context;
/**
* Array of completion helpers.
* @var CompletionInterface[]
*/
protected $helpers = array();
/**
* Index the command name was detected at
* @var int
*/
private $commandWordIndex;
public function __construct(Application $application, ?CompletionContext $context = null)
{
$this->application = $application;
$this->context = $context;
// Set up completions for commands that are built-into Application
$this->addHandler(
new Completion(
'help',
'command_name',
Completion::TYPE_ARGUMENT,
$this->getCommandNames()
)
);
$this->addHandler(
new Completion(
'list',
'namespace',
Completion::TYPE_ARGUMENT,
$application->getNamespaces()
)
);
}
public function setContext(CompletionContext $context)
{
$this->context = $context;
}
/**
* @return CompletionContext
*/
public function getContext()
{
return $this->context;
}
/**
* @param CompletionInterface[] $array
*/
public function addHandlers(array $array)
{
$this->helpers = array_merge($this->helpers, $array);
}
/**
* @param CompletionInterface $helper
*/
public function addHandler(CompletionInterface $helper)
{
$this->helpers[] = $helper;
}
/**
* Do the actual completion, returning an array of strings to provide to the parent shell's completion system
*
* @throws \RuntimeException
* @return string[]
*/
public function runCompletion()
{
if (!$this->context) {
throw new \RuntimeException('A CompletionContext must be set before requesting completion.');
}
// Set the command to query options and arugments from
$this->command = $this->detectCommand();
$process = array(
'completeForOptionValues',
'completeForOptionShortcuts',
'completeForOptionShortcutValues',
'completeForOptions',
'completeForCommandName',
'completeForCommandArguments'
);
foreach ($process as $methodName) {
$result = $this->{$methodName}();
if (false !== $result) {
// Return the result of the first completion mode that matches
return $this->filterResults((array) $result);
}
}
return array();
}
/**
* Get an InputInterface representation of the completion context
*
* @deprecated Incorrectly uses the ArrayInput API and is no longer needed.
* This will be removed in the next major version.
*
* @return ArrayInput
*/
public function getInput()
{
// Filter the command line content to suit ArrayInput
$words = $this->context->getWords();
array_shift($words);
$words = array_filter($words);
return new ArrayInput($words);
}
/**
* Attempt to complete the current word as a long-form option (--my-option)
*
* @return array|false
*/
protected function completeForOptions()
{
$word = $this->context->getCurrentWord();
if (substr($word, 0, 2) === '--') {
$options = array();
foreach ($this->getAllOptions() as $opt) {
$options[] = '--'.$opt->getName();
}
return $options;
}
return false;
}
/**
* Attempt to complete the current word as an option shortcut.
*
* If the shortcut exists it will be completed, but a list of possible shortcuts is never returned for completion.
*
* @return array|false
*/
protected function completeForOptionShortcuts()
{
$word = $this->context->getCurrentWord();
if (strpos($word, '-') === 0 && strlen($word) == 2) {
$definition = $this->command ? $this->command->getNativeDefinition() : $this->application->getDefinition();
if ($definition->hasShortcut(substr($word, 1))) {
return array($word);
}
}
return false;
}
/**
* Attempt to complete the current word as the value of an option shortcut
*
* @return array|false
*/
protected function completeForOptionShortcutValues()
{
$wordIndex = $this->context->getWordIndex();
if ($this->command && $wordIndex > 1) {
$left = $this->context->getWordAtIndex($wordIndex - 1);
// Complete short options
if ($left[0] == '-' && strlen($left) == 2) {
$shortcut = substr($left, 1);
$def = $this->command->getNativeDefinition();
if (!$def->hasShortcut($shortcut)) {
return false;
}
$opt = $def->getOptionForShortcut($shortcut);
if ($opt->isValueRequired() || $opt->isValueOptional()) {
return $this->completeOption($opt);
}
}
}
return false;
}
/**
* Attemp to complete the current word as the value of a long-form option
*
* @return array|false
*/
protected function completeForOptionValues()
{
$wordIndex = $this->context->getWordIndex();
if ($this->command && $wordIndex > 1) {
$left = $this->context->getWordAtIndex($wordIndex - 1);
if (strpos($left, '--') === 0) {
$name = substr($left, 2);
$def = $this->command->getNativeDefinition();
if (!$def->hasOption($name)) {
return false;
}
$opt = $def->getOption($name);
if ($opt->isValueRequired() || $opt->isValueOptional()) {
return $this->completeOption($opt);
}
}
}
return false;
}
/**
* Attempt to complete the current word as a command name
*
* @return array|false
*/
protected function completeForCommandName()
{
if (!$this->command || $this->context->getWordIndex() == $this->commandWordIndex) {
return $this->getCommandNames();
}
return false;
}
/**
* Attempt to complete the current word as a command argument value
*
* @see Symfony\Component\Console\Input\InputArgument
* @return array|false
*/
protected function completeForCommandArguments()
{
if (!$this->command || strpos($this->context->getCurrentWord(), '-') === 0) {
return false;
}
$definition = $this->command->getNativeDefinition();
$argWords = $this->mapArgumentsToWords($definition->getArguments());
$wordIndex = $this->context->getWordIndex();
if (isset($argWords[$wordIndex])) {
$name = $argWords[$wordIndex];
} elseif (!empty($argWords) && $definition->getArgument(end($argWords))->isArray()) {
$name = end($argWords);
} else {
return false;
}
if ($helper = $this->getCompletionHelper($name, Completion::TYPE_ARGUMENT)) {
return $helper->run();
}
if ($this->command instanceof CompletionAwareInterface) {
return $this->command->completeArgumentValues($name, $this->context);
}
return false;
}
/**
* Find a CompletionInterface that matches the current command, target name, and target type
*
* @param string $name
* @param string $type
* @return CompletionInterface|null
*/
protected function getCompletionHelper($name, $type)
{
foreach ($this->helpers as $helper) {
if ($helper->getType() != $type && $helper->getType() != CompletionInterface::ALL_TYPES) {
continue;
}
if ($helper->getCommandName() == CompletionInterface::ALL_COMMANDS || $helper->getCommandName() == $this->command->getName()) {
if ($helper->getTargetName() == $name) {
return $helper;
}
}
}
return null;
}
/**
* Complete the value for the given option if a value completion is availble
*
* @param InputOption $option
* @return array|false
*/
protected function completeOption(InputOption $option)
{
if ($helper = $this->getCompletionHelper($option->getName(), Completion::TYPE_OPTION)) {
return $helper->run();
}
if ($this->command instanceof CompletionAwareInterface) {
return $this->command->completeOptionValues($option->getName(), $this->context);
}
return false;
}
/**
* Step through the command line to determine which word positions represent which argument values
*
* The word indexes of argument values are found by eliminating words that are known to not be arguments (options,
* option values, and command names). Any word that doesn't match for elimination is assumed to be an argument value,
*
* @param InputArgument[] $argumentDefinitions
* @return array as [argument name => word index on command line]
*/
protected function mapArgumentsToWords($argumentDefinitions)
{
$argumentPositions = array();
$argumentNumber = 0;
$previousWord = null;
$argumentNames = array_keys($argumentDefinitions);
// Build a list of option values to filter out
$optionsWithArgs = $this->getOptionWordsWithValues();
foreach ($this->context->getWords() as $wordIndex => $word) {
// Skip program name, command name, options, and option values
if ($wordIndex == 0
|| $wordIndex === $this->commandWordIndex
|| ($word && '-' === $word[0])
|| in_array($previousWord, $optionsWithArgs)) {
$previousWord = $word;
continue;
} else {
$previousWord = $word;
}
// If argument n exists, pair that argument's name with the current word
if (isset($argumentNames[$argumentNumber])) {
$argumentPositions[$wordIndex] = $argumentNames[$argumentNumber];
}
$argumentNumber++;
}
return $argumentPositions;
}
/**
* Build a list of option words/flags that will have a value after them
* Options are returned in the format they appear as on the command line.
*
* @return string[] - eg. ['--myoption', '-m', ... ]
*/
protected function getOptionWordsWithValues()
{
$strings = array();
foreach ($this->getAllOptions() as $option) {
if ($option->isValueRequired()) {
$strings[] = '--' . $option->getName();
if ($option->getShortcut()) {
$strings[] = '-' . $option->getShortcut();
}
}
}
return $strings;
}
/**
* Filter out results that don't match the current word on the command line
*
* @param string[] $array
* @return string[]
*/
protected function filterResults(array $array)
{
$curWord = $this->context->getCurrentWord();
return array_filter($array, function($val) use ($curWord) {
return fnmatch($curWord.'*', $val);
});
}
/**
* Get the combined options of the application and entered command
*
* @return InputOption[]
*/
protected function getAllOptions()
{
if (!$this->command) {
return $this->application->getDefinition()->getOptions();
}
return array_merge(
$this->command->getNativeDefinition()->getOptions(),
$this->application->getDefinition()->getOptions()
);
}
/**
* Get command names available for completion
*
* Filters out hidden commands where supported.
*
* @return string[]
*/
protected function getCommandNames()
{
$commands = array();
foreach ($this->application->all() as $name => $command) {
if (!$command->isHidden()) {
$commands[] = $name;
}
}
return $commands;
}
/**
* Find the current command name in the command-line
*
* Note this only cares about flag-type options. Options with values cannot
* appear before a command name in Symfony Console application.
*
* @return Command|null
*/
private function detectCommand()
{
// Always skip the first word (program name)
$skipNext = true;
foreach ($this->context->getWords() as $index => $word) {
// Skip word if flagged
if ($skipNext) {
$skipNext = false;
continue;
}
// Skip empty words and words that look like options
if (strlen($word) == 0 || $word[0] === '-') {
continue;
}
// Return the first unambiguous match to argument-like words
try {
$cmd = $this->application->find($word);
$this->commandWordIndex = $index;
return $cmd;
} catch (\InvalidArgumentException $e) {
// Exception thrown, when multiple or no commands are found.
}
}
// No command found
return null;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
class EnvironmentCompletionContext extends CompletionContext
{
/**
* Set up completion context from the environment variables set by the parent shell
*/
public function __construct()
{
$this->commandLine = getenv('CMDLINE_CONTENTS');
$this->charIndex = intval(getenv('CMDLINE_CURSOR_INDEX'));
if ($this->commandLine === false) {
$message = 'Failed to configure from environment; Environment var CMDLINE_CONTENTS not set.';
if (getenv('COMP_LINE')) {
$message .= "\n\nYou appear to be attempting completion using an out-dated hook. If you've just updated,"
. " you probably need to reinitialise the completion shell hook by reloading your shell"
. " profile or starting a new shell session. If you are using a hard-coded (rather than generated)"
. " hook, you will need to update that function with the new environment variable names."
. "\n\nSee here for details: https://github.com/stecman/symfony-console-completion/issues/31";
}
throw new \RuntimeException($message);
}
}
/**
* Use the word break characters set by the parent shell.
*
* @throws \RuntimeException
*/
public function useWordBreaksFromEnvironment()
{
$breaks = getenv('CMDLINE_WORDBREAKS');
if (!$breaks) {
throw new \RuntimeException('Failed to read word breaks from environment; Environment var CMDLINE_WORDBREAKS not set');
}
$this->wordBreaks = $breaks;
}
}
@@ -0,0 +1,214 @@
<?php
namespace Stecman\Component\Symfony\Console\BashCompletion;
final class HookFactory
{
/**
* Hook scripts
*
* These are shell-specific scripts that pass required information from that shell's
* completion system to the interface of the completion command in this module.
*
* The following placeholders are replaced with their value at runtime:
*
* %%function_name%% - name of the generated shell function run for completion
* %%program_name%% - command name completion will be enabled for
* %%program_path%% - path to program the completion is for/generated by
* %%completion_command%% - command to be run to compute completions
*
* NOTE: Comments are stripped out by HookFactory::stripComments as eval reads
* input as a single line, causing it to break if comments are included.
* While comments work using `... | source /dev/stdin`, existing installations
* are likely using eval as it's been part of the instructions for a while.
*
* @var array
*/
protected static $hooks = array(
// BASH Hook
'bash' => <<<'END'
# BASH completion for %%program_path%%
function %%function_name%% {
# Copy BASH's completion variables to the ones the completion command expects
# These line up exactly as the library was originally designed for BASH
local CMDLINE_CONTENTS="$COMP_LINE";
local CMDLINE_CURSOR_INDEX="$COMP_POINT";
local CMDLINE_WORDBREAKS="$COMP_WORDBREAKS";
export CMDLINE_CONTENTS CMDLINE_CURSOR_INDEX CMDLINE_WORDBREAKS;
local RESULT STATUS;
# Force splitting by newline instead of default delimiters
local IFS=$'\n';
RESULT="$(%%completion_command%% </dev/null)";
STATUS=$?;
local cur mail_check_backup;
mail_check_backup=$MAILCHECK;
MAILCHECK=-1;
_get_comp_words_by_ref -n : cur;
# Check if shell provided path completion is requested
# @see Completion\ShellPathCompletion
if [ $STATUS -eq 200 ]; then
# Turn file/dir completion on temporarily and give control back to BASH
compopt -o default;
return 0;
# Bail out if PHP didn't exit cleanly
elif [ $STATUS -ne 0 ]; then
echo -e "$RESULT";
return $?;
fi;
COMPREPLY=(`compgen -W "$RESULT" -- $cur`);
__ltrim_colon_completions "$cur";
MAILCHECK=mail_check_backup;
};
if [ "$(type -t _get_comp_words_by_ref)" == "function" ]; then
complete -F %%function_name%% "%%program_name%%";
else
>&2 echo "Completion was not registered for %%program_name%%:";
>&2 echo "The 'bash-completion' package is required but doesn't appear to be installed.";
fi;
END
// ZSH Hook
, 'zsh' => <<<'END'
# ZSH completion for %%program_path%%
function %%function_name%% {
local -x CMDLINE_CONTENTS="$words";
local -x CMDLINE_CURSOR_INDEX;
(( CMDLINE_CURSOR_INDEX = ${#${(j. .)words[1,CURRENT]}} ));
local RESULT STATUS;
RESULT=("${(@f)$( %%completion_command%% )}");
STATUS=$?;
# Check if shell provided path completion is requested
# @see Completion\ShellPathCompletion
if [ $STATUS -eq 200 ]; then
_path_files;
return 0;
# Bail out if PHP didn't exit cleanly
elif [ $STATUS -ne 0 ]; then
echo -e "$RESULT";
return $?;
fi;
compadd -- $RESULT;
};
compdef %%function_name%% "%%program_name%%";
END
);
/**
* Return the names of shells that have hooks
*
* @return string[]
*/
public static function getShellTypes()
{
return array_keys(self::$hooks);
}
/**
* Return a completion hook for the specified shell type
*
* @param string $type - a key from self::$hooks
* @param string $programPath
* @param ?string $programName
* @param bool $multiple
*
* @return string
*/
public function generateHook($type, $programPath, $programName = null, $multiple = false)
{
if (!isset(self::$hooks[$type])) {
throw new \RuntimeException(sprintf(
"Cannot generate hook for unknown shell type '%s'. Available hooks are: %s",
$type,
implode(', ', self::getShellTypes())
));
}
// Use the program path if an alias/name is not given
$programName = $programName ?: $programPath;
if ($multiple) {
$completionCommand = '$1 _completion';
} else {
$completionCommand = $programPath . ' _completion';
}
// Pass shell type during completion so output can be encoded if the shell requires it
$completionCommand .= " --shell-type $type";
return str_replace(
array(
'%%function_name%%',
'%%program_name%%',
'%%program_path%%',
'%%completion_command%%',
),
array(
$this->generateFunctionName($programPath, $programName),
$programName,
$programPath,
$completionCommand
),
$this->stripComments(self::$hooks[$type])
);
}
/**
* Generate a function name that is unlikely to conflict with other generated function names in the same shell
*/
protected function generateFunctionName($programPath, $programName)
{
return sprintf(
'_%s_%s_complete',
$this->sanitiseForFunctionName(basename($programName)),
substr(md5($programPath), 0, 16)
);
}
/**
* Make a string safe for use as a shell function name
*
* @param string $name
* @return string
*/
protected function sanitiseForFunctionName($name)
{
$name = str_replace('-', '_', $name);
return preg_replace('/[^A-Za-z0-9_]+/', '', $name);
}
/**
* Strip '#' style comments from a string
*
* BASH's eval doesn't work with comments as it removes line breaks, so comments have to be stripped out
* for this method of sourcing the hook to work. Eval seems to be the most reliable method of getting a
* hook into a shell, so while it would be nice to render comments, this stripping is required for now.
*
* @param string $script
* @return string
*/
protected function stripComments($script)
{
return preg_replace('/(^\s*\#.*$)/m', '', $script);
}
}