diff --git a/book/src/cli-usage.md b/book/src/cli-usage.md index 81aff5b1..97925694 100644 --- a/book/src/cli-usage.md +++ b/book/src/cli-usage.md @@ -35,11 +35,13 @@ There is a set of special commands that only work in interactive mode: | Command | Action | |---------|--------| -| `list`, `ls` | List all functions, dimensions, variables and units | +| `list` | List all functions, dimensions, variables and units | | `list ` | Where `` can be `functions`, `dimensions`, `variables`, `units` | -| `info ` | Get more information about units, variables and functions | +| `info ` | Get more information about units, variables, and functions | | `clear` | Clear screen | | `help`, `?` | View short help text | +| `save` | Save the current session history to file `history.nbt` in the current directory | +| `save ` | Save the current session history to file `` relative to the current working directory | | `quit`, `exit` | Quit the session | ### Key bindings diff --git a/book/src/web-usage.md b/book/src/web-usage.md index 48eb7ec9..bdeb390c 100644 --- a/book/src/web-usage.md +++ b/book/src/web-usage.md @@ -24,12 +24,12 @@ There is a set of special commands that only work in the web version: | Command | Action | |---------|--------| -| `list`, `ls` | List all constants, units, and dimensions | +| `list` | List all functions, dimensions, variables and units | | `list ` | Where `` can be `functions`, `dimensions`, `variables`, `units` | -| `info ` | Get more information about units and variables | +| `info ` | Get more information about units, variables, and functions | +| `clear` | Clear screen | | `help`, `?` | View short help text | | `reset` | Reset state (clear constants, functions, units, …) | -| `clear` | Clear screen | ## Key bindings diff --git a/numbat-cli/src/main.rs b/numbat-cli/src/main.rs index 63c9f907..bf887b71 100644 --- a/numbat-cli/src/main.rs +++ b/numbat-cli/src/main.rs @@ -10,12 +10,14 @@ use config::{ColorMode, Config, ExchangeRateFetchingPolicy, IntroBanner, PrettyP use highlighter::NumbatHighlighter; use itertools::Itertools; +use numbat::command::{self, CommandParser, SourcelessCommandParser}; use numbat::diagnostic::ErrorDiagnostic; use numbat::help::help_markup; use numbat::markup as m; use numbat::module_importer::{BuiltinModuleImporter, ChainedImporter, FileSystemImporter}; use numbat::pretty_print::PrettyPrint; use numbat::resolver::CodeSource; +use numbat::session_history::{ParseEvaluationResult, SessionHistory, SessionHistoryOptions}; use numbat::{Context, NumbatError}; use numbat::{InterpreterSettings, NameResolutionError}; @@ -93,6 +95,11 @@ struct Args { debug: bool, } +struct ParseEvaluationOutcome { + control_flow: ControlFlow, + result: ParseEvaluationResult, +} + #[derive(Debug, Clone, Copy, PartialEq)] enum ExecutionMode { Normal, @@ -188,7 +195,7 @@ impl Cli { ExecutionMode::Normal, PrettyPrintMode::Never, ); - if result.is_break() { + if result.control_flow.is_break() { bail!("Interpreter error in Prelude code") } } @@ -203,7 +210,7 @@ impl Cli { ExecutionMode::Normal, PrettyPrintMode::Never, ); - if result.is_break() { + if result.control_flow.is_break() { bail!("Interpreter error in user initialization code") } } @@ -245,7 +252,7 @@ impl Cli { self.config.pretty_print, ); - let result_status = match result { + let result_status = match result.control_flow { std::ops::ControlFlow::Continue(()) => Ok(()), std::ops::ControlFlow::Break(_) => { bail!("Interpreter stopped") @@ -340,6 +347,8 @@ impl Cli { rl: &mut Editor, interactive: bool, ) -> Result<()> { + let mut session_history = SessionHistory::default(); + loop { let readline = rl.readline(&self.config.prompt); match readline { @@ -347,95 +356,113 @@ impl Cli { if !line.trim().is_empty() { rl.add_history_entry(&line)?; - match line.trim() { - "list" | "ls" => { - println!( - "{}", - ansi_format( - &self.context.lock().unwrap().print_environment(), - false - ) - ); - } - "list functions" | "ls functions" => { - println!( - "{}", - ansi_format( - &self.context.lock().unwrap().print_functions(), - false - ) - ); - } - "list dimensions" | "ls dimensions" => { - println!( - "{}", - ansi_format( - &self.context.lock().unwrap().print_dimensions(), - false - ) - ); - } - "list variables" | "ls variables" => { - println!( - "{}", - ansi_format( - &self.context.lock().unwrap().print_variables(), - false - ) - ); - } - "list units" | "ls units" => { - println!( - "{}", - ansi_format(&self.context.lock().unwrap().print_units(), false) - ); - } - "clear" => { - rl.clear_screen()?; - } - "quit" | "exit" => { - return Ok(()); - } - "help" | "?" => { - let help = help_markup(); - print!("{}", ansi_format(&help, true)); - // currently, the ansi formatter adds indents - // _after_ each newline and so we need to manually - // add an extra blank line to absorb this indent - println!(); - } - _ => { - if let Some(keyword) = line.strip_prefix("info ") { - let help = self - .context - .lock() - .unwrap() - .print_info_for_keyword(keyword.trim()); - println!("{}", ansi_format(&help, true)); - continue; - } - let result = self.parse_and_evaluate( - &line, - CodeSource::Text, - if interactive { - ExecutionMode::Interactive - } else { - ExecutionMode::Normal - }, - self.config.pretty_print, - ); - - match result { - std::ops::ControlFlow::Continue(()) => {} - std::ops::ControlFlow::Break(ExitStatus::Success) => { - return Ok(()); + // if we enter here, the line looks like a command + if let Some(sourceless_parser) = SourcelessCommandParser::new(&line) { + let mut parser = CommandParser::new( + sourceless_parser, + self.context + .lock() + .unwrap() + .resolver_mut() + .add_code_source(CodeSource::Text, &line), + ); + + match parser.parse_command() { + Ok(command) => match command { + command::Command::Help => { + let help = help_markup(); + print!("{}", ansi_format(&help, true)); + // currently, the ansi formatter adds indents + // _after_ each newline and so we need to manually + // add an extra blank line to absorb this indent + println!(); } - std::ops::ControlFlow::Break(ExitStatus::Error) => { - bail!("Interpreter stopped due to error") + command::Command::Info { item } => { + let help = self + .context + .lock() + .unwrap() + .print_info_for_keyword(item); + println!("{}", ansi_format(&help, true)); } + command::Command::List { items } => { + let context = self.context.lock().unwrap(); + let m = match items { + None => context.print_environment(), + Some(command::ListItems::Functions) => { + context.print_functions() + } + Some(command::ListItems::Dimensions) => { + context.print_dimensions() + } + Some(command::ListItems::Variables) => { + context.print_variables() + } + Some(command::ListItems::Units) => { + context.print_units() + } + }; + println!("{}", ansi_format(&m, false)); + } + command::Command::Clear => rl.clear_screen()?, + command::Command::Save { dst } => { + let save_result = session_history.save( + dst, + SessionHistoryOptions { + include_err_lines: false, + trim_lines: true, + }, + ); + match save_result { + Ok(_) => { + let m = m::text( + "successfully saved session history to", + ) + m::space() + + m::string(dst); + println!("{}", ansi_format(&m, interactive)); + } + Err(err) => { + self.print_diagnostic(*err); + continue; + } + } + } + command::Command::Quit => return Ok(()), + }, + Err(e) => { + self.print_diagnostic(e); + continue; } } + + continue; + } + + let ParseEvaluationOutcome { + control_flow, + result, + } = self.parse_and_evaluate( + &line, + CodeSource::Text, + if interactive { + ExecutionMode::Interactive + } else { + ExecutionMode::Normal + }, + self.config.pretty_print, + ); + + match control_flow { + std::ops::ControlFlow::Continue(()) => {} + std::ops::ControlFlow::Break(ExitStatus::Success) => { + return Ok(()); + } + std::ops::ControlFlow::Break(ExitStatus::Error) => { + bail!("Interpreter stopped due to error") + } } + + session_history.push(line, result); } } Err(ReadlineError::Interrupted) => {} @@ -456,7 +483,7 @@ impl Cli { code_source: CodeSource, execution_mode: ExecutionMode, pretty_print_mode: PrettyPrintMode, - ) -> ControlFlow { + ) -> ParseEvaluationOutcome { let to_be_printed: Arc>> = Arc::new(Mutex::new(vec![])); let to_be_printed_c = to_be_printed.clone(); let mut settings = InterpreterSettings { @@ -465,7 +492,7 @@ impl Cli { }), }; - let result = + let interpretation_result = self.context .lock() .unwrap() @@ -479,7 +506,12 @@ impl Cli { PrettyPrintMode::Auto => interactive, }; - match result { + let parse_eval_result = match &interpretation_result { + Ok(_) => Ok(()), + Err(_) => Err(()), + }; + + let control_flow = match interpretation_result { Ok((statements, interpreter_result)) => { if interactive || pretty_print { println!(); @@ -536,6 +568,11 @@ impl Cli { self.print_diagnostic(e); execution_mode.exit_status_in_case_of_error() } + }; + + ParseEvaluationOutcome { + control_flow, + result: parse_eval_result, } } diff --git a/numbat-wasm/www/index.js b/numbat-wasm/www/index.js index 263d8548..53e8184c 100644 --- a/numbat-wasm/www/index.js +++ b/numbat-wasm/www/index.js @@ -47,15 +47,15 @@ function interpret(input) { combined_input = ""; updateUrlQuery(null); this.clear(); - } else if (input_trimmed == "list" || input_trimmed == "ls") { + } else if (input_trimmed == "list") { output = numbat.print_environment(); - } else if (input_trimmed == "list functions" || input_trimmed == "ls functions") { + } else if (input_trimmed == "list functions") { output = numbat.print_functions(); - } else if (input_trimmed == "list dimensions" || input_trimmed == "ls dimensions") { + } else if (input_trimmed == "list dimensions") { output = numbat.print_dimensions(); - } else if (input_trimmed == "list variables" || input_trimmed == "ls variables") { + } else if (input_trimmed == "list variables") { output = numbat.print_variables(); - } else if (input_trimmed == "list units" || input_trimmed == "ls units") { + } else if (input_trimmed == "list units") { output = numbat.print_units(); } else if (input_trimmed == "help" || input_trimmed == "?") { output = numbat.help(); diff --git a/numbat/src/command.rs b/numbat/src/command.rs new file mode 100644 index 00000000..4b492234 --- /dev/null +++ b/numbat/src/command.rs @@ -0,0 +1,496 @@ +use std::str::{FromStr, SplitWhitespace}; + +use crate::{ + parser::ParseErrorKind, + span::{SourceCodePositition, Span}, + ParseError, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ListItems { + Functions, + Dimensions, + Variables, + Units, +} + +enum QuitAlias { + Quit, + Exit, +} + +enum CommandKind { + Help, + Info, + List, + Clear, + Save, + Quit(QuitAlias), +} + +impl FromStr for CommandKind { + type Err = (); + + fn from_str(word: &str) -> Result { + use CommandKind::*; + Ok(match word { + "help" | "?" => Help, + "info" => Info, + "list" => List, + "clear" => Clear, + "save" => Save, + "quit" => Quit(QuitAlias::Quit), + "exit" => Quit(QuitAlias::Exit), + _ => return Err(()), + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Command<'a> { + Help, + Info { item: &'a str }, + List { items: Option }, + Clear, + Save { dst: &'a str }, + Quit, +} + +/// Contains just the words and word boundaries of the input we're parsing, no +/// `code_source_id` +/// +/// This type has a single method, a fallible initializer. If it succeeds, then we know +/// we're actually looking at a command (not necessarily a well-formed one, eg `list +/// foobar` is a command but will fail later on). Then we go and construct a new +/// `code_source_id`, and then use a full `CommandParser`, constructed from this and the +/// `code_source_id`, to do the actual command parsing. If the initializer fails, then +/// we proceed with parsing the input as a numbat expression (which will create its own +/// `code_source_id`). +pub struct SourcelessCommandParser<'a> { + /// The command we're running ("list", "quit", etc) without any of its args + command_kind: CommandKind, + /// The words in the input. This does not include the command name, which has been + /// stripped off and placed in `command_kind` + args: SplitWhitespace<'a>, + /// For tracking spans. Contains `(start, start+len)` for each (whitespace-separated) + /// word in the input + word_boundaries: Vec<(u32, u32)>, +} + +impl<'a> SourcelessCommandParser<'a> { + ///Fallibly construct a new `Self` from the input + /// + /// Returns: + /// - `None`, if the first word of the input is not a command + /// - `Some(Self)`, if the first word of the input is a command + /// + /// If this returns `None`, you should proceed with parsing the input as an ordinary + /// numbat expression + pub fn new(input: &'a str) -> Option { + let mut words: SplitWhitespace<'_> = input.split_whitespace(); + let command_kind = words.next().and_then(|w| w.parse().ok())?; + + let mut word_boundaries = Vec::new(); + let mut prev_char_was_whitespace = true; + let mut start_idx = 0; + + for (i, c) in input + .char_indices() + // force trailing whitespace to get last word + .chain(std::iter::once((input.len(), ' '))) + { + if prev_char_was_whitespace && !c.is_whitespace() { + start_idx = u32::try_from(i).unwrap(); + } else if !prev_char_was_whitespace && c.is_whitespace() { + word_boundaries.push((start_idx, u32::try_from(i).unwrap())); + } + prev_char_was_whitespace = c.is_whitespace(); + } + + Some(Self { + command_kind, + args: words, + word_boundaries, + }) + } +} + +/// A "full" command parser, containing both the state we need to parse (`inner: +/// SourcelessCommandParser`) and a `code_source_id` to report errors correctly +/// +/// All actual parsing happens through this struct. Since we managed to obtain a +/// `SourcelessCommandParser`, we know that the input was a command and not a numbat +/// expression, so we can proceed with parsing the command further. +pub struct CommandParser<'a> { + inner: SourcelessCommandParser<'a>, + code_source_id: usize, +} + +impl<'a> CommandParser<'a> { + /// Construct a new `CommandParser` from an existing `SourcelessCommandParser`, + /// which contains the input, and a `code_source_id`, for reporting errors + pub fn new(inner: SourcelessCommandParser<'a>, code_source_id: usize) -> Self { + Self { + inner, + code_source_id, + } + } + + /// Get the span starting at the start of the word at `word_index`, through the end of + /// the last word represented by `word_boundaries` + /// + /// ## Panics + /// If `word_index` is out of bounds, ie `word_index >= word_boundaries.len()` + fn span_through_end(&self, word_index: usize) -> Span { + let start = self.inner.word_boundaries[word_index].0; + let end = self.inner.word_boundaries.last().unwrap().1; + self.span_from_boundary((start, end)) + } + + /// Get the span between indices given by `start` and `end` + /// + /// The only role of `&self` here is to provide the `code_source_id` + fn span_from_boundary(&self, (start, end): (u32, u32)) -> Span { + Span { + start: SourceCodePositition { + byte: start, + line: 1, + position: start, + }, + end: SourceCodePositition { + byte: end, + line: 1, + position: end, + }, + code_source_id: self.code_source_id, + } + } + + fn err_at_idx(&self, index: usize, err_msg: impl Into) -> ParseError { + ParseError { + kind: ParseErrorKind::InvalidCommand(err_msg.into()), + span: self.span_from_boundary(self.inner.word_boundaries[index]), + } + } + + fn err_through_end_from(&self, index: usize, err_msg: impl Into) -> ParseError { + ParseError { + kind: ParseErrorKind::InvalidCommand(err_msg.into()), + span: self.span_through_end(index), + } + } + + fn ensure_zero_args( + &mut self, + cmd: &'static str, + err_msg_suffix: &'static str, + ) -> Result<(), ParseError> { + if self.inner.args.next().is_some() { + let message = format!("`{}` takes 0 arguments{}", cmd, err_msg_suffix); + return Err(self.err_through_end_from(1, message)); + } + Ok(()) + } + + /// Attempt to parse the input provided to Self::new as a command, such as "help", + /// "list ", "quit", etc + /// + /// Returns: + /// - `Ok(Command)`, if the input is a valid command with correct arguments + /// - `Err(ParseError)`, if the input starts with a valid command but has the wrong + /// number or kind of arguments, e.g. `list foobar` + pub fn parse_command(&mut self) -> Result { + let command = match &self.inner.command_kind { + CommandKind::Help => { + self.ensure_zero_args("help", "; use `info ` for information about an item")?; + Command::Help + } + CommandKind::Clear => { + self.ensure_zero_args("clear", "")?; + Command::Clear + } + CommandKind::Quit(alias) => { + self.ensure_zero_args( + match alias { + QuitAlias::Quit => "quit", + QuitAlias::Exit => "exit", + }, + "", + )?; + + Command::Quit + } + CommandKind::Info => { + let err_msg = "`info` requires exactly one argument, the item to get info on"; + let Some(item) = self.inner.args.next() else { + return Err(self.err_at_idx(0, err_msg)); + }; + + if self.inner.args.next().is_some() { + return Err(self.err_through_end_from(1, err_msg)); + } + + Command::Info { item } + } + CommandKind::List => { + let items = self.inner.args.next(); + + if self.inner.args.next().is_some() { + return Err(self.err_through_end_from(2, "`list` takes at most one argument")); + } + + let items = match items { + None => None, + Some("functions") => Some(ListItems::Functions), + Some("dimensions") => Some(ListItems::Dimensions), + Some("variables") => Some(ListItems::Variables), + Some("units") => Some(ListItems::Units), + _ => { + return Err(self.err_at_idx( + 1, + "if provided, the argument to `list` must be \ + one of: functions, dimensions, variables, units", + )); + } + }; + + Command::List { items } + } + CommandKind::Save => { + let Some(dst) = self.inner.args.next() else { + return Ok(Command::Save { dst: "history.nbt" }); + }; + + if self.inner.args.next().is_some() { + return Err(self.err_through_end_from( + 2, + "`save` requires exactly one argument, the destination", + )); + } + + Command::Save { dst } + } + }; + + Ok(command) + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn parser(input: &'static str) -> Option> { + Some(CommandParser::new(SourcelessCommandParser::new(input)?, 0)) + } + + // can't be a function due to lifetimes/borrow checker + macro_rules! parse { + ($input:literal) => {{ + parser($input).unwrap().parse_command() + }}; + } + + #[test] + fn test_command_parser() { + assert!(parser("").is_none()); + assert!(parser(" ").is_none()); + assert!(parser(" ").is_none()); + + assert!(parser("x").is_none()); + assert!(parser("x ").is_none()); + assert!(parser(" x").is_none()); + assert!(parser(" x ").is_none()); + + assert!(parser("xyz").is_none()); + assert!(parser("xyz ").is_none()); + assert!(parser(" xyz").is_none()); + assert!(parser(" xyz ").is_none()); + + assert!(parser("abc x").is_none(),); + assert!(parser("abc x ").is_none(),); + assert!(parser(" abc x").is_none()); + assert!(parser(" abc x ").is_none()); + + assert_eq!(&parser("list").unwrap().inner.word_boundaries, &[(0, 4)]); + assert_eq!(&parser("list ").unwrap().inner.word_boundaries, &[(0, 4)]); + assert_eq!(&parser(" list").unwrap().inner.word_boundaries, &[(1, 5)]); + assert_eq!(&parser(" list ").unwrap().inner.word_boundaries, &[(1, 5)]); + + assert_eq!( + &parser("list ab").unwrap().inner.word_boundaries, + &[(0, 4), (7, 9)] + ); + assert_eq!( + &parser("list ab ").unwrap().inner.word_boundaries, + &[(0, 4), (7, 9)] + ); + assert_eq!( + &parser(" list ab").unwrap().inner.word_boundaries, + &[(1, 5), (8, 10)] + ); + assert_eq!( + &parser(" list ab ").unwrap().inner.word_boundaries, + &[(1, 5), (8, 10)] + ); + + assert_eq!( + &parser("list ab xy").unwrap().inner.word_boundaries, + &[(0, 4), (7, 9), (10, 12)] + ); + assert_eq!( + &parser("list ab xy ").unwrap().inner.word_boundaries, + &[(0, 4), (7, 9), (12, 14)] + ); + assert_eq!( + parser(" list ab xy").unwrap().inner.word_boundaries, + &[(3, 7), (10, 12), (16, 18)] + ); + assert_eq!( + parser(" list ab xy ") + .unwrap() + .inner + .word_boundaries, + &[(3, 7), (10, 12), (16, 18)] + ); + } + + #[test] + fn test_existent_commands() { + // these shouldn't happen at runtime because the REPL skips over all + // whitespace lines, but we still want to handle them just in case + assert!(parser("").is_none()); + assert!(parser(" ").is_none()); + + // valid commands + assert!(parser("help").is_some()); + assert!(parser("help arg").is_some()); + assert!(parser("help arg1 arg2").is_some()); + + assert!(parser("info").is_some()); + assert!(parser("info arg").is_some()); + assert!(parser("info arg1 arg2").is_some()); + + assert!(parser("clear").is_some()); + assert!(parser("clear arg").is_some()); + assert!(parser("clear arg1 arg2").is_some()); + + assert!(parser("list").is_some()); + assert!(parser("list arg").is_some()); + assert!(parser("list arg1 arg2").is_some()); + + assert!(parser("quit").is_some()); + assert!(parser("quit arg").is_some()); + assert!(parser("quit arg1 arg2").is_some()); + + assert!(parser("exit").is_some()); + assert!(parser("exit arg").is_some()); + assert!(parser("exit arg1 arg2").is_some()); + + assert!(parser("save").is_some()); + assert!(parser("save arg").is_some()); + assert!(parser("save arg1 arg2").is_some()); + + // invalid (nonempty) command names are all None so that parsing can continue on + // what is presumably a math expression. case matters + assert!(parser(".").is_none()); + assert!(parser(",").is_none()); + assert!(parser(";").is_none()); + assert!(parser("ls").is_none()); + assert!(parser("HELP").is_none()); + assert!(parser("List xyz").is_none()); + assert!(parser("qUIt abc").is_none()); + assert!(parser("listfunctions").is_none()); + assert!(parser("exitquit").is_none()); + } + + #[test] + fn test_whitespace() { + assert_eq!(parse!("list").unwrap(), Command::List { items: None }); + assert_eq!(parse!(" list").unwrap(), Command::List { items: None }); + assert_eq!(parse!("list ").unwrap(), Command::List { items: None }); + assert_eq!(parse!(" list ").unwrap(), Command::List { items: None }); + assert_eq!( + parse!("list functions ").unwrap(), + Command::List { + items: Some(ListItems::Functions) + } + ); + assert_eq!( + parse!(" list functions ").unwrap(), + Command::List { + items: Some(ListItems::Functions) + } + ); + assert_eq!( + parse!(" list functions ").unwrap(), + Command::List { + items: Some(ListItems::Functions) + } + ); + assert_eq!( + parse!("list functions").unwrap(), + Command::List { + items: Some(ListItems::Functions) + } + ); + } + + #[test] + fn test_args() { + assert_eq!(parse!("help").unwrap(), Command::Help); + assert!(parse!("help arg").is_err()); + assert!(parse!("help arg1 arg2").is_err()); + + assert!(parse!("info").is_err()); + assert_eq!(parse!("info arg").unwrap(), Command::Info { item: "arg" }); + assert_eq!(parse!("info .").unwrap(), Command::Info { item: "." }); + assert!(parse!("info arg1 arg2").is_err()); + + assert_eq!(parse!("clear").unwrap(), Command::Clear); + assert!(parse!("clear arg").is_err()); + assert!(parse!("clear arg1 arg2").is_err()); + + assert_eq!(parse!("list").unwrap(), Command::List { items: None }); + assert_eq!( + parse!("list functions").unwrap(), + Command::List { + items: Some(ListItems::Functions) + } + ); + assert_eq!( + parse!("list dimensions").unwrap(), + Command::List { + items: Some(ListItems::Dimensions) + } + ); + assert_eq!( + parse!("list variables").unwrap(), + Command::List { + items: Some(ListItems::Variables) + } + ); + assert_eq!( + parse!("list units").unwrap(), + Command::List { + items: Some(ListItems::Units) + } + ); + + assert_eq!(parse!("quit").unwrap(), Command::Quit); + assert!(parse!("quit arg").is_err()); + assert!(parse!("quit arg1 arg2").is_err()); + + assert_eq!(parse!("exit").unwrap(), Command::Quit); + assert!(parse!("exit arg").is_err()); + assert!(parse!("exit arg1 arg2").is_err()); + + assert_eq!( + parse!("save").unwrap(), + Command::Save { dst: "history.nbt" } + ); + assert_eq!(parse!("save arg").unwrap(), Command::Save { dst: "arg" }); + assert_eq!(parse!("save .").unwrap(), Command::Save { dst: "." }); + assert!(parse!("save arg1 arg2").is_err()); + } +} diff --git a/numbat/src/interpreter/mod.rs b/numbat/src/interpreter/mod.rs index d131e1e6..83186963 100644 --- a/numbat/src/interpreter/mod.rs +++ b/numbat/src/interpreter/mod.rs @@ -60,6 +60,9 @@ pub enum RuntimeError { #[error("Empty list")] EmptyList, + + #[error("Could not write to file: {0:?}")] + FileWrite(std::path::PathBuf), } #[derive(Debug, PartialEq, Eq)] diff --git a/numbat/src/lib.rs b/numbat/src/lib.rs index c164b467..555940e1 100644 --- a/numbat/src/lib.rs +++ b/numbat/src/lib.rs @@ -4,6 +4,7 @@ mod ast; pub mod buffered_writer; mod bytecode_interpreter; mod column_formatter; +pub mod command; mod currency; mod datetime; mod decorator; @@ -32,6 +33,7 @@ mod product; mod quantity; mod registry; pub mod resolver; +pub mod session_history; mod span; mod suggestion; mod tokenizer; @@ -533,6 +535,10 @@ impl Context { &self.resolver } + pub fn resolver_mut(&mut self) -> &mut Resolver { + &mut self.resolver + } + pub fn interpret( &mut self, code: &str, diff --git a/numbat/src/parser.rs b/numbat/src/parser.rs index 255b75dd..61b08a6f 100644 --- a/numbat/src/parser.rs +++ b/numbat/src/parser.rs @@ -236,6 +236,9 @@ pub enum ParseErrorKind { #[error("Expected local variable definition after where/and")] ExpectedLocalVariableDefinition, + + #[error("Invalid command: {0}")] + InvalidCommand(String), } #[derive(Debug, Clone, Error)] @@ -3391,7 +3394,7 @@ mod tests { fn accumulate_errors() { // error on the last character of a line assert_snapshot!(snap_parse( - "1 + + "1 +\x20 2 + 3"), @r###" Successfully parsed: Expression(BinaryOperator { op: Add, lhs: Scalar(Span { start: SourceCodePositition { byte: 17, line: 2, position: 13 }, end: SourceCodePositition { byte: 18, line: 2, position: 14 }, code_source_id: 0 }, Number(2.0)), rhs: Scalar(Span { start: SourceCodePositition { byte: 21, line: 2, position: 17 }, end: SourceCodePositition { byte: 22, line: 2, position: 18 }, code_source_id: 0 }, Number(3.0)), span_op: Some(Span { start: SourceCodePositition { byte: 19, line: 2, position: 15 }, end: SourceCodePositition { byte: 20, line: 2, position: 16 }, code_source_id: 0 }) }) @@ -3402,7 +3405,7 @@ mod tests { assert_snapshot!(snap_parse( " let cool = 50 - let tamo = * 30 + let tamo = * 30\x20 assert_eq(tamo + cool == 80) 30m"), @r###" Successfully parsed: diff --git a/numbat/src/resolver.rs b/numbat/src/resolver.rs index caa65017..5f653fd6 100644 --- a/numbat/src/resolver.rs +++ b/numbat/src/resolver.rs @@ -65,7 +65,7 @@ impl Resolver { } } - fn add_code_source(&mut self, code_source: CodeSource, content: &str) -> usize { + pub fn add_code_source(&mut self, code_source: CodeSource, content: &str) -> usize { let code_source_name = match &code_source { CodeSource::Text => { self.text_code_source_count += 1; diff --git a/numbat/src/session_history.rs b/numbat/src/session_history.rs new file mode 100644 index 00000000..bd3abed8 --- /dev/null +++ b/numbat/src/session_history.rs @@ -0,0 +1,139 @@ +use std::{fs, io, path::Path}; + +use crate::RuntimeError; + +pub type ParseEvaluationResult = Result<(), ()>; + +#[derive(Debug)] +struct SessionHistoryItem { + input: String, + result: ParseEvaluationResult, +} + +#[derive(Default)] +pub struct SessionHistory(Vec); + +impl SessionHistory { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SessionHistoryOptions { + pub include_err_lines: bool, + pub trim_lines: bool, +} + +impl SessionHistory { + pub fn push(&mut self, input: String, result: ParseEvaluationResult) { + self.0.push(SessionHistoryItem { input, result }); + } + + fn save_inner( + &self, + mut w: impl io::Write, + options: SessionHistoryOptions, + err_fn: impl Fn(io::Error) -> RuntimeError, + ) -> Result<(), Box> { + let SessionHistoryOptions { + include_err_lines, + trim_lines, + } = options; + + for item in &self.0 { + if item.result.is_err() && !include_err_lines { + continue; + } + + let input = if trim_lines { + item.input.trim() + } else { + &item.input + }; + + writeln!(w, "{input}").map_err(&err_fn)? + } + Ok(()) + } + + pub fn save( + &self, + dst: impl AsRef, + options: SessionHistoryOptions, + ) -> Result<(), Box> { + let dst = dst.as_ref(); + let err_fn = |_: io::Error| RuntimeError::FileWrite(dst.to_owned()); + + let f = fs::File::create(dst).map_err(err_fn)?; + self.save_inner(f, options, err_fn) + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::io::Cursor; + + #[test] + fn test() { + let mut sh = SessionHistory::new(); + + // arbitrary non-ascii characters + sh.push(" a→ ".to_owned(), Ok(())); + sh.push(" b × c ".to_owned(), Err(())); + sh.push(" d ♔ e ⚀ f ".to_owned(), Err(())); + sh.push(" g ☼ h ▶︎ i ❖ j ".to_owned(), Ok(())); + + let test_cases = [ + ( + SessionHistoryOptions { + include_err_lines: false, + trim_lines: false, + }, + " a→ \n g ☼ h ▶︎ i ❖ j \n", + ), + ( + SessionHistoryOptions { + include_err_lines: true, + trim_lines: false, + }, + " a→ \n b × c \n d ♔ e ⚀ f \n g ☼ h ▶︎ i ❖ j \n", + ), + ( + SessionHistoryOptions { + include_err_lines: false, + trim_lines: true, + }, + "a→\ng ☼ h ▶︎ i ❖ j\n", + ), + ( + SessionHistoryOptions { + include_err_lines: true, + trim_lines: true, + }, + "a→\nb × c\nd ♔ e ⚀ f\ng ☼ h ▶︎ i ❖ j\n", + ), + ]; + + for (options, expected) in test_cases { + let mut s = Cursor::new(Vec::::new()); + sh.save_inner(&mut s, options, |_| unreachable!()).unwrap(); + assert_eq!(expected, String::from_utf8(s.into_inner()).unwrap()) + } + } + + #[test] + fn test_error() { + let sh = SessionHistory::new(); + assert!(sh + .save( + ".", // one place we know writing will fail + SessionHistoryOptions { + include_err_lines: false, + trim_lines: false + } + ) + .is_err()) + } +}