use std::collections::HashSet; use std::env; use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::thread::available_parallelism; use crate::{ clap_app, config::{get_args_from_config_file, get_args_from_env_opts_var, get_args_from_env_vars}, }; use bat::style::StyleComponentList; use bat::theme::{theme, ThemeName, ThemeOptions, ThemePreference}; use bat::BinaryBehavior; use bat::StripAnsiMode; use clap::ArgMatches; use console::Term; use crate::input::{new_file_input, new_stdin_input}; use bat::{ bat_warning, config::{Config, VisibleLines}, error::*, input::Input, line_range::{HighlightedLineRanges, LineRange, LineRanges}, style::{StyleComponent, StyleComponents}, MappingTarget, NonprintableNotation, PagingMode, SyntaxMapping, WrappingMode, }; fn is_truecolor_terminal() -> bool { env::var("COLORTERM") .map(|colorterm| colorterm == "truecolor" || colorterm == "24bit") .unwrap_or(false) } 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, } impl App { pub fn new() -> Result { #[cfg(windows)] 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, 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 (version, diagnostic) let should_skip_config = wild::args_os().any(|arg| { matches!( arg.to_str(), 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 version or diagnostic information 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 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 = 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()); // Put the zero-th CLI argument (program name) first args.insert(0, cli_args.next().unwrap()); // .. and the rest at the end cli_args.for_each(|a| args.push(a)); args }; Ok(clap_app::build_app(interactive_output).get_matches_from(args)) } pub fn config(&self, inputs: &[Input]) -> Result> { let style_components = self.style_components()?; let extra_plain = self.matches.get_count("plain") > 1; let plain_last_index = self .matches .indices_of("plain") .and_then(Iterator::max) .unwrap_or_default(); let paging_last_index = self .matches .indices_of("paging") .and_then(Iterator::max) .unwrap_or_default(); let paging_mode = match self.matches.get_one::("paging").map(|s| s.as_str()) { Some("always") => { // Disable paging if the second -p (or -pp) is specified after --paging=always if extra_plain && plain_last_index > paging_last_index { PagingMode::Never } else { PagingMode::Always } } Some("never") => PagingMode::Never, Some("auto") | None => { // If we have -pp as an option when in auto mode, the pager should be disabled. if extra_plain || self.matches.get_flag("no-paging") { PagingMode::Never } else if inputs.iter().any(Input::is_stdin) // ignore stdin when --list-themes is used because in that case no input will be read anyways && !self.matches.get_flag("list-themes") { // If we are reading from stdin, only enable paging if we write to an // interactive terminal and if we do not *read* from an interactive // terminal. if self.interactive_output && !std::io::stdin().is_terminal() { PagingMode::QuitIfOneScreen } else { PagingMode::Never } } else if self.interactive_output { PagingMode::QuitIfOneScreen } else { PagingMode::Never } } _ => unreachable!("other values for --paging are not allowed"), }; let mut syntax_mapping = SyntaxMapping::new(); // start building glob matchers for builtin mappings immediately // this is an appropriate approach because it's statistically likely that // all the custom mappings need to be checked if available_parallelism()?.get() > 1 { syntax_mapping.start_offload_build_all(); } if let Some(values) = self.matches.get_many::("ignored-suffix") { for suffix in values { syntax_mapping.insert_ignored_suffix(suffix); } } if let Some(values) = self.matches.get_many::("map-syntax") { // later args take precedence over earlier ones, hence `.rev()` // see: https://github.com/sharkdp/bat/pull/2755#discussion_r1456416875 for from_to in values.rev() { let parts: Vec<_> = from_to.split(':').collect(); if parts.len() != 2 { return Err("Invalid syntax mapping. The format of the -m/--map-syntax option is ':'. For example: '*.cpp:C++'.".into()); } syntax_mapping.insert(parts[0], MappingTarget::MapTo(parts[1]))?; } } let maybe_term_width = self .matches .get_one::("terminal-width") .and_then(|w| { if w.starts_with('+') || w.starts_with('-') { // Treat argument as a delta to the current terminal width w.parse().ok().map(|delta: i16| { let old_width: u16 = Term::stdout().size().1; let new_width: i32 = i32::from(old_width) + i32::from(delta); if new_width <= 0 { old_width as usize } else { new_width as usize } }) } else { w.parse().ok() } }); Ok(Config { true_color: is_truecolor_terminal(), language: self .matches .get_one::("language") .map(|s| s.as_str()) .or_else(|| { if self.matches.get_flag("show-all") { Some("show-nonprintable") } else { None } }), show_nonprintable: self.matches.get_flag("show-all"), nonprintable_notation: match self .matches .get_one::("nonprintable-notation") .map(|s| s.as_str()) { Some("unicode") => NonprintableNotation::Unicode, Some("caret") => NonprintableNotation::Caret, _ => unreachable!("other values for --nonprintable-notation are not allowed"), }, binary: match self.matches.get_one::("binary").map(|s| s.as_str()) { Some("as-text") => BinaryBehavior::AsText, Some("no-printing") => BinaryBehavior::NoPrinting, _ => unreachable!("other values for --binary are not allowed"), }, wrapping_mode: if self.interactive_output || maybe_term_width.is_some() { if !self.matches.get_flag("chop-long-lines") { match self.matches.get_one::("wrap").map(|s| s.as_str()) { Some("character") => WrappingMode::Character, Some("never") => WrappingMode::NoWrapping(true), Some("auto") | None => { if style_components.plain() { WrappingMode::NoWrapping(false) } else { WrappingMode::Character } } _ => unreachable!("other values for --wrap are not allowed"), } } else { WrappingMode::NoWrapping(true) } } else { // We don't have the tty width when piping to another program. // There's no point in wrapping when this is the case. WrappingMode::NoWrapping(false) }, colored_output: self.matches.get_flag("force-colorization") || match self.matches.get_one::("color").map(|s| s.as_str()) { Some("always") => true, Some("never") => false, Some("auto") => !env_no_color() && self.interactive_output, _ => unreachable!("other values for --color are not allowed"), }, paging_mode, term_width: maybe_term_width.unwrap_or(Term::stdout().size().1 as usize), loop_through: !(self.interactive_output || self.matches.get_one::("color").map(|s| s.as_str()) == Some("always") || self .matches .get_one::("decorations") .map(|s| s.as_str()) == Some("always") || self.matches.get_flag("force-colorization") || self.matches.get_flag("number")), tab_width: self .matches .get_one::("tabs") .map(String::from) .and_then(|t| t.parse().ok()) .unwrap_or( if style_components.plain() && paging_mode == PagingMode::Never { 0 } else { 4 }, ), strip_ansi: match self .matches .get_one::("strip-ansi") .map(|s| s.as_str()) { Some("never") => StripAnsiMode::Never, Some("always") => StripAnsiMode::Always, Some("auto") => StripAnsiMode::Auto, _ => unreachable!("other values for --strip-ansi are not allowed"), }, theme: theme(self.theme_options()).to_string(), visible_lines: match self.matches.try_contains_id("diff").unwrap_or_default() && self.matches.get_flag("diff") { #[cfg(feature = "git")] true => VisibleLines::DiffContext( self.matches .get_one::("diff-context") .and_then(|t| t.parse().ok()) .unwrap_or(2), ), _ => VisibleLines::Ranges( self.matches .get_many::("line-range") .map(|vs| vs.map(|s| LineRange::from(s.as_str())).collect()) .transpose()? .map(LineRanges::from) .unwrap_or_default(), ), }, style_components, syntax_mapping, pager: self.matches.get_one::("pager").map(|s| s.as_str()), use_italic_text: self .matches .get_one::("italic-text") .map(|s| s.as_str()) == Some("always"), highlighted_lines: self .matches .get_many::("highlight-line") .map(|ws| ws.map(|s| LineRange::from(s.as_str())).collect()) .transpose()? .map(LineRanges::from) .map(HighlightedLineRanges) .unwrap_or_default(), use_custom_assets: !self.matches.get_flag("no-custom-assets"), #[cfg(feature = "lessopen")] use_lessopen: self.matches.get_flag("lessopen"), set_terminal_title: self.matches.get_flag("set-terminal-title"), squeeze_lines: if self.matches.get_flag("squeeze-blank") { Some( self.matches .get_one::("squeeze-limit") .map(|limit| limit.to_owned()) .unwrap_or(1), ) } else { None }, }) } pub fn inputs(&self) -> Result>> { let filenames: Option> = self .matches .get_many::("file-name") .map(|vs| vs.map(|p| p.as_path()).collect::>()); let files: Option> = self .matches .get_many::("FILE") .map(|vs| vs.map(|p| p.as_path()).collect::>()); // verify equal length of file-names and input FILEs if filenames.is_some() && files.is_some() && filenames.as_ref().map(|v| v.len()) != files.as_ref().map(|v| v.len()) { return Err("Must be one file name per input type.".into()); } let mut filenames_or_none: Box>> = match filenames { Some(filenames) => Box::new(filenames.into_iter().map(Some)), None => Box::new(std::iter::repeat(None)), }; if files.is_none() { return Ok(vec![new_stdin_input( filenames_or_none.next().unwrap_or(None), )]); } let files_or_none: Box> = match files { Some(ref files) => Box::new(files.iter().map(|name| Some(*name))), None => Box::new(std::iter::repeat(None)), }; let mut file_input = Vec::new(); for (filepath, provided_name) in files_or_none.zip(filenames_or_none) { if let Some(filepath) = filepath { if filepath.to_str().unwrap_or_default() == "-" { file_input.push(new_stdin_input(provided_name)); } else { file_input.push(new_file_input(filepath, provided_name)); } } } Ok(file_input) } fn forced_style_components(&self) -> Option { // No components if `--decorations=never``. if self .matches .get_one::("decorations") .map(|s| s.as_str()) == Some("never") { return Some(StyleComponents(HashSet::new())); } // Only line numbers if `--number`. if self.matches.get_flag("number") { return Some(StyleComponents(HashSet::from([ StyleComponent::LineNumbers, ]))); } // Plain if `--plain` is specified at least once. if self.matches.get_count("plain") > 0 { return Some(StyleComponents(HashSet::from([StyleComponent::Plain]))); } // Default behavior. None } fn style_components(&self) -> Result { let matches = &self.matches; let mut styled_components = match self.forced_style_components() { Some(forced_components) => forced_components, // Parse the `--style` arguments and merge them. None if matches.contains_id("style") => { let lists = matches .get_many::("style") .expect("styles present") .map(|v| StyleComponentList::from_str(v)) .collect::>>()?; StyleComponentList::to_components(lists, self.interactive_output, true) } // Use the default. None => StyleComponents(HashSet::from_iter( StyleComponent::Default .components(self.interactive_output) .iter() .cloned(), )), }; // If `grid` is set, remove `rule` as it is a subset of `grid`, and print a warning. if styled_components.grid() && styled_components.0.remove(&StyleComponent::Rule) { bat_warning!("Style 'rule' is a subset of style 'grid', 'rule' will not be visible."); } Ok(styled_components) } fn theme_options(&self) -> ThemeOptions { 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 = matches .get_one::("theme-dark") .map(|t| ThemeName::from_str(t).unwrap()); let theme_light = matches .get_one::("theme-light") .map(|t| ThemeName::from_str(t).unwrap()); ThemeOptions { theme, theme_dark, theme_light, } } }