diff --git a/Cargo.lock b/Cargo.lock index 0f7cebae01d4ff5544f0852e211445e9fed3f042..c839e5b346533f1df492fec33b7c8142f9bce85d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,11 +467,13 @@ name = "proost" version = "0.1.0" dependencies = [ "anyhow", + "atty", "clap", "colored", "kernel", "parser", "rustyline", + "rustyline-derive", ] [[package]] @@ -559,6 +561,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "rustyline-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107c3d5d7f370ac09efa62a78375f94d94b8a33c61d8c278b96683fb4dbf2d8d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scopeguard" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index de3a1a7c0339c1a351e9ccc25f79c1b1edbfdfe5..fbfb7250491e87009e6fb3ce2545b2b308e3c968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ anyhow = "1.0" clap = { version = "4.0.10", features = ["derive"] } derive_more = "0.99.17" num-bigint = "0.4" +atty = "0.2" +colored = "2" +rustyline = "10.0.0" +rustyline-derive = "0.7.0" [workspace.package] authors = [ diff --git a/proost/Cargo.toml b/proost/Cargo.toml index 096b95e3adce1c1d869aa671af0a2c49f0126ce2..20d23c84f28ebf4219f18904ffc8dbc2a5df1f5f 100644 --- a/proost/Cargo.toml +++ b/proost/Cargo.toml @@ -12,6 +12,7 @@ parser.path = "../parser" anyhow.workspace = true clap.workspace = true - -colored = "2" -rustyline = "10.0.0" +atty.workspace = true +colored.workspace = true +rustyline.workspace = true +rustyline-derive.workspace = true diff --git a/proost/src/main.rs b/proost/src/main.rs index 8eac4020aee8cc4fb8a4df983bcfd99abd4e44ad..2f07188ee6f64f48d97a9826ac359ef5847186bc 100644 --- a/proost/src/main.rs +++ b/proost/src/main.rs @@ -1,29 +1,37 @@ #![feature(box_syntax)] +#![feature(let_chains)] mod process; +mod rustyline_helper; +use std::fs; + +use atty::Stream; use clap::Parser; +use rustyline::error::ReadlineError; +use rustyline::{Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers, Result}; +use rustyline_helper::*; + use kernel::Environment; use process::*; -use rustyline::error::ReadlineError; -use rustyline::Editor; -use std::error::Error; -use std::fs; -// clap configuration #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { + /// some .mdln files files: Vec<String>, + /// remove syntax highlighting + #[arg(long)] + no_color: bool, } -// constants fetching const VERSION: &str = env!("CARGO_PKG_VERSION"); const NAME: &str = env!("CARGO_PKG_NAME"); -fn main() -> Result<(), Box<dyn Error>> { +fn main() -> Result<()> { let args = Args::parse(); + // check if files are inputed if !args.files.is_empty() { for path in args.files.iter() { match fs::read_to_string(path) { @@ -36,8 +44,22 @@ fn main() -> Result<(), Box<dyn Error>> { return Ok(()); } - let mut rl_err: Option<ReadlineError> = None; - let mut rl = Editor::<()>::new()?; + // check if we are in a terminal + if atty::isnt(Stream::Stdout) || atty::isnt(Stream::Stdin) { + return Ok(()); + } + + let helper = RustyLineHelper::new(!args.no_color); + let mut rl = Editor::<RustyLineHelper>::new()?; + rl.set_helper(Some(helper)); + rl.bind_sequence( + KeyEvent::from('\t'), + EventHandler::Conditional(Box::new(TabEventHandler)), + ); + rl.bind_sequence( + KeyEvent(KeyCode::Enter, Modifiers::ALT), + EventHandler::Simple(Cmd::Newline), + ); println!("Welcome to {} {}", NAME, VERSION); let mut env = Environment::new(); @@ -52,14 +74,8 @@ fn main() -> Result<(), Box<dyn Error>> { Ok(_) => (), Err(ReadlineError::Interrupted) => {} Err(ReadlineError::Eof) => break, - Err(err) => { - rl_err = Some(err); - break; - } + Err(err) => return Err(err), } } - match rl_err { - None => Ok(()), - Some(err) => Err(box err), - } + Ok(()) } diff --git a/proost/src/rustyline_helper.rs b/proost/src/rustyline_helper.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6a10e9982bb4e01d33509c3054a6773653c685b --- /dev/null +++ b/proost/src/rustyline_helper.rs @@ -0,0 +1,218 @@ +use colored::*; +use rustyline::completion::{Completer, FilenameCompleter, Pair}; +use rustyline::highlight::Highlighter; +use rustyline::hint::HistoryHinter; +use rustyline::validate::{ValidationContext, ValidationResult, Validator}; +use rustyline::{Cmd, ConditionalEventHandler, Context, Event, EventContext, RepeatCount, Result}; +use rustyline_derive::{Helper, Hinter}; +use std::borrow::Cow::{self, Borrowed, Owned}; + +/// Language keywords that should be highligted +const KEYWORDS: [&str; 4] = ["check", "def", "eval", "import"]; + +/// An Helper for a RustyLine Editor that implements: +/// - a standard hinter +/// - customs validator, completer and highlighter +#[derive(Helper, Hinter)] +pub struct RustyLineHelper { + color: bool, + completer: FilenameCompleter, + #[rustyline(Hinter)] + hinter: HistoryHinter, +} + +impl RustyLineHelper { + pub fn new(color: bool) -> Self { + Self { + color, + completer: FilenameCompleter::new(), + hinter: HistoryHinter {}, + } + } +} + +/// An Handler for the tab event +pub struct TabEventHandler; +impl ConditionalEventHandler for TabEventHandler { + fn handle(&self, _: &Event, n: RepeatCount, _: bool, ctx: &EventContext) -> Option<Cmd> { + if ctx.line().starts_with("import") { + return None; + } + Some(Cmd::Insert(n, " ".to_string())) + } +} + +/// A variation of FilenameCompleter: +/// file completion is available only after having typed import +impl Completer for RustyLineHelper { + type Candidate = Pair; + + fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>)> { + if line.starts_with("import") { + self.completer.complete_path(line, pos) + } else { + Ok(Default::default()) + } + } +} + +/// A variation of MatchingBracketValidator: +/// no validation occurs when entering the import command +impl Validator for RustyLineHelper { + fn validate(&self, ctx: &mut ValidationContext) -> Result<ValidationResult> { + if ctx.input().starts_with("import") { + return Ok(ValidationResult::Valid(None)); + } + + Ok(validate_arrows(ctx.input()) + .or_else(|| validate_brackets(ctx.input())) + .unwrap_or(ValidationResult::Valid(None))) + } +} + +fn validate_arrows(input: &str) -> Option<ValidationResult> { + let mut iter = input.as_bytes().iter().rev(); + + if let Some(b) = iter.find(|b| **b != b' ') { + if *b == b'>' && let Some(b) = iter.next() && (*b == b'-' || *b == b'=') { + return Some(ValidationResult::Incomplete) + } + } + + None +} + +fn validate_brackets(input: &str) -> Option<ValidationResult> { + let mut stack = vec![]; + + for c in input.chars() { + match c { + '(' => stack.push(c), + ')' => match stack.pop() { + Some('(') => {} + Some(_) => { + return Some(ValidationResult::Invalid(Some( + "\nMismatched brackets: ) is not properly closed".to_string(), + ))) + } + None => { + return Some(ValidationResult::Invalid(Some( + "\nMismatched brackets: ( is unpaired".to_string(), + ))) + } + }, + _ => {} + } + } + + if stack.is_empty() { + None + } else { + Some(ValidationResult::Incomplete) + } +} + +/// A variation of MatchingBrackerHighlighter: +/// no check occurs before cursor +/// see: https://docs.rs/rustyline/10.0.0/rustyline/highlight/struct.MatchingBracketHighlighter.html +impl Highlighter for RustyLineHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + if !self.color { + return Owned(hint.to_owned()); + } + Owned(format!("{}", hint.bold())) + } + + fn highlight_char(&self, _line: &str, _pos: usize) -> bool { + self.color + } + + fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { + if line.len() <= 1 || !self.color { + return Borrowed(line); + } + let mut copy = line.to_owned(); + + if let Some((bracket, pos)) = get_bracket(line, pos) + && let Some((matching, pos)) = find_matching_bracket(line, pos, bracket) { + let s = String::from(matching as char); + copy.replace_range(pos..=pos, &s.blue().bold()); + } + KEYWORDS + .iter() + .for_each(|keyword| replace_inplace(&mut copy, keyword, &keyword.blue().bold())); + Owned(copy) + } +} + +/// Variation of the std replace function that only replace full words +pub fn replace_inplace(input: &mut String, from: &str, to: &str) { + let mut offset = 0; + while let Some(pos) = input[offset..].find(from) { + if (pos == 0 || input.as_bytes()[offset + pos - 1] == b' ') + && (offset + pos + from.len() == input.len() + || input.as_bytes()[offset + pos + from.len()] == b' ') + { + input.replace_range(offset + pos..offset + pos + from.len(), to); + offset += pos + to.len(); + } else { + offset += pos + from.len() + } + } +} + +fn find_matching_bracket(line: &str, pos: usize, bracket: u8) -> Option<(u8, usize)> { + let matching_bracket = matching_bracket(bracket); + let mut to_match = 1; + + let match_bracket = |b: u8| { + if b == matching_bracket { + to_match -= 1; + } else if b == bracket { + to_match += 1; + }; + to_match == 0 + }; + + if is_open_bracket(bracket) { + // forward search + line[pos + 1..] + .bytes() + .position(match_bracket) + .map(|pos2| (matching_bracket, pos2 + pos + 1)) + } else { + // backward search + line[..pos] + .bytes() + .rev() + .position(match_bracket) + .map(|pos2| (matching_bracket, pos - pos2 - 1)) + } +} + +/// Check if the cursor is on a bracket +const fn get_bracket(line: &str, pos: usize) -> Option<(u8, usize)> { + if !line.is_empty() && pos < line.len() { + let b = line.as_bytes()[pos]; + if is_bracket(b) { + return Some((b, pos)); + } + } + None +} + +const fn matching_bracket(bracket: u8) -> u8 { + match bracket { + b'(' => b')', + b')' => b'(', + _ => unreachable!(), + } +} + +const fn is_bracket(bracket: u8) -> bool { + matches!(bracket, b'(' | b')') +} + +const fn is_open_bracket(bracket: u8) -> bool { + matches!(bracket, b'(') +}