<Space>/ (the user's pick), or :search text straight to the best match, searches every document cumulus knows of: the open ones (the shown one first), the ones opened lately, the .cum files in this document's folder, and every document their portals and backlinks lead to, however far (the user's pick; up to 200). Open documents are searched as they are, the others as their files have them. It's the / popup with a list of documents to search: one list, best first whatever the document, each match with its document's name at the end of its row (the user's pick), the note beside it from that document. Enter goes to the match through the jump list's GoToPlace, opening a closed document. n and N then go through every match, document to document (the user's pick), going round. The matches are kept in the last search as they were, as vim's :cnext goes through a :vimgrep list: searched again from each document, equal matches would trade places and n would bounce between two. Co-Authored-By: Claude Opus 5.5 <[email protected]>
566 lines
20 KiB
Rust
566 lines
20 KiB
Rust
//! 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<String>,
|
|
force: bool,
|
|
},
|
|
New(Option<Title>),
|
|
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));
|
|
}
|
|
}
|
|
}
|