Add $LESSOPEN and $LESSCLOSE support (#2444)

This commit is contained in:
Anomalocaridid 2023-09-02 06:48:26 +00:00 committed by GitHub
parent 3abc0c0fc4
commit e32ad0b048
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 748 additions and 4 deletions

View File

@ -77,6 +77,7 @@
- Make the default macOS theme depend on Dark Mode. See #2197, #1746 (@Enselic) - Make the default macOS theme depend on Dark Mode. See #2197, #1746 (@Enselic)
- Support for separate system and user config files. See #668 (@patrickpichler) - Support for separate system and user config files. See #668 (@patrickpichler)
- Add support for $LESSOPEN and $LESSCLOSE. See #1597, #1739, and #2444 (@Anomalocaridid)
## Bugfixes ## Bugfixes

89
Cargo.lock generated
View File

@ -140,10 +140,12 @@ dependencies = [
"nix", "nix",
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"os_str_bytes",
"path_abs", "path_abs",
"plist", "plist",
"predicates", "predicates",
"regex", "regex",
"run_script",
"semver", "semver",
"serde", "serde",
"serde_yaml", "serde_yaml",
@ -351,6 +353,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "dunce"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c"
[[package]] [[package]]
name = "either" name = "either"
version = "1.8.0" version = "1.8.0"
@ -464,6 +472,27 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "git-version" name = "git-version"
version = "0.3.5" version = "0.3.5"
@ -773,6 +802,15 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.1" version = "0.12.1"
@ -831,6 +869,12 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]] [[package]]
name = "predicates" name = "predicates"
version = "3.0.3" version = "3.0.3"
@ -895,6 +939,36 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@ -951,6 +1025,15 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "run_script"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fdc55b3a7ad58e02de47eaf7a854c6791c8421da48ff296c152317d3beaf230"
dependencies = [
"fsio",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.37.3" version = "0.37.3"
@ -1322,6 +1405,12 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "wild" name = "wild"
version = "2.1.0" version = "2.1.0"

View File

@ -21,6 +21,7 @@ application = [
"build-assets", "build-assets",
"git", "git",
"minimal-application", "minimal-application",
"lessopen",
] ]
# Mainly for developers that want to iterate quickly # Mainly for developers that want to iterate quickly
# Be aware that the included features might change in the future # Be aware that the included features might change in the future
@ -33,6 +34,7 @@ minimal-application = [
] ]
git = ["git2"] # Support indicating git modifications git = ["git2"] # Support indicating git modifications
paging = ["shell-words", "grep-cli"] # Support applying a pager on the output 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"] 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: # 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 } walkdir = { version = "2.3", optional = true }
bytesize = { version = "1.2.0" } bytesize = { version = "1.2.0" }
encoding_rs = "0.8.32" encoding_rs = "0.8.32"
os_str_bytes = { version = "~6.4", optional = true }
run_script = { version = "^0.10.0", optional = true}
[dependencies.git2] [dependencies.git2]
version = "0.18" version = "0.18"

View File

