commit 7b3f782aae46f3e70dc2b5713d61ef77500b5fe7 Author: John Date: Fri Apr 19 07:30:17 2024 -0500 repline: Promote to its own crate! cl-repl: Major refactor based on the design of typeck.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0437167 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "repline" +repository.workspace = true +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +crossterm = "0.27.0" diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..9fdb6fb --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,283 @@ +//! The [Editor] + +use crossterm::{cursor::*, execute, queue, style::*, terminal::*}; +use std::{collections::VecDeque, fmt::Display, io::Write}; + +use super::error::{Error, ReplResult}; + +fn is_newline(c: &char) -> bool { + *c == '\n' +} + +fn write_chars<'a, W: Write>( + c: impl IntoIterator, + w: &mut W, +) -> std::io::Result<()> { + for c in c { + write!(w, "{c}")?; + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct Editor<'a> { + head: VecDeque, + tail: VecDeque, + + pub color: &'a str, + begin: &'a str, + again: &'a str, +} + +impl<'a> Editor<'a> { + pub fn new(color: &'a str, begin: &'a str, again: &'a str) -> Self { + Self { head: Default::default(), tail: Default::default(), color, begin, again } + } + pub fn iter(&self) -> impl Iterator { + let Self { head, tail, .. } = self; + head.iter().chain(tail.iter()) + } + pub fn undraw(&self, w: &mut W) -> ReplResult<()> { + let Self { head, .. } = self; + match head.iter().copied().filter(is_newline).count() { + 0 => write!(w, "\x1b[0G"), + lines => write!(w, "\x1b[{}F", lines), + }?; + queue!(w, Clear(ClearType::FromCursorDown))?; + // write!(w, "\x1b[0J")?; + Ok(()) + } + pub fn redraw(&self, w: &mut W) -> ReplResult<()> { + let Self { head, tail, color, begin, again } = self; + write!(w, "{color}{begin}\x1b[0m ")?; + // draw head + for c in head { + match c { + '\n' => write!(w, "\r\n{color}{again}\x1b[0m "), + _ => w.write_all({ *c as u32 }.to_le_bytes().as_slice()), + }? + } + // save cursor + execute!(w, SavePosition)?; + // draw tail + for c in tail { + match c { + '\n' => write!(w, "\r\n{color}{again}\x1b[0m "), + _ => write!(w, "{c}"), + }? + } + // restore cursor + execute!(w, RestorePosition)?; + Ok(()) + } + pub fn prompt(&self, w: &mut W) -> ReplResult<()> { + let Self { head, color, begin, again, .. } = self; + queue!( + w, + MoveToColumn(0), + Print(color), + Print(if head.is_empty() { begin } else { again }), + ResetColor, + Print(' '), + )?; + Ok(()) + } + pub fn print_head(&self, w: &mut W) -> ReplResult<()> { + self.prompt(w)?; + write_chars( + self.head.iter().skip( + self.head + .iter() + .rposition(is_newline) + .unwrap_or(self.head.len()) + + 1, + ), + w, + )?; + Ok(()) + } + pub fn print_tail(&self, w: &mut W) -> ReplResult<()> { + let Self { tail, .. } = self; + queue!(w, SavePosition, Clear(ClearType::UntilNewLine))?; + write_chars(tail.iter().take_while(|&c| !is_newline(c)), w)?; + queue!(w, RestorePosition)?; + Ok(()) + } + pub fn push(&mut self, c: char, w: &mut W) -> ReplResult<()> { + // Tail optimization: if the tail is empty, + //we don't have to undraw and redraw on newline + if self.tail.is_empty() { + self.head.push_back(c); + match c { + '\n' => { + write!(w, "\r\n")?; + self.print_head(w)?; + } + c => { + queue!(w, Print(c))?; + } + }; + return Ok(()); + } + + if '\n' == c { + self.undraw(w)?; + } + self.head.push_back(c); + match c { + '\n' => self.redraw(w)?, + _ => { + write!(w, "{c}")?; + self.print_tail(w)?; + } + } + Ok(()) + } + pub fn pop(&mut self, w: &mut W) -> ReplResult> { + if let Some('\n') = self.head.back() { + self.undraw(w)?; + } + let c = self.head.pop_back(); + // if the character was a newline, we need to go back a line + match c { + Some('\n') => self.redraw(w)?, + Some(_) => { + // go back a char + queue!(w, MoveLeft(1), Print(' '), MoveLeft(1))?; + self.print_tail(w)?; + } + None => {} + } + Ok(c) + } + + pub fn extend, W: Write>( + &mut self, + iter: T, + w: &mut W, + ) -> ReplResult<()> { + for c in iter { + self.push(c, w)?; + } + Ok(()) + } + + pub fn restore(&mut self, s: &str) { + self.clear(); + self.head.extend(s.chars()) + } + pub fn clear(&mut self) { + self.head.clear(); + self.tail.clear(); + } + pub fn delete(&mut self, w: &mut W) -> ReplResult { + match self.tail.front() { + Some('\n') => { + self.undraw(w)?; + let out = self.tail.pop_front(); + self.redraw(w)?; + out + } + _ => { + let out = self.tail.pop_front(); + self.print_tail(w)?; + out + } + } + .ok_or(Error::EndOfInput) + } + pub fn erase_word(&mut self, w: &mut W) -> ReplResult<()> { + while self.pop(w)?.filter(|c| !c.is_whitespace()).is_some() {} + Ok(()) + } + pub fn len(&self) -> usize { + self.head.len() + self.tail.len() + } + pub fn is_empty(&self) -> bool { + self.head.is_empty() && self.tail.is_empty() + } + pub fn ends_with(&self, iter: impl DoubleEndedIterator) -> bool { + let mut iter = iter.rev(); + let mut head = self.head.iter().rev(); + loop { + match (iter.next(), head.next()) { + (None, _) => break true, + (Some(_), None) => break false, + (Some(a), Some(b)) if a != *b => break false, + (Some(_), Some(_)) => continue, + } + } + } + /// Moves the cursor back `steps` steps + pub fn cursor_back(&mut self, steps: usize, w: &mut W) -> ReplResult<()> { + for _ in 0..steps { + if let Some('\n') = self.head.back() { + self.undraw(w)?; + } + let Some(c) = self.head.pop_back() else { + return Ok(()); + }; + self.tail.push_front(c); + match c { + '\n' => self.redraw(w)?, + _ => queue!(w, MoveLeft(1))?, + } + } + Ok(()) + } + /// Moves the cursor forward `steps` steps + pub fn cursor_forward(&mut self, steps: usize, w: &mut W) -> ReplResult<()> { + for _ in 0..steps { + if let Some('\n') = self.tail.front() { + self.undraw(w)? + } + let Some(c) = self.tail.pop_front() else { + return Ok(()); + }; + self.head.push_back(c); + match c { + '\n' => self.redraw(w)?, + _ => queue!(w, MoveRight(1))?, + } + } + Ok(()) + } + /// Goes to the beginning of the current line + pub fn home(&mut self, w: &mut W) -> ReplResult<()> { + loop { + match self.head.back() { + Some('\n') | None => break Ok(()), + Some(_) => self.cursor_back(1, w)?, + } + } + } + /// Goes to the end of the current line + pub fn end(&mut self, w: &mut W) -> ReplResult<()> { + loop { + match self.tail.front() { + Some('\n') | None => break Ok(()), + Some(_) => self.cursor_forward(1, w)?, + } + } + } +} + +impl<'a, 'e> IntoIterator for &'e Editor<'a> { + type Item = &'e char; + type IntoIter = std::iter::Chain< + std::collections::vec_deque::Iter<'e, char>, + std::collections::vec_deque::Iter<'e, char>, + >; + fn into_iter(self) -> Self::IntoIter { + self.head.iter().chain(self.tail.iter()) + } +} +impl<'a> Display for Editor<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + for c in self.iter() { + f.write_char(*c)?; + } + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..241254e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +use crate::iter::chars::BadUnicode; + +/// Result type for Repline +pub type ReplResult = std::result::Result; +/// Borrowed error (does not implement [Error](std::error::Error)!) +#[derive(Debug)] +pub enum Error { + /// User broke with Ctrl+C + CtrlC(String), + /// User broke with Ctrl+D + CtrlD(String), + /// Invalid unicode codepoint + BadUnicode(u32), + /// Error came from [std::io] + IoFailure(std::io::Error), + /// End of input + EndOfInput, +} + +impl std::error::Error for Error {} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::CtrlC(_) => write!(f, "Ctrl+C"), + Error::CtrlD(_) => write!(f, "Ctrl+D"), + Error::BadUnicode(u) => write!(f, "\\u{{{u:x}}} is not a valid unicode codepoint"), + Error::IoFailure(s) => write!(f, "{s}"), + Error::EndOfInput => write!(f, "End of input"), + } + } +} +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::IoFailure(value) + } +} +impl From for Error { + fn from(value: BadUnicode) -> Self { + let BadUnicode(code) = value; + Self::BadUnicode(code) + } +} diff --git a/src/iter.rs b/src/iter.rs new file mode 100644 index 0000000..4af50bc --- /dev/null +++ b/src/iter.rs @@ -0,0 +1,68 @@ +//! Shmancy iterator adapters + +pub use chars::Chars; +pub use flatten::Flatten; + +pub mod chars { + //! Converts an [Iterator] into an + //! [Iterator]> + + /// Invalid unicode codepoint found when iterating over [Chars] + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct BadUnicode(pub u32); + impl std::error::Error for BadUnicode {} + impl std::fmt::Display for BadUnicode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(code) = self; + write!(f, "Bad unicode: {code}") + } + } + + /// Converts an [Iterator] into an + /// [Iterator] + #[derive(Clone, Debug)] + pub struct Chars>(pub I); + impl> Iterator for Chars { + type Item = Result; + fn next(&mut self) -> Option { + let Self(bytes) = self; + let start = bytes.next()? as u32; + let (mut out, count) = match start { + start if start & 0x80 == 0x00 => (start, 0), // ASCII valid range + start if start & 0xe0 == 0xc0 => (start & 0x1f, 1), // 1 continuation byte + start if start & 0xf0 == 0xe0 => (start & 0x0f, 2), // 2 continuation bytes + start if start & 0xf8 == 0xf0 => (start & 0x07, 3), // 3 continuation bytes + _ => return None, + }; + for _ in 0..count { + let cont = bytes.next()? as u32; + if cont & 0xc0 != 0x80 { + return None; + } + out = out << 6 | (cont & 0x3f); + } + Some(char::from_u32(out).ok_or(BadUnicode(out))) + } + } +} +pub mod flatten { + //! Flattens an [Iterator] returning [`Result`](Result) or [`Option`](Option) + //! into a *non-[FusedIterator](std::iter::FusedIterator)* over `T` + + /// Flattens an [Iterator] returning [`Result`](Result) or [`Option`](Option) + /// into a *non-[FusedIterator](std::iter::FusedIterator)* over `T` + #[derive(Clone, Debug)] + pub struct Flatten>(pub I); + impl>> Iterator for Flatten, I> { + type Item = T; + fn next(&mut self) -> Option { + self.0.next()?.ok() + } + } + impl>> Iterator for Flatten, I> { + type Item = T; + fn next(&mut self) -> Option { + self.0.next()? + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d779cef --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +//! A small pseudo-multiline editing library + +mod editor; +mod iter; +mod raw; + +pub mod error; +pub mod prebaked; +pub mod repline; + +pub use error::Error; +pub use prebaked::{read_and, Response}; +pub use repline::Repline; diff --git a/src/prebaked.rs b/src/prebaked.rs new file mode 100644 index 0000000..ba2aa15 --- /dev/null +++ b/src/prebaked.rs @@ -0,0 +1,56 @@ +//! Here's a menu I prepared earlier! +//! +//! Constructs a [Repline] and repeatedly runs the provided closure on the input strings, +//! obeying the closure's [Response]. + +use std::error::Error; + +use crate::{error::Error as RlError, repline::Repline}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +/// Control codes for the [prebaked menu](read_and) +pub enum Response { + /// Accept the line, and save it to history + Accept, + /// Reject the line, and clear the buffer + Deny, + /// End the loop + Break, + /// Gather more input and try again + Continue, +} + +/// Implements a basic menu loop using an embedded [Repline]. +/// +/// Repeatedly runs the provided closure on the input strings, +/// obeying the closure's [Response]. +/// +/// Captures and displays all user [Error]s. +/// +/// # Keybinds +/// - `Ctrl+C` exits the loop +/// - `Ctrl+D` clears the input, but *runs the closure* with the old input +pub fn read_and(color: &str, begin: &str, again: &str, mut f: F) -> Result<(), RlError> +where F: FnMut(&str) -> Result> { + let mut rl = Repline::new(color, begin, again); + loop { + let line = match rl.read() { + Err(RlError::CtrlC(_)) => break, + Err(RlError::CtrlD(line)) => { + rl.deny(); + line + } + Ok(line) => line, + Err(e) => Err(e)?, + }; + print!("\x1b[G\x1b[J"); + match f(&line) { + Ok(Response::Accept) => rl.accept(), + Ok(Response::Deny) => rl.deny(), + Ok(Response::Break) => break, + Ok(Response::Continue) => continue, + Err(e) => print!("\x1b[40G\x1b[A\x1bJ\x1b[91m{e}\x1b[0m\x1b[B"), + } + } + Ok(()) +} diff --git a/src/raw.rs b/src/raw.rs new file mode 100644 index 0000000..1b66d3f --- /dev/null +++ b/src/raw.rs @@ -0,0 +1,21 @@ +//! Sets the terminal to [`raw`] mode for the duration of the returned object's lifetime. + +/// Sets the terminal to raw mode for the duration of the returned object's lifetime. +pub fn raw() -> impl Drop { + Raw::default() +} +struct Raw(); +impl Default for Raw { + fn default() -> Self { + std::thread::yield_now(); + crossterm::terminal::enable_raw_mode() + .expect("should be able to transition into raw mode"); + Raw() + } +} +impl Drop for Raw { + fn drop(&mut self) { + crossterm::terminal::disable_raw_mode() + .expect("should be able to transition out of raw mode"); + } +} diff --git a/src/repline.rs b/src/repline.rs new file mode 100644 index 0000000..4dc19e7 --- /dev/null +++ b/src/repline.rs @@ -0,0 +1,175 @@ +//! Prompts the user, reads the lines. Not much more to it than that. +//! +//! This module is in charge of parsing keyboard input and interpreting it for the line editor. + +use crate::{editor::Editor, error::*, iter::*, raw::raw}; +use std::{ + collections::VecDeque, + io::{stdout, Bytes, Read, Result, Write}, +}; + +/// Prompts the user, reads the lines. Not much more to it than that. +#[derive(Debug)] +pub struct Repline<'a, R: Read> { + input: Chars, Bytes>>, + + history: VecDeque, // previous lines + hindex: usize, // current index into the history buffer + + ed: Editor<'a>, // the current line buffer +} + +impl<'a> Repline<'a, std::io::Stdin> { + pub fn new(color: &'a str, begin: &'a str, again: &'a str) -> Self { + Self::with_input(std::io::stdin(), color, begin, again) + } +} + +impl<'a, R: Read> Repline<'a, R> { + /// Constructs a [Repline] with the given [Reader](Read), color, begin, and again prompts. + pub fn with_input(input: R, color: &'a str, begin: &'a str, again: &'a str) -> Self { + Self { + input: Chars(Flatten(input.bytes())), + history: Default::default(), + hindex: 0, + ed: Editor::new(color, begin, again), + } + } + /// Set the terminal prompt color + pub fn set_color(&mut self, color: &'a str) { + self.ed.color = color + } + /// Append line to history and clear it + pub fn accept(&mut self) { + self.history_append(self.ed.to_string()); + self.ed.clear(); + self.hindex = self.history.len(); + } + /// Clear the line + pub fn deny(&mut self) { + self.ed.clear() + } + /// Reads in a line, and returns it for validation + pub fn read(&mut self) -> ReplResult { + const INDENT: &str = " "; + let mut stdout = stdout().lock(); + let stdout = &mut stdout; + let _make_raw = raw(); + // self.ed.begin_frame(stdout)?; + // self.ed.redraw_frame(stdout)?; + self.ed.print_head(stdout)?; + loop { + stdout.flush()?; + match self.input.next().ok_or(Error::EndOfInput)?? { + // Ctrl+C: End of Text. Immediately exits. + '\x03' => { + drop(_make_raw); + writeln!(stdout)?; + return Err(Error::CtrlC(self.ed.to_string())); + } + // Ctrl+D: End of Transmission. Ends the current line. + '\x04' => { + drop(_make_raw); + writeln!(stdout)?; + return Err(Error::CtrlD(self.ed.to_string())); + } + // Tab: extend line by 4 spaces + '\t' => { + self.ed.extend(INDENT.chars(), stdout)?; + } + // ignore newlines, process line feeds. Not sure how cross-platform this is. + '\n' => {} + '\r' => { + self.ed.push('\n', stdout)?; + return Ok(self.ed.to_string()); + } + // Ctrl+Backspace in my terminal + '\x17' => { + self.ed.erase_word(stdout)?; + } + // Escape sequence + '\x1b' => self.escape(stdout)?, + // backspace + '\x08' | '\x7f' => { + let ed = &mut self.ed; + if ed.ends_with(INDENT.chars()) { + for _ in 0..INDENT.len() { + ed.pop(stdout)?; + } + } else { + ed.pop(stdout)?; + } + } + c if c.is_ascii_control() => { + if cfg!(debug_assertions) { + self.ed.extend(c.escape_debug(), stdout)?; + } + } + c => { + self.ed.push(c, stdout)?; + } + } + } + } + /// Handle ANSI Escape + fn escape(&mut self, w: &mut W) -> ReplResult<()> { + match self.input.next().ok_or(Error::EndOfInput)?? { + '[' => self.csi(w)?, + 'O' => todo!("Process alternate character mode"), + other => self.ed.extend(other.escape_debug(), w)?, + } + Ok(()) + } + /// Handle ANSI Control Sequence Introducer + fn csi(&mut self, w: &mut W) -> ReplResult<()> { + match self.input.next().ok_or(Error::EndOfInput)?? { + 'A' => { + self.hindex = self.hindex.saturating_sub(1); + self.restore_history(w)? + } + 'B' => { + self.hindex = self.hindex.saturating_add(1).min(self.history.len()); + self.restore_history(w)? + } + 'C' => self.ed.cursor_forward(1, w)?, + 'D' => self.ed.cursor_back(1, w)?, + 'H' => self.ed.home(w)?, + 'F' => self.ed.end(w)?, + '3' => { + if let '~' = self.input.next().ok_or(Error::EndOfInput)?? { + let _ = self.ed.delete(w); + } + } + other => { + if cfg!(debug_assertions) { + self.ed.extend(other.escape_debug(), w)?; + } + } + } + Ok(()) + } + /// Restores the currently selected history + fn restore_history(&mut self, w: &mut W) -> ReplResult<()> { + let Self { history, hindex, ed, .. } = self; + ed.undraw(w)?; + ed.clear(); + ed.print_head(w)?; + if let Some(history) = history.get(*hindex) { + ed.extend(history.chars(), w)? + } + Ok(()) + } + + /// Append line to history + fn history_append(&mut self, mut buf: String) { + while buf.ends_with(char::is_whitespace) { + buf.pop(); + } + if !self.history.contains(&buf) { + self.history.push_back(buf) + } + while self.history.len() > 20 { + self.history.pop_front(); + } + } +}