//! A command line, understood: [`parse`] turns its text into a typed //! [`Call`], checking every argument, or says clearly what's wrong. use std::path::PathBuf; use thiserror::Error; use super::complete::{edit_distance, rank}; use super::registry::{self, Choice, CommandId as Id, Spec}; use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title}; pub use crate::model::{Layout, Side}; use crate::view::ViewKind; /// What `:export` writes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportFormat { Markdown, Text, } /// What the board's columns are. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoardColumns { Tags, Colors, } /// One change `:tag` makes. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TagChange { Add(Tag), Remove(Tag), } /// What `:set` does with a setting, the way vim's `:set` reads. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SetArg { /// `name?`: show its value. Show(String), /// `name&`: back to the default. Reset(String), /// `name`: turn an on/off setting on. On(String), /// `noname`: turn it off. Off(String), /// `name!`: flip it. Toggle(String), /// `name=value`. Value(String, String), } /// What a command line asks for, with its arguments checked and typed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Call { /// Open a document; no path reads the focused one again from its file. Edit { path: Option, force: bool, }, New(Option), Write, WriteAll, Quit { force: bool, }, QuitAll { force: bool, }, WriteQuit, Buffers, /// Switch to the open document with this name. Buffer(String), CloseDocument { force: bool, }, Rename(Title), Split(Option<ViewKind>), VSplit(Option<ViewKind>), Only, ClosePane, Show(ViewKind), Board(Option<BoardColumns>), Capture(String), Link(NodeId), Unlink(Option<NodeId>), Portal(String), /// A branch into a document of its own, at this path (or next to this /// one, named after it). BranchOut(Option<String>), Tags(Vec<TagChange>), /// A palette color, or `None` to clear it. Color(Option<ColorName>), Style(StyleName), Border(Border), Fold(Fold), Focus, Tidy(Layout), Align(Side), Find(String), /// `:search`: every document. Search(String), Backlinks, Jumps, Marks, Registers, Set(SetArg), Config, Help(Option<String>), Version, Theme(String), Export { format: ExportFormat, path: Option<String>, /// `:export!`: write over a file that's there. force: bool, }, Import(String), } /// What the command line can name, from the app: names are checked against /// these when parsing, and offered when completing. #[derive(Debug, Clone, Default)] pub struct Sources { /// The open documents' names. pub documents: Vec<String>, /// This document's nodes, with their titles. pub nodes: Vec<(NodeId, String)>, /// The palette's colors. pub colors: Vec<String>, /// The theme's named styles. pub styles: Vec<String>, pub themes: Vec<String>, /// The settings `:set` can change. pub settings: Vec<String>, /// The tags used in this document. pub tags: Vec<String>, /// Where relative file paths start: the document's folder. pub folder: PathBuf, } /// Why a command line can't run. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum CallError { #[error("type a command, like :help")] Empty, #[error("there's no command :{name}{}", did_you_mean(.suggestion))] Unknown { name: String, suggestion: Option<&'static str> }, #[error(":{0} doesn't take a !")] NoBang(&'static str), #[error("{usage}: the {what} is missing")] Missing { usage: String, what: &'static str }, #[error("{usage}: {extra:?} is more than it takes")] TooMuch { usage: String, extra: String }, #[error("{given:?} isn't a {what}: use {choices}")] NotAChoice { what: &'static str, given: String, choices: String }, #[error("there's no {what} like {given:?}")] NoMatch { what: &'static str, given: String }, #[error("{0}")] Invalid(String), } fn did_you_mean(suggestion: &Option<&str>) -> String { suggestion.map(|name| format!("; did you mean :{name}?")).unwrap_or_default() } /// Reads a command line (without its `:`). pub fn parse(line: &str, sources: &Sources) -> Result<Call, CallError> { let line = line.trim().trim_start_matches(':').trim_start(); let name_end = line.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(line.len()); let (name, after) = line.split_at(name_end); if name.is_empty() { return Err(CallError::Empty); } let (bang, rest) = match after.strip_prefix('!') { Some(rest) => (true, rest), None => (false, after), }; let spec = registry::find(name) .ok_or_else(|| CallError::Unknown { name: name.to_string(), suggestion: suggestion(name) })?; if bang && !spec.bang { return Err(CallError::NoBang(spec.name)); } let args = read_args(spec, rest.trim())?; build(spec, bang, &args, sources) } /// A command name close to a mistyped one: a letter or two off. fn suggestion(name: &str) -> Option<&'static str> { registry::SPECS .iter() .map(|spec| (edit_distance(name, spec.name), spec.name)) .filter(|(distance, _)| *distance <= 2) .min() .map(|(_, name)| name) } /// Splits what follows the command's name into its arguments: one word /// each, or the rest of the line for the kinds that take it. An optional /// argument left out is `None`. fn read_args<'a>(spec: &Spec, mut rest: &'a str) -> Result<Vec<Option<&'a str>>, CallError> { let mut values = Vec::new(); for arg in spec.args { let value = if rest.is_empty() { None } else if arg.kind.takes_the_rest() { Some(std::mem::take(&mut rest)) } else { let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let (word, after) = rest.split_at(end); rest = after.trim_start(); Some(word) }; if value.is_none() && arg.required { return Err(CallError::Missing { usage: spec.usage(), what: arg.name }); } values.push(value); } if !rest.is_empty() { return Err(CallError::TooMuch { usage: spec.usage(), extra: rest.to_string() }); } Ok(values) } /// The call, from a command and its arguments as typed. (Required /// arguments are there: `read_args` checked.) fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> Result<Call, CallError> { let first = args.first().copied().flatten(); let second = args.get(1).copied().flatten(); let text = first.unwrap_or_default(); Ok(match spec.id { Id::Edit => Call::Edit { path: first.map(str::to_string), force: bang }, Id::New => Call::New(first.map(title).transpose()?), Id::Write => Call::Write, Id::WriteAll => Call::WriteAll, Id::Quit => Call::Quit { force: bang }, Id::QuitAll => Call::QuitAll { force: bang }, Id::WriteQuit => Call::WriteQuit, Id::Buffers => Call::Buffers, Id::Buffer => Call::Buffer(best("open document", text, &sources.documents)?.to_string()), Id::CloseDocument => Call::CloseDocument { force: bang }, Id::Rename => Call::Rename(title(text)?), Id::Split => Call::Split(first.map(view).transpose()?), Id::VSplit => Call::VSplit(first.map(view).transpose()?), Id::Only => Call::Only, Id::ClosePane => Call::ClosePane, Id::Canvas => Call::Show(ViewKind::Canvas), Id::Outline => Call::Show(ViewKind::Outline), Id::Note => Call::Show(ViewKind::Note), Id::Board => Call::Board(first.map(board_columns).transpose()?), Id::Capture => Call::Capture(text.to_string()), Id::Link => Call::Link(node(text, &sources.nodes)?), Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?), Id::Portal => Call::Portal(text.to_string()), Id::BranchOut => Call::BranchOut(first.map(str::to_string)), Id::Tags => Call::Tags(tag_changes(text)?), Id::Color => Call::Color(color(text, &sources.colors)?), Id::Style => Call::Style(style(text, &sources.styles)?), Id::Border => Call::Border(border(text)?), Id::Fold => Call::Fold(fold(text)?), Id::Focus => Call::Focus, Id::Tidy => Call::Tidy(first.map(layout).transpose()?.unwrap_or(Layout::Map)), Id::Align => Call::Align(side(text)?), Id::Find => Call::Find(text.to_string()), Id::Search => Call::Search(text.to_string()), Id::Backlinks => Call::Backlinks, Id::Jumps => Call::Jumps, Id::Marks => Call::Marks, Id::Registers => Call::Registers, Id::Set => Call::Set(set_arg(text, &sources.settings)?), Id::Config => Call::Config, Id::Help => Call::Help(first.map(str::to_string)), Id::Version => Call::Version, Id::Theme => Call::Theme(exactly("theme", text, &sources.themes)?.to_string()), Id::Export => { Call::Export { format: export_format(text)?, path: second.map(str::to_string), force: bang } } Id::Import => Call::Import(text.to_string()), }) } fn title(text: &str) -> Result<Title, CallError> { Title::parse(text).map_err(|error| CallError::Invalid(error.to_string())) } /// The best fuzzy match for `query` among `names`. fn best<'a>(what: &'static str, query: &str, names: &'a [String]) -> Result<&'a str, CallError> { rank(query, names.iter().map(String::as_str)) .first() .copied() .ok_or_else(|| CallError::NoMatch { what, given: query.to_string() }) } /// The node whose title matches `query`: an exact title first, then the best /// fuzzy match. fn node(query: &str, nodes: &[(NodeId, String)]) -> Result<NodeId, CallError> { let exact = nodes.iter().find(|(_, title)| title.eq_ignore_ascii_case(query)); let fuzzy = || { let best = rank(query, nodes.iter().map(|(_, title)| title.as_str())).first().copied()?; nodes.iter().find(|(_, title)| title == best) }; exact .or_else(fuzzy) .map(|(id, _)| *id) .ok_or_else(|| CallError::NoMatch { what: "node", given: query.to_string() }) } /// A name that has to be one of `names` exactly (ignoring case). fn exactly<'a>(what: &'static str, given: &str, names: &'a [String]) -> Result<&'a str, CallError> { names.iter().find(|name| name.eq_ignore_ascii_case(given)).map(String::as_str).ok_or_else(|| { if names.is_empty() { CallError::NoMatch { what, given: given.to_string() } } else { CallError::NotAChoice { what, given: given.to_string(), choices: names.join(", ") } } }) } fn color(given: &str, colors: &[String]) -> Result<Option<ColorName>, CallError> { if given.eq_ignore_ascii_case("none") { return Ok(None); } let name = exactly("color", given, colors)?; ColorName::parse(name).map(Some).map_err(|error| CallError::Invalid(error.to_string())) } fn style(given: &str, styles: &[String]) -> Result<StyleName, CallError> { let name = exactly("style", given, styles)?; StyleName::parse(name).map_err(|error| CallError::Invalid(error.to_string())) } fn tag_changes(text: &str) -> Result<Vec<TagChange>, CallError> { let invalid = |error: crate::model::TextError| CallError::Invalid(error.to_string()); text.split_whitespace() .map(|word| match word.strip_prefix('-') { Some(name) => Tag::parse(name).map(TagChange::Remove).map_err(invalid), None => Tag::parse(word.trim_start_matches('+')).map(TagChange::Add).map_err(invalid), }) .collect() } fn set_arg(text: &str, settings: &[String]) -> Result<SetArg, CallError> { let known = |name: &str| settings.iter().any(|setting| setting == name); let check = |name: &str| { if known(name) { Ok(name.to_string()) } else { Err(CallError::NoMatch { what: "setting", given: name.into() }) } }; if let Some((name, value)) = text.split_once('=') { return Ok(SetArg::Value(check(name.trim())?, value.trim().to_string())); } if let Some(name) = text.strip_suffix('?') { return Ok(SetArg::Show(check(name)?)); } if let Some(name) = text.strip_suffix('&') { return Ok(SetArg::Reset(check(name)?)); } if let Some(name) = text.strip_suffix('!') { return Ok(SetArg::Toggle(check(name)?)); } if let Some(name) = text.strip_prefix("no") && known(name) { return Ok(SetArg::Off(name.to_string())); } Ok(SetArg::On(check(text)?)) } /// The error for a word that isn't one of the choices. fn not_a_choice(what: &'static str, given: &str, choices: &[Choice]) -> CallError { let words: Vec<&str> = choices.iter().map(|choice| choice.word).collect(); CallError::NotAChoice { what, given: given.to_string(), choices: words.join(", ") } } fn view(word: &str) -> Result<ViewKind, CallError> { ViewKind::ALL .into_iter() .find(|kind| kind.name() == word.to_ascii_lowercase()) .ok_or_else(|| not_a_choice("view", word, registry::VIEWS)) } fn board_columns(word: &str) -> Result<BoardColumns, CallError> { match word.to_ascii_lowercase().as_str() { "tag" | "tags" => Ok(BoardColumns::Tags), "color" | "colors" => Ok(BoardColumns::Colors), _ => Err(not_a_choice("way to make columns", word, registry::BOARD_COLUMNS)), } } /// A border by name, as `:border` takes it. pub fn border(word: &str) -> Result<Border, CallError> { match word.to_ascii_lowercase().as_str() { "rounded" => Ok(Border::Rounded), "square" => Ok(Border::Square), "double" => Ok(Border::Double), "thick" => Ok(Border::Thick), "dashed" => Ok(Border::Dashed), "hidden" | "none" => Ok(Border::Hidden), _ => Err(not_a_choice("border", word, registry::BORDERS)), } } fn fold(word: &str) -> Result<Fold, CallError> { match word.to_ascii_lowercase().as_str() { "open" => Ok(Fold::Open), "auto" => Ok(Fold::Auto), "closed" | "close" => Ok(Fold::Closed), _ => Err(not_a_choice("way to fold", word, registry::FOLDS)), } } fn layout(word: &str) -> Result<Layout, CallError> { match word.to_ascii_lowercase().as_str() { "map" => Ok(Layout::Map), "chart" => Ok(Layout::Chart), "grid" => Ok(Layout::Grid), _ => Err(not_a_choice("layout", word, registry::LAYOUTS)), } } fn side(word: &str) -> Result<Side, CallError> { match word.to_ascii_lowercase().as_str() { "left" => Ok(Side::Left), "right" => Ok(Side::Right), "top" => Ok(Side::Top), "bottom" => Ok(Side::Bottom), "center" | "middle" => Ok(Side::Center), _ => Err(not_a_choice("side", word, registry::SIDES)), } } fn export_format(word: &str) -> Result<ExportFormat, CallError> { match word.to_ascii_lowercase().as_str() { "md" | "markdown" => Ok(ExportFormat::Markdown), "txt" | "text" => Ok(ExportFormat::Text), _ => Err(not_a_choice("format", word, registry::FORMATS)), } } #[cfg(test)] mod tests { use super::*; fn sources() -> Sources { let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect(); Sources { documents: names(&["Garden plans", "Kitchen", "Reading list"]), nodes: vec![ (NodeId::new(1), "Garden plans".into()), (NodeId::new(2), "Raised beds".into()), (NodeId::new(3), "Rain barrel".into()), ], colors: names(&["red", "orange", "teal"]), styles: names(&["question"]), themes: names(&["default", "light"]), settings: names(&["autosave", "mouse"]), tags: names(&["todo"]), folder: PathBuf::from("."), } } fn call(line: &str) -> Result<Call, CallError> { parse(line, &sources()) } #[test] fn names_bangs_and_arguments() { assert_eq!(call("q"), Ok(Call::Quit { force: false })); assert_eq!(call(":qa!"), Ok(Call::QuitAll { force: true })); assert_eq!(call("x"), Ok(Call::WriteQuit)); assert_eq!( call("e notes/garden.cum"), Ok(Call::Edit { path: Some("notes/garden.cum".into()), force: false }) ); assert_eq!(call("vs outline"), Ok(Call::VSplit(Some(ViewKind::Outline)))); assert_eq!(call("in buy a rain barrel"), Ok(Call::Capture("buy a rain barrel".into()))); assert_eq!(call("fold auto"), Ok(Call::Fold(Fold::Auto))); assert_eq!(call("tidy"), Ok(Call::Tidy(Layout::Map))); assert_eq!(call("color none"), Ok(Call::Color(None))); assert_eq!( call("export md out.md"), Ok(Call::Export { format: ExportFormat::Markdown, path: Some("out.md".into()), force: false }) ); } #[test] fn names_are_matched_fuzzily_where_it_helps() { assert_eq!(call("b gpl"), Ok(Call::Buffer("Garden plans".into()))); assert_eq!(call("link rbar"), Ok(Call::Link(NodeId::new(3)))); assert_eq!(call("link raised beds"), Ok(Call::Link(NodeId::new(2))), "an exact title wins"); let changes = call("tag +idea -todo later").unwrap(); let tag = |name| Tag::parse(name).unwrap(); assert_eq!( changes, Call::Tags(vec![ TagChange::Add(tag("idea")), TagChange::Remove(tag("todo")), TagChange::Add(tag("later")) ]) ); assert_eq!(call("set nomouse"), Ok(Call::Set(SetArg::Off("mouse".into())))); assert_eq!(call("set autosave?"), Ok(Call::Set(SetArg::Show("autosave".into())))); } #[test] fn mistakes_say_what_to_do() { let message = |line: &str| call(line).unwrap_err().to_string(); assert_eq!(message(""), "type a command, like :help"); assert_eq!(message("folx open"), "there's no command :folx; did you mean :fold?"); assert_eq!(message("w!"), ":write doesn't take a !"); assert_eq!(message("b"), ":b[uffer] <document>: the document is missing"); assert_eq!(message("fold up"), "\"up\" isn't a way to fold: use open, auto, closed"); assert_eq!(message("fold open now"), ":fo[ld] <open|auto|closed>: \"now\" is more than it takes"); assert_eq!(message("color corla"), "\"corla\" isn't a color: use red, orange, teal"); assert_eq!(message("b xyz"), "there's no open document like \"xyz\""); assert_eq!(message("set colour=1"), "there's no setting like \"colour\""); } #[test] fn every_choice_word_is_understood() { let check = |choices: &[Choice], read: &dyn Fn(&str) -> bool| { for choice in choices { assert!(read(choice.word), "{}", choice.word); } }; check(registry::VIEWS, &|word| view(word).is_ok()); check(registry::BORDERS, &|word| border(word).is_ok()); check(registry::FOLDS, &|word| fold(word).is_ok()); check(registry::LAYOUTS, &|word| layout(word).is_ok()); check(registry::SIDES, &|word| side(word).is_ok()); check(registry::FORMATS, &|word| export_format(word).is_ok()); check(registry::BOARD_COLUMNS, &|word| board_columns(word).is_ok()); } #[test] fn every_command_can_be_typed() { let samples = [ ("edit", "x.cum"), ("buffer", "kitchen"), ("rename", "New name"), ("in", "an idea"), ("link", "rain"), ("portal", "x.cum"), ("tag", "+x"), ("color", "red"), ("style", "question"), ("border", "thick"), ("fold", "open"), ("align", "left"), ("find", "beds"), ("search", "beds"), ("set", "mouse"), ("theme", "light"), ("export", "md"), ("import", "plants.md"), ]; for spec in registry::SPECS { let argument = samples.iter().find(|(name, _)| *name == spec.name).map_or("", |(_, argument)| argument); let line = format!("{} {argument}", spec.name); assert!(call(&line).is_ok(), ":{line} → {:?}", call(&line)); } } }