Skip to main content

trg/
escape.rs

1//! Terminal escaping. A log line is written by whoever sent the request, so it
2//! can carry escape sequences that retitle the window, clear the screen, write
3//! the clipboard, or on older terminals type text back into the shell.
4
5use std::borrow::Cow;
6use std::io::{self, Write};
7
8use termcolor::{ColorSpec, HyperlinkSpec, WriteColor};
9
10/// A writer that shows control bytes as text — `\x1b` rather than a real ESC —
11/// so the terminal displays them instead of acting on them.
12///
13/// It sits between the printers and their `termcolor::Buffer`. trg's own
14/// colours never pass through `write`: termcolor emits them from `set_color`
15/// and `reset`, which are delegated untouched, so they keep working while every
16/// byte that came out of a log or a member name is neutralised.
17///
18/// Escaped: C0 controls except tab and newline, DEL, and the UTF-8 encoded C1
19/// controls U+0080..U+009F (U+009B is a CSI on terminals that honour C1). A
20/// carriage return is kept when it is half of a CRLF line ending — escaping it
21/// would litter every line of a Windows log — and escaped on its own, where it
22/// moves the cursor back over the line and can hide what came before it.
23///
24/// Both of the two-byte decisions (CR then LF, C2 then a C1 byte) can straddle
25/// two writes, so the first byte is held back until the next one arrives. A
26/// held byte survives a colour change — grep-printer resets colour between a
27/// match ending in CR and its LF — and is settled on flush and on drop.
28///
29/// [`Escaped::json`] renders the same bytes as JSON escapes (`\u007f`), because
30/// the JSON printer's output is already-encoded JSON: serde escapes C0 itself
31/// but leaves DEL and C1 raw inside strings, and those are the only places
32/// such bytes can appear.
33///
34/// A literal `\x1b` already in a log prints the same as an escaped ESC. That
35/// ambiguity is accepted: backslashes are not escaped, so Windows paths and
36/// regexes in logs stay readable.
37pub struct Escaped<W: Write> {
38    inner: W,
39    on: bool,
40    json: bool,
41    pending: Option<u8>,
42}
43
44impl<W: Write> Escaped<W> {
45    pub fn new(inner: W, on: bool) -> Self {
46        Self { inner, on, json: false, pending: None }
47    }
48
49    /// Switch an unused writer to JSON escapes.
50    pub fn into_json(mut self) -> Self {
51        self.json = true;
52        self
53    }
54
55    pub fn json(inner: W, on: bool) -> Self {
56        Self { inner, on, json: true, pending: None }
57    }
58
59    /// One escaped character: a single control byte, or the C1 code point
60    /// whose second UTF-8 byte (`C2 xx`) is its value.
61    fn emit(&mut self, value: u8, c1: bool) -> io::Result<()> {
62        match (self.json, c1) {
63            (true, _) => write!(self.inner, "\\u{value:04x}"),
64            (false, true) => write!(self.inner, "\\u{{{value:x}}}"),
65            (false, false) => write!(self.inner, "\\x{value:02x}"),
66        }
67    }
68
69    /// Emit a held byte whose follower turned out not to be the one that
70    /// changes its meaning: a lone CR is escaped, a lone C2 is ordinary UTF-8.
71    fn settle(&mut self) -> io::Result<()> {
72        match self.pending.take() {
73            Some(b'\r') => self.emit(b'\r', false),
74            Some(b) => self.inner.write_all(&[b]),
75            None => Ok(()),
76        }
77    }
78}
79
80impl<W: Write> Write for Escaped<W> {
81    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
82        if !self.on {
83            self.inner.write_all(buf)?;
84            return Ok(buf.len());
85        }
86        if buf.is_empty() {
87            return Ok(0);
88        }
89
90        let mut i = 0;
91        match (self.pending, buf[0]) {
92            (Some(b'\r'), b'\n') => {
93                self.pending = None;
94                self.inner.write_all(b"\r")?;
95            }
96            (Some(0xC2), n @ 0x80..=0x9F) => {
97                self.pending = None;
98                self.emit(n, true)?;
99                i = 1;
100            }
101            _ => self.settle()?,
102        }
103
104        // Safe bytes are copied in runs; only an escape breaks a run. The scan
105        // for the next byte worth a second look is a plain predicate so it
106        // stays a tight loop: on a matching-heavy run nearly every byte is
107        // safe, and a per-byte `match` tripled the CPU spent printing.
108        let mut run = i;
109        while i < buf.len() {
110            match buf[i..].iter().position(|&b| needs_look(b)) {
111                Some(n) => i += n,
112                None => break,
113            }
114            let b = buf[i];
115            let next = buf.get(i + 1).copied();
116            let width = match (b, next) {
117                (b'\t' | b'\n', _) => 0,
118                (b'\r', Some(b'\n')) => 0,
119                (b'\r' | 0xC2, None) => {
120                    self.inner.write_all(&buf[run..i])?;
121                    self.pending = Some(b);
122                    return Ok(buf.len());
123                }
124                (0x00..=0x1F | 0x7F, _) => 1,
125                (0xC2, Some(0x80..=0x9F)) => 2,
126                _ => 0,
127            };
128            if width == 0 {
129                i += 1;
130                continue;
131            }
132            self.inner.write_all(&buf[run..i])?;
133            if width == 1 {
134                self.emit(b, false)?;
135            } else {
136                self.emit(buf[i + 1], true)?;
137            }
138            i += width;
139            run = i;
140        }
141        self.inner.write_all(&buf[run..])?;
142        Ok(buf.len())
143    }
144
145    fn flush(&mut self) -> io::Result<()> {
146        self.settle()?;
147        self.inner.flush()
148    }
149}
150
151/// Bytes that might be escaped, or start a pair that might be. CR and C2 are
152/// only candidates: whether they are escaped depends on the byte after them.
153fn needs_look(b: u8) -> bool {
154    (b < 0x20 && b != b'\t' && b != b'\n') || b == 0x7F || b == 0xC2
155}
156
157impl<W: Write> Drop for Escaped<W> {
158    fn drop(&mut self) {
159        // Nowhere to report a failure from a drop; the bytes before it have
160        // already gone through `write`, which did report.
161        let _ = self.settle();
162    }
163}
164
165impl<W: WriteColor> WriteColor for Escaped<W> {
166    fn supports_color(&self) -> bool {
167        self.inner.supports_color()
168    }
169    fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> {
170        self.inner.set_color(spec)
171    }
172    fn reset(&mut self) -> io::Result<()> {
173        self.inner.reset()
174    }
175    fn is_synchronous(&self) -> bool {
176        self.inner.is_synchronous()
177    }
178    fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> {
179        self.inner.set_hyperlink(link)
180    }
181    fn supports_hyperlinks(&self) -> bool {
182        self.inner.supports_hyperlinks()
183    }
184}
185
186static STDERR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
187
188/// Decide once, at startup, whether stderr is escaped. Process-wide because
189/// diagnostics are printed from deep inside the archive reader, where threading
190/// a flag through would touch every signature on the way.
191pub fn set_stderr(on: bool) {
192    let _ = STDERR.set(on);
193}
194
195/// `text` for a diagnostic, under the stderr decision. Before `set_stderr` has
196/// run — a flag that failed to parse — it falls back to `auto`.
197pub fn for_stderr(s: &str) -> Cow<'_, str> {
198    let on = *STDERR.get_or_init(|| io::IsTerminal::is_terminal(&io::stderr()));
199    text(s, on)
200}
201
202/// [`Escaped`] for a diagnostic string bound for stderr, which carries paths
203/// and member names and is a terminal just as often as stdout.
204pub fn text(s: &str, on: bool) -> Cow<'_, str> {
205    let clean = !s.bytes().any(|b| b < 0x20 && b != b'\t' && b != b'\n' || b == 0x7F)
206        && !s.chars().any(|c| ('\u{80}'..='\u{9f}').contains(&c));
207    if !on || clean {
208        return Cow::Borrowed(s);
209    }
210    let mut out = Vec::with_capacity(s.len() + 16);
211    {
212        let mut w = Escaped::new(&mut out, true);
213        // A `Vec` never fails a write.
214        let _ = w.write_all(s.as_bytes());
215    }
216    // Escaping only ever replaces whole characters with ASCII.
217    Cow::Owned(String::from_utf8(out).expect("escaping keeps UTF-8 valid"))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// Feed `chunks` as separate writes, then drop, and return the output.
225    fn esc(chunks: &[&[u8]]) -> Vec<u8> {
226        let mut out = Vec::new();
227        {
228            let mut w = Escaped::new(&mut out, true);
229            for c in chunks {
230                w.write_all(c).unwrap();
231            }
232        }
233        out
234    }
235
236    #[test]
237    fn esc_and_bel_become_visible() {
238        assert_eq!(esc(&[b"a\x1b]0;t\x07b"]), br"a\x1b]0;t\x07b");
239    }
240
241    #[test]
242    fn newline_and_tab_pass_through() {
243        assert_eq!(esc(&[b"a\tb\n"]), b"a\tb\n");
244    }
245
246    #[test]
247    fn del_and_nul_are_escaped() {
248        assert_eq!(esc(&[b"\x7f\x00"]), br"\x7f\x00");
249    }
250
251    #[test]
252    fn crlf_split_across_writes_is_kept() {
253        assert_eq!(esc(&[b"line\r", b"\n"]), b"line\r\n");
254    }
255
256    #[test]
257    fn a_trailing_cr_at_the_very_end_is_escaped() {
258        assert_eq!(esc(&[b"line\r"]), br"line\x0d");
259    }
260
261    #[test]
262    fn a_c1_split_across_writes_is_escaped() {
263        assert_eq!(esc(&[b"a\xc2", b"\x9bb"]), br"a\u{9b}b");
264    }
265
266    #[test]
267    fn ordinary_utf8_split_across_writes_is_untouched() {
268        // U+00E9 is C3 A9; U+00A0 is C2 A0, the byte after C2 but not a C1.
269        assert_eq!(esc(&["é".as_bytes(), b"\xc2", b"\xa0"]), "é\u{a0}".as_bytes());
270    }
271
272    #[test]
273    fn off_is_byte_exact() {
274        let mut out = Vec::new();
275        Escaped::new(&mut out, false).write_all(b"\x1b[2J\r").unwrap();
276        assert_eq!(out, b"\x1b[2J\r");
277    }
278
279    /// grep-printer resets colour between a match ending in CR and the LF.
280    #[test]
281    fn crlf_survives_a_colour_reset_between_its_halves() {
282        let mut buf = termcolor::Buffer::ansi();
283        {
284            let mut w = Escaped::new(&mut buf, true);
285            w.set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Red))).unwrap();
286            w.write_all(b"crlf\r").unwrap();
287            w.reset().unwrap();
288            w.write_all(b"\n").unwrap();
289        }
290        let out = buf.into_inner();
291        assert!(!out.windows(4).any(|w| w == br"\x0d"), "CRLF escaped: {out:?}");
292        assert!(out.ends_with(b"\r\n"), "got: {out:?}");
293    }
294
295    /// JSON output is already escaped by serde except for DEL and C1, which it
296    /// leaves raw; their escapes must be JSON's own or the output stops parsing.
297    #[test]
298    fn json_style_uses_json_escapes() {
299        let mut out = Vec::new();
300        {
301            let mut w = Escaped::json(&mut out, true);
302            w.write_all("{\"text\":\"a\u{7f}b\u{9b}c\"}\n".as_bytes()).unwrap();
303        }
304        assert_eq!(out, br#"{"text":"a\u007fb\u009bc"}"#.iter().copied().chain(*b"\n").collect::<Vec<_>>());
305    }
306
307    #[test]
308    fn text_escapes_a_str() {
309        assert_eq!(text("a\x1b[31mb", true), r"a\x1b[31mb");
310        assert_eq!(text("a\x1b[31mb", false), "a\x1b[31mb");
311    }
312}