ailloli_ui_terminal_core/
parser.rs

1//! Incremental VT byte parsing into [`TerminalState`].
2//!
3//! The parser supports a deliberately bounded terminal-control subset. Unknown,
4//! malformed, ignored, DCS, and security-sensitive operations are generally
5//! represented as [`TerminalWarning`] values rather
6//! than returned errors. Parsing itself performs no I/O.
7
8use crate::{
9    ShellKind, TerminalColor, TerminalMouseTrackingMode, TerminalProcessStatus, TerminalState,
10    TerminalWarning,
11};
12
13/// Incrementally applies terminal-process bytes to mutable terminal state.
14///
15/// Implementations retain parsing state across calls so callers may split UTF-8
16/// and escape sequences at arbitrary byte boundaries. The operation has no
17/// return value; implementations report recoverable protocol issues through the
18/// supplied state, typically as warnings.
19///
20/// # Examples
21///
22/// ```
23/// use ailloli_ui_terminal_core::{TerminalParser, TerminalState, VteTerminalParser};
24///
25/// fn feed(parser: &mut impl TerminalParser, state: &mut TerminalState, bytes: &[u8]) {
26///     parser.advance(state, bytes);
27/// }
28///
29/// let mut parser = VteTerminalParser::new();
30/// let mut state = TerminalState::new();
31/// feed(&mut parser, &mut state, b"hello");
32/// assert!(state.screen.lines[0].plain_text().starts_with("hello"));
33/// ```
34pub trait TerminalParser {
35    /// Consumes one arbitrary byte chunk and applies completed actions to `state`.
36    ///
37    /// An empty slice is a no-op. Chunks may end mid-character or mid-sequence;
38    /// the parser must retain enough state for a later call.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use ailloli_ui_terminal_core::{TerminalParser, TerminalState, VteTerminalParser};
44    ///
45    /// let mut parser = VteTerminalParser::default();
46    /// let mut state = TerminalState::new();
47    /// let utf8 = "界".as_bytes();
48    /// parser.advance(&mut state, &utf8[..1]);
49    /// parser.advance(&mut state, &utf8[1..]);
50    /// assert_eq!(state.screen.lines[0].cells[0].text, "界");
51    /// ```
52    fn advance(&mut self, state: &mut TerminalState, bytes: &[u8]);
53}
54
55/// [`vte`] state-machine adapter for the supported terminal-state operations.
56///
57/// One instance represents one incremental byte stream. Reusing it for
58/// unrelated streams can carry an incomplete escape/UTF-8 sequence from the
59/// first stream into the next; create a fresh parser per terminal stream.
60///
61/// # Examples
62///
63/// ```
64/// use ailloli_ui_terminal_core::{TerminalParser, TerminalState, VteTerminalParser};
65///
66/// let mut parser = VteTerminalParser::new();
67/// let mut state = TerminalState::new();
68/// parser.advance(&mut state, b"A\x1b[31mB");
69/// assert_eq!(state.screen.lines[0].cells[1].text, "B");
70/// ```
71pub struct VteTerminalParser {
72    /// Incremental VT state machine, including any partial sequence.
73    parser: vte::Parser,
74}
75
76impl VteTerminalParser {
77    /// Creates a parser with no buffered partial sequence.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use ailloli_ui_terminal_core::{TerminalParser, TerminalState, VteTerminalParser};
83    ///
84    /// let mut parser = VteTerminalParser::new();
85    /// let mut state = TerminalState::new();
86    /// parser.advance(&mut state, b"ok");
87    /// assert!(state.screen.lines[0].plain_text().starts_with("ok"));
88    /// ```
89    pub fn new() -> Self {
90        Self {
91            parser: vte::Parser::new(),
92        }
93    }
94}
95
96impl Default for VteTerminalParser {
97    /// Creates the same empty parser as [`Self::new`].
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use ailloli_ui_terminal_core::VteTerminalParser;
103    /// let _parser = VteTerminalParser::default();
104    /// ```
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl TerminalParser for VteTerminalParser {
111    /// Advances the internal [`vte`] machine with a temporary state performer.
112    fn advance(&mut self, state: &mut TerminalState, bytes: &[u8]) {
113        let mut performer = TerminalPerformer { state };
114        self.parser.advance(&mut performer, bytes);
115    }
116}
117
118/// Per-call bridge from parsed VT actions to mutable terminal state.
119struct TerminalPerformer<'a> {
120    /// State receiving each parsed action.
121    state: &'a mut TerminalState,
122}
123
124impl vte::Perform for TerminalPerformer<'_> {
125    /// Writes one decoded character and refreshes the prompt heuristic.
126    fn print(&mut self, c: char) {
127        self.state.write_char(c);
128        self.state.update_prompt_heuristic();
129    }
130
131    /// Applies supported C0 controls; bell is ignored and others warn.
132    fn execute(&mut self, byte: u8) {
133        match byte {
134            b'\n' | 0x0b | 0x0c => self.state.line_feed(),
135            b'\r' => self.state.carriage_return(),
136            b'\t' => self.state.tab(),
137            0x08 => self.state.backspace(),
138            0x07 => {}
139            _ => self.unsupported(format!("C0 0x{byte:02x}")),
140        }
141    }
142
143    /// Rejects every DCS entry and records its final action character.
144    fn hook(&mut self, _params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char) {
145        self.unsupported(format!("DCS {action}"));
146    }
147
148    /// Discards DCS payload bytes after the DCS hook has warned.
149    fn put(&mut self, _byte: u8) {}
150
151    /// Completes a discarded DCS payload without further mutation.
152    fn unhook(&mut self) {}
153
154    /// Dispatches supported operating-system commands using lossy text payloads.
155    ///
156    /// Title/CWD payload components are rejoined with semicolons. OSC 52 never
157    /// writes a clipboard: when disallowed it records a blocked warning; when
158    /// allowed it still records an unsupported warning for the unimplemented I/O.
159    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
160        let Some(code) = params
161            .first()
162            .and_then(|bytes| std::str::from_utf8(bytes).ok())
163        else {
164            self.unsupported("OSC invalid");
165            return;
166        };
167        let payload = join_params(&params[1..]);
168
169        match code {
170            "0" | "1" | "2" => self.state.set_title(payload),
171            "7" => self.state.set_cwd_uri(payload),
172            "8" => {
173                let link_params = params
174                    .get(1)
175                    .and_then(|bytes| std::str::from_utf8(bytes).ok())
176                    .unwrap_or_default();
177                let uri = params
178                    .get(2)
179                    .and_then(|bytes| std::str::from_utf8(bytes).ok())
180                    .unwrap_or_default();
181                if uri.is_empty() {
182                    self.state.close_hyperlink();
183                } else {
184                    self.state.open_hyperlink(link_params, uri);
185                }
186            }
187            "52" => {
188                if self.state.security.allow_clipboard_write {
189                    self.unsupported("OSC 52 clipboard write");
190                } else {
191                    self.state.warnings.push(TerminalWarning::blocked_sequence(
192                        "OSC 52",
193                        "clipboard write disabled by terminal security policy",
194                    ));
195                }
196            }
197            "133" => self.dispatch_osc_133(&params[1..]),
198            "1337" => self.dispatch_osc_1337(&params[1..]),
199            "9001" => self.dispatch_ailloli_ui_shell_osc(&params[1..]),
200            _ => self.unsupported(format!("OSC {code}")),
201        }
202    }
203
204    /// Applies supported ANSI CSI actions or records an unsupported warning.
205    ///
206    /// Zero/missing count parameters use each action's ANSI default. Scroll
207    /// region defaulting casts the active row count to `u16`, so manually
208    /// constructed screens taller than `u16::MAX` lose high bits in that default.
209    fn csi_dispatch(
210        &mut self,
211        params: &vte::Params,
212        intermediates: &[u8],
213        ignore: bool,
214        action: char,
215    ) {
216        if ignore {
217            self.unsupported(format!("CSI ignored {action}"));
218            return;
219        }
220
221        if intermediates == b"?" {
222            self.dispatch_private_csi(params, action);
223            return;
224        }
225
226        match action {
227            'A' => self.state.move_cursor_up(param_or(params, 0, 1) as usize),
228            'B' => self.state.move_cursor_down(param_or(params, 0, 1) as usize),
229            'C' => self
230                .state
231                .move_cursor_forward(param_or(params, 0, 1) as usize),
232            'D' => self.state.move_cursor_back(param_or(params, 0, 1) as usize),
233            'E' => self
234                .state
235                .move_cursor_next_line(param_or(params, 0, 1) as usize),
236            'F' => self
237                .state
238                .move_cursor_previous_line(param_or(params, 0, 1) as usize),
239            'G' => self
240                .state
241                .set_cursor_column_ansi(param_or(params, 0, 1) as usize),
242            'H' | 'f' => {
243                let row = param_or(params, 0, 1) as usize;
244                let col = param_or(params, 1, 1) as usize;
245                self.state.set_cursor_position_ansi(row.max(1), col.max(1));
246            }
247            'd' => self
248                .state
249                .set_cursor_row_ansi(param_or(params, 0, 1) as usize),
250            'e' => self.state.move_cursor_down(param_or(params, 0, 1) as usize),
251            '@' => self
252                .state
253                .insert_blank_chars(param_or(params, 0, 1) as usize),
254            'J' => {
255                let mode = param_or(params, 0, 0);
256                if matches!(mode, 0..=2) {
257                    self.state.erase_display(mode);
258                } else {
259                    self.unsupported(format!("CSI {mode} J"));
260                }
261            }
262            'K' => {
263                let mode = param_or(params, 0, 0);
264                if matches!(mode, 0..=2) {
265                    self.state.erase_line(mode);
266                } else {
267                    self.unsupported(format!("CSI {mode} K"));
268                }
269            }
270            'L' => self
271                .state
272                .insert_lines(self.state.cursor.row, param_or(params, 0, 1) as usize),
273            'M' => self
274                .state
275                .delete_lines(self.state.cursor.row, param_or(params, 0, 1) as usize),
276            'P' => self.state.delete_chars(param_or(params, 0, 1) as usize),
277            'X' => self.state.erase_chars(param_or(params, 0, 1) as usize),
278            'r' => {
279                let rows = self.state.active_screen().rows;
280                let top = param_or(params, 0, 1).max(1) as usize;
281                let bottom = param_or(params, 1, rows as u16).max(1) as usize;
282                self.state
283                    .set_scroll_region(top.saturating_sub(1), bottom.saturating_sub(1));
284                self.state.set_cursor_position(0, 0);
285            }
286            'm' => self.dispatch_sgr(params),
287            's' => self.state.save_cursor(),
288            'u' => self.state.restore_cursor(),
289            _ => self.unsupported(format!("CSI {:?} {action}", params_to_debug(params))),
290        }
291    }
292
293    /// Applies save/restore and application-keypad ESC sequences.
294    fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) {
295        if ignore {
296            self.unsupported(format!("ESC ignored 0x{byte:02x}"));
297            return;
298        }
299        match (intermediates, byte) {
300            (b"", b'7') => self.state.save_cursor(),
301            (b"", b'8') => self.state.restore_cursor(),
302            (b"", b'=') => self.state.set_application_keypad_mode(true),
303            (b"", b'>') => self.state.set_application_keypad_mode(false),
304            _ => self.unsupported(format!("ESC {intermediates:?} 0x{byte:02x}")),
305        }
306    }
307}
308
309impl TerminalPerformer<'_> {
310    /// Parses the `ailloli_ui:` protocol and accepted legacy `octavui:` prefix.
311    ///
312    /// Fields use the first matching named alias, then an action-specific
313    /// positional fallback. Missing required fields, invalid payload encoding,
314    /// invalid process status, and unknown actions append unsupported warnings.
315    fn dispatch_ailloli_ui_shell_osc(&mut self, params: &[&[u8]]) {
316        let Some(parts) = shell_parts(params) else {
317            self.unsupported("OSC 9001 invalid shell payload");
318            return;
319        };
320        let Some(action) = parts.first().and_then(|part| {
321            part.strip_prefix("ailloli_ui:")
322                .or_else(|| part.strip_prefix("octavui:"))
323        }) else {
324            self.unsupported("OSC 9001 missing ailloli_ui action");
325            return;
326        };
327        let fields = shell_fields(&parts[1..]);
328
329        match action {
330            "prompt_start" => self.state.mark_shell_prompt_start(),
331            "command_start" => {
332                let command_line = shell_field_value(&fields, &["cmd", "command"])
333                    .or_else(|| shell_positional_value(&fields, 0))
334                    .unwrap_or_default();
335                let cwd_uri = shell_field_value(&fields, &["cwd", "cwd_uri"]);
336                let started_at_ms = shell_u64_value(&fields, &["started_at_ms", "time_ms"]);
337                self.state
338                    .start_shell_command(command_line, cwd_uri, started_at_ms);
339            }
340            "command_end" => {
341                let exit_code = shell_i32_value(&fields, &["exit", "exit_code", "code"]);
342                let signal = shell_i32_value(&fields, &["signal"]);
343                let ended_at_ms = shell_u64_value(&fields, &["ended_at_ms", "time_ms"]);
344                let duration_ms = shell_u64_value(&fields, &["duration_ms"]);
345                self.state
346                    .finish_shell_command(exit_code, signal, ended_at_ms, duration_ms);
347            }
348            "cwd" => {
349                let Some(cwd_uri) = shell_field_value(&fields, &["uri", "cwd", "cwd_uri"])
350                    .or_else(|| shell_positional_value(&fields, 0))
351                else {
352                    self.unsupported("OSC 9001 cwd missing uri");
353                    return;
354                };
355                self.state.set_cwd_uri(cwd_uri);
356            }
357            "shell" => {
358                let Some(shell) = shell_field_value(&fields, &["kind", "shell"])
359                    .or_else(|| shell_positional_value(&fields, 0))
360                else {
361                    self.unsupported("OSC 9001 shell missing kind");
362                    return;
363                };
364                self.state.set_shell_kind(ShellKind::from_name(&shell));
365            }
366            "process_status" => {
367                let Some(status) = shell_process_status(&fields) else {
368                    self.unsupported("OSC 9001 process_status invalid");
369                    return;
370                };
371                self.state.set_shell_process_status(status);
372            }
373            _ => self.unsupported(format!("OSC 9001 ailloli_ui:{action}")),
374        }
375    }
376
377    /// Parses the supported OSC 133 prompt/start/end aliases.
378    ///
379    /// Action `A` marks a prompt, `B` starts a command, and `C` finishes it.
380    /// Unknown/missing actions warn. An absent command for `B` becomes empty;
381    /// invalid numeric values are treated as absent.
382    fn dispatch_osc_133(&mut self, params: &[&[u8]]) {
383        let Some(parts) = shell_parts(params) else {
384            self.unsupported("OSC 133 invalid shell payload");
385            return;
386        };
387        let Some(action) = parts.first().map(String::as_str) else {
388            self.unsupported("OSC 133 missing action");
389            return;
390        };
391        let fields = shell_fields(&parts[1..]);
392
393        match action {
394            "A" => self.state.mark_shell_prompt_start(),
395            "B" => {
396                let command_line = shell_field_value(&fields, &["cmd", "command"])
397                    .or_else(|| shell_positional_value(&fields, 0))
398                    .unwrap_or_default();
399                let cwd_uri = shell_field_value(&fields, &["cwd", "cwd_uri"]);
400                self.state.start_shell_command(command_line, cwd_uri, None);
401            }
402            "C" => {
403                let exit_code =
404                    shell_i32_value(&fields, &["exit", "exit_code", "code"]).or_else(|| {
405                        shell_positional_value(&fields, 0).and_then(|value| value.parse().ok())
406                    });
407                let signal = shell_i32_value(&fields, &["signal"]);
408                self.state
409                    .finish_shell_command(exit_code, signal, None, None);
410            }
411            _ => self.unsupported(format!("OSC 133 {action}")),
412        }
413    }
414
415    /// Parses only OSC 1337 `CurrentDir=...`; all other payloads warn.
416    fn dispatch_osc_1337(&mut self, params: &[&[u8]]) {
417        let Some(parts) = shell_parts(params) else {
418            self.unsupported("OSC 1337 invalid payload");
419            return;
420        };
421        let Some(first) = parts.first() else {
422            self.unsupported("OSC 1337 missing payload");
423            return;
424        };
425        if let Some(cwd_uri) = first.strip_prefix("CurrentDir=") {
426            self.state.set_cwd_uri(cwd_uri.to_string());
427        } else {
428            self.unsupported(format!("OSC 1337 {first}"));
429        }
430    }
431
432    /// Applies supported DEC private mode set/reset parameters.
433    ///
434    /// Multiple flattened parameters are applied in order. Unknown parameters
435    /// append warnings without rolling back modes already changed.
436    fn dispatch_private_csi(&mut self, params: &vte::Params, action: char) {
437        match action {
438            'h' | 'l' => {
439                let enabled = action == 'h';
440                for param in flat_params(params) {
441                    match param {
442                        25 => self.state.set_cursor_visible(enabled),
443                        1 => self.state.set_application_cursor_mode(enabled),
444                        7 => self.state.set_wraparound_mode(enabled),
445                        9 => self.state.set_mouse_tracking_mode(if enabled {
446                            TerminalMouseTrackingMode::X10
447                        } else {
448                            TerminalMouseTrackingMode::Off
449                        }),
450                        1000 => self.state.set_mouse_tracking_mode(if enabled {
451                            TerminalMouseTrackingMode::Normal
452                        } else {
453                            TerminalMouseTrackingMode::Off
454                        }),
455                        1002 => self.state.set_mouse_tracking_mode(if enabled {
456                            TerminalMouseTrackingMode::ButtonMotion
457                        } else {
458                            TerminalMouseTrackingMode::Off
459                        }),
460                        1003 => self.state.set_mouse_tracking_mode(if enabled {
461                            TerminalMouseTrackingMode::AnyMotion
462                        } else {
463                            TerminalMouseTrackingMode::Off
464                        }),
465                        1006 => self.state.set_sgr_mouse_mode(enabled),
466                        1049 => {
467                            if enabled {
468                                self.state.switch_to_alternate_screen();
469                            } else {
470                                self.state.switch_to_normal_screen();
471                            }
472                        }
473                        2004 => self.state.set_bracketed_paste_mode(enabled),
474                        _ => self.unsupported(format!(
475                            "CSI ? {param} {}",
476                            if enabled { 'h' } else { 'l' }
477                        )),
478                    }
479                }
480            }
481            _ => self.unsupported(format!("CSI ? {:?} {action}", params_to_debug(params))),
482        }
483    }
484
485    /// Applies supported SGR style codes to the state's current style.
486    ///
487    /// Colon-grouped parameters and malformed extended colors warn and discard
488    /// all style changes from that CSI. Other unsupported scalar codes warn but
489    /// allow supported codes in the same CSI to commit. Extended color supports
490    /// `38/48;5;n` and `38/48;2;r;g;b` with components in `0..=255`.
491    fn dispatch_sgr(&mut self, params: &vte::Params) {
492        let grouped = grouped_params(params);
493        if grouped.iter().any(|param| param.len() > 1) {
494            self.unsupported(format!("CSI {grouped:?} m"));
495            return;
496        }
497
498        let codes = if grouped.is_empty() {
499            vec![0]
500        } else {
501            grouped.iter().map(|param| param[0]).collect::<Vec<_>>()
502        };
503
504        let mut style = self.state.current_style;
505        let mut idx = 0;
506        while idx < codes.len() {
507            match codes[idx] {
508                0 => style.reset_sgr(),
509                1 => style.bold = true,
510                2 => style.dim = true,
511                3 => style.italic = true,
512                4 => style.underline = true,
513                7 => style.inverse = true,
514                9 => style.strike = true,
515                22 => {
516                    style.bold = false;
517                    style.dim = false;
518                }
519                23 => style.italic = false,
520                24 => style.underline = false,
521                27 => style.inverse = false,
522                29 => style.strike = false,
523                30..=37 => style.fg = TerminalColor::Ansi((codes[idx] - 30) as u8),
524                40..=47 => style.bg = TerminalColor::Ansi((codes[idx] - 40) as u8),
525                90..=97 => style.fg = TerminalColor::Ansi((codes[idx] - 90 + 8) as u8),
526                100..=107 => style.bg = TerminalColor::Ansi((codes[idx] - 100 + 8) as u8),
527                39 => style.fg = TerminalColor::DefaultFg,
528                49 => style.bg = TerminalColor::DefaultBg,
529                38 | 48 => {
530                    let Some((color, consumed)) = parse_extended_color(&codes[idx + 1..]) else {
531                        self.unsupported(format!("CSI {codes:?} m"));
532                        return;
533                    };
534                    if codes[idx] == 38 {
535                        style.fg = color;
536                    } else {
537                        style.bg = color;
538                    }
539                    idx += consumed;
540                }
541                code => self.unsupported(format!("SGR {code}")),
542            }
543            idx += 1;
544        }
545        self.state.current_style = style;
546    }
547
548    /// Appends one unsupported-sequence warning without deduplication or limits.
549    fn unsupported(&mut self, sequence: impl Into<String>) {
550        self.state
551            .warnings
552            .push(TerminalWarning::unsupported_sequence(sequence));
553    }
554}
555
556/// Parses an SGR extended-color suffix and its consumed suffix-code count.
557///
558/// Extra suffix codes are left for the caller. Missing components or values
559/// above 255 return `None`.
560fn parse_extended_color(codes: &[u16]) -> Option<(TerminalColor, usize)> {
561    match codes {
562        [5, index, ..] if *index <= 255 => Some((TerminalColor::Indexed(*index as u8), 2)),
563        [2, r, g, b, ..] if *r <= 255 && *g <= 255 && *b <= 255 => {
564            Some((TerminalColor::Rgb(*r as u8, *g as u8, *b as u8), 4))
565        }
566        _ => None,
567    }
568}
569
570/// Copies VTE parameters while retaining colon-grouped subparameters.
571fn grouped_params(params: &vte::Params) -> Vec<Vec<u16>> {
572    params.iter().map(|param| param.to_vec()).collect()
573}
574
575/// Flattens all VTE parameter groups, returning ANSI default zero when empty.
576fn flat_params(params: &vte::Params) -> Vec<u16> {
577    let values = params
578        .iter()
579        .flat_map(|param| param.iter().copied())
580        .collect::<Vec<_>>();
581    if values.is_empty() {
582        vec![0]
583    } else {
584        values
585    }
586}
587
588/// Returns a nonzero first subparameter at `index`, otherwise `default`.
589fn param_or(params: &vte::Params, index: usize, default: u16) -> u16 {
590    params
591        .iter()
592        .nth(index)
593        .and_then(|param| param.first().copied())
594        .filter(|value| *value != 0)
595        .unwrap_or(default)
596}
597
598/// Copies grouped parameters for stable warning formatting.
599fn params_to_debug(params: &vte::Params) -> Vec<Vec<u16>> {
600    grouped_params(params)
601}
602
603/// Joins OSC byte components with semicolons and decodes invalid UTF-8 lossily.
604fn join_params(params: &[&[u8]]) -> String {
605    let mut joined = Vec::new();
606    for (index, param) in params.iter().enumerate() {
607        if index > 0 {
608            joined.push(b';');
609        }
610        joined.extend_from_slice(param);
611    }
612    String::from_utf8_lossy(&joined).into_owned()
613}
614
615/// Parsed shell-protocol field, either named or positional.
616#[derive(Debug, Clone, PartialEq, Eq)]
617struct ShellField {
618    /// ASCII-lowercase name, or `None` for a positional value.
619    key: Option<String>,
620    /// Decoded field value, possibly empty.
621    value: String,
622}
623
624/// Strictly UTF-8 and percent-decodes every shell-protocol component.
625///
626/// Any invalid component rejects the entire payload. A literal plus sign stays
627/// `+`; only `%HH` escapes are decoded.
628fn shell_parts(params: &[&[u8]]) -> Option<Vec<String>> {
629    params
630        .iter()
631        .map(|param| {
632            let value = std::str::from_utf8(param).ok()?;
633            percent_decode(value)
634        })
635        .collect()
636}
637
638/// Splits each component at its first `=` and ASCII-lowercases named keys.
639fn shell_fields(parts: &[String]) -> Vec<ShellField> {
640    parts
641        .iter()
642        .map(|part| {
643            if let Some((key, value)) = part.split_once('=') {
644                ShellField {
645                    key: Some(key.to_ascii_lowercase()),
646                    value: value.to_string(),
647                }
648            } else {
649                ShellField {
650                    key: None,
651                    value: part.to_string(),
652                }
653            }
654        })
655        .collect()
656}
657
658/// Clones the first named field whose normalized key appears in `keys`.
659fn shell_field_value(fields: &[ShellField], keys: &[&str]) -> Option<String> {
660    fields.iter().find_map(|field| {
661        let key = field.key.as_deref()?;
662        keys.contains(&key).then(|| field.value.clone())
663    })
664}
665
666/// Clones the zero-based positional field, ignoring named fields.
667fn shell_positional_value(fields: &[ShellField], index: usize) -> Option<String> {
668    fields
669        .iter()
670        .filter(|field| field.key.is_none())
671        .nth(index)
672        .map(|field| field.value.clone())
673}
674
675/// Parses the first matching named field as a signed 32-bit decimal integer.
676fn shell_i32_value(fields: &[ShellField], keys: &[&str]) -> Option<i32> {
677    shell_field_value(fields, keys).and_then(|value| value.parse().ok())
678}
679
680/// Parses the first matching named field as an unsigned 64-bit decimal integer.
681fn shell_u64_value(fields: &[ShellField], keys: &[&str]) -> Option<u64> {
682    shell_field_value(fields, keys).and_then(|value| value.parse().ok())
683}
684
685/// Parses a case-insensitive supported process status and its optional number.
686///
687/// `exited`/`exit` and `signaled`/`signal` aliases are accepted. A malformed or
688/// missing code/signal does not reject a recognized status; that number is `None`.
689fn shell_process_status(fields: &[ShellField]) -> Option<TerminalProcessStatus> {
690    let status = shell_field_value(fields, &["status"])
691        .or_else(|| shell_positional_value(fields, 0))?
692        .to_ascii_lowercase();
693    match status.as_str() {
694        "starting" => Some(TerminalProcessStatus::Starting),
695        "running" => Some(TerminalProcessStatus::Running),
696        "exited" | "exit" => Some(TerminalProcessStatus::Exited {
697            code: shell_i32_value(fields, &["code", "exit", "exit_code"]),
698        }),
699        "signaled" | "signal" => Some(TerminalProcessStatus::Signaled {
700            signal: shell_i32_value(fields, &["signal"]),
701        }),
702        "unknown" => Some(TerminalProcessStatus::Unknown),
703        _ => None,
704    }
705}
706
707/// Decodes `%HH` byte escapes and requires the resulting bytes to be UTF-8.
708///
709/// Incomplete escapes, non-hex digits, and decoded invalid UTF-8 return `None`.
710/// Unescaped non-ASCII input is copied byte-for-byte.
711fn percent_decode(input: &str) -> Option<String> {
712    let bytes = input.as_bytes();
713    let mut decoded = Vec::with_capacity(bytes.len());
714    let mut index = 0;
715    while index < bytes.len() {
716        if bytes[index] == b'%' {
717            let hi = *bytes.get(index + 1)?;
718            let lo = *bytes.get(index + 2)?;
719            decoded.push((hex_value(hi)? << 4) | hex_value(lo)?);
720            index += 3;
721        } else {
722            decoded.push(bytes[index]);
723            index += 1;
724        }
725    }
726    String::from_utf8(decoded).ok()
727}
728
729/// Maps one ASCII hexadecimal digit to `0..=15`.
730fn hex_value(byte: u8) -> Option<u8> {
731    match byte {
732        b'0'..=b'9' => Some(byte - b'0'),
733        b'a'..=b'f' => Some(byte - b'a' + 10),
734        b'A'..=b'F' => Some(byte - b'A' + 10),
735        _ => None,
736    }
737}
738
739#[cfg(test)]
740/// Parser tests for chunking, terminal controls, security, and shell protocols.
741mod tests {
742    use super::*;
743    use crate::{
744        ActiveScreen, CommandStatus, ShellEvent, ShellKind, TerminalConfig,
745        TerminalMouseTrackingMode, TerminalProcessStatus, TerminalSecurityPolicy, TerminalSize,
746        TerminalStyle, TerminalWarningKind,
747    };
748
749    /// Creates a test terminal with ten retained scrollback lines.
750    fn state(rows: usize, cols: usize) -> TerminalState {
751        TerminalState::with_config(TerminalConfig {
752            size: TerminalSize::new(rows, cols),
753            scrollback_limit: 10,
754            security: TerminalSecurityPolicy::default(),
755        })
756    }
757
758    #[test]
759    fn parser_handles_incremental_chunks_and_partial_utf8() {
760        let mut parser = VteTerminalParser::new();
761        let mut state = state(2, 8);
762        let wide = "界".as_bytes();
763
764        parser.advance(&mut state, b"he");
765        parser.advance(&mut state, &wide[..1]);
766        parser.advance(&mut state, &wide[1..]);
767
768        assert_eq!(state.screen.cell(0, 0).expect("cell").text, "h");
769        assert_eq!(state.screen.cell(0, 1).expect("cell").text, "e");
770        assert_eq!(state.screen.cell(0, 2).expect("cell").text, "界");
771    }
772
773    #[test]
774    fn parser_handles_c0_controls() {
775        let mut parser = VteTerminalParser::new();
776        let mut state = state(3, 8);
777
778        parser.advance(&mut state, b"ab\rZ\nY\x08X");
779
780        assert_eq!(state.screen.line(0).expect("line").plain_text(), "Zb      ");
781        assert_eq!(state.screen.line(1).expect("line").plain_text(), " X      ");
782        assert_eq!(state.cursor.row, 1);
783        assert_eq!(state.cursor.col, 2);
784    }
785
786    #[test]
787    fn parser_handles_cursor_position_and_movement() {
788        let mut parser = VteTerminalParser::new();
789        let mut state = state(4, 5);
790
791        parser.advance(&mut state, b"\x1b[2;3HX\x1b[AY\x1b[2DZ");
792
793        assert_eq!(state.screen.cell(1, 2).expect("cell").text, "X");
794        assert_eq!(state.screen.cell(0, 3).expect("cell").text, "Y");
795        assert_eq!(state.screen.cell(0, 2).expect("cell").text, "Z");
796    }
797
798    #[test]
799    fn terminal_parser_supports_shell_redraw_sequences() {
800        let mut parser = VteTerminalParser::new();
801        let mut state = state(3, 12);
802
803        parser.advance(&mut state, b"abcdef\x1b[3GZ");
804        assert_eq!(
805            state.screen.line(0).expect("line").plain_text(),
806            "abZdef      "
807        );
808
809        parser.advance(&mut state, b"\x1b[s\x1b[1;8HX\x1b[uY");
810        assert_eq!(state.screen.cell(0, 3).expect("restored").text, "Y");
811        assert_eq!(state.screen.cell(0, 7).expect("absolute").text, "X");
812
813        parser.advance(&mut state, b"\x1b7\x1b[2;2HE\x1b8R");
814        assert_eq!(state.screen.cell(1, 1).expect("esc save target").text, "E");
815        assert_eq!(state.screen.cell(0, 4).expect("esc restored").text, "R");
816
817        parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2X");
818        assert_eq!(
819            state.screen.line(0).expect("erase").plain_text(),
820            "ab  ef      "
821        );
822
823        parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2P");
824        assert_eq!(
825            state.screen.line(0).expect("delete").plain_text(),
826            "abef        "
827        );
828
829        parser.advance(&mut state, b"\x1b[1;1H\x1b[2Kabcdef\x1b[3G\x1b[2@");
830        assert_eq!(
831            state.screen.line(0).expect("insert").plain_text(),
832            "ab  cdef    "
833        );
834    }
835
836    #[test]
837    fn terminal_parser_supports_multiline_redraw_cursor_sequences() {
838        let mut parser = VteTerminalParser::new();
839        let mut state = state(5, 8);
840
841        parser.advance(&mut state, b"\x1b[2;4HX\x1b[2FY");
842        assert_eq!(state.cursor.row, 0);
843        assert_eq!(state.cursor.col, 1);
844        assert_eq!(state.screen.cell(1, 3).expect("cell").text, "X");
845        assert_eq!(state.screen.cell(0, 0).expect("cell").text, "Y");
846
847        parser.advance(&mut state, b"\x1b[3EZ");
848        assert_eq!(state.screen.cell(3, 0).expect("cell").text, "Z");
849
850        parser.advance(&mut state, b"\x1b[5dV\x1b[1eW");
851        assert_eq!(state.screen.cell(4, 1).expect("cell").text, "V");
852        assert_eq!(state.screen.cell(4, 2).expect("cell").text, "W");
853    }
854
855    #[test]
856    fn parser_handles_clear_screen_and_line() {
857        let mut parser = VteTerminalParser::new();
858        let mut state = state(3, 5);
859
860        parser.advance(&mut state, b"abcde\x1b[1;3H\x1b[K");
861        assert_eq!(state.screen.line(0).expect("line").plain_text(), "ab   ");
862        assert_eq!(state.screen.line(1).expect("line").plain_text(), "     ");
863
864        parser.advance(&mut state, b"\x1b[2J");
865        assert_eq!(state.screen.line(0).expect("line").plain_text(), "     ");
866        assert_eq!(state.screen.line(1).expect("line").plain_text(), "     ");
867    }
868
869    #[test]
870    fn parser_handles_insert_and_delete_lines() {
871        let mut parser = VteTerminalParser::new();
872        let mut state = state(4, 3);
873
874        parser.advance(
875            &mut state,
876            b"\x1b[1;1H0\x1b[2;1H1\x1b[3;1H2\x1b[4;1H3\x1b[2;1H\x1b[L",
877        );
878        assert_eq!(state.screen.line(1).expect("line").plain_text(), "   ");
879        assert_eq!(state.screen.line(2).expect("line").plain_text(), "1  ");
880
881        parser.advance(&mut state, b"\x1b[M");
882        assert_eq!(state.screen.line(1).expect("line").plain_text(), "1  ");
883    }
884
885    #[test]
886    fn parser_handles_sgr_basic_indexed_and_truecolor() {
887        let mut parser = VteTerminalParser::new();
888        let mut state = state(2, 8);
889
890        parser.advance(
891            &mut state,
892            b"\x1b[31;1mR\x1b[38;5;123mI\x1b[48;2;1;2;3mB\x1b[0mN",
893        );
894
895        let red = state.screen.cell(0, 0).expect("red");
896        assert_eq!(red.style.fg, TerminalColor::Ansi(1));
897        assert!(red.style.bold);
898        assert_eq!(
899            state.screen.cell(0, 1).expect("indexed").style.fg,
900            TerminalColor::Indexed(123)
901        );
902        assert_eq!(
903            state.screen.cell(0, 2).expect("rgb").style.bg,
904            TerminalColor::Rgb(1, 2, 3)
905        );
906        assert_eq!(
907            state.screen.cell(0, 3).expect("normal").style,
908            TerminalStyle::default()
909        );
910    }
911
912    #[test]
913    fn parser_handles_private_cursor_and_alternate_screen() {
914        let mut parser = VteTerminalParser::new();
915        let mut state = state(2, 8);
916
917        parser.advance(&mut state, b"main\x1b[?25l\x1b[?1049halt\x1b[?25h");
918
919        assert_eq!(state.active_screen, ActiveScreen::Alternate);
920        assert_eq!(
921            state.alternate_screen.line(0).expect("line").plain_text(),
922            "alt     "
923        );
924        assert!(state.cursor.visible);
925
926        parser.advance(&mut state, b"\x1b[?1049l");
927        assert_eq!(state.active_screen, ActiveScreen::Normal);
928        assert_eq!(state.screen.line(0).expect("line").plain_text(), "main    ");
929    }
930
931    #[test]
932    fn terminal_parser_handles_tui_modes_scroll_region_and_keypad() {
933        let mut parser = VteTerminalParser::new();
934        let mut state = state(5, 6);
935
936        parser.advance(
937            &mut state,
938            b"\x1b[?1h\x1b[?7l\x1b[?1002h\x1b[?1006h\x1b[?2004h\x1b=\x1b[2;4r",
939        );
940
941        assert!(state.modes.application_cursor);
942        assert!(!state.modes.wraparound);
943        assert_eq!(
944            state.modes.mouse_tracking,
945            TerminalMouseTrackingMode::ButtonMotion
946        );
947        assert!(state.modes.sgr_mouse);
948        assert!(state.modes.bracketed_paste);
949        assert!(state.modes.application_keypad);
950        assert_eq!(state.screen.scroll_top, 1);
951        assert_eq!(state.screen.scroll_bottom, 3);
952        assert_eq!(state.cursor.row, 0);
953        assert_eq!(state.cursor.col, 0);
954
955        parser.advance(
956            &mut state,
957            b"\x1b[?1l\x1b[?7h\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b>",
958        );
959
960        assert!(!state.modes.application_cursor);
961        assert!(state.modes.wraparound);
962        assert_eq!(state.modes.mouse_tracking, TerminalMouseTrackingMode::Off);
963        assert!(!state.modes.sgr_mouse);
964        assert!(!state.modes.bracketed_paste);
965        assert!(!state.modes.application_keypad);
966    }
967
968    #[test]
969    fn parser_handles_osc_title_cwd_hyperlink_and_blocked_clipboard() {
970        let mut parser = VteTerminalParser::new();
971        let mut state = state(2, 16);
972
973        parser.advance(
974            &mut state,
975            b"\x1b]0;Ailloli UI\x07\x1b]7;file:///tmp/ailloli_ui\x07\x1b]8;id=one;https://example.test\x07x\x1b]8;;\x07y\x1b]52;c;abcd\x07",
976        );
977
978        assert_eq!(state.title.as_deref(), Some("Ailloli UI"));
979        assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/ailloli_ui"));
980        assert_eq!(state.hyperlinks.len(), 1);
981        assert_eq!(state.hyperlinks[0].uri, "https://example.test");
982        assert_eq!(
983            state.screen.cell(0, 0).expect("linked").hyperlink,
984            Some(state.hyperlinks[0].id)
985        );
986        assert_eq!(state.screen.cell(0, 1).expect("plain").hyperlink, None);
987        assert!(state
988            .warnings
989            .iter()
990            .any(|warning| warning.kind == TerminalWarningKind::BlockedSequence));
991    }
992
993    #[test]
994    fn parser_shell_osc_9001_tracks_command_state() {
995        let mut parser = VteTerminalParser::new();
996        let mut state = state(4, 16);
997
998        parser.advance(
999            &mut state,
1000            b"\x1b]9001;ailloli_ui:shell;kind=zsh\x07\
1001              \x1b]9001;ailloli_ui:cwd;uri=file:///tmp/ailloli_ui\x07\
1002              \x1b]9001;ailloli_ui:prompt_start\x07\
1003              \x1b]9001;ailloli_ui:command_start;cmd=cargo%20test;started_at_ms=10\x07ok\n\
1004              \x1b]9001;ailloli_ui:command_end;exit=0;ended_at_ms=25\x07",
1005        );
1006
1007        assert_eq!(state.shell.shell_kind, ShellKind::Zsh);
1008        assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/ailloli_ui"));
1009        assert_eq!(
1010            state.shell.cwd_uri.as_deref(),
1011            Some("file:///tmp/ailloli_ui")
1012        );
1013        let command = state.shell.last_command.as_ref().expect("last command");
1014        assert_eq!(command.command_line, "cargo test");
1015        assert_eq!(command.status, CommandStatus::Succeeded);
1016        assert_eq!(command.duration_ms, Some(15));
1017        assert!(command.output_range.end_line.is_some());
1018        assert_eq!(
1019            state.shell.process_status,
1020            TerminalProcessStatus::Exited { code: Some(0) }
1021        );
1022    }
1023
1024    #[test]
1025    fn parser_shell_osc_accepts_legacy_namespace() {
1026        let mut parser = VteTerminalParser::new();
1027        let mut state = state(2, 16);
1028
1029        parser.advance(
1030            &mut state,
1031            b"\x1b]9001;octavui:cwd;uri=file:///tmp/legacy-project\x07",
1032        );
1033
1034        assert_eq!(state.cwd_uri.as_deref(), Some("file:///tmp/legacy-project"));
1035    }
1036
1037    #[test]
1038    fn parser_shell_osc_aliases_track_prompt_command_and_cwd() {
1039        let mut parser = VteTerminalParser::new();
1040        let mut state = state(4, 16);
1041
1042        parser.advance(
1043            &mut state,
1044            b"\x1b]1337;CurrentDir=file:///workspace/user\x07\
1045              \x1b]133;A\x07\
1046              \x1b]133;B;cmd=ls%20-la\x07output\n\
1047              \x1b]133;C;exit=2\x07",
1048        );
1049
1050        assert_eq!(state.cwd_uri.as_deref(), Some("file:///workspace/user"));
1051        assert!(state.shell.last_prompt_line.is_some());
1052        let command = state.shell.last_command.as_ref().expect("last command");
1053        assert_eq!(command.command_line, "ls -la");
1054        assert_eq!(command.status, CommandStatus::Failed);
1055        assert_eq!(command.exit_code, Some(2));
1056    }
1057
1058    #[test]
1059    fn parser_shell_prompt_heuristic_marks_prompt_without_command_exit() {
1060        let mut parser = VteTerminalParser::new();
1061        let mut state = state(2, 16);
1062
1063        parser.advance(&mut state, b"c@h:~$ ");
1064
1065        assert!(state.shell.prompt_visible);
1066        assert!(state.shell.current_command.is_none());
1067        assert!(state.shell.last_command.is_none());
1068    }
1069
1070    #[test]
1071    fn parser_shell_malformed_osc_warns_without_mutating_command_state() {
1072        let mut parser = VteTerminalParser::new();
1073        let mut state = state(2, 16);
1074
1075        parser.advance(
1076            &mut state,
1077            b"\x1b]9001;bad-action\x07\x1b]9001;ailloli_ui:cwd\x07",
1078        );
1079
1080        assert!(state.shell.current_command.is_none());
1081        assert!(state.shell.history.is_empty());
1082        assert!(state
1083            .warnings
1084            .iter()
1085            .any(|warning| warning.kind == TerminalWarningKind::UnsupportedSequence));
1086    }
1087
1088    #[test]
1089    fn parser_shell_events_are_ordered_and_drainable() {
1090        let mut parser = VteTerminalParser::new();
1091        let mut state = state(2, 16);
1092
1093        parser.advance(
1094            &mut state,
1095            b"\x1b]9001;ailloli_ui:process_status;status=starting\x07\
1096              \x1b]9001;ailloli_ui:process_status;status=running\x07",
1097        );
1098
1099        let events = state.shell.drain_events();
1100        assert!(matches!(
1101            events.as_slice(),
1102            [
1103                ShellEvent::ProcessStatusChanged {
1104                    status: TerminalProcessStatus::Starting
1105                },
1106                ShellEvent::ProcessStatusChanged {
1107                    status: TerminalProcessStatus::Running
1108                }
1109            ]
1110        ));
1111    }
1112
1113    #[test]
1114    fn parser_records_unsupported_warnings() {
1115        let mut parser = VteTerminalParser::new();
1116        let mut state = state(2, 8);
1117
1118        parser.advance(&mut state, b"\x1b[?999h\x1bP1;2zignored\x1b\\");
1119
1120        assert!(state
1121            .warnings
1122            .iter()
1123            .any(|warning| warning.kind == TerminalWarningKind::UnsupportedSequence));
1124    }
1125}