diff --git a/.gitmodules b/.gitmodules index b64280c6..20e0bbb4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -49,9 +49,6 @@ [submodule "assets/themes/zenburn"] path = assets/themes/zenburn url = https://github.com/colinta/zenburn.git -[submodule "assets/syntaxes/Kotlin"] - path = assets/syntaxes/02_Extra/Kotlin - url = https://github.com/vkostyukov/kotlin-sublime-package [submodule "assets/syntaxes/Elm"] path = assets/syntaxes/02_Extra/Elm url = https://github.com/elm-community/SublimeElmLanguageSupport @@ -281,3 +278,6 @@ [submodule "assets/syntaxes/02_Extra/COBOL"] path = assets/syntaxes/02_Extra/COBOL url = https://github.com/adukhan99/sublime_cobol.git +[submodule "assets/syntaxes/02_Extra/Kotlin"] + path = assets/syntaxes/02_Extra/Kotlin + url = https://github.com/guille/sublime-kotlin diff --git a/CHANGELOG.md b/CHANGELOG.md index bd76e2e7..090a019c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ ## Bugfixes - Fix `--list-themes` unconditionally probing the terminal via OSC 10/11 even when `--theme` was set to an explicit value, see #3700 (regression introduced in bc42149a). (@optimistiCli) +- Fix inverted `$LESSCLOSE` warning so bat warns on nonzero exit, not on success. See #3654 (@cuiweixie) +- Sanitize control characters in filenames before displaying them in the file header, error messages, and the terminal title, preventing ANSI escape injection via crafted filenames. Closes #3054, see #3691 (@curious-rabbit) +- Report initial input read errors instead of treating them as empty input. Closes #3002, see #3706 (@lawrence3699) - Treat ZIP archives as binary content based on their magic header, see #3686 (@officialasishkumar) - Fix i686 `.deb` package using incorrect architecture name (`i686` instead of `i386`), preventing installation on Debian. Closes #3611, see #3650 (@Sim-hu) - Fix inconsistent `.deb` MUSL package names (aarch64-musl used `arm64` instead of `musl-linux-arm64`, and `musleabihf` target missed `bat-musl` prefix). Closes #3482, see #3642 (@mvanhorn) @@ -37,6 +40,7 @@ - Fixed test compatibility with future Cargo build directory changes, see #3550 (@nmacl) - Fixed bug caused by using `--plain` and `--terminal-width=N` flags simultaneously, see #3529 (@H4k1l) - Fixed syntax tests path, see #3610 (@foxfromworld) +- Fix zsh tab completion word-splitting language names containing spaces (e.g. `HTML (Jinja2)`, `Apache Conf`), see #3693 (@YoshKoz) ## Other - Use git version of cross. See #3533 (@OctopusET) @@ -54,6 +58,7 @@ - Fixed manpage syntax so that ANSI escape codes don't get incorrectly highlighted and thus broken, see #3586 (@BlueElectivire) - Map several Google Cloud CLI config files to their appropriate syntax #3635 (@victor-gp) - Map all ignore dotfiles to Git Ignore syntax #3636 (@victor-gp) +- Improved Kotlin syntax, see #3699 (@guille) ## Themes diff --git a/README.md b/README.md index d6ef3381..375ec6d7 100644 --- a/README.md +++ b/README.md @@ -19,20 +19,6 @@ [Русский]

-### Sponsors - -A special *thank you* goes to our biggest sponsors:
- -

- - Warp -
- Warp, the intelligent terminal -
- Available on MacOS, Linux, Windows -
-

