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 mut encoded = Vec::with_capacity(input.len());
63    for &byte in input {
64        encode_paste_byte(byte, &mut encoded);
65    }
66    encoded
67}
68
69fn encode_paste_byte(byte: u8, output: &mut Vec<u8>) {
70    if (0x20..=0x7e).contains(&byte) || byte >= 0x80 {
71        output.push(byte);
72        return;
73    }
74
75    match byte {
76        b'\0' => output.extend_from_slice(b"\\000"),
77        b'\x07' => output.extend_from_slice(b"\\a"),
78        b'\x08' => output.extend_from_slice(b"\\b"),
79        b'\t' => output.extend_from_slice(b"\\t"),
80        b'\n' => output.extend_from_slice(b"\\n"),
81        b'\x0b' => output.extend_from_slice(b"\\v"),
82        b'\x0c' => output.extend_from_slice(b"\\f"),
83        b'\r' => output.extend_from_slice(b"\\r"),
84        _ => {
85            output.push(b'\\');
86            output.push(b'0' + ((byte >> 6) & 0x7));
87            output.push(b'0' + ((byte >> 3) & 0x7));
88            output.push(b'0' + (byte & 0x7));
89        }
90    }
91}
92
93fn encode_byte(byte: u8, flags: VisFlags, output: &mut String) {
94    if should_keep_raw(byte, flags) {
95        output.push(char::from(byte));
96        return;
97    }
98
99    if flags.cstyle {
100        match byte {
101            b'\0' => {
102                output.push_str("\\000");
103                return;
104            }
105            b'\x07' => {
106                output.push_str("\\a");
107                return;
108            }
109            b'\x08' => {
110                output.push_str("\\b");
111                return;
112            }
113            b'\x09' => {
114                output.push_str("\\t");
115                return;
116            }
117            b'\x0a' => {
118                output.push_str("\\n");
119                return;
120            }
121            b'\x0b' => {
122                output.push_str("\\v");
123                return;
124            }
125            b'\x0c' => {
126                output.push_str("\\f");
127                return;
128            }
129            b'\x0d' => {
130                output.push_str("\\r");
131                return;
132            }
133            b'\\' if !flags.noslash => {
134                output.push_str("\\\\");
135                return;
136            }
137            _ => {}
138        }
139    }
140
141    if byte == b'\\' && !flags.noslash {
142        output.push_str("\\\\");
143        return;
144    }
145
146    if flags.octal || flags.safe || flags.cstyle {
147        output.push('\\');
148        output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
149        output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
150        output.push(char::from(b'0' + (byte & 0x7)));
151        return;
152    }
153
154    output.push(char::from(byte));
155}
156
157fn should_keep_raw(byte: u8, flags: VisFlags) -> bool {
158    if byte == b'\\' && !flags.noslash {
159        return false;
160    }
161
162    if flags.safe {
163        return (0x20..=0x7e).contains(&byte);
164    }
165
166    if byte == b'\t' {
167        return !flags.tab;
168    }
169    if byte == b'\n' {
170        return !flags.newline;
171    }
172
173    (0x20..=0x7e).contains(&byte)
174}
175
176fn truncate_at_byte_boundary(value: &mut String, max_len: usize) {
177    if value.len() <= max_len {
178        return;
179    }
180
181    let mut boundary = max_len;
182    while !value.is_char_boundary(boundary) {
183        boundary -= 1;
184    }
185    value.truncate(boundary);
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{encode_buffer_sample, encode_bytes, encode_paste_bytes, VisFlags};
191
192    #[test]
193    fn sample_encoding_matches_tmux_style_for_common_controls() {
194        let encoded = encode_buffer_sample(b"one\two\nthree\\");
195        assert_eq!(encoded, "one\\two\\nthree\\\\");
196    }
197
198    #[test]
199    fn sample_encoding_truncates_to_tmux_width() {
200        let input = vec![b'a'; 205];
201        let encoded = encode_buffer_sample(&input);
202        assert_eq!(encoded.len(), 203);
203        assert!(encoded.ends_with("..."));
204    }
205
206    #[test]
207    fn safe_paste_encoding_keeps_printable_bytes() {
208        assert_eq!(encode_paste_bytes(b"hello"), b"hello");
209    }
210
211    #[test]
212    fn safe_paste_encoding_escapes_controls_without_backslash_escaping() {
213        let encoded = String::from_utf8(encode_paste_bytes(b"\t\\\x1b")).expect("utf8");
214        assert_eq!(encoded, "\\t\\\\033");
215    }
216
217    #[test]
218    fn safe_paste_encoding_preserves_utf8_bytes() {
219        let input = "echo PASTED_漢字".as_bytes();
220        assert_eq!(encode_paste_bytes(input), input);
221    }
222
223    #[test]
224    fn safe_paste_encoding_escapes_delete() {
225        let encoded = String::from_utf8(encode_paste_bytes(b"\x7f")).expect("utf8");
226        assert_eq!(encoded, "\\177");
227    }
228
229    #[test]
230    fn generic_encoding_respects_noslash() {
231        let encoded = encode_bytes(
232            b"\\",
233            VisFlags {
234                noslash: true,
235                ..VisFlags::default()
236            },
237        );
238        assert_eq!(encoded, "\\");
239    }
240}