hgrep 0.3.1

hgrep is a grep tool with human-friendly search output. This is similar to `-C` option of `grep` command, but its output is enhanced with syntax highlighting focusing on human readable outputs.
Documentation
#![allow(clippy::uninlined_format_args)]

use anyhow::{Context, Result};
use clap::{Arg, ArgAction, ArgMatches, Command};
use hgrep::grep::BufReadExt;
use hgrep::printer::{PrinterOptions, TextWrapMode};
use std::cmp;
use std::env;
use std::io;
use std::process;

#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

#[cfg(feature = "ripgrep")]
use hgrep::ripgrep;

#[cfg(feature = "bat-printer")]
use hgrep::bat::BatPrinter;

#[cfg(feature = "syntect-printer")]
use hgrep::syntect::SyntectPrinter;

const COMPLETION_SHELLS: [&str; 5] = ["bash", "zsh", "powershell", "fish", "elvish"];

fn command() -> Command {
    #[cfg(feature = "syntect-printer")]
    const DEFAULT_PRINTER: &str = "syntect";

    #[cfg(all(not(feature = "syntect-printer"), feature = "bat-printer"))]
    const DEFAULT_PRINTER: &str = "bat";

    let cmd = Command::new("hgrep")
        .version(env!("CARGO_PKG_VERSION"))
        .about(
            "hgrep is grep with human-friendly search output. It eats an output of `grep -nH` and prints the matches \
            with syntax-highlighted code snippets.\n\n\
            $ grep -nH pattern -R . | hgrep\n\n\
            For more details, visit https://github.com/rhysd/hgrep"
        )
        .arg(
            Arg::new("min-context")
                .short('c')
                .long("min-context")
                .num_args(1)
                .value_name("NUM")
                .default_value("3")
                .help("Minimum lines of leading and trailing context surrounding each match"),
        )
        .arg(
            Arg::new("max-context")
                .short('C')
                .long("max-context")
                .num_args(1)
                .value_name("NUM")
                .default_value("6")
                .help("Maximum lines of leading and trailing context surrounding each match"),
        )
        .arg(
            Arg::new("no-grid")
                .short('G')
                .long("no-grid")
                .action(ArgAction::SetTrue)
                .help("Remove borderlines for more compact output"),
        )
        .arg(
            Arg::new("grid")
                .long("grid")
                .action(ArgAction::SetTrue)
                .help("Add borderlines to output. This flag is an opposite of --no-grid"),
        )
        .arg(
            Arg::new("tab")
                .long("tab")
                .num_args(1)
                .value_name("NUM")
                .default_value("4")
                .help("Number of spaces for tab character. Set 0 to pass tabs through directly"),
        )
        .arg(
            Arg::new("theme")
                .long("theme")
                .num_args(1)
                .value_name("THEME")
                .help("Theme for syntax highlighting. Use --list-themes flag to print the theme list"),
        )
        .arg(
            Arg::new("list-themes")
                .long("list-themes")
                .action(ArgAction::SetTrue)
                .help("List all available theme names and their samples. Samples show the output where 'let' is searched. The names can be used at --theme option"),
        )
        .arg(
            Arg::new("printer")
                .short('p')
                .long("printer")
                .value_name("PRINTER")
                .default_value(DEFAULT_PRINTER)
                .value_parser([
                    #[cfg(feature = "syntect-printer")]
                    "syntect",
                    #[cfg(feature = "bat-printer")]
                    "bat",
                ])
                .help("Printer to print the match results"),
        )
        .arg(
            Arg::new("term-width")
                .long("term-width")
                .num_args(1)
                .value_name("NUM")
                .help("Width (number of characters) of terminal window"),
        ).arg(
            Arg::new("wrap")
                .long("wrap")
                .num_args(1)
                .value_name("MODE")
                .default_value("char")
                .value_parser(["char", "never"])
                .ignore_case(true)
                .help("Text-wrapping mode. 'char' enables character-wise text-wrapping. 'never' disables text-wrapping")
        ).arg(
            Arg::new("first-only")
                .short('f')
                .long("first-only")
                .action(ArgAction::SetTrue)
                .help("Show only the first code snippet per file")
        )
        .arg(
            Arg::new("generate-completion-script")
                .long("generate-completion-script")
                .num_args(1)
                .value_name("SHELL")
                .value_parser(COMPLETION_SHELLS)
                .ignore_case(true)
                .help("Print completion script for SHELL to stdout"),
        )
        .arg(
            Arg::new("generate-man-page")
                .long("generate-man-page")
                .action(ArgAction::SetTrue)
                .help("Print man page to stdout"),
        );

    #[cfg(feature = "bat-printer")]
    let cmd = cmd.arg(
        Arg::new("custom-assets")
            .long("custom-assets")
            .action(ArgAction::SetTrue)
            .help("Load bat's custom assets. Note that this flag may not work with some version of `bat` command. This flag is only for bat printer"),
    );

    #[cfg(feature = "syntect-printer")]
    let cmd = cmd
        .arg(
            Arg::new("background")
                .long("background")
                .action(ArgAction::SetTrue)
                .help("Paint background colors. This flag is only for syntect printer"),
        )
        .arg(
            Arg::new("ascii-lines")
                .long("ascii-lines")
                .action(ArgAction::SetTrue)
                .help(
                    "Use ASCII characters for drawing border lines instead of Unicode characters",
                ),
        );

    #[cfg(feature = "ripgrep")]
    let cmd = cmd
            .about(
                "hgrep is grep with human-friendly search output. It eats an output of `grep -nH` and prints the \
                matches with syntax-highlighted code snippets.\n\n\
                $ grep -nH pattern -R . | hgrep\n\n\
                hgrep has its builtin subset of ripgrep. Its search output and performance are better than reading \
                `grep -nH`.\n\n\
                $ hgrep pattern\n\n\
                For more details, visit https://github.com/rhysd/hgrep"
            )
            .override_usage("hgrep [FLAGS] [OPTIONS] [PATTERN [PATH...]]")
            .arg(
                Arg::new("no-ignore")
                    .long("no-ignore")
                    .action(ArgAction::SetTrue)
                    .help("Don't respect ignore files (.gitignore, .ignore, etc.)"),
            )
            .arg(
                Arg::new("ignore-case")
                    .short('i')
                    .long("ignore-case")
                    .action(ArgAction::SetTrue)
                    .overrides_with("smart-case")
                    .help("When this flag is provided, the given pattern will be searched case insensitively. This flag overrides --smart-case"),
            )
            .arg(
                Arg::new("smart-case")
                    .short('S')
                    .long("smart-case")
                    .action(ArgAction::SetTrue)
                    .overrides_with("ignore-case")
                    .help("Search case insensitively if the pattern is all lowercase. Search case sensitively otherwise. This flag overrides --ignore-case"),
            )
            .arg(
                Arg::new("hidden")
                    .short('.')
                    .long("hidden")
                    .action(ArgAction::SetTrue)
                    .help("Search hidden files and directories. By default, hidden files and directories are skipped"),
            )
            .arg(
                Arg::new("glob")
                    .short('g')
                    .long("glob")
                    .action(ArgAction::Append)
                    .num_args(1)
                    .value_name("GLOB")
                    .allow_hyphen_values(true)
                    .help("Include or exclude files and directories for searching that match the given glob"),
            )
            .arg(
                Arg::new("glob-case-insensitive")
                    .long("glob-case-insensitive")
                    .action(ArgAction::SetTrue)
                    .help("Process glob patterns given with the -g/--glob flag case insensitively"),
            )
            .arg(
                Arg::new("fixed-strings")
                    .short('F')
                    .long("fixed-strings")
                    .action(ArgAction::SetTrue)
                    .help("Treat the pattern as a literal string instead of a regular expression"),
            )
            .arg(
                Arg::new("word-regexp")
                    .short('w')
                    .long("word-regexp")
                    .action(ArgAction::SetTrue)
                    .overrides_with("line-regexp")
                    .help("Only show matches surrounded by word boundaries. This flag overrides --line-regexp"),
            )
            .arg(
                Arg::new("follow-symlink")
                    .short('L')
                    .long("follow")
                    .action(ArgAction::SetTrue)
                    .help("When this flag is enabled, hgrep will follow symbolic links while traversing directories"),
            )
            .arg(
                Arg::new("multiline")
                    .short('U')
                    .long("multiline")
                    .action(ArgAction::SetTrue)
                    .help("Enable matching across multiple lines"),
            )
            .arg(
                Arg::new("multiline-dotall")
                    .long("multiline-dotall")
                    .action(ArgAction::SetTrue)
                    .help("Enable \"dot all\" in your regex pattern, which causes '.' to match newlines when multiline searching is enabled"),
            )
            .arg(
                Arg::new("crlf")
                    .long("crlf")
                    .action(ArgAction::SetTrue)
                    .help(r"When enabled, hgrep will treat CRLF ('\r\n') as a line terminator instead of just '\n'. This flag is useful on Windows"),
            )
            .arg(
                Arg::new("mmap")
                    .long("mmap")
                    .action(ArgAction::SetTrue)
                    .help("Search using memory maps when possible. mmap is disabled by default unlike ripgrep"),
            )
            .arg(
                Arg::new("max-count")
                    .short('m')
                    .long("max-count")
                    .num_args(1)
                    .value_name("NUM")
                    .help("Limit the number of matching lines per file searched to NUM"),
            )
            .arg(
                Arg::new("max-depth")
                    .long("max-depth")
                    .num_args(1)
                    .value_name("NUM")
                    .help("Limit the depth of directory traversal to NUM levels beyond the paths given"),
            )
            .arg(
                Arg::new("line-regexp")
                    .short('x')
                    .long("line-regexp")
                    .action(ArgAction::SetTrue)
                    .overrides_with("word-regexp")
                    .help("Only show matches surrounded by line boundaries. This is equivalent to putting ^...$ around the search pattern. This flag overrides --word-regexp"),
            )
            .arg(
                Arg::new("pcre2")
                    .short('P')
                    .long("pcre2")
                    .action(ArgAction::SetTrue)
                    .help("When this flag is present, hgrep will use the PCRE2 regex engine instead of its default regex engine"),
            )
            .arg(
                Arg::new("type")
                    .short('t')
                    .long("type")
                    .num_args(1)
                    .value_name("TYPE")
                    .action(clap::ArgAction::Append)
                    .help("Only search files matching TYPE. This option is repeatable. --type-list can print the list of types"),
            )
            .arg(
                Arg::new("type-not")
                    .short('T')
                    .long("type-not")
                    .num_args(1)
                    .value_name("TYPE")
                    .action(clap::ArgAction::Append)
                    .help("Do not search files matching TYPE. Inverse of --type. This option is repeatable. --type-list can print the list of types"),
            )
            .arg(
                Arg::new("type-list")
                    .long("type-list")
                    .action(ArgAction::SetTrue)
                    .help("Show all supported file types and their corresponding globs"),
            )
            .arg(
                Arg::new("max-filesize")
                    .long("max-filesize")
                    .num_args(1)
                    .value_name("NUM+SUFFIX?")
                    .help("Ignore files larger than NUM in size. This does not apply to directories.The input format accepts suffixes of K, M or G which correspond to kilobytes, megabytes and gigabytes, respectively. If no suffix is provided the input is treated as bytes"),
            )
            .arg(
                Arg::new("invert-match")
                    .short('v')
                    .long("invert-match")
                    .action(ArgAction::SetTrue)
                    .help("Invert matching. Show lines that do not match the given pattern"),
            )
            .arg(
                Arg::new("one-file-system")
                    .long("one-file-system")
                    .action(ArgAction::SetTrue)
                    .help("When enabled, the search will not cross file system boundaries relative to where it started from"),
            )
            .arg(
                Arg::new("no-unicode")
                    .long("no-unicode")
                    .action(ArgAction::SetTrue)
                    .help("Disable unicode-aware regular expression matching"),
            )
            .arg(
                Arg::new("regex-size-limit")
                    .long("regex-size-limit")
                    .num_args(1)
                    .value_name("NUM+SUFFIX?")
                    .help("The upper size limit of the compiled regex. The default limit is 10M. For the size suffixes, see --max-filesize"),
            )
            .arg(
                Arg::new("dfa-size-limit")
                    .long("dfa-size-limit")
                    .num_args(1)
                    .value_name("NUM+SUFFIX?")
                    .help("The upper size limit of the regex DFA. The default limit is 10M. For the size suffixes, see --max-filesize"),
            )
            .arg(
                Arg::new("PATTERN")
                    .help("Pattern to search. Regular expression is available"),
            )
            .arg(
                Arg::new("PATH")
                    .help("Paths to search")
                    .num_args(0..)
                    .value_hint(clap::ValueHint::AnyPath)
                    .value_parser(clap::builder::ValueParser::path_buf()),
            );

    cmd
}

