[Console] Add callback support to Console\Question autocompleter

In order to enable more dynamic use cases such as word-by-word
autocomplete and path-based autocomplete, update the autocomplete logic
of the Question object and its helper to accept a callback function.
This function is called on each keystroke and should return an array of
possibilities to present to the user.

The original logic only accepted an array, which required
implementations to anticipate in advance all possible input values.

This change is fully backwards-compatible, but reimplements the old
behaviour by initializing a "dumb" callback function that always returns
the same array regardless of input.
This commit is contained in:
Mikkel Paulson 2019-04-07 15:22:28 -04:00 committed by Nicolas Grekas
parent 408e4aa48e
commit caad562c11
5 changed files with 375 additions and 19 deletions

View File

@ -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
-----

View File

@ -115,7 +115,7 @@ class QuestionHelper extends Helper
$this->writePrompt($output, $question);
$inputStream = $this->inputStream ?: STDIN;
$autocomplete = $question->getAutocompleterValues();
$autocomplete = $question->getAutocompleterCallback();
if (null === $autocomplete || !$this->hasSttyAvailable()) {
$ret = false;
@ -137,7 +137,7 @@ class QuestionHelper extends Helper
$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) {
@ -194,17 +194,15 @@ class QuestionHelper extends Helper
/**
* 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');
@ -232,7 +230,7 @@ class QuestionHelper extends Helper
if (0 === $i) {
$ofs = -1;
$matches = $autocomplete;
$matches = $autocomplete($ret);
$numMatches = \count($matches);
} else {
$numMatches = 0;
@ -260,18 +258,25 @@ class QuestionHelper extends Helper
} 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;
@ -287,7 +292,7 @@ class QuestionHelper extends Helper
$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;

View File

@ -25,7 +25,7 @@ class Question
private $attempts;
private $hidden = false;
private $hiddenFallback = true;
private $autocompleterValues;
private $autocompleterCallback;
private $validator;
private $default;
private $normalizer;
@ -81,7 +81,7 @@ class Question
*/
public function setHidden($hidden)
{
if ($this->autocompleterValues) {
if ($this->autocompleterCallback) {
throw new LogicException('A hidden question cannot use the autocompleter.');
}
@ -121,7 +121,9 @@ class Question
*/
public function getAutocompleterValues()
{
return $this->autocompleterValues;
$callback = $this->getAutocompleterCallback();
return $callback ? $callback('') : null;
}
/**
@ -138,17 +140,46 @@ class Question
{
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;
}

View File

@ -198,6 +198,67 @@ class QuestionHelperTest extends AbstractQuestionHelperTest
$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()) {

View File

@ -0,0 +1,257 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Tests\Question;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Question\Question;
class QuestionTest extends TestCase
{
private $question;
protected function setUp()
{
parent::setUp();
$this->question = new Question('Test question');
}
public function providerTrueFalse()
{
return [[true], [false]];
}
public function testGetQuestion()
{
self::assertSame('Test question', $this->question->getQuestion());
}
public function testGetDefault()
{
$question = new Question('Test question', 'Default value');
self::assertSame('Default value', $question->getDefault());
}
public function testGetDefaultDefault()
{
self::assertNull($this->question->getDefault());
}
/**
* @dataProvider providerTrueFalse
*/
public function testIsSetHidden(bool $hidden)
{
$this->question->setHidden($hidden);
self::assertSame($hidden, $this->question->isHidden());
}
public function testIsHiddenDefault()
{
self::assertFalse($this->question->isHidden());
}
public function testSetHiddenWithAutocompleterValues()
{
$this->question->setAutocompleterValues(['a', 'b']);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage(
'A hidden question cannot use the autocompleter.'
);
$this->question->setHidden(true);
}
public function testSetHiddenWithNoAutocompleterValues()
{
$this->question->setAutocompleterValues(['a', 'b']);
$this->question->setAutocompleterValues(null);
$exception = null;
try {
$this->question->setHidden(true);
} catch (\Exception $exception) {
// Do nothing
}
$this->assertNull($exception);
}
/**
* @dataProvider providerTrueFalse
*/
public function testIsSetHiddenFallback(bool $hidden)
{
$this->question->setHiddenFallback($hidden);
self::assertSame($hidden, $this->question->isHiddenFallback());
}
public function testIsHiddenFallbackDefault()
{
self::assertTrue($this->question->isHiddenFallback());
}
public function providerGetSetAutocompleterValues()
{
return [
'array' => [
['a', 'b', 'c', 'd'],
['a', 'b', 'c', 'd'],
],
'associative array' => [
['a' => 'c', 'b' => 'd'],
['a', 'b', 'c', 'd'],
],
'iterator' => [
new \ArrayIterator(['a', 'b', 'c', 'd']),
['a', 'b', 'c', 'd'],
],
'null' => [null, null],
];
}
/**
* @dataProvider providerGetSetAutocompleterValues
*/
public function testGetSetAutocompleterValues($values, $expectValues)
{
$this->question->setAutocompleterValues($values);
self::assertSame(
$expectValues,
$this->question->getAutocompleterValues()
);
}
public function providerSetAutocompleterValuesInvalid()
{
return [
['Potato'],
[new \stdclass()],
[false],
];
}
/**
* @dataProvider providerSetAutocompleterValuesInvalid
*/
public function testSetAutocompleterValuesInvalid($values)
{
self::expectException(InvalidArgumentException::class);
self::expectExceptionMessage(
'Autocompleter values can be either an array, "null" or a "Traversable" object.'
);
$this->question->setAutocompleterValues($values);
}
public function testSetAutocompleterValuesWhenHidden()
{
$this->question->setHidden(true);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage(
'A hidden question cannot use the autocompleter.'
);
$this->question->setAutocompleterValues(['a', 'b']);
}
public function testSetAutocompleterValuesWhenNotHidden()
{
$this->question->setHidden(true);
$this->question->setHidden(false);
$exception = null;
try {
$this->question->setAutocompleterValues(['a', 'b']);
} catch (\Exception $exception) {
// Do nothing
}
$this->assertNull($exception);
}
public function testGetAutocompleterValuesDefault()
{
self::assertNull($this->question->getAutocompleterValues());
}
public function providerGetSetValidator()
{
return [
[function ($input) { return $input; }],
[null],
];
}
/**
* @dataProvider providerGetSetValidator
*/
public function testGetSetValidator($callback)
{
$this->question->setValidator($callback);
self::assertSame($callback, $this->question->getValidator());
}
public function testGetValidatorDefault()
{
self::assertNull($this->question->getValidator());
}
public function providerGetSetMaxAttempts()
{
return [[1], [5], [null]];
}
/**
* @dataProvider providerGetSetMaxAttempts
*/
public function testGetSetMaxAttempts($attempts)
{
$this->question->setMaxAttempts($attempts);
self::assertSame($attempts, $this->question->getMaxAttempts());
}
public function providerSetMaxAttemptsInvalid()
{
return [['Potato'], [0], [-1]];
}
/**
* @dataProvider providerSetMaxAttemptsInvalid
*/
public function testSetMaxAttemptsInvalid($attempts)
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('Maximum number of attempts must be a positive value.');
$this->question->setMaxAttempts($attempts);
}
public function testGetMaxAttemptsDefault()
{
self::assertNull($this->question->getMaxAttempts());
}
public function testGetSetNormalizer()
{
$normalizer = function ($input) { return $input; };
$this->question->setNormalizer($normalizer);
self::assertSame($normalizer, $this->question->getNormalizer());
}
public function testGetNormalizerDefault()
{
self::assertNull($this->question->getNormalizer());
}
}