on master help sign in
Merge pull request #1402 from Enselic/fix-1063

Fix #1063: Do not use 'more' or 'most' PAGER, as they are not compatible with bats output

commit252e5a6b1360bab10b90f59926e7fa784c1b7f9d authorDavid Peter <sharkdp@users.noreply.github.com> dateSun 10 Jan 2021, 14:51 (6 years ago) parentfaa27ed8dd67cc
13 files changed+408−82
CHANGELOG.md+1 Cargo.lock+78 Cargo.toml+1 src/lib.rs+2 src/output.rs+53−82 src/pager.rs+115 tests/integration_tests.rs+82 tests/mocked-pagers/more+2 tests/mocked-pagers/more.bat+1 tests/mocked-pagers/most+2 tests/mocked-pagers/most.bat+1 tests/utils/mocked_pagers.rs+69 tests/utils/mod.rs+1
CHANGELOG.md+1
@@ -13,6 +13,7 @@
1313- VimL syntax highlighting fix, see #1450 (@esensar)
1414- Print an 'Invalid syntax theme settings' error message if a custom theme is broken, see #614 (@Enselic)
1515- If plain mode is set and wrap is not explicitly opted in, long lines will no be truncated, see #1426
16+- If `PAGER` (but not `BAT_PAGER` or `--pager`) is `more` or `most`, silently use `less` instead to ensure support for colors, see #1063 (@Enselic)
1617
1718## Other
1819
Cargo.lock+78
@@ -114,6 +114,7 @@ dependencies = [
114114 "semver",
115115 "serde",
116116 "serde_yaml",
117+ "serial_test",
117118 "shell-words",
118119 "syntect",
119120 "tempdir",
@@ -550,6 +551,15 @@ dependencies = [
550551 "hashbrown",
551552]
552553
554+[[package]]
555+name = "instant"
556+version = "0.1.9"
557+source = "registry+https://github.com/rust-lang/crates.io-index"
558+checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec"
559+dependencies = [
560+ "cfg-if 1.0.0",
561+]
562+
553563[[package]]
554564name = "itoa"
555565version = "0.4.7"
@@ -622,6 +632,15 @@ version = "0.5.4"
622632source = "registry+https://github.com/rust-lang/crates.io-index"
623633checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
624634
635+[[package]]
636+name = "lock_api"
637+version = "0.4.2"
638+source = "registry+https://github.com/rust-lang/crates.io-index"
639+checksum = "dd96ffd135b2fd7b973ac026d28085defbe8983df057ced3eb4f2130b0831312"
640+dependencies = [
641+ "scopeguard",
642+]
643+
625644[[package]]
626645name = "log"
627646version = "0.4.11"
@@ -712,6 +731,31 @@ dependencies = [
712731 "pkg-config",
713732]
714733
734+[[package]]
735+name = "parking_lot"
736+version = "0.11.1"
737+source = "registry+https://github.com/rust-lang/crates.io-index"
738+checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb"
739+dependencies = [
740+ "instant",
741+ "lock_api",
742+ "parking_lot_core",
743+]
744+
745+[[package]]
746+name = "parking_lot_core"
747+version = "0.8.2"
748+source = "registry+https://github.com/rust-lang/crates.io-index"
749+checksum = "9ccb628cad4f84851442432c60ad8e1f607e29752d0bf072cbd0baf28aa34272"
750+dependencies = [
751+ "cfg-if 1.0.0",
752+ "instant",
753+ "libc",
754+ "redox_syscall",
755+ "smallvec",
756+ "winapi",
757+]
758+
715759[[package]]
716760name = "path_abs"
717761version = "0.5.0"
@@ -917,6 +961,12 @@ dependencies = [
917961 "winapi-util",
918962]
919963
964+[[package]]
965+name = "scopeguard"
966+version = "1.1.0"
967+source = "registry+https://github.com/rust-lang/crates.io-index"
968+checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
969+
920970[[package]]
921971name = "semver"
922972version = "0.11.0"
@@ -978,12 +1028,40 @@ dependencies = [
9781028 "yaml-rust",
9791029]
9801030
1031+[[package]]
1032+name = "serial_test"
1033+version = "0.5.1"
1034+source = "registry+https://github.com/rust-lang/crates.io-index"
1035+checksum = "e0bccbcf40c8938196944a3da0e133e031a33f4d6b72db3bda3cc556e361905d"
1036+dependencies = [
1037+ "lazy_static",
1038+ "parking_lot",
1039+ "serial_test_derive",
1040+]
1041+
1042+[[package]]
1043+name = "serial_test_derive"
1044+version = "0.5.1"
1045+source = "registry+https://github.com/rust-lang/crates.io-index"
1046+checksum = "b2acd6defeddb41eb60bb468f8825d0cfd0c2a76bc03bfd235b6a1dc4f6a1ad5"
1047+dependencies = [
1048+ "proc-macro2",
1049+ "quote",
1050+ "syn",
1051+]
1052+
9811053[[package]]
9821054name = "shell-words"
9831055version = "1.0.0"
9841056source = "registry+https://github.com/rust-lang/crates.io-index"
9851057checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074"
9861058
1059+[[package]]
1060+name = "smallvec"
1061+version = "1.6.1"
1062+source = "registry+https://github.com/rust-lang/crates.io-index"
1063+checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e"
1064+
9871065[[package]]
9881066name = "snailquote"
9891067version = "0.3.0"
Cargo.toml+1
@@ -76,6 +76,7 @@ default-features = false
7676[dev-dependencies]
7777tempdir = "0.3"
7878assert_cmd = "1.0.2"
79+serial_test = "0.5.1"
7980predicates = "1.0.6"
8081wait-timeout = "0.2.0"
8182
src/lib.rs+2
@@ -33,6 +33,8 @@ mod less;
3333pub mod line_range;
3434mod output;
3535#[cfg(feature = "paging")]
36+mod pager;
37+#[cfg(feature = "paging")]
3638pub(crate) mod paging;
3739mod preprocessor;
3840mod pretty_printer;
src/output.rs+53−82
@@ -48,100 +48,71 @@ impl OutputType {
4848 wrapping_mode: WrappingMode,
4949 pager_from_config: Option<&str>,
5050 ) -> Result<Self> {
51− use std::env;
52− use std::ffi::OsString;
53− use std::path::PathBuf;
51+ use crate::pager::{self, PagerKind, PagerSource};
5452 use std::process::{Command, Stdio};
5553
56− let mut replace_arguments_to_less = false;
54+ let pager_opt =
55+ pager::get_pager(pager_from_config).chain_err(|| "Could not parse pager command.")?;
5756
58− let pager_from_env = match (env::var("BAT_PAGER"), env::var("PAGER")) {
59− (Ok(bat_pager), _) => Some(bat_pager),
60− (_, Ok(pager)) => {
61− // less needs to be called with the '-R' option in order to properly interpret the
62− // ANSI color sequences printed by bat. If someone has set PAGER="less -F", we
63− // therefore need to overwrite the arguments and add '-R'.
64− //
65− // We only do this for PAGER (as it is not specific to 'bat'), not for BAT_PAGER
66− // or bats '--pager' command line option.
67− replace_arguments_to_less = true;
68− Some(pager)
69− }
70− _ => None,
57+ let pager = match pager_opt {
58+ Some(pager) => pager,
59+ None => return Ok(OutputType::stdout()),
7160 };
7261
73− let pager_from_config = pager_from_config.map(|p| p.to_string());
74−
75− if pager_from_config.is_some() {
76− replace_arguments_to_less = false;
62+ if pager.kind == PagerKind::Bat {
63+ return Err(ErrorKind::InvalidPagerValueBat.into());
7764 }
7865
79− let pager = pager_from_config
80− .or(pager_from_env)
81− .unwrap_or_else(|| String::from("less"));
82−
83− let pagerflags =
84− shell_words::split(&pager).chain_err(|| "Could not parse pager command.")?;
85−
86− match pagerflags.split_first() {
87− Some((pager_name, args)) => {
88− let pager_path = PathBuf::from(pager_name);
66+ let mut p = Command::new(pager.bin);
67+ let args = pager.args;
68+
69+ if pager.kind == PagerKind::Less {
70+ // less needs to be called with the '-R' option in order to properly interpret the
71+ // ANSI color sequences printed by bat. If someone has set PAGER="less -F", we
72+ // therefore need to overwrite the arguments and add '-R'.
73+ //
74+ // We only do this for PAGER (as it is not specific to 'bat'), not for BAT_PAGER
75+ // or bats '--pager' command line option.
76+ let replace_arguments_to_less = pager.source == PagerSource::EnvVarPager;
77+
78+ if args.is_empty() || replace_arguments_to_less {
79+ p.arg("--RAW-CONTROL-CHARS");
80+ if single_screen_action == SingleScreenAction::Quit {
81+ p.arg("--quit-if-one-screen");
82+ }
8983
90− if pager_path.file_stem() == Some(&OsString::from("bat")) {
91− return Err(ErrorKind::InvalidPagerValueBat.into());
84+ if wrapping_mode == WrappingMode::NoWrapping(true) {
85+ p.arg("--chop-long-lines");
9286 }
9387
94− let is_less = pager_path.file_stem() == Some(&OsString::from("less"));
95−
96− let mut process = if is_less {
97− let mut p = Command::new(&pager_path);
98− if args.is_empty() || replace_arguments_to_less {
99− p.arg("--RAW-CONTROL-CHARS");
100− if single_screen_action == SingleScreenAction::Quit {
101− p.arg("--quit-if-one-screen");
102− }
103−
104− if wrapping_mode == WrappingMode::NoWrapping(true) {
105− p.arg("--chop-long-lines");
106− }
107−
108− // Passing '--no-init' fixes a bug with '--quit-if-one-screen' in older
109− // versions of 'less'. Unfortunately, it also breaks mouse-wheel support.
110− //
111− // See: http://www.greenwoodsoftware.com/less/news.530.html
112− //
113− // For newer versions (530 or 558 on Windows), we omit '--no-init' as it
114− // is not needed anymore.
115− match retrieve_less_version() {
116− None => {
117− p.arg("--no-init");
118− }
119− Some(version)
120− if (version < 530 || (cfg!(windows) && version < 558)) =>
121− {
122− p.arg("--no-init");
123− }
124− _ => {}
125− }
126− } else {
127− p.args(args);
88+ // Passing '--no-init' fixes a bug with '--quit-if-one-screen' in older
89+ // versions of 'less'. Unfortunately, it also breaks mouse-wheel support.
90+ //
91+ // See: http://www.greenwoodsoftware.com/less/news.530.html
92+ //
93+ // For newer versions (530 or 558 on Windows), we omit '--no-init' as it
94+ // is not needed anymore.
95+ match retrieve_less_version() {
96+ None => {
97+ p.arg("--no-init");
98+ }
99+ Some(version) if (version < 530 || (cfg!(windows) && version < 558)) => {
100+ p.arg("--no-init");
128101 }
129− p.env("LESSCHARSET", "UTF-8");
130− p
131− } else {
132− let mut p = Command::new(&pager_path);
133− p.args(args);
134− p
135− };
136−
137− Ok(process
138− .stdin(Stdio::piped())
139− .spawn()
140− .map(OutputType::Pager)
141− .unwrap_or_else(|_| OutputType::stdout()))
102+ _ => {}
103+ }
104+ } else {
105+ p.args(args);
142106 }
143− None => Ok(OutputType::stdout()),
144− }
107+ p.env("LESSCHARSET", "UTF-8");
108+ } else {
109+ p.args(args);
110+ };
111+
112+ Ok(p.stdin(Stdio::piped())
113+ .spawn()
114+ .map(OutputType::Pager)
115+ .unwrap_or_else(|_| OutputType::stdout()))
145116 }
146117
147118 pub(crate) fn stdout() -> Self {
src/pager.rsnew file+115
@@ -0,0 +1,115 @@
1+use shell_words::ParseError;
2+use std::env;
3+
4+/// If we use a pager, this enum tells us from where we were told to use it.
5+#[derive(Debug, PartialEq)]
6+pub(crate) enum PagerSource {
7+ /// From --config
8+ Config,
9+
10+ /// From the env var BAT_PAGER
11+ EnvVarBatPager,
12+
13+ /// From the env var PAGER
14+ EnvVarPager,
15+
16+ /// No pager was specified, default is used
17+ Default,
18+}
19+
20+/// We know about some pagers, for example 'less'. This is a list of all pagers we know about
21+#[derive(Debug, PartialEq)]
22+pub(crate) enum PagerKind {
23+ /// bat
24+ Bat,
25+
26+ /// less
27+ Less,
28+
29+ /// more
30+ More,
31+
32+ /// most
33+ Most,
34+
35+ /// A pager we don't know about
36+ Unknown,
37+}
38+
39+impl PagerKind {
40+ fn from_bin(bin: &str) -> PagerKind {
41+ use std::path::Path;
42+
43+ match Path::new(bin)
44+ .file_stem()
45+ .map(|s| s.to_string_lossy())
46+ .as_deref()
47+ {
48+ Some("bat") => PagerKind::Bat,
49+ Some("less") => PagerKind::Less,
50+ Some("more") => PagerKind::More,
51+ Some("most") => PagerKind::Most,
52+ _ => PagerKind::Unknown,
53+ }
54+ }
55+}
56+
57+/// A pager such as 'less', and from where we got it.
58+#[derive(Debug)]
59+pub(crate) struct Pager {
60+ /// The pager binary
61+ pub bin: String,
62+
63+ /// The pager binary arguments (that we might tweak)
64+ pub args: Vec<String>,
65+
66+ /// What pager this is
67+ pub kind: PagerKind,
68+
69+ /// From where this pager comes
70+ pub source: PagerSource,
71+}
72+
73+impl Pager {
74+ fn new(bin: &str, args: &[String], kind: PagerKind, source: PagerSource) -> Pager {
75+ Pager {
76+ bin: String::from(bin),
77+ args: args.to_vec(),
78+ kind,
79+ source,
80+ }
81+ }
82+}
83+
84+/// Returns what pager to use, after looking at both config and environment variables.
85+pub(crate) fn get_pager(config_pager: Option<&str>) -> Result<Option<Pager>, ParseError> {
86+ let bat_pager = env::var("BAT_PAGER");
87+ let pager = env::var("PAGER");
88+
89+ let (cmd, source) = match (config_pager, &bat_pager, &pager) {
90+ (Some(config_pager), _, _) => (config_pager, PagerSource::Config),
91+ (_, Ok(bat_pager), _) => (bat_pager.as_str(), PagerSource::EnvVarBatPager),
92+ (_, _, Ok(pager)) => (pager.as_str(), PagerSource::EnvVarPager),
93+ _ => ("less", PagerSource::Default),
94+ };
95+
96+ let parts = shell_words::split(cmd)?;
97+ match parts.split_first() {
98+ Some((bin, args)) => {
99+ let kind = PagerKind::from_bin(bin);
100+
101+ // 'more' and 'most' do not supports colors; automatically use 'less' instead
102+ // if the problematic pager came from the generic PAGER env var
103+ let no_color_support = kind == PagerKind::More || kind == PagerKind::Most;
104+ let use_less_instead = no_color_support && source == PagerSource::EnvVarPager;
105+
106+ Ok(Some(if use_less_instead {
107+ let no_args = vec![];
108+ Pager::new("less", &no_args, PagerKind::Less, PagerSource::EnvVarPager)
109+ } else {
110+ Pager::new(bin, args, kind, source)
111+ }))
112+ }
113+ None => Ok(None),
114+ }
115+}
tests/integration_tests.rs+82
@@ -1,6 +1,7 @@
11use assert_cmd::assert::OutputAssertExt;
22use assert_cmd::cargo::CommandCargoExt;
33use predicates::{prelude::predicate, str::PredicateStrExt};
4+use serial_test::serial;
45use std::fs::File;
56use std::path::Path;
67use std::process::{Command, Stdio};
@@ -9,6 +10,9 @@ use std::str::from_utf8;
910#[cfg(unix)]
1011use std::time::Duration;
1112
13+mod utils;
14+use utils::mocked_pagers;
15+
1216const EXAMPLES_DIR: &str = "tests/examples";
1317
1418#[cfg(unix)]
@@ -505,6 +509,84 @@ fn pager_value_bat() {
505509 .failure();
506510}
507511
512+/// We shall use less instead of most if PAGER is used since PAGER
513+/// is a generic env var
514+#[test]
515+#[serial] // Because of PATH
516+fn pager_most_from_pager_env_var() {
517+ mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
518+ // If the output is not "I am most" then we know 'most' is not used
519+ bat()
520+ .env("PAGER", mocked_pagers::from("most"))
521+ .arg("--paging=always")
522+ .arg("test.txt")
523+ .assert()
524+ .success()
525+ .stdout(predicate::eq("hello world\n").normalize());
526+ });
527+}
528+
529+/// If the bat-specific BAT_PAGER is used, obey the wish of the user
530+/// and allow 'most'
531+#[test]
532+#[serial] // Because of PATH
533+fn pager_most_from_bat_pager_env_var() {
534+ mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
535+ bat()
536+ .env("BAT_PAGER", mocked_pagers::from("most"))
537+ .arg("--paging=always")
538+ .arg("test.txt")
539+ .assert()
540+ .success()
541+ .stdout(predicate::str::contains("I am most"));
542+ });
543+}
544+
545+/// Same reasoning with --pager as with BAT_PAGER
546+#[test]
547+#[serial] // Because of PATH
548+fn pager_most_from_pager_arg() {
549+ mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
550+ bat()
551+ .arg("--paging=always")
552+ .arg(format!("--pager={}", mocked_pagers::from("most")))
553+ .arg("test.txt")
554+ .assert()
555+ .success()
556+ .stdout(predicate::str::contains("I am most"));
557+ });
558+}
559+
560+/// Make sure the logic for 'most' applies even if an argument is passed
561+#[test]
562+#[serial] // Because of PATH
563+fn pager_most_with_arg() {
564+ mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
565+ bat()
566+ .env("PAGER", format!("{} -w", mocked_pagers::from("most")))
567+ .arg("--paging=always")
568+ .arg("test.txt")
569+ .assert()
570+ .success()
571+ .stdout(predicate::eq("hello world\n").normalize());
572+ });
573+}
574+
575+/// Sanity check that 'more' is treated like 'most'
576+#[test]
577+#[serial] // Because of PATH
578+fn pager_more() {
579+ mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
580+ bat()
581+ .env("PAGER", mocked_pagers::from("more"))
582+ .arg("--paging=always")
583+ .arg("test.txt")
584+ .assert()
585+ .success()
586+ .stdout(predicate::eq("hello world\n").normalize());
587+ });
588+}
589+
508590#[test]
509591fn alias_pager_disable() {
510592 bat()
tests/mocked-pagers/morenew file+2
@@ -0,0 +1,2 @@
1+#!/usr/bin/env bash
2+echo I am more
tests/mocked-pagers/more.batnew file+1
@@ -0,0 +1 @@
1+ECHO I am more
tests/mocked-pagers/mostnew file+2
@@ -0,0 +1,2 @@
1+#!/usr/bin/env bash
2+echo I am most
tests/mocked-pagers/most.batnew file+1
@@ -0,0 +1 @@
1+ECHO I am most
tests/utils/mocked_pagers.rsnew file+69
@@ -0,0 +1,69 @@
1+use assert_cmd::Command;
2+use predicates::prelude::predicate;
3+use std::env;
4+use std::path::{Path, PathBuf};
5+
6+/// For some tests we want mocked versions of some pagers
7+/// This fn returns the absolute path to the directory with these mocked pagers
8+fn get_mocked_pagers_dir() -> PathBuf {
9+ let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("Missing CARGO_MANIFEST_DIR");
10+ Path::new(&cargo_manifest_dir)
11+ .join("tests")
12+ .join("mocked-pagers")
13+}
14+
15+/// On Unix: 'most' -> 'most'
16+/// On Windows: 'most' -> 'most.bat'
17+pub fn from(base: &str) -> String {
18+ if cfg!(windows) {
19+ format!("{}.bat", base)
20+ } else {
21+ String::from(base)
22+ }
23+}
24+
25+/// Prepends a directory to the PATH environment variable
26+/// Returns the original value for later restoration
27+fn prepend_dir_to_path_env_var(dir: PathBuf) -> String {
28+ // Get current PATH
29+ let original_path = env::var("PATH").expect("No PATH?!");
30+
31+ // Add the new dir first
32+ let mut split_paths = env::split_paths(&original_path).collect::<Vec<_>>();
33+ split_paths.insert(0, dir);
34+
35+ // Set PATH with the new dir
36+ let new_path = env::join_paths(split_paths).expect("Failed to join paths");
37+ env::set_var("PATH", new_path);
38+
39+ // Return the original value for later restoration of it
40+ original_path
41+}
42+
43+/// Helper to restore the value of PATH
44+fn restore_path(original_path: String) {
45+ env::set_var("PATH", original_path);
46+}
47+
48+/// Allows test to run that require our mocked versions of 'more' and 'most'
49+/// in PATH. Temporarily changes PATH while the test code runs, and then restore it
50+/// to avoid pollution of global state
51+pub fn with_mocked_versions_of_more_and_most_in_path(actual_test: fn()) {
52+ let original_path = prepend_dir_to_path_env_var(get_mocked_pagers_dir());
53+
54+ // Make sure our own variants of 'more' and 'most' are used
55+ Command::new(from("more"))
56+ .assert()
57+ .success()
58+ .stdout(predicate::str::contains("I am more"));
59+ Command::new(from("most"))
60+ .assert()
61+ .success()
62+ .stdout(predicate::str::contains("I am most"));
63+
64+ // Now run the actual test
65+ actual_test();
66+
67+ // Make sure to restore PATH since it is global state
68+ restore_path(original_path);
69+}
tests/utils/mod.rsnew file+1
@@ -0,0 +1 @@
1+pub mod mocked_pagers;