fn generate_completion_script<W: io::Write>(shell: &str, out: &mut W) {
    use clap_complete::generate;
    use clap_complete::shells::*;

    let mut cmd = command();
    if shell.eq_ignore_ascii_case("bash") {
        generate(Bash, &mut cmd, "hgrep", out)
    } else if shell.eq_ignore_ascii_case("zsh") {
        generate(Zsh, &mut cmd, "hgrep", out)
    } else if shell.eq_ignore_ascii_case("powershell") {
        generate(PowerShell, &mut cmd, "hgrep", out)
    } else if shell.eq_ignore_ascii_case("fish") {
        generate(Fish, &mut cmd, "hgrep", out)
    } else if shell.eq_ignore_ascii_case("elvish") {
        generate(Elvish, &mut cmd, "hgrep", out)
    } else {
        unreachable!() // SHELL argument was validated by clap
    }
}

#[cfg(feature = "ripgrep")]
fn build_ripgrep_config(
    min_context: u64,
    max_context: u64,
    matches: &ArgMatches,
) -> Result<ripgrep::Config<'_>> {
    let mut config = ripgrep::Config::default();
    config
        .min_context(min_context)
        .max_context(max_context)
        .no_ignore(matches.get_flag("no-ignore"))
        .hidden(matches.get_flag("hidden"))
        .case_insensitive(matches.get_flag("ignore-case"))
        .smart_case(matches.get_flag("smart-case"))
        .glob_case_insensitive(matches.get_flag("glob-case-insensitive"))
        .pcre2(matches.get_flag("pcre2")) // must be before fixed_string
        .fixed_strings(matches.get_flag("fixed-strings"))
        .word_regexp(matches.get_flag("word-regexp"))
        .follow_symlink(matches.get_flag("follow-symlink"))
        .multiline(matches.get_flag("multiline"))
        .crlf(matches.get_flag("crlf"))
        .multiline_dotall(matches.get_flag("multiline-dotall"))
        .mmap(matches.get_flag("mmap"))
        .line_regexp(matches.get_flag("line-regexp"))
        .invert_match(matches.get_flag("invert-match"))
        .one_file_system(matches.get_flag("one-file-system"))
        .no_unicode(matches.get_flag("no-unicode"));

    if let Some(globs) = matches.get_many::<String>("glob") {
        config.globs(globs.map(String::as_str));
    }

    if let Some(num) = matches.get_one::<String>("max-count") {
        let num = num
            .parse()
            .context("could not parse --max-count option value as unsigned integer")?;
        config.max_count(num);
    }

    if let Some(num) = matches.get_one::<String>("max-depth") {
        let num = num
            .parse()
            .context("could not parse --max-depth option value as unsigned integer")?;
        config.max_depth(num);
    }

    if let Some(size) = matches.get_one::<String>("max-filesize") {
        config
            .max_filesize(size)
            .context("could not parse --max-filesize option value as file size string")?;
    }

    if let Some(limit) = matches.get_one::<String>("regex-size-limit") {
        config
            .regex_size_limit(limit)
            .context("could not parse --regex-size-limit option value as size string")?;
    }

    if let Some(limit) = matches.get_one::<String>("dfa-size-limit") {
        config
            .dfa_size_limit(limit)
            .context("could not parse --dfa-size-limit option value as size string")?;
    }

    let types = matches.get_many::<String>("type");
    if let Some(types) = types {
        config.types(types.map(String::as_str));
    }

    let types_not = matches.get_many::<String>("type-not");
    if let Some(types_not) = types_not {
        config.types_not(types_not.map(String::as_str));
    }

    Ok(config)
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum PrinterKind {
    #[cfg(feature = "bat-printer")]
    Bat,
    #[cfg(feature = "syntect-printer")]
    Syntect,
}

