input = $input;
$this->output = $output;
if (!$this->input->isReadable()) {
$this->close();
return;
}
// push input through control code parser
$parser = new ControlCodeParser($input);
$that = $this;
$codes = array(
"\n" => 'onKeyEnter', // ^J
"\x7f" => 'onKeyBackspace', // ^?
"\t" => 'onKeyTab', // ^I
"\x04" => 'handleEnd', // ^D
"\033[A" => 'onKeyUp',
"\033[B" => 'onKeyDown',
"\033[C" => 'onKeyRight',
"\033[D" => 'onKeyLeft',
"\033[1~" => 'onKeyHome',
// "\033[2~" => 'onKeyInsert',
"\033[3~" => 'onKeyDelete',
"\033[4~" => 'onKeyEnd',
// "\033[20~" => 'onKeyF10',
);
$decode = function ($code) use ($codes, $that, $base) {
// The user confirms input with enter key which should usually
// generate a NL (`\n`) character. Common terminals also seem to
// accept a CR (`\r`) character in place and handle this just like a
// NL. Similarly `ext-readline` uses different `icrnl` and `igncr`
// TTY settings on some platforms, so we also accept CR as an alias
// for NL here. This implies key binding for NL will also trigger.
if ($code === "\r") {
$code = "\n";
}
// forward compatibility: check if any key binding exists on base Stdio instance
if ($base !== null && $base->listeners($code)) {
$base->emit($code, array($code));
return;
}
// deprecated: check if any key binding exists on this Readline instance
if ($that->listeners($code)) {
$that->emit($code, array($code));
return;
}
if (isset($codes[$code])) {
$method = $codes[$code];
$that->$method($code);
return;
}
};
$parser->on('csi', $decode);
$parser->on('c0', $decode);
// push resulting data through utf8 sequencer
$utf8 = new Utf8Sequencer($parser);
$utf8->on('data', function ($data) use ($that, $base) {
$that->onFallback($data, $base);
});
// process all stream events (forwarded from input stream)
$utf8->on('end', array($this, 'handleEnd'));
$utf8->on('error', array($this, 'handleError'));
$utf8->on('close', array($this, 'close'));
}
/**
* prompt to prepend to input line
*
* Will redraw the current input prompt with the current input buffer.
*
* @param string $prompt
* @return self
* @uses self::redraw()
* @deprecated use Stdio::setPrompt() instead
*/
public function setPrompt($prompt)
{
if ($prompt === $this->prompt) {
return $this;
}
$this->prompt = $prompt;
return $this->redraw();
}
/**
* returns the prompt to prepend to input line
*
* @return string
* @see self::setPrompt()
* @deprecated use Stdio::getPrompt() instead
*/
public function getPrompt()
{
return $this->prompt;
}
/**
* sets whether/how to echo text input
*
* The default setting is `true`, which means that every character will be
* echo'ed as-is, i.e. you can see what you're typing.
* For example: Typing "test" shows "test".
*
* You can turn this off by supplying `false`, which means that *nothing*
* will be echo'ed while you're typing. This could be a good idea for
* password prompts. Note that this could be confusing for users, so using
* a character replacement as following is often preferred.
* For example: Typing "test" shows "" (nothing).
*
* Alternative, you can supply a single character replacement character
* that will be echo'ed for each character in the text input. This could
* be a good idea for password prompts, where an asterisk character ("*")
* is often used to indicate typing activity and password length.
* For example: Typing "test" shows "****" (with asterisk replacement)
*
* Changing this setting will redraw the current prompt and echo the current
* input buffer according to the new setting.
*
* @param boolean|string $echo echo can be turned on (boolean true) or off (boolean true), or you can supply a single character replacement string
* @return self
* @uses self::redraw()
* @deprecated use Stdio::setEcho() instead
*/
public function setEcho($echo)
{
if ($echo === $this->echo) {
return $this;
}
$this->echo = $echo;
// only redraw if there is any input
if ($this->linebuffer !== '') {
$this->redraw();
}
return $this;
}
/**
* whether or not to support moving cursor left and right
*
* switching cursor support moves the cursor to the end of the current
* input buffer (if any).
*
* @param boolean $move
* @return self
* @uses self::redraw()
* @deprecated use Stdio::setMove() instead
*/
public function setMove($move)
{
$this->move = !!$move;
return $this->moveCursorTo($this->strlen($this->linebuffer));
}
/**
* Gets current cursor position measured in number of text characters.
*
* Note that the number of text characters doesn't necessarily reflect the
* number of monospace cells occupied by the text characters. If you want
* to know the latter, use `self::getCursorCell()` instead.
*
* @return int
* @see self::getCursorCell() to get the position measured in monospace cells
* @see self::moveCursorTo() to move the cursor to a given character position
* @see self::moveCursorBy() to move the cursor by given number of characters
* @see self::setMove() to toggle whether the user can move the cursor position
* @deprecated use Stdio::getCursorPosition() instead
*/
public function getCursorPosition()
{
return $this->linepos;
}
/**
* Gets current cursor position measured in monospace cells.
*
* Note that the cell position doesn't necessarily reflect the number of
* text characters. If you want to know the latter, use
* `self::getCursorPosition()` instead.
*
* Most "normal" characters occupy a single monospace cell, i.e. the ASCII
* sequence for "A" requires a single cell, as do most UTF-8 sequences
* like "Ä".
*
* However, there are a number of code points that do not require a cell
* (i.e. invisible surrogates) or require two cells (e.g. some asian glyphs).
*
* Also note that this takes the echo mode into account, i.e. the cursor is
* always at position zero if echo is off. If using a custom echo character
* (like asterisk), it will take its width into account instead of the actual
* input characters.
*
* @return int
* @see self::getCursorPosition() to get current cursor position measured in characters
* @see self::moveCursorTo() to move the cursor to a given character position
* @see self::moveCursorBy() to move the cursor by given number of characters
* @see self::setMove() to toggle whether the user can move the cursor position
* @see self::setEcho()
* @deprecated use Stdio::getCursorCell() instead
*/
public function getCursorCell()
{
if ($this->echo === false) {
return 0;
}
if ($this->echo !== true) {
return $this->strwidth($this->echo) * $this->linepos;
}
return $this->strwidth($this->substr($this->linebuffer, 0, $this->linepos));
}
/**
* Moves cursor to right by $n chars (or left if $n is negative).
*
* Zero value or values out of range (exceeding current input buffer) are
* simply ignored.
*
* Will redraw() the readline only if the visible cell position changes,
* see `self::getCursorCell()` for more details.
*
* @param int $n
* @return self
* @uses self::moveCursorTo()
* @uses self::redraw()
* @deprecated use Stdio::moveCursorBy() instead
*/
public function moveCursorBy($n)
{
return $this->moveCursorTo($this->linepos + $n);
}
/**
* Moves cursor to given position in current line buffer.
*
* Values out of range (exceeding current input buffer) are simply ignored.
*
* Will redraw() the readline only if the visible cell position changes,
* see `self::getCursorCell()` for more details.
*
* @param int $n
* @return self
* @uses self::redraw()
* @deprecated use Stdio::moveCursorTo() instead
*/
public function moveCursorTo($n)
{
if ($n < 0 || $n === $this->linepos || $n > $this->strlen($this->linebuffer)) {
return $this;
}
$old = $this->getCursorCell();
$this->linepos = $n;
// only redraw if visible cell position change (implies cursor is actually visible)
if ($this->getCursorCell() !== $old) {
$this->redraw();
}
return $this;
}
/**
* Appends the given input to the current text input buffer at the current position
*
* This moves the cursor accordingly to the number of characters added.
*
* @param string $input
* @return self
* @uses self::redraw()
* @deprecated use Stdio::addInput() instead
*/
public function addInput($input)
{
if ($input === '') {
return $this;
}
// read everything up until before current position
$pre = $this->substr($this->linebuffer, 0, $this->linepos);
$post = $this->substr($this->linebuffer, $this->linepos);
$this->linebuffer = $pre . $input . $post;
$this->linepos += $this->strlen($input);
// only redraw if input should be echo'ed (i.e. is not hidden anyway)
if ($this->echo !== false) {
$this->redraw();
}
return $this;
}
/**
* set current text input buffer
*
* this moves the cursor to the end of the current
* input buffer (if any).
*
* @param string $input
* @return self
* @uses self::redraw()
* @deprecated use Stdio::setInput() instead
*/
public function setInput($input)
{
if ($this->linebuffer === $input) {
return $this;
}
// remember old input length if echo replacement is used
$oldlen = (is_string($this->echo)) ? $this->strlen($this->linebuffer) : null;
$this->linebuffer = $input;
$this->linepos = $this->strlen($this->linebuffer);
// only redraw if input should be echo'ed (i.e. is not hidden anyway)
// and echo replacement is used, make sure the input length changes
if ($this->echo !== false && $this->linepos !== $oldlen) {
$this->redraw();
}
return $this;
}
/**
* get current text input buffer
*
* @return string
* @deprecated use Stdio::getInput() instead
*/
public function getInput()
{
return $this->linebuffer;
}
/**
* Adds a new line to the (bottom position of the) history list
*
* @param string $line
* @return self
* @uses self::limitHistory() to make sure list does not exceed limits
* @deprecated use Stdio::addHistory() instead
*/
public function addHistory($line)
{
$this->historyLines []= $line;
return $this->limitHistory($this->historyLimit);
}
/**
* Clears the complete history list
*
* @return self
* @deprecated use Stdio::clearHistory() instead
*/
public function clearHistory()
{
$this->historyLines = array();
$this->historyPosition = null;
if ($this->historyUnsaved !== null) {
$this->setInput($this->historyUnsaved);
$this->historyUnsaved = null;
}
return $this;
}
/**
* Returns an array with all lines in the history
*
* @return string[]
* @deprecated use Stdio::listHistory() instead
*/
public function listHistory()
{
return $this->historyLines;
}
/**
* Limits the history to a maximum of N entries and truncates the current history list accordingly
*
* @param int|null $limit
* @return self
* @deprecated use Stdio::limitHistory() instead
*/
public function limitHistory($limit)
{
$this->historyLimit = $limit === null ? null : $limit;
// limit send and currently exceeded
if ($this->historyLimit !== null && isset($this->historyLines[$this->historyLimit])) {
// adjust position in history according to new position after applying limit
if ($this->historyPosition !== null) {
$this->historyPosition -= count($this->historyLines) - $this->historyLimit;
// current position will drop off from list => restore original
if ($this->historyPosition < 0) {
$this->setInput($this->historyUnsaved);
$this->historyPosition = null;
$this->historyUnsaved = null;
}
}
$this->historyLines = array_slice($this->historyLines, -$this->historyLimit, $this->historyLimit);
}
return $this;
}
/**
* set autocompletion handler to use
*
* The autocomplete handler will be called whenever the user hits the TAB
* key.
*
* @param callable|null $autocomplete
* @return self
* @throws \InvalidArgumentException if the given callable is invalid
* @deprecated use Stdio::setAutocomplete() instead
*/
public function setAutocomplete($autocomplete)
{
if ($autocomplete !== null && !is_callable($autocomplete)) {
throw new \InvalidArgumentException('Invalid autocomplete function given');
}
$this->autocomplete = $autocomplete;
return $this;
}
/**
* Whether or not to emit a audible/visible BELL signal when using a disabled function
*
* By default, this class will emit a BELL signal when using a disable function,
* such as using the left or backspace keys when
* already at the beginning of the line.
*
* Whether or not the BELL is audible/visible depends on the termin and its
* settings, i.e. some terminals may "beep" or flash the screen or emit a
* short vibration.
*
* @param bool $bell
* @return void
* @internal use Stdio::setBell() instead
*/
public function setBell($bell)
{
$this->bell = (bool)$bell;
}
/**
* redraw the current input prompt
*
* Usually, there should be no need to call this method manually. It will
* be invoked automatically whenever we detect the readline input needs to
* be (re)written to the output.
*
* Clear the current line and draw the input prompt. If input echo is
* enabled, will also draw the current input buffer and move to the current
* input buffer position.
*
* @return self
* @internal
*/
public function redraw()
{
// Erase characters from cursor to end of line and then redraw actual input
$this->output->write("\r\033[K" . $this->getDrawString());
return $this;
}
/**
* Returns the string that is used to draw the input prompt
*
* @return string
* @internal
*/
public function getDrawString()
{
$output = $this->prompt;
if ($this->echo !== false) {
if ($this->echo === true) {
$buffer = $this->linebuffer;
} else {
$buffer = str_repeat($this->echo, $this->strlen($this->linebuffer));
}
// write output, then move back $reverse chars (by sending backspace)
$output .= $buffer . str_repeat("\x08", $this->strwidth($buffer) - $this->getCursorCell());
}
return $output;
}
/** @internal */
public function onKeyBackspace()
{
// left delete only if not at the beginning
if ($this->linepos === 0) {
$this->bell();
} else {
$this->deleteChar($this->linepos - 1);
}
}
/** @internal */
public function onKeyDelete()
{
// right delete only if not at the end
if ($this->isEol()) {
$this->bell();
} else {
$this->deleteChar($this->linepos);
}
}
/** @internal */
public function onKeyHome()
{
if ($this->move && $this->linepos !== 0) {
$this->moveCursorTo(0);
} else {
$this->bell();
}
}
/** @internal */
public function onKeyEnd()
{
if ($this->move && !$this->isEol()) {
$this->moveCursorTo($this->strlen($this->linebuffer));
} else {
$this->bell();
}
}
/** @internal */
public function onKeyTab()
{
if ($this->autocomplete === null) {
$this->bell();
return;
}
// current word prefix and offset for start of word in input buffer
// "echo foo|bar world" will return just "foo" with word offset 5
$word = $this->substr($this->linebuffer, 0, $this->linepos);
$start = 0;
$end = $this->linepos;
// buffer prefix and postfix for everything that will *not* be matched
// above example will return "echo " and "bar world"
$prefix = '';
$postfix = $this->substr($this->linebuffer, $this->linepos);
// skip everything before last space
$pos = strrpos($word, ' ');
if ($pos !== false) {
$prefix = (string)substr($word, 0, $pos + 1);
$word = (string)substr($word, $pos + 1);
$start = $this->strlen($prefix);
}
// skip double quote (") or single quote (') from argument
$quote = null;
if (isset($word[0]) && ($word[0] === '"' || $word[0] === '\'')) {
$quote = $word[0];
++$start;
$prefix .= $word[0];
$word = (string)substr($word, 1);
}
// invoke autocomplete callback
$words = call_user_func($this->autocomplete, $word, $start, $end);
// return early if autocomplete does not return anything
if ($words === null) {
return;
}
// remove from list of possible words that do not start with $word or are duplicates
$words = array_unique($words);
if ($word !== '' && $words) {
$words = array_filter($words, function ($w) use ($word) {
return strpos($w, $word) === 0;
});
}
// return if neither of the possible words match
if (!$words) {
$this->bell();
return;
}
// search longest common prefix among all possible matches
$found = reset($words);
$all = count($words);
if ($all > 1) {
while ($found !== '') {
// count all words that start with $found
$matches = count(array_filter($words, function ($w) use ($found) {
return strpos($w, $found) === 0;
}));
// ALL words match $found => common substring found
if ($all === $matches) {
break;
}
// remove last letter from $found and try again
$found = $this->substr($found, 0, -1);
}
// found more than one possible match with this prefix => print options
if ($found === $word || $found === '') {
// limit number of possible matches
if (count($words) > $this->autocompleteSuggestions) {
$more = count($words) - ($this->autocompleteSuggestions - 1);
$words = array_slice($words, 0, $this->autocompleteSuggestions - 1);
$words []= '(+' . $more . ' others)';
}
$this->output->write("\n" . implode(' ', $words) . "\n");
$this->redraw();
return;
}
}
if ($quote !== null && $all === 1 && (strpos($postfix, $quote) === false || strpos($postfix, $quote) > strpos($postfix, ' '))) {
// add closing quote if word started in quotes and postfix does not already contain closing quote before next space
$found .= $quote;
} elseif ($found === '') {
// add single quotes around empty match
$found = '\'\'';
}
if ($postfix === '' && $all === 1) {
// append single space after match unless there's a postfix or there are multiple completions
$found .= ' ';
}
// replace word in input with best match and adjust cursor
$this->linebuffer = $prefix . $found . $postfix;
$this->moveCursorBy($this->strlen($found) - $this->strlen($word));
}
/** @internal */
public function onKeyEnter()
{
if ($this->echo !== false) {
$this->output->write("\n");
}
$this->processLine("\n");
}
/** @internal */
public function onKeyLeft()
{
if ($this->move && $this->linepos !== 0) {
$this->moveCursorBy(-1);
} else {
$this->bell();
}
}
/** @internal */
public function onKeyRight()
{
if ($this->move && !$this->isEol()) {
$this->moveCursorBy(1);
} else {
$this->bell();
}
}
/** @internal */
public function onKeyUp()
{
// ignore if already at top or history is empty
if ($this->historyPosition === 0 || !$this->historyLines) {
$this->bell();
return;
}
if ($this->historyPosition === null) {
// first time up => move to last entry
$this->historyPosition = count($this->historyLines) - 1;
$this->historyUnsaved = $this->getInput();
} else {
// somewhere in the list => move by one
$this->historyPosition--;
}
$this->setInput($this->historyLines[$this->historyPosition]);
}
/** @internal */
public function onKeyDown()
{
// ignore if not currently cycling through history
if ($this->historyPosition === null) {
$this->bell();
return;
}
if (isset($this->historyLines[$this->historyPosition + 1])) {
// this is still a valid position => advance by one and apply
$this->historyPosition++;
$this->setInput($this->historyLines[$this->historyPosition]);
} else {
// moved beyond bottom => restore original unsaved input
$this->setInput($this->historyUnsaved);
$this->historyPosition = null;
$this->historyUnsaved = null;
}
}
/**
* Will be invoked for character(s) that could not otherwise be processed by the sequencer
*
* @internal
*/
public function onFallback($chars, EventEmitterInterface $base = null)
{
// check if there's any special key binding for any of the chars
$buffer = '';
foreach ($this->strsplit($chars) as $char) {
// forward compatibility: check if any key binding exists on base Stdio instance
// deprecated: check if any key binding exists on this Readline instance
$emit = null;
if ($base !== null && $base->listeners($char)) {
$emit = $base;
} else if ($this->listeners($char)) {
$emit = $this;
}
if ($emit !== null) {
// special key binding for this character found
// process all characters before this one before invoking function
if ($buffer !== '') {
$this->addInput($buffer);
$buffer = '';
}
$emit->emit($char, array($char));
} else {
$buffer .= $char;
}
}
// process remaining input characters after last special key binding
if ($buffer !== '') {
$this->addInput($buffer);
}
}
/**
* delete a character at the given position
*
* Removing a character left to the current cursor will also move the cursor
* to the left.
*
* @param int $n
*/
private function deleteChar($n)
{
// read everything up until before current position
$pre = $this->substr($this->linebuffer, 0, $n);
$post = $this->substr($this->linebuffer, $n + 1);
$this->linebuffer = $pre . $post;
// move cursor one cell to the left if we're deleting in front of the cursor
if ($n < $this->linepos) {
--$this->linepos;
}
$this->redraw();
}
/**
* process the current line buffer, emit event and redraw empty line
*
* @uses self::setInput()
*/
protected function processLine($eol)
{
// reset history cycle position
$this->historyPosition = null;
$this->historyUnsaved = null;
// store and reset/clear/redraw current input
$line = $this->linebuffer;
if ($line !== '') {
// the line is not empty, reset it (and implicitly redraw prompt)
$this->setInput('');
} elseif ($this->echo !== false) {
// explicitly redraw prompt after empty line
$this->redraw();
}
// process stored input buffer
$this->emit('data', array($line . $eol));
}
/**
* @param string $str
* @return int
* @codeCoverageIgnore
*/
private function strlen($str)
{
// prefer mb_strlen() if available
if (function_exists('mb_strlen')) {
return mb_strlen($str, $this->encoding);
}
// otherwise replace all unicode chars with dots and count dots
return strlen(preg_replace('/./us', '.', $str));
}
/**
* @param string $str
* @param int $start
* @param ?int $len
* @return string
* @codeCoverageIgnore
*/
private function substr($str, $start = 0, $len = null)
{
if ($len === null) {
$len = $this->strlen($str) - $start;
}
// prefer mb_substr() if available
if (function_exists('mb_substr')) {
return (string)mb_substr($str, $start, $len, $this->encoding);
}
// otherwise build array with all unicode chars and slice array
preg_match_all('/./us', $str, $matches);
return implode('', array_slice($matches[0], $start, $len));
}
/**
* @internal
* @param string $str
* @return int
* @codeCoverageIgnore
*/
public function strwidth($str)
{
// prefer mb_strwidth() if available
if (function_exists('mb_strwidth')) {
return mb_strwidth($str, $this->encoding);
}
// otherwise replace each double-width unicode graphemes with two dots, all others with single dot and count number of dots
// mbstring's list of double-width graphemes is *very* long: https://3v4l.org/GEg3u
// let's use symfony's list from https://github.com/symfony/polyfill-mbstring/blob/e79d363049d1c2128f133a2667e4f4190904f7f4/Mbstring.php#L523
// which looks like they originally came from http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
return strlen(preg_replace(
array(
'/[\x{1100}-\x{115F}\x{2329}\x{232A}\x{2E80}-\x{303E}\x{3040}-\x{A4CF}\x{AC00}-\x{D7A3}\x{F900}-\x{FAFF}\x{FE10}-\x{FE19}\x{FE30}-\x{FE6F}\x{FF00}-\x{FF60}\x{FFE0}-\x{FFE6}\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}]/u',
'/./us',
),
array(
'..',
'.',
),
$str
));
}
/**
* @param string $str
* @return string[]
*/
private function strsplit($str)
{
return preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
}
/**
* @return bool
*/
private function isEol()
{
return $this->linepos === $this->strlen($this->linebuffer);
}
/**
* @return void
*/
private function bell()
{
if ($this->bell) {
$this->output->write("\x07"); // BEL a.k.a. \a
}
}
/** @internal */
public function handleEnd()
{
if ($this->linebuffer !== '') {
$this->processLine('');
}
if (!$this->closed) {
$this->emit('end');
$this->close();
}
}
/** @internal */
public function handleError(\Exception $error)
{
$this->emit('error', array($error));
$this->close();
}
public function isReadable()
{
return !$this->closed && $this->input->isReadable();
}
public function pause()
{
$this->input->pause();
}
public function resume()
{
$this->input->resume();
}
public function pipe(WritableStreamInterface $dest, array $options = array())
{
Util::pipe($this, $dest, $options);
return $dest;
}
public function close()
{
if ($this->closed) {
return;
}
$this->closed = true;
$this->input->close();
$this->emit('close');
$this->removeAllListeners();
}
}