Skip to content

Commit 9bfafbf

Browse files
MikkelPaulsonnicolas-grekas
authored andcommitted
[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.
1 parent 93d8bd2 commit 9bfafbf

File tree

5 files changed

+375
-19
lines changed

5 files changed

+375
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ CHANGELOG
66

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

1012
4.2.0
1113
-----

Helper/QuestionHelper.php

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ private function doAsk(OutputInterface $output, Question $question)
115115
$this->writePrompt($output, $question);
116116

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

120120
if (null === $autocomplete || !$this->hasSttyAvailable()) {
121121
$ret = false;
@@ -137,7 +137,7 @@ private function doAsk(OutputInterface $output, Question $question)
137137
$ret = trim($ret);
138138
}
139139
} else {
140-
$ret = trim($this->autocomplete($output, $question, $inputStream, \is_array($autocomplete) ? $autocomplete : iterator_to_array($autocomplete, false)));
140+
$ret = trim($this->autocomplete($output, $question, $inputStream, $autocomplete));
141141
}
142142

143143
if ($output instanceof ConsoleSectionOutput) {
@@ -194,17 +194,15 @@ protected function writeError(OutputInterface $output, \Exception $error)
194194
/**
195195
* Autocompletes a question.
196196
*
197-
* @param OutputInterface $output
198-
* @param Question $question
199-
* @param resource $inputStream
197+
* @param resource $inputStream
200198
*/
201-
private function autocomplete(OutputInterface $output, Question $question, $inputStream, array $autocomplete): string
199+
private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string
202200
{
203201
$ret = '';
204202

205203
$i = 0;
206204
$ofs = -1;
207-
$matches = $autocomplete;
205+
$matches = $autocomplete($ret);
208206
$numMatches = \count($matches);
209207

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

233231
if (0 === $i) {
234232
$ofs = -1;
235-
$matches = $autocomplete;
233+
$matches = $autocomplete($ret);
236234
$numMatches = \count($matches);
237235
} else {
238236
$numMatches = 0;
@@ -260,18 +258,25 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
260258
} elseif (\ord($c) < 32) {
261259
if ("\t" === $c || "\n" === $c) {
262260
if ($numMatches > 0 && -1 !== $ofs) {
263-
$ret = $matches[$ofs];
261+
$ret = (string) $matches[$ofs];
264262
// Echo out remaining chars for current match
265263
$output->write(substr($ret, $i));
266264
$i = \strlen($ret);
265+
266+
$matches = array_filter(
267+
$autocomplete($ret),
268+
function ($match) use ($ret) {
269+
return '' === $ret || 0 === strpos($match, $ret);
270+
}
271+
);
272+
$numMatches = \count($matches);
273+
$ofs = -1;
267274
}
268275

269276
if ("\n" === $c) {
270277
$output->write($c);
271278
break;
272279
}
273-
274-
$numMatches = 0;
275280
}
276281

277282
continue;
@@ -287,7 +292,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
287292
$numMatches = 0;
288293
$ofs = 0;
289294

290-
foreach ($autocomplete as $value) {
295+
foreach ($autocomplete($ret) as $value) {
291296
// If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
292297
if (0 === strpos($value, $ret)) {
293298
$matches[$numMatches++] = $value;

Question/Question.php

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class Question
2525
private $attempts;
2626
private $hidden = false;
2727
private $hiddenFallback = true;
28-
private $autocompleterValues;
28+
private $autocompleterCallback;
2929
private $validator;
3030
private $default;
3131
private $normalizer;
@@ -81,7 +81,7 @@ public function isHidden()
8181
*/
8282
public function setHidden($hidden)
8383
{
84-
if ($this->autocompleterValues) {
84+
if ($this->autocompleterCallback) {
8585
throw new LogicException('A hidden question cannot use the autocompleter.');
8686
}
8787

@@ -121,7 +121,9 @@ public function setHiddenFallback($fallback)
121121
*/
122122
public function getAutocompleterValues()
123123
{
124-
return $this->autocompleterValues;
124+
$callback = $this->getAutocompleterCallback();
125+
126+
return $callback ? $callback('') : null;
125127
}
126128

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

143-
if (null !== $values && !\is_array($values) && !$values instanceof \Traversable) {
144+
$callback = static function () use ($values) {
145+
return $values;
146+
};
147+
} elseif ($values instanceof \Traversable) {
148+
$valueCache = null;
149+
$callback = static function () use ($values, &$valueCache) {
150+
return $valueCache ?? $valueCache = iterator_to_array($values, false);
151+
};
152+
} elseif (null === $values) {
153+
$callback = null;
154+
} else {
144155
throw new InvalidArgumentException('Autocompleter values can be either an array, "null" or a "Traversable" object.');
145156
}
146157

147-
if ($this->hidden) {
158+
return $this->setAutocompleterCallback($callback);
159+
}
160+
161+
/**
162+
* Gets the callback function used for the autocompleter.
163+
*/
164+
public function getAutocompleterCallback(): ?callable
165+
{
166+
return $this->autocompleterCallback;
167+
}
168+
169+
/**
170+
* Sets the callback function used for the autocompleter.
171+
*
172+
* The callback is passed the user input as argument and should return an iterable of corresponding suggestions.
173+
*
174+
* @return $this
175+
*/
176+
public function setAutocompleterCallback(callable $callback = null): self
177+
{
178+
if ($this->hidden && null !== $callback) {
148179
throw new LogicException('A hidden question cannot use the autocompleter.');
149180
}
150181

151-
$this->autocompleterValues = $values;
182+
$this->autocompleterCallback = $callback;
152183

153184
return $this;
154185
}

Tests/Helper/QuestionHelperTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,67 @@ public function testAskWithAutocomplete()
198198
$this->assertEquals('FooBundle', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question));
199199
}
200200

201+
public function testAskWithAutocompleteCallback()
202+
{
203+
if (!$this->hasSttyAvailable()) {
204+
$this->markTestSkipped('`stty` is required to test autocomplete functionality');
205+
}
206+
207+
// Po<TAB>Cr<TAB>P<DOWN ARROW><DOWN ARROW><NEWLINE>
208+
$inputStream = $this->getInputStream("Pa\177\177o\tCr\t\033[A\033[A\033[A\n");
209+
210+
$dialog = new QuestionHelper();
211+
$helperSet = new HelperSet([new FormatterHelper()]);
212+
$dialog->setHelperSet($helperSet);
213+
214+
$question = new Question('What\'s for dinner?');
215+
216+
// A simple test callback - return an array containing the words the
217+
// user has already completed, suffixed with all known words.
218+
//
219+
// Eg: If the user inputs "Potato C", the return will be:
220+
//
221+
// ["Potato Carrot ", "Potato Creme ", "Potato Curry ", ...]
222+
//
223+
// No effort is made to avoid irrelevant suggestions, as this is handled
224+
// by the autocomplete function.
225+
$callback = function ($input) {
226+
$knownWords = [
227+
'Carrot',
228+
'Creme',
229+
'Curry',
230+
'Parsnip',
231+
'Pie',
232+
'Potato',
233+
'Tart',
234+
];
235+
236+
$inputWords = explode(' ', $input);
237+
$lastInputWord = array_pop($inputWords);
238+
$suggestionBase = $inputWords
239+
? implode(' ', $inputWords).' '
240+
: '';
241+
242+
return array_map(
243+
function ($word) use ($suggestionBase) {
244+
return $suggestionBase.$word.' ';
245+
},
246+
$knownWords
247+
);
248+
};
249+
250+
$question->setAutocompleterCallback($callback);
251+
252+
$this->assertSame(
253+
'Potato Creme Pie',
254+
$dialog->ask(
255+
$this->createStreamableInputInterfaceMock($inputStream),
256+
$this->createOutputInterface(),
257+
$question
258+
)
259+
);
260+
}
261+
201262
public function testAskWithAutocompleteWithNonSequentialKeys()
202263
{
203264
if (!$this->hasSttyAvailable()) {

0 commit comments

Comments
 (0)