atuin_common/string/escape_non_printable_posix_ext.rs
1use std::borrow::Cow;
2
3/// Extension trait for anything that can behave like a string to make it easy to
4/// escape control characters into a printable, `cat -v`-style representation.
5///
6/// Intended to help prevent control characters being printed and interpreted by
7/// the terminal when printing history as well as to ensure the commands that
8/// appear in the interactive search reflect the actual command run rather than
9/// just the printable characters.
10///
11/// The representation is the POSIX caret/meta notation used by `cat -v`:
12/// - C0 controls (`0x00..=0x1F`) and DEL (`0x7F`) become `^` followed by the
13/// character xor'd with `0x40`, so NUL is `^@`, tab is `^I`, ESC is `^[`, and
14/// DEL is `^?`.
15/// - C1 controls (`0x80..=0x9F`) become `M-^` followed by their low 7 bits
16/// xor'd with `0x40`, so U+009B (single-byte CSI) is `M-^[`.
17///
18/// Everything else — including spaces and printable multi-byte Unicode — is
19/// left untouched.
20pub trait EscapeNonPrintablePosixExt: AsRef<str> {
21 fn escape_non_printable(&self) -> Cow<'_, str> {
22 // Each character escapes to a (possibly empty) prefix followed by a
23 // single payload character, so every branch yields the same iterator
24 // type without any fixed-size padding.
25 let escape_char = |c: char| {
26 let (prefix, payload): (&str, char) = if c.is_ascii_control() {
27 // C0 controls and DEL: `^@`..`^_` and `^?`.
28 ("^", (c as u8 ^ 0x40) as char)
29 } else if c.is_control() {
30 // C1 controls (U+0080..=U+009F): `cat -v` meta+caret notation.
31 ("M-^", ((c as u8 & 0x7f) ^ 0x40) as char)
32 } else {
33 // Printable (including spaces and multi-byte Unicode): unchanged.
34 ("", c)
35 };
36
37 prefix.chars().chain(std::iter::once(payload))
38 };
39
40 let s = self.as_ref();
41 if !s.contains(|c: char| c.is_control()) {
42 return Cow::Borrowed(s);
43 }
44
45 Cow::Owned(s.chars().flat_map(escape_char).collect())
46 }
47}
48
49impl<T: AsRef<str>> EscapeNonPrintablePosixExt for T {}
50
51#[cfg(test)]
52mod tests {
53 use std::borrow::Cow;
54
55 use proptest::prelude::*;
56 use rstest::rstest;
57
58 use super::EscapeNonPrintablePosixExt;
59
60 /// Table-driven examples of the exact `cat -v` mapping. Each case is its own
61 /// test, so a failure names precisely which input broke.
62 #[rstest]
63 // Nothing to escape — returned unchanged (space is printable).
64 #[case::plain_text_unchanged("plain text", "plain text")]
65 #[case::two_words_unchanged("two words", "two words")]
66 // Printable multi-byte Unicode is preserved; only the control char changes.
67 #[case::multibyte_unicode_preserved_around_escaped_control("🐢\x1b[32m🦀", "🐢^[[32m🦀")]
68 // C0 controls and DEL → caret notation (`^` + byte ^ 0x40).
69 #[case::esc_0x1b("\x1b[31mfoo", "^[[31mfoo")] // ESC (0x1b)
70 #[case::tab_0x09("foo\tbar", "foo^Ibar")] // TAB (0x09)
71 #[case::nul_0x00("a\0b", "a^@b")] // NUL (0x00) — the core of issue #3589
72 #[case::del_0x7f("a\x7fb", "a^?b")] // DEL (0x7f ^ 0x40 == '?')
73 // C1 controls (U+0080..=U+009F) → `cat -v` meta+caret notation.
74 #[case::c1_control_0x80("\u{80}", "M-^@")]
75 #[case::single_char_csi_0x9b("a\u{9b}b", "aM-^[b")] // single-char CSI: 0x9b & 0x7f == 0x1b → ^[
76 #[case::c1_control_0x9f("\u{9f}", "M-^_")]
77 fn escapes_as_cat_v(#[case] input: &str, #[case] expected: &str) {
78 assert_eq!(input.escape_non_printable(), expected);
79 }
80
81 #[rstest]
82 fn escapes_all_c0_controls_as_caret() {
83 // Exhaustively check every C0 control 0x00..=0x1f → '^' + (byte ^ 0x40).
84 for byte in 0x00u8..=0x1f {
85 let input = (byte as char).to_string();
86 let expected = format!("^{}", (byte ^ 0x40) as char);
87 assert_eq!(input.escape_non_printable(), expected, "byte {byte:#04x}");
88 }
89 }
90
91 proptest! {
92 /// The whole point of the function: for ANY input string, the escaped
93 /// output never contains a control character.
94 #[rstest]
95 fn output_never_contains_control_chars(s in r"(?s).*") {
96 let escaped = s.escape_non_printable();
97 prop_assert!(!escaped.chars().any(char::is_control));
98 }
99
100 /// A string with no control characters is returned borrowed (zero-copy),
101 /// and is therefore byte-for-byte unchanged.
102 #[rstest]
103 fn control_free_input_is_borrowed(s in r"[^\p{Cc}]*") {
104 prop_assert!(matches!(s.escape_non_printable(), Cow::Borrowed(_)));
105 }
106
107 /// Escaping is idempotent: the output is already fully printable, so
108 /// escaping it a second time changes nothing.
109 #[rstest]
110 fn escaping_is_idempotent(s in r"(?s).*") {
111 let once = s.escape_non_printable().into_owned();
112 let twice = once.escape_non_printable().into_owned();
113 prop_assert_eq!(once, twice);
114 }
115 }
116}