diff --git a/src/cli.rs b/src/cli.rs index 07a7c7d44..f76e4b9be 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use bat::assets::HighlightingAssets; +use clap::error::Error; use clap::{ArgMatches, ColorChoice, CommandFactory, FromArgMatches, Parser, ValueEnum, ValueHint}; use clap_complete::Shell; use console::Term; @@ -16,6 +17,7 @@ use crate::config::delta_unreachable; use crate::env::DeltaEnv; use crate::git_config::GitConfig; use crate::options; +use crate::subcommands; use crate::utils; use crate::utils::bat::output::PagingMode; @@ -1217,23 +1219,12 @@ pub enum DetectDarkLight { #[derive(Debug)] pub enum Call { Delta(T), + DeltaDiff(T, PathBuf, PathBuf), + SubCommand(T, subcommands::SubCommand), Help(String), Version(String), } -// Custom conversion because a) generic TryFrom is not possible and -// b) the Delta(T) variant can't be converted. -impl Call { - fn try_convert(self) -> Option> { - use Call::*; - match self { - Delta(_) => None, - Help(help) => Some(Help(help)), - Version(ver) => Some(Version(ver)), - } - } -} - impl Opt { fn handle_help_and_version(args: &[OsString]) -> Call { match Self::command().try_get_matches_from(args) { @@ -1283,9 +1274,34 @@ impl Opt { Call::Help(help) } Err(e) => { - e.exit(); + // Calls `e.exit()` if error persists. + let (matches, subcmd) = subcommands::extract(args, e); + Call::SubCommand(matches, subcmd) + } + Ok(matches) => { + // subcommands take precedence over diffs + let minus_file = matches.get_one::("minus_file").map(PathBuf::from); + if let Some(subcmd) = &minus_file { + if let Some(arg) = subcmd.to_str() { + if subcommands::SUBCOMMANDS.contains(&arg) { + let unreachable_error = + Error::new(clap::error::ErrorKind::InvalidSubcommand); + let (matches, subcmd) = subcommands::extract(args, unreachable_error); + return Call::SubCommand(matches, subcmd); + } + } + } + + match ( + minus_file, + matches.get_one::("plus_file").map(PathBuf::from), + ) { + (Some(minus_file), Some(plus_file)) => { + Call::DeltaDiff(matches, minus_file, plus_file) + } + _ => Call::Delta(matches), + } } - Ok(matches) => Call::Delta(matches), } } @@ -1293,7 +1309,7 @@ impl Opt { args: Vec, env: &DeltaEnv, assets: HighlightingAssets, - ) -> Call { + ) -> (Call<()>, Option) { #[cfg(test)] // Set argv[0] when called in tests: let args = { @@ -1301,13 +1317,12 @@ impl Opt { args.insert(0, OsString::from("delta")); args }; - let matches = match Self::handle_help_and_version(&args) { - Call::Delta(t) => t, - msg => { - return msg - .try_convert() - .unwrap_or_else(|| panic!("Call<_> conversion failed")) - } + let (matches, call) = match Self::handle_help_and_version(&args) { + Call::Delta(t) => (t, Call::Delta(())), + Call::DeltaDiff(t, a, b) => (t, Call::DeltaDiff((), a, b)), + Call::SubCommand(t, cmd) => (t, Call::SubCommand((), cmd)), + Call::Help(help) => return (Call::Help(help), None), + Call::Version(ver) => return (Call::Version(ver), None), }; let mut final_config = if *matches.get_one::("no_gitconfig").unwrap_or(&false) { @@ -1323,12 +1338,8 @@ impl Opt { } } - Call::Delta(Self::from_clap_and_git_config( - env, - matches, - final_config, - assets, - )) + let opt = Self::from_clap_and_git_config(env, matches, final_config, assets); + (call, Some(opt)) } pub fn from_iter_and_git_config( @@ -1399,9 +1410,3 @@ lazy_static! { .into_iter() .collect(); } - -// Call::Help(format!( -// "foo\nbar\nbatz\n{}\n{}", -// help.replace("Options:", "well well\n\nOptions:"), -// h2 -// )) diff --git a/src/main.rs b/src/main.rs index d8372a9dd..b8b86a95b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,13 +25,15 @@ mod subcommands; mod tests; use std::ffi::OsString; -use std::io::{self, Cursor, ErrorKind, IsTerminal, Write}; -use std::process; +use std::io::{self, BufRead, Cursor, ErrorKind, IsTerminal, Write}; +use std::process::{self, Command, Stdio}; use bytelines::ByteLinesReader; use crate::cli::Call; +use crate::config::delta_unreachable; use crate::delta::delta; +use crate::subcommands::{SubCmdKind, SubCommand}; use crate::utils::bat::assets::list_languages; use crate::utils::bat::output::{OutputType, PagingMode}; @@ -67,7 +69,7 @@ fn main() -> std::io::Result<()> { ctrlc::set_handler(|| {}) .unwrap_or_else(|err| eprintln!("Failed to set ctrl-c handler: {err}")); let exit_code = run_app(std::env::args_os().collect::>(), None)?; - // when you call process::exit, no destructors are called, so we want to do it only once, here + // when you call process::exit, no drop impls are called, so we want to do it only once, here process::exit(exit_code); } @@ -81,19 +83,16 @@ pub fn run_app( ) -> std::io::Result { let env = env::DeltaEnv::init(); let assets = utils::bat::assets::load_highlighting_assets(); - let opt = cli::Opt::from_args_and_git_config(args, &env, assets); + let (call, opt) = cli::Opt::from_args_and_git_config(args, &env, assets); - let opt = match opt { - Call::Version(msg) => { - writeln!(std::io::stdout(), "{}", msg.trim_end())?; - return Ok(0); - } - Call::Help(msg) => { - OutputType::oneshot_write(msg)?; - return Ok(0); - } - Call::Delta(opt) => opt, - }; + if let Call::Version(msg) = call { + writeln!(std::io::stdout(), "{}", msg.trim_end())?; + return Ok(0); + } else if let Call::Help(msg) = call { + OutputType::oneshot_write(msg)?; + return Ok(0); + } + let opt = opt.expect("Opt is set"); let subcommand_result = if let Some(shell) = opt.generate_completion { Some(subcommands::generate_completion::generate_completion_file( @@ -153,26 +152,134 @@ pub fn run_app( output_type.handle().unwrap() }; - if let (Some(minus_file), Some(plus_file)) = (&config.minus_file, &config.plus_file) { - let exit_code = subcommands::diff::diff(minus_file, plus_file, &config, &mut writer); - return Ok(exit_code); - } + let subcmd = match call { + Call::DeltaDiff(_, minus, plus) => { + match subcommands::diff::build_diff_cmd(&minus, &plus, &config) { + Err(code) => return Ok(code), + Ok(val) => val, + } + } + Call::SubCommand(_, subcmd) => subcmd, + Call::Delta(_) => SubCommand::none(), + Call::Help(_) | Call::Version(_) => delta_unreachable("help/version handled earlier"), + }; - if io::stdin().is_terminal() { - eprintln!( - "\ - The main way to use delta is to configure it as the pager for git: \ - see https://github.com/dandavison/delta#get-started. \ - You can also use delta to diff two files: `delta file_A file_B`." - ); - return Ok(config.error_exit_code); - } + if subcmd.is_none() { + // Default delta run: read input from stdin, write to stdout or pager (pager started already^). + if io::stdin().is_terminal() { + eprintln!( + "\ + The main way to use delta is to configure it as the pager for git: \ + see https://github.com/dandavison/delta#get-started. \ + You can also use delta to diff two files: `delta file_A file_B`." + ); + return Ok(config.error_exit_code); + } + + let res = delta(io::stdin().lock().byte_lines(), &mut writer, &config); - if let Err(error) = delta(io::stdin().lock().byte_lines(), &mut writer, &config) { - match error.kind() { - ErrorKind::BrokenPipe => return Ok(0), - _ => eprintln!("{error}"), + if let Err(error) = res { + match error.kind() { + ErrorKind::BrokenPipe => return Ok(0), + _ => { + eprintln!("{error}"); + return Ok(config.error_exit_code); + } + } } - }; - Ok(0) + + Ok(0) + } else { + // First start a subcommand, and process input from it to delta(). Also handle + // subcommand stderr and exit codes, e.g. for git and diff logic. + + let (subcmd_bin, subcmd_args) = subcmd.args.split_first().unwrap(); + let subcmd_kind = subcmd.kind; // for easier {} formatting + + let subcmd_bin_path = match grep_cli::resolve_binary(std::path::PathBuf::from(subcmd_bin)) { + Ok(path) => path, + Err(err) => { + eprintln!("Failed to resolve command {subcmd_bin:?}: {err}"); + return Ok(config.error_exit_code); + } + }; + + let cmd = Command::new(subcmd_bin) + .args(subcmd_args.iter()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + if let Err(err) = cmd { + eprintln!("Failed to execute the command {subcmd_bin:?}: {err}"); + return Ok(config.error_exit_code); + } + let mut cmd = cmd.unwrap(); + + let cmd_stdout = cmd.stdout.as_mut().expect("Failed to open stdout"); + let cmd_stdout_buf = io::BufReader::new(cmd_stdout); + + let res = delta(cmd_stdout_buf.byte_lines(), &mut writer, &config); + + if let Err(error) = res { + match error.kind() { + ErrorKind::BrokenPipe => return Ok(0), + _ => { + eprintln!("{error}"); + return Ok(config.error_exit_code); + } + } + }; + + let subcmd_status = cmd + .wait() + .unwrap_or_else(|_| { + delta_unreachable(&format!("{subcmd_kind} process not running.")); + }) + .code() + .unwrap_or_else(|| { + eprintln!("{subcmd_kind} process terminated without exit status."); + config.error_exit_code + }); + + let mut stderr_lines = + io::BufReader::new(cmd.stderr.expect("Failed to open stderr")).lines(); + if let Some(line1) = stderr_lines.next() { + // prefix the first error line prefixed with the called subcommand + eprintln!( + "{}: {}", + subcmd_kind, + line1.unwrap_or("".into()) + ); + } + + // On `git diff` unknown option: print first line (which is an error message) but not + // the remainder (which is the entire --help text). + if !(subcmd_status == 129 + && matches!(subcmd_kind, SubCmdKind::GitDiff | SubCmdKind::Git(_))) + { + for line in stderr_lines { + eprintln!("{}", line.unwrap_or("".into())); + } + } + + if matches!(subcmd_kind, SubCmdKind::GitDiff | SubCmdKind::Diff) && subcmd_status >= 2 { + eprintln!( + "{subcmd_kind} process failed with exit status {subcmd_status}. Command was: {}", + format_args!( + "{} {}", + subcmd_bin_path.display(), + shell_words::join( + subcmd_args + .iter() + .map(|arg0: &OsString| std::ffi::OsStr::to_string_lossy(arg0)) + ), + ) + ); + } + + Ok(subcmd_status) + } + + // `output_type` drop impl runs here } diff --git a/src/subcommands/diff.rs b/src/subcommands/diff.rs index 53a36c754..47c6bda09 100644 --- a/src/subcommands/diff.rs +++ b/src/subcommands/diff.rs @@ -1,34 +1,28 @@ -use std::io::{BufRead, ErrorKind, Write}; -use std::path::{Path, PathBuf}; -use std::process; +use std::path::Path; -use bytelines::ByteLinesReader; +use crate::config::{self}; -use crate::config::{self, delta_unreachable}; -use crate::delta; use crate::utils::git::retrieve_git_version; -#[derive(Debug, PartialEq)] -enum Differ { - GitDiff, - Diff, -} +use crate::subcommands::{SubCmdKind, SubCommand}; +use std::ffi::OsString; -/// Run `git diff` on the files provided on the command line and display the output. Fall back to +/// Build `git diff` command for the files provided on the command line. Fall back to /// `diff` if the supplied "files" use process substitution. -pub fn diff( +pub fn build_diff_cmd( minus_file: &Path, plus_file: &Path, config: &config::Config, - writer: &mut dyn Write, -) -> i32 { - use std::io::BufReader; +) -> Result { + // suppress `dead_code` warning, values are accessed via `get_one::("plus/minus_file")` + debug_assert!(config.minus_file.as_ref().unwrap() == minus_file); + debug_assert!(config.plus_file.as_ref().unwrap() == plus_file); let mut diff_args = match shell_words::split(config.diff_args.trim()) { Ok(words) => words, Err(err) => { eprintln!("Failed to parse diff args: {}: {err}", config.diff_args); - return config.error_exit_code; + return Err(config.error_exit_code); } }; // Permit e.g. -@U1 @@ -52,12 +46,12 @@ pub fn diff( || via_process_substitution(plus_file)) => { ( - Differ::GitDiff, + SubCmdKind::GitDiff, vec!["git", "diff", "--no-index", "--color"], ) } _ => ( - Differ::Diff, + SubCmdKind::Diff, if diff_args_set_unified_context(&diff_args) { vec!["diff"] } else { @@ -73,79 +67,11 @@ pub fn diff( .map(String::as_str), ); diff_cmd.push("--"); + let mut diff_cmd = diff_cmd.iter().map(OsString::from).collect::>(); + diff_cmd.push(minus_file.into()); + diff_cmd.push(plus_file.into()); - let (diff_bin, diff_cmd) = diff_cmd.split_first().unwrap(); - let diff_path = match grep_cli::resolve_binary(PathBuf::from(diff_bin)) { - Ok(path) => path, - Err(err) => { - eprintln!("Failed to resolve command '{diff_bin}': {err}"); - return config.error_exit_code; - } - }; - - let diff_process = process::Command::new(&diff_path) - .args(diff_cmd) - .args([minus_file, plus_file]) - .stdout(process::Stdio::piped()) - .stderr(process::Stdio::piped()) - .spawn(); - - if let Err(err) = diff_process { - eprintln!("Failed to execute the command '{diff_bin}': {err}"); - return config.error_exit_code; - } - let mut diff_process = diff_process.unwrap(); - - if let Err(error) = delta::delta( - BufReader::new(diff_process.stdout.take().unwrap()).byte_lines(), - writer, - config, - ) { - match error.kind() { - ErrorKind::BrokenPipe => return 0, - _ => { - eprintln!("{error}"); - return config.error_exit_code; - } - } - }; - - // Return the exit code from the diff process, so that the exit code contract of `delta file1 - // file2` is the same as that of `diff file1 file2` (i.e. 0 if same, 1 if different, >= 2 if - // error). - let code = diff_process - .wait() - .unwrap_or_else(|_| { - delta_unreachable(&format!("'{diff_bin}' process not running.")); - }) - .code() - .unwrap_or_else(|| { - eprintln!("'{diff_bin}' process terminated without exit status."); - config.error_exit_code - }); - if code >= 2 { - for line in BufReader::new(diff_process.stderr.unwrap()).lines() { - eprintln!("{}", line.unwrap_or("".into())); - if code == 129 && differ == Differ::GitDiff { - // `git diff` unknown option: print first line (which is an error message) but not - // the remainder (which is the entire --help text). - break; - } - } - eprintln!( - "'{diff_bin}' process failed with exit status {code}. Command was: {}", - format_args!( - "{} {} {} {}", - diff_path.display(), - shell_words::join(diff_cmd), - minus_file.display(), - plus_file.display() - ) - ); - config.error_exit_code - } else { - code - } + Ok(SubCommand::new(differ, diff_cmd)) } /// Do the user-supplied `diff` args set the unified context? diff --git a/src/subcommands/generic_subcmd.rs b/src/subcommands/generic_subcmd.rs new file mode 100644 index 000000000..80c9f0d46 --- /dev/null +++ b/src/subcommands/generic_subcmd.rs @@ -0,0 +1,153 @@ +use crate::cli::Opt; +use clap::CommandFactory; +use clap::{ArgMatches, Error}; +use std::ffi::OsString; + +pub const RG: &str = "rg"; +const GIT: &str = "git"; +pub const SUBCOMMANDS: &[&str] = &[RG, "show", "log", "diff", "grep", "blame", GIT]; + +#[derive(Debug, PartialEq)] +pub enum SubCmdKind { + Git(String), + GitDiff, + Diff, + Rg, + None, +} + +impl std::fmt::Display for SubCmdKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use SubCmdKind::*; + let s = match self { + Git(arg) => return formatter.write_fmt(format_args!("git {arg}")), + GitDiff => "git diff", + Diff => "diff", + Rg => "rg", + None => "", + }; + formatter.write_str(s) + } +} + +#[derive(Debug)] +pub struct SubCommand { + pub kind: SubCmdKind, + pub args: Vec, +} + +impl SubCommand { + pub fn new(kind: SubCmdKind, args: Vec) -> Self { + Self { kind, args } + } + + pub fn none() -> Self { + Self { + kind: SubCmdKind::None, + args: vec![], + } + } + + pub fn is_none(&self) -> bool { + matches!(self.kind, SubCmdKind::None) + } +} + +pub fn extract(args: &[OsString], orig_error: Error) -> (ArgMatches, SubCommand) { + for (i, arg) in args.iter().enumerate() { + let arg = if let Some(arg) = arg.to_str() { + arg + } else { + continue; + }; + if SUBCOMMANDS.contains(&arg) { + match Opt::command().try_get_matches_from(&args[..i]) { + Err(ref e) if e.kind() == clap::error::ErrorKind::DisplayVersion => { + unreachable!("version handled by caller"); + } + Err(ref e) if e.kind() == clap::error::ErrorKind::DisplayHelp => { + unreachable!("help handled by caller"); + } + Ok(matches) => { + let mut subcmd: Vec = if arg == RG { + vec![arg, "--json"] + } else { + vec!["git", "-c", "color.ui=always", arg] + } + .iter() + .map(OsString::from) + .collect(); + + if arg == GIT { + // we would build: delta git git show.. => delta git show .. + subcmd.pop(); + } + let kind = if arg == RG { + SubCmdKind::Rg + } else { + SubCmdKind::Git( + args[i..] + .first() + .map(|a| std::ffi::OsStr::to_string_lossy(a.as_ref())) + .unwrap_or("?".into()) + .to_string(), + ) + }; + subcmd.extend(args[i + 1..].iter().map(|arg| arg.into())); + return (matches, SubCommand::new(kind, subcmd)); + } + Err(_) => { + // part before the subcommand failed to parse, report that error + #[cfg(not(test))] + orig_error.exit(); + #[cfg(test)] + panic!("parse error before subcommand "); + } + } + } + } + // no valid subcommand found, exit with the original error + #[cfg(not(test))] + orig_error.exit(); + #[cfg(test)] + { + let _ = orig_error; + panic!("no subcommand found"); + } +} + +#[cfg(test)] +mod test { + use crate::ansi::explain_ansi; + use std::ffi::OsString; + use std::io::Cursor; + + #[test] + fn subcommand_rg() { + if grep_cli::resolve_binary("rg").is_err() { + return; + } + + let mut writer = Cursor::new(vec![]); + let needle = format!("{}{}", "Y40ii4RihK6", "lHiK4BDsGS").to_string(); + // --minus-style has no effect, just for cmdline parsing + let runargs = ["--minus-style", "normal", "rg", &needle, "src/", "-N", "-C", "2", "-C0"] + .iter() + .map(OsString::from) + .collect::>(); + let exit_code = crate::run_app(runargs, Some(&mut writer)).unwrap(); + let rg_output = std::str::from_utf8(writer.get_ref()).unwrap(); + let mut lines = rg_output.lines(); + // eprintln!("{}", rg_output); + assert_eq!( + r#"(purple)src/utils/process.rs(normal) "#, + explain_ansi(lines.next().expect("line 1"), false) + ); + let line2 = format!( + r#"(231) .(81)join(231)((186)"(normal 28){}(186)x"(231));"#, + needle + ); + assert_eq!(line2, explain_ansi(lines.next().expect("line 2"), false)); + assert_eq!(exit_code, 0); + } +} diff --git a/src/subcommands/mod.rs b/src/subcommands/mod.rs index bb7a1f7ca..db0baf5af 100644 --- a/src/subcommands/mod.rs +++ b/src/subcommands/mod.rs @@ -1,4 +1,4 @@ -pub mod diff; +// smaller helper subcommands: pub mod generate_completion; pub mod list_syntax_themes; pub mod parse_ansi; @@ -7,3 +7,12 @@ pub mod show_colors; pub mod show_config; pub mod show_syntax_themes; pub mod show_themes; + +// start subprocesses: +// diff (fileA, fileB), and generic subcommands +pub mod diff; +mod generic_subcmd; +pub(crate) use generic_subcmd::extract; +pub(crate) use generic_subcmd::SubCmdKind; +pub(crate) use generic_subcmd::SubCommand; +pub(crate) use generic_subcmd::SUBCOMMANDS; diff --git a/src/subcommands/show_colors.rs b/src/subcommands/show_colors.rs index 3a5cabc25..e9aab5277 100644 --- a/src/subcommands/show_colors.rs +++ b/src/subcommands/show_colors.rs @@ -18,7 +18,7 @@ pub fn show_colors() -> std::io::Result<()> { let assets = utils::bat::assets::load_highlighting_assets(); let opt = match cli::Opt::from_args_and_git_config(args, &env, assets) { - cli::Call::Delta(opt) => opt, + (cli::Call::Delta(_), Some(opt)) => opt, _ => panic!("non-Delta Call variant should not occur here"), };