- ### Syntax highlighting `bat` supports syntax highlighting for a large number of programming and markup @@ -208,12 +194,6 @@ Note that the [Manpage syntax](assets/syntaxes/02_Extra/Manpage.sublime-syntax) The [`prettybat`](https://github.com/eth-p/bat-extras/blob/master/doc/prettybat.md) script is a wrapper that will format code and print it with `bat`. -#### `Warp` - - - Warp - - #### Highlighting `--help` messages You can use `bat` to colorize help text: `$ cp --help | bat -plhelp` diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index 3c3f81d1..f8de56e4 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -90,7 +90,7 @@ _{{PROJECT_EXECUTABLE}}_main() { languages) local IFS=$'\n' local -a languages - languages=( $({{PROJECT_EXECUTABLE}} --list-languages | awk -F':|,' '{ for (i = 1; i <= NF; ++i) printf("%s:%s\n", $i, $1) }') ) + languages=( ${(f)"$({{PROJECT_EXECUTABLE}} --list-languages | awk -F':|,' '{ for (i = 1; i <= NF; ++i) printf("%s:%s\n", $i, $1) }')"} ) _describe 'language' languages && ret=0 ;; diff --git a/assets/syntaxes/02_Extra/Idris2 b/assets/syntaxes/02_Extra/Idris2 index bbfe50e0..4d8eb35a 160000 --- a/assets/syntaxes/02_Extra/Idris2 +++ b/assets/syntaxes/02_Extra/Idris2 @@ -1 +1 @@ -Subproject commit bbfe50e023e0edc74f5e0c003eb946528d49279f +Subproject commit 4d8eb35a38254d422030e77b4933530008dd3c6e diff --git a/assets/syntaxes/02_Extra/Kotlin b/assets/syntaxes/02_Extra/Kotlin index aeeed278..c3536941 160000 --- a/assets/syntaxes/02_Extra/Kotlin +++ b/assets/syntaxes/02_Extra/Kotlin @@ -1 +1 @@ -Subproject commit aeeed2780b04aea3d293c547c24cae27cafef0c5 +Subproject commit c353694169c047bd746c93c214289fc52d152cae diff --git a/assets/syntaxes/02_Extra/Kotlin.sublime-syntax b/assets/syntaxes/02_Extra/Kotlin.sublime-syntax deleted file mode 100644 index eab59192..00000000 --- a/assets/syntaxes/02_Extra/Kotlin.sublime-syntax +++ /dev/null @@ -1,398 +0,0 @@ -%YAML 1.2 ---- -# http://www.sublimetext.com/docs/3/syntax.html -name: Kotlin -file_extensions: - - kt - - kts -scope: source.Kotlin -contexts: - main: - - include: comments - - match: '^\s*(package)\b(?:\s*([^ ;$]+)\s*)?' - captures: - 1: keyword.other.kotlin - 2: entity.name.package.kotlin - - include: imports - - include: statements - classes: - - match: (?" - pop: true - - include: generics - - match: \( - push: - - match: \) - pop: true - - include: parameters - - match: (:) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: "(?={|$)" - pop: true - - match: \w+ - scope: entity.other.inherited-class.kotlin - - match: \( - push: - - match: \) - pop: true - - include: expressions - - match: '\{' - push: - - match: '\}' - pop: true - - include: statements - comments: - - match: /\* - captures: - 0: punctuation.definition.comment.kotlin - push: - - meta_scope: comment.block.kotlin - - match: \*/ - captures: - 0: punctuation.definition.comment.kotlin - pop: true - - match: \s*((//).*$\n?) - captures: - 1: comment.line.double-slash.kotlin - 2: punctuation.definition.comment.kotlin - constants: - - match: \b(true|false|null|this|super)\b - scope: constant.language.kotlin - - match: '\b((0(x|X)[0-9a-fA-F]*)|(([0-9]+\.?[0-9]*)|(\.[0-9]+))((e|E)(\+|-)?[0-9]+)?)([LlFf])?\b' - scope: constant.numeric.kotlin - - match: '\b([A-Z][A-Z0-9_]+)\b' - scope: constant.other.kotlin - expressions: - - match: \( - push: - - match: \) - pop: true - - include: expressions - - include: types - - include: strings - - include: constants - - include: comments - - include: keywords - functions: - - match: (?=\s*\b(?:fun)\b) - push: - - match: '(?=$|\})' - pop: true - - match: \b(fun)\b - captures: - 1: keyword.other.kotlin - push: - - match: (?=\() - pop: true - - match: < - push: - - match: ">" - pop: true - - include: generics - - match: '([\.<\?>\w]+\.)?(\w+)' - captures: - 2: entity.name.function.kotlin - - match: \( - push: - - match: \) - pop: true - - include: parameters - - match: (:) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: "(?={|=|$)" - pop: true - - include: types - - match: '\{' - push: - - match: '(?=\})' - pop: true - - include: statements - - match: (=) - captures: - 1: keyword.operator.assignment.kotlin - push: - - match: (?=$) - pop: true - - include: expressions - generics: - - match: (:) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: (?=,|>) - pop: true - - include: types - - include: keywords - - match: \w+ - scope: storage.type.generic.kotlin - getters-and-setters: - - match: \b(get)\b\s*\(\s*\) - captures: - 1: entity.name.function.kotlin - push: - - match: '\}|(?=\bset\b)|$' - pop: true - - match: (=) - captures: - 1: keyword.operator.assignment.kotlin - push: - - match: (?=$|\bset\b) - pop: true - - include: expressions - - match: '\{' - push: - - match: '\}' - pop: true - - include: expressions - - match: \b(set)\b\s*(?=\() - captures: - 1: entity.name.function.kotlin - push: - - match: '\}|(?=\bget\b)|$' - pop: true - - match: \( - push: - - match: \) - pop: true - - include: parameters - - match: (=) - captures: - 1: keyword.operator.assignment.kotlin - push: - - match: (?=$|\bset\b) - pop: true - - include: expressions - - match: '\{' - push: - - match: '\}' - pop: true - - include: expressions - imports: - - match: '^\s*(import)\s+[^ $]+\s+(as)?' - captures: - 1: keyword.other.kotlin - 2: keyword.other.kotlin - keywords: - - match: \b(var|val|public|private|protected|abstract|final|sealed|enum|open|attribute|annotation|override|inline|vararg|in|out|internal|data|tailrec|operator|infix|const|yield|typealias|typeof|reified|suspend)\b - scope: storage.modifier.kotlin - - match: \b(try|catch|finally|throw)\b - scope: keyword.control.catch-exception.kotlin - - match: \b(if|else|while|for|do|return|when|where|break|continue)\b - scope: keyword.control.kotlin - - match: \b(in|is|!in|!is|as|as\?|assert)\b - scope: keyword.operator.kotlin - - match: (==|!=|===|!==|<=|>=|<|>) - scope: keyword.operator.comparison.kotlin - - match: (=) - scope: keyword.operator.assignment.kotlin - - match: (::) - scope: keyword.operator.kotlin - - match: (:) - scope: keyword.operator.declaration.kotlin - - match: \b(by)\b - scope: keyword.other.by.kotlin - - match: (\?\.) - scope: keyword.operator.safenav.kotlin - - match: (\.) - scope: keyword.operator.dot.kotlin - - match: (\?:) - scope: keyword.operator.elvis.kotlin - - match: (\-\-|\+\+) - scope: keyword.operator.increment-decrement.kotlin - - match: (\+=|\-=|\*=|\/=) - scope: keyword.operator.arithmetic.assign.kotlin - - match: (\.\.) - scope: keyword.operator.range.kotlin - - match: (\-|\+|\*|\/|%) - scope: keyword.operator.arithmetic.kotlin - - match: (!|&&|\|\|) - scope: keyword.operator.logical.kotlin - - match: (;) - scope: punctuation.terminator.kotlin - namespaces: - - match: \b(namespace)\b - scope: keyword.other.kotlin - - match: '\{' - push: - - match: '\}' - pop: true - - include: statements - parameters: - - match: (:) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: (?=,|\)|=) - pop: true - - include: types - - match: (=) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: (?=,|\)) - pop: true - - include: expressions - - include: keywords - - match: \w+ - scope: variable.parameter.function.kotlin - statements: - - include: namespaces - - include: typedefs - - include: classes - - include: functions - - include: variables - - include: getters-and-setters - - include: expressions - strings: - - match: '"""' - captures: - 0: punctuation.definition.string.begin.kotlin - push: - - meta_scope: string.quoted.third.kotlin - - match: '"""' - captures: - 0: punctuation.definition.string.end.kotlin - pop: true - - match: '(\$\w+|\$\{[^\}]+\})' - scope: variable.parameter.template.kotlin - - match: \\. - scope: constant.character.escape.kotlin - - match: '"' - captures: - 0: punctuation.definition.string.begin.kotlin - push: - - meta_scope: string.quoted.double.kotlin - - match: '"' - captures: - 0: punctuation.definition.string.end.kotlin - pop: true - - match: '(\$\w+|\$\{[^\}]+\})' - scope: variable.parameter.template.kotlin - - match: \\. - scope: constant.character.escape.kotlin - - match: "'" - captures: - 0: punctuation.definition.string.begin.kotlin - push: - - meta_scope: string.quoted.single.kotlin - - match: "'" - captures: - 0: punctuation.definition.string.end.kotlin - pop: true - - match: \\. - scope: constant.character.escape.kotlin - - match: "`" - captures: - 0: punctuation.definition.string.begin.kotlin - push: - - meta_scope: string.quoted.single.kotlin - - match: "`" - captures: - 0: punctuation.definition.string.end.kotlin - pop: true - typedefs: - - match: (?=\s*(?:type)) - push: - - match: (?=$) - pop: true - - match: \b(type)\b - scope: keyword.other.kotlin - - match: < - push: - - match: ">" - pop: true - - include: generics - - include: expressions - types: - - match: \b(Nothing|Any|Unit|String|CharSequence|Int|Boolean|Char|Long|Double|Float|Short|Byte|dynamic)\b - scope: storage.type.buildin.kotlin - - match: \b(IntArray|BooleanArray|CharArray|LongArray|DoubleArray|FloatArray|ShortArray|ByteArray)\b - scope: storage.type.buildin.array.kotlin - - match: \b(Array|Collection|List|Map|Set|MutableList|MutableMap|MutableSet|Sequence)<\b - captures: - 1: storage.type.buildin.collection.kotlin - push: - - match: ">" - pop: true - - include: types - - include: keywords - - match: \w+< - push: - - match: ">" - pop: true - - include: types - - include: keywords - - match: '\{' - push: - - match: '\}' - pop: true - - include: statements - - match: \( - push: - - match: \) - pop: true - - include: types - - match: (->) - scope: keyword.operator.declaration.kotlin - variables: - - match: (?=\s*\b(?:var|val)\b) - push: - - match: (?=:|=|(\b(by)\b)|$) - pop: true - - match: \b(var|val)\b - captures: - 1: keyword.other.kotlin - push: - - match: (?=:|=|(\b(by)\b)|$) - pop: true - - match: < - push: - - match: ">" - pop: true - - include: generics - - match: '([\.<\?>\w]+\.)?(\w+)' - captures: - 2: entity.name.variable.kotlin - - match: (:) - captures: - 1: keyword.operator.declaration.kotlin - push: - - match: (?==|$) - pop: true - - include: types - - include: getters-and-setters - - match: \b(by)\b - captures: - 1: keyword.other.kotlin - push: - - match: (?=$) - pop: true - - include: expressions - - match: (=) - captures: - 1: keyword.operator.assignment.kotlin - push: - - match: (?=$) - pop: true - - include: expressions - - include: getters-and-setters diff --git a/doc/sponsors.md b/doc/sponsors.md index 24509077..641e5205 100644 --- a/doc/sponsors.md +++ b/doc/sponsors.md @@ -10,5 +10,3 @@ No issue will have a different priority based on sponsorship status of the reporter. Contributions from anybody are most welcomed, please see our [`CONTRIBUTING.md`](../CONTRIBUTING.md) guide. - -If you want to see our biggest sponsors, check the top of [`README.md`](../README.md#sponsors). diff --git a/doc/sponsors/graphite-logo.jpeg b/doc/sponsors/graphite-logo.jpeg deleted file mode 100644 index 00443818..00000000 Binary files a/doc/sponsors/graphite-logo.jpeg and /dev/null differ diff --git a/doc/sponsors/warp-logo.png b/doc/sponsors/warp-logo.png deleted file mode 100644 index f99dd38c..00000000 Binary files a/doc/sponsors/warp-logo.png and /dev/null differ diff --git a/doc/sponsors/warp-pack-header.png b/doc/sponsors/warp-pack-header.png deleted file mode 100644 index ed7efe6a..00000000 Binary files a/doc/sponsors/warp-pack-header.png and /dev/null differ diff --git a/src/assets.rs b/src/assets.rs index 29247bd7..9483f032 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -158,7 +158,9 @@ impl HighlightingAssets { let syntax_match = mapping.get_syntax_for(path); if let Some(MappingTarget::MapToUnknown) = syntax_match { - return Err(Error::UndetectedSyntax(path.to_string_lossy().into())); + return Err(Error::UndetectedSyntax( + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()), + )); } if let Some(MappingTarget::MapTo(syntax_name)) = syntax_match { @@ -175,13 +177,17 @@ impl HighlightingAssets { ) { (Some(syntax), _) => Ok(syntax), - (_, Some(MappingTarget::MapExtensionToUnknown)) => { - Err(Error::UndetectedSyntax(path.to_string_lossy().into())) - } + (_, Some(MappingTarget::MapExtensionToUnknown)) => Err(Error::UndetectedSyntax( + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()), + )), _ => self .get_syntax_for_file_extension(file_name, &mapping.ignored_suffixes)? - .ok_or_else(|| Error::UndetectedSyntax(path.to_string_lossy().into())), + .ok_or_else(|| { + Error::UndetectedSyntax(crate::preprocessor::sanitize_for_terminal( + &path.to_string_lossy(), + )) + }), } } diff --git a/src/bin/bat/main.rs b/src/bin/bat/main.rs index 5184cf99..1d89f8ec 100644 --- a/src/bin/bat/main.rs +++ b/src/bin/bat/main.rs @@ -258,7 +258,9 @@ pub fn list_themes( 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}"); + // Prevent BEL/ESC/C1 bytes in the title from terminating or nesting the OSC. + let safe_title = bat::sanitize_for_terminal(&new_terminal_title); + print!("{osc_command_for_setting_terminal_title}{safe_title}{osc_end_command}"); io::stdout().flush().unwrap(); } diff --git a/src/input.rs b/src/input.rs index 30f13f98..facd64bf 100644 --- a/src/input.rs +++ b/src/input.rs @@ -207,7 +207,7 @@ impl<'a> Input<'a> { kind: OpenedInputKind::StdIn, description, metadata: self.metadata, - reader: InputReader::new(stdin), + reader: InputReader::try_new(stdin)?, }) } @@ -216,34 +216,34 @@ impl<'a> Input<'a> { description, metadata: self.metadata, reader: { - let mut file = File::open(&path) - .map_err(|e| format!("'{}': {e}", path.to_string_lossy()))?; + let path_display = + crate::preprocessor::sanitize_for_terminal(&path.to_string_lossy()); + let mut file = + File::open(&path).map_err(|e| format!("'{path_display}': {e}"))?; if file.metadata()?.is_dir() { - return Err(format!("'{}' is a directory.", path.to_string_lossy()).into()); + return Err(format!("'{path_display}' is a directory.").into()); } if let Some(stdout) = stdout_identifier { - let input_identifier = Identifier::try_from(file).map_err(|e| { - format!("{}: Error identifying file: {e}", path.to_string_lossy()) - })?; + let input_identifier = Identifier::try_from(file) + .map_err(|e| format!("{path_display}: Error identifying file: {e}"))?; if stdout.surely_conflicts_with(&input_identifier) { return Err(format!( - "IO circle detected. The input from '{}' is also an output. Aborting to avoid infinite loop.", - path.to_string_lossy() + "IO circle detected. The input from '{path_display}' is also an output. Aborting to avoid infinite loop.", ) .into()); } file = input_identifier.into_inner().expect("The file was lost in the clircle::Identifier, this should not have happened..."); } - InputReader::new(BufReader::new(file)) + InputReader::try_new(BufReader::new(file))? }, }), InputKind::CustomReader(reader) => Ok(OpenedInput { description, kind: OpenedInputKind::CustomReader, metadata: self.metadata, - reader: InputReader::new(BufReader::new(reader)), + reader: InputReader::try_new(BufReader::new(reader))?, }), } } @@ -257,24 +257,29 @@ pub(crate) struct InputReader<'a> { } impl<'a> InputReader<'a> { - pub(crate) fn new(mut reader: R) -> InputReader<'a> { + #[cfg(test)] + pub(crate) fn new(reader: R) -> InputReader<'a> { + Self::try_new(reader).expect("reading the first line failed") + } + + pub(crate) fn try_new(mut reader: R) -> io::Result> { let mut first_line = vec![]; - reader.read_until(b'\n', &mut first_line).ok(); + reader.read_until(b'\n', &mut first_line)?; let content_type = inspect_content_type(&first_line); if content_type == Some(ContentType::UTF_16LE) { - read_utf16_line(&mut reader, &mut first_line, 0x00, 0x0A).ok(); + read_utf16_line(&mut reader, &mut first_line, 0x00, 0x0A)?; } else if content_type == Some(ContentType::UTF_16BE) { - read_utf16_line(&mut reader, &mut first_line, 0x0A, 0x00).ok(); + read_utf16_line(&mut reader, &mut first_line, 0x0A, 0x00)?; } - InputReader { + Ok(InputReader { inner: Box::new(reader), first_line, content_type, unbuffered: false, - } + }) } pub(crate) fn read_line(&mut self, buf: &mut Vec) -> io::Result { @@ -405,6 +410,27 @@ fn non_zip_pk_prefix_is_not_treated_as_binary() { ); } +#[test] +fn input_open_returns_initial_read_errors() { + struct FailingRead; + + impl Read for FailingRead { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + Err(io::Error::other("initial read failed")) + } + } + + let input = Input::from_reader(Box::new(FailingRead)); + let result = input.open(io::empty(), None); + + assert!(result.is_err()); + assert!(result + .err() + .unwrap() + .to_string() + .contains("initial read failed")); +} + #[test] fn utf16le() { let content = b"\xFF\xFE\x73\x00\x0A\x00\x64\x00"; diff --git a/src/lessopen.rs b/src/lessopen.rs index 966bc2c0..c7593b53 100644 --- a/src/lessopen.rs +++ b/src/lessopen.rs @@ -155,7 +155,7 @@ impl LessOpenPreprocessor { Ok(OpenedInput { kind, - reader: InputReader::new(BufReader::new( + reader: InputReader::try_new(BufReader::new( if matches!(self.kind, LessOpenKind::TempFile) { let lessopen_string = match String::from_utf8(lessopen_stdout) { Ok(string) => string, @@ -192,7 +192,7 @@ impl LessOpenPreprocessor { .map(|s| s.replacen("%s", &path_str, 1).replacen("%s", "-", 1)), } }, - )), + ))?, metadata: input.metadata, description: input.description, }) @@ -261,7 +261,7 @@ impl Drop for Preprocessed { } }; - if lessclose_output.status.success() { + if !lessclose_output.status.success() { bat_warning!("$LESSCLOSE exited with nonzero exit code",) }; } diff --git a/src/lib.rs b/src/lib.rs index 4c60f10e..685214dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ mod vscreen; pub(crate) mod wrapping; pub use nonprintable_notation::{BinaryBehavior, NonprintableNotation}; +pub use preprocessor::sanitize_for_terminal; pub use preprocessor::StripAnsiMode; pub use pretty_printer::{Input, PrettyPrinter, Syntax}; pub use syntax_mapping::{MappingTarget, SyntaxMapping}; diff --git a/src/preprocessor.rs b/src/preprocessor.rs index 6b4e2935..a34f9f9e 100644 --- a/src/preprocessor.rs +++ b/src/preprocessor.rs @@ -149,6 +149,35 @@ pub fn strip_ansi(line: &str) -> String { buffer } +/// Escape C0, DEL, and C1 control characters so a string from an untrusted +/// filename or path can be safely written to the terminal. +pub fn sanitize_for_terminal(input: &str) -> String { + if !input + .chars() + .any(|c| matches!(c, '\x00'..='\x08' | '\x0A'..='\x1F' | '\x7F'..='\u{9F}')) + { + return input.to_owned(); + } + + let mut out = String::with_capacity(input.len() + 8); + for c in input.chars() { + match c { + '\t' => out.push('\t'), + '\x00'..='\x1F' => { + out.push('^'); + out.push(char::from_u32(0x40 + c as u32).unwrap_or('?')); + } + '\x7F' => out.push_str("^?"), + '\u{80}'..='\u{9F}' => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } + other => out.push(other), + } + } + out +} + /// Strips overstrike sequences (backspace formatting) from input. /// /// Overstrike formatting is used by man pages and some help output: @@ -261,3 +290,40 @@ fn test_strip_overstrike() { // Unicode with overstrike assert_eq!(strip_overstrike("ä\x08äöü", 2), "äöü"); } + +#[test] +fn test_sanitize_for_terminal_passthrough() { + assert_eq!(sanitize_for_terminal(""), ""); + assert_eq!(sanitize_for_terminal("hello.txt"), "hello.txt"); + assert_eq!(sanitize_for_terminal("résumé.pdf"), "résumé.pdf"); + assert_eq!(sanitize_for_terminal("日本語.md"), "日本語.md"); + assert_eq!( + sanitize_for_terminal("path/with spaces/file.log"), + "path/with spaces/file.log" + ); + assert_eq!(sanitize_for_terminal("a\tb"), "a\tb"); +} + +#[test] +fn test_sanitize_for_terminal_c0_controls() { + assert_eq!( + sanitize_for_terminal("\x1b[31mINJECTED\x1b[0m.txt"), + "^[[31mINJECTED^[[0m.txt" + ); + assert_eq!(sanitize_for_terminal("bad\x07rest"), "bad^Grest"); + assert_eq!(sanitize_for_terminal("\x00\x08\n\r\x7F"), "^@^H^J^M^?"); + assert_eq!(sanitize_for_terminal("\u{9b}31m"), "\\u{9b}31m"); + assert_eq!( + sanitize_for_terminal("\u{9d}0;pwned\x07"), + "\\u{9d}0;pwned^G" + ); +} + +#[test] +fn test_sanitize_for_terminal_idempotent_on_sanitized() { + let dirty = "\x1b]0;pwned\x07file.txt"; + let clean = sanitize_for_terminal(dirty); + assert_eq!(sanitize_for_terminal(&clean), clean); + assert!(!clean.contains('\x1b')); + assert!(!clean.contains('\x07')); +} diff --git a/src/printer.rs b/src/printer.rs index c58c914d..17f7aa30 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -29,7 +29,9 @@ use crate::error::*; use crate::input::OpenedInput; use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult}; use crate::output::OutputHandle; -use crate::preprocessor::{expand_tabs, replace_nonprintable, strip_ansi, strip_overstrike}; +use crate::preprocessor::{ + expand_tabs, replace_nonprintable, sanitize_for_terminal, strip_ansi, strip_overstrike, +}; use crate::style::StyleComponent; use crate::terminal::{as_terminal_escaped, to_ansi_color}; use crate::vscreen::{AnsiStyle, EscapeSequence, EscapeSequenceIterator}; @@ -489,7 +491,7 @@ impl Printer for InteractivePrinter<'_> { (but will be present if the output of 'bat' is piped). You can use 'bat -A' \ to show the binary file contents.", Yellow.paint("[bat warning]"), - input.description.summary(), + sanitize_for_terminal(&input.description.summary()), )?; } else if self.config.style_components.grid() { self.print_horizontal_line(handle, '┬')?; @@ -543,9 +545,11 @@ impl Printer for InteractivePrinter<'_> { "{}{}{mode}", description .kind() - .map(|kind| format!("{kind}: ")) + .map(|kind| format!("{}: ", sanitize_for_terminal(kind))) .unwrap_or_else(|| "".into()), - self.colors.header_value.paint(description.title()), + self.colors + .header_value + .paint(sanitize_for_terminal(description.title())), ); self.print_header_multiline_component(handle, &header_filename) } diff --git a/tests/syntax-tests/highlighted/Kotlin/test.kt b/tests/syntax-tests/highlighted/Kotlin/test.kt index dcb1560e..bb17aba2 100644 --- a/tests/syntax-tests/highlighted/Kotlin/test.kt +++ b/tests/syntax-tests/highlighted/Kotlin/test.kt @@ -1,85 +1,85 @@ -import kotlin.math.* +import kotlin.math.* -data class Example( - val name: String, - val numbers: List<Int?> +data class Example( + val name: String, + val numbers: List<Int?> ) -fun interface JokeInterface { - fun isFunny(): Boolean +fun interface JokeInterface { + fun isFunny(): Boolean } -abstract class AbstractJoke : JokeInterface { - override fun isFunny() = false - abstract fun content(): String +abstract class AbstractJoke : JokeInterface { + override fun isFunny() = false + abstract fun content(): String } -class Joke : AbstractJoke() { - override fun isFunny(): Boolean { +class Joke : AbstractJoke() { + override fun isFunny(): Boolean {  return true - } - override fun content(): String = "content of joke here, haha" + } + override fun content(): String = "content of joke here, haha" } -class DelegatedJoke(val joke: Joke) : JokeInterface by joke { - val number: Long = 123L +class DelegatedJoke(val joke: Joke) : JokeInterface by joke { + val number: Long = 123L - companion object { - const val someConstant = "some constant text" - } + companion object { + const val someConstant = "some constant text" + } } -object SomeSingleton +object SomeSingleton -sealed class Shape { - abstract fun area(): Double +sealed class Shape { + abstract fun area(): Double } -data class Square(val sideLength: Double) : Shape() { - override fun area(): Double = sideLength.pow(2) +data class Square(val sideLength: Double) : Shape() { + override fun area(): Double = sideLength.pow(2) } -object Point : Shape() { - override fun area() = .0 +object Point : Shape() { + override fun area() = .0 } -class Circle(val radius: Double) : Shape() { - override fun area(): Double { - return PI * radius * radius - } +class Circle(val radius: Double) : Shape() { + override fun area(): Double { + return PI * radius * radius + } } -fun String.extensionMethod() = "test" +fun String.extensionMethod() = "test" -fun main() { - val name = """ +fun main() { + val name = """  multiline  string    some numbers: 123123 42 - """.trimIndent() - val example = Example(name = name, numbers = listOf(512, 42, null, -1)) + """.trimIndent() + val example = Example(name = name, numbers = listOf(512, 42, null, -1)) - example.numbers - .filterNotNull() - .forEach { println(it) } + example.numbers + .filterNotNull() + .forEach { println(it) } - setOf(Joke(), DelegatedJoke(Joke()).joke) - .filter(JokeInterface::isFunny) - .map(AbstractJoke::content) - .forEachIndexed { index: Int, joke -> - println("I heard a funny joke(#${index + 1}): $joke") - } + setOf(Joke(), DelegatedJoke(Joke()).joke) + .filter(JokeInterface::isFunny) + .map(AbstractJoke::content) + .forEachIndexed { index: Int, joke -> + println("I heard a funny joke(#${index + 1}): $joke") + } - listOf(Square(12.3), Point, Circle(5.2)) - .associateWith(Shape::area) - .toList() - .sortedBy { it.second } - .forEach { - println("${it.first}: ${it.second}") - } + listOf(Square(12.3), Point, Circle(5.2)) + .associateWith(Shape::area) + .toList() + .sortedBy { it.second } + .forEach { + println("${it.first}: ${it.second}") + } - println("some string".extensionMethod()) + println("some string".extensionMethod()) - require(SomeSingleton::class.simpleName == "SomeSingletonName") { "something does not seem right..." } + require(SomeSingleton::class.simpleName == "SomeSingletonName") { "something does not seem right..." } }