Skip to main content

forge_terminal/
osc.rs

1//! The OSC 133 scanner: exact command marks from the shell's own integration.
2//!
3//! The shell writes `OSC 133 ; A` at the prompt start, `; B` at the prompt
4//! end, `; C` before a command runs and `; D [;<exit>]` when it finishes. The
5//! scanner reads those sequences out of the byte stream **before** the bytes
6//! reach `Term`, records the marks, and leaves the bytes untouched - the
7//! client sees exactly what the shell wrote.
8//!
9//! A sequence split across two reads is carried in the scanner's own state,
10//! so a mark is never lost to a read boundary.
11
12/// One command mark the shell emitted.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum Mark {
15    /// The prompt is about to be drawn.
16    PromptStart,
17    /// The prompt is done; the person may type.
18    PromptEnd,
19    /// A command started running.
20    CommandStart,
21    /// A command finished, with the exit code when the shell carried one.
22    CommandEnd(Option<i32>),
23}
24
25/// The marks seen so far, and the state they imply.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub struct Marks {
28    /// Whether any OSC 133 mark has been seen. `false` means the shell has no
29    /// integration and the classifier must fall back to the heuristic.
30    pub seen: bool,
31    /// Whether a command is running: a `C` with no `D` after it.
32    pub running: bool,
33    /// The exit code the last `D` carried, when it carried one.
34    pub last_exit: Option<i32>,
35    /// The last mark seen.
36    pub last: Option<Mark>,
37}
38
39/// The scanner's position in the byte stream.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41enum Scan {
42    Ground,
43    Escape,
44    Osc,
45    OscEscape,
46}
47
48/// The most of one OSC payload the scanner keeps. A clipboard write can be
49/// megabytes; the scanner only needs the `133;` prefix, so a payload past
50/// this is discarded rather than held.
51const MAX_PAYLOAD: usize = 4096;
52
53/// The OSC 133 scanner.
54#[derive(Debug)]
55pub struct Osc133 {
56    scan: Scan,
57    payload: Vec<u8>,
58    discard: bool,
59    marks: Marks,
60}
61
62impl Default for Osc133 {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Osc133 {
69    pub fn new() -> Self {
70        Self { scan: Scan::Ground, payload: Vec::new(), discard: false, marks: Marks::default() }
71    }
72
73    /// The marks seen so far.
74    pub fn marks(&self) -> Marks {
75        self.marks
76    }
77
78    /// Scan one read's bytes, recording any marks. The bytes are not changed.
79    pub fn feed(&mut self, bytes: &[u8]) {
80        for &byte in bytes {
81            self.step(byte);
82        }
83    }
84
85    fn step(&mut self, byte: u8) {
86        match self.scan {
87            Scan::Ground => {
88                if byte == 0x1b {
89                    self.scan = Scan::Escape;
90                }
91            }
92            Scan::Escape => match byte {
93                b']' => {
94                    self.scan = Scan::Osc;
95                    self.payload.clear();
96                    self.discard = false;
97                }
98                0x1b => {}
99                _ => self.scan = Scan::Ground,
100            },
101            Scan::Osc => match byte {
102                0x07 => {
103                    self.finish();
104                    self.scan = Scan::Ground;
105                }
106                0x1b => self.scan = Scan::OscEscape,
107                _ => self.push(byte),
108            },
109            Scan::OscEscape => match byte {
110                b'\\' => {
111                    self.finish();
112                    self.scan = Scan::Ground;
113                }
114                0x1b => {}
115                _ => {
116                    // The ESC ended the OSC without a string terminator; the
117                    // byte starts whatever comes next.
118                    self.finish();
119                    self.scan = Scan::Ground;
120                    self.step(byte);
121                }
122            },
123        }
124    }
125
126    fn push(&mut self, byte: u8) {
127        if self.discard {
128            return;
129        }
130        if self.payload.len() >= MAX_PAYLOAD {
131            self.discard = true;
132            self.payload.clear();
133            return;
134        }
135        self.payload.push(byte);
136    }
137
138    fn finish(&mut self) {
139        if self.discard {
140            self.discard = false;
141            self.payload.clear();
142            return;
143        }
144        let payload = std::mem::take(&mut self.payload);
145        let Some(rest) = payload.strip_prefix(b"133;") else { return };
146        let mark = match rest {
147            b"A" => Mark::PromptStart,
148            b"B" => Mark::PromptEnd,
149            b"C" => Mark::CommandStart,
150            b"D" => Mark::CommandEnd(None),
151            other => {
152                let Some(code) = other.strip_prefix(b"D;") else { return };
153                let Ok(text) = std::str::from_utf8(code) else { return };
154                let Ok(code) = text.parse::<i32>() else { return };
155                Mark::CommandEnd(Some(code))
156            }
157        };
158        self.record(mark);
159    }
160
161    fn record(&mut self, mark: Mark) {
162        self.marks.seen = true;
163        self.marks.last = Some(mark);
164        match mark {
165            Mark::CommandStart => self.marks.running = true,
166            Mark::CommandEnd(code) => {
167                self.marks.running = false;
168                self.marks.last_exit = code;
169            }
170            Mark::PromptStart | Mark::PromptEnd => {}
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn scan(bytes: &[u8]) -> Marks {
180        let mut scanner = Osc133::new();
181        scanner.feed(bytes);
182        scanner.marks()
183    }
184
185    /// The four marks arrive in order and leave the state where the shell
186    /// says it is: a `C` is a command running, a `D;7` is a failure.
187    #[test]
188    fn the_four_marks_are_recorded_in_order() {
189        let marks =
190            scan(b"\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\x1b]133;C\x07out\r\n\x1b]133;D;7\x07");
191        assert!(marks.seen, "the shell has integration");
192        assert!(!marks.running, "the command finished");
193        assert_eq!(marks.last_exit, Some(7), "the failing command's code");
194        assert_eq!(marks.last, Some(Mark::CommandEnd(Some(7))));
195    }
196
197    /// A `C` with no `D` is a command still running, which is what
198    /// `terminal__wait {until: "exit"}` waits on.
199    #[test]
200    fn a_command_start_with_no_end_is_running() {
201        let marks = scan(b"\x1b]133;A\x07\x1b]133;B\x07\x1b]133;C\x07");
202        assert!(marks.running);
203        assert_eq!(marks.last_exit, None);
204    }
205
206    /// **A mark split across two reads is not lost.** A read boundary can
207    /// fall anywhere, including inside the escape sequence.
208    #[test]
209    fn a_mark_split_across_reads_is_recorded() {
210        let mut scanner = Osc133::new();
211        scanner.feed(b"\x1b]13");
212        scanner.feed(b"3;D;130");
213        scanner.feed(b"\x07");
214        let marks = scanner.marks();
215        assert_eq!(marks.last_exit, Some(130), "Ctrl-C's code survives the split");
216        assert!(!marks.running);
217    }
218
219    /// The string terminator may be `ESC \` as well as BEL, and both end the
220    /// same mark.
221    #[test]
222    fn the_esc_backslash_terminator_ends_a_mark() {
223        let marks = scan(b"\x1b]133;D;0\x1b\\");
224        assert_eq!(marks.last_exit, Some(0));
225    }
226
227    /// A shell with no integration leaves `seen` false, which is what makes
228    /// the classifier say `heuristic`.
229    #[test]
230    fn a_stream_with_no_marks_is_not_exact() {
231        let marks = scan(b"$ ls\r\nfile\r\n$ ");
232        assert!(!marks.seen);
233        assert!(!marks.running);
234        assert_eq!(marks.last_exit, None);
235    }
236
237    /// Another OSC sequence is not a mark, and a malformed `D` is ignored
238    /// rather than read as an exit code.
239    #[test]
240    fn other_osc_sequences_and_malformed_marks_are_ignored() {
241        let marks = scan(b"\x1b]0;title\x07\x1b]133;D;notanumber\x07\x1b]133;Z\x07");
242        assert!(!marks.seen, "none of those is a mark");
243    }
244
245    /// A payload past the cap is discarded rather than held, and the scanner
246    /// recovers for the next mark.
247    #[test]
248    fn an_oversized_payload_is_discarded_and_the_scanner_recovers() {
249        let mut scanner = Osc133::new();
250        scanner.feed(b"\x1b]52;c;");
251        scanner.feed(&vec![b'A'; MAX_PAYLOAD + 10]);
252        scanner.feed(b"\x07");
253        assert!(!scanner.marks().seen, "the oversized payload is not a mark");
254        scanner.feed(b"\x1b]133;D;3\x07");
255        assert_eq!(scanner.marks().last_exit, Some(3), "and the next mark is read");
256    }
257}