diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bc2627..4c0422e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ - Make the default macOS theme depend on Dark Mode. See #2197, #1746 (@Enselic) - Support for separate system and user config files. See #668 (@patrickpichler) +- Add support for $LESSOPEN and $LESSCLOSE. See #1597, #1739, and #2444 (@Anomalocaridid) ## Bugfixes diff --git a/Cargo.lock b/Cargo.lock index cc17cefc..cfc26f1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,10 +140,12 @@ dependencies = [ "nix", "nu-ansi-term", "once_cell", + "os_str_bytes", "path_abs", "plist", "predicates", "regex", + "run_script", "semver", "serde", "serde_yaml", @@ -351,6 +353,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dunce" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c" + [[package]] name = "either" version = "1.8.0" @@ -464,6 +472,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsio" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad0ce30be0cc441b325c5d705c8b613a0ca0d92b6a8953d41bd236dc09a36d0" +dependencies = [ + "dunce", + "rand", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "git-version" version = "0.3.5" @@ -773,6 +802,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "os_str_bytes" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" +dependencies = [ + "memchr", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -831,6 +869,12 @@ dependencies = [ "time", ] +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "predicates" version = "3.0.3" @@ -895,6 +939,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -951,6 +1025,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "run_script" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fdc55b3a7ad58e02de47eaf7a854c6791c8421da48ff296c152317d3beaf230" +dependencies = [ + "fsio", +] + [[package]] name = "rustix" version = "0.37.3" @@ -1322,6 +1405,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "wild" version = "2.1.0" diff --git a/Cargo.toml b/Cargo.toml index a22d20b8..5b88cb5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ application = [ "build-assets", "git", "minimal-application", + "lessopen", ] # Mainly for developers that want to iterate quickly # Be aware that the included features might change in the future @@ -33,6 +34,7 @@ minimal-application = [ ] git = ["git2"] # Support indicating git modifications paging = ["shell-words", "grep-cli"] # Support applying a pager on the output +lessopen = ["run_script", "os_str_bytes"] # Support $LESSOPEN preprocessor build-assets = ["syntect/yaml-load", "syntect/plist-load", "regex", "walkdir"] # You need to use one of these if you depend on bat as a library: @@ -64,6 +66,8 @@ regex = { version = "1.8.3", optional = true } walkdir = { version = "2.3", optional = true } bytesize = { version = "1.2.0" } encoding_rs = "0.8.32" +os_str_bytes = { version = "~6.4", optional = true } +run_script = { version = "^0.10.0", optional = true} [dependencies.git2] version = "0.18" diff --git a/assets/completions/_bat.ps1.in b/assets/completions/_bat.ps1.in index a8458e97..fe0b8b07 100644 --- a/assets/completions/_bat.ps1.in +++ b/assets/completions/_bat.ps1.in @@ -59,6 +59,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script [CompletionResult]::new('--unbuffered', 'unbuffered', [CompletionResultType]::ParameterName, 'unbuffered') [CompletionResult]::new('--no-config', 'no-config', [CompletionResultType]::ParameterName, 'Do not use the configuration file') [CompletionResult]::new('--no-custom-assets', 'no-custom-assets', [CompletionResultType]::ParameterName, 'Do not load custom assets') + [CompletionResult]::new('--no-lessopen', 'no-lessopen', [CompletionResultType]::ParameterName, 'Do not use the $LESSOPEN preprocessor') [CompletionResult]::new('--config-file', 'config-file', [CompletionResultType]::ParameterName, 'Show path to the configuration file.') [CompletionResult]::new('--generate-config-file', 'generate-config-file', [CompletionResultType]::ParameterName, 'Generates a default configuration file.') [CompletionResult]::new('--config-dir', 'config-dir', [CompletionResultType]::ParameterName, 'Show bat''s configuration directory.') diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index ec8109de..0939c6f2 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -46,6 +46,7 @@ _{{PROJECT_EXECUTABLE}}_main() { '(: --list-themes --list-languages -L)'{-L,--list-languages}'[Display all supported languages]' '(: --no-config)'--no-config'[Do not use the configuration file]' '(: --no-custom-assets)'--no-custom-assets'[Do not load custom assets]' + '(: --no-lessopen)'--no-lessopen'[Do not use the $LESSOPEN preprocessor]' '(: --config-dir)'--config-dir'[Show bat'"'"'s configuration directory]' '(: --config-file)'--config-file'[Show path to the configuration file]' '(: --generate-config-file)'--generate-config-file'[Generates a default configuration file]' diff --git a/assets/manual/bat.1.in b/assets/manual/bat.1.in index 2b03295a..057cfc21 100644 --- a/assets/manual/bat.1.in +++ b/assets/manual/bat.1.in @@ -243,6 +243,17 @@ If you ever want to remove the custom languages, you can clear the cache with `\ Similarly to custom languages, {{PROJECT_EXECUTABLE}} supports Sublime Text \fB.tmTheme\fR themes. These can be installed to `\fB$({{PROJECT_EXECUTABLE}} --config-dir)/themes\fR`, and are added to the cache with `\fB{{PROJECT_EXECUTABLE}} cache --build`. + +.SH "INPUT PREPROCESSOR" +Much like less(1) does, {{PROJECT_EXECUTABLE}} supports input preprocessors via the LESSOPEN and LESSCLOSE environment variables. +In addition, {{PROJECT_EXECUTABLE}} attempts to be as compatible with less's preprocessor implementation as possible. + +To run {{PROJECT_EXECUTABLE}} without using the preprocessor, call: + +\fB{{PROJECT_EXECUTABLE}} --no-lessopen\fR + +For more information, see the "INPUT PREPROCESSOR" section of less(1). + .SH "MORE INFORMATION" For more information and up-to-date documentation, visit the {{PROJECT_EXECUTABLE}} repo: diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index c208b14f..95b66a92 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -281,6 +281,8 @@ impl App { .map(HighlightedLineRanges) .unwrap_or_default(), use_custom_assets: !self.matches.get_flag("no-custom-assets"), + #[cfg(feature = "lessopen")] + use_lessopen: !self.matches.get_flag("no-lessopen"), }) } diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index acdd1d08..f6318537 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -497,7 +497,20 @@ pub fn build_app(interactive_output: bool) -> Command { .action(ArgAction::SetTrue) .hide(true) .help("Do not load custom assets"), + ); + + #[cfg(feature = "lessopen")] + { + app = app.arg( + Arg::new("no-lessopen") + .long("no-lessopen") + .action(ArgAction::SetTrue) + .hide(true) + .help("Do not use the $LESSOPEN preprocessor"), ) + } + + app = app .arg( Arg::new("config-file") .long("config-file") @@ -536,7 +549,7 @@ pub fn build_app(interactive_output: bool) -> Command { .alias("diagnostics") .action(ArgAction::SetTrue) .hide_short_help(true) - .help("Show diagnostic information for bug reports.") + .help("Show diagnostic information for bug reports."), ) .arg( Arg::new("acknowledgements") diff --git a/src/config.rs b/src/config.rs index eaefb7d6..83acc7df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,6 +90,10 @@ pub struct Config<'a> { /// Whether or not to allow custom assets. If this is false or if custom assets (a.k.a. /// cached assets) are not available, assets from the binary will be used instead. pub use_custom_assets: bool, + + // Whether or not to use $LESSOPEN if set + #[cfg(feature = "lessopen")] + pub use_lessopen: bool, } #[cfg(all(feature = "minimal-application", feature = "paging"))] diff --git a/src/controller.rs b/src/controller.rs index 42c13936..f378cbc6 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -6,6 +6,8 @@ use crate::config::{Config, VisibleLines}; use crate::diff::{get_git_diff, LineChanges}; use crate::error::*; use crate::input::{Input, InputReader, OpenedInput}; +#[cfg(feature = "lessopen")] +use crate::lessopen::LessOpenPreprocessor; #[cfg(feature = "git")] use crate::line_range::LineRange; use crate::line_range::{LineRanges, RangeCheckResult}; @@ -19,11 +21,18 @@ use clircle::{Clircle, Identifier}; pub struct Controller<'a> { config: &'a Config<'a>, assets: &'a HighlightingAssets, + #[cfg(feature = "lessopen")] + preprocessor: Option, } impl<'b> Controller<'b> { pub fn new<'a>(config: &'a Config, assets: &'a HighlightingAssets) -> Controller<'a> { - Controller { config, assets } + Controller { + config, + assets, + #[cfg(feature = "lessopen")] + preprocessor: LessOpenPreprocessor::new().ok(), + } } pub fn run( @@ -123,7 +132,18 @@ impl<'b> Controller<'b> { stdout_identifier: Option<&Identifier>, is_first: bool, ) -> Result<()> { - let mut opened_input = input.open(stdin, stdout_identifier)?; + let mut opened_input = { + #[cfg(feature = "lessopen")] + match self.preprocessor { + Some(ref preprocessor) if self.config.use_lessopen => { + preprocessor.open(input, stdin, stdout_identifier)? + } + _ => input.open(stdin, stdout_identifier)?, + } + + #[cfg(not(feature = "lessopen"))] + input.open(stdin, stdout_identifier)? + }; #[cfg(feature = "git")] let line_changes = if self.config.visible_lines.diff_mode() || (!self.config.loop_through && self.config.style_components.changes()) diff --git a/src/error.rs b/src/error.rs index 3579d4ff..007737b0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,12 @@ pub enum Error { InvalidPagerValueBat, #[error("{0}")] Msg(String), + #[cfg(feature = "lessopen")] + #[error(transparent)] + VarError(#[from] ::std::env::VarError), + #[cfg(feature = "lessopen")] + #[error(transparent)] + CommandParseError(#[from] ::shell_words::ParseError), } impl From<&'static str> for Error { diff --git a/src/input.rs b/src/input.rs index 23e21506..ccab98bf 100644 --- a/src/input.rs +++ b/src/input.rs @@ -256,7 +256,7 @@ pub(crate) struct InputReader<'a> { } impl<'a> InputReader<'a> { - fn new(mut reader: R) -> InputReader<'a> { + pub(crate) fn new(mut reader: R) -> InputReader<'a> { let mut first_line = vec![]; reader.read_until(b'\n', &mut first_line).ok(); diff --git a/src/lessopen.rs b/src/lessopen.rs new file mode 100644 index 00000000..7c26e838 --- /dev/null +++ b/src/lessopen.rs @@ -0,0 +1,390 @@ +#![cfg(feature = "lessopen")] + +use std::convert::TryFrom; +use std::env; +use std::fs::File; +use std::io::{BufRead, BufReader, Cursor, Read, Write}; +use std::path::PathBuf; +use std::str; + +use clircle::{Clircle, Identifier}; +use os_str_bytes::RawOsString; +use run_script::{IoOptions, ScriptOptions}; + +use crate::error::Result; +use crate::input::{Input, InputKind, InputReader, OpenedInput, OpenedInputKind}; + +/// Preprocess files and/or stdin using $LESSOPEN and $LESSCLOSE +pub(crate) struct LessOpenPreprocessor { + lessopen: String, + lessclose: Option, + command_options: ScriptOptions, + kind: LessOpenKind, + /// Whether or not data piped via stdin is to be preprocessed + preprocess_stdin: bool, +} + +enum LessOpenKind { + Piped, + PipedIgnoreExitCode, + TempFile, +} + +impl LessOpenPreprocessor { + /// Create a new instance of LessOpenPreprocessor + /// Will return Ok if and only if $LESSOPEN is set + pub(crate) fn new() -> Result { + let lessopen = env::var("LESSOPEN")?; + + // "||" means pipe directly to bat without making a temporary file + // Also, if preprocessor output is empty and exit code is zero, use the empty output + // Otherwise, if output is empty and exit code is nonzero, use original file contents + let (kind, lessopen) = if lessopen.starts_with("||") { + (LessOpenKind::Piped, lessopen.chars().skip(2).collect()) + // "|" means pipe, but ignore exit code, always using preprocessor output + } else if lessopen.starts_with('|') { + ( + LessOpenKind::PipedIgnoreExitCode, + lessopen.chars().skip(1).collect(), + ) + // If neither appear, write output to a temporary file and read from that + } else { + (LessOpenKind::TempFile, lessopen) + }; + + // "-" means that stdin is preprocessed along with files and may appear alongside "|" and "||" + let (stdin, lessopen) = if lessopen.starts_with('-') { + (true, lessopen.chars().skip(1).collect()) + } else { + (false, lessopen) + }; + + let mut command_options = ScriptOptions::new(); + command_options.runner = env::var("SHELL").ok(); + command_options.input_redirection = IoOptions::Pipe; + + Ok(Self { + lessopen: lessopen.replacen("%s", "$1", 1), + lessclose: env::var("LESSCLOSE") + .ok() + .map(|str| str.replacen("%s", "$1", 1).replacen("%s", "$2", 1)), + command_options, + kind, + preprocess_stdin: stdin, + }) + } + + pub(crate) fn open<'a, R: BufRead + 'a>( + &self, + input: Input<'a>, + mut stdin: R, + stdout_identifier: Option<&Identifier>, + ) -> Result> { + let (lessopen_stdout, path_str, kind) = match input.kind { + InputKind::OrdinaryFile(ref path) => { + let path_str = match path.to_str() { + Some(str) => str, + None => return input.open(stdin, stdout_identifier), + }; + + let (exit_code, lessopen_stdout, _) = match run_script::run( + &self.lessopen, + &vec![path_str.to_string()], + &self.command_options, + ) { + Ok(output) => output, + Err(_) => return input.open(stdin, stdout_identifier), + }; + + if self.fall_back_to_original_file(&lessopen_stdout, exit_code) { + return input.open(stdin, stdout_identifier); + } + + ( + RawOsString::from_string(lessopen_stdout), + path_str.to_string(), + OpenedInputKind::OrdinaryFile(path.to_path_buf()), + ) + } + InputKind::StdIn => { + if self.preprocess_stdin { + if let Some(stdout) = stdout_identifier { + let input_identifier = Identifier::try_from(clircle::Stdio::Stdin) + .map_err(|e| format!("Stdin: Error identifying file: {}", e))?; + if stdout.surely_conflicts_with(&input_identifier) { + return Err("IO circle detected. The input from stdin is also an output. Aborting to avoid infinite loop.".into()); + } + } + + // stdin isn't Clone, so copy it to a cloneable buffer + let mut stdin_buffer = Vec::new(); + stdin.read_to_end(&mut stdin_buffer).unwrap(); + + let mut lessopen_handle = match run_script::spawn( + &self.lessopen, + &vec!["-".to_string()], + &self.command_options, + ) { + Ok(handle) => handle, + Err(_) => { + return input.open(stdin, stdout_identifier); + } + }; + + if lessopen_handle + .stdin + .as_mut() + .unwrap() + .write_all(&stdin_buffer.clone()) + .is_err() + { + return input.open(stdin, stdout_identifier); + } + + let lessopen_output = match lessopen_handle.wait_with_output() { + Ok(output) => output, + Err(_) => { + return input.open(Cursor::new(stdin_buffer), stdout_identifier); + } + }; + + if lessopen_output.stdout.is_empty() + && (!lessopen_output.status.success() + || matches!(self.kind, LessOpenKind::PipedIgnoreExitCode)) + { + return input.open(Cursor::new(stdin_buffer), stdout_identifier); + } + + ( + RawOsString::assert_from_raw_vec(lessopen_output.stdout), + "-".to_string(), + OpenedInputKind::StdIn, + ) + } else { + return input.open(stdin, stdout_identifier); + } + } + InputKind::CustomReader(_) => { + return input.open(stdin, stdout_identifier); + } + }; + + Ok(OpenedInput { + kind, + reader: InputReader::new(BufReader::new( + if matches!(self.kind, LessOpenKind::TempFile) { + // Remove newline at end of temporary file path returned by $LESSOPEN + let stdout = match lessopen_stdout.strip_suffix("\n") { + Some(stripped) => stripped.to_owned(), + None => lessopen_stdout, + }; + + let stdout = stdout.into_os_string(); + + let file = match File::open(PathBuf::from(&stdout)) { + Ok(file) => file, + Err(_) => { + return input.open(stdin, stdout_identifier); + } + }; + + Preprocessed { + kind: PreprocessedKind::TempFile(file), + lessclose: self.lessclose.clone(), + command_args: vec![path_str, stdout.to_str().unwrap().to_string()], + command_options: self.command_options.clone(), + } + } else { + Preprocessed { + kind: PreprocessedKind::Piped(Cursor::new(lessopen_stdout.into_raw_vec())), + lessclose: self.lessclose.clone(), + command_args: vec![path_str, "-".to_string()], + command_options: self.command_options.clone(), + } + }, + )), + metadata: input.metadata, + description: input.description, + }) + } + + fn fall_back_to_original_file(&self, lessopen_output: &str, exit_code: i32) -> bool { + lessopen_output.is_empty() + && (exit_code != 0 || matches!(self.kind, LessOpenKind::PipedIgnoreExitCode)) + } + + #[cfg(test)] + /// For testing purposes only + /// Create an instance of LessOpenPreprocessor with specified valued for $LESSOPEN and $LESSCLOSE + fn mock_new(lessopen: Option<&str>, lessclose: Option<&str>) -> Result { + if let Some(command) = lessopen { + env::set_var("LESSOPEN", command) + } else { + env::remove_var("LESSOPEN") + } + + if let Some(command) = lessclose { + env::set_var("LESSCLOSE", command) + } else { + env::remove_var("LESSCLOSE") + } + + Self::new() + } +} + +enum PreprocessedKind { + Piped(Cursor>), + TempFile(File), +} + +impl Read for PreprocessedKind { + fn read(&mut self, buf: &mut [u8]) -> std::result::Result { + match self { + PreprocessedKind::Piped(data) => data.read(buf), + PreprocessedKind::TempFile(data) => data.read(buf), + } + } +} + +pub struct Preprocessed { + kind: PreprocessedKind, + lessclose: Option, + command_args: Vec, + command_options: ScriptOptions, +} + +impl Read for Preprocessed { + fn read(&mut self, buf: &mut [u8]) -> std::result::Result { + self.kind.read(buf) + } +} + +impl Drop for Preprocessed { + fn drop(&mut self) { + if let Some(ref command) = self.lessclose { + self.command_options.output_redirection = IoOptions::Inherit; + + run_script::run(command, &self.command_args, &self.command_options) + .expect("failed to run $LESSCLOSE to clean up file"); + } + } +} + +#[cfg(test)] +mod tests { + // All tests here are serial because they all involve reading and writing environment variables + // Running them in parallel causes these tests and some others to randomly fail + use serial_test::serial; + + use super::*; + + /// Reset environment variables after each test as a precaution + fn reset_env_vars() { + env::remove_var("LESSOPEN"); + env::remove_var("LESSCLOSE"); + } + + #[test] + #[serial] + fn test_just_lessopen() -> Result<()> { + let preprocessor = LessOpenPreprocessor::mock_new(Some("|batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(preprocessor.lessclose.is_none()); + + reset_env_vars(); + + Ok(()) + } + + #[test] + #[serial] + fn test_just_lessclose() -> Result<()> { + let preprocessor = LessOpenPreprocessor::mock_new(None, Some("lessclose.sh %s %s")); + + assert!(preprocessor.is_err()); + + reset_env_vars(); + + Ok(()) + } + + #[test] + #[serial] + fn test_both_lessopen_and_lessclose() -> Result<()> { + let preprocessor = + LessOpenPreprocessor::mock_new(Some("lessopen.sh %s"), Some("lessclose.sh %s %s"))?; + + assert_eq!(preprocessor.lessopen, "lessopen.sh $1"); + assert_eq!(preprocessor.lessclose.unwrap(), "lessclose.sh $1 $2"); + + reset_env_vars(); + + Ok(()) + } + + #[test] + #[serial] + fn test_lessopen_prefixes() -> Result<()> { + let preprocessor = LessOpenPreprocessor::mock_new(Some("batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!(preprocessor.kind, LessOpenKind::TempFile)); + assert!(!preprocessor.preprocess_stdin); + + let preprocessor = LessOpenPreprocessor::mock_new(Some("|batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!( + preprocessor.kind, + LessOpenKind::PipedIgnoreExitCode + )); + assert!(!preprocessor.preprocess_stdin); + + let preprocessor = LessOpenPreprocessor::mock_new(Some("||batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!(preprocessor.kind, LessOpenKind::Piped)); + assert!(!preprocessor.preprocess_stdin); + + let preprocessor = LessOpenPreprocessor::mock_new(Some("-batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!(preprocessor.kind, LessOpenKind::TempFile)); + assert!(preprocessor.preprocess_stdin); + + let preprocessor = LessOpenPreprocessor::mock_new(Some("|-batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!( + preprocessor.kind, + LessOpenKind::PipedIgnoreExitCode + )); + assert!(preprocessor.preprocess_stdin); + + let preprocessor = LessOpenPreprocessor::mock_new(Some("||-batpipe %s"), None)?; + + assert_eq!(preprocessor.lessopen, "batpipe $1"); + assert!(matches!(preprocessor.kind, LessOpenKind::Piped)); + assert!(preprocessor.preprocess_stdin); + + reset_env_vars(); + + Ok(()) + } + + #[test] + #[serial] + fn replace_part_of_argument() -> Result<()> { + let preprocessor = + LessOpenPreprocessor::mock_new(Some("|echo File:%s"), Some("echo File:%s Temp:%s"))?; + + assert_eq!(preprocessor.lessopen, "echo File:$1"); + assert_eq!(preprocessor.lessclose.unwrap(), "echo File:$1 Temp:$2"); + + reset_env_vars(); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4f56f85b..0296ad32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,8 @@ mod diff; pub mod error; pub mod input; mod less; +#[cfg(feature = "lessopen")] +mod lessopen; pub mod line_range; pub(crate) mod nonprintable_notation; mod output; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index d494d280..8fc2c30c 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2025,3 +2025,200 @@ fn acknowledgements() { ) .stderr(""); } + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +fn lessopen_file_piped() { + bat() + .env("LESSOPEN", "|echo File is %s") + .arg("test.txt") + .assert() + .success() + .stdout("File is test.txt\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +fn lessopen_stdin_piped() { + bat() + .env("LESSOPEN", "|cat") + .write_stdin("hello world\n") + .assert() + .success() + .stdout("hello world\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +#[serial] // Randomly fails otherwise +fn lessopen_and_lessclose_file_temp() { + // This is mainly to test that $LESSCLOSE gets passed the correct file paths + // In this case, the original file and the temporary file returned by $LESSOPEN + bat() + .env("LESSOPEN", "echo empty.txt") + .env("LESSCLOSE", "echo lessclose: %s %s") + .arg("test.txt") + .assert() + .success() + .stdout("lessclose: test.txt empty.txt\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +#[serial] // Randomly fails otherwise +fn lessopen_and_lessclose_file_piped() { + // This is mainly to test that $LESSCLOSE gets passed the correct file paths + // In these cases, the original file and a dash + bat() + // This test will not work properly if $LESSOPEN does not output anything + .env("LESSOPEN", "|cat test.txt ") + .env("LESSCLOSE", "echo lessclose: %s %s") + .arg("empty.txt") + .assert() + .success() + .stdout("hello world\nlessclose: empty.txt -\n"); + + bat() + .env("LESSOPEN", "||cat empty.txt") + .env("LESSCLOSE", "echo lessclose: %s %s") + .arg("empty.txt") + .assert() + .success() + .stdout("lessclose: empty.txt -\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +#[serial] // Randomly fails otherwise +fn lessopen_and_lessclose_stdin_temp() { + // This is mainly to test that $LESSCLOSE gets passed the correct file paths + // In this case, a dash and the temporary file returned by $LESSOPEN + bat() + .env("LESSOPEN", "-echo empty.txt") + .env("LESSCLOSE", "echo lessclose: %s %s") + .write_stdin("test.txt") + .assert() + .success() + .stdout("lessclose: - empty.txt\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +#[serial] // Randomly fails otherwise +fn lessopen_and_lessclose_stdin_piped() { + // This is mainly to test that $LESSCLOSE gets passed the correct file paths + // In these cases, two dashes + bat() + // This test will not work properly if $LESSOPEN does not output anything + .env("LESSOPEN", "|-cat test.txt") + .env("LESSCLOSE", "echo lessclose: %s %s") + .write_stdin("empty.txt") + .assert() + .success() + .stdout("hello world\nlessclose: - -\n"); + + bat() + .env("LESSOPEN", "||-cat empty.txt") + .env("LESSCLOSE", "echo lessclose: %s %s") + .write_stdin("empty.txt") + .assert() + .success() + .stdout("lessclose: - -\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +fn lessopen_handling_empty_output_file() { + bat() + .env("LESSOPEN", "|cat empty.txt") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); + + bat() + .env("LESSOPEN", "|cat nonexistent.txt") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); + + bat() + .env("LESSOPEN", "||cat empty.txt") + .arg("test.txt") + .assert() + .success() + .stdout(""); + + bat() + .env("LESSOPEN", "||cat nonexistent.txt") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +fn lessopen_handling_empty_output_stdin() { + bat() + .env("LESSOPEN", "|-cat empty.txt") + .write_stdin("hello world\n") + .assert() + .success() + .stdout("hello world\n"); + + bat() + .env("LESSOPEN", "|-cat nonexistent.txt") + .write_stdin("hello world\n") + .assert() + .success() + .stdout("hello world\n"); + + bat() + .env("LESSOPEN", "||-cat empty.txt") + .write_stdin("hello world\n") + .assert() + .success() + .stdout(""); + + bat() + .env("LESSOPEN", "||-cat nonexistent.txt") + .write_stdin("hello world\n") + .assert() + .success() + .stdout("hello world\n"); +} + +#[cfg(unix)] // Expected output assumed that tests are run on a Unix-like system +#[cfg(feature = "lessopen")] +#[test] +fn lessopen_uses_shell() { + bat() + .env("LESSOPEN", "|cat < %s") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); +} + +#[cfg(unix)] +#[cfg(feature = "lessopen")] +#[test] +fn do_not_use_lessopen() { + bat() + .env("LESSOPEN", "|echo File is %s") + .arg("--no-lessopen") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); +} diff --git a/tests/utils/command.rs b/tests/utils/command.rs index 87420f4b..40b01960 100644 --- a/tests/utils/command.rs +++ b/tests/utils/command.rs @@ -17,6 +17,9 @@ pub fn bat_raw_command_with_config() -> Command { cmd.env_remove("COLORTERM"); cmd.env_remove("NO_COLOR"); cmd.env_remove("PAGER"); + cmd.env_remove("LESSOPEN"); + cmd.env_remove("LESSCLOSE"); + cmd.env_remove("SHELL"); cmd }