Add the command line: commands, completion, ghost text and history
`:` opens a vim-style command line. Every command is described once, in a typed table (name, the short form that still works, aliases, a `!`, its arguments and what it does), so parsing, completion and the help can never disagree. Short forms work as in vim (`:e`, `:vs`, `:q!`, `:x`), and a test checks that no typed name can mean two commands. Parsing turns a line into a typed Call, checking every argument against what the app offers (open documents, nodes, colors, styles, settings), or says what's wrong in words: the usage when an argument is missing, the choices when a word isn't one, "did you mean :fold?" for a typo. Documents and nodes match fuzzily (`:b gpl` finds Garden plans). While typing, a popup above the word lists what can finish it (command names by prefix, choices, files from the folders on disk, documents and nodes fuzzily), with Tab and Shift-Tab putting them in the line. Ghost text after the cursor comes from the history first, like fish's, then from the best candidate; → or Ctrl-e takes it. Up and Down browse the lines that start with what was typed. The line itself is a small input that steps over whole characters, accents and emoji included. The canvas example opens it with `:` and runs a handful of commands: :fold, :color, :border, :tag, :focus, :rename, :in (to an Inbox), :link, :unlink, :find, :version and :q. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c2e62de172
commit
bfc184f5ef
14 files changed
+2094
-11
No files matched your search
@@ -0,0 +1,570 @@
|
||||
//! 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};
|
||||
use crate::view::ViewKind;
|
||||
|
||||
/// How `:tidy` arranges a branch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// A mind map, growing to the right.
|
||||
Map,
|
||||
/// An org chart, growing down.
|
||||
Chart,
|
||||
/// A grid, for idea dumps.
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Which edges `:align` lines up.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
Center,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Edit {
|
||||
path: 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),
|
||||
BranchOut,
|
||||
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),
|
||||
Backlinks,
|
||||
Jumps,
|
||||
Marks,
|
||||
Registers,
|
||||
Set(SetArg),
|
||||
Config,
|
||||
Help(Option<String>),
|
||||
Version,
|
||||
Theme(String),
|
||||
Export {
|
||||
format: ExportFormat,
|
||||
path: Option<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: text.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,
|
||||
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::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) },
|
||||
})
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
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: "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()) })
|
||||
);
|
||||
}
|
||||
|
||||
#[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("e"), ":e[dit][!] <file>: the file 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"),
|
||||
("set", "mouse"),
|
||||
("theme", "light"),
|
||||
("export", "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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user