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