From caad562c11eeb76c7f4b1d39e0c024d41e616a09 Mon Sep 17 00:00:00 2001 From: Mikkel Paulson Date: Sun, 7 Apr 2019 15:22:28 -0400 Subject: [PATCH] [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. --- src/Symfony/Component/Console/CHANGELOG.md | 2 + .../Console/Helper/QuestionHelper.php | 29 +- .../Component/Console/Question/Question.php | 45 ++- .../Tests/Helper/QuestionHelperTest.php | 61 +++++ .../Console/Tests/Question/QuestionTest.php | 257 ++++++++++++++++++ 5 files changed, 375 insertions(+), 19 deletions(-) create mode 100644 src/Symfony/Component/Console/Tests/Question/QuestionTest.php diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index ff7973319c..67decd30be 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -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 ----- diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index b8b76833a6..75e660a3fb 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -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; diff --git a/src/Symfony/Component/Console/Question/Question.php b/src/Symfony/Component/Console/Question/Question.php index eac82cfad3..9201af2fd5 100644 --- a/src/Symfony/Component/Console/Question/Question.php +++ b/src/Symfony/Component/Console/Question/Question.php @@ -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; } diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php index 69d5470b8c..fc0f2293a4 100644 --- a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -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'); + } + + // PoCrP + $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()) { diff --git a/src/Symfony/Component/Console/Tests/Question/QuestionTest.php b/src/Symfony/Component/Console/Tests/Question/QuestionTest.php new file mode 100644 index 0000000000..537cd30144 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Question/QuestionTest.php @@ -0,0 +1,257 @@ + + * + * 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()); + } +}