diff --git a/atuin-common/src/utils.rs b/atuin-common/src/utils.rs index 59050b96..b6018fbb 100644 --- a/atuin-common/src/utils.rs +++ b/atuin-common/src/utils.rs @@ -1,4 +1,6 @@ +use std::borrow::Cow; use std::env; +use std::fmt::Write; use std::path::PathBuf; use rand::RngCore; @@ -100,6 +102,34 @@ pub fn is_bash() -> bool { env::var("ATUIN_SHELL_BASH").is_ok() } +/// Extension trait for anything that can behave like a string to make it easy to escape control +/// characters. +/// +/// Intended to help prevent control characters being printed and interpreted by the terminal when +/// printing history as well as to ensure the commands that appear in the interactive search +/// reflect the actual command run rather than just the printable characters. +pub trait Escapable: AsRef { + fn escape_control(&self) -> Cow { + if !self.as_ref().contains(|c: char| c.is_ascii_control()) { + self.as_ref().into() + } else { + let mut remaining = self.as_ref(); + // Not a perfect way to reserve space but should reduce the allocations + let mut buf = String::with_capacity(remaining.as_bytes().len()); + while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) { + // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char + buf.push_str(&remaining[..i]); + let _ = write!(&mut buf, "\\x{:02x}", remaining.as_bytes()[i]); + remaining = &remaining[i + 1..]; + } + buf.push_str(remaining); + buf.into() + } + } +} + +impl> Escapable for T {} + #[cfg(test)] mod tests { use time::Month; @@ -183,4 +213,34 @@ mod tests { assert_eq!(uuids.len(), how_many); } + + #[test] + fn escape_control_characters() { + use super::Escapable; + // CSI colour sequence + assert_eq!("\x1b[31mfoo".escape_control(), "\\x1b[31mfoo"); + + // Tabs count as control chars + assert_eq!("foo\tbar".escape_control(), "foo\\x09bar"); + + // space is in control char range but should be excluded + assert_eq!("two words".escape_control(), "two words"); + + // unicode multi-byte characters + let s = "🐢\x1b[32m🦀"; + assert_eq!(s.escape_control(), s.replace("\x1b", "\\x1b")); + } + + #[test] + fn escape_no_control_characters() { + use super::Escapable as _; + assert!(matches!( + "no control characters".escape_control(), + Cow::Borrowed(_) + )); + assert!(matches!( + "with \x1b[31mcontrol\x1b[0m characters".escape_control(), + Cow::Owned(_) + )); + } } diff --git a/atuin/src/command/client/history.rs b/atuin/src/command/client/history.rs index 10f1feb6..4178180c 100644 --- a/atuin/src/command/client/history.rs +++ b/atuin/src/command/client/history.rs @@ -5,7 +5,7 @@ use std::{ time::Duration, }; -use atuin_common::utils; +use atuin_common::utils::{self, Escapable as _}; use clap::Subcommand; use eyre::{Context, Result}; use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; @@ -201,7 +201,7 @@ impl FormatKey for FmtHistory<'_> { #[allow(clippy::cast_sign_loss)] fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { match key { - "command" => f.write_str(self.0.command.trim())?, + "command" => f.write_str(&self.0.command.trim().escape_control())?, "directory" => f.write_str(self.0.cwd.trim())?, "exit" => f.write_str(&self.0.exit.to_string())?, "duration" => { diff --git a/atuin/src/command/client/search.rs b/atuin/src/command/client/search.rs index 726da348..0e8e0205 100644 --- a/atuin/src/command/client/search.rs +++ b/atuin/src/command/client/search.rs @@ -1,4 +1,4 @@ -use atuin_common::utils; +use atuin_common::utils::{self, Escapable as _}; use clap::Parser; use eyre::Result; @@ -155,7 +155,7 @@ impl Cmd { if self.interactive { let item = interactive::history(&self.query, settings, db).await?; - eprintln!("{item}"); + eprintln!("{}", item.escape_control()); } else { let list_mode = ListMode::from_flags(self.human, self.cmd_only); diff --git a/atuin/src/command/client/search/history_list.rs b/atuin/src/command/client/search/history_list.rs index de4b46ce..39c1dc32 100644 --- a/atuin/src/command/client/search/history_list.rs +++ b/atuin/src/command/client/search/history_list.rs @@ -1,6 +1,7 @@ use std::time::Duration; use atuin_client::history::History; +use atuin_common::utils::Escapable as _; use ratatui::{ buffer::Buffer, layout::Rect, @@ -168,7 +169,7 @@ impl DrawState<'_> { style = style.fg(Color::Red).add_modifier(Modifier::BOLD); } - for section in h.command.split_ascii_whitespace() { + for section in h.command.escape_control().split_ascii_whitespace() { self.x += 1; if self.x > self.list_area.width { // Avoid attempting to draw a command section beyond the width diff --git a/atuin/src/command/client/stats.rs b/atuin/src/command/client/stats.rs index 55844ce7..e990b70b 100644 --- a/atuin/src/command/client/stats.rs +++ b/atuin/src/command/client/stats.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use atuin_common::utils::Escapable as _; use clap::Parser; use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; use eyre::{bail, Result}; @@ -66,7 +67,10 @@ fn compute_stats(settings: &Settings, history: &[History], count: usize) -> Resu print!(" "); } - println!("{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{command}{ResetColor}"); + println!( + "{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{}{ResetColor}", + command.escape_control() + ); } println!("Total commands: {}", history.len()); println!("Unique commands: {unique}");