diff --git a/README.md b/README.md index 53f276e..5a28a1f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Async, event-driven and UTF-8 aware standard console input & output (STDIN, STDO * [Echo](#echo) * [Input buffer](#input-buffer) * [Cursor](#cursor) + * [History](#history) * [Advanced](#advanced) * [Stdout](#stdout) * [Stdin](#stdin) @@ -283,6 +284,99 @@ For example, to move the cursor one character to the left, simply call: $readline->moveCursorBy(-1); ``` +#### History + +By default, users can access the history of previous commands by using their +UP and DOWN cursor keys on the keyboard. +The history will start with an empty state, thus this feature is effectively +disabled, as the UP and DOWN cursor keys have no function then. + +Note that the history is not maintained automatically. +Any input the user submits by hitting enter will *not* be added to the history +automatically. +This may seem inconvenient at first, but it actually gives you more control over +what (and when) lines should be added to the history. +If you want to automatically add everything from the user input to the history, +you may want to use something like this: + +```php +$readline->on('data', function ($line) use ($readline) { + $all = $readline->listHistory(); + + // skip empty line and duplicate of previous line + if (trim($line) !== '' && $line !== end($all)) { + $readline->addHistory($line); + } +}); +``` + +The `listHistory(): string[]` method can be used to +return an array with all lines in the history. +This will be an empty array until you add new entries via `addHistory()`. + +```php +$list = $readline->listHistory(); + +assert(count($list) === 0); +``` + +The `addHistory(string $line): Readline` method can be used to +add a new line to the (bottom position of the) history list. +A following `listHistory()` call will return this line as the last element. + +```php +$readline->addHistory('a'); +$readline->addHistory('b'); + +$list = $readline->listHistory(); +assert($list === array('a', 'b')); +``` + +The `clearHistory(): Readline` method can be used to +clear the complete history list. +A following `listHistory()` call will return an empty array until you add new +entries via `addHistory()` again. +Note that the history feature will effectively be disabled if the history is +empty, as the UP and DOWN cursor keys have no function then. + +```php +$readline->clearHistory(); + +$list = $readline->listHistory(); +assert(count($list) === 0); +``` + +The `limitHistory(?int $limit): Readline` method can be used to +set a limit of history lines to keep in memory. +By default, only the last 500 lines will be kept in memory and everything else +will be discarded. +You can use an integer value to limit this to the given number of entries or +use `null` for an unlimited number (not recommended, because everything is +kept in RAM). +If you set the limit to `0` (int zero), the history will effectively be +disabled, as no lines can be added to or returned from the history list. +If you're building a CLI application, you may also want to use something like +this to obey the `HISTSIZE` environment variable: + +```php +$limit = getenv('HISTSIZE'); +if ($limit === '' || $limit < 0) { + // empty string or negative value means unlimited + $readline->limitHistory(null); +} elseif ($limit !== false) { + // apply any other value if given + $readline->limitHistory($limit); +} +``` + +There is no such thing as a `readHistory()` or `writeHistory()` method +because filesystem operations are inherently blocking and thus beyond the scope +of this library. +Using your favorite filesystem API and an appropriate number of `addHistory()` +or a single `listHistory()` call respectively should be fairly straight +forward and is left up as an exercise for the reader of this documentation +(i.e. *you*). + ### Advanced #### Stdout diff --git a/examples/periodic.php b/examples/periodic.php index 9eac66c..397b5fa 100644 --- a/examples/periodic.php +++ b/examples/periodic.php @@ -7,8 +7,29 @@ $loop = React\EventLoop\Factory::create(); $stdio = new Stdio($loop); - -$stdio->getReadline()->setPrompt('> '); +$readline = $stdio->getReadline(); + +$readline->setPrompt('> '); + +// limit history to HISTSIZE env +$limit = getenv('HISTSIZE'); +if ($limit === '' || $limit < 0) { + // empty string or negative value means unlimited + $readline->limitHistory(null); +} elseif ($limit !== false) { + // apply any other value if given + $readline->limitHistory($limit); +} + +// add all lines from input to history +$readline->on('data', function ($line) use ($readline) { + $all = $readline->listHistory(); + + // skip empty line and duplicate of previous line + if (trim($line) !== '' && $line !== end($all)) { + $readline->addHistory($line); + } +}); $stdio->writeln('Will print periodic messages until you type "quit" or "exit"'); diff --git a/src/History.php b/src/History.php deleted file mode 100644 index 9af7dba..0000000 --- a/src/History.php +++ /dev/null @@ -1,23 +0,0 @@ -history []= $line; - } - - public function moveUp() - { - - } - - public function moveDown() - { - - } -} diff --git a/src/Readline.php b/src/Readline.php index 7870986..b472215 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -17,7 +17,6 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $echo = true; private $autocomplete = null; private $move = true; - private $history = null; private $encoding = 'utf-8'; private $input; @@ -25,6 +24,11 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $sequencer; private $closed = false; + private $historyLines = array(); + private $historyPosition = null; + private $historyUnsaved = null; + private $historyLimit = 500; + public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output) { $this->input = $input; @@ -311,17 +315,73 @@ public function getInput() } /** - * set history handler to use (or none) + * Adds a new line to the (bottom position of the) history list * - * The history handler will be called whenever the user hits the UP or DOWN - * arrow keys. + * @param string $line + * @return self + * @uses self::limitHistory() to make sure list does not exceed limits + */ + public function addHistory($line) + { + $this->historyLines []= $line; + + return $this->limitHistory($this->historyLimit); + } + + /** + * Clears the complete history list * - * @param HistoryInterface|null $history * @return self */ - public function setHistory(HistoryInterface $history = null) + 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[] + */ + public function listHistory() { - $this->history = $history; + 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 + */ + public function limitHistory($limit) + { + $this->historyLimit = $limit === null ? null : (int)$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; } @@ -468,16 +528,40 @@ public function onKeyRight() /** @internal */ public function onKeyUp() { - if ($this->history !== null) { - $this->history->up(); + // ignore if already at top or history is empty + if ($this->historyPosition === 0 || !$this->historyLines) { + 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() { - if ($this->history !== null) { - $this->history->down(); + // ignore if not currently cycling through history + if ($this->historyPosition === null) { + 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; } } @@ -537,6 +621,10 @@ public function deleteChar($n) */ protected function processLine() { + // reset history cycle position + $this->historyPosition = null; + $this->historyUnsaved = null; + // store and reset/clear/redraw current input $line = $this->linebuffer; if ($line !== '') { @@ -548,9 +636,6 @@ protected function processLine() } // process stored input buffer - if ($this->history !== null) { - $this->history->addLine($line); - } $this->emit('data', array($line)); } diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index a623674..e57bea4 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -629,4 +629,277 @@ public function testPipeWillReturnDest() $this->assertEquals($dest, $ret); } + + public function testHistoryStartsEmpty() + { + $this->assertEquals(array(), $this->readline->listHistory()); + } + + public function testHistoryAddReturnsSelf() + { + $this->assertSame($this->readline, $this->readline->addHistory('hello')); + } + + public function testHistoryAddEndsUpInList() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + $this->readline->addHistory('c'); + + $this->assertEquals(array('a', 'b', 'c'), $this->readline->listHistory()); + } + + public function testHistoryUpEmptyDoesNotChangeInput() + { + $this->readline->onKeyUp(); + + $this->assertEquals('', $this->readline->getInput()); + } + + public function testHistoryUpCyclesToLast() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + + $this->assertEquals('b', $this->readline->getInput()); + } + + public function testHistoryUpBeyondTopCyclesToFirst() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + $this->readline->onKeyUp(); + $this->readline->onKeyUp(); + + $this->assertEquals('a', $this->readline->getInput()); + } + + public function testHistoryUpAndThenEnterRestoresCycleToBottom() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + + $this->readline->onKeyEnter(); + + $this->readline->onKeyUp(); + + $this->assertEquals('b', $this->readline->getInput()); + } + + public function testHistoryDownNotCyclingDoesNotChangeInput() + { + $this->readline->onKeyDown(); + + $this->assertEquals('', $this->readline->getInput()); + } + + public function testHistoryDownAfterUpRestoresEmpty() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + $this->readline->onKeyDown(); + + $this->assertEquals('', $this->readline->getInput()); + } + + public function testHistoryDownAfterUpToTopRestoresBottom() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + $this->readline->onKeyUp(); + $this->readline->onKeyDown(); + + $this->assertEquals('b', $this->readline->getInput()); + } + + public function testHistoryDownAfterUpRestoresOriginal() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->setInput('hello'); + + $this->readline->onKeyUp(); + $this->readline->onKeyDown(); + + $this->assertEquals('hello', $this->readline->getInput()); + } + + public function testHistoryDownBeyondAfterUpStillRestoresOriginal() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->setInput('hello'); + + $this->readline->onKeyUp(); + $this->readline->onKeyDown(); + $this->readline->onKeyDown(); + + $this->assertEquals('hello', $this->readline->getInput()); + } + + public function testHistoryClearReturnsSelf() + { + $this->assertSame($this->readline, $this->readline->clearHistory()); + } + + public function testHistoryClearResetsToEmptyList() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->clearHistory(); + + $this->assertEquals(array(), $this->readline->listHistory()); + } + + public function testHistoryClearWhileCyclingRestoresOriginalInput() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->setInput('hello'); + + $this->readline->onKeyUp(); + + $this->readline->clearHistory(); + + $this->assertEquals('hello', $this->readline->getInput()); + } + + public function testHistoryLimitReturnsSelf() + { + $this->assertSame($this->readline, $this->readline->limitHistory(100)); + } + + public function testHistoryLimitTruncatesCurrentListToLimit() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + $this->readline->addHistory('c'); + + $this->readline->limitHistory(2); + + $this->assertCount(2, $this->readline->listHistory()); + $this->assertEquals(array('b', 'c'), $this->readline->listHistory()); + } + + public function testHistoryLimitToZeroEmptiesCurrentList() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + $this->readline->addHistory('c'); + + $this->readline->limitHistory(0); + + $this->assertCount(0, $this->readline->listHistory()); + } + + public function testHistoryLimitTruncatesAddingBeyondLimit() + { + $this->readline->limitHistory(2); + + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + $this->readline->addHistory('c'); + + $this->assertCount(2, $this->readline->listHistory()); + $this->assertEquals(array('b', 'c'), $this->readline->listHistory()); + } + + public function testHistoryLimitZeroAlwaysReturnsEmpty() + { + $this->readline->limitHistory(0); + + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + $this->readline->addHistory('c'); + + $this->assertCount(0, $this->readline->listHistory()); + } + + public function testHistoryLimitUnlimitedDoesNotTruncate() + { + $this->readline->limitHistory(null); + + for ($i = 0; $i < 1000; ++$i) { + $this->readline->addHistory('line' . $i); + } + + $this->assertCount(1000, $this->readline->listHistory()); + } + + public function testHistoryLimitRestoresOriginalInputIfCurrentIsTruncated() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->setInput('hello'); + + $this->readline->onKeyUp(); + + $this->readline->limitHistory(0); + + $this->assertEquals('hello', $this->readline->getInput()); + } + + public function testHistoryLimitKeepsCurrentIfCurrentRemainsDespiteTruncation() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + + $this->readline->limitHistory(1); + + $this->assertEquals('b', $this->readline->getInput()); + } + + public function testHistoryLimitOnlyInBetweenTruncatesToLastAndKeepsInput() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->onKeyUp(); + + $this->readline->limitHistory(3); + + $this->assertEquals('b', $this->readline->getInput()); + + $this->readline->addHistory('c'); + $this->readline->addHistory('d'); + + $this->assertCount(3, $this->readline->listHistory()); + $this->assertEquals(array('b', 'c', 'd'), $this->readline->listHistory()); + + $this->assertEquals('b', $this->readline->getInput()); + } + + public function testHistoryLimitRestoresOriginalIfCurrentIsTruncatedDueToAdding() + { + $this->readline->addHistory('a'); + $this->readline->addHistory('b'); + + $this->readline->setInput('hello'); + + $this->readline->onKeyUp(); + + $this->readline->limitHistory(1); + + $this->readline->addHistory('c'); + $this->readline->addHistory('d'); + + $this->assertEquals('hello', $this->readline->getInput()); + } }