fn run(matches: ArgMatches) -> Result<bool> {
    if let Some(shell) = matches.get_one::<String>("generate-completion-script") {
        let stdout = io::stdout();
        generate_completion_script(shell, &mut stdout.lock());
        return Ok(true);
    }

    if matches.get_flag("generate-man-page") {
        let man = clap_mangen::Man::new(command());
        let stdout = io::stdout();
        man.render(&mut stdout.lock())?;
        return Ok(true);
    }

    #[allow(unused_variables)] // printer_kind is unused when syntect-printer is disabled for now
    let printer_kind = match matches.get_one::<String>("printer").unwrap().as_str() {
        #[cfg(feature = "bat-printer")]
        "bat" => PrinterKind::Bat,
        #[cfg(not(feature = "bat-printer"))]
        "bat" => anyhow::bail!("--printer bat is not available because 'bat-printer' feature was disabled at compilation"),
        #[cfg(feature = "syntect-printer")]
        "syntect" => PrinterKind::Syntect,
        #[cfg(not(feature = "syntect-printer"))]
        "syntect" => anyhow::bail!("--printer syntect is not available because 'syntect-printer' feature was disabled at compilation"),
        p => unreachable!(), // Argument paraser already checked this case
    };

    let min_context = matches
        .get_one::<String>("min-context")
        .unwrap()
        .parse()
        .context("could not parse \"min-context\" option value as unsigned integer")?;
    let max_context = matches
        .get_one::<String>("max-context")
        .unwrap()
        .parse()
        .context("could not parse \"max-context\" option value as unsigned integer")?;
    let max_context = cmp::max(min_context, max_context);

    let mut printer_opts = PrinterOptions::default();
    if let Some(width) = matches.get_one::<String>("tab") {
        printer_opts.tab_width = width
            .parse()
            .context("could not parse \"tab\" option value as unsigned integer")?;
    }

    #[cfg(feature = "bat-printer")]
    let theme_env = env::var("BAT_THEME").ok();
    #[cfg(feature = "bat-printer")]
    if printer_kind == PrinterKind::Bat {
        if let Some(var) = &theme_env {
            printer_opts.theme = Some(var);
        }
    }
    if let Some(theme) = matches.get_one::<String>("theme") {
        printer_opts.theme = Some(theme);
    }

    let is_grid = matches.get_flag("grid");
    #[cfg(feature = "bat-printer")]
    if printer_kind == PrinterKind::Bat {
        if let Ok("plain" | "header" | "numbers") =
            env::var("BAT_STYLE").as_ref().map(String::as_str)
        {
            if !is_grid {
                printer_opts.grid = false;
            }
        }
    }
    if matches.get_flag("no-grid") && !is_grid {
        printer_opts.grid = false;
    }

    if let Some(width) = matches.get_one::<String>("term-width") {
        let width = width
            .parse()
            .context("could not parse \"term-width\" option value as unsigned integer")?;
        printer_opts.term_width = width;
        if width < 10 {
            anyhow::bail!("Too small value at --term-width option ({} < 10)", width);
        }
    }

    if let Some(mode) = matches.get_one::<String>("wrap") {
        if mode.eq_ignore_ascii_case("never") {
            printer_opts.text_wrap = TextWrapMode::Never;
        } else if mode.eq_ignore_ascii_case("char") {
            printer_opts.text_wrap = TextWrapMode::Char;
        } else {
            unreachable!(); // Option value was validated by clap
        }
    }

    if matches.get_flag("first-only") {
        printer_opts.first_only = true;
    }

    #[cfg(feature = "syntect-printer")]
    {
        if matches.get_flag("background") {
            printer_opts.background_color = true;
            #[cfg(feature = "bat-printer")]
            if printer_kind == PrinterKind::Bat {
                anyhow::bail!("--background flag is only available for syntect printer since bat does not support painting background colors");
            }
        }

        if matches.get_flag("ascii-lines") {
            printer_opts.ascii_lines = true;
            #[cfg(feature = "bat-printer")]
            if printer_kind == PrinterKind::Bat {
                anyhow::bail!("--ascii-lines flag is only available for syntect printer since bat does not support this feature");
            }
        }
    }

    #[cfg(feature = "bat-printer")]
    if matches.get_flag("custom-assets") {
        printer_opts.custom_assets = true;
        #[cfg(feature = "syntect-printer")]
        if printer_kind == PrinterKind::Syntect {
            anyhow::bail!("--custom-assets flag is only available for bat printer");
        }
    }

    if matches.get_flag("list-themes") {
        #[cfg(feature = "syntect-printer")]
        if printer_kind == PrinterKind::Syntect {
            hgrep::syntect::list_themes(io::stdout().lock(), &printer_opts)?;
            return Ok(true);
        }

        #[cfg(feature = "bat-printer")]
        if printer_kind == PrinterKind::Bat {
            BatPrinter::new(printer_opts).list_themes()?;
            return Ok(true);
        }

        unreachable!();
    }

    #[cfg(feature = "ripgrep")]
    if matches.get_flag("type-list") {
        let config = build_ripgrep_config(min_context, max_context, &matches)?;
        config.print_types(io::stdout().lock())?;
        return Ok(true);
    }

    #[cfg(feature = "ripgrep")]
    if let Some(pattern) = matches.get_one::<String>("PATTERN") {
        use std::path::PathBuf;

        let paths = matches
            .get_many::<PathBuf>("PATH")
            .map(|p| p.map(PathBuf::as_path));
        let config = build_ripgrep_config(min_context, max_context, &matches)?;

        #[cfg(feature = "syntect-printer")]
        if printer_kind == PrinterKind::Syntect {
            let printer = SyntectPrinter::with_stdout(printer_opts)?;
            return ripgrep::grep(printer, pattern, paths, config);
        }

        #[cfg(feature = "bat-printer")]
        if printer_kind == PrinterKind::Bat {
            let printer = std::sync::Mutex::new(BatPrinter::new(printer_opts));
            return ripgrep::grep(printer, pattern, paths, config);
        }

        unreachable!();
    }

    #[cfg(feature = "syntect-printer")]
    if printer_kind == PrinterKind::Syntect {
        use hgrep::printer::Printer;
        use rayon::prelude::*;
        let printer = SyntectPrinter::with_stdout(printer_opts)?;
        return io::BufReader::new(io::stdin())
            .grep_lines()
            .chunks_per_file(min_context, max_context)
            .par_bridge()
            .map(|file| {
                printer.print(file?)?;
                Ok(true)
            })
            .try_reduce(|| false, |a, b| Ok(a || b));
    }

    #[cfg(feature = "bat-printer")]
    if printer_kind == PrinterKind::Bat {
        let mut found = false;
        let printer = BatPrinter::new(printer_opts);
        let stdin = io::stdin();
        for f in io::BufReader::new(stdin.lock())
            .grep_lines()
            .chunks_per_file(min_context, max_context)
        {
            printer.print(f?)?;
            found = true;
        }
        return Ok(found);
    }

    unreachable!();
}

