|
| 1 | +//! Incremental sniffer for the `DECSCUSR` cursor-shape escape. |
| 2 | +//! |
| 3 | +//! vt100 parses and applies cursor *position* but does not model cursor |
| 4 | +//! *shape*, so the actor watches the same byte stream it feeds the parser and |
| 5 | +//! folds out the latest `DECSCUSR` sequence: `CSI Ps SP q`, i.e. `ESC [`, an |
| 6 | +//! optional decimal parameter, a space (`0x20`), then `q`. The scanner is fed |
| 7 | +//! the raw PTY reads in order, and a sequence split across two reads resumes |
| 8 | +//! from the carried state, so a child that emits the escape in pieces is still |
| 9 | +//! recognized. |
| 10 | +//! |
| 11 | +//! Only this one final byte (`q`) is matched. Any other intermediate or final |
| 12 | +//! byte abandons the in-progress sequence, so unrelated CSI sequences (colors, |
| 13 | +//! cursor moves) never produce a false shape change. |
| 14 | +
|
| 15 | +use crate::types::CursorShape; |
| 16 | + |
| 17 | +/// Where the scanner is inside a candidate `CSI Ps SP q` sequence. |
| 18 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 19 | +enum State { |
| 20 | + /// No escape in progress. |
| 21 | + #[default] |
| 22 | + Ground, |
| 23 | + /// Saw `ESC`, waiting for `[`. |
| 24 | + Escape, |
| 25 | + /// Inside `CSI`, accumulating the decimal parameter (and looking for the |
| 26 | + /// space that ends it). |
| 27 | + Params, |
| 28 | + /// Saw the space after the parameter, waiting for the final `q`. |
| 29 | + Intermediate, |
| 30 | +} |
| 31 | + |
| 32 | +/// A byte-stream scanner that surfaces the most recent `DECSCUSR` shape. |
| 33 | +/// |
| 34 | +/// One scanner lives per terminal actor. Feed it every PTY read with |
| 35 | +/// [`Scanner::feed`]; it returns `Some(shape)` for each `DECSCUSR` it completes |
| 36 | +/// so the caller can publish the latest one. |
| 37 | +#[derive(Debug, Default)] |
| 38 | +pub struct Scanner { |
| 39 | + state: State, |
| 40 | + /// The decimal parameter accumulated in [`State::Params`], capped so a |
| 41 | + /// hostile child cannot overflow it; real parameters are one or two digits. |
| 42 | + param: u16, |
| 43 | +} |
| 44 | + |
| 45 | +const ESC: u8 = 0x1b; |
| 46 | +const CSI_OPEN: u8 = b'['; |
| 47 | +const SPACE: u8 = b' '; |
| 48 | +const FINAL: u8 = b'q'; |
| 49 | + |
| 50 | +impl Scanner { |
| 51 | + /// Scan `bytes`, returning the last completed `DECSCUSR` shape if any. |
| 52 | + /// |
| 53 | + /// Returns the final shape in the buffer rather than every intermediate one |
| 54 | + /// because the actor only tracks the current shape; an earlier escape in |
| 55 | + /// the same read is immediately superseded. |
| 56 | + pub fn feed(&mut self, bytes: &[u8]) -> Option<CursorShape> { |
| 57 | + let mut latest = None; |
| 58 | + for &byte in bytes { |
| 59 | + if let Some(shape) = self.step(byte) { |
| 60 | + latest = Some(shape); |
| 61 | + } |
| 62 | + } |
| 63 | + latest |
| 64 | + } |
| 65 | + |
| 66 | + fn step(&mut self, byte: u8) -> Option<CursorShape> { |
| 67 | + match self.state { |
| 68 | + State::Ground => { |
| 69 | + if byte == ESC { |
| 70 | + self.state = State::Escape; |
| 71 | + } |
| 72 | + None |
| 73 | + } |
| 74 | + State::Escape => { |
| 75 | + self.state = if byte == CSI_OPEN { |
| 76 | + self.param = 0; |
| 77 | + State::Params |
| 78 | + } else if byte == ESC { |
| 79 | + State::Escape |
| 80 | + } else { |
| 81 | + State::Ground |
| 82 | + }; |
| 83 | + None |
| 84 | + } |
| 85 | + State::Params => { |
| 86 | + if byte.is_ascii_digit() { |
| 87 | + self.param = self |
| 88 | + .param |
| 89 | + .saturating_mul(10) |
| 90 | + .saturating_add(u16::from(byte - b'0')); |
| 91 | + } else if byte == SPACE { |
| 92 | + self.state = State::Intermediate; |
| 93 | + } else { |
| 94 | + // Any other byte (another intermediate, a different final, |
| 95 | + // or a stray ESC) ends this candidate. |
| 96 | + self.restart_on(byte); |
| 97 | + } |
| 98 | + None |
| 99 | + } |
| 100 | + State::Intermediate => { |
| 101 | + let shape = (byte == FINAL).then(|| CursorShape::from_decscusr(self.param)); |
| 102 | + self.restart_on(byte); |
| 103 | + shape |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + /// Reset to ground, but honor a fresh `ESC` so back-to-back sequences with |
| 109 | + /// no separator are not dropped. |
| 110 | + const fn restart_on(&mut self, byte: u8) { |
| 111 | + self.state = if byte == ESC { State::Escape } else { State::Ground }; |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +#[cfg(test)] |
| 116 | +mod tests { |
| 117 | + use super::*; |
| 118 | + |
| 119 | + #[test] |
| 120 | + fn maps_each_decscusr_parameter_to_a_shape() { |
| 121 | + // The boundary between the three shape buckets, including the unknown |
| 122 | + // fallback to block. |
| 123 | + let cases = [ |
| 124 | + (b"\x1b[0 q".as_slice(), CursorShape::Block), |
| 125 | + (b"\x1b[2 q", CursorShape::Block), |
| 126 | + (b"\x1b[3 q", CursorShape::Underline), |
| 127 | + (b"\x1b[4 q", CursorShape::Underline), |
| 128 | + (b"\x1b[5 q", CursorShape::Bar), |
| 129 | + (b"\x1b[6 q", CursorShape::Bar), |
| 130 | + (b"\x1b[ q", CursorShape::Block), |
| 131 | + (b"\x1b[99 q", CursorShape::Block), |
| 132 | + ]; |
| 133 | + for (bytes, want) in cases { |
| 134 | + let mut scanner = Scanner::default(); |
| 135 | + assert_eq!(scanner.feed(bytes), Some(want), "for {bytes:?}"); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn ignores_unrelated_csi_sequences() { |
| 141 | + // A color SGR and a cursor move must not be read as a shape change. |
| 142 | + let mut scanner = Scanner::default(); |
| 143 | + assert_eq!(scanner.feed(b"\x1b[1;31mhi\x1b[2;5H"), None); |
| 144 | + } |
| 145 | + |
| 146 | + #[test] |
| 147 | + fn resumes_a_sequence_split_across_feeds() { |
| 148 | + let mut scanner = Scanner::default(); |
| 149 | + assert_eq!(scanner.feed(b"\x1b[5"), None); |
| 150 | + assert_eq!(scanner.feed(b" q"), Some(CursorShape::Bar)); |
| 151 | + } |
| 152 | + |
| 153 | + #[test] |
| 154 | + fn returns_the_last_shape_in_one_feed() { |
| 155 | + let mut scanner = Scanner::default(); |
| 156 | + assert_eq!( |
| 157 | + scanner.feed(b"\x1b[3 q text \x1b[6 q"), |
| 158 | + Some(CursorShape::Bar) |
| 159 | + ); |
| 160 | + } |
| 161 | +} |
0 commit comments