Navigation Menu

Skip to content

Commit

Permalink
feature #30997 [Console] Add callback support to Console\Question aut…
Browse files Browse the repository at this point in the history
…ocompleter (Mikkel Paulson)

This PR was merged into the 4.3-dev branch.

Discussion
----------

[Console] Add callback support to Console\Question autocompleter

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | minor edge case, see below
| Deprecations? | no
| Tests pass?   | yes (with expanded coverage)
| Fixed tickets | N/A
| License       | MIT
| Doc PR        | symfony/symfony-docs#11349

Autocompletion is a useful feature, but it's not always possible to anticipate every input the user could provide in advance. For instance, if we're allowing the user to input a path to a file, it's not practical to populate an array with every file and directory in the filesystem, but we can easily build a callback function that populates its suggestions based on the path already inputted.

This change replaces the autocomplete logic that accepts an array of suggestions with an architecture that uses a callback function to populate suggestions in real time as the user provides input.

The first commit adds a test class covering all methods of the `Question` object, while the second commit modifies the `Question` object to accept and store a callback function. The existing `[gs]etAutocompleterValues()` methods are preserved, but instead of being referenced directly from the `QuestionHelper`, they create and call their own callbacks to emulate the current behaviour.

There is one edge case that is changed, as documented in the test: when a `Traversable` object is passed to `setAutocompleterValues()`, the return value of `getAutocompleterValues()` will be the unpacked (array) form of that object rather than the object itself. The unpacking is done lazily and cached on the callback function.

Commits
-------

caad562 [Console] Add callback support to Console\Question autocompleter
  • Loading branch information
fabpot committed Apr 8, 2019
2 parents fa308e2 + caad562 commit 4e1244e
Show file tree
Hide file tree
Showing 5 changed files with 375 additions and 19 deletions.
2 changes: 2 additions & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Expand Up @@ -6,6 +6,8 @@ CHANGELOG

* added support for hyperlinks
* added `ProgressBar::iterate()` method that simplify updating the progress bar when iterating
* added `Question::setAutocompleterCallback()` to provide a callback function
that dynamically generates suggestions as the user types

4.2.0
-----
Expand Down
29 changes: 17 additions & 12 deletions src/Symfony/Component/Console/Helper/QuestionHelper.php
Expand Up @@ -115,7 +115,7 @@ private function doAsk(OutputInterface $output, Question $question)
$this->writePrompt($output, $question);

$inputStream = $this->inputStream ?: STDIN;
$autocomplete = $question->getAutocompleterValues();
$autocomplete = $question->getAutocompleterCallback();

if (null === $autocomplete || !$this->hasSttyAvailable()) {
$ret = false;
Expand All @@ -137,7 +137,7 @@ private function doAsk(OutputInterface $output, Question $question)
$ret = trim($ret);
}
} else {
$ret = trim($this->autocomplete($output, $question, $inputStream, \is_array($autocomplete) ? $autocomplete : iterator_to_array($autocomplete, false)));
$ret = trim($this->autocomplete($output, $question, $inputStream, $autocomplete));
}

