diff --git a/CHANGELOG.md b/CHANGELOG.md index 06db2cbb..a17726fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## Features +- Set terminal title to file names when Paging is not Paging::Never #2807 (@Oliver-Looney) + ## Bugfixes - Fix long file name wrapping in header, see #2835 (@FilipRazek) - Fix `NO_COLOR` support, see #2767 (@acuteenvy) +- Fix handling of inputs with OSC ANSI escape sequences, see #2541 and #2544 (@eth-p) +- Fix handling of inputs with combined ANSI color and attribute sequences, see #2185 and #2856 (@eth-p) +- Fix panel width when line 10000 wraps, see #2854 (@eth-p) ## Other @@ -20,12 +25,16 @@ - Use proper Architecture for Debian packages built for musl, see #2811 (@Enselic) - Pull in fix for unsafe-libyaml security advisory, see #2812 (@dtolnay) - Update git-version dependency to use Syn v2, see #2816 (@dtolnay) +- Update git2 dependency to v0.18.2, see #2852 (@eth-p) +- Apply clippy fixes #2864 (@cyqsimon) ## Syntaxes - `cmd-help`: scope subcommands followed by other terms, and other misc improvements, see #2819 (@victor-gp) - Upgrade JQ syntax, see #2820 (@dependabot[bot]) - Associate `xsh` files with `xonsh` syntax that is Python, see #2840 (@anki-code). +- Added auto detect syntax for `.jsonc` #2795 (@mxaddict) +- Added auto detect syntax for `.aws/{config,credentials}` #2795 (@mxaddict) ## Themes @@ -36,6 +45,7 @@ - [BREAKING] `SyntaxMapping::{empty,builtin}` are removed; use `SyntaxMapping::new` instead - [BREAKING] `SyntaxMapping::mappings` is replaced by `SyntaxMapping::{builtin,custom,all}_mappings` - Make `Controller::run_with_error_handler`'s error handler `FnMut`, see #2831 (@rhysd) +- Improve compile time by 20%, see #2815 (@dtolnay) # v0.24.0 diff --git a/Cargo.lock b/Cargo.lock index 4aaf7b32..ff674b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ "globset", "grep-cli", "home", - "indexmap 2.1.0", + "indexmap 2.2.2", "itertools", "nix", "nu-ansi-term", @@ -142,6 +142,7 @@ dependencies = [ "run_script", "semver", "serde", + "serde_derive", "serde_with", "serde_yaml", "serial_test", @@ -272,13 +273,14 @@ checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "clircle" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e87cbed5354f17bd8ca8821a097fb62599787fe8f611743fad7ee156a0a600" +checksum = "ec0b92245ea62a7a751db4b0e4a583f8978e508077ef6de24fcc0d0dc5311a8d" dependencies = [ "cfg-if", "libc", "serde", + "serde_derive", "winapi", ] @@ -580,7 +582,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax", ] [[package]] @@ -646,9 +648,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" dependencies = [ "equivalent", "hashbrown 0.14.1", @@ -693,9 +695,9 @@ checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" [[package]] name = "libgit2-sys" -version = "0.16.1+1.7.1" +version = "0.16.2+1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2a2bb3680b094add03bb3732ec520ece34da31a8cd2d633d1389d0f0fb60d0c" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" dependencies = [ "cc", "libc", @@ -1027,7 +1029,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax", ] [[package]] @@ -1038,15 +1040,9 @@ checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" - [[package]] name = "regex-syntax" version = "0.8.2" @@ -1113,9 +1109,9 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "semver" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" +checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" @@ -1150,28 +1146,29 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" dependencies = [ "serde", ] [[package]] name = "serde_with" -version = "3.4.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +checksum = "15d167997bd841ec232f5b2b8e0e26606df2e7caa4c31b95ea9ca52b200bd270" dependencies = [ "serde", + "serde_derive", "serde_with_macros", ] [[package]] name = "serde_with_macros" -version = "3.4.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +checksum = "865f9743393e638991566a8b7a479043c2c8da94a33e0a31f18214c9cae0a64d" dependencies = [ "darling", "proc-macro2", @@ -1185,7 +1182,7 @@ version = "0.9.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15e0ef66bf939a7c890a0bf6d5a733c70202225f9888a89ed5c62298b019129" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.2", "itoa", "ryu", "serde", @@ -1258,9 +1255,9 @@ dependencies = [ [[package]] name = "syntect" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02b4b303bf8d08bfeb0445cba5068a3d306b6baece1d5582171a9bf49188f91" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" dependencies = [ "bincode", "bitflags 1.3.2", @@ -1270,8 +1267,9 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.7.5", + "regex-syntax", "serde", + "serde_derive", "serde_json", "thiserror", "walkdir", @@ -1374,11 +1372,11 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "toml" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ff9e3abce27ee2c9a37f9ad37238c1bdd4e789c84ba37df76aa4d528f5072cc" +checksum = "c6a4b9e8023eb94392d3dca65d717c53abc5dad49c07cb65bb8fcd87115fa325" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.2", "serde", "serde_spanned", "toml_datetime", @@ -1396,11 +1394,11 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.20.7" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.2", "serde", "serde_spanned", "toml_datetime", diff --git a/Cargo.toml b/Cargo.toml index 3b7f10e6..05a2acb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,11 +53,12 @@ content_inspector = "0.2.4" shell-words = { version = "1.1.0", optional = true } unicode-width = "0.1.11" globset = "0.4" -serde = { version = "1.0", features = ["derive"] } +serde = "1.0" +serde_derive = "1.0" serde_yaml = "0.9.28" semver = "1.0" path_abs = { version = "0.5", default-features = false } -clircle = "0.4" +clircle = "0.5" bugreport = { version = "0.5.0", optional = true } etcetera = { version = "0.8.0", optional = true } grep-cli = { version = "0.1.10", optional = true } @@ -74,7 +75,7 @@ optional = true default-features = false [dependencies.syntect] -version = "5.1.0" +version = "5.2.0" default-features = false features = ["parsing"] @@ -94,19 +95,21 @@ serial_test = { version = "2.0.0", default-features = false } predicates = "3.0.4" wait-timeout = "0.2.0" tempfile = "3.8.1" +serde = { version = "1.0", features = ["derive"] } [target.'cfg(unix)'.dev-dependencies] nix = { version = "0.26.4", default-features = false, features = ["term"] } [build-dependencies] anyhow = "1.0.78" -indexmap = { version = "2.1.0", features = ["serde"] } +indexmap = { version = "2.2.2", features = ["serde"] } itertools = "0.11.0" once_cell = "1.18" regex = "1.10.2" -serde = { version = "1.0", features = ["derive"] } -serde_with = { version = "3.4.0", default-features = false, features = ["macros"] } -toml = { version = "0.8.6", features = ["preserve_order"] } +serde = "1.0" +serde_derive = "1.0" +serde_with = { version = "3.6.1", default-features = false, features = ["macros"] } +toml = { version = "0.8.9", features = ["preserve_order"] } walkdir = "2.4" [build-dependencies.clap] diff --git a/README.md b/README.md index 352ae64d..57baf2b0 100644 --- a/README.md +++ b/README.md @@ -602,7 +602,8 @@ set, `less` is used by default. If you want to use a different pager, you can ei `PAGER` variable or set the `BAT_PAGER` environment variable to override what is specified in `PAGER`. -**Note**: If `PAGER` is `more` or `most`, `bat` will silently use `less` instead to ensure support for colors. +>[!NOTE] +> If `PAGER` is `more` or `most`, `bat` will silently use `less` instead to ensure support for colors. If you want to pass command-line arguments to the pager, you can also set them via the `PAGER`/`BAT_PAGER` variables: @@ -613,20 +614,37 @@ export BAT_PAGER="less -RF" Instead of using environment variables, you can also use `bat`s [configuration file](https://github.com/sharkdp/bat#configuration-file) to configure the pager (`--pager` option). -**Note**: By default, if the pager is set to `less` (and no command-line options are specified), -`bat` will pass the following command line options to the pager: `-R`/`--RAW-CONTROL-CHARS`, -`-F`/`--quit-if-one-screen` and `-X`/`--no-init`. The last option (`-X`) is only used for `less` -versions older than 530. -The `-R` option is needed to interpret ANSI colors correctly. The second option (`-F`) instructs -less to exit immediately if the output size is smaller than the vertical size of the terminal. -This is convenient for small files because you do not have to press `q` to quit the pager. The -third option (`-X`) is needed to fix a bug with the `--quit-if-one-screen` feature in old versions -of `less`. Unfortunately, it also breaks mouse-wheel support in `less`. +### Using `less` as a pager -If you want to enable mouse-wheel scrolling on older versions of `less`, you can pass just `-R` (as -in the example above, this will disable the quit-if-one-screen feature). For less 530 or newer, -it should work out of the box. +When using `less` as a pager, `bat` will automatically pass extra options along to `less` +to improve the experience. Specifically, `-R`/`--RAW-CONTROL-CHARS`, `-F`/`--quit-if-one-screen`, +and under certain conditions, `-X`/`--no-init` and/or `-S`/`--chop-long-lines`. + +>[!IMPORTANT] +> These options will not be added if: +> - The pager is not named `less`. +> - The `--pager` argument contains any command-line arguments (e.g. `--pager="less -R"`). +> - The `BAT_PAGER` environment variable contains any command-line arguments (e.g. `export BAT_PAGER="less -R"`) +> +> The `--quit-if-one-screen` option will not be added when: +> - The `--paging=always` argument is used. +> - The `BAT_PAGING` environment is set to `always`. + +The `-R` option is needed to interpret ANSI colors correctly. + +The `-F` option instructs `less` to exit immediately if the output size is smaller than +the vertical size of the terminal. This is convenient for small files because you do not +have to press `q` to quit the pager. + +The `-X` option is needed to fix a bug with the `--quit-if-one-screen` feature in versions +of `less` older than version 530. Unfortunately, it also breaks mouse-wheel support in `less`. +If you want to enable mouse-wheel scrolling on older versions of `less` and do not mind losing +the quit-if-one-screen feature, you can set the pager (via `--pager` or `BAT_PAGER`) to `less -R`. +For `less` 530 or newer, it should work out of the box. + +The `-S` option is added when `bat`'s `-S`/`--chop-long-lines` option is used. This tells `less` +to truncate any lines larger than the terminal width. ### Indentation diff --git a/assets/syntaxes/02_Extra/cmd-help b/assets/syntaxes/02_Extra/cmd-help index b150d845..209559b7 160000 --- a/assets/syntaxes/02_Extra/cmd-help +++ b/assets/syntaxes/02_Extra/cmd-help @@ -1 +1 @@ -Subproject commit b150d84534dd060afdcaf3f58977faeaf5917e56 +Subproject commit 209559b72f7e8848c988828088231b3a4d8b6838 diff --git a/assets/themes/zenburn b/assets/themes/zenburn index e627f1cb..86d4ee7a 160000 --- a/assets/themes/zenburn +++ b/assets/themes/zenburn @@ -1 +1 @@ -Subproject commit e627f1cb223c1171ab0a6a48d166c87aeae2a1d5 +Subproject commit 86d4ee7a1f884851a1d21d66249687f527fced32 diff --git a/build/syntax_mapping.rs b/build/syntax_mapping.rs index c29b9225..959caea8 100644 --- a/build/syntax_mapping.rs +++ b/build/syntax_mapping.rs @@ -10,7 +10,7 @@ use indexmap::IndexMap; use itertools::Itertools; use once_cell::sync::Lazy; use regex::Regex; -use serde::Deserialize; +use serde_derive::Deserialize; use serde_with::DeserializeFromStr; use walkdir::WalkDir; diff --git a/doc/long-help.txt b/doc/long-help.txt index 247120fb..3ac4a40f 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -160,6 +160,9 @@ Options: --acknowledgements Show acknowledgements. + --set-terminal-title + Sets terminal title to filenames when using a pager. + -h, --help Print help (see a summary with '-h') diff --git a/src/assets/assets_metadata.rs b/src/assets/assets_metadata.rs index 700c4c3b..cfc7a9e0 100644 --- a/src/assets/assets_metadata.rs +++ b/src/assets/assets_metadata.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::time::SystemTime; use semver::Version; -use serde::{Deserialize, Serialize}; +use serde_derive::{Deserialize, Serialize}; use crate::error::*; diff --git a/src/assets/lazy_theme_set.rs b/src/assets/lazy_theme_set.rs index bf749154..fcc3eb46 100644 --- a/src/assets/lazy_theme_set.rs +++ b/src/assets/lazy_theme_set.rs @@ -3,8 +3,7 @@ use super::*; use std::collections::BTreeMap; use std::convert::TryFrom; -use serde::Deserialize; -use serde::Serialize; +use serde_derive::{Deserialize, Serialize}; use once_cell::unsync::OnceCell; diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index a2c09770..8843d53b 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -289,6 +289,7 @@ impl App { use_custom_assets: !self.matches.get_flag("no-custom-assets"), #[cfg(feature = "lessopen")] use_lessopen: self.matches.get_flag("lessopen"), + set_terminal_title: self.matches.get_flag("set-terminal-title"), }) } diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index e8222a1d..6ceed784 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -567,6 +567,13 @@ pub fn build_app(interactive_output: bool) -> Command { .action(ArgAction::SetTrue) .hide_short_help(true) .help("Show acknowledgements."), + ) + .arg( + Arg::new("set-terminal-title") + .long("set-terminal-title") + .action(ArgAction::SetTrue) + .hide_short_help(true) + .help("Sets terminal title to filenames when using a pager."), ); // Check if the current directory contains a file name cache. Otherwise, diff --git a/src/bin/bat/main.rs b/src/bin/bat/main.rs index f48abdc1..d877bb9b 100644 --- a/src/bin/bat/main.rs +++ b/src/bin/bat/main.rs @@ -229,9 +229,33 @@ pub fn list_themes(cfg: &Config, config_dir: &Path, cache_dir: &Path) -> Result< Ok(()) } +fn set_terminal_title_to(new_terminal_title: String) { + let osc_command_for_setting_terminal_title = "\x1b]0;"; + let osc_end_command = "\x07"; + print!( + "{}{}{}", + osc_command_for_setting_terminal_title, new_terminal_title, osc_end_command + ); + io::stdout().flush().unwrap(); +} + +fn get_new_terminal_title(inputs: &Vec) -> String { + let mut new_terminal_title = "bat: ".to_string(); + for (index, input) in inputs.iter().enumerate() { + new_terminal_title += input.description().title(); + if index < inputs.len() - 1 { + new_terminal_title += ", "; + } + } + new_terminal_title +} + fn run_controller(inputs: Vec, config: &Config, cache_dir: &Path) -> Result { let assets = assets_from_cache_or_binary(config.use_custom_assets, cache_dir)?; let controller = Controller::new(config, &assets); + if config.paging_mode != PagingMode::Never && config.set_terminal_title { + set_terminal_title_to(get_new_terminal_title(&inputs)); + } controller.run(inputs, None) } diff --git a/src/config.rs b/src/config.rs index 83acc7df..c5cc2abd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -94,6 +94,9 @@ pub struct Config<'a> { // Whether or not to use $LESSOPEN if set #[cfg(feature = "lessopen")] pub use_lessopen: bool, + + // Weather or not to set terminal title when using a pager + pub set_terminal_title: bool, } #[cfg(all(feature = "minimal-application", feature = "paging"))] diff --git a/src/decorations.rs b/src/decorations.rs index d3ed9b34..85d8103a 100644 --- a/src/decorations.rs +++ b/src/decorations.rs @@ -46,7 +46,7 @@ impl Decoration for LineNumberDecoration { _printer: &InteractivePrinter, ) -> DecorationText { if continuation { - if line_number > self.cached_wrap_invalid_at { + if line_number >= self.cached_wrap_invalid_at { let new_width = self.cached_wrap.width + 1; return DecorationText { text: self.color.paint(" ".repeat(new_width)).to_string(), diff --git a/src/printer.rs b/src/printer.rs index 257cc766..f413fdc3 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -7,8 +7,6 @@ use nu_ansi_term::Style; use bytesize::ByteSize; -use console::AnsiCodeIterator; - use syntect::easy::HighlightLines; use syntect::highlighting::Color; use syntect::highlighting::Theme; @@ -33,9 +31,23 @@ use crate::line_range::RangeCheckResult; use crate::preprocessor::{expand_tabs, replace_nonprintable}; use crate::style::StyleComponent; use crate::terminal::{as_terminal_escaped, to_ansi_color}; -use crate::vscreen::AnsiStyle; +use crate::vscreen::{AnsiStyle, EscapeSequence, EscapeSequenceIterator}; use crate::wrapping::WrappingMode; +const ANSI_UNDERLINE_ENABLE: EscapeSequence = EscapeSequence::CSI { + raw_sequence: "\x1B[4m", + parameters: "4", + intermediates: "", + final_byte: "m", +}; + +const ANSI_UNDERLINE_DISABLE: EscapeSequence = EscapeSequence::CSI { + raw_sequence: "\x1B[24m", + parameters: "24", + intermediates: "", + final_byte: "m", +}; + pub enum OutputHandle<'a> { IoWrite(&'a mut dyn io::Write), FmtWrite(&'a mut dyn fmt::Write), @@ -554,7 +566,7 @@ impl<'a> Printer for InteractivePrinter<'a> { self.config.highlighted_lines.0.check(line_number) == RangeCheckResult::InRange; if highlight_this_line && self.config.theme == "ansi" { - self.ansi_style.update("^[4m"); + self.ansi_style.update(ANSI_UNDERLINE_ENABLE); } let background_color = self @@ -581,23 +593,17 @@ impl<'a> Printer for InteractivePrinter<'a> { let italics = self.config.use_italic_text; for &(style, region) in ®ions { - let ansi_iterator = AnsiCodeIterator::new(region); + let ansi_iterator = EscapeSequenceIterator::new(region); for chunk in ansi_iterator { match chunk { - // ANSI escape passthrough. - (ansi, true) => { - self.ansi_style.update(ansi); - write!(handle, "{}", ansi)?; - } - // Regular text. - (text, false) => { - let text = &*self.preprocess(text, &mut cursor_total); + EscapeSequence::Text(text) => { + let text = self.preprocess(text, &mut cursor_total); let text_trimmed = text.trim_end_matches(|c| c == '\r' || c == '\n'); write!( handle, - "{}", + "{}{}", as_terminal_escaped( style, &format!("{}{}", self.ansi_style, text_trimmed), @@ -605,9 +611,11 @@ impl<'a> Printer for InteractivePrinter<'a> { colored_output, italics, background_color - ) + ), + self.ansi_style.to_reset_sequence(), )?; + // Pad the rest of the line. if text.len() != text_trimmed.len() { if let Some(background_color) = background_color { let ansi_style = Style { @@ -625,6 +633,12 @@ impl<'a> Printer for InteractivePrinter<'a> { write!(handle, "{}", &text[text_trimmed.len()..])?; } } + + // ANSI escape passthrough. + _ => { + write!(handle, "{}", chunk.raw())?; + self.ansi_style.update(chunk); + } } } } @@ -634,17 +648,11 @@ impl<'a> Printer for InteractivePrinter<'a> { } } else { for &(style, region) in ®ions { - let ansi_iterator = AnsiCodeIterator::new(region); + let ansi_iterator = EscapeSequenceIterator::new(region); for chunk in ansi_iterator { match chunk { - // ANSI escape passthrough. - (ansi, true) => { - self.ansi_style.update(ansi); - write!(handle, "{}", ansi)?; - } - // Regular text. - (text, false) => { + EscapeSequence::Text(text) => { let text = self.preprocess( text.trim_end_matches(|c| c == '\r' || c == '\n'), &mut cursor_total, @@ -687,7 +695,7 @@ impl<'a> Printer for InteractivePrinter<'a> { // It wraps. write!( handle, - "{}\n{}", + "{}{}\n{}", as_terminal_escaped( style, &format!("{}{}", self.ansi_style, line_buf), @@ -696,6 +704,7 @@ impl<'a> Printer for InteractivePrinter<'a> { self.config.use_italic_text, background_color ), + self.ansi_style.to_reset_sequence(), panel_wrap.clone().unwrap() )?; @@ -724,6 +733,12 @@ impl<'a> Printer for InteractivePrinter<'a> { ) )?; } + + // ANSI escape passthrough. + _ => { + write!(handle, "{}", chunk.raw())?; + self.ansi_style.update(chunk); + } } } } @@ -744,8 +759,8 @@ impl<'a> Printer for InteractivePrinter<'a> { } if highlight_this_line && self.config.theme == "ansi" { - self.ansi_style.update("^[24m"); - write!(handle, "\x1B[24m")?; + write!(handle, "{}", ANSI_UNDERLINE_DISABLE.raw())?; + self.ansi_style.update(ANSI_UNDERLINE_DISABLE); } Ok(()) diff --git a/src/syntax_mapping/builtins/common/50-aws-credentials.toml b/src/syntax_mapping/builtins/common/50-aws-credentials.toml new file mode 100644 index 00000000..a16e6e8f --- /dev/null +++ b/src/syntax_mapping/builtins/common/50-aws-credentials.toml @@ -0,0 +1,2 @@ +[mappings] +"INI" = ["**/.aws/credentials", "**/.aws/config"] diff --git a/src/syntax_mapping/builtins/common/50-jsonl.toml b/src/syntax_mapping/builtins/common/50-json.toml similarity index 65% rename from src/syntax_mapping/builtins/common/50-jsonl.toml rename to src/syntax_mapping/builtins/common/50-json.toml index 4b70a4d0..e604868a 100644 --- a/src/syntax_mapping/builtins/common/50-jsonl.toml +++ b/src/syntax_mapping/builtins/common/50-json.toml @@ -1,3 +1,3 @@ # JSON Lines is a simple variation of JSON #2535 [mappings] -"JSON" = ["*.jsonl"] +"JSON" = ["*.jsonl", "*.jsonc"] diff --git a/src/vscreen.rs b/src/vscreen.rs index ea5d4da6..f7ba3f91 100644 --- a/src/vscreen.rs +++ b/src/vscreen.rs @@ -1,4 +1,8 @@ -use std::fmt::{Display, Formatter}; +use std::{ + fmt::{Display, Formatter}, + iter::Peekable, + str::CharIndices, +}; // Wrapper to avoid unnecessary branching when input doesn't have ANSI escape sequences. pub struct AnsiStyle { @@ -10,7 +14,7 @@ impl AnsiStyle { AnsiStyle { attributes: None } } - pub fn update(&mut self, sequence: &str) -> bool { + pub fn update(&mut self, sequence: EscapeSequence) -> bool { match &mut self.attributes { Some(a) => a.update(sequence), None => { @@ -19,6 +23,13 @@ impl AnsiStyle { } } } + + pub fn to_reset_sequence(&self) -> String { + match self.attributes { + Some(ref a) => a.to_reset_sequence(), + None => String::new(), + } + } } impl Display for AnsiStyle { @@ -31,6 +42,8 @@ impl Display for AnsiStyle { } struct Attributes { + has_sgr_sequences: bool, + foreground: String, background: String, underlined: String, @@ -61,11 +74,20 @@ struct Attributes { /// ON: ^[9m /// OFF: ^[29m strike: String, + + /// The hyperlink sequence. + /// FORMAT: \x1B]8;{ID};{URL}\e\\ + /// + /// `\e\\` may be replaced with BEL `\x07`. + /// Setting both {ID} and {URL} to an empty string represents no hyperlink. + hyperlink: String, } impl Attributes { pub fn new() -> Self { Attributes { + has_sgr_sequences: false, + foreground: "".to_owned(), background: "".to_owned(), underlined: "".to_owned(), @@ -76,34 +98,56 @@ impl Attributes { underline: "".to_owned(), italic: "".to_owned(), strike: "".to_owned(), + hyperlink: "".to_owned(), } } /// Update the attributes with an escape sequence. /// Returns `false` if the sequence is unsupported. - pub fn update(&mut self, sequence: &str) -> bool { - let mut chars = sequence.char_indices().skip(1); - - if let Some((_, t)) = chars.next() { - match t { - '(' => self.update_with_charset('(', chars.map(|(_, c)| c)), - ')' => self.update_with_charset(')', chars.map(|(_, c)| c)), - '[' => { - if let Some((i, last)) = chars.last() { - // SAFETY: Always starts with ^[ and ends with m. - self.update_with_csi(last, &sequence[2..i]) - } else { - false + pub fn update(&mut self, sequence: EscapeSequence) -> bool { + use EscapeSequence::*; + match sequence { + Text(_) => return false, + Unknown(_) => { /* defer to update_with_unsupported */ } + OSC { + raw_sequence, + command, + .. + } => { + if command.starts_with("8;") { + return self.update_with_hyperlink(raw_sequence); + } + /* defer to update_with_unsupported */ + } + CSI { + final_byte, + parameters, + .. + } => { + match final_byte { + "m" => return self.update_with_sgr(parameters), + _ => { + // NOTE(eth-p): We might want to ignore these, since they involve cursor or buffer manipulation. + /* defer to update_with_unsupported */ } } - _ => self.update_with_unsupported(sequence), } - } else { - false + NF { nf_sequence, .. } => { + let mut iter = nf_sequence.chars(); + match iter.next() { + Some('(') => return self.update_with_charset('(', iter), + Some(')') => return self.update_with_charset(')', iter), + _ => { /* defer to update_with_unsupported */ } + } + } } + + self.update_with_unsupported(sequence.raw()) } fn sgr_reset(&mut self) { + self.has_sgr_sequences = false; + self.foreground.clear(); self.background.clear(); self.underlined.clear(); @@ -121,13 +165,14 @@ impl Attributes { .map(|p| p.parse::()) .map(|p| p.unwrap_or(0)); // Treat errors as 0. + self.has_sgr_sequences = true; while let Some(p) = iter.next() { match p { 0 => self.sgr_reset(), - 1 => self.bold = format!("\x1B[{}m", parameters), - 2 => self.dim = format!("\x1B[{}m", parameters), - 3 => self.italic = format!("\x1B[{}m", parameters), - 4 => self.underline = format!("\x1B[{}m", parameters), + 1 => self.bold = "\x1B[1m".to_owned(), + 2 => self.dim = "\x1B[2m".to_owned(), + 3 => self.italic = "\x1B[3m".to_owned(), + 4 => self.underline = "\x1B[4m".to_owned(), 23 => self.italic.clear(), 24 => self.underline.clear(), 22 => { @@ -138,7 +183,7 @@ impl Attributes { 40..=49 => self.background = Self::parse_color(p, &mut iter), 58..=59 => self.underlined = Self::parse_color(p, &mut iter), 90..=97 => self.foreground = Self::parse_color(p, &mut iter), - 100..=107 => self.foreground = Self::parse_color(p, &mut iter), + 100..=107 => self.background = Self::parse_color(p, &mut iter), _ => { // Unsupported SGR sequence. // Be compatible and pretend one just wasn't was provided. @@ -149,19 +194,23 @@ impl Attributes { true } - fn update_with_csi(&mut self, finalizer: char, sequence: &str) -> bool { - if finalizer == 'm' { - self.update_with_sgr(sequence) - } else { - false - } - } - fn update_with_unsupported(&mut self, sequence: &str) -> bool { self.unknown_buffer.push_str(sequence); false } + fn update_with_hyperlink(&mut self, sequence: &str) -> bool { + if sequence == "8;;" { + // Empty hyperlink ID and HREF -> end of hyperlink. + self.hyperlink.clear(); + } else { + self.hyperlink.clear(); + self.hyperlink.push_str(sequence); + } + + true + } + fn update_with_charset(&mut self, kind: char, set: impl Iterator) -> bool { self.charset = format!("\x1B{}{}", kind, set.take(1).collect::()); true @@ -179,13 +228,35 @@ impl Attributes { _ => format!("\x1B[{}m", color), } } + + /// Gets an ANSI escape sequence to reset all the known attributes. + pub fn to_reset_sequence(&self) -> String { + let mut buf = String::with_capacity(17); + + // TODO: Enable me in a later pull request. + // if self.has_sgr_sequences { + // buf.push_str("\x1B[m"); + // } + + if !self.hyperlink.is_empty() { + buf.push_str("\x1B]8;;\x1B\\"); // Disable hyperlink. + } + + // TODO: Enable me in a later pull request. + // if !self.charset.is_empty() { + // // https://espterm.github.io/docs/VT100%20escape%20codes.html + // buf.push_str("\x1B(B\x1B)B"); // setusg0 and setusg1 + // } + + buf + } } impl Display for Attributes { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}{}{}{}", self.foreground, self.background, self.underlined, @@ -195,6 +266,7 @@ impl Display for Attributes { self.underline, self.italic, self.strike, + self.hyperlink, ) } } @@ -210,3 +282,646 @@ fn join( .collect::>() .join(delimiter) } + +/// A range of indices for a raw ANSI escape sequence. +#[derive(Debug, PartialEq)] +enum EscapeSequenceOffsets { + Text { + start: usize, + end: usize, + }, + Unknown { + start: usize, + end: usize, + }, + #[allow(clippy::upper_case_acronyms)] + NF { + // https://en.wikipedia.org/wiki/ANSI_escape_code#nF_Escape_sequences + start_sequence: usize, + start: usize, + end: usize, + }, + #[allow(clippy::upper_case_acronyms)] + OSC { + // https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences + start_sequence: usize, + start_command: usize, + start_terminator: usize, + end: usize, + }, + #[allow(clippy::upper_case_acronyms)] + CSI { + // https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences + start_sequence: usize, + start_parameters: usize, + start_intermediates: usize, + start_final_byte: usize, + end: usize, + }, +} + +/// An iterator over the offests of ANSI/VT escape sequences within a string. +/// +/// ## Example +/// +/// ```ignore +/// let iter = EscapeSequenceOffsetsIterator::new("\x1B[33mThis is yellow text.\x1B[m"); +/// ``` +struct EscapeSequenceOffsetsIterator<'a> { + text: &'a str, + chars: Peekable>, +} + +impl<'a> EscapeSequenceOffsetsIterator<'a> { + pub fn new(text: &'a str) -> EscapeSequenceOffsetsIterator<'a> { + return EscapeSequenceOffsetsIterator { + text, + chars: text.char_indices().peekable(), + }; + } + + /// Takes values from the iterator while the predicate returns true. + /// If the predicate returns false, that value is left. + fn chars_take_while(&mut self, pred: impl Fn(char) -> bool) -> Option<(usize, usize)> { + self.chars.peek()?; + + let start = self.chars.peek().unwrap().0; + let mut end: usize = start; + while let Some((i, c)) = self.chars.peek() { + if !pred(*c) { + break; + } + + end = *i + c.len_utf8(); + self.chars.next(); + } + + Some((start, end)) + } + + fn next_text(&mut self) -> Option { + self.chars_take_while(|c| c != '\x1B') + .map(|(start, end)| EscapeSequenceOffsets::Text { start, end }) + } + + fn next_sequence(&mut self) -> Option { + let (start_sequence, c) = self.chars.next().expect("to not be finished"); + match self.chars.peek() { + None => Some(EscapeSequenceOffsets::Unknown { + start: start_sequence, + end: start_sequence + c.len_utf8(), + }), + + Some((_, ']')) => self.next_osc(start_sequence), + Some((_, '[')) => self.next_csi(start_sequence), + Some((i, c)) => match c { + '\x20'..='\x2F' => self.next_nf(start_sequence), + c => Some(EscapeSequenceOffsets::Unknown { + start: start_sequence, + end: i + c.len_utf8(), + }), + }, + } + } + + fn next_osc(&mut self, start_sequence: usize) -> Option { + let (osc_open_index, osc_open_char) = self.chars.next().expect("to not be finished"); + debug_assert_eq!(osc_open_char, ']'); + + let mut start_terminator: usize; + let mut end_sequence: usize; + + loop { + match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B')) { + None => { + start_terminator = self.text.len(); + end_sequence = start_terminator; + break; + } + + Some((_, end)) => { + start_terminator = end; + end_sequence = end; + } + } + + match self.chars.next() { + Some((ti, '\x07')) => { + end_sequence = ti + '\x07'.len_utf8(); + break; + } + + Some((ti, '\x1B')) => { + match self.chars.next() { + Some((i, '\\')) => { + end_sequence = i + '\\'.len_utf8(); + break; + } + + None => { + end_sequence = ti + '\x1B'.len_utf8(); + break; + } + + _ => { + // Repeat, since `\\`(anything) isn't a valid ST. + } + } + } + + None => { + // Prematurely ends. + break; + } + + Some((_, tc)) => { + panic!("this should not be reached: char {:?}", tc) + } + } + } + + Some(EscapeSequenceOffsets::OSC { + start_sequence, + start_command: osc_open_index + osc_open_char.len_utf8(), + start_terminator, + end: end_sequence, + }) + } + + fn next_csi(&mut self, start_sequence: usize) -> Option { + let (csi_open_index, csi_open_char) = self.chars.next().expect("to not be finished"); + debug_assert_eq!(csi_open_char, '['); + + let start_parameters: usize = csi_open_index + csi_open_char.len_utf8(); + + // Keep iterating while within the range of `0x30-0x3F`. + let mut start_intermediates: usize = start_parameters; + if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x30'..='\x3F')) { + start_intermediates = end; + } + + // Keep iterating while within the range of `0x20-0x2F`. + let mut start_final_byte: usize = start_intermediates; + if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x20'..='\x2F')) { + start_final_byte = end; + } + + // Take the last char. + let end_of_sequence = match self.chars.next() { + None => start_final_byte, + Some((i, c)) => i + c.len_utf8(), + }; + + Some(EscapeSequenceOffsets::CSI { + start_sequence, + start_parameters, + start_intermediates, + start_final_byte, + end: end_of_sequence, + }) + } + + fn next_nf(&mut self, start_sequence: usize) -> Option { + let (nf_open_index, nf_open_char) = self.chars.next().expect("to not be finished"); + debug_assert!(matches!(nf_open_char, '\x20'..='\x2F')); + + let start: usize = nf_open_index; + let mut end: usize = start; + + // Keep iterating while within the range of `0x20-0x2F`. + match self.chars_take_while(|c| matches!(c, '\x20'..='\x2F')) { + Some((_, i)) => end = i, + None => { + return Some(EscapeSequenceOffsets::NF { + start_sequence, + start, + end, + }) + } + } + + // Get the final byte. + if let Some((i, c)) = self.chars.next() { + end = i + c.len_utf8() + } + + Some(EscapeSequenceOffsets::NF { + start_sequence, + start, + end, + }) + } +} + +impl<'a> Iterator for EscapeSequenceOffsetsIterator<'a> { + type Item = EscapeSequenceOffsets; + fn next(&mut self) -> Option { + match self.chars.peek() { + Some((_, '\x1B')) => self.next_sequence(), + Some((_, _)) => self.next_text(), + None => None, + } + } +} + +/// An iterator over ANSI/VT escape sequences within a string. +/// +/// ## Example +/// +/// ```ignore +/// let iter = EscapeSequenceIterator::new("\x1B[33mThis is yellow text.\x1B[m"); +/// ``` +pub struct EscapeSequenceIterator<'a> { + text: &'a str, + offset_iter: EscapeSequenceOffsetsIterator<'a>, +} + +impl<'a> EscapeSequenceIterator<'a> { + pub fn new(text: &'a str) -> EscapeSequenceIterator<'a> { + return EscapeSequenceIterator { + text, + offset_iter: EscapeSequenceOffsetsIterator::new(text), + }; + } +} + +impl<'a> Iterator for EscapeSequenceIterator<'a> { + type Item = EscapeSequence<'a>; + fn next(&mut self) -> Option { + use EscapeSequenceOffsets::*; + self.offset_iter.next().map(|offsets| match offsets { + Unknown { start, end } => EscapeSequence::Unknown(&self.text[start..end]), + Text { start, end } => EscapeSequence::Text(&self.text[start..end]), + NF { + start_sequence, + start, + end, + } => EscapeSequence::NF { + raw_sequence: &self.text[start_sequence..end], + nf_sequence: &self.text[start..end], + }, + OSC { + start_sequence, + start_command, + start_terminator, + end, + } => EscapeSequence::OSC { + raw_sequence: &self.text[start_sequence..end], + command: &self.text[start_command..start_terminator], + terminator: &self.text[start_terminator..end], + }, + CSI { + start_sequence, + start_parameters, + start_intermediates, + start_final_byte, + end, + } => EscapeSequence::CSI { + raw_sequence: &self.text[start_sequence..end], + parameters: &self.text[start_parameters..start_intermediates], + intermediates: &self.text[start_intermediates..start_final_byte], + final_byte: &self.text[start_final_byte..end], + }, + }) + } +} + +/// A parsed ANSI/VT100 escape sequence. +#[derive(Debug, PartialEq)] +pub enum EscapeSequence<'a> { + Text(&'a str), + Unknown(&'a str), + #[allow(clippy::upper_case_acronyms)] + NF { + raw_sequence: &'a str, + nf_sequence: &'a str, + }, + #[allow(clippy::upper_case_acronyms)] + OSC { + raw_sequence: &'a str, + command: &'a str, + terminator: &'a str, + }, + #[allow(clippy::upper_case_acronyms)] + CSI { + raw_sequence: &'a str, + parameters: &'a str, + intermediates: &'a str, + final_byte: &'a str, + }, +} + +impl<'a> EscapeSequence<'a> { + pub fn raw(&self) -> &'a str { + use EscapeSequence::*; + match *self { + Text(raw) => raw, + Unknown(raw) => raw, + NF { raw_sequence, .. } => raw_sequence, + OSC { raw_sequence, .. } => raw_sequence, + CSI { raw_sequence, .. } => raw_sequence, + } + } +} + +#[cfg(test)] +mod tests { + use crate::vscreen::{ + EscapeSequence, EscapeSequenceIterator, EscapeSequenceOffsets, + EscapeSequenceOffsetsIterator, + }; + + #[test] + fn test_escape_sequence_offsets_iterator_parses_text() { + let mut iter = EscapeSequenceOffsetsIterator::new("text"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Text { start: 0, end: 4 }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_text_stops_at_esc() { + let mut iter = EscapeSequenceOffsetsIterator::new("text\x1B[ming"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Text { start: 0, end: 4 }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_osc_with_bel() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B]abc\x07"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::OSC { + start_sequence: 0, + start_command: 2, + start_terminator: 5, + end: 6, + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_osc_with_st() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B]abc\x1B\\"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::OSC { + start_sequence: 0, + start_command: 2, + start_terminator: 5, + end: 7, + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_osc_thats_broken() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B]ab"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::OSC { + start_sequence: 0, + start_command: 2, + start_terminator: 4, + end: 4, + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_csi() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[m"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 2, + start_final_byte: 2, + end: 3 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_csi_with_parameters() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[1;34m"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 6, + start_final_byte: 6, + end: 7 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_csi_with_intermediates() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[$m"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 2, + start_final_byte: 3, + end: 4 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_csi_with_parameters_and_intermediates() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[1$m"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 3, + start_final_byte: 4, + end: 5 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_csi_thats_broken() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B["); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 2, + start_final_byte: 2, + end: 2 + }) + ); + + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[1"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 3, + start_final_byte: 3, + end: 3 + }) + ); + + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[1$"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 0, + start_parameters: 2, + start_intermediates: 3, + start_final_byte: 4, + end: 4 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_nf() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B($0"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::NF { + start_sequence: 0, + start: 1, + end: 4 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_parses_nf_thats_broken() { + let mut iter = EscapeSequenceOffsetsIterator::new("\x1B("); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::NF { + start_sequence: 0, + start: 1, + end: 1 + }) + ); + } + + #[test] + fn test_escape_sequence_offsets_iterator_iterates() { + let mut iter = EscapeSequenceOffsetsIterator::new("text\x1B[33m\x1B]OSC\x07\x1B(0"); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::Text { start: 0, end: 4 }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::CSI { + start_sequence: 4, + start_parameters: 6, + start_intermediates: 8, + start_final_byte: 8, + end: 9 + }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::OSC { + start_sequence: 9, + start_command: 11, + start_terminator: 14, + end: 15 + }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequenceOffsets::NF { + start_sequence: 15, + start: 16, + end: 18 + }) + ); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_escape_sequence_iterator_iterates() { + let mut iter = EscapeSequenceIterator::new("text\x1B[33m\x1B]OSC\x07\x1B]OSC\x1B\\\x1B(0"); + assert_eq!(iter.next(), Some(EscapeSequence::Text("text"))); + assert_eq!( + iter.next(), + Some(EscapeSequence::CSI { + raw_sequence: "\x1B[33m", + parameters: "33", + intermediates: "", + final_byte: "m", + }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequence::OSC { + raw_sequence: "\x1B]OSC\x07", + command: "OSC", + terminator: "\x07", + }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequence::OSC { + raw_sequence: "\x1B]OSC\x1B\\", + command: "OSC", + terminator: "\x1B\\", + }) + ); + assert_eq!( + iter.next(), + Some(EscapeSequence::NF { + raw_sequence: "\x1B(0", + nf_sequence: "(0", + }) + ); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_sgr_attributes_do_not_leak_into_wrong_field() { + let mut attrs = crate::vscreen::Attributes::new(); + + // Bold, Dim, Italic, Underline, Foreground, Background + attrs.update(EscapeSequence::CSI { + raw_sequence: "\x1B[1;2;3;4;31;41m", + parameters: "1;2;3;4;31;41", + intermediates: "", + final_byte: "m", + }); + + assert_eq!(attrs.bold, "\x1B[1m"); + assert_eq!(attrs.dim, "\x1B[2m"); + assert_eq!(attrs.italic, "\x1B[3m"); + assert_eq!(attrs.underline, "\x1B[4m"); + assert_eq!(attrs.foreground, "\x1B[31m"); + assert_eq!(attrs.background, "\x1B[41m"); + + // Bold, Bright Foreground, Bright Background + attrs.sgr_reset(); + attrs.update(EscapeSequence::CSI { + raw_sequence: "\x1B[1;94;103m", + parameters: "1;94;103", + intermediates: "", + final_byte: "m", + }); + + assert_eq!(attrs.bold, "\x1B[1m"); + assert_eq!(attrs.foreground, "\x1B[94m"); + assert_eq!(attrs.background, "\x1B[103m"); + } +} diff --git a/tests/examples/regression_tests/issue_2541.txt b/tests/examples/regression_tests/issue_2541.txt new file mode 100644 index 00000000..1059b94e --- /dev/null +++ b/tests/examples/regression_tests/issue_2541.txt @@ -0,0 +1 @@ +]8;;http://example.com\This is a link]8;;\n \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 437ae8e7..3612654b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -936,6 +936,18 @@ fn env_var_bat_paging() { }); } +#[test] +fn basic_set_terminal_title() { + bat() + .arg("--paging=always") + .arg("--set-terminal-title") + .arg("test.txt") + .assert() + .success() + .stdout("\u{1b}]0;bat: test.txt\x07hello world\n") + .stderr(""); +} + #[test] fn diagnostic_sanity_check() { bat() @@ -1163,6 +1175,20 @@ fn bom_stripped_when_no_color_and_not_loop_through() { ); } +// Regression test for https://github.com/sharkdp/bat/issues/2541 +#[test] +fn no_broken_osc_emit_with_line_wrapping() { + bat() + .arg("--color=always") + .arg("--decorations=never") + .arg("--wrap=character") + .arg("--terminal-width=40") + .arg("regression_tests/issue_2541.txt") + .assert() + .success() + .stdout(predicate::function(|s: &str| s.lines().count() == 1)); +} + #[test] fn can_print_file_named_cache() { bat_with_config() @@ -1919,6 +1945,62 @@ fn ansi_passthrough_emit() { } } +// Ensure that a simple ANSI sequence passthrough is emitted properly on wrapped lines. +// This also helps ensure that escape sequences are counted as part of the visible characters when wrapping. +#[test] +fn ansi_sgr_emitted_when_wrapped() { + bat() + .arg("--paging=never") + .arg("--color=never") + .arg("--terminal-width=20") + .arg("--wrap=character") + .arg("--decorations=always") + .arg("--style=plain") + .write_stdin("\x1B[33mColor...............Also color.\n") + .assert() + .success() + .stdout("\x1B[33m\x1B[33mColor...............\n\x1B[33mAlso color.\n") + // FIXME: ~~~~~~~~ should not be emitted twice. + .stderr(""); +} + +// Ensure that a simple ANSI sequence passthrough is emitted properly on wrapped lines. +// This also helps ensure that escape sequences are counted as part of the visible characters when wrapping. +#[test] +fn ansi_hyperlink_emitted_when_wrapped() { + bat() + .arg("--paging=never") + .arg("--color=never") + .arg("--terminal-width=20") + .arg("--wrap=character") + .arg("--decorations=always") + .arg("--style=plain") + .write_stdin("\x1B]8;;http://example.com/\x1B\\Hyperlinks..........Wrap across lines.\n") + .assert() + .success() + .stdout("\x1B]8;;http://example.com/\x1B\\\x1B]8;;http://example.com/\x1B\\Hyperlinks..........\x1B]8;;\x1B\\\n\x1B]8;;http://example.com/\x1B\\Wrap across lines.\n") + // FIXME: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ should not be emitted twice. + .stderr(""); +} + +// Ensure that multiple ANSI sequence SGR attributes are combined when emitted on wrapped lines. +#[test] +fn ansi_sgr_joins_attributes_when_wrapped() { + bat() + .arg("--paging=never") + .arg("--color=never") + .arg("--terminal-width=20") + .arg("--wrap=character") + .arg("--decorations=always") + .arg("--style=plain") + .write_stdin("\x1B[33mColor. \x1B[1mBold.........Also bold and color.\n") + .assert() + .success() + .stdout("\x1B[33m\x1B[33mColor. \x1B[1m\x1B[33m\x1B[1mBold.........\n\x1B[33m\x1B[1mAlso bold and color.\n") + // FIXME: ~~~~~~~~ ~~~~~~~~~~~~~~~ should not be emitted twice. + .stderr(""); +} + #[test] fn ignored_suffix_arg() { bat() diff --git a/tests/tester/mod.rs b/tests/tester/mod.rs index 8ddea11f..c4e916a6 100644 --- a/tests/tester/mod.rs +++ b/tests/tester/mod.rs @@ -22,7 +22,7 @@ impl BatTester { pub fn test_snapshot(&self, name: &str, style: &str) { let output = Command::new(&self.exe) .current_dir(self.temp_dir.path()) - .args(&[ + .args([ "sample.rs", "--no-config", "--paging=never",