From 77db95791aef7d698aa3bade07c28842ebf8e502 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 26 Feb 2024 15:15:34 -0600 Subject: [PATCH] cl-frontend: Improve the line-editing capabilities in REPL mode. - Note: This adds a dependency on Crossterm, to set the terminal into raw mode. This could be eliminated by linking the libraries Crossterm uses directly... Or I could embrace Crossterm, and use it for all escape sequences in the terminal, instead of assuming DEC VT500 compatibility. --- cl-frontend/Cargo.toml | 1 + cl-frontend/src/lib.rs | 69 +++--- cl-frontend/src/main.rs | 5 +- cl-frontend/src/repline.rs | 481 +++++++++++++++++++++++++++++++++++++ 4 files changed, 511 insertions(+), 45 deletions(-) create mode 100644 cl-frontend/src/repline.rs diff --git a/cl-frontend/Cargo.toml b/cl-frontend/Cargo.toml index caa01aa..bac1e19 100644 --- a/cl-frontend/Cargo.toml +++ b/cl-frontend/Cargo.toml @@ -11,3 +11,4 @@ publish.workspace = true [dependencies] conlang = { path = "../libconlang" } +crossterm = "0.27.0" diff --git a/cl-frontend/src/lib.rs b/cl-frontend/src/lib.rs index 506b096..ee51c55 100644 --- a/cl-frontend/src/lib.rs +++ b/cl-frontend/src/lib.rs @@ -199,7 +199,6 @@ pub mod cli { use std::{ convert::Infallible, error::Error, - io::{stdin, stdout, Write}, path::{Path, PathBuf}, str::FromStr, }; @@ -358,32 +357,12 @@ pub mod cli { /// Prompt functions impl Repl { - pub fn prompt_begin(&self) { - print!( - "{}{} {ANSI_RESET}", - self.mode.ansi_color(), - self.prompt_begin - ); - let _ = stdout().flush(); - } - pub fn prompt_again(&self) { - print!( - "{}{} {ANSI_RESET}", - self.mode.ansi_color(), - self.prompt_again - ); - let _ = stdout().flush(); - } pub fn prompt_error(&self, err: &impl Error) { println!("{ANSI_RED}{} {err}{ANSI_RESET}", self.prompt_error) } pub fn begin_output(&self) { print!("{ANSI_OUTPUT}") } - fn reprompt(&self, buf: &mut String) { - self.prompt_begin(); - buf.clear(); - } } /// The actual REPL impl Repl { @@ -393,56 +372,62 @@ pub mod cli { } /// Runs the main REPL loop pub fn repl(&mut self) { - let mut buf = String::new(); - self.prompt_begin(); - while let Ok(len) = stdin().read_line(&mut buf) { + use crate::repline::Repline; + let mut rl = Repline::new( + // std::fs::File::open("/dev/stdin").unwrap(), + self.mode.ansi_color(), + self.prompt_begin, + self.prompt_again, + ); + // self.prompt_begin(); + while let Ok(line) = rl.read() { // Exit the loop - if len == 0 { + if line.is_empty() { println!(); break; } self.begin_output(); // Process mode-change commands - if self.command(&buf) { - self.reprompt(&mut buf); + if self.command(&line) { + rl.accept(); + rl.set_color(self.mode.ansi_color()); continue; } // Lex the buffer, or reset and output the error - let code = Program::new(&buf); + let code = Program::new(&line); match code.lex().into_iter().find(|l| l.is_err()) { None => (), Some(Ok(_)) => unreachable!(), Some(Err(error)) => { eprintln!("{error}"); - self.reprompt(&mut buf); + rl.deny(); continue; } }; + // TODO: only lex the program once // Tokenize mode doesn't require valid parse, so it gets processed first if self.mode == Mode::Tokenize { self.tokenize(&code); - self.reprompt(&mut buf); + rl.deny(); continue; } // Parse code and dispatch to the proper function - match (len, code.parse()) { + match (line.len(), code.parse()) { + (0, Ok(_)) => { + println!(); + break; + } // If the code is OK, run it and print any errors (_, Ok(mut code)) => { + println!(); self.dispatch(&mut code); - buf.clear(); + rl.accept(); } - // If the user types two newlines, print syntax errors - (1, Err(e)) => { - self.prompt_error(&e); - buf.clear(); - } - // Otherwise, ask for more input - _ => { - self.prompt_again(); + (_, Err(e)) => { + print!("\x1b[100G\x1b[37m//{e}"); continue; } } - self.prompt_begin() } } @@ -506,3 +491,5 @@ pub mod cli { ) } } + +pub mod repline; diff --git a/cl-frontend/src/main.rs b/cl-frontend/src/main.rs index 4f0b7e0..ec50ac4 100644 --- a/cl-frontend/src/main.rs +++ b/cl-frontend/src/main.rs @@ -2,8 +2,5 @@ use cl_frontend::{args::Args, cli::CLI}; use std::error::Error; fn main() -> Result<(), Box> { - // parse args - let args = Args::new().parse().unwrap_or_default(); - let mut cli = CLI::from(args); - cli.run() + CLI::from(Args::new().parse().unwrap_or_default()).run() } diff --git a/cl-frontend/src/repline.rs b/cl-frontend/src/repline.rs new file mode 100644 index 0000000..f09f30b --- /dev/null +++ b/cl-frontend/src/repline.rs @@ -0,0 +1,481 @@ +//! A small pseudo-multiline editing library +// #![allow(unused)] + +pub mod error { + /// 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, "0x{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) + } + } +} + +pub mod ignore { + //! Does nothing, universally. + //! + //! Introduces the [Ignore] trait, and its singular function, [ignore](Ignore::ignore), + //! which does nothing. + impl Ignore for T {} + /// Does nothing + /// + /// # Examples + /// ```rust + /// #![deny(unused_must_use)] + /// # use cl_frontend::repline::ignore::Ignore; + /// ().ignore(); + /// Err::<(), &str>("Foo").ignore(); + /// Some("Bar").ignore(); + /// 42.ignore(); + /// + /// #[must_use] + /// fn the_meaning() -> usize { + /// 42 + /// } + /// the_meaning().ignore(); + /// ``` + pub trait Ignore { + /// Does nothing + fn ignore(&self) {} + } +} + +pub mod chars { + //! Converts an [Iterator] into an + //! [Iterator] + + use super::error::*; + + /// Converts an [Iterator] into an + /// [Iterator] + #[derive(Clone, Debug)] + pub struct Chars>(pub I); + impl> Chars { + pub fn new(bytes: I) -> Self { + Self(bytes) + } + } + impl> Iterator for Chars { + type Item = ReplResult; + 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(Error::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` + 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()? + } + } +} + +pub mod raw { + //! 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"); + // std::thread::yield_now(); + } + } +} + +mod out { + #![allow(unused)] + use std::io::{Result, Write}; + + /// A [Writer](Write) that flushes after every wipe + #[derive(Clone, Debug)] + pub(super) struct EagerWriter { + out: W, + } + impl EagerWriter { + pub fn new(writer: W) -> Self { + Self { out: writer } + } + } + impl Write for EagerWriter { + fn write(&mut self, buf: &[u8]) -> Result { + let out = self.out.write(buf)?; + self.out.flush()?; + Ok(out) + } + fn flush(&mut self) -> Result<()> { + self.out.flush() + } + } +} + +use self::{chars::Chars, editor::Editor, error::*, flatten::Flatten, ignore::Ignore, raw::raw}; +use std::{ + collections::VecDeque, + io::{stdout, Bytes, Read, Result, Write}, +}; + +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, 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 + } + /// Reads in a line, and returns it for validation + pub fn read(&mut self) -> ReplResult { + const INDENT: &str = " "; + let mut stdout = stdout().lock(); + let _make_raw = raw(); + self.ed.begin_frame(&mut stdout)?; + self.ed.render(&mut stdout)?; + loop { + stdout.flush()?; + self.ed.begin_frame(&mut stdout)?; + match self.input.next().ok_or(Error::EndOfInput)?? { + // Ctrl+C: End of Text. Immediately exits. + // Ctrl+D: End of Transmission. Ends the current line. + '\x03' => { + drop(_make_raw); + return Err(Error::CtrlC(self.ed.to_string())); + } + '\x04' => { + drop(_make_raw); + writeln!(stdout).ignore(); + self.ed.render(&mut stdout)?; // TODO: this, better + return Ok(self.ed.to_string()); + // return Err(Error::CtrlD(self.ed.to_string())); + } + // Tab: extend line by 4 spaces + '\t' => { + self.ed.extend(INDENT.chars()); + } + // ignore newlines, process line feeds. Not sure how cross-platform this is. + '\n' => {} + '\r' => { + self.ed.push('\n').ignore(); + self.ed.render(&mut stdout)?; + return Ok(self.ed.to_string()); + } + // Escape sequence + '\x1b' => self.escape()?, + // backspace + '\x08' | '\x7f' => { + let ed = &mut self.ed; + if ed.ends_with(INDENT.chars()) { + for _ in 0..INDENT.len() { + ed.pop().ignore(); + } + } else { + self.ed.pop().ignore(); + } + } + c if c.is_ascii_control() => { + eprint!("\\x{:02x}", c as u32); + } + c => { + self.ed.push(c).unwrap(); + } + } + self.ed.render(&mut stdout)?; + } + } + /// Handle ANSI Escape + fn escape(&mut self) -> ReplResult<()> { + match self.input.next().ok_or(Error::EndOfInput)?? { + '[' => self.csi()?, + 'O' => todo!("Process alternate character mode"), + other => self.ed.extend(['\x1b', other]), + } + Ok(()) + } + /// Handle ANSI Control Sequence Introducer + fn csi(&mut self) -> ReplResult<()> { + match self.input.next().ok_or(Error::EndOfInput)?? { + 'A' => { + self.hindex = self.hindex.saturating_sub(1); + self.restore_history(); + } + 'B' => { + self.hindex = self + .hindex + .saturating_add(1) + .min(self.history.len().saturating_sub(1)); + self.restore_history(); + } + 'C' => self.ed.cursor_back(1).ignore(), + 'D' => self.ed.cursor_forward(1).ignore(), + '3' => { + if let '~' = self.input.next().ok_or(Error::EndOfInput)?? { + self.ed.delete().ignore() + } + } + other => { + eprintln!("{other}"); + } + } + Ok(()) + } + /// Restores the currently selected history + pub fn restore_history(&mut self) { + let Self { history, hindex, ed, .. } = self; + if !(0..history.len()).contains(hindex) { + return; + }; + ed.clear(); + ed.extend( + history + .get(*hindex) + .expect("history should contain index") + .trim_end_matches(|c: char| c != '\n' && c.is_whitespace()) + .chars(), + ); + } + + /// Append line to history and clear it + pub fn accept(&mut self) { + self.history_append(self.ed.iter().collect()); + self.ed.clear(); + self.hindex = self.history.len(); + } + /// Append line to history + pub fn history_append(&mut self, buf: String) { + if !self.history.contains(&buf) { + self.history.push_back(buf) + } + while self.history.len() > 20 { + self.history.pop_front(); + } + } + /// Clear the line + pub fn deny(&mut self) {} +} + +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) + } +} + +pub mod editor { + use std::{collections::VecDeque, fmt::Display, io::Write}; + + use super::error::{Error, ReplResult}; + + #[derive(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 { + self.head.iter() + } + pub fn begin_frame(&self, w: &mut W) -> ReplResult<()> { + let Self { head, .. } = self; + match head.iter().filter(|&&c| c == '\n').count() { + 0 => write!(w, "\x1b[0G"), + lines => write!(w, "\x1b[{}F", lines), + }?; + write!(w, "\x1b[0J")?; + Ok(()) + } + pub fn render(&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 + write!(w, "\x1b[s")?; + // draw tail + for c in tail { + match c { + '\n' => write!(w, "\r\n{color}{again}\x1b[0m "), + _ => write!(w, "{c}"), + }? + } + // restore cursor + w.write_all(b"\x1b[u").map_err(Into::into) + } + 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 push(&mut self, c: char) -> ReplResult<()> { + self.head.push_back(c); + Ok(()) + } + pub fn pop(&mut self) -> ReplResult { + self.head.pop_back().ok_or(Error::EndOfInput) + } + pub fn delete(&mut self) -> ReplResult { + self.tail.pop_front().ok_or(Error::EndOfInput) + } + 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_forward(&mut self, steps: usize) -> Option<()> { + let Self { head, tail, .. } = self; + for _ in 0..steps { + tail.push_front(head.pop_back()?); + } + Some(()) + } + /// Moves the cursor forward `steps` steps + pub fn cursor_back(&mut self, steps: usize) -> Option<()> { + let Self { head, tail, .. } = self; + for _ in 0..steps { + head.push_back(tail.pop_front()?); + } + Some(()) + } + } + + impl<'a> Extend for Editor<'a> { + fn extend>(&mut self, iter: T) { + self.head.extend(iter) + } + } + + 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; + let Self { head, tail, .. } = self; + for c in head { + f.write_char(*c)?; + } + for c in tail { + f.write_char(*c)?; + } + Ok(()) + } + } +}