Support history file (#44)

* Support history file

History has a new constructor to remember a file to read from/ write to.
Writing is deferred to the Drop.
Binary supports history file via `REEDLINE_HISTFILE` env variable

Additional changes:

Inverted the ordering of the history VecDeque to simplify file IO and
history internals. This makes the search code slightly more brittle.

* Clarify history API

* Harmonize return type of iterators
This commit is contained in:
Stefan Holderbach 2021-04-30 00:45:12 +02:00 committed by GitHub
parent 6c8ca05b4c
commit 32c9ac0c25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 179 additions and 34 deletions

View File

@ -90,8 +90,19 @@ impl Reedline {
}
}
pub fn with_history(history: History) -> Self {
let mut rl = Reedline::new();
rl.history = history;
rl
}
pub fn print_history(&mut self) -> Result<()> {
let history: Vec<_> = self.history.iter().rev().cloned().enumerate().collect();
let history: Vec<_> = self
.history
.iter_chronologic()
.cloned()
.enumerate()
.collect();
for (i, entry) in history {
self.print_line(&format!("{}\t{}", i + 1, entry))?;
@ -542,7 +553,7 @@ impl Reedline {
match search.result {
Some((history_index, offset)) => {
let history_result = &self.history[history_index];
let history_result = self.history.get_nth_newest(history_index).unwrap();
self.stdout.queue(Print(&history_result[..offset]))?;
self.stdout.queue(SavePosition)?;
@ -732,8 +743,9 @@ impl Reedline {
Some(search) => {
self.queue_prompt_indicator()?;
if let Some((history_index, _)) = search.result {
self.line_buffer
.set_buffer(self.history[history_index].clone());
self.line_buffer.set_buffer(
self.history.get_nth_newest(history_index).unwrap().clone(),
);
}
self.history_search = None;
}

View File

@ -1,13 +1,25 @@
use std::{collections::VecDeque, ops::Index};
use std::fs::{File, OpenOptions};
use std::io::BufReader;
use std::io::{BufRead, BufWriter, Write};
use std::{collections::VecDeque, path::PathBuf};
/// Default size of the `History`
const HISTORY_SIZE: usize = 100;
pub const HISTORY_SIZE: usize = 100;
/// Stateful history that allows up/down-arrow browsing with an internal cursor
/// Stateful history that allows up/down-arrow browsing with an internal cursor.
///
/// Can optionally be associated with a newline separated history file.
/// Similar to bash's behavior without HISTTIMEFORMAT.
/// (See https://www.gnu.org/software/bash/manual/html_node/Bash-History-Facilities.html)
/// All new changes within a certain History capacity will be written to disk when History is dropped.
#[derive(Debug)]
pub struct History {
capacity: usize,
entries: VecDeque<String>,
cursor: isize, // -1 not browsing through history, >= 0 index into history
cursor: usize, // If cursor == entries.len() outside history browsing
file: Option<PathBuf>,
len_on_disk: usize, // Keep track what was previously written to disk
truncate_file: bool, // as long as the file would not exceed capacity we can use appending writes
}
impl Default for History {
@ -17,18 +29,105 @@ impl Default for History {
}
impl History {
/// Creates an in-memory history that remembers `<= capacity` elements
/// Creates a new in-memory history that remembers `<= capacity` elements
pub fn new(capacity: usize) -> Self {
if capacity > isize::MAX as usize {
if capacity == usize::MAX {
panic!("History capacity too large to be addressed safely");
}
History {
capacity,
entries: VecDeque::with_capacity(capacity),
cursor: -1,
cursor: 0,
file: None,
len_on_disk: 0,
truncate_file: false,
}
}
/// Creates a new history with an associated history file.
///
/// History file format: commands separated by new lines.
/// If file exists file will be read otherwise empty file will be created.
///
/// Side effects: creates all nested directories to the file
pub fn with_file(capacity: usize, file: PathBuf) -> std::io::Result<Self> {
let mut hist = Self::new(capacity);
if let Some(base_dir) = file.parent() {
std::fs::create_dir_all(base_dir)?;
}
hist.file = Some(file);
hist.load_file()?;
Ok(hist)
}
/// Loads history from the associated newline separated file
///
/// Expects the `History` to be empty.
///
/// Side effect: create/touch not yet existing file.
fn load_file(&mut self) -> std::io::Result<()> {
let f = File::open(
self.file
.as_ref()
.expect("History::load_file should only be called if a filename is set"),
);
assert!(
self.entries.is_empty(),
"History currently designed to load file once in the constructor"
);
match f {
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => {
File::create(self.file.as_ref().unwrap())?;
Ok(())
}
_ => Err(e),
},
Ok(file) => {
let reader = BufReader::new(file);
let mut from_file: VecDeque<String> = reader.lines().map(|s| s.unwrap()).collect();
let from_file = if from_file.len() > self.capacity {
from_file.split_off(from_file.len() - self.capacity)
} else {
from_file
};
self.len_on_disk = from_file.len();
self.entries = from_file;
Ok(())
}
}
}
/// Writes unwritten history contents to disk.
/// If file would exceed `capacity` truncates the oldest entries.
fn flush(&mut self) -> std::io::Result<()> {
if self.file.is_none() {
return Ok(());
}
let file = if self.truncate_file {
// Rewrite the whole file if we truncated the old output
self.len_on_disk = 0;
OpenOptions::new()
.write(true)
.open(self.file.as_ref().unwrap())?
} else {
// If the file is not beyond capacity just append new stuff
// (use the stored self.len_on_disk as offset)
OpenOptions::new()
.append(true)
.open(self.file.as_ref().unwrap())?
};
let mut writer = BufWriter::new(file);
for line in self.entries.range(self.len_on_disk..) {
writer.write_all(line.as_bytes())?;
writer.write_all("\n".as_bytes())?;
}
writer.flush()?;
self.len_on_disk = self.entries.len();
Ok(())
}
/// Access the underlying entries (exported for possible fancy access to underlying VecDeque)
#[allow(dead_code)]
pub fn entries(&self) -> &VecDeque<String> {
@ -37,31 +136,38 @@ impl History {
/// Append an entry if non-empty and not repetition of the previous entry
pub fn append(&mut self, entry: String) {
if self.entries.len() + 1 == self.capacity {
// History is "full", so we delete the oldest entry first,
// before adding a new one.
self.entries.pop_back();
}
// Don't append if the preceding value is identical or the string empty
if self
.entries
.front()
.back()
.map_or(true, |previous| previous != &entry)
&& !entry.is_empty()
{
self.entries.push_front(entry);
if self.entries.len() == self.capacity {
// History is "full", so we delete the oldest entry first,
// before adding a new one.
self.entries.pop_front();
self.cursor = self.cursor.saturating_sub(1);
self.len_on_disk = self.len_on_disk.saturating_sub(1);
self.truncate_file = true;
}
// Keep the cursor meaning consistent if no call to `reset_cursor()` is done by the consumer
if self.cursor == self.entries.len() {
self.cursor += 1;
}
self.entries.push_back(entry);
}
}
/// Reset the internal browsing cursor
pub fn reset_cursor(&mut self) {
self.cursor = -1
self.cursor = self.entries.len()
}
/// Try to move back in history. Returns `None` if history is exhausted.
pub fn go_back(&mut self) -> Option<&str> {
if self.cursor < (self.entries.len() as isize - 1) {
self.cursor += 1;
if self.cursor > 0 {
self.cursor -= 1;
Some(self.entries.get(self.cursor as usize).unwrap())
} else {
None
@ -70,27 +176,46 @@ impl History {
/// Try to move forward in history. Returns `None` if history is exhausted (moving beyond most recent element).
pub fn go_forward(&mut self) -> Option<&str> {
if self.cursor >= 0 {
self.cursor -= 1;
if self.cursor < self.entries.len() {
self.cursor += 1;
}
if self.cursor >= 0 {
if self.cursor < self.entries.len() {
Some(self.entries.get(self.cursor as usize).unwrap())
} else {
None
}
}
/// Yields iterator to immutable references from the underlying data structure
pub fn iter(&self) -> std::collections::vec_deque::Iter<'_, String> {
/// Yields iterator to immutable references from the underlying data structure.
/// Order: Oldest entries first.
pub fn iter_chronologic(
&self,
) -> impl Iterator<Item = &String> + DoubleEndedIterator + ExactSizeIterator + '_ {
self.entries.iter()
}
/// Yields iterator to immutable references from the underlying data structure.
/// Order: Most recent entries first.
pub fn iter_recent(
&self,
) -> impl Iterator<Item = &String> + DoubleEndedIterator + ExactSizeIterator + '_ {
self.entries.iter().rev()
}
/// Helper to get items on zero based index starting at the most recent.
pub fn get_nth_newest(&self, idx: usize) -> Option<&String> {
self.entries.get(self.entries().len() - idx - 1)
}
/// Helper to get items on zero based index starting at the oldest entry.
pub fn get_nth_oldest(&self, idx: usize) -> Option<&String> {
self.entries.get(idx)
}
}
impl Index<usize> for History {
type Output = String;
fn index(&self, index: usize) -> &Self::Output {
&self.entries[index]
impl Drop for History {
fn drop(&mut self) {
let _ = self.flush();
}
}

View File

@ -42,7 +42,7 @@ impl BasicSearch {
self.result = None;
} else {
self.result = history
.iter()
.iter_recent()
.enumerate()
.skip(start)
.filter_map(|(history_index, s)| {

View File

@ -2,6 +2,7 @@ mod engine;
pub use engine::{Reedline, Signal};
mod history;
pub use history::{History, HISTORY_SIZE};
mod history_search;

View File

@ -1,9 +1,16 @@
use crossterm::Result;
use reedline::{Reedline, Signal};
use reedline::{History, Reedline, Signal, HISTORY_SIZE};
fn main() -> Result<()> {
let mut line_editor = Reedline::new();
let mut line_editor = match std::env::var("REEDLINE_HISTFILE") {
Ok(histfile) if !histfile.is_empty() => {
// TODO: Allow change of capacity and don't unwrap
let history = History::with_file(HISTORY_SIZE, histfile.into()).unwrap();
Reedline::with_history(history)
}
_ => Reedline::new(),
};
// quick command like parameter handling
let args: Vec<String> = std::env::args().collect();