From 70ec3fc24e46cae8f0054a03f92ecfd53c7b98d6 Mon Sep 17 00:00:00 2001 From: Muntasir Mahmud Date: Mon, 24 Nov 2025 02:10:56 +0600 Subject: [PATCH] feat: add paging to '-h' and '--help' (#3478) * feat: add paging to '-h' and '--help' Fixes #1587 --- CHANGELOG.md | 2 + src/bin/bat/app.rs | 146 ++++++++++++++++++++++++++++++++----- src/bin/bat/clap_app.rs | 17 +++++ tests/integration_tests.rs | 38 ++++++++++ 4 files changed, 184 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca431961..9993c0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Features +- Add paging to '-h' and '--help' see PR #3478 (@MuntasirSZN) + ## Bugfixes - Fix hang when using `--list-themes` with an explicit pager, see #3457 (@abhinavcool42) diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 0304516e..4c0ade1e 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -38,6 +38,11 @@ pub fn env_no_color() -> bool { env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty()) } +enum HelpType { + Short, + Long, +} + pub struct App { pub matches: ArgMatches, interactive_output: bool, @@ -49,38 +54,126 @@ impl App { let _ = nu_ansi_term::enable_ansi_support(); let interactive_output = std::io::stdout().is_terminal(); + let matches = Self::matches(interactive_output)?; + + if matches.get_flag("help") { + let help_type = if wild::args_os().any(|arg| arg == "--help") { + HelpType::Long + } else { + HelpType::Short + }; + + let use_pager = match matches.get_one::("paging").map(|s| s.as_str()) { + Some("never") => false, + _ => !matches.get_flag("no-paging"), + }; + + let use_color = match matches.get_one::("color").map(|s| s.as_str()) { + Some("always") => true, + Some("never") => false, + _ => interactive_output, // auto: use color if interactive + }; + + let custom_pager = matches.get_one::("pager").map(|s| s.to_string()); + let theme_options = Self::theme_options_from_matches(&matches); + + Self::display_help( + interactive_output, + help_type, + use_pager, + use_color, + custom_pager, + theme_options, + )?; + std::process::exit(0); + } Ok(App { - matches: Self::matches(interactive_output)?, + matches, interactive_output, }) } + fn display_help( + interactive_output: bool, + help_type: HelpType, + use_pager: bool, + use_color: bool, + custom_pager: Option, + theme_options: ThemeOptions, + ) -> Result<()> { + use crate::assets::assets_from_cache_or_binary; + use crate::directories::PROJECT_DIRS; + use bat::{ + config::Config, + controller::Controller, + input::Input, + style::{StyleComponent, StyleComponents}, + theme::theme, + PagingMode, + }; + + let mut cmd = clap_app::build_app(interactive_output); + let help_text = match help_type { + HelpType::Short => cmd.render_help().to_string(), + HelpType::Long => cmd.render_long_help().to_string(), + }; + + let inputs: Vec = vec![Input::from_reader(Box::new(help_text.as_bytes()))]; + + let paging_mode = if use_pager { + PagingMode::QuitIfOneScreen + } else { + PagingMode::Never + }; + + let pager = bat::config::get_pager_executable(custom_pager.as_deref()); + + let help_config = Config { + style_components: StyleComponents::new(StyleComponent::Plain.components(false)), + paging_mode, + pager: pager.as_deref(), + colored_output: use_color, + true_color: use_color, + language: if use_color { Some("help") } else { None }, + theme: theme(theme_options).to_string(), + ..Default::default() + }; + + let cache_dir = PROJECT_DIRS.cache_dir(); + let assets = assets_from_cache_or_binary(false, cache_dir)?; + Controller::new(&help_config, &assets) + .run(inputs, None) + .ok(); + + Ok(()) + } + fn matches(interactive_output: bool) -> Result { // Check if we should skip config file processing for special arguments - // that don't require full application setup (help, version, diagnostic) + // that don't require full application setup (version, diagnostic) let should_skip_config = wild::args_os().any(|arg| { matches!( arg.to_str(), - Some("-h" | "--help" | "-V" | "--version" | "--diagnostic" | "--diagnostics") + Some("-V" | "--version" | "--diagnostic" | "--diagnostics") ) }); + // Check if help was requested - help should go through the same code path + // but be forgiving of config file errors + let help_requested = + wild::args_os().any(|arg| matches!(arg.to_str(), Some("-h" | "--help"))); + let args = if wild::args_os().nth(1) == Some("cache".into()) { // Skip the config file and env vars wild::args_os().collect::>() } else if wild::args_os().any(|arg| arg == "--no-config") || should_skip_config { // Skip the arguments in bats config file when --no-config is present - // or when user requests help, version, or diagnostic information + // or when user requests version or diagnostic information let mut cli_args = wild::args_os(); - let mut args = if should_skip_config { - // For special commands, don't even try to load env vars that might fail - vec![] - } else { - get_args_from_env_vars() - }; + let mut args = get_args_from_env_vars(); // Put the zero-th CLI argument (program name) first args.insert(0, cli_args.next().unwrap()); @@ -88,14 +181,28 @@ impl App { // .. and the rest at the end cli_args.for_each(|a| args.push(a)); + args + } else if help_requested { + // Help goes through the normal config path but only uses env vars for themes + // to avoid failing on invalid config options + let mut cli_args = wild::args_os(); + let mut args = get_args_from_env_vars(); + + // Put the zero-th CLI argument (program name) first + args.insert(0, cli_args.next().unwrap()); + + // .. and the rest at the end (includes --help and other CLI args) + cli_args.for_each(|a| args.push(a)); args } else { let mut cli_args = wild::args_os(); // Read arguments from bats config file - let mut args = get_args_from_env_opts_var() - .unwrap_or_else(get_args_from_config_file) - .map_err(|_| "Could not parse configuration file")?; + let mut args = match get_args_from_env_opts_var() { + Some(result) => result, + None => get_args_from_config_file(), + } + .map_err(|_| "Could not parse configuration file")?; // Selected env vars supersede config vars args.extend(get_args_from_env_vars()); @@ -462,17 +569,18 @@ impl App { } fn theme_options(&self) -> ThemeOptions { - let theme = self - .matches + Self::theme_options_from_matches(&self.matches) + } + + fn theme_options_from_matches(matches: &ArgMatches) -> ThemeOptions { + let theme = matches .get_one::("theme") .map(|t| ThemePreference::from_str(t).unwrap()) .unwrap_or_default(); - let theme_dark = self - .matches + let theme_dark = matches .get_one::("theme-dark") .map(|t| ThemeName::from_str(t).unwrap()); - let theme_light = self - .matches + let theme_light = matches .get_one::("theme-light") .map(|t| ThemeName::from_str(t).unwrap()); ThemeOptions { diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index 8c9f7c1f..acb505ee 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -34,6 +34,8 @@ pub fn build_app(interactive_output: bool) -> Command { .args_conflicts_with_subcommands(true) .allow_external_subcommands(true) .disable_help_subcommand(true) + .disable_help_flag(true) + .disable_version_flag(true) .max_term_width(100) .about("A cat(1) clone with wings.") .long_about("A cat(1) clone with syntax highlighting and Git integration.") @@ -654,6 +656,21 @@ pub fn build_app(interactive_output: bool) -> Command { .action(ArgAction::SetTrue) .hide_short_help(true) .help("Sets terminal title to filenames when using a pager."), + ) + .arg( + Arg::new("help") + .short('h') + .long("help") + .action(ArgAction::SetTrue) + .help("Print help (see more with '--help')") + .long_help("Print help (see a summary with '-h')"), + ) + .arg( + Arg::new("version") + .long("version") + .short('V') + .action(ArgAction::Version) + .help("Print version"), ); // Check if the current directory contains a file name cache. Otherwise, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 20aaad94..27ee6add 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -605,6 +605,44 @@ fn test_help(arg: &str, expect_file: &str) { .assert_eq(&String::from_utf8_lossy(&assert.get_output().stdout)); } +#[test] +fn short_help_with_highlighting() { + bat() + .arg("-h") + .arg("--paging=never") + .arg("--color=always") + .assert() + .success() + .stdout(predicate::str::contains("\x1B[")) + .stdout(predicate::str::contains("Usage:")) + .stdout(predicate::str::contains("Options:")); +} + +#[test] +fn long_help_with_highlighting() { + bat() + .arg("--help") + .arg("--paging=never") + .arg("--color=always") + .assert() + .success() + .stdout(predicate::str::contains("\x1B[")) + .stdout(predicate::str::contains("Usage:")) + .stdout(predicate::str::contains("Options:")); +} + +#[test] +fn help_with_color_never() { + bat() + .arg("--help") + .arg("--color=never") + .arg("--paging=never") + .assert() + .success() + .stdout(predicate::str::contains("\x1B[").not()) + .stdout(predicate::str::contains("Usage:")); +} + #[cfg(unix)] fn setup_temp_file(content: &[u8]) -> io::Result<(PathBuf, tempfile::TempDir)> { let dir = tempfile::tempdir().expect("Couldn't create tempdir");