Skip to main content

rmux_core/
vis.rs

1//! vis-style escaping helpers used by tmux-facing text surfaces.
2
3/// Flags controlling vis-style byte escaping.
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5pub(crate) struct VisFlags {
6    /// Emit octal escapes for bytes without a shorter cstyle form.
7    pub octal: bool,
8    /// Prefer C-style escapes for common control characters.
9    pub cstyle: bool,
10    /// Escape tabs.
11    pub tab: bool,
12    /// Escape newlines.
13    pub newline: bool,
14    /// Escape control bytes unsafe for terminal output.
15    pub safe: bool,
16    /// Do not specially escape backslashes.
17    pub noslash: bool,
18}
19
20/// Encodes bytes using the requested vis-style escaping rules.
21#[must_use]
22pub(crate) fn encode_bytes(input: &[u8], flags: VisFlags) -> String {
23    let mut encoded = String::new();
24    for &byte in input {
25        encode_byte(byte, flags, &mut encoded);
26    }
27    encoded
28}
29
30/// Encodes a UTF-8 string using the requested vis-style escaping rules.
31#[must_use]
32pub(crate) fn encode_str(input: &str, flags: VisFlags) -> String {
33    encode_bytes(input.as_bytes(), flags)
34}
35
36/// tmux-compatible `paste_make_sample` preview rendering.
37#[must_use]
38pub(crate) fn encode_buffer_sample(input: &[u8]) -> String {
39    const WIDTH: usize = 200;
40
41    let flags = VisFlags {
42        octal: true,
43        cstyle: true,
44        tab: true,
45        newline: true,
46        safe: false,
47        noslash: false,
48    };
49
50    let prefix_len = input.len().min(WIDTH);
51    let mut encoded = encode_bytes(&input[..prefix_len], flags);
52    if input.len() > WIDTH || encoded.len() > WIDTH {
53        truncate_at_byte_boundary(&mut encoded, WIDTH);
54        encoded.push_str("...");
55    }
56    encoded
57}
58
59/// Encodes bytes for tmux-style safe paste-buffer writes.
60#[must_use]
61pub fn encode_paste_bytes(input: &[u8]) -> Vec<u8> {
62    let flags = VisFlags {
63        octal: true,
64        cstyle: true,
65        tab: true,
66        newline: true,
67        safe: true,
68        noslash: true,
69    };
70    encode_bytes(input, flags).into_bytes()
71}
72
73fn encode_byte(byte: u8, flags: VisFlags, output: &mut String) {
74    if should_keep_raw(byte, flags) {
75        output.push(char::from(byte));
76        return;
77    }
78
79    if flags.cstyle {
80        match byte {
81            b'\0' => {
82                output.push_str("\\000");
83                return;
84            }
85            b'\x07' => {
86                output.push_str("\\a");
87                return;
88            }
89            b'\x08' => {
90                output.push_str("\\b");
91                return;
92            }
93            b'\x09' => {
94                output.push_str("\\t");
95                return;
96            }
97            b'\x0a' => {
98                output.push_str("\\n");
99                return;
100            }
101            b'\x0b' => {
102                output.push_str("\\v");
103                return;
104            }
105            b'\x0c' => {
106                output.push_str("\\f");
107                return;
108            }
109            b'\x0d' => {
110                output.push_str("\\r");
111                return;
112            }
113            b'\\' if !flags.noslash => {
114                output.push_str("\\\\");
115                return;
116            }
117            _ => {}
118        }
119    }
120
121    if byte == b'\\' && !flags.noslash {
122        output.push_str("\\\\");
123        return;
124    }
125
126    if flags.octal || flags.safe || flags.cstyle {
127        output.push('\\');
128        output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
129        output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
130        output.push(char::from(b'0' + (byte & 0x7)));
131        return;
132    }
133
134    output.push(char::from(byte));
135}
136
137fn should_keep_raw(byte: u8, flags: VisFlags) -> bool {
138    if byte == b'\\' && !flags.noslash {
139        return false;
140    }
141
142    if flags.safe {
143        return (0x20..=0x7e).contains(&byte);
144    }
145
146    if byte == b'\t' {
147        return !flags.tab;
148    }
149    if byte == b'\n' {
150        return !flags.newline;
151    }
152
153    (0x20..=0x7e).contains(&byte)
154}
155
156fn truncate_at_byte_boundary(value: &mut String, max_len: usize) {
157    if value.len() <= max_len {
158        return;
159    }
160
161    let mut boundary = max_len;
162    while !value.is_char_boundary(boundary) {
163        boundary -= 1;
164    }
165    value.truncate(boundary);
166}
167
168#[cfg(test)]
169mod tests {
170    use super::{encode_buffer_sample, encode_bytes, encode_paste_bytes, VisFlags};
171
172    #[test]
173    fn sample_encoding_matches_tmux_style_for_common_controls() {
174        let encoded = encode_buffer_sample(b"one\two\nthree\\");
175        assert_eq!(encoded, "one\\two\\nthree\\\\");
176    }
177
178    #[test]
179    fn sample_encoding_truncates_to_tmux_width() {
180        let input = vec![b'a'; 205];
181        let encoded = encode_buffer_sample(&input);
182        assert_eq!(encoded.len(), 203);
183        assert!(encoded.ends_with("..."));
184    }
185
186    #[test]
187    fn safe_paste_encoding_keeps_printable_bytes() {
188        assert_eq!(encode_paste_bytes(b"hello"), b"hello");
189    }
190
191    #[test]
192    fn safe_paste_encoding_escapes_controls_without_backslash_escaping() {
193        let encoded = String::from_utf8(encode_paste_bytes(b"\t\\\x1b")).expect("utf8");
194        assert_eq!(encoded, "\\t\\\\033");
195    }
196
197    #[test]
198    fn generic_encoding_respects_noslash() {
199        let encoded = encode_bytes(
200            b"\\",
201            VisFlags {
202                noslash: true,
203                ..VisFlags::default()
204            },
205        );
206        assert_eq!(encoded, "\\");
207    }
208}