fn main() {
    #[cfg(windows)]
    if let Err(code) = ansi_term::enable_ansi_support() {
        eprintln!(
            "ANSI color support could not be enabled with error code {}",
            code
        );
        process::exit(2);
    }

    let status = match run(command().get_matches()) {
        Ok(true) => 0,
        Ok(false) => 1,
        Err(err) => {
            eprintln!("\x1b[1;91merror:\x1b[0m {}", err);
            for err in err.chain().skip(1) {
                eprintln!("  Caused by: {}", err);
            }
            2
        }
    };

    process::exit(status);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(not(windows))]
    const SNAPSHOT_DIR: &str = "../testdata/snapshots";
    #[cfg(windows)]
    const SNAPSHOT_DIR: &str = r#"..\testdata\snapshots"#;

    fn cmdline<'a>(args: &[&'a str]) -> Vec<&'a str> {
        let mut v = vec!["hgrep"];
        v.extend(args);
        v
    }

    mod arg_matches {
        use super::*;

        fn get_raw_matched_arguments(mat: &ArgMatches) -> Vec<(String, Vec<String>)> {
            let mut v = mat
                .ids()
                .map(|id| {
                    let id = id.as_str().to_string();
                    let args = mat
                        .get_raw(&id)
                        .map(|values| values.map(|v| v.to_string_lossy().to_string()).collect())
                        .unwrap_or_default();
                    (id, args)
                })
                .collect::<Vec<_>>();
            v.sort();
            v
        }

        macro_rules! snapshot_test {
            ($name:ident, $args:expr) => {
                #[test]
                fn $name() {
                    let mut settings = insta::Settings::clone_current();
                    settings.set_snapshot_path(SNAPSHOT_DIR);
                    settings.bind(|| {
                        let cmd = command();
                        let mat = cmd.get_matches_from(cmdline(&$args));
                        let raw = get_raw_matched_arguments(&mat);
                        insta::assert_debug_snapshot!(raw);
                    });
                }
            };
        }

        snapshot_test!(no_arg, []);
        snapshot_test!(pat_only, ["pat"]);
        snapshot_test!(pat_and_dir, ["pat", "dir1"]);
        snapshot_test!(pat_and_dirs, ["pat", "dir1", "dir2", "dir3"]);
        snapshot_test!(min_max_long, ["--min-context", "2", "--max-context", "4"]);
        snapshot_test!(min_max_short, ["-c", "2", "-C", "4"]);
        snapshot_test!(grid, ["--grid"]);
        snapshot_test!(no_grid, ["--no-grid"]);
        snapshot_test!(theme, ["--theme", "Nord"]);
        snapshot_test!(tab, ["--tab", "8"]);
        snapshot_test!(bat_printer_long, ["--printer", "bat"]);
        snapshot_test!(bat_printer_short, ["-p", "bat"]);
        snapshot_test!(term_width, ["--term-width", "200"]);
        snapshot_test!(wrap_mode, ["--wrap", "never"]);
        snapshot_test!(first_only, ["--first-only"]);
        snapshot_test!(background, ["--background"]);
        snapshot_test!(ascii_lines, ["--ascii-lines"]);
        snapshot_test!(custom_assets, ["--printer", "bat", "--custom-assets"]);
        snapshot_test!(list_themes, ["--list-themes"]);
        snapshot_test!(type_list, ["--type-list"]);
        snapshot_test!(
            generate_completion_script,
            ["--generate-completion-script", "bash"]
        );
        snapshot_test!(generate_man_page, ["--generate-man-page"]);
        snapshot_test!(
            all_printer_opts_before_args,
            [
                "--min-context",
                "5",
                "--max-context",
                "10",
                "--grid",
                "--no-grid",
                "--theme",
                "Nord",
                "--tab",
                "2",
                "--printer",
                "syntect",
                "--term-width",
                "120",
                "--wrap",
                "never",
                "--first-only",
                "--background",
                "--ascii-lines",
                "--custom-assets",
                "--list-themes",
                "some pattern",
                "dir1",
                "dir2",
            ]
        );
        snapshot_test!(
            all_printer_opts_after_args,
            [
                "some pattern",
                "dir1",
                "dir2",
                "--min-context",
                "5",
                "--max-context",
                "10",
                "--grid",
                "--no-grid",
                "--theme",
                "Nord",
                "--tab",
                "2",
                "--printer",
                "syntect",
                "--term-width",
                "120",
                "--wrap",
                "never",
                "--first-only",
                "--background",
                "--ascii-lines",
                "--custom-assets",
                "--list-themes",
            ]
        );

        #[test]
        fn invalid_option() {
            for args in [
                &["--min-context", "foo"][..],
                &["--max-context", "foo"][..],
                &["--term-width", "foo"][..],
                &["--term-width", "1"][..],
                &["--tab", "foo"][..],
                &["--printer", "syntect", "--custom-assets"][..],
                &["--printer", "bat", "--background"][..],
                &["--printer", "bat", "--ascii-lines"][..],
            ] {
                let mat = command().get_matches_from(cmdline(args));
                assert!(run(mat).is_err(), "args: {:?}", args);
            }
        }

        #[test]
        fn arg_parser_debug_assert() {
            command().debug_assert();
        }

        #[test]
        fn arg_parse_error() {
            for args in [
                &["--unknown-arg"][..],
                &["--printer", "foo"][..],
                &["--wrap", "foo"][..],
                &["--generate-completion-script", "unknown-shell"][..],
            ] {
                let parsed = command().try_get_matches_from(cmdline(args));
                assert!(parsed.is_err(), "args: {:?}", args);
            }
        }
    }

    mod ripgrep_config {
        use super::*;

        macro_rules! snapshot_test {
            ($name:ident, $args:expr) => {
                #[test]
                fn $name() {
                    let mut settings = insta::Settings::clone_current();
                    settings.set_snapshot_path(SNAPSHOT_DIR);
                    settings.bind(|| {
                        let mat = command().get_matches_from(cmdline(&$args));
                        let min_ctx = mat
                            .get_one::<String>("min-context")
                            .unwrap()
                            .parse()
                            .unwrap();
                        let max_ctx = mat
                            .get_one::<String>("max-context")
                            .unwrap()
                            .parse()
                            .unwrap();

                        let cfg = build_ripgrep_config(min_ctx, max_ctx, &mat).unwrap();
                        insta::assert_debug_snapshot!(cfg);
                    });
                }
            };
        }

        snapshot_test!(no_arg, []);
        snapshot_test!(pat_only, ["pat"]);
        snapshot_test!(pat_and_dirs, ["pat", "dir1", "dir2"]);
        snapshot_test!(glob_one, ["--glob", "*.txt", "pat", "dir"]);
        snapshot_test!(
            glob_many,
            ["-g", "*.txt", "-g", "*.rs", "-g", "*.md", "pat", "dir"]
        );
        snapshot_test!(glob_before_opt, ["-g", "*.txt", "-i", "pat", "dir"]);
        snapshot_test!(glob_arg_with_hyphen, ["-g", "-foo_*.txt", "pat", "dir"]);
        snapshot_test!(ignore_case_smart_case, ["-i", "-S", "pat", "dir"]);
        snapshot_test!(smart_case_ignore_case, ["-S", "-i", "pat", "dir"]);
        snapshot_test!(max_count, ["--max-count", "100", "pat", "dir"]);
        snapshot_test!(max_count_short, ["-m", "100", "pat", "dir"]);
        snapshot_test!(max_depth, ["--max-depth", "10", "pat", "dir"]);
        snapshot_test!(line_regexp_word_regexp, ["-x", "-w", "pat", "dir"]);
        snapshot_test!(word_regexp_line_regexp, ["-w", "-x", "pat", "dir"]);
        snapshot_test!(pcre2, ["-P", "pat", "dir"]);
        snapshot_test!(fixed_string_override_pcre2, ["-F", "-P", "pat", "dir"]);
        snapshot_test!(type_one, ["--type", "rust", "pat", "dir"]);
        snapshot_test!(type_many, ["-t", "rust", "-t", "go", "pat", "dir"]);
        snapshot_test!(type_not_one, ["--type-not", "rust", "pat", "dir"]);
        snapshot_test!(type_not_many, ["-T", "rust", "-T", "go", "pat", "dir"]);
        snapshot_test!(
            type_and_type_not_many,
            ["-t", "rust", "-T", "rust", "-T", "go", "-t", "go", "pat", "dir"]
        );
        snapshot_test!(
            regex_size_limit,
            ["--regex-size-limit", "20M", "pat", "dir"]
        );
        snapshot_test!(dfa_size_limit, ["--dfa-size-limit", "20M", "pat", "dir"]);
        snapshot_test!(
            bool_long_flags,
            [
                "--no-ignore",
                "--ignore-case",
                "--smart-case",
                "--glob-case-insensitive",
                "--fixed-strings",
                "--word-regexp",
                "--follow",
                "--multiline",
                "--multiline-dotall",
                "--crlf",
                "--mmap",
                "--hidden",
                "--line-regexp",
                "--pcre2",
                "--one-file-system",
                "--no-unicode",
                "pat",
                "dir",
            ]
        );
        snapshot_test!(
            bool_short_flags,
            ["-i", "-S", "-F", "-w", "-L", "-U", "-.", "-x", "-P", "pat", "dir"]
        );
    }

    #[test]
    fn generate_completion() {
        for shell in COMPLETION_SHELLS {
            let mut v = vec![];
            generate_completion_script(shell, &mut v);
            assert!(!v.is_empty(), "shell: {}", shell);
        }
    }
}