@ -59,6 +59,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
[CompletionResult]::new('--unbuffered', 'unbuffered', [CompletionResultType]::ParameterName, 'unbuffered') [CompletionResult]::new('--unbuffered', 'unbuffered', [CompletionResultType]::ParameterName, 'unbuffered')
[CompletionResult]::new('--no-config', 'no-config', [CompletionResultType]::ParameterName, 'Do not use the configuration file') [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-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('--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('--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.') [CompletionResult]::new('--config-dir', 'config-dir', [CompletionResultType]::ParameterName, 'Show bat''s configuration directory.')

View File

@ -46,6 +46,7 @@ _{{PROJECT_EXECUTABLE}}_main() {
'(: --list-themes --list-languages -L)'{-L,--list-languages}'[Display all supported languages]' '(: --list-themes --list-languages -L)'{-L,--list-languages}'[Display all supported languages]'
'(: --no-config)'--no-config'[Do not use the configuration file]' '(: --no-config)'--no-config'[Do not use the configuration file]'
'(: --no-custom-assets)'--no-custom-assets'[Do not load custom assets]' '(: --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-dir)'--config-dir'[Show bat'"'"'s configuration directory]'
'(: --config-file)'--config-file'[Show path to the configuration file]' '(: --config-file)'--config-file'[Show path to the configuration file]'
'(: --generate-config-file)'--generate-config-file'[Generates a default configuration file]' '(: --generate-config-file)'--generate-config-file'[Generates a default configuration file]'

View File

@ -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. 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 These can be installed to `\fB$({{PROJECT_EXECUTABLE}} --config-dir)/themes\fR`, and are added to the cache with
`\fB{{PROJECT_EXECUTABLE}} cache --build`. `\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" .SH "MORE INFORMATION"
For more information and up-to-date documentation, visit the {{PROJECT_EXECUTABLE}} repo: For more information and up-to-date documentation, visit the {{PROJECT_EXECUTABLE}} repo:

View File

@ -281,6 +281,8 @@ impl App {
.map(HighlightedLineRanges) .map(HighlightedLineRanges)
.unwrap_or_default(), .unwrap_or_default(),
use_custom_assets: !self.matches.get_flag("no-custom-assets"), use_custom_assets: !self.matches.get_flag("no-custom-assets"),
#[cfg(feature = "lessopen")]
use_lessopen: !self.matches.get_flag("no-lessopen"),
}) })
} }

View File

@ -497,7 +497,20 @@ pub fn build_app(interactive_output: bool) -> Command {
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.hide(true) .hide(true)
.help("Do not load custom assets"), .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(
Arg::new("config-file") Arg::new("config-file")
.long("config-file") .long("config-file")
@ -536,7 +549,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.alias("diagnostics") .alias("diagnostics")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.hide_short_help(true) .hide_short_help(true)
.help("Show diagnostic information for bug reports.") .help("Show diagnostic information for bug reports."),
) )
.arg( .arg(
Arg::new("acknowledgements") Arg::new("acknowledgements")

View File

@ -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. /// 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. /// cached assets) are not available, assets from the binary will be used instead.
pub use_custom_assets: bool, 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"))] #[cfg(all(feature = "minimal-application", feature = "paging"))]

View File

@ -6,6 +6,8 @@ use crate::config::{Config, VisibleLines};
use crate::diff::{get_git_diff, LineChanges}; use crate::diff::{get_git_diff, LineChanges};
use crate::error::*; use crate::error::*;
use crate::input::{Input, InputReader, OpenedInput}; use crate::input::{Input, InputReader, OpenedInput};
#[cfg(feature = "lessopen")]
use crate::lessopen::LessOpenPreprocessor;
#[cfg(feature = "git")] #[cfg(feature = "git")]
use crate::line_range::LineRange; use crate::line_range::LineRange;
use crate::line_range::{LineRanges, RangeCheckResult}; use crate::line_range::{LineRanges, RangeCheckResult};
@ -19,11 +21,18 @@ use clircle::{Clircle, Identifier};
pub struct Controller<'a> { pub struct Controller<'a> {
config: &'a Config<'a>, config: &'a Config<'a>,
assets: &'a HighlightingAssets, assets: &'a HighlightingAssets,
#[cfg(feature = "lessopen")]
preprocessor: Option<LessOpenPreprocessor>,
} }
impl<'b> Controller<'b> { impl<'b> Controller<'b> {
pub fn new<'a>(config: &'a Config, assets: &'a HighlightingAssets) -> Controller<'a> { 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( pub fn run(
@ -123,7 +132,18 @@ impl<'b> Controller<'b> {
stdout_identifier: Option<&Identifier>, stdout_identifier: Option<&Identifier>,
is_first: bool, is_first: bool,
) -> Result<()> { ) -> 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")] #[cfg(feature = "git")]
let line_changes = if self.config.visible_lines.diff_mode() let line_changes = if self.config.visible_lines.diff_mode()
|| (!self.config.loop_through && self.config.style_components.changes()) || (!self.config.loop_through && self.config.style_components.changes())

View File

@ -28,6 +28,12 @@ pub enum Error {
InvalidPagerValueBat, InvalidPagerValueBat,
#[error("{0}")] #[error("{0}")]
Msg(String), 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 { impl From<&'static str> for Error {

View File

@ -256,7 +256,7 @@ pub(crate) struct InputReader<'a> {
} }
impl<'a> 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![]; let mut first_line = vec![];
reader.read_until(b'\n', &mut first_line).ok(); reader.read_until(b'\n', &mut first_line).ok();

390
src/lessopen.rs Normal file
View File

@ -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(())
}
}

View File

@ -34,6 +34,8 @@ mod diff;
pub mod error; pub mod error;
pub mod input; pub mod input;
mod less; mod less;
#[cfg(feature = "lessopen")]
mod lessopen;
pub mod line_range; pub mod line_range;
pub(crate) mod nonprintable_notation; pub(crate) mod nonprintable_notation;
mod output; mod output;

View File

@ -2025,3 +2025,200 @@ fn acknowledgements() {
) )
.stderr(""); .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");
}

View File

@ -17,6 +17,9 @@ pub fn bat_raw_command_with_config() -> Command {
cmd.env_remove("COLORTERM"); cmd.env_remove("COLORTERM");
cmd.env_remove("NO_COLOR"); cmd.env_remove("NO_COLOR");
cmd.env_remove("PAGER"); cmd.env_remove("PAGER");
cmd.env_remove("LESSOPEN");
cmd.env_remove("LESSCLOSE");
cmd.env_remove("SHELL");
cmd cmd
} }