if ($output instanceof ConsoleSectionOutput) {
Expand Down Expand Up @@ -194,17 +194,15 @@ protected function writeError(OutputInterface $output, \Exception $error)
/**
* Autocompletes a question.
*
* @param OutputInterface $output
* @param Question $question
* @param resource $inputStream
* @param resource $inputStream
*/
private function autocomplete(OutputInterface $output, Question $question, $inputStream, array $autocomplete): string
private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string
{
$ret = '';

$i = 0;
$ofs = -1;
$matches = $autocomplete;
$matches = $autocomplete($ret);
$numMatches = \count($matches);

$sttyMode = shell_exec('stty -g');
Expand Down Expand Up @@ -232,7 +230,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu

if (0 === $i) {
$ofs = -1;
$matches = $autocomplete;
$matches = $autocomplete($ret);
$numMatches = \count($matches);
} else {
$numMatches = 0;
Expand Down Expand Up @@ -260,18 +258,25 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
} elseif (\ord($c) < 32) {
if ("\t" === $c || "\n" === $c) {
if ($numMatches > 0 && -1 !== $ofs) {
$ret = $matches[$ofs];
$ret = (string) $matches[$ofs];
// Echo out remaining chars for current match
$output->write(substr($ret, $i));
$i = \strlen($ret);

$matches = array_filter(
$autocomplete($ret),
function ($match) use ($ret) {
return '' === $ret || 0 === strpos($match, $ret);
}
);
$numMatches = \count($matches);
$ofs = -1;
}

if ("\n" === $c) {
$output->write($c);
break;
}

$numMatches = 0;
}

continue;
Expand All @@ -287,7 +292,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
$numMatches = 0;
$ofs = 0;

foreach ($autocomplete as $value) {
foreach ($autocomplete($ret) as $value) {
// If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
if (0 === strpos($value, $ret)) {
$matches[$numMatches++] = $value;
Expand Down
45 changes: 38 additions & 7 deletions src/Symfony/Component/Console/Question/Question.php
Expand Up @@ -25,7 +25,7 @@ class Question
private $attempts;
private $hidden = false;
private $hiddenFallback = true;
private $autocompleterValues;
private $autocompleterCallback;
private $validator;
private $default;
private $normalizer;
Expand Down Expand Up @@ -81,7 +81,7 @@ public function isHidden()
*/
public function setHidden($hidden)
{
if ($this->autocompleterValues) {
if ($this->autocompleterCallback) {
throw new LogicException('A hidden question cannot use the autocompleter.');
}

Expand Down Expand Up @@ -121,7 +121,9 @@ public function setHiddenFallback($fallback)
*/
public function getAutocompleterValues()
{
return $this->autocompleterValues;
$callback = $this->getAutocompleterCallback();

return $callback ? $callback('') : null;
}

/**
Expand All @@ -138,17 +140,46 @@ public function setAutocompleterValues($values)
{
if (\is_array($values)) {
$values = $this->isAssoc($values) ? array_merge(array_keys($values), array_values($values)) : array_values($values);
}

if (null !== $values && !\is_array($values) && !$values instanceof \Traversable) {
$callback = static function () use ($values) {
return $values;
};
} elseif ($values instanceof \Traversable) {
$valueCache = null;
$callback = static function () use ($values, &$valueCache) {
return $valueCache ?? $valueCache = iterator_to_array($values, false);
};
} elseif (null === $values) {
$callback = null;
} else {
throw new InvalidArgumentException('Autocompleter values can be either an array, "null" or a "Traversable" object.');
}

if ($this->hidden) {
return $this->setAutocompleterCallback($callback);
}

/**
* Gets the callback function used for the autocompleter.
*/
public function getAutocompleterCallback(): ?callable
{
return $this->autocompleterCallback;
}

/**
* Sets the callback function used for the autocompleter.
*
* The callback is passed the user input as argument and should return an iterable of corresponding suggestions.
*
* @return $this
*/
public function setAutocompleterCallback(callable $callback = null): self
{
if ($this->hidden && null !== $callback) {
throw new LogicException('A hidden question cannot use the autocompleter.');
}

$this->autocompleterValues = $values;
$this->autocompleterCallback = $callback;

return $this;
}
Expand Down
61 changes: 61 additions & 0 deletions src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php
Expand Up @@ -198,6 +198,67 @@ public function testAskWithAutocomplete()
$this->assertEquals('FooBundle', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
}

public function testAskWithAutocompleteCallback()
{
if (!$this->hasSttyAvailable()) {
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
}

// Po<TAB>Cr<TAB>P<DOWN ARROW><DOWN ARROW><NEWLINE>
$inputStream = $this->getInputStream("Pa\177\177o\tCr\t\033[A\033[A\033[A\n");

$dialog = new QuestionHelper();
$helperSet = new HelperSet([new FormatterHelper()]);
$dialog->setHelperSet($helperSet);

$question = new Question('What\'s for dinner?');

// A simple test callback - return an array containing the words the
// user has already completed, suffixed with all known words.
//
// Eg: If the user inputs "Potato C", the return will be:
//
// ["Potato Carrot ", "Potato Creme ", "Potato Curry ", ...]
//
// No effort is made to avoid irrelevant suggestions, as this is handled
// by the autocomplete function.
$callback = function ($input) {
$knownWords = [
'Carrot',
'Creme',
'Curry',
'Parsnip',
'Pie',
'Potato',
'Tart',
];

$inputWords = explode(' ', $input);
$lastInputWord = array_pop($inputWords);
$suggestionBase = $inputWords
? implode(' ', $inputWords).' '
: '';

return array_map(
function ($word) use ($suggestionBase) {
return $suggestionBase.$word.' ';
},
$knownWords
);
};

$question->setAutocompleterCallback($callback);

$this->assertSame(
'Potato Creme Pie',
$dialog->ask(
$this->createStreamableInputInterfaceMock($inputStream),
$this->createOutputInterface(),
$question
)
);
}

public function testAskWithAutocompleteWithNonSequentialKeys()
{
if (!$this->hasSttyAvailable()) {
Expand Down

0 comments on commit 4e1244e

Please sign in to comment.