diff --git a/Cargo.lock b/Cargo.lock index 7d6666c..834c788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ dependencies = [ [[package]] name = "rustcalc" -version = "1.4.1" +version = "1.5.0" dependencies = [ "colored", "dirs", diff --git a/Cargo.toml b/Cargo.toml index b5cfc65..25154bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustcalc" -version = "1.4.1" +version = "1.5.0" authors = ["George-Lewis "] edition = "2018" description = "A command-line utility for evaluating mathematical statements" diff --git a/src/cli/editor/completer.rs b/src/cli/editor/completer.rs new file mode 100644 index 0000000..a64d97b --- /dev/null +++ b/src/cli/editor/completer.rs @@ -0,0 +1,49 @@ +use rustyline::completion::{Completer, Pair}; + +use super::{ + finder::{find_items, Findable}, + MyHelper, +}; + +fn find_candidates(line: &str, items: &[Item]) -> Option> { + let create_intermediate = |stride, item: &Item| { + let replacement = item.replacement()[stride..].to_string(); + let display = item.format(); + Pair { + display, + replacement, + } + }; + find_items(line, items, create_intermediate) +} + +impl Completer for MyHelper<'_> { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let line = &line[..pos]; + + let funcs = self.funcs.borrow(); + let vars = self.vars.borrow(); + + let candidates = if let Some(candidates) = find_candidates(line, &funcs) { + (pos, candidates) + } else if let Some(candidates) = find_candidates(line, &vars) { + (pos, candidates) + } else { + (0, vec![]) + }; + + rustyline::Result::Ok(candidates) + } + + fn update(&self, line: &mut rustyline::line_buffer::LineBuffer, start: usize, elected: &str) { + let end = line.pos(); + line.replace(start..end, elected); + } +} diff --git a/src/cli/editor/finder.rs b/src/cli/editor/finder.rs new file mode 100644 index 0000000..e4d9d5c --- /dev/null +++ b/src/cli/editor/finder.rs @@ -0,0 +1,128 @@ +use std::borrow::Cow; + +use rustmatheval::model::{ + functions::{Function, PREFIX as FUNCTION_PREFIX}, + variables::{Variable, PREFIX as VARIABLE_PREFIX}, +}; + +use crate::funcs::format_func_with_args; +use crate::vars::format_var_name; + +/// Find the position of the last instance of `c` +/// +/// ## Examples +/// +/// ``` +/// use rustmatheval::model::functions::PREFIX; +/// let s = format1("abc {}foo bar", PREFIX); +/// let pos = find_last(PREFIX, s).unwrap(); +/// assert_eq!(s.chars().nth(pos), PREFIX); +/// ``` +pub fn find_last(c: char, str: &str) -> Option { + str.chars() + .into_iter() + .rev() + .position(|ch| ch == c) + .map(|pos| str.chars().count() - pos - 1) +} + +/// Find all possibly completable `Item`s in `line` at the position closest to the end as indicated by [`Findable::prefix`] +/// and perform transformations on it using the two `Fn` parameters. +/// +/// ## Parameters +/// * `Item` - A [`Findable`] type to search for in `line` +/// * `Intermediate` - The output type +/// * `ToIntermediate` - A `Fn` type capable of converting `Item`s to `Intermediate` +/// +/// ## Arguments +/// * `line` - The string to find items within +/// * `items` - A slice of items, these are candidates for the search +/// * `create_intermediate` - A `Fn` that: +/// * accepts: +/// * `stride: usize`: The stride of the matching section +/// * `item: &Item`: The matching item +/// * and produces an `Intermediate` +/// +/// ## Returns +/// Returns `None` if there are no possible matches inside the input stringThere are two reasons this could occur: +/// * There is no prefix in the string, and thus no matches are possible +/// * There is a prefix in the string, but none of the items could possibly complete the identifier +/// +/// Otherwise: Returns a vector of intermediates +pub(super) fn find_items( + line: &str, + items: &[Item], + create_intermediate: ToIntermediate, +) -> Option> +where + Item: Findable, + ToIntermediate: Fn(usize, &Item) -> Intermediate, +{ + if let Some(pos) = find_last(Item::prefix(), line) { + // +1 removes prefix + // e.g. "#foobar" => "foobar" + let line = &line[pos + 1..]; + let stride = line.len(); + + let matches: Vec = items + .iter() + .filter(|it| it.name().starts_with(line)) + .map(|it| create_intermediate(stride, it)) + .collect(); + if !matches.is_empty() { + return Some(matches); + } + } + None +} + +/// Represents a type that can be found (using [`find_items`]) +pub trait Findable { + fn name(&self) -> &str; + fn replacement(&self) -> Cow<'_, str>; + fn format(&self) -> String; + fn prefix() -> char; +} + +impl Findable for Function { + fn name(&self) -> &str { + &self.name + } + + fn replacement(&self) -> Cow<'_, str> { + let appendix = if self.arity() == 0 { + // If the function takes no arguments we can just open and close the parens + "()" + } else { + "(" + }; + let formatted = format!("{}{}", &self.name, appendix); + Cow::Owned(formatted) + } + + fn format(&self) -> String { + format_func_with_args(self) + } + + fn prefix() -> char { + FUNCTION_PREFIX + } +} + +impl Findable for Variable { + fn name(&self) -> &str { + &self.repr + } + + fn replacement(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.repr) + } + + fn format(&self) -> String { + format_var_name(&self.repr).to_string() + } + + fn prefix() -> char { + VARIABLE_PREFIX + } +} diff --git a/src/cli/editor/highlighter.rs b/src/cli/editor/highlighter.rs new file mode 100644 index 0000000..890d70b --- /dev/null +++ b/src/cli/editor/highlighter.rs @@ -0,0 +1,37 @@ +use std::borrow::Cow; + +use colored::Colorize; +use rustyline::highlight::Highlighter; + +use super::MyHelper; + +impl Highlighter for MyHelper<'_> { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> std::borrow::Cow<'l, str> { + Cow::Borrowed(line) + } + + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> std::borrow::Cow<'b, str> { + Cow::Borrowed(prompt) + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> { + Cow::Owned(hint.black().on_white().to_string()) + } + + fn highlight_candidate<'c>( + &self, + candidate: &'c str, + _completion: rustyline::CompletionType, + ) -> std::borrow::Cow<'c, str> { + // We don't highlight the candidate because the completer formats with color + Cow::Borrowed(candidate) + } + + fn highlight_char(&self, _line: &str, _pos: usize) -> bool { + true + } +} diff --git a/src/cli/editor/hinter.rs b/src/cli/editor/hinter.rs new file mode 100644 index 0000000..2652b39 --- /dev/null +++ b/src/cli/editor/hinter.rs @@ -0,0 +1,25 @@ +use rustyline::hint::Hinter; + +use super::{ + finder::{find_items, Findable}, + MyHelper, +}; + +pub fn find_hint(line: &str, items: &[Item]) -> Option { + let create_intermediate = |stride, item: &Item| item.replacement()[stride..].to_string(); + let hints = find_items(line, items, create_intermediate); + hints.and_then(|hints| hints.into_iter().max_by_key(String::len)) +} + +impl Hinter for MyHelper<'_> { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + let line = &line[..pos]; + + let funcs = self.funcs.borrow(); + let vars = self.vars.borrow(); + + find_hint(line, &funcs).or_else(|| find_hint(line, &vars)) + } +} diff --git a/src/cli/editor/mod.rs b/src/cli/editor/mod.rs new file mode 100644 index 0000000..e108142 --- /dev/null +++ b/src/cli/editor/mod.rs @@ -0,0 +1,32 @@ +use std::cell::RefCell; + +use rustmatheval::model::{functions::Function, variables::Variable}; +use rustyline::{config::Configurer, Editor, Helper}; + +mod completer; +mod finder; +mod highlighter; +mod hinter; +mod validator; + +pub fn editor<'a>( + funcs: &'a RefCell>, + vars: &'a RefCell>, +) -> Editor> { + let mut editor = Editor::::new(); + + let helper = MyHelper { funcs, vars }; + + editor.set_helper(Some(helper)); + + editor.set_completion_type(rustyline::CompletionType::List); + + editor +} + +pub struct MyHelper<'cell> { + pub funcs: &'cell RefCell>, + pub vars: &'cell RefCell>, +} + +impl Helper for MyHelper<'_> {} diff --git a/src/cli/editor/validator.rs b/src/cli/editor/validator.rs new file mode 100644 index 0000000..8ad5665 --- /dev/null +++ b/src/cli/editor/validator.rs @@ -0,0 +1,16 @@ +use rustyline::validate::{ValidationResult, Validator}; + +use super::MyHelper; + +impl Validator for MyHelper<'_> { + fn validate( + &self, + _ctx: &mut rustyline::validate::ValidationContext, + ) -> rustyline::Result { + Ok(rustyline::validate::ValidationResult::Valid(None)) + } + + fn validate_while_typing(&self) -> bool { + false + } +} diff --git a/src/cli/funcs.rs b/src/cli/funcs.rs index fecbd75..78c5074 100644 --- a/src/cli/funcs.rs +++ b/src/cli/funcs.rs @@ -41,11 +41,18 @@ pub fn format_func_name(name: &str) -> ColoredString { format!("#{}", name.magenta().bold()).normal() } -fn format_func(func: &Function, funcs: &[Function], vars: &[Variable]) -> String { +pub fn format_func_with_args(func: &Function) -> String { format!( - "[ {}({}) = {} ]", + "{}({})", format_func_name(&func.name), - func.args.iter().map(color_arg).join(", "), + func.args.iter().map(color_arg).join(", ") + ) +} + +fn format_func(func: &Function, funcs: &[Function], vars: &[Variable]) -> String { + format!( + "[ {} = {} ]", + format_func_with_args(func), stringify_func_code(func, funcs, vars) ) } diff --git a/src/cli/main.rs b/src/cli/main.rs index 8368ca1..16e389e 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; +mod editor; mod error; mod funcs; mod rcfile; @@ -13,14 +14,13 @@ use lib::{doeval, model::EvaluationContext}; pub use rustmatheval as lib; use config::HISTORY_FILE; -use rustyline::Editor; use error::Error; -use std::{env, process}; +use std::{cell::RefCell, env, process}; use cli::{handle_errors, handle_input}; -use crate::cli::handle_library_errors; +use crate::{cli::handle_library_errors, editor::editor}; pub fn main() -> ! { // One-shot mode @@ -51,10 +51,10 @@ pub fn main() -> ! { process::exit(code); } - let mut vars = vec![]; - let mut funcs = vec![]; + let vars = RefCell::new(vec![]); + let funcs = RefCell::new(vec![]); - if let Err(inner) = rcfile::load(&mut vars, &mut funcs) { + if let Err(inner) = rcfile::load(&mut vars.borrow_mut(), &mut funcs.borrow_mut()) { match inner { Error::Io(inner) => { println!("Error loading RCFile: {:#?}", inner); @@ -63,7 +63,7 @@ pub fn main() -> ! { } }; - let mut editor = Editor::<()>::new(); + let mut editor = editor(&funcs, &vars); if let Some(path) = HISTORY_FILE.as_deref() { editor.load_history(path).ok(); @@ -88,7 +88,7 @@ pub fn main() -> ! { // Add the line to the history editor.add_history_entry(&input); - match handle_input(&input, &mut vars, &mut funcs) { + match handle_input(&input, &mut vars.borrow_mut(), &mut funcs.borrow_mut()) { Ok(formatted) => println!("{}", formatted), Err(error) => { let msg = handle_errors(&error, &input); diff --git a/src/lib/model/functions.rs b/src/lib/model/functions.rs index a651e36..7623352 100644 --- a/src/lib/model/functions.rs +++ b/src/lib/model/functions.rs @@ -8,6 +8,8 @@ use super::{ EvaluationContext, }; +pub const PREFIX: char = '#'; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Functions<'a> { Builtin(&'a Operator), @@ -61,7 +63,7 @@ impl Searchable for Function { impl Function { pub fn is(text: &str) -> bool { - text.starts_with('#') + text.starts_with(PREFIX) } pub fn next_function<'a>(text: &str, funcs: &'a [Self]) -> Option<(&'a Self, usize)> { get_by_repr(text, funcs) diff --git a/src/lib/model/variables.rs b/src/lib/model/variables.rs index e9b9fbf..2cb0e27 100644 --- a/src/lib/model/variables.rs +++ b/src/lib/model/variables.rs @@ -1,5 +1,7 @@ use super::representable::{get_by_repr, Searchable}; +pub const PREFIX: char = '$'; + #[derive(Clone, Debug, PartialEq)] /// Represents a variable, a value with a name pub struct Variable { @@ -29,7 +31,7 @@ impl Variable { /// Returns whether or not the given representation could reference a valid variable pub fn is(repr: &str) -> bool { - repr.starts_with('$') + repr.starts_with(PREFIX) } }