use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Invocation {
Run {
file: Option<PathBuf>,
opts: RunOpts,
},
Help,
Version,
Doctor,
Diff {
old: PathBuf,
new: PathBuf,
opts: RunOpts,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RunOpts {
pub no_config: bool,
pub log: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CliError {
#[error("unknown option '{0}'")]
UnknownOption(String),
#[error("unexpected argument '{0}' — edamame opens one file at a time (two with --diff)")]
ExtraArgument(String),
#[error("reading from stdin is not supported")]
StdinNotSupported,
#[error("--diff needs exactly two files: edamame --diff <old> <new>")]
DiffNeedsTwoFiles,
}
impl Invocation {
pub fn parse<I>(args: I) -> Result<Self, CliError>
where
I: IntoIterator<Item = OsString>,
{
let mut files: Vec<PathBuf> = Vec::new();
let mut extra: Option<String> = None;
let mut opts = RunOpts::default();
let mut help = false;
let mut version = false;
let mut doctor = false;
let mut diff = false;
let mut positional_only = false;
for arg in args {
let flag = if positional_only {
None
} else {
arg.to_str().filter(|s| s.starts_with('-'))
};
match flag {
Some("--") => positional_only = true,
Some("-h" | "--help") => help = true,
Some("-V" | "--version") => version = true,
Some("--doctor") => doctor = true,
Some("--diff") => diff = true,
Some("--no-config") => opts.no_config = true,
Some("--log") => opts.log = true,
Some("-") => return Err(CliError::StdinNotSupported),
Some(other) => return Err(CliError::UnknownOption(other.to_owned())),
None => {
if files.len() >= 2 {
return Err(CliError::ExtraArgument(arg.to_string_lossy().into_owned()));
}
if files.len() == 1 {
extra = Some(arg.to_string_lossy().into_owned());
}
files.push(PathBuf::from(arg));
}
}
}
if help {
return Ok(Self::Help);
}
if version {
return Ok(Self::Version);
}
if doctor {
return Ok(Self::Doctor);
}
if diff {
let mut it = files.into_iter();
let (Some(old), Some(new)) = (it.next(), it.next()) else {
return Err(CliError::DiffNeedsTwoFiles);
};
return Ok(Self::Diff { old, new, opts });
}
if let Some(extra) = extra {
return Err(CliError::ExtraArgument(extra));
}
Ok(Self::Run {
file: files.pop(),
opts,
})
}
}
pub fn split_startup_anchor(arg: &Path) -> (PathBuf, Option<String>) {
split_startup_anchor_with(arg, |p| p.exists())
}
fn split_startup_anchor_with(
arg: &Path,
exists: impl Fn(&Path) -> bool,
) -> (PathBuf, Option<String>) {
let keep = || (arg.to_path_buf(), None);
let Some(text) = arg.to_str() else {
return keep();
};
let Some((path, fragment)) = text.rsplit_once('#') else {
return keep();
};
if path.is_empty() || fragment.is_empty() || exists(arg) {
return keep();
}
(PathBuf::from(path), Some(fragment.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Invocation, CliError> {
Invocation::parse(args.iter().map(OsString::from))
}
fn run(args: &[&str]) -> (Option<PathBuf>, RunOpts) {
match parse(args).expect("parses") {
Invocation::Run { file, opts } => (file, opts),
other => panic!("expected Run, got {other:?}"),
}
}
#[test]
fn no_arguments_starts_an_empty_buffer() {
assert_eq!(run(&[]), (None, RunOpts::default()));
}
#[test]
fn a_bare_path_is_the_file_to_open() {
let (file, opts) = run(&["notes.md"]);
assert_eq!(file, Some(PathBuf::from("notes.md")));
assert_eq!(opts, RunOpts::default());
}
#[test]
fn run_flags_combine_with_a_file_in_any_order() {
let expected = RunOpts {
no_config: true,
log: true,
};
for args in [
["--no-config", "--log", "notes.md"],
["notes.md", "--no-config", "--log"],
["--log", "notes.md", "--no-config"],
] {
let (file, opts) = run(&args);
assert_eq!(file, Some(PathBuf::from("notes.md")), "{args:?}");
assert_eq!(opts, expected, "{args:?}");
}
}
#[test]
fn informational_flags_have_both_spellings() {
assert_eq!(parse(&["--help"]), Ok(Invocation::Help));
assert_eq!(parse(&["-h"]), Ok(Invocation::Help));
assert_eq!(parse(&["--version"]), Ok(Invocation::Version));
assert_eq!(parse(&["-V"]), Ok(Invocation::Version));
assert_eq!(parse(&["--doctor"]), Ok(Invocation::Doctor));
}
#[test]
fn informational_flags_win_over_a_run() {
assert_eq!(
parse(&["--doctor", "--version", "--help"]),
Ok(Invocation::Help)
);
assert_eq!(parse(&["--doctor", "--version"]), Ok(Invocation::Version));
assert_eq!(parse(&["notes.md", "--doctor"]), Ok(Invocation::Doctor));
assert_eq!(
parse(&["--no-config", "--version"]),
Ok(Invocation::Version)
);
}
#[test]
fn double_dash_ends_flag_parsing() {
let (file, opts) = run(&["--log", "--", "--doctor"]);
assert_eq!(file, Some(PathBuf::from("--doctor")));
assert!(opts.log, "flags before `--` still apply");
assert_eq!(
parse(&["--", "a.md", "--"]),
Err(CliError::ExtraArgument("--".to_owned()))
);
}
#[test]
fn unknown_flags_and_extra_files_are_rejected() {
assert_eq!(
parse(&["--doctorr"]),
Err(CliError::UnknownOption("--doctorr".to_owned()))
);
assert_eq!(
parse(&["-x"]),
Err(CliError::UnknownOption("-x".to_owned()))
);
assert_eq!(
parse(&["-hV"]),
Err(CliError::UnknownOption("-hV".to_owned()))
);
assert_eq!(
parse(&["a.md", "b.md"]),
Err(CliError::ExtraArgument("b.md".to_owned()))
);
}
fn diff(args: &[&str]) -> (PathBuf, PathBuf, RunOpts) {
match parse(args).expect("parses") {
Invocation::Diff { old, new, opts } => (old, new, opts),
other => panic!("expected Diff, got {other:?}"),
}
}
#[test]
fn diff_takes_two_paths_in_git_difftool_order() {
let (old, new, opts) = diff(&["--diff", "left.md", "right.md"]);
assert_eq!(old, PathBuf::from("left.md"));
assert_eq!(new, PathBuf::from("right.md"));
assert_eq!(opts, RunOpts::default());
}
#[test]
fn diff_accepts_the_flag_in_any_position() {
for args in [
["--diff", "a.md", "b.md"],
["a.md", "--diff", "b.md"],
["a.md", "b.md", "--diff"],
] {
let (old, new, _) = diff(&args);
assert_eq!((old, new), (PathBuf::from("a.md"), PathBuf::from("b.md")));
}
}
#[test]
fn diff_combines_with_the_run_flags() {
let (_, _, opts) = diff(&["--diff", "--no-config", "a.md", "--log", "b.md"]);
assert_eq!(
opts,
RunOpts {
no_config: true,
log: true
}
);
}
#[test]
fn diff_with_fewer_than_two_files_is_an_error() {
assert_eq!(parse(&["--diff"]), Err(CliError::DiffNeedsTwoFiles));
assert_eq!(
parse(&["--diff", "only.md"]),
Err(CliError::DiffNeedsTwoFiles)
);
}
#[test]
fn diff_still_rejects_a_third_file() {
assert_eq!(
parse(&["--diff", "a.md", "b.md", "c.md"]),
Err(CliError::ExtraArgument("c.md".to_owned()))
);
}
#[test]
fn informational_flags_win_over_a_diff() {
assert_eq!(
parse(&["--diff", "a.md", "b.md", "--help"]),
Ok(Invocation::Help)
);
assert_eq!(parse(&["--diff", "--version"]), Ok(Invocation::Version));
}
#[test]
fn two_files_without_diff_still_name_the_second_one() {
assert_eq!(
parse(&["a.md", "b.md"]),
Err(CliError::ExtraArgument("b.md".to_owned()))
);
assert_eq!(
parse(&["a.md", "b.md", "c.md"]),
Err(CliError::ExtraArgument("c.md".to_owned()))
);
}
#[test]
fn a_bare_dash_is_refused_rather_than_opened_as_a_file() {
assert_eq!(parse(&["-"]), Err(CliError::StdinNotSupported));
}
#[cfg(unix)]
#[test]
fn non_utf8_file_names_survive() {
use std::os::unix::ffi::OsStringExt;
let raw = OsString::from_vec(vec![b'n', 0xff, b'.', b'm', b'd']);
let parsed = Invocation::parse([raw.clone()]).expect("parses");
assert_eq!(
parsed,
Invocation::Run {
file: Some(PathBuf::from(raw)),
opts: RunOpts::default(),
}
);
}
fn split(arg: &str) -> (PathBuf, Option<String>) {
split_startup_anchor_with(Path::new(arg), |_| false)
}
#[test]
fn startup_argument_splits_off_its_section() {
assert_eq!(
split("docs/editing.md#images"),
(PathBuf::from("docs/editing.md"), Some("images".to_owned()))
);
}
#[test]
fn startup_argument_without_a_section_is_untouched() {
assert_eq!(
split("docs/editing.md"),
(PathBuf::from("docs/editing.md"), None)
);
}
#[test]
fn an_empty_half_is_not_a_section() {
assert_eq!(split("notes.md#"), (PathBuf::from("notes.md#"), None));
assert_eq!(split("#notes.md"), (PathBuf::from("#notes.md"), None));
}
#[test]
fn only_the_last_hash_splits() {
assert_eq!(
split("notes#2024/index.md#intro"),
(
PathBuf::from("notes#2024/index.md"),
Some("intro".to_owned())
)
);
}
#[test]
fn a_file_really_named_with_a_hash_wins_over_the_split() {
let arg = Path::new("notes#1.md");
assert_eq!(
split_startup_anchor_with(arg, |p| p == Path::new("notes#1.md")),
(PathBuf::from("notes#1.md"), None)
);
}
}