summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAnomalocaridid <29845794+Anomalocaridid@users.noreply.github.com>2023-09-02 06:48:26 +0000
committerGitHub <noreply@github.com>2023-09-02 08:48:26 +0200
commite32ad0b048c52c81e61774266f2c19ea0feae67b (patch)
tree3b0e1fa80563bb97e4c02232bfac852f065aa0c2
parent3abc0c0fc4ab69c543c70db1fff9d49aed3c9010 (diff)
Add `$LESSOPEN` and `$LESSCLOSE` support (#2444)
-rw-r--r--CHANGELOG.md1
-rw-r--r--Cargo.lock89
-rw-r--r--Cargo.toml4
-rw-r--r--assets/completions/_bat.ps1.in1
-rw-r--r--assets/completions/bat.zsh.in1
-rw-r--r--assets/manual/bat.1.in11
-rw-r--r--src/bin/bat/app.rs2
-rw-r--r--src/bin/bat/clap_app.rs15
-rw-r--r--src/config.rs4
-rw-r--r--src/controller.rs24
-rw-r--r--src/error.rs6
-rw-r--r--src/input.rs2
-rw-r--r--src/lessopen.rs390
-rw-r--r--src/lib.rs2
-rw-r--r--tests/integration_tests.rs197
-rw-r--r--tests/utils/command.rs3
16 files changed, 748 insertions, 4 deletions
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",
@@ -352,6 +354,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -465,6 +473,27 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -774,6 +803,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -832,6 +870,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -896,6 +940,36 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -952,6 +1026,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1323,6 +1406,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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<LessOpenPreprocessor>,
}
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<R: BufRead + 'a>(mut reader: R) -> InputReader<'a> {
+ pub(crate) fn new<R: BufRead + 'a>(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<String>,
+ 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<LessOpenPreprocessor> {
+ 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<OpenedInput<'a>> {
+ 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<LessOpenPreprocessor> {
+ 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<Vec<u8>>),
+ TempFile(File),
+}
+
+impl Read for PreprocessedKind {
+ fn read(&mut self, buf: &mut [u8]) -> std::result::Result<usize, std::io::Error> {
+ match self {
+ PreprocessedKind::Piped(data) => data.read(buf),
+ PreprocessedKind::TempFile(data) => data.read(buf),
+ }
+ }
+}
+
+pub struct Preprocessed {
+ kind: PreprocessedKind,
+ lessclose: Option<String>,
+ command_args: Vec<String>,
+ command_options: ScriptOptions,
+}
+
+impl Read for Preprocessed {
+ fn read(&mut self, buf: &mut [u8]) -> std::result::Result<usize, std::io::Error> {
+ 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()