From f58f800f70c8dc14612bbd5c4c3864efa25ac748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 22 May 2015 09:34:25 +0200 Subject: [PATCH 1/7] Add skeleton for proper history support --- src/History.php | 23 --------- src/Readline.php | 43 +++++++++++----- src/Readline/History.php | 14 +++++ src/Readline/MemoryHistory.php | 58 +++++++++++++++++++++ src/Readline/NullHistory.php | 23 +++++++++ tests/Readline/MemoryHistoryTest.php | 77 ++++++++++++++++++++++++++++ tests/Readline/NullHistoryTest.php | 18 +++++++ tests/ReadlineTest.php | 29 +++++++++++ 8 files changed, 248 insertions(+), 37 deletions(-) delete mode 100644 src/History.php create mode 100644 src/Readline/History.php create mode 100644 src/Readline/MemoryHistory.php create mode 100644 src/Readline/NullHistory.php create mode 100644 tests/Readline/MemoryHistoryTest.php create mode 100644 tests/Readline/NullHistoryTest.php 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..c363133 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -8,6 +8,8 @@ use React\Stream\Util; use Clue\React\Utf8\Sequencer as Utf8Sequencer; use Clue\React\Term\ControlCodeParser; +use Clue\React\Stdio\Readline\History; +use Clue\React\Stdio\Readline\MemoryHistory; class Readline extends EventEmitter implements ReadableStreamInterface { @@ -17,7 +19,7 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $echo = true; private $autocomplete = null; private $move = true; - private $history = null; + private $history; private $encoding = 'utf-8'; private $input; @@ -25,10 +27,16 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $sequencer; private $closed = false; - public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output) + public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output, History $history = null) { $this->input = $input; + + if ($history === null) { + $history = new MemoryHistory(); + } + $this->output = $output; + $this->history = $history; if (!$this->input->isReadable()) { return $this->close(); @@ -311,21 +319,34 @@ public function getInput() } /** - * set history handler to use (or none) + * set history handler to use * * The history handler will be called whenever the user hits the UP or DOWN * arrow keys. * - * @param HistoryInterface|null $history + * If you do not want to use history support, simply pass a `NullHistory` object. + * + * @param History $history new history handler to use * @return self */ - public function setHistory(HistoryInterface $history = null) + public function setHistory(History $history) { $this->history = $history; return $this; } + /** + * Gets the current history handler in use + * + * @return History + * @see self::setHistory() + */ + public function getHistory() + { + return $this->history; + } + /** * set autocompletion handler to use (or none) * @@ -468,17 +489,13 @@ public function onKeyRight() /** @internal */ public function onKeyUp() { - if ($this->history !== null) { - $this->history->up(); - } + $this->history->moveUp($this); } /** @internal */ public function onKeyDown() { - if ($this->history !== null) { - $this->history->down(); - } + $this->history->moveDown($this); } /** @@ -548,9 +565,7 @@ protected function processLine() } // process stored input buffer - if ($this->history !== null) { - $this->history->addLine($line); - } + $this->history->addLine($line); $this->emit('data', array($line)); } diff --git a/src/Readline/History.php b/src/Readline/History.php new file mode 100644 index 0000000..5e20ee3 --- /dev/null +++ b/src/Readline/History.php @@ -0,0 +1,14 @@ +lines []= $line; + $this->position = null; + } + + public function moveUp(Readline $readline) + { + // ignore if already at top or history is empty + if ($this->position === 0 || !$this->lines) { + return; + } + + if ($this->position === null) { + // first time up => move to last entry + $this->position = count($this->lines) - 1; + + // TODO: buffer current user input + } else { + // somewhere in the list => move by one + $this->position--; + } + + $readline->setInput($this->lines[$this->position]); + } + + public function moveDown(Readline $readline) + { + if ($this->position === null) { + return; + } + + if (($this->position + 1) < count($this->lines)) { + // this is still a valid position => advance by one and apply + $this->position++; + $readline->setInput($this->lines[$this->position]); + } else { + // moved beyond bottom => restore empty input + $readline->setInput(''); + $this->position = null; + } + } +} diff --git a/src/Readline/NullHistory.php b/src/Readline/NullHistory.php new file mode 100644 index 0000000..a63761a --- /dev/null +++ b/src/Readline/NullHistory.php @@ -0,0 +1,23 @@ +readline = $this->getMockBuilder('Clue\React\Stdio\Readline')->disableOriginalConstructor()->getMock(); + $this->history = new MemoryHistory(); + } + + public function testCanAdd() + { + $this->history->addLine('a'); + $this->history->addLine('b'); + + return $this->history; + } + + /** + * @depends testCanAdd + * @param History $history + */ + public function testMovingUpRestoresLastEntry(History $history) + { + $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('b')); + + $history->moveUp($this->readline); + + return $history; + } + + /** + * @depends testMovingUpRestoresLastEntry + * @param History $history + */ + public function testMovingUpMovesToNextEntryWhichIsFirst(History $history) + { + $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('a')); + + $history->moveUp($this->readline); + + return $history; + } + + /** + * @depends testMovingUpMovesToNextEntryWhichIsFirst + * @param History $history + */ + public function testMovingUpWhenAlreadyOnFirstDoesNothing(History $history) + { + $this->readline->expects($this->never())->method('setInput'); + + $history->moveUp($this->readline); + } + + public function testMovingDownDoesNothing() + { + $this->history->addLine('ignored'); + + $this->readline->expects($this->never())->method('setInput'); + + $this->history->moveDown($this->readline); + } + + public function testMovingInEmptyHistoryDoesNothing() + { + $this->readline->expects($this->never())->method('setInput'); + + $this->history->moveUp($this->readline); + $this->history->moveDown($this->readline); + } +} diff --git a/tests/Readline/NullHistoryTest.php b/tests/Readline/NullHistoryTest.php new file mode 100644 index 0000000..3081d93 --- /dev/null +++ b/tests/Readline/NullHistoryTest.php @@ -0,0 +1,18 @@ +getMockBuilder('Clue\React\Stdio\Readline')->disableOriginalConstructor()->getMock(); + $readline->expects($this->never())->method('setInput'); + + $history = new NullHistory(); + $history->addLine('a'); + $history->addLine('b'); + + $history->moveUp($readline); + $history->moveDown($readline); + } +} diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index a623674..5bce370 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -495,6 +495,35 @@ public function testCursorCellObeysCustomEchoAsterisk(Readline $readline) $this->assertEquals(3, $readline->getCursorCell()); } + public function testHistoryGetterReturnsSameFromSetter() + { + $history = $this->getMock('Clue\React\Stdio\Readline\History'); + + $this->assertSame($this->readline, $this->readline->setHistory($history)); + + $this->assertSame($history, $this->readline->getHistory()); + } + + public function testKeysCursorUpInvokesHistoryHandler() + { + $history = $this->getMock('Clue\React\Stdio\Readline\History'); + $history->expects($this->once())->method('moveUp')->with($this->equalTo($this->readline)); + + $this->readline->setHistory($history); + + $this->readline->onKeyUp($this->readline); + } + + public function testKeysCursorDownInvokesHistoryHandler() + { + $history = $this->getMock('Clue\React\Stdio\Readline\History'); + $history->expects($this->once())->method('moveDown')->with($this->equalTo($this->readline)); + + $this->readline->setHistory($history); + + $this->readline->onKeyDown($this->readline); + } + public function testEmitEmptyInputOnEnter() { $this->readline->on('data', $this->expectCallableOnceWith('')); From 5de09dd7f60206f6fdd046a7e11d65f549146e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 22 May 2015 11:55:14 +0200 Subject: [PATCH 2/7] Support restoring current user input when cycling history --- src/Readline/MemoryHistory.php | 8 ++++---- tests/Readline/MemoryHistoryTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Readline/MemoryHistory.php b/src/Readline/MemoryHistory.php index 97efa9c..d8fad8b 100644 --- a/src/Readline/MemoryHistory.php +++ b/src/Readline/MemoryHistory.php @@ -8,6 +8,7 @@ class MemoryHistory implements History { private $lines = array(); private $position = null; + private $unsaved = null; public function addLine($line) { @@ -29,8 +30,7 @@ public function moveUp(Readline $readline) if ($this->position === null) { // first time up => move to last entry $this->position = count($this->lines) - 1; - - // TODO: buffer current user input + $this->unsaved = $readline->getInput(); } else { // somewhere in the list => move by one $this->position--; @@ -50,8 +50,8 @@ public function moveDown(Readline $readline) $this->position++; $readline->setInput($this->lines[$this->position]); } else { - // moved beyond bottom => restore empty input - $readline->setInput(''); + // moved beyond bottom => restore original unsaved input + $readline->setInput($this->unsaved); $this->position = null; } } diff --git a/tests/Readline/MemoryHistoryTest.php b/tests/Readline/MemoryHistoryTest.php index b80d8c2..7098e65 100644 --- a/tests/Readline/MemoryHistoryTest.php +++ b/tests/Readline/MemoryHistoryTest.php @@ -74,4 +74,28 @@ public function testMovingInEmptyHistoryDoesNothing() $this->history->moveUp($this->readline); $this->history->moveDown($this->readline); } + + public function testMovingUpClearsCurrentInput() + { + $this->readline->expects($this->once())->method('getInput')->will($this->returnValue('input')); + + $this->history->addLine('first'); + + $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('first')); + + $this->history->moveUp($this->readline); + + return $this->history; + } + + /** + * @depends testMovingUpClearsCurrentInput + * @param History $history + */ + public function testMovingDownRestoresLastBufferAgain(History $history) + { + $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('input')); + + $history->moveDown($this->readline); + } } From f40b5b26cfb4b02bc624692deca4ad089da35fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 8 Dec 2016 22:41:50 +0100 Subject: [PATCH 3/7] Simplify history API, now inspired by the readline API --- README.md | 52 +++++++++ src/Readline.php | 88 ++++++++++----- src/Readline/History.php | 14 --- src/Readline/MemoryHistory.php | 58 ---------- src/Readline/NullHistory.php | 23 ---- tests/Readline/MemoryHistoryTest.php | 101 ----------------- tests/Readline/NullHistoryTest.php | 18 --- tests/ReadlineTest.php | 163 ++++++++++++++++++++++----- 8 files changed, 248 insertions(+), 269 deletions(-) delete mode 100644 src/Readline/History.php delete mode 100644 src/Readline/MemoryHistory.php delete mode 100644 src/Readline/NullHistory.php delete mode 100644 tests/Readline/MemoryHistoryTest.php delete mode 100644 tests/Readline/NullHistoryTest.php diff --git a/README.md b/README.md index 53f276e..cf5c518 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,57 @@ 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. + +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); +``` + +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/src/Readline.php b/src/Readline.php index c363133..75f03c3 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -8,8 +8,6 @@ use React\Stream\Util; use Clue\React\Utf8\Sequencer as Utf8Sequencer; use Clue\React\Term\ControlCodeParser; -use Clue\React\Stdio\Readline\History; -use Clue\React\Stdio\Readline\MemoryHistory; class Readline extends EventEmitter implements ReadableStreamInterface { @@ -19,7 +17,6 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $echo = true; private $autocomplete = null; private $move = true; - private $history; private $encoding = 'utf-8'; private $input; @@ -27,16 +24,14 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $sequencer; private $closed = false; - public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output, History $history = null) + private $historyLines = array(); + private $historyPosition = null; + private $historyUnsaved = null; + + public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output) { $this->input = $input; - - if ($history === null) { - $history = new MemoryHistory(); - } - $this->output = $output; - $this->history = $history; if (!$this->input->isReadable()) { return $this->close(); @@ -319,32 +314,44 @@ public function getInput() } /** - * set history handler to use + * 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. - * - * If you do not want to use history support, simply pass a `NullHistory` object. + * @param string $line + * @return self + */ + public function addHistory($line) + { + $this->historyLines []= $line; + + return $this; + } + + /** + * Clears the complete history list * - * @param History $history new history handler to use * @return self */ - public function setHistory(History $history) + public function clearHistory() { - $this->history = $history; + $this->historyLines = array(); + $this->historyPosition = null; + + if ($this->historyUnsaved !== null) { + $this->setInput($this->historyUnsaved); + $this->historyUnsaved = null; + } return $this; } /** - * Gets the current history handler in use + * Returns an array with all lines in the history * - * @return History - * @see self::setHistory() + * @return string[] */ - public function getHistory() + public function listHistory() { - return $this->history; + return $this->historyLines; } /** @@ -489,13 +496,40 @@ public function onKeyRight() /** @internal */ public function onKeyUp() { - $this->history->moveUp($this); + // 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() { - $this->history->moveDown($this); + // ignore if not currently cycling through history + if ($this->historyPosition === null) { + return; + } + + if (($this->historyPosition + 1) < count($this->historyLines)) { + // 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; + } } /** @@ -565,7 +599,9 @@ protected function processLine() } // process stored input buffer - $this->history->addLine($line); + if ($line !== '') { + $this->addHistory($line); + } $this->emit('data', array($line)); } diff --git a/src/Readline/History.php b/src/Readline/History.php deleted file mode 100644 index 5e20ee3..0000000 --- a/src/Readline/History.php +++ /dev/null @@ -1,14 +0,0 @@ -lines []= $line; - $this->position = null; - } - - public function moveUp(Readline $readline) - { - // ignore if already at top or history is empty - if ($this->position === 0 || !$this->lines) { - return; - } - - if ($this->position === null) { - // first time up => move to last entry - $this->position = count($this->lines) - 1; - $this->unsaved = $readline->getInput(); - } else { - // somewhere in the list => move by one - $this->position--; - } - - $readline->setInput($this->lines[$this->position]); - } - - public function moveDown(Readline $readline) - { - if ($this->position === null) { - return; - } - - if (($this->position + 1) < count($this->lines)) { - // this is still a valid position => advance by one and apply - $this->position++; - $readline->setInput($this->lines[$this->position]); - } else { - // moved beyond bottom => restore original unsaved input - $readline->setInput($this->unsaved); - $this->position = null; - } - } -} diff --git a/src/Readline/NullHistory.php b/src/Readline/NullHistory.php deleted file mode 100644 index a63761a..0000000 --- a/src/Readline/NullHistory.php +++ /dev/null @@ -1,23 +0,0 @@ -readline = $this->getMockBuilder('Clue\React\Stdio\Readline')->disableOriginalConstructor()->getMock(); - $this->history = new MemoryHistory(); - } - - public function testCanAdd() - { - $this->history->addLine('a'); - $this->history->addLine('b'); - - return $this->history; - } - - /** - * @depends testCanAdd - * @param History $history - */ - public function testMovingUpRestoresLastEntry(History $history) - { - $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('b')); - - $history->moveUp($this->readline); - - return $history; - } - - /** - * @depends testMovingUpRestoresLastEntry - * @param History $history - */ - public function testMovingUpMovesToNextEntryWhichIsFirst(History $history) - { - $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('a')); - - $history->moveUp($this->readline); - - return $history; - } - - /** - * @depends testMovingUpMovesToNextEntryWhichIsFirst - * @param History $history - */ - public function testMovingUpWhenAlreadyOnFirstDoesNothing(History $history) - { - $this->readline->expects($this->never())->method('setInput'); - - $history->moveUp($this->readline); - } - - public function testMovingDownDoesNothing() - { - $this->history->addLine('ignored'); - - $this->readline->expects($this->never())->method('setInput'); - - $this->history->moveDown($this->readline); - } - - public function testMovingInEmptyHistoryDoesNothing() - { - $this->readline->expects($this->never())->method('setInput'); - - $this->history->moveUp($this->readline); - $this->history->moveDown($this->readline); - } - - public function testMovingUpClearsCurrentInput() - { - $this->readline->expects($this->once())->method('getInput')->will($this->returnValue('input')); - - $this->history->addLine('first'); - - $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('first')); - - $this->history->moveUp($this->readline); - - return $this->history; - } - - /** - * @depends testMovingUpClearsCurrentInput - * @param History $history - */ - public function testMovingDownRestoresLastBufferAgain(History $history) - { - $this->readline->expects($this->once())->method('setInput')->with($this->equalTo('input')); - - $history->moveDown($this->readline); - } -} diff --git a/tests/Readline/NullHistoryTest.php b/tests/Readline/NullHistoryTest.php deleted file mode 100644 index 3081d93..0000000 --- a/tests/Readline/NullHistoryTest.php +++ /dev/null @@ -1,18 +0,0 @@ -getMockBuilder('Clue\React\Stdio\Readline')->disableOriginalConstructor()->getMock(); - $readline->expects($this->never())->method('setInput'); - - $history = new NullHistory(); - $history->addLine('a'); - $history->addLine('b'); - - $history->moveUp($readline); - $history->moveDown($readline); - } -} diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index 5bce370..309e259 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -495,35 +495,6 @@ public function testCursorCellObeysCustomEchoAsterisk(Readline $readline) $this->assertEquals(3, $readline->getCursorCell()); } - public function testHistoryGetterReturnsSameFromSetter() - { - $history = $this->getMock('Clue\React\Stdio\Readline\History'); - - $this->assertSame($this->readline, $this->readline->setHistory($history)); - - $this->assertSame($history, $this->readline->getHistory()); - } - - public function testKeysCursorUpInvokesHistoryHandler() - { - $history = $this->getMock('Clue\React\Stdio\Readline\History'); - $history->expects($this->once())->method('moveUp')->with($this->equalTo($this->readline)); - - $this->readline->setHistory($history); - - $this->readline->onKeyUp($this->readline); - } - - public function testKeysCursorDownInvokesHistoryHandler() - { - $history = $this->getMock('Clue\React\Stdio\Readline\History'); - $history->expects($this->once())->method('moveDown')->with($this->equalTo($this->readline)); - - $this->readline->setHistory($history); - - $this->readline->onKeyDown($this->readline); - } - public function testEmitEmptyInputOnEnter() { $this->readline->on('data', $this->expectCallableOnceWith('')); @@ -658,4 +629,138 @@ 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 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()); + } } From 85e861dc54ec7dac0ef90260c6ba3855b802a15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 9 Dec 2016 15:35:02 +0100 Subject: [PATCH 4/7] Do not automatically add user input to history --- README.md | 19 +++++++++++++++++++ examples/periodic.php | 13 ++++++++++++- src/Readline.php | 3 --- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf5c518..ba06535 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,25 @@ 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()`. diff --git a/examples/periodic.php b/examples/periodic.php index 9eac66c..db9fe53 100644 --- a/examples/periodic.php +++ b/examples/periodic.php @@ -7,8 +7,19 @@ $loop = React\EventLoop\Factory::create(); $stdio = new Stdio($loop); +$readline = $stdio->getReadline(); -$stdio->getReadline()->setPrompt('> '); +$readline->setPrompt('> '); + +// 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/Readline.php b/src/Readline.php index 75f03c3..7307b9f 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -599,9 +599,6 @@ protected function processLine() } // process stored input buffer - if ($line !== '') { - $this->addHistory($line); - } $this->emit('data', array($line)); } From 0c2b183ba1d28688d249dbae792e7f59de193d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 11 Dec 2016 23:54:48 +0100 Subject: [PATCH 5/7] Reset current history position on enter --- src/Readline.php | 4 ++++ tests/ReadlineTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/Readline.php b/src/Readline.php index 7307b9f..9ab75e7 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -588,6 +588,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 !== '') { diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index 309e259..840f273 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -678,6 +678,20 @@ public function testHistoryUpBeyondTopCyclesToFirst() $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(); From 6664dcd293bacf7b50d2a4fd4c70e057120a9bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 11 Dec 2016 16:54:53 +0100 Subject: [PATCH 6/7] Limit history to 500 lines by default and add limitHistory() method --- README.md | 23 ++++++++++++++++ examples/periodic.php | 10 +++++++ src/Readline.php | 22 +++++++++++++++ tests/ReadlineTest.php | 62 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/README.md b/README.md index ba06535..5a28a1f 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,29 @@ $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. diff --git a/examples/periodic.php b/examples/periodic.php index db9fe53..397b5fa 100644 --- a/examples/periodic.php +++ b/examples/periodic.php @@ -11,6 +11,16 @@ $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(); diff --git a/src/Readline.php b/src/Readline.php index 9ab75e7..10de377 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -27,6 +27,7 @@ class Readline extends EventEmitter implements ReadableStreamInterface private $historyLines = array(); private $historyPosition = null; private $historyUnsaved = null; + private $historyLimit = 500; public function __construct(ReadableStreamInterface $input, WritableStreamInterface $output) { @@ -323,6 +324,10 @@ public function addHistory($line) { $this->historyLines []= $line; + if ($this->historyLimit !== null) { + $this->historyLines = array_slice($this->historyLines, -$this->historyLimit, $this->historyLimit); + } + return $this; } @@ -354,6 +359,23 @@ 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 + */ + public function limitHistory($limit) + { + $this->historyLimit = $limit === null ? null : (int)$limit; + + if ($this->historyLimit !== null) { + $this->historyLines = array_slice($this->historyLines, -$this->historyLimit, $this->historyLimit); + } + + return $this; + } + /** * set autocompletion handler to use (or none) * diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index 840f273..01fb96f 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -777,4 +777,66 @@ public function testHistoryClearWhileCyclingRestoresOriginalInput() $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()); + } } From a40a30eea5f607398fe83a55224ed3a7350b46b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 11 Dec 2016 18:20:59 +0100 Subject: [PATCH 7/7] Adjust current history position when truncating to limit --- src/Readline.php | 25 ++++++++++++----- tests/ReadlineTest.php | 63 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/Readline.php b/src/Readline.php index 10de377..b472215 100644 --- a/src/Readline.php +++ b/src/Readline.php @@ -319,16 +319,13 @@ public function getInput() * * @param string $line * @return self + * @uses self::limitHistory() to make sure list does not exceed limits */ public function addHistory($line) { $this->historyLines []= $line; - if ($this->historyLimit !== null) { - $this->historyLines = array_slice($this->historyLines, -$this->historyLimit, $this->historyLimit); - } - - return $this; + return $this->limitHistory($this->historyLimit); } /** @@ -369,7 +366,20 @@ public function limitHistory($limit) { $this->historyLimit = $limit === null ? null : (int)$limit; - if ($this->historyLimit !== null) { + // 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); } @@ -543,7 +553,7 @@ public function onKeyDown() return; } - if (($this->historyPosition + 1) < count($this->historyLines)) { + 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]); @@ -551,6 +561,7 @@ public function onKeyDown() // moved beyond bottom => restore original unsaved input $this->setInput($this->historyUnsaved); $this->historyPosition = null; + $this->historyUnsaved = null; } } diff --git a/tests/ReadlineTest.php b/tests/ReadlineTest.php index 01fb96f..e57bea4 100644 --- a/tests/ReadlineTest.php +++ b/tests/ReadlineTest.php @@ -839,4 +839,67 @@ public function testHistoryLimitUnlimitedDoesNotTruncate() $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()); + } }