Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 425c202

Browse files
committed
feature #30997 [Console] Add callback support to Console\Question autocompleter (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 ------- caad562c11 [Console] Add callback support to Console\Question autocompleter
2 parents 26b32d3 + 9bfafbf commit 425c202

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)