From b664e50aae673cf6a311656881aff3319510a8ec Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Mon, 28 Sep 2026 13:56:39 -0700 Subject: [PATCH 1/4] Enable clap's derive feature No behavior change. The CLI is about to move from hand-built Commands to typed derive structs. AI assistance: Claude Code. Agent-Session: b28d94c6-2325-41ab-9fab-e9027acc5b09 Agent-Session: 01a0e9c2-2c41-7062-b484-92e030c4ab9c --- Cargo.lock | 13 +++++++++++++ Cargo.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 032aa4f9e..c633ae424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,6 +387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -402,6 +403,18 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "clap_lex" version = "0.7.4" diff --git a/Cargo.toml b/Cargo.toml index b39a52880..57e8d6a51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ include = [ [dependencies] git2 = { version = "0.20", default-features = false } regex = "1.10.4" -clap = { version = "4.0.0", features = ["cargo", "env", "wrap_help", "string"] } +clap = { version = "4.0.0", features = ["cargo", "derive", "env", "wrap_help", "string"] } typed-arena = "2.0.2" rustc-hash = "2.0.0" strsim = "0.11.0" From 766868a54acee6d9313c67a5087ac2532d3a4bbe Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Mon, 28 Sep 2026 13:56:39 -0700 Subject: [PATCH 2/4] Declare the command line as clap derive types No behavior change. Cli, Command, ConfigArgs and ConfigCommand describe the same arguments the builder below defines: the same help strings, groups and conflicts, and the builder's lowercase value names (``, `[items]...`) spelled out so help, usage and errors stay byte-identical. Nothing uses them yet. AI assistance: Claude Code. Agent-Session: b28d94c6-2325-41ab-9fab-e9027acc5b09 Agent-Session: 01a0e9c2-2c41-7062-b484-92e030c4ab9c --- src/cli.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index cf1e08637..65207cbd5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,7 @@ use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; use crate::options::DiffOptions; use crate::plugin::Pipeline; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use git2::{DiffStatsFormat, Repository}; use std::{ ffi::OsString, @@ -12,6 +13,130 @@ use std::{ sync::Arc, }; +/// Structural diffs with Git-style comparison inputs +#[derive(Parser)] +#[command( + name = env!("CARGO_BIN_NAME"), + version, + group(ArgGroup::new("metadata").args(["name_only", "name_status", "stat", "numstat", "shortstat"])), + after_help = "Examples:\n diffr\n diffr --cached\n diffr main...HEAD -- src/\n diffr --no-index -- before.rs after.rs\n diffr main HEAD --format ndjson\n\nUnsupported Git flags are rejected; this is not a complete git diff implementation." +)] +struct Cli { + #[command(subcommand)] + command: Option, + #[arg(long, value_name = "repo", default_value = ".")] + repo: String, + /// Replace the global configuration file + #[arg(long = "config", value_name = "PATH")] + config_file: Option, + /// Concurrent file diffs for --format ndjson; results are emitted as each finishes + #[arg(short, long, value_name = "jobs", default_value_t = 16)] + jobs: usize, + /// File tag priority: files carrying an earlier listed tag come first + #[arg(long, value_name = "order", value_delimiter = ',')] + order: Vec, + #[arg(long, visible_alias = "staged")] + cached: bool, + #[arg(long)] + merge_base: bool, + #[arg(long)] + no_index: bool, + #[arg(short = 'R', long)] + reverse: bool, + #[arg(long)] + exit_code: bool, + #[arg(long)] + quiet: bool, + #[arg(long)] + name_only: bool, + #[arg(long)] + name_status: bool, + #[arg(long)] + stat: bool, + #[arg(long)] + numstat: bool, + #[arg(long)] + shortstat: bool, + #[arg(short = 'z', long)] + null: bool, + #[arg(long)] + no_renames: bool, + #[arg(short = 'M', long, conflicts_with = "no_renames")] + find_renames: bool, + /// Unchanged lines kept around each change; defaults to plugins.bundled.context.lines + #[arg(short = 'U', long, value_name = "unified")] + unified: Option, + /// Write the event stream to stdout instead of opening the terminal UI + #[arg(long, value_name = "format")] + format: Option, + /// Emit initial files followed by deferred annotations (NDJSON v4) + #[arg(long, requires = "format")] + stream_annotations: bool, + /// Include every token's tree-sitter capture name in --format ndjson output + #[arg(long)] + syntax: bool, + /// Columns for --stat; defaults to the terminal's width + #[arg(long, value_name = "width")] + width: Option, + #[arg(long)] + ignore_comments: bool, + #[arg(long, value_name = "byte-limit")] + byte_limit: Option, + #[arg(long, value_name = "graph-limit")] + graph_limit: Option, + #[arg(long, value_name = "parse-error-limit")] + parse_error_limit: Option, + #[arg(value_name = "items", num_args = 0..)] + items: Vec, +} + +impl Cli { + fn metadata(&self) -> bool { + self.name_only || self.name_status || self.stat || self.numstat || self.shortstat + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum Format { + Ndjson, +} + +#[derive(Subcommand)] +enum Command { + /// Show, edit, or open the settings screen for diffr's configuration + Config(ConfigArgs), +} + +#[derive(Args)] +struct ConfigArgs { + /// Initial search in the settings screen + #[arg(value_name = "query")] + query: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum ConfigCommand { + /// Print the configuration's JSON Schema + Schema, + /// Print the resolved configuration + Show { + #[arg(long)] + json: bool, + /// Do not redact the API key + #[arg(long)] + reveal: bool, + }, + /// Write one key to the global configuration file + Set { + #[arg(value_name = "key")] + key: String, + #[arg(value_name = "value")] + value: String, + }, +} + fn flag(name: &'static str) -> Arg { Arg::new(name).long(name).action(ArgAction::SetTrue) } From 3139c027d7d20fecfe2b2ad8bce864802d648cb8 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Mon, 28 Sep 2026 13:56:40 -0700 Subject: [PATCH 3/4] Parse into Cli and read flags as fields No behavior change. The builder and every get_flag("...") / get_one().unwrap() lookup give way to field access on the parsed Cli; the config subcommand becomes an enum match, so the unreachable "unknown config command" arm goes. The split at `--` before parsing is kept as is. AI assistance: Claude Code. Agent-Session: b28d94c6-2325-41ab-9fab-e9027acc5b09 Agent-Session: 01a0e9c2-2c41-7062-b484-92e030c4ab9c --- src/cli.rs | 230 ++++++++++++++++------------------------------------- 1 file changed, 67 insertions(+), 163 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 65207cbd5..47b74631e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,8 +3,7 @@ use crate::config::{self, Config}; use crate::git::{Comparison, DiffSession, FileParams, Operand, Result}; use crate::options::DiffOptions; use crate::plugin::Pipeline; -use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use git2::{DiffStatsFormat, Repository}; use std::{ ffi::OsString, @@ -137,10 +136,6 @@ enum ConfigCommand { }, } -fn flag(name: &'static str) -> Arg { - Arg::new(name).long(name).action(ArgAction::SetTrue) -} - pub(crate) fn run() -> Result { let mut argv: Vec = std::env::args_os().collect(); let frontend_args = argv[1..].to_vec(); @@ -155,70 +150,13 @@ pub(crate) fn run() -> Result { paths }) .unwrap_or_default(); - let args = Command::new(env!("CARGO_BIN_NAME")) - .version(env!("CARGO_PKG_VERSION")) - .about("Structural diffs with Git-style comparison inputs") - .arg(Arg::new("repo").long("repo").default_value(".")) - .arg(Arg::new("config_file").long("config").value_name("PATH").help("Replace the global configuration file")) - .arg( - Arg::new("jobs") - .long("jobs") - .short('j') - .value_parser(clap::value_parser!(usize)) - .default_value("16") - .help("Concurrent file diffs for --format ndjson; results are emitted as each finishes"), - ) - .arg(Arg::new("order").long("order").value_delimiter(',').action(ArgAction::Append).help("File tag priority: files carrying an earlier listed tag come first")) - .arg(flag("cached").visible_alias("staged")) - .arg(flag("merge-base")) - .arg(flag("no-index")) - .arg(flag("reverse").short('R')) - .arg(flag("exit-code")) - .arg(flag("quiet")) - .arg(flag("name-only")) - .arg(flag("name-status")) - .arg(flag("stat")) - .arg(flag("numstat")) - .arg(flag("shortstat")) - .group(ArgGroup::new("metadata").args(["name-only", "name-status", "stat", "numstat", "shortstat"])) - .arg(flag("null").short('z')) - .arg(flag("no-renames")) - .arg(flag("find-renames").short('M').conflicts_with("no-renames")) - .arg(Arg::new("unified").short('U').long("unified").value_parser(clap::value_parser!(u32)).help("Unchanged lines kept around each change; defaults to plugins.bundled.context.lines")) - .arg(Arg::new("format").long("format").value_parser(["ndjson"]).help("Write the event stream to stdout instead of opening the terminal UI")) - .arg(flag("stream-annotations").requires("format").help("Emit initial files followed by deferred annotations (NDJSON v4)")) - .arg(flag("syntax").help("Include every token's tree-sitter capture name in --format ndjson output")) - .arg(Arg::new("width").long("width").value_parser(clap::value_parser!(usize)).help("Columns for --stat; defaults to the terminal's width")) - .arg(flag("ignore-comments")) - .arg(Arg::new("byte-limit").long("byte-limit").value_parser(clap::value_parser!(usize))) - .arg(Arg::new("graph-limit").long("graph-limit").value_parser(clap::value_parser!(usize))) - .arg(Arg::new("parse-error-limit").long("parse-error-limit").value_parser(clap::value_parser!(usize))) - .arg(Arg::new("items").num_args(0..).value_parser(clap::value_parser!(OsString))) - .subcommand( - Command::new("config") - .about("Show, edit, or open the settings screen for diffr's configuration") - .arg(Arg::new("query").help("Initial search in the settings screen")) - .subcommand(Command::new("schema").about("Print the configuration's JSON Schema")) - .subcommand( - Command::new("show") - .about("Print the resolved configuration") - .arg(flag("json")) - .arg(flag("reveal").help("Do not redact the API key")), - ) - .subcommand( - Command::new("set") - .about("Write one key to the global configuration file") - .arg(Arg::new("key").required(true)) - .arg(Arg::new("value").required(true)), - ), - ) - .after_help("Examples:\n diffr\n diffr --cached\n diffr main...HEAD -- src/\n diffr --no-index -- before.rs after.rs\n diffr main HEAD --format ndjson\n\nUnsupported Git flags are rejected; this is not a complete git diff implementation.") - .get_matches_from(argv); - if let Some(("config", sub)) = args.subcommand() { - return run_config(&args, sub); + let args = Cli::parse_from(argv); + match &args.command { + Some(Command::Config(config)) => return run_config(&args, config), + None => {} } - let streaming = args.contains_id("format"); - let metadata_or_quiet = args.get_flag("quiet") || args.contains_id("metadata"); + let streaming = args.format.is_some(); + let metadata_or_quiet = args.quiet || args.metadata(); if !streaming && !metadata_or_quiet { return launch_tui(&frontend_args, true); } @@ -226,59 +164,39 @@ pub(crate) fn run() -> Result { return Err("--format ndjson cannot be combined with --quiet or metadata output".into()); } let stream_options = crate::protocol::stream::Options { - syntax: args.get_flag("syntax"), - updates: args.get_flag("stream-annotations"), + syntax: args.syntax, + updates: args.stream_annotations, }; - let items: Vec = args - .get_many::("items") - .into_iter() - .flatten() - .cloned() - .collect(); - if args.get_flag("no-index") { + if args.no_index { return no_index( &args, - items.into_iter().chain(explicit_paths).collect(), + args.items.iter().cloned().chain(explicit_paths).collect(), stream_options, ); } - if args.get_flag("null") && !args.get_flag("name-only") && !args.get_flag("name-status") { + if args.null && !args.name_only && !args.name_status { return Err("-z currently requires --name-only or --name-status".into()); } - let location = std::fs::canonicalize(args.get_one::("repo").unwrap())?; + let location = std::fs::canonicalize(&args.repo)?; let repo = Repository::discover(&location)?; let workspace = repo.workdir().unwrap_or(repo.path()); - let (comparison, paths) = select( - &repo, - &location, - &args, - items, - explicit_paths, - has_separator, - )?; + let (comparison, paths) = select(&repo, &location, &args, explicit_paths, has_separator)?; let files = FileParams { paths, - renames: !args.get_flag("no-renames"), - order: args - .get_many::("order") - .into_iter() - .flatten() - .cloned() - .collect(), + // `-M` conflicts with `--no-renames`; renames are on by default. + renames: args.find_renames || !args.no_renames, + order: args.order.clone(), }; if metadata_or_quiet { let diff = comparison.resolve(&repo)?.diff(&repo, &files)?; let changed = diff.deltas().len() > 0; - if !args.get_flag("quiet") { + if !args.quiet { let width = args - .get_one::("width") - .copied() + .width .unwrap_or_else(crate::options::detect_terminal_width); print_metadata(&diff, &args, width)?; } - return Ok(i32::from( - changed && (args.get_flag("exit-code") || args.get_flag("quiet")), - )); + return Ok(i32::from(changed && (args.exit_code || args.quiet))); } let mut config = load_config(&args)?; apply_unified(&args, &mut config); @@ -295,13 +213,12 @@ pub(crate) fn run() -> Result { )?; session.diff_options = diff_options; let changed = session.remaining() > 0; - let jobs = *args.get_one::("jobs").unwrap(); - if jobs == 0 { + if args.jobs == 0 { return Err("--jobs must be at least 1".into()); } let ended = crate::protocol::stream::write( session, - jobs, + args.jobs, Arc::new(pipeline), stream_options, &mut io::stdout().lock(), @@ -309,26 +226,25 @@ pub(crate) fn run() -> Result { Ok(if ended.failed || ended.aborted { 2 } else { - i32::from(changed && args.get_flag("exit-code")) + i32::from(changed && args.exit_code) }) } fn select( repo: &Repository, location: &Path, - args: &ArgMatches, - items: Vec, + args: &Cli, explicit_paths: Vec, has_separator: bool, ) -> Result<(Comparison, Vec)> { let mut revisions = Vec::new(); let mut paths = Vec::new(); - for item in items { + for item in &args.items { let text = item .to_str() .ok_or("non-UTF-8 revision/path arguments are unsupported")?; let is_rev = repo.revparse(text).is_ok(); - let is_path = location.join(&item).exists(); + let is_path = location.join(item).exists(); if is_rev && is_path && !has_separator { return Err( format!("ambiguous revision and path {text:?}; use -- to separate them").into(), @@ -339,7 +255,7 @@ fn select( } else if is_rev { return Err("revisions must precede paths; use -- to separate them".into()); } else if is_path { - paths.push(item); + paths.push(item.clone()); } else { return Err( format!("unknown revision or path {text:?}; use -- before pathspecs").into(), @@ -354,7 +270,7 @@ fn select( .into_iter() .map(|path| normalize_path(prefix, &path)) .collect::>>()?; - let cached = args.get_flag("cached"); + let cached = args.cached; let mut comparison = match revisions.as_slice() { [] if cached => Comparison { before: match repo.head() { @@ -403,7 +319,7 @@ fn select( }, _ => return Err("expected at most two revisions (--cached takes at most one)".into()), }; - if args.get_flag("merge-base") { + if args.merge_base { let a = revisions .first() .ok_or("--merge-base requires a revision")?; @@ -416,7 +332,7 @@ fn select( revisions.get(1).map(String::as_str).unwrap_or("HEAD"), )?; } - if args.get_flag("reverse") { + if args.reverse { comparison.reverse(); } Ok((comparison, paths)) @@ -444,13 +360,13 @@ fn normalize_path(prefix: &Path, path: &std::ffi::OsStr) -> Result { Ok(normalized.to_str().ok_or("non-UTF-8 pathspec")?.to_owned()) } -fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Result<()> { +fn print_metadata(diff: &git2::Diff<'_>, args: &Cli, width: usize) -> Result<()> { let mut stdout = io::stdout().lock(); - if args.get_flag("name-only") || args.get_flag("name-status") { - let separator: &[u8] = if args.get_flag("null") { b"\0" } else { b"\t" }; - let terminator: &[u8] = if args.get_flag("null") { b"\0" } else { b"\n" }; + if args.name_only || args.name_status { + let separator: &[u8] = if args.null { b"\0" } else { b"\t" }; + let terminator: &[u8] = if args.null { b"\0" } else { b"\n" }; for delta in diff.deltas() { - if args.get_flag("name-status") { + if args.name_status { write!( stdout, "{}", @@ -480,7 +396,7 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res } return Ok(()); } - if args.get_flag("numstat") { + if args.numstat { for (index, delta) in diff.deltas().enumerate() { let patch = git2::Patch::from_diff(diff, index)?; if let Some(patch) = patch { @@ -504,7 +420,7 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res } return Ok(()); } - let format = if args.get_flag("shortstat") { + let format = if args.shortstat { DiffStatsFormat::SHORT } else { DiffStatsFormat::FULL @@ -514,22 +430,18 @@ fn print_metadata(diff: &git2::Diff<'_>, args: &ArgMatches, width: usize) -> Res } fn no_index( - args: &ArgMatches, + args: &Cli, paths: Vec, stream_options: crate::protocol::stream::Options, ) -> Result { if paths.len() != 2 { return Err("--no-index requires two file paths".into()); } - if args.get_flag("cached") - || args.get_flag("merge-base") - || args.contains_id("metadata") - || args.get_flag("null") - { + if args.cached || args.merge_base || args.metadata() || args.null { return Err("--no-index currently supports structural file output and --quiet, not index or metadata options".into()); } let mut paths = paths; - if args.get_flag("reverse") { + if args.reverse { paths.swap(0, 1); } let read = |path: &OsString| -> Result> { @@ -541,7 +453,7 @@ fn no_index( let before = read(&paths[0])?; let after = read(&paths[1])?; let changed = before != after; - if args.get_flag("quiet") { + if args.quiet { return Ok(i32::from(changed)); } let mut config = load_config(args)?; @@ -577,80 +489,72 @@ fn no_index( Ok(if ended.failed || ended.aborted { 2 } else { - i32::from(changed && args.get_flag("exit-code")) + i32::from(changed && args.exit_code) }) } /// The engine limits: the configured `[diff]` table, then the command-line /// flags. -fn diff_options(args: &ArgMatches, params: &config::Params) -> DiffOptions { - let mut options = params.diff.options(args.get_flag("ignore-comments")); - if let Some(limit) = args.get_one::("byte-limit") { - options.byte_limit = *limit; +fn diff_options(args: &Cli, params: &config::Params) -> DiffOptions { + let mut options = params.diff.options(args.ignore_comments); + if let Some(limit) = args.byte_limit { + options.byte_limit = limit; } - if let Some(limit) = args.get_one::("graph-limit") { - options.graph_limit = *limit; + if let Some(limit) = args.graph_limit { + options.graph_limit = limit; } - if let Some(limit) = args.get_one::("parse-error-limit") { - options.parse_error_limit = *limit; + if let Some(limit) = args.parse_error_limit { + options.parse_error_limit = limit; } options } /// `-U` overrides the context plugin's `lines` for this run. -fn apply_unified(args: &ArgMatches, config: &mut Config) { - let Some(unified) = args.get_one::("unified") else { +fn apply_unified(args: &Cli, config: &mut Config) { + let Some(unified) = args.unified else { return; }; if let Some(entry) = config.plugins.entries.get_mut("bundled.context") { entry .options - .insert("lines".into(), serde_json::Value::from(*unified)); + .insert("lines".into(), serde_json::Value::from(unified)); } } /// The global file, or the `--config` file in its place. -fn load_config(args: &ArgMatches) -> Result { - Ok(Config::load( - args.get_one::("config_file").map(Path::new), - )?) +fn load_config(args: &Cli) -> Result { + Ok(Config::load(args.config_file.as_deref())?) } /// `diffr config`: settings, schema, resolved values and edits. -fn run_config(args: &ArgMatches, sub: &ArgMatches) -> Result { +fn run_config(args: &Cli, config: &ConfigArgs) -> Result { let mut stdout = io::stdout().lock(); - match sub.subcommand() { - Some(("schema", _)) => { + match &config.command { + Some(ConfigCommand::Schema) => { serde_json::to_writer_pretty(&mut stdout, &Config::schema())?; stdout.write_all(b"\n")?; } - Some(("show", show)) => { + Some(ConfigCommand::Show { json, reveal }) => { let config = load_config(args)?; - let reveal = show.get_flag("reveal"); - if show.get_flag("json") { - serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, reveal))?; + if *json { + serde_json::to_writer_pretty(&mut stdout, &config::store::show(&config, *reveal))?; stdout.write_all(b"\n")?; } else { stdout.write_all( - toml::to_string_pretty(&config::store::redacted(&config, reveal))?.as_bytes(), + toml::to_string_pretty(&config::store::redacted(&config, *reveal))?.as_bytes(), )?; } } - Some(("set", set)) => { - let path = match args.get_one::("config_file") { - Some(path) => PathBuf::from(path), + Some(ConfigCommand::Set { key, value }) => { + let path = match &args.config_file { + Some(path) => path.clone(), None => config::global_path()?, }; - config::store::set( - &path, - set.get_one::("key").unwrap(), - set.get_one::("value").unwrap(), - )?; + config::store::set(&path, key, value)?; } - Some((other, _)) => return Err(format!("unknown config command {other}").into()), None => { let mut frontend = vec![OsString::from("--settings")]; - if let Some(query) = sub.get_one::("query") { + if let Some(query) = &config.query { frontend.push(query.into()); } return launch_tui(&frontend, false); From ec4fd6de22ee951e5afba4508b9f4981678725de Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Mon, 28 Sep 2026 13:56:40 -0700 Subject: [PATCH 4/4] Declare the debug options as a derive type No behavior change. options.rs's second hand-built Command becomes a `#[derive(Parser)]` struct with the same name, usage, version strings, alphabetical option order and env vars; main still dispatches on argv[1] == "debug" and parse_args still returns a Mode. AI assistance: Claude Code. Agent-Session: b28d94c6-2325-41ab-9fab-e9027acc5b09 Agent-Session: 01a0e9c2-2c41-7062-b484-92e030c4ab9c --- src/options.rs | 189 ++++++++++++++++++++++++------------------------- 1 file changed, 94 insertions(+), 95 deletions(-) diff --git a/src/options.rs b/src/options.rs index e960c3d4e..aa09873dc 100644 --- a/src/options.rs +++ b/src/options.rs @@ -5,7 +5,7 @@ use std::ffi::OsStr; use std::fmt::Display; use std::path::{Path, PathBuf}; -use clap::{crate_description, Arg, ArgAction, Command}; +use clap::{crate_description, Parser}; use crate::exit_codes::EXIT_BAD_ARGUMENTS; use crate::parse::guess_language::{language_override_from_name, LanguageOverride}; @@ -44,53 +44,52 @@ impl Default for DiffOptions { } } -fn app() -> clap::Command { - Command::new("diffr") - // Show options in alphabetical order, rather than in - // declaration order. - .next_display_order(None) - .override_usage(USAGE) - .version(env!("CARGO_PKG_VERSION")) - .long_version(VERSION.as_str()) - .about(crate_description!()) - .arg( - Arg::new("dump-syntax") - .long("dump-syntax") - .value_name("PATH") - .action(ArgAction::Set) - .long_help( - "Parse a single file with tree-sitter and display the diffr syntax tree.", - ).help_heading("DEBUG OPTIONS"), - ) - .arg( - Arg::new("dump-syntax-dot") - .long("dump-syntax-dot") - .value_name("PATH") - .action(ArgAction::Set) - .long_help( - "Parse a single file with tree-sitter and display the diffr syntax tree, as a DOT graph.", - ).help_heading("DEBUG OPTIONS"), - ) - .arg( - Arg::new("dump-ts") - .long("dump-ts") - .value_name("PATH") - .action(ArgAction::Set) - .long_help( - "Parse a single file with tree-sitter and display the tree-sitter parse tree.", - ).help_heading("DEBUG OPTIONS"), - ) - .arg( - Arg::new("ignore-comments").long("ignore-comments") - .action(ArgAction::SetTrue) - .env("DFT_IGNORE_COMMENTS") - .help("Don't consider comments when diffing.") - ) - .arg( - Arg::new("override").long("override") - .value_name("GLOB:NAME") - .action(ArgAction::Append) - .help(concat!("Associate this glob pattern with this language, overriding normal language detection. For example: +/// `diffr debug`: syntax dumps and the language list. +#[derive(Parser)] +#[command( + name = "diffr", + // Show options in alphabetical order, rather than in + // declaration order. + next_display_order = None, + override_usage = USAGE, + version = env!("CARGO_PKG_VERSION"), + long_version = VERSION.as_str(), + about = crate_description!(), + arg_required_else_help = true +)] +pub(crate) struct DebugArgs { + #[arg( + long, + value_name = "PATH", + long_help = "Parse a single file with tree-sitter and display the diffr syntax tree.", + help_heading = "DEBUG OPTIONS" + )] + dump_syntax: Option, + #[arg( + long, + value_name = "PATH", + long_help = "Parse a single file with tree-sitter and display the diffr syntax tree, as a DOT graph.", + help_heading = "DEBUG OPTIONS" + )] + dump_syntax_dot: Option, + #[arg( + long, + value_name = "PATH", + long_help = "Parse a single file with tree-sitter and display the tree-sitter parse tree.", + help_heading = "DEBUG OPTIONS" + )] + dump_ts: Option, + #[arg( + long, + env = "DFT_IGNORE_COMMENTS", + help = "Don't consider comments when diffing." + )] + ignore_comments: bool, + #[arg( + long = "override", + value_name = "GLOB:NAME", + env = "DFT_OVERRIDE", + help = concat!("Associate this glob pattern with this language, overriding normal language detection. For example: $ ", env!("CARGO_BIN_NAME"), " debug --override='*.c:C++' --dump-syntax file.c @@ -106,15 +105,14 @@ $ export DFT_OVERRIDE='CustomFile:json' $ export DFT_OVERRIDE_1='*.c:text' $ export DFT_OVERRIDE_2='*.js:javascript jsx' -When multiple overrides are specified, the first matching override wins.")) - .env("DFT_OVERRIDE") - ) - .arg( - Arg::new("list-languages").long("list-languages") - .action(ArgAction::SetTrue) - .help("Print all the languages supported by diffr, along with their recognised extensions.") - ) - .arg_required_else_help(true) +When multiple overrides are specified, the first matching override wins.") + )] + overrides: Vec, + #[arg( + long, + help = "Print all the languages supported by diffr, along with their recognised extensions." + )] + list_languages: bool, } #[derive(Eq, PartialEq, Debug)] @@ -235,51 +233,50 @@ fn parse_overrides_or_die(raw_overrides: &[String]) -> Vec<(LanguageOverride, Ve /// Parse CLI arguments passed to the binary. pub(crate) fn parse_args() -> Mode { - let matches = app().get_matches_from(std::env::args_os().skip(1)); - - let ignore_comments = matches.get_flag("ignore-comments"); + DebugArgs::parse_from(std::env::args_os().skip(1)).mode() +} - let mut raw_overrides: Vec = vec![]; - if let Some(overrides) = matches.get_many("override") { - raw_overrides = overrides.cloned().collect(); - } - for i in 1..=9 { - if let Ok(value) = env::var(format!("DFT_OVERRIDE_{}", i)) { - raw_overrides.push(value); +impl DebugArgs { + fn mode(&self) -> Mode { + let mut raw_overrides = self.overrides.clone(); + for i in 1..=9 { + if let Ok(value) = env::var(format!("DFT_OVERRIDE_{}", i)) { + raw_overrides.push(value); + } } - } - let language_overrides = parse_overrides_or_die(&raw_overrides); + let language_overrides = parse_overrides_or_die(&raw_overrides); - if matches.get_flag("list-languages") { - return Mode::ListLanguages { language_overrides }; - } + if self.list_languages { + return Mode::ListLanguages { language_overrides }; + } - if let Some(path) = matches.get_one::("dump-syntax") { - return Mode::DumpSyntax { - path: path.to_owned(), - ignore_comments, - language_overrides, - }; - } + if let Some(path) = &self.dump_syntax { + return Mode::DumpSyntax { + path: path.to_owned(), + ignore_comments: self.ignore_comments, + language_overrides, + }; + } - if let Some(path) = matches.get_one::("dump-syntax-dot") { - return Mode::DumpSyntaxDot { - path: path.to_owned(), - ignore_comments, - language_overrides, - }; - } + if let Some(path) = &self.dump_syntax_dot { + return Mode::DumpSyntaxDot { + path: path.to_owned(), + ignore_comments: self.ignore_comments, + language_overrides, + }; + } - if let Some(path) = matches.get_one::("dump-ts") { - return Mode::DumpTreeSitter { - path: path.to_owned(), - language_overrides, - }; - } + if let Some(path) = &self.dump_ts { + return Mode::DumpTreeSitter { + path: path.to_owned(), + language_overrides, + }; + } - eprintln!("Pass one of --dump-syntax, --dump-syntax-dot, --dump-ts or --list-languages."); - std::process::exit(EXIT_BAD_ARGUMENTS); + eprintln!("Pass one of --dump-syntax, --dump-syntax-dot, --dump-ts or --list-languages."); + std::process::exit(EXIT_BAD_ARGUMENTS); + } } /// Try to work out the width of the terminal we're on, or fall back @@ -309,11 +306,13 @@ pub(crate) fn detect_terminal_width() -> usize { #[cfg(test)] mod tests { + use clap::CommandFactory; + use super::*; #[test] fn test_app() { - app().debug_assert(); + DebugArgs::command().debug_assert(); } #[test]