Skip to content

Commit 9447177

Browse files
authored
Merge pull request #41 from clue-labs/autocomplete
Add autocomplete support
2 parents 3567ff6 + 95908e7 commit 9447177

File tree

6 files changed

+608
-51
lines changed

6 files changed

+608
-51
lines changed

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Async, event-driven and UTF-8 aware standard console input & output (STDIN, STDO
1515
* [Input buffer](#input-buffer)
1616
* [Cursor](#cursor)
1717
* [History](#history)
18+
* [Autocomplete](#autocomplete)
1819
* [Advanced](#advanced)
1920
* [Stdout](#stdout)
2021
* [Stdin](#stdin)
@@ -377,6 +378,101 @@ or a single `listHistory()` call respectively should be fairly straight
377378
forward and is left up as an exercise for the reader of this documentation
378379
(i.e. *you*).
379380

381+
#### Autocomplete
382+
383+
By default, users can use autocompletion by using their TAB keys on the keyboard.
384+
The autocomplete function is not registered by default, thus this feature is
385+
effectively disabled, as the TAB key has no function then.
386+
387+
The `setAutocomplete(?callable $autocomplete): Readline` method can be used to
388+
register a new autocomplete handler.
389+
In its most simple form, you won't need to assign any arguments and can simply
390+
return an array of possible word matches from a callable like this:
391+
392+
```php
393+
$readline->setAutocomplete(function () {
394+
return array(
395+
'exit',
396+
'echo',
397+
'help',
398+
);
399+
});
400+
```
401+
402+
If the user types `he [TAB]`, the first match will be skipped as it does not
403+
match the current word prefix and the second one will be picked automatically,
404+
so that the resulting input buffer is `hello `.
405+
406+
If the user types `e [TAB]`, then this will match multiple entries and the user
407+
will be presented with a list of up to 8 available word completions to choose
408+
from like this:
409+
410+
```php
411+
> e [TAB]
412+
exit echo
413+
> e
414+
```
415+
416+
Unless otherwise specified, the matches will be performed against the current
417+
word boundaries in the input buffer.
418+
This means that if the user types `hello [SPACE] ex [TAB]`, then the resulting
419+
input buffer is `hello exit `, which may or may not be what you need depending
420+
on your particular use case.
421+
422+
In order to give your more control over this behavior, the autocomplete function
423+
actually receives three arguments (similar to `ext-readline`'s
424+
[`readline_completion_function()`](http://php.net/manual/en/function.readline-completion-function.php)):
425+
The first argument will be the current incomplete word according to current
426+
cursor position and word boundaries, while the second and third argument will be
427+
the start and end offset of this word within the complete input buffer measured
428+
in (Unicode) characters.
429+
The above examples will be invoked as `$fn('he', 0, 2)`, `$fn('e', 0, 1)` and
430+
`$fn('ex', 6, 8)` respectively.
431+
You may want to use this as an `$offset` argument to check if the current word
432+
is an argument or a root command and the `$word` argument to autocomplete
433+
partial filename matches like this:
434+
435+
```php
436+
$readline->setAutocomplete(function ($word, $offset) {
437+
if ($offset <= 1) {
438+
// autocomplete root commands at offset=0/1 only
439+
return array('cat', 'rm', 'stat');
440+
} else {
441+
// autocomplete all command arguments as glob pattern
442+
return glob($word . '*', GLOB_MARK);
443+
}
444+
});
445+
```
446+
447+
> Note that the user may also use quotes and/or leading whitespace around the
448+
root command, for example `"hell [TAB]`, in which case the offset will be
449+
advanced such as this will be invoked as `$fn('hell', 1, 4)`.
450+
Unless you use a more sophisticated argument parser, a decent approximation may
451+
be using `$offset <= 1` to check this is a root command.
452+
453+
If you need even more control over autocompletion, you may also want to access
454+
and/or manipulate the [input buffer](#input-buffer) and [cursor](#cursor)
455+
directly like this:
456+
457+
```php
458+
$readline->setAutocomplete(function () use ($readline) {
459+
if ($readline->getInput() === 'run') {
460+
$readline->setInput('run --test --value=42');
461+
$readline->moveCursorBy(-2);
462+
}
463+
464+
// return empty array so normal autocompletion doesn't kick in
465+
return array();
466+
});
467+
```
468+
469+
You can use a `null` value to remove the autocomplete function again and thus
470+
disable the autocomplete function:
471+
472+
```php
473+
$readline->setAutocomplete(null);
474+
```
475+
380476
### Advanced
381477

382478
#### Stdout

examples/periodic.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131
}
3232
});
3333

34+
// autocomplete the following commands (at offset=0/1 only)
35+
$readline->setAutocomplete(function ($_, $offset) {
36+
return $offset > 1 ? array() : array('exit', 'quit', 'help', 'echo', 'print', 'printf');
37+
});
38+
3439
$stdio->writeln('Will print periodic messages until you type "quit" or "exit"');
3540

3641
$stdio->on('line', function ($line) use ($stdio, $loop, &$timer) {
3742
$stdio->writeln('you just said: ' . $line . ' (' . strlen($line) . ')');
3843

39-
if ($line === 'quit' || $line === 'exit') {
44+
if (in_array(trim($line), array('quit', 'exit'))) {
4045
$timer->cancel();
4146
$stdio->end();
4247
}

src/Autocomplete.php

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/Readline.php

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ class Readline extends EventEmitter implements ReadableStreamInterface
1515
private $linebuffer = '';
1616
private $linepos = 0;
1717
private $echo = true;
18-
private $autocomplete = null;
1918
private $move = true;
2019
private $encoding = 'utf-8';
2120

@@ -29,6 +28,9 @@ class Readline extends EventEmitter implements ReadableStreamInterface
2928
private $historyUnsaved = null;
3029
private $historyLimit = 500;
3130

31+
private $autocomplete = null;
32+
private $autocompleteSuggestions = 8;
33+
3234
public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output)
3335
{
3436
$this->input = $input;
@@ -37,7 +39,6 @@ public function __construct(ReadableStreamInterface $input, WritableStreamInterf
3739
if (!$this->input->isReadable()) {
3840
return $this->close();
3941
}
40-
4142
// push input through control code parser
4243
$parser = new ControlCodeParser($input);
4344

@@ -387,16 +388,22 @@ public function limitHistory($limit)
387388
}
388389

389390
/**
390-
* set autocompletion handler to use (or none)
391+
* set autocompletion handler to use
391392
*
392393
* The autocomplete handler will be called whenever the user hits the TAB
393394
* key.
394395
*
395-
* @param AutocompleteInterface|null $autocomplete
396+
* @param callable|null $autocomplete
396397
* @return self
398+
* @throws InvalidArgumentException if the given callable is invalid
397399
*/
398-
public function setAutocomplete(AutocompleteInterface $autocomplete = null)
400+
401+
public function setAutocomplete($autocomplete)
399402
{
403+
if ($autocomplete !== null && !is_callable($autocomplete)) {
404+
throw new \InvalidArgumentException('Invalid autocomplete function given');
405+
}
406+
400407
$this->autocomplete = $autocomplete;
401408

402409
return $this;
@@ -495,9 +502,110 @@ public function onKeyEnd()
495502
/** @internal */
496503
public function onKeyTab()
497504
{
498-
if ($this->autocomplete !== null) {
499-
$this->autocomplete->run();
505+
if ($this->autocomplete === null) {
506+
return;
507+
}
508+
509+
// current word prefix and offset for start of word in input buffer
510+
// "echo foo|bar world" will return just "foo" with word offset 5
511+
$word = $this->substr($this->linebuffer, 0, $this->linepos);
512+
$start = 0;
513+
$end = $this->linepos;
514+
515+
// buffer prefix and postfix for everything that will *not* be matched
516+
// above example will return "echo " and "bar world"
517+
$prefix = '';
518+
$postfix = $this->substr($this->linebuffer, $this->linepos);
519+
520+
// skip everything before last space
521+
$pos = strrpos($word, ' ');
522+
if ($pos !== false) {
523+
$prefix = (string)substr($word, 0, $pos + 1);
524+
$word = (string)substr($word, $pos + 1);
525+
$start = $this->strlen($prefix);
500526
}
527+
528+
// skip double quote (") or single quote (') from argument
529+
$quote = null;
530+
if (isset($word[0]) && ($word[0] === '"' || $word[0] === '\'')) {
531+
$quote = $word[0];
532+
++$start;
533+
$prefix .= $word[0];
534+
$word = (string)substr($word, 1);
535+
}
536+
537+
// invoke autocomplete callback
538+
$words = call_user_func($this->autocomplete, $word, $start, $end);
539+
540+
// return early if autocomplete does not return anything
541+
if ($words === null) {
542+
return;
543+
}
544+
545+
// remove from list of possible words that do not start with $word or are duplicates
546+
$words = array_unique($words);
547+
if ($word !== '' && $words) {
548+
$words = array_filter($words, function ($w) use ($word) {
549+
return strpos($w, $word) === 0;
550+
});
551+
}
552+
553+
// return if neither of the possible words match
554+
if (!$words) {
555+
return;
556+
}
557+
558+
// search longest common prefix among all possible matches
559+
$found = reset($words);
560+
$all = count($words);
561+
if ($all > 1) {
562+
while ($found !== '') {
563+
// count all words that start with $found
564+
$matches = count(array_filter($words, function ($w) use ($found) {
565+
return strpos($w, $found) === 0;
566+
}));
567+
568+
// ALL words match $found => common substring found
569+
if ($all === $matches) {
570+
break;
571+
}
572+
573+
// remove last letter from $found and try again
574+
$found = $this->substr($found, 0, -1);
575+
}
576+
577+
// found more than one possible match with this prefix => print options
578+
if ($found === $word || $found === '') {
579+
// limit number of possible matches
580+
if (count($words) > $this->autocompleteSuggestions) {
581+
$more = count($words) - ($this->autocompleteSuggestions - 1);
582+
$words = array_slice($words, 0, $this->autocompleteSuggestions - 1);
583+
$words []= '(+' . $more . ' others)';
584+
}
585+
586+
$this->output->write("\n" . implode(' ', $words) . "\n");
587+
$this->redraw();
588+
589+
return;
590+
}
591+
}
592+
593+
if ($quote !== null && $all === 1 && (strpos($postfix, $quote) === false || strpos($postfix, $quote) > strpos($postfix, ' '))) {
594+
// add closing quote if word started in quotes and postfix does not already contain closing quote before next space
595+
$found .= $quote;
596+
} elseif ($found === '') {
597+
// add single quotes around empty match
598+
$found = '\'\'';
599+
}
600+
601+
if ($postfix === '' && $all === 1) {
602+
// append single space after match unless there's a postfix or there are multiple completions
603+
$found .= ' ';
604+
}
605+
606+
// replace word in input with best match and adjust cursor
607+
$this->linebuffer = $prefix . $found . $postfix;
608+
$this->moveCursorBy($this->strlen($found) - $this->strlen($word));
501609
}
502610

503611
/** @internal */

0 commit comments

Comments
 (0)