aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--atuin-common/src/utils.rs60
-rw-r--r--atuin/src/command/client/history.rs4
-rw-r--r--atuin/src/command/client/search.rs4
-rw-r--r--atuin/src/command/client/search/history_list.rs3
-rw-r--r--atuin/src/command/client/stats.rs6
5 files changed, 71 insertions, 6 deletions
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<str> {
+ fn escape_control(&self) -> Cow<str> {
+ 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<T: AsRef<str>> 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}");