diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 5185387..f8bcf66 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -1238,15 +1238,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getopts" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" -dependencies = [ - "unicode-width", -] - [[package]] name = "getrandom" version = "0.1.16" @@ -1730,6 +1721,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexopt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" + [[package]] name = "libc" version = "0.2.150" @@ -2880,8 +2877,8 @@ dependencies = [ "dirs", "emojis", "env_logger", - "getopts", "insta", + "lexopt", "log", "memchr", "muda", @@ -3341,12 +3338,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" -[[package]] -name = "unicode-width" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" - [[package]] name = "unsafe-libyaml" version = "0.2.9" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 48f2c7b..109dc76 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -32,7 +32,7 @@ anyhow = "1.0.75" dirs = "5.0.1" emojis = "0.6.1" env_logger = "0.10.0" -getopts = "0.2.21" +lexopt = "0.3.0" log = "0.4.20" memchr = "2.6.4" muda = "0.10.0" diff --git a/v2/src/cli.rs b/v2/src/cli.rs index 0c706f5..16b2d34 100644 --- a/v2/src/cli.rs +++ b/v2/src/cli.rs @@ -1,7 +1,6 @@ use anyhow::Result; -use getopts::Options as GetOpts; use std::env; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ThemeOption { @@ -10,8 +9,27 @@ pub enum ThemeOption { Light, } +impl ThemeOption { + fn new(name: &str) -> Result<Self> { + match name { + "dark" | "Dark" => Ok(Self::Dark), + "light" | "Light" => Ok(Self::Light), + "system" | "System" => Ok(Self::System), + _ => anyhow::bail!( + r#"Value for --theme must be one of "dark", "light" or "system" but got {name:?}"#, + ), + } + } +} + +#[derive(Debug)] +pub enum Parsed { + Options(Options), + Help(&'static str), +} + #[non_exhaustive] -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, PartialEq)] pub struct Options { pub debug: bool, pub init_file: Option<PathBuf>, @@ -23,84 +41,91 @@ pub struct Options { pub data_dir: Option<PathBuf>, } -impl Options { - pub fn from_args(iter: impl Iterator<Item = String>) -> Result<Option<Self>> { - let mut opts = GetOpts::new(); - opts.optflag("h", "help", "print this help"); - opts.optopt("t", "theme", r#"window theme ("dark", "light" or "system")"#, "THEME"); - opts.optflag("", "no-watch", "disable to watch file changes"); - opts.optflag( - "", - "generate-config-file", - "generate default config file at the config directory. this overwrites an existing file", - ); - opts.optopt("", "config-dir", "custom config directory path", "PATH"); - opts.optopt("", "data-dir", "custom data directory path", "PATH"); - opts.optflag("", "debug", "enable debug features"); - - let matches = opts.parse(iter)?; - - #[allow(clippy::print_stdout)] - if matches.opt_present("h") { - println!("{}", opts.usage("Usage: shiba [option] [PATH...]")); - return Ok(None); +impl Default for Options { + fn default() -> Self { + Self { + debug: false, + init_file: None, + watch_paths: vec![], + watch: true, + theme: None, + gen_config_file: false, + config_dir: None, + data_dir: None, } + } +} - let theme = match matches.opt_str("t") { - Some(theme) => match theme.as_str() { - "dark" | "Dark" => Some(ThemeOption::Dark), - "light" | "Light" => Some(ThemeOption::Light), - "system" | "System" => Some(ThemeOption::System), - _ => anyhow::bail!( - r#"Value for --theme must be one of "dark", "light" or "system" but got {:?}"#, - theme, - ), - }, - None => None, - }; - let watch = !matches.opt_present("no-watch"); - let gen_config_file = matches.opt_present("generate-config-file"); - let config_dir = matches.opt_str("config-dir").map(PathBuf::from); - let data_dir = matches.opt_str("data-dir").map(PathBuf::from); - let debug = matches.opt_present("debug"); - - let mut init_file = None; - let mut watch_paths = vec![]; - let mut cwd: Option<PathBuf> = None; - for arg in matches.free.iter() { - let path = Path::new(arg); - let exists = path.exists(); - - // `path.canonicalize()` returns an error when the path does not exist. Instead, create the absolute path - // using current directory as a parent - let path = if exists { - path.canonicalize()? - } else if let Some(dir) = &cwd { - dir.join(path) - } else { - let dir = env::current_dir()?.canonicalize()?; - let path = dir.join(path); - cwd = Some(dir); - path +impl Options { + const USAGE: &'static str = r#" + Usage: shiba [options...] [PATH...] + + Options: + -t, --theme THEME Window theme ("dark", "light" or "system") + --no-watch Disable to watch file changes + --generate-config-file Generate the default config file overwriting an existing file + --config-dir PATH Custom the config directory path + --data-dir PATH Custom the data directory path + --debug Enable debug features + -h, --help Print this help + "#; + + pub fn parse(args: impl Iterator<Item = String>) -> Result<Parsed> { + use lexopt::prelude::*; + + fn value(parser: &mut lexopt::Parser) -> Result<String> { + let Ok(v) = parser.value()?.into_string() else { + anyhow::bail!("Invalid UTF-8 sequence in command line argument"); }; + if v.starts_with('-') { + anyhow::bail!("Expected option value but got option name {v}"); + } + Ok(v) + } + + let mut opts = Options::default(); - if init_file.is_some() || path.is_dir() || !exists { - watch_paths.push(path); - } else { - init_file = Some(path); + let mut cwd: Option<PathBuf> = None; + let mut parser = lexopt::Parser::from_iter(args); + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => return Ok(Parsed::Help(Self::USAGE)), + Short('t') | Long("theme") => { + opts.theme = Some(ThemeOption::new(&value(&mut parser)?)?); + } + Long("no-watch") => opts.watch = false, + Long("generate-config-file") => opts.gen_config_file = true, + Long("config-dir") => opts.config_dir = Some(value(&mut parser)?.into()), + Long("data-dir") => opts.data_dir = Some(value(&mut parser)?.into()), + Long("debug") => opts.debug = true, + Value(path) => { + let path = PathBuf::from(path); + let exists = path.exists(); + + // `path.canonicalize()` returns an error when the path does not exist. Instead, create the absolute path + // using current directory as a parent + let path = if exists { + path.canonicalize()? + } else if let Some(dir) = &cwd { + dir.join(path) + } else { + let dir = env::current_dir()?.canonicalize()?; + let path = dir.join(path); + cwd = Some(dir); + path + }; + + if opts.init_file.is_some() || path.is_dir() || !exists { + opts.watch_paths.push(path); + } else { + opts.init_file = Some(path); + } + } + _ => return Err(arg.unexpected().into()), } } - Ok(Some(Self { - debug, - init_file, - watch_paths, - watch, - theme, - gen_config_file, - config_dir, - data_dir, - })) + Ok(Parsed::Options(opts)) } } @@ -108,15 +133,24 @@ impl Options { mod tests { use super::*; + fn cmdline(args: &[&str]) -> impl Iterator<Item = String> { + let mut c = vec!["shiba".to_string()]; + c.extend(args.iter().map(ToString::to_string)); + c.into_iter() + } + #[test] fn parse_args_ok() { let cur = env::current_dir().unwrap().canonicalize().unwrap(); - let tests = &[ - (&[][..], Options { watch: true, ..Default::default() }), + #[rustfmt::skip] + let tests = [ + ( + &[][..], + Options::default(), + ), ( &["README.md"][..], Options { - watch: true, init_file: Some(cur.join("README.md")), ..Default::default() }, @@ -124,7 +158,6 @@ mod tests { ( &["README.md", "src"][..], Options { - watch: true, init_file: Some(cur.join("README.md")), watch_paths: vec![cur.join("src")], ..Default::default() @@ -133,22 +166,26 @@ mod tests { ( &["file-not-existing.md"][..], Options { - watch: true, init_file: None, watch_paths: vec![cur.join("file-not-existing.md")], ..Default::default() }, ), - (&["--no-watch"][..], Options::default()), - (&["--debug"][..], Options { watch: true, debug: true, ..Default::default() }), + ( + &["--no-watch"][..], + Options { watch: false, ..Default::default() }, + ), + ( + &["--debug"][..], + Options { debug: true, ..Default::default() }, + ), ( &["--theme", "dark"][..], - Options { watch: true, theme: Some(ThemeOption::Dark), ..Default::default() }, + Options { theme: Some(ThemeOption::Dark), ..Default::default() }, ), ( &["--config-dir", "some-dir"][..], Options { - watch: true, config_dir: Some(PathBuf::from("some-dir")), ..Default::default() }, @@ -156,7 +193,6 @@ mod tests { ( &["--data-dir", "some-dir"][..], Options { - watch: true, data_dir: Some(PathBuf::from("some-dir")), ..Default::default() }, @@ -164,31 +200,54 @@ mod tests { ]; for (args, want) in tests { - let opts = Options::from_args(args.iter().map(|&s| String::from(s))).unwrap().unwrap(); - assert_eq!(&opts, want, "args={:?}", args); + match Options::parse(cmdline(args)).unwrap() { + Parsed::Options(opts) => assert_eq!(opts, want, "args={args:?}"), + Parsed::Help(_) => panic!("--help is returned for {args:?}"), + } } } #[test] fn help_option() { - let args = [String::from("--help")]; - let opts = Options::from_args(args.into_iter()).unwrap(); - assert!(opts.is_none(), "{:?}", opts); + match Options::parse(cmdline(&["--help"])).unwrap() { + Parsed::Options(opts) => panic!("--help is not recognized: {opts:?}"), + Parsed::Help(help) => { + assert!(help.contains("Usage: shiba [options...] [PATH...]"), "{help:?}") + } + } } #[test] fn parse_args_error() { - let args = [String::from("--foo")]; - let err = Options::from_args(args.into_iter()).unwrap_err(); - assert!(format!("{}", err).contains("Unrecognized option"), "{:?}", err); - - let args = [String::from("--theme"), String::from("foo")]; - let err = Options::from_args(args.into_iter()).unwrap_err(); - let msg = format!("{}", err); - assert!( - msg.contains(r#"Value for --theme must be one of "dark", "light" or "system""#), - "{:?}", - err, - ); + let err = Options::parse(cmdline(&["--foo"])).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("invalid option '--foo'"), "unexpected message {msg:?}"); + + // Test missing value + for arg in ["--config-dir", "--data-dir", "--theme"] { + let err = Options::parse(cmdline(&[arg, "--debug"])).unwrap_err(); + assert_eq!( + format!("{err}"), + "Expected option value but got option name --debug", + "unexpected message {err:?} for {arg:?}", + ); + + let err = Options::parse(cmdline(&["--debug", arg])).unwrap_err(); + assert_eq!( + format!("{err}"), + format!("missing argument for option '{arg}'"), + "unexpected message {err:?} for {arg:?}", + ); + } + + // Test --theme + { + let err = Options::parse(cmdline(&["--theme", "foo"])).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains(r#"Value for --theme must be one of "dark", "light" or "system""#), + "unexpected message {msg:?}", + ); + } } } diff --git a/v2/src/lib.rs b/v2/src/lib.rs index e00539b..771ed45 100644 --- a/v2/src/lib.rs +++ b/v2/src/lib.rs @@ -19,7 +19,7 @@ mod watcher; mod windows; mod wry; -pub use cli::Options; +pub use cli::{Options, Parsed}; #[cfg(feature = "__bench")] pub use markdown::{MarkdownContent, MarkdownParser}; #[cfg(feature = "__bench")] diff --git a/v2/src/main.rs b/v2/src/main.rs index 69960c4..9f7ae24 100644 --- a/v2/src/main.rs +++ b/v2/src/main.rs @@ -5,7 +5,7 @@ use anyhow::Result; use log::LevelFilter; -use shiba_preview::{run, Options}; +use shiba_preview::{run, Options, Parsed}; use std::env; fn main() -> Result<()> { @@ -14,12 +14,19 @@ fn main() -> Result<()> { #[cfg(all(windows, not(debug_assertions), not(__bench)))] let _console = shiba_preview::WindowsConsole::attach(); - let Some(options) = Options::from_args(env::args().skip(1))? else { return Ok(()) }; - let level = if options.debug { LevelFilter::Debug } else { LevelFilter::Info }; - env_logger::builder() - .filter_level(level) - .format_timestamp(None) - .filter_module("html5ever", LevelFilter::Off) - .init(); - run(options) + match Options::parse(env::args())? { + Parsed::Options(options) => { + let level = if options.debug { LevelFilter::Debug } else { LevelFilter::Info }; + env_logger::builder() + .filter_level(level) + .format_timestamp(None) + .filter_module("html5ever", LevelFilter::Off) + .init(); + run(options) + } + Parsed::Help(help) => { + println!("{}", help); + Ok(()) + } + } }