feat(vt): port Ghostty's VT parsing layer

Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate:
- Parser state machine with a compile-time generated parse table and
  the UTF-8 decoder
- OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks,
  semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard,
  color and text-sizing protocols, context signals)
- CSI, SGR, modes table with save/restore, charsets, ansi, device status,
  mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser
- color palette / X11 color names, kitty color and graphics command parser

Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md):
parser actions own their payloads instead of borrowing parser buffers;
OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw)
variant; tmux regexes replaced by equivalent hand-written matchers.

Known follow-up: semantic_prompt.rs carries local copies of the printf-q
and percent decoders that belong in ghostty-os.

Test Plan:
- cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too)
- cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
This commit is contained in:
ddidderr committed 2026-10-11 21:52:41 +02:00
1 parent 987f2957ad
commit bf84326c2f
44 files changed
+15040 -6

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
//! Specialized encodings used in some OSC protocols
//! (ZIG/src/terminal/osc/encoding.zig).
/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the
/// additional requirement of not containing any C0 escape codes
/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f).
///
/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications).
///
/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8
pub fn is_safe_utf8(s: &[u8]) -> bool {
let Ok(s) = std::str::from_utf8(s) else {
return false;
};
!s.chars()
.any(|c| matches!(c as u32, 0x00..=0x1f | 0x7f | 0x80..=0x9f))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn is_safe_utf8_cases() {
assert!(is_safe_utf8(b"Hello world!"));
assert!(is_safe_utf8("安全的ユニコード☀️".as_bytes()));
assert!(!is_safe_utf8(b"No linebreaks\nallowed"));
assert!(!is_safe_utf8(b"\x07no bells"));
assert!(!is_safe_utf8(b"\x1b]9;no OSCs\x1b\\\x1b[m"));
assert!(!is_safe_utf8(b"\x9f8-bit escapes are clever, but no"));
}
}
+741
View File
@@ -0,0 +1,741 @@
//! OSC (Operating System Command) related functions and types
//! (ZIG/src/terminal/osc.zig).
//!
//! OSC is another set of control sequences for terminal programs that start with
//! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings
//! and other irregular formatting so a dedicated parser is created to handle it.
//!
//! Rust port notes (W1):
//! - Zig `[:0]const u8` fields become owned `Vec<u8>` (no NUL terminator, not
//! necessarily valid UTF-8).
//! - Zig's "fixed buffer vs. allocating writer" is a single `Vec<u8>` with an
//! optional maximum length (`MAX_BUF`, which always reserves one byte for
//! the NUL terminator the Zig code writes). The Zig `alloc: ?Allocator` is
//! `alloc: bool` ("an allocator is available").
//! - `Parser::end` returns an owned `Command` instead of a pointer into the
//! parser.
pub mod encoding;
pub mod parsers;
pub use parsers::color;
pub use parsers::semantic_prompt;
use crate::kitty::color as kitty_color;
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
/// This generally shouldn't ever be set except as an initial zero value.
/// Ignore it.
Invalid,
/// Set the window title of the terminal
///
/// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8
/// with each code unit further encoded with two hex digits).
///
/// If title mode 2 is set or the terminal is setup for unconditional
/// utf-8 titles text is interpreted as utf-8. Else text is interpreted
/// as latin1.
ChangeWindowTitle(Vec<u8>),
/// Set the icon of the terminal window. The name of the icon is not
/// well defined, so this is currently ignored by Ghostty at the time
/// of writing this. We just parse it so that we don't get parse errors
/// in the log.
ChangeWindowIcon(Vec<u8>),
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
SemanticPrompt(SemanticPrompt),
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
/// contents is set on the clipboard.
ClipboardContents {
kind: u8,
data: Vec<u8>,
},
/// OSC 7. Reports the current working directory of the shell. This is
/// a moderately flawed escape sequence but one that many major terminals
/// support so we also support it. To understand the flaws, read through
/// this terminal-wg issue: https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/20
ReportPwd {
/// The reported pwd value. This is not checked for validity. It should
/// be a file URL but it is up to the caller to utilize this value.
value: Vec<u8>,
},
/// OSC 22. Set the mouse shape. There doesn't seem to be a standard
/// naming scheme for cursors but it looks like terminals such as Foot
/// are moving towards using the W3C CSS cursor names. For OSC parsing,
/// we just parse whatever string is given.
MouseShape {
value: Vec<u8>,
},
/// OSC color operations to set, reset, or report color settings. Some OSCs
/// allow multiple operations to be specified in a single OSC so we need a
/// list-like datastructure to manage them.
///
/// Currently, these OSCs are handled by `color_operation`:
///
/// 4, 5, 10-19, 104, 105, 110-119
ColorOperation {
op: color::Operation,
requests: color::List,
terminator: Terminator,
},
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
KittyColorProtocol(kitty_color::OSC),
/// Show a desktop notification (OSC 9 or OSC 777)
ShowDesktopNotification {
title: Vec<u8>,
body: Vec<u8>,
},
/// Start a hyperlink (OSC 8)
HyperlinkStart {
id: Option<Vec<u8>>,
uri: Vec<u8>,
},
/// End a hyperlink (OSC 8)
HyperlinkEnd,
/// ConEmu sleep (OSC 9;1)
ConemuSleep {
duration_ms: u16,
},
/// ConEmu show GUI message box (OSC 9;2)
ConemuShowMessageBox(Vec<u8>),
/// ConEmu change tab title (OSC 9;3)
ConemuChangeTabTitle(ConemuChangeTabTitle),
/// ConEmu progress report (OSC 9;4)
ConemuProgressReport(ProgressReport),
/// ConEmu wait input (OSC 9;5)
ConemuWaitInput,
/// ConEmu GUI macro (OSC 9;6)
ConemuGuimacro(Vec<u8>),
/// ConEmu run process (OSC 9;7)
ConemuRunProcess(Vec<u8>),
/// ConEmu output environment variable (OSC 9;8)
ConemuOutputEnvironmentVariable(Vec<u8>),
/// ConEmu XTerm keyboard and output emulation (OSC 9;10)
/// https://conemu.github.io/en/TerminalModes.html
ConemuXtermEmulation {
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
keyboard: Option<bool>,
/// None => do not change
/// Some(false) => turn off
/// Some(true) => turn on
output: Option<bool>,
},
/// ConEmu comment (OSC 9;11)
ConemuComment(Vec<u8>),
/// Kitty text sizing protocol (OSC 66)
KittyTextSizing(parsers::kitty_text_sizing::OSC),
KittyClipboardProtocol(KittyClipboardProtocol),
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
ContextSignal(parsers::context_signal::Command),
}
pub type SemanticPrompt = parsers::semantic_prompt::Command;
pub type KittyClipboardProtocol = parsers::kitty_clipboard_protocol::OSC;
/// Payload of `Command::ConemuChangeTabTitle`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConemuChangeTabTitle {
Reset,
Value(Vec<u8>),
}
/// The discriminant of a [`Command`] (Zig `Command.Key`). Order matches Zig.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Key {
Invalid,
ChangeWindowTitle,
ChangeWindowIcon,
SemanticPrompt,
ClipboardContents,
ReportPwd,
MouseShape,
ColorOperation,
KittyColorProtocol,
ShowDesktopNotification,
HyperlinkStart,
HyperlinkEnd,
ConemuSleep,
ConemuShowMessageBox,
ConemuChangeTabTitle,
ConemuProgressReport,
ConemuWaitInput,
ConemuGuimacro,
ConemuRunProcess,
ConemuOutputEnvironmentVariable,
ConemuXtermEmulation,
ConemuComment,
KittyTextSizing,
KittyClipboardProtocol,
ContextSignal,
}
impl Command {
pub fn key(&self) -> Key {
match self {
Command::Invalid => Key::Invalid,
Command::ChangeWindowTitle(_) => Key::ChangeWindowTitle,
Command::ChangeWindowIcon(_) => Key::ChangeWindowIcon,
Command::SemanticPrompt(_) => Key::SemanticPrompt,
Command::ClipboardContents { .. } => Key::ClipboardContents,
Command::ReportPwd { .. } => Key::ReportPwd,
Command::MouseShape { .. } => Key::MouseShape,
Command::ColorOperation { .. } => Key::ColorOperation,
Command::KittyColorProtocol(_) => Key::KittyColorProtocol,
Command::ShowDesktopNotification { .. } => Key::ShowDesktopNotification,
Command::HyperlinkStart { .. } => Key::HyperlinkStart,
Command::HyperlinkEnd => Key::HyperlinkEnd,
Command::ConemuSleep { .. } => Key::ConemuSleep,
Command::ConemuShowMessageBox(_) => Key::ConemuShowMessageBox,
Command::ConemuChangeTabTitle(_) => Key::ConemuChangeTabTitle,
Command::ConemuProgressReport(_) => Key::ConemuProgressReport,
Command::ConemuWaitInput => Key::ConemuWaitInput,
Command::ConemuGuimacro(_) => Key::ConemuGuimacro,
Command::ConemuRunProcess(_) => Key::ConemuRunProcess,
Command::ConemuOutputEnvironmentVariable(_) => Key::ConemuOutputEnvironmentVariable,
Command::ConemuXtermEmulation { .. } => Key::ConemuXtermEmulation,
Command::ConemuComment(_) => Key::ConemuComment,
Command::KittyTextSizing(_) => Key::KittyTextSizing,
Command::KittyClipboardProtocol(_) => Key::KittyClipboardProtocol,
Command::ContextSignal(_) => Key::ContextSignal,
}
}
}
/// ConEmu progress report (OSC 9;4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressReport {
pub state: ProgressState,
pub progress: Option<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressState {
Remove,
Set,
Error,
Indeterminate,
Pause,
}
/// The terminator used to end an OSC command. For OSC commands that demand
/// a response, we try to match the terminator used in the request since that
/// is most likely to be accepted by the calling program.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Terminator {
/// The preferred string terminator is ESC followed by \
#[default]
St,
/// Some applications and terminals use BELL (0x07) as the string terminator.
Bel,
}
impl Terminator {
/// Initialize the terminator based on the last byte seen. If the
/// last byte is a BEL then we use BEL, otherwise we just assume ST.
pub fn init(ch: Option<u8>) -> Terminator {
match ch {
Some(0x07) => Terminator::Bel,
_ => Terminator::St,
}
}
/// The terminator as a string.
pub fn string(self) -> &'static [u8] {
match self {
Terminator::St => b"\x1b\\",
Terminator::Bel => b"\x07",
}
}
}
impl std::fmt::Display for Terminator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The terminator is always ASCII.
f.write_str(std::str::from_utf8(self.string()).unwrap_or(""))
}
}
/// State of the OSC parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Start,
Invalid,
// OSC command prefixes. Not all of these are valid OSCs, but may be
// needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to
// have a state "77" even though there is no OSC 77).
S0,
S1,
S2,
S3,
S4,
S5,
S6,
S7,
S8,
S9,
S30,
S300,
S3008,
S10,
S11,
S12,
S13,
S14,
S15,
S16,
S17,
S18,
S19,
S21,
S22,
S52,
S55,
S66,
S77,
S104,
S110,
S111,
S112,
S113,
S114,
S115,
S116,
S117,
S118,
S119,
S133,
S552,
S777,
S1337,
S5522,
}
/// How OSC payload data is being accumulated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Writer {
/// Not accumulating (still parsing the numeric prefix).
None,
/// Fixed-size buffer of `MAX_BUF` bytes.
Fixed,
/// Growable buffer.
Allocating,
}
#[derive(Debug, Clone)]
pub struct Parser {
/// Whether an allocator is available, i.e. whether data longer than
/// MAX_BUF may be accepted for some commands (e.g. OSC 52), and whether
/// commands that require an allocator are processed. (Zig:
/// `alloc: ?Allocator`.)
pub alloc: bool,
/// Current state of the parser.
pub state: State,
/// Buffer for temporary storage of OSC data
buffer: Vec<u8>,
writer: Writer,
}
impl Default for Parser {
fn default() -> Self {
Self::init(false)
}
}
impl Parser {
/// Maximum size of a "normal" OSC.
pub const MAX_BUF: usize = 2048;
pub fn init(alloc: bool) -> Parser {
Parser {
alloc,
state: State::Start,
buffer: Vec::new(),
writer: Writer::None,
}
}
/// Reset the parser state.
pub fn reset(&mut self) {
self.state = State::Start;
self.buffer.clear();
self.writer = Writer::None;
}
/// Make sure that we have an allocator. If we don't, set the state to
/// invalid so that any additional OSC data is discarded.
fn ensure_allocator(&mut self) -> bool {
if self.alloc {
return true;
}
log::warn!(
"An allocator is required to process OSC {:?} but none was provided.",
self.state
);
self.state = State::Invalid;
false
}
/// Set up a fixed Writer to collect the rest of the OSC data.
fn write_to_fixed(&mut self) {
self.writer = Writer::Fixed;
}
/// Set up an allocating Writer to collect the rest of the OSC data. If we
/// don't have an allocator, fall back to writing to a fixed buffer and
/// hope that it's big enough.
fn write_to_allocating(&mut self) {
if !self.alloc {
self.write_to_fixed();
return;
}
self.writer = Writer::Allocating;
}
/// Helper for the per-command parsers. Mirrors the Zig prologue
/// `writer orelse invalid; writer.writeByte(0) catch invalid;
/// writer.buffered()`: returns a copy of the accumulated data (without
/// the NUL terminator) or `None` (and marks the parser invalid) if no
/// data was being accumulated or there was no room for the terminator.
pub fn data(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
// Reserve room for the NUL terminator.
if self.writer == Writer::Fixed && self.buffer.len() >= Self::MAX_BUF {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Like [`Parser::data`] but without reserving room for the NUL
/// terminator (for parsers that, like Zig's color parsers, read
/// `writer.buffered()` directly).
pub fn buffered(&mut self) -> Option<Vec<u8>> {
if self.writer == Writer::None {
self.state = State::Invalid;
return None;
}
Some(self.buffer.clone())
}
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
pub fn has_writer(&self) -> bool {
self.writer != Writer::None
}
fn write_byte(&mut self, c: u8) {
match self.writer {
Writer::None => unreachable!(),
Writer::Fixed => {
if self.buffer.len() >= Self::MAX_BUF {
// Overflow: discard any further input.
self.state = State::Invalid;
} else {
self.buffer.push(c);
}
}
Writer::Allocating => self.buffer.push(c),
}
}
/// Consume the next character c and advance the parser state.
pub fn next(&mut self, c: u8) {
#[allow(clippy::enum_glob_use)]
use State::*;
// If the state becomes invalid for any reason, just discard
// any further input.
if self.state == Invalid {
return;
}
// If a writer has been initialized, we just accumulate the rest of the
// OSC sequence in the writer's buffer and skip the state machine.
if self.writer != Writer::None {
self.write_byte(c);
return;
}
match self.state {
// handled above, so should never be here
Invalid => unreachable!(),
Start => {
self.state = match c {
b'0' => S0,
b'1' => S1,
b'2' => S2,
b'3' => S3,
b'4' => S4,
b'5' => S5,
b'6' => S6,
b'7' => S7,
b'8' => S8,
b'9' => S9,
_ => Invalid,
}
}
S3 => {
self.state = match c {
b'0' => S30,
_ => Invalid,
}
}
S30 => {
self.state = match c {
b'0' => S300,
_ => Invalid,
}
}
S300 => {
self.state = match c {
b'8' => S3008,
_ => Invalid,
}
}
S3008 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S1 => match c {
b';' => self.write_to_fixed(),
b'0' => self.state = S10,
b'1' => self.state = S11,
b'2' => self.state = S12,
b'3' => self.state = S13,
b'4' => self.state = S14,
b'5' => self.state = S15,
b'6' => self.state = S16,
b'7' => self.state = S17,
b'8' => self.state = S18,
b'9' => self.state = S19,
_ => self.state = Invalid,
},
S10 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'4' => self.state = S104,
_ => self.state = Invalid,
},
S104 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S11 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'0' => self.state = S110,
b'1' => self.state = S111,
b'2' => self.state = S112,
b'3' => self.state = S113,
b'4' => self.state = S114,
b'5' => self.state = S115,
b'6' => self.state = S116,
b'7' => self.state = S117,
b'8' => self.state = S118,
b'9' => self.state = S119,
_ => self.state = Invalid,
},
S4 | S12 | S14 | S15 | S16 | S17 | S18 | S19 | S21 | S110 | S111 | S112 | S113
| S114 | S115 | S116 | S117 | S118 | S119 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
_ => self.state = Invalid,
},
S13 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'3' => self.state = S133,
_ => self.state = Invalid,
},
S2 => match c {
b';' => self.write_to_fixed(),
b'1' => self.state = S21,
b'2' => self.state = S22,
_ => self.state = Invalid,
},
S5 => match c {
b';' => {
if self.ensure_allocator() {
self.write_to_fixed();
}
}
b'2' => self.state = S52,
b'5' => self.state = S55,
_ => self.state = Invalid,
},
S6 => match c {
b'6' => self.state = S66,
_ => self.state = Invalid,
},
S52 | S66 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S55 => match c {
b'2' => self.state = S552,
_ => self.state = Invalid,
},
S7 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S77,
_ => self.state = Invalid,
},
S77 => match c {
b'7' => self.state = S777,
_ => self.state = Invalid,
},
S133 => match c {
b';' => self.write_to_fixed(),
b'7' => self.state = S1337,
_ => self.state = Invalid,
},
S552 => match c {
b'2' => self.state = S5522,
_ => self.state = Invalid,
},
S1337 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
S5522 => match c {
b';' => self.write_to_allocating(),
_ => self.state = Invalid,
},
S0 | S22 | S777 | S8 | S9 => match c {
b';' => self.write_to_fixed(),
_ => self.state = Invalid,
},
}
}
/// End the sequence and return the command, if any. If the return value
/// is None, then no valid command was found. The optional terminator_ch
/// is the final character in the OSC sequence. This is used to determine
/// the response terminator.
pub fn end(&mut self, terminator_ch: Option<u8>) -> Option<Command> {
#[allow(clippy::enum_glob_use)]
use State::*;
match self.state {
Start => None,
Invalid => None,
S0 | S2 => parsers::change_window_title::parse(self, terminator_ch),
S1 => parsers::change_window_icon::parse(self, terminator_ch),
S4 | S5 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S104 | S110
| S111 | S112 | S113 | S114 | S115 | S116 | S117 | S118 | S119 => {
parsers::color::parse(self, terminator_ch)
}
S7 => parsers::report_pwd::parse(self, terminator_ch),
S8 => parsers::hyperlink::parse(self, terminator_ch),
S9 => parsers::osc9::parse(self, terminator_ch),
S21 => parsers::kitty_color::parse(self, terminator_ch),
S22 => parsers::mouse_shape::parse(self, terminator_ch),
S52 => parsers::clipboard_operation::parse(self, terminator_ch),
S55 => None,
S3 | S30 | S300 => None,
S3008 => parsers::context_signal::parse(self, terminator_ch),
S6 => None,
S66 => parsers::kitty_text_sizing::parse(self, terminator_ch),
S77 => None,
S133 => parsers::semantic_prompt::parse(self, terminator_ch),
S552 => None,
S777 => parsers::rxvt_extension::parse(self, terminator_ch),
S1337 => parsers::iterm2::parse(self, terminator_ch),
S5522 => parsers::kitty_clipboard_protocol::parse(self, terminator_ch),
}
}
}
@@ -0,0 +1,24 @@
//! OSC 1 (ZIG/src/terminal/osc/parsers/change_window_icon.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 1
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ChangeWindowIcon(data))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_1_change_window_icon() {
let mut p = Parser::init(false);
for c in b"1;ab" {
p.next(*c);
}
let cmd = p.end(None).unwrap();
assert_eq!(cmd, Command::ChangeWindowIcon(b"ab".to_vec()));
}
}
@@ -0,0 +1,92 @@
//! OSC 0 and OSC 2 (ZIG/src/terminal/osc/parsers/change_window_title.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 0 and OSC 2
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ChangeWindowTitle(data))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn feed(p: &mut Parser, s: &[u8]) {
for c in s {
p.next(*c);
}
}
#[test]
fn osc_0_change_window_title() {
let mut p = Parser::init(false);
feed(&mut p, b"0;ab");
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle(b"ab".to_vec())
);
}
#[test]
fn osc_0_longer_than_buffer() {
let mut p = Parser::init(false);
let mut input = b"0;".to_vec();
input.extend(std::iter::repeat_n(b'a', Parser::MAX_BUF + 2));
feed(&mut p, &input);
assert!(p.end(None).is_none());
}
#[test]
fn osc_0_one_shorter_than_buffer_length() {
let mut p = Parser::init(false);
let title = vec![b'a'; Parser::MAX_BUF - 1];
let mut input = b"0;".to_vec();
input.extend(&title);
feed(&mut p, &input);
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(title));
}
#[test]
fn osc_0_exactly_at_buffer_length() {
let mut p = Parser::init(false);
let mut input = b"0;".to_vec();
input.extend(vec![b'a'; Parser::MAX_BUF]);
feed(&mut p, &input);
// This should be None because we always reserve space for a null terminator.
assert!(p.end(None).is_none());
}
#[test]
fn osc_2_change_window_title_with_2() {
let mut p = Parser::init(false);
feed(&mut p, b"2;ab");
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle(b"ab".to_vec())
);
}
#[test]
fn osc_2_change_window_title_with_utf8() {
let mut p = Parser::init(false);
feed(&mut p, b"2;");
// '—' EM DASH U+2014 (E2 80 94)
feed(&mut p, &[0xE2, 0x80, 0x94]);
p.next(b' ');
// '‐' HYPHEN U+2010 (E2 80 90)
// Intententionally chosen to conflict with the 0x90 C1 control
feed(&mut p, &[0xE2, 0x80, 0x90]);
assert_eq!(
p.end(None).unwrap(),
Command::ChangeWindowTitle("— ‐".as_bytes().to_vec())
);
}
#[test]
fn osc_2_change_window_title_empty() {
let mut p = Parser::init(false);
feed(&mut p, b"2;");
assert_eq!(p.end(None).unwrap(), Command::ChangeWindowTitle(Vec::new()));
}
}
@@ -0,0 +1,91 @@
//! OSC 52 (ZIG/src/terminal/osc/parsers/clipboard_operation.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 52
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S52);
let data = parser.data()?;
// Zig's `data` includes the NUL terminator, so `data.len == 1` there
// means the payload is empty.
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
if data[0] == b';' {
Some(Command::ClipboardContents {
kind: b'c',
data: data[1..].to_vec(),
})
} else {
if data.len() < 2 {
parser.state = State::Invalid;
return None;
}
if data[1] != b';' {
parser.state = State::Invalid;
return None;
}
Some(Command::ClipboardContents {
kind: data[0],
data: data[2..].to_vec(),
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn run(alloc: bool, input: &[u8]) -> Option<Command> {
let mut p = Parser::init(alloc);
for c in input {
p.next(*c);
}
p.end(None)
}
#[test]
fn osc_52_get_set_clipboard() {
assert_eq!(
run(false, b"52;s;?").unwrap(),
Command::ClipboardContents {
kind: b's',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_get_set_clipboard_optional_parameter() {
assert_eq!(
run(false, b"52;;?").unwrap(),
Command::ClipboardContents {
kind: b'c',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_get_set_clipboard_with_allocator() {
assert_eq!(
run(true, b"52;s;?").unwrap(),
Command::ClipboardContents {
kind: b's',
data: b"?".to_vec()
}
);
}
#[test]
fn osc_52_clear_clipboard() {
assert_eq!(
run(false, b"52;;").unwrap(),
Command::ClipboardContents {
kind: b'c',
data: Vec::new()
}
);
}
}
+564
View File
@@ -0,0 +1,564 @@
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
//! (ZIG/src/terminal/osc/parsers/color.zig).
use crate::color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor};
use crate::osc::{Command, Parser, State, Terminator};
/// The possible operations we support for colors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operation {
Osc4,
Osc5,
Osc10,
Osc11,
Osc12,
Osc13,
Osc14,
Osc15,
Osc16,
Osc17,
Osc18,
Osc19,
Osc104,
Osc105,
Osc110,
Osc111,
Osc112,
Osc113,
Osc114,
Osc115,
Osc116,
Osc117,
Osc118,
Osc119,
}
/// Parse OSCs 4, 5, 10-19, 104, 110-119
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
if !parser.alloc {
parser.state = State::Invalid;
return None;
}
// If we've collected any extra data parse that, otherwise use an empty
// string.
let data = if parser.has_writer() {
parser.buffered()?
} else {
Vec::new()
};
// Check and make sure that we're parsing the correct OSCs
let op = match parser.state {
State::S4 => Operation::Osc4,
State::S5 => Operation::Osc5,
State::S10 => Operation::Osc10,
State::S11 => Operation::Osc11,
State::S12 => Operation::Osc12,
State::S13 => Operation::Osc13,
State::S14 => Operation::Osc14,
State::S15 => Operation::Osc15,
State::S16 => Operation::Osc16,
State::S17 => Operation::Osc17,
State::S18 => Operation::Osc18,
State::S19 => Operation::Osc19,
State::S104 => Operation::Osc104,
State::S110 => Operation::Osc110,
State::S111 => Operation::Osc111,
State::S112 => Operation::Osc112,
State::S113 => Operation::Osc113,
State::S114 => Operation::Osc114,
State::S115 => Operation::Osc115,
State::S116 => Operation::Osc116,
State::S117 => Operation::Osc117,
State::S118 => Operation::Osc118,
State::S119 => Operation::Osc119,
_ => {
parser.state = State::Invalid;
return None;
}
};
Some(Command::ColorOperation {
op,
requests: parse_color(op, &data),
terminator: Terminator::init(terminator_ch),
})
}
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
}
/// Zig `std.fmt.parseInt(u9, s, 10)`: optional sign, digits only.
fn parse_u9(s: &[u8]) -> Option<u16> {
let (neg, digits) = match s.first()? {
b'+' => (false, &s[1..]),
b'-' => (true, &s[1..]),
_ => (false, s),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
if !c.is_ascii_digit() {
return None;
}
acc = acc * 10 + (c - b'0') as u32;
if acc > 511 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u16)
}
/// Parse any color operation string. This should NOT include the operation
/// itself, but only the body of the operation. e.g. for "4;a;b;c" the body
/// should be "a;b;c" and the operation should be set accordingly.
///
/// Color parsing is fairly complicated so we pull this out to a specialized
/// function rather than go through our OSC parsing state machine. This is
/// much slower and requires more memory (since we need to buffer the full
/// request) but grants us an easier to understand and testable implementation.
///
/// If color changing ends up being a bottleneck we can optimize this later.
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
let mut it = tokenize(buf);
match op {
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
}
}
/// Map a parsed numeric color to a target for OSC 4/5/104/105.
/// `special_only` is true for OSC 5/105; otherwise 0-255 is the palette and
/// 256+ is the special offset.
fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
if special_only {
// std.math.cast(u3, color) then intToEnum
let v = u8::try_from(color).ok().filter(|&v| v < 8)?;
return SpecialColor::from_u8(v).map(Target::Special);
}
if let Ok(idx) = u8::try_from(color) {
return Some(Target::Palette(idx));
}
let v = u8::try_from(color - 256).ok().filter(|&v| v < 8)?;
SpecialColor::from_u8(v).map(Target::Special)
}
/// OSC 4/5
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
let mut result = List::new();
loop {
// We expect a `c; spec` pair. If either doesn't exist then
// we return the results up to this point.
let Some(color_str) = it.next() else {
return result;
};
let Some(spec_str) = it.next() else {
return result;
};
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
return result;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
return result;
};
// "?" always results in a query.
if spec_str == b"?" {
result.push(Request::Query(target));
continue;
}
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
};
result.push(Request::Set(ColoredTarget { target, color: rgb }));
}
}
/// OSC 104/105: Reset ANSI Colors
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
// Note: xterm stops parsing the reset list on any error, but we're
// more flexible and try the next value. This matches the behavior of
// Kitty and I don't see a downside to being more flexible here. Hopefully
// no one depends on the exact behavior of xterm.
let mut result = List::new();
loop {
let Some(color_str) = it.next() else {
// If no parameters are given, we reset the full table.
if result.is_empty() {
result.push(match op {
Operation::Osc104 => Request::ResetPalette,
Operation::Osc105 => Request::ResetSpecial,
_ => unreachable!(),
});
}
return result;
};
// Empty color strings are ignored, not treated as an error.
if color_str.is_empty() {
continue;
}
// Color must be numeric. u9 because that'll fit our palette + special
let Some(color) = parse_u9(color_str) else {
continue;
};
// Parse the color.
let Some(target) = ansi_target(color, op == Operation::Osc105) else {
continue;
};
result.push(Request::Reset(target));
}
}
/// OSC 10-19: Get/Set Dynamic Colors
fn parse_get_set_dynamic_color<'a>(
start: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
let mut result = List::new();
let mut color = start;
loop {
let Some(spec_str) = it.next() else {
return result;
};
if spec_str == b"?" {
result.push(Request::Query(Target::Dynamic(color)));
} else {
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
return result;
};
result.push(Request::Set(ColoredTarget {
target: Target::Dynamic(color),
color: rgb,
}));
}
// Each successive value uses the next color so long as it exists.
match color.next() {
Some(n) => color = n,
None => return result,
}
}
}
/// OSC 110-119: Reset Dynamic Colors
fn parse_reset_dynamic_color<'a>(
color: DynamicColor,
it: &mut impl Iterator<Item = &'a [u8]>,
) -> List {
let mut result = List::new();
if it.next().is_some() {
return result;
}
result.push(Request::Reset(Target::Dynamic(color)));
result
}
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
/// avoid copying; a `Vec` is equivalent.)
pub type List = Vec<Request>;
/// A single operation related to the terminal color palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Request {
Set(ColoredTarget),
Query(Target),
Reset(Target),
ResetPalette,
ResetSpecial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Palette(u8),
Special(SpecialColor),
Dynamic(DynamicColor),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColoredTarget {
pub target: Target,
pub color: Rgb,
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
const RED: Rgb = Rgb { r: 255, g: 0, b: 0 };
const BLUE: Rgb = Rgb { r: 0, g: 0, b: 255 };
fn set(target: Target, color: Rgb) -> Request {
Request::Set(ColoredTarget { target, color })
}
const SPECIALS: [SpecialColor; 5] = [
SpecialColor::Bold,
SpecialColor::Underline,
SpecialColor::Blink,
SpecialColor::Reverse,
SpecialColor::Italic,
];
const DYNAMICS: [(DynamicColor, Operation, Operation); 10] = [
(
DynamicColor::Foreground,
Operation::Osc10,
Operation::Osc110,
),
(
DynamicColor::Background,
Operation::Osc11,
Operation::Osc111,
),
(DynamicColor::Cursor, Operation::Osc12, Operation::Osc112),
(
DynamicColor::PointerForeground,
Operation::Osc13,
Operation::Osc113,
),
(
DynamicColor::PointerBackground,
Operation::Osc14,
Operation::Osc114,
),
(
DynamicColor::TektronixForeground,
Operation::Osc15,
Operation::Osc115,
),
(
DynamicColor::TektronixBackground,
Operation::Osc16,
Operation::Osc116,
),
(
DynamicColor::HighlightBackground,
Operation::Osc17,
Operation::Osc117,
),
(
DynamicColor::TektronixCursor,
Operation::Osc18,
Operation::Osc118,
),
(
DynamicColor::HighlightForeground,
Operation::Osc19,
Operation::Osc119,
),
];
#[test]
fn osc_4_empty_param() {
let mut p = Parser::init(false);
for ch in "4;;".bytes() {
p.next(ch);
}
assert!(p.end(Some(0x1b)).is_none());
}
#[test]
fn osc_4() {
// Test every palette index
for idx in 0..u8::MAX {
// printf '\e]4;0;red\\'
let list = parse_color(Operation::Osc4, format!("{idx};red").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
// printf '\e]4;0;?\\'
let list = parse_color(Operation::Osc4, format!("{idx};?").as_bytes());
assert_eq!(vec![Request::Query(Target::Palette(idx))], list);
// Trailing invalid data produces results up to that point
let list = parse_color(Operation::Osc4, format!("{idx};red;").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
// Whitespace doesn't produce a working value in xterm but we
// allow it because Kitty does and it seems harmless.
let list = parse_color(Operation::Osc4, format!("{idx};red ").as_bytes());
assert_eq!(vec![set(Target::Palette(idx), RED)], list);
}
// Test every special color
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc4, format!("{};red", 256 + i).as_bytes());
assert_eq!(vec![set(Target::Special(*special), RED)], list);
}
}
#[test]
fn osc_5() {
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc5, format!("{i};red").as_bytes());
assert_eq!(vec![set(Target::Special(*special), RED)], list);
}
}
#[test]
fn osc_4_multiple_requests() {
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
assert_eq!(
vec![set(Target::Palette(0), RED), set(Target::Palette(1), BLUE)],
list
);
// Multiple requests with same index overwrite each other
let list = parse_color(Operation::Osc4, b"0;red;0;blue");
assert_eq!(
vec![set(Target::Palette(0), RED), set(Target::Palette(0), BLUE)],
list
);
}
#[test]
fn osc_104() {
for idx in 0..u8::MAX {
let list = parse_color(Operation::Osc104, idx.to_string().as_bytes());
assert_eq!(vec![Request::Reset(Target::Palette(idx))], list);
}
for (i, special) in SPECIALS.iter().enumerate() {
let list = parse_color(Operation::Osc104, (256 + i).to_string().as_bytes());
assert_eq!(vec![Request::Reset(Target::Special(*special))], list);
}
}
#[test]
fn osc_104_empty_index() {
let list = parse_color(Operation::Osc104, b"0;;1");
assert_eq!(
vec![
Request::Reset(Target::Palette(0)),
Request::Reset(Target::Palette(1))
],
list
);
}
#[test]
fn osc_104_invalid_index() {
let list = parse_color(Operation::Osc104, b"ffff;1");
assert_eq!(vec![Request::Reset(Target::Palette(1))], list);
}
#[test]
fn osc_104_reset_all() {
let list = parse_color(Operation::Osc104, b"");
assert_eq!(vec![Request::ResetPalette], list);
}
#[test]
fn osc_105_reset_all() {
let list = parse_color(Operation::Osc105, b"");
assert_eq!(vec![Request::ResetSpecial], list);
}
// OSC 10-19: Get/Set Dynamic Colors
#[test]
fn osc_10_to_19_dynamic() {
for (color, op, _) in DYNAMICS {
// printf '\e]10;red\e\\'
let list = parse_color(op, b"red");
assert_eq!(vec![set(Target::Dynamic(color), RED)], list);
}
}
#[test]
fn osc_10_to_19_dynamic_multiple() {
// printf '\e]11;red;blue\e\\'
let list = parse_color(Operation::Osc11, b"red;blue");
assert_eq!(
vec![
set(Target::Dynamic(DynamicColor::Background), RED),
set(Target::Dynamic(DynamicColor::Cursor), BLUE)
],
list
);
}
// OSC 110-119: Reset Dynamic Colors
#[test]
fn osc_110_to_119_reset_dynamic() {
for (color, _, op) in DYNAMICS {
let expected = vec![Request::Reset(Target::Dynamic(color))];
// printf '\e]110\e\\'
assert_eq!(expected, parse_color(op, b""));
// xterm allows a trailing semicolon.
assert_eq!(expected, parse_color(op, b";"));
// xterm does NOT allow any whitespace
assert_eq!(0, parse_color(op, b" ").len());
}
}
#[test]
fn osc_112_incomplete_sequence() {
// https://github.com/darrenstarr/VtNetCore/pull/14
let mut p = Parser::init(true);
for ch in b"112" {
p.next(*ch);
}
let cmd = p.end(Some(0x07)).unwrap();
let Command::ColorOperation {
op,
requests,
terminator,
} = cmd
else {
panic!("wrong command");
};
assert_eq!(Terminator::Bel, terminator);
assert_eq!(Operation::Osc112, op);
assert_eq!(
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
requests
);
}
}
@@ -0,0 +1,498 @@
//! OSC 3008: Hierarchical Context Signalling (UAPI spec)
//! Specification: https://uapi-group.org/specifications/specs/osc_context/
//! (ZIG/src/terminal/osc/parsers/context_signal.zig)
//!
//! OSC 3008 allows programs to signal context changes to the terminal emulator.
//! Each context has an identifier and metadata fields. Contexts are hierarchical
//! and form a stack.
use crate::osc::{Command as OscCommand, Parser, State};
/// Maximum length of a context identifier (per spec).
const MAX_CONTEXT_ID_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// OSC 3008;start=<id> - initiates, updates, or returns to a context.
Start,
/// OSC 3008;end=<id> - terminates a context.
End,
}
/// A single OSC 3008 context signal command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub action: Action,
/// The context identifier. Must be 1-64 characters in the 32..126 byte range.
pub id: Vec<u8>,
/// Raw unparsed metadata fields after the context ID.
/// Fields are semicolon-separated key=value pairs.
/// Parsed lazily via `read_option`.
pub metadata: Vec<u8>,
}
/// Value of a decoded metadata field (Zig `Field.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldValue<'a> {
Type(ContextType),
Exit(ExitStatus),
U64(u64),
Str(&'a [u8]),
}
impl Command {
/// Read a metadata field value from the raw fields string.
/// Returns None if the field is not present or malformed.
pub fn read_option(&self, option: Field) -> Option<FieldValue<'_>> {
option.read(&self.metadata)
}
pub fn context_type(&self) -> Option<ContextType> {
match self.read_option(Field::Type)? {
FieldValue::Type(v) => Some(v),
_ => None,
}
}
pub fn exit(&self) -> Option<ExitStatus> {
match self.read_option(Field::Exit)? {
FieldValue::Exit(v) => Some(v),
_ => None,
}
}
/// For the numeric fields (`pid`, `pidfdid`, `status`).
pub fn read_u64(&self, option: Field) -> Option<u64> {
match self.read_option(option)? {
FieldValue::U64(v) => Some(v),
_ => None,
}
}
/// For the string fields.
pub fn read_str(&self, option: Field) -> Option<&[u8]> {
match self.read_option(option)? {
FieldValue::Str(v) => Some(v),
_ => None,
}
}
}
/// Context types defined by the specification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextType {
Boot,
Container,
Vm,
Elevate,
Chpriv,
Subcontext,
Remote,
Shell,
Command,
App,
Service,
Session,
}
impl ContextType {
pub fn parse(value: &[u8]) -> Option<ContextType> {
Some(match value {
b"boot" => ContextType::Boot,
b"container" => ContextType::Container,
b"vm" => ContextType::Vm,
b"elevate" => ContextType::Elevate,
b"chpriv" => ContextType::Chpriv,
b"subcontext" => ContextType::Subcontext,
b"remote" => ContextType::Remote,
b"shell" => ContextType::Shell,
b"command" => ContextType::Command,
b"app" => ContextType::App,
b"service" => ContextType::Service,
b"session" => ContextType::Session,
_ => return None,
})
}
}
/// Exit status for the `exit` end-sequence field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitStatus {
Success,
Failure,
Crash,
Interrupt,
}
impl ExitStatus {
pub fn parse(value: &[u8]) -> Option<ExitStatus> {
Some(match value {
b"success" => ExitStatus::Success,
b"failure" => ExitStatus::Failure,
b"crash" => ExitStatus::Crash,
b"interrupt" => ExitStatus::Interrupt,
_ => return None,
})
}
}
/// Metadata fields that can appear in OSC 3008 sequences.
/// Fields are read lazily from the raw string using the `read` method.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
// Start sequence fields
Type,
User,
Hostname,
Machineid,
Bootid,
Pid,
Pidfdid,
Comm,
Cwd,
Cmdline,
Vm,
Container,
Targetuser,
Targethost,
Sessionid,
// End sequence fields
Exit,
Status,
Signal,
}
impl Field {
fn key(self) -> &'static str {
match self {
Field::Type => "type",
Field::User => "user",
Field::Hostname => "hostname",
Field::Machineid => "machineid",
Field::Bootid => "bootid",
Field::Pid => "pid",
Field::Pidfdid => "pidfdid",
Field::Comm => "comm",
Field::Cwd => "cwd",
Field::Cmdline => "cmdline",
Field::Vm => "vm",
Field::Container => "container",
Field::Targetuser => "targetuser",
Field::Targethost => "targethost",
Field::Sessionid => "sessionid",
Field::Exit => "exit",
Field::Status => "status",
Field::Signal => "signal",
}
}
/// Read the field value from the raw fields string.
///
/// The raw fields string contains semicolon-separated key=value pairs
/// e.g. "type=container;user=lennart;hostname=zeta".
///
/// Unknown or malformed fields are ignored per the specification.
pub fn read(self, raw: &[u8]) -> Option<FieldValue<'_>> {
let key = self.key().as_bytes();
for full in raw.split(|&c| c == b';') {
let value = match full.iter().position(|&c| c == b'=') {
Some(eql) if &full[..eql] == key => &full[eql + 1..],
_ => continue,
};
return match self {
Field::Type => ContextType::parse(value).map(FieldValue::Type),
Field::Exit => ExitStatus::parse(value).map(FieldValue::Exit),
Field::Pid | Field::Pidfdid | Field::Status => {
if value.iter().any(|c| !c.is_ascii_digit()) {
return None;
}
std::str::from_utf8(value)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(FieldValue::U64)
}
// String fields
_ => {
if value.is_empty() {
None
} else {
Some(FieldValue::Str(value))
}
}
};
}
// Not found
None
}
}
/// Parse OSC 3008: hierarchical context signalling.
///
/// Expected data format (after "3008;" prefix has been consumed by the state machine):
/// start=<id>[;<field>=<value>]*
/// end=<id>[;<field>=<value>]*
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
let data = parser.buffered()?;
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
// Determine the action (start= or end=)
let (action, prefix_len) = if data.starts_with(b"start=") {
(Action::Start, b"start=".len())
} else if data.starts_with(b"end=") {
(Action::End, b"end=".len())
} else {
log::warn!(
"OSC 3008: expected 'start=' or 'end=' prefix, got: {}",
String::from_utf8_lossy(&data[..data.len().min(10)])
);
parser.state = State::Invalid;
return None;
};
// Skip past the "start=" or "end=" prefix
let rest = &data[prefix_len..];
if rest.is_empty() {
log::warn!("OSC 3008: missing context ID");
parser.state = State::Invalid;
return None;
}
// Extract the context ID (up to the first semicolon or end of data)
let id_end = rest.iter().position(|&c| c == b';').unwrap_or(rest.len());
let id = &rest[..id_end];
// Validate context ID length (1-64 chars per spec)
if id.is_empty() || id.len() > MAX_CONTEXT_ID_LEN {
log::warn!(
"OSC 3008: context ID length {} out of range (1-{})",
id.len(),
MAX_CONTEXT_ID_LEN
);
parser.state = State::Invalid;
return None;
}
// Validate context ID characters (32..126 byte range per spec)
for &c in id {
if !(0x20..=0x7e).contains(&c) {
log::warn!("OSC 3008: invalid character 0x{c:02x} in context ID");
parser.state = State::Invalid;
return None;
}
}
// Extract raw metadata fields (everything after the ID)
let metadata = if id_end < rest.len() {
rest[id_end + 1..].to_vec()
} else {
Vec::new()
};
Some(OscCommand::ContextSignal(Command {
action,
id: id.to_vec(),
metadata,
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &str) -> Option<OscCommand> {
let mut p = Parser::init(false);
for &ch in input.as_bytes() {
p.next(ch);
}
p.end(None)
}
fn cs(input: &str) -> Command {
match run(input) {
Some(OscCommand::ContextSignal(c)) => c,
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn osc_3008_basic_start_command() {
let c = cs("3008;start=abc123");
assert_eq!(Action::Start, c.action);
assert_eq!(b"abc123", &c.id[..]);
assert_eq!(b"", &c.metadata[..]);
}
#[test]
fn osc_3008_basic_end_command() {
let c = cs("3008;end=abc123");
assert_eq!(Action::End, c.action);
assert_eq!(b"abc123", &c.id[..]);
assert_eq!(b"", &c.metadata[..]);
}
#[test]
fn osc_3008_start_with_metadata_fields() {
let c = cs(
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta",
);
assert_eq!(Action::Start, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
assert_eq!(Some(ContextType::Container), c.context_type());
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
}
#[test]
fn osc_3008_start_with_all_common_fields() {
let c = cs(
"3008;start=myctx;type=shell;user=root;hostname=myhost;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=bash",
);
assert_eq!(Some(ContextType::Shell), c.context_type());
assert_eq!(b"root", c.read_str(Field::User).unwrap());
assert_eq!(b"myhost", c.read_str(Field::Hostname).unwrap());
assert_eq!(
b"3deb5353d3ba43d08201c136a47ead7b",
c.read_str(Field::Machineid).unwrap()
);
assert_eq!(
b"d4a3d0fdf2e24fdea6d971ce73f4fbf2",
c.read_str(Field::Bootid).unwrap()
);
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
assert_eq!(Some(1063162), c.read_u64(Field::Pidfdid));
assert_eq!(b"bash", c.read_str(Field::Comm).unwrap());
}
#[test]
fn osc_3008_end_with_exit_metadata() {
let c = cs("3008;end=myctx;exit=success;status=0");
assert_eq!(Action::End, c.action);
assert_eq!(b"myctx", &c.id[..]);
assert_eq!(Some(ExitStatus::Success), c.exit());
assert_eq!(Some(0), c.read_u64(Field::Status));
}
#[test]
fn osc_3008_end_with_failure_exit() {
let c = cs("3008;end=myctx;exit=failure;status=1;signal=SIGKILL");
assert_eq!(Some(ExitStatus::Failure), c.exit());
assert_eq!(Some(1), c.read_u64(Field::Status));
assert_eq!(b"SIGKILL", c.read_str(Field::Signal).unwrap());
}
#[test]
fn osc_3008_unknown_fields_are_ignored() {
let c = cs("3008;start=myctx;type=shell;unknownfield=value;user=root");
assert_eq!(Some(ContextType::Shell), c.context_type());
assert_eq!(b"root", c.read_str(Field::User).unwrap());
}
#[test]
fn osc_3008_missing_field_returns_null() {
let c = cs("3008;start=myctx;user=lennart");
assert!(c.context_type().is_none());
assert!(c.read_str(Field::Hostname).is_none());
assert!(c.read_u64(Field::Pid).is_none());
}
#[test]
fn osc_3008_invalid_prefix() {
assert!(run("3008;bogus=abc123").is_none());
}
#[test]
fn osc_3008_empty_data() {
assert!(run("3008;start=").is_none());
}
#[test]
fn osc_3008_max_length_context_id() {
let id = "a".repeat(64);
let c = cs(&format!("3008;start={id}"));
assert_eq!(id.as_bytes(), &c.id[..]);
}
#[test]
fn osc_3008_over_length_context_id() {
let id = "a".repeat(65);
assert!(run(&format!("3008;start={id}")).is_none());
}
#[test]
fn osc_3008_context_type_enum_coverage() {
let types = [
("boot", ContextType::Boot),
("container", ContextType::Container),
("vm", ContextType::Vm),
("elevate", ContextType::Elevate),
("chpriv", ContextType::Chpriv),
("subcontext", ContextType::Subcontext),
("remote", ContextType::Remote),
("shell", ContextType::Shell),
("command", ContextType::Command),
("app", ContextType::App),
("service", ContextType::Service),
("session", ContextType::Session),
];
for (s, e) in types {
assert_eq!(Some(e), ContextType::parse(s.as_bytes()));
}
assert!(ContextType::parse(b"invalid").is_none());
}
#[test]
fn osc_3008_exit_status_enum_coverage() {
assert_eq!(Some(ExitStatus::Success), ExitStatus::parse(b"success"));
assert_eq!(Some(ExitStatus::Failure), ExitStatus::parse(b"failure"));
assert_eq!(Some(ExitStatus::Crash), ExitStatus::parse(b"crash"));
assert_eq!(Some(ExitStatus::Interrupt), ExitStatus::parse(b"interrupt"));
assert!(ExitStatus::parse(b"invalid").is_none());
}
#[test]
fn osc_3008_spec_example_container_start() {
let c = cs(
"3008;start=bed86fab93af4328bbed0a1224af6d40;type=container;user=lennart;hostname=zeta;machineid=3deb5353d3ba43d08201c136a47ead7b;bootid=d4a3d0fdf2e24fdea6d971ce73f4fbf2;pid=1062862;pidfdid=1063162;comm=systemd-nspawn;container=foobar",
);
assert_eq!(Action::Start, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
assert_eq!(Some(ContextType::Container), c.context_type());
assert_eq!(b"lennart", c.read_str(Field::User).unwrap());
assert_eq!(b"zeta", c.read_str(Field::Hostname).unwrap());
assert_eq!(b"systemd-nspawn", c.read_str(Field::Comm).unwrap());
assert_eq!(b"foobar", c.read_str(Field::Container).unwrap());
assert_eq!(Some(1062862), c.read_u64(Field::Pid));
}
#[test]
fn osc_3008_spec_example_context_end() {
let c = cs("3008;end=bed86fab93af4328bbed0a1224af6d40");
assert_eq!(Action::End, c.action);
assert_eq!(b"bed86fab93af4328bbed0a1224af6d40", &c.id[..]);
}
#[test]
fn osc_3008_cwd_and_cmdline_fields() {
let c = cs("3008;start=myctx;type=command;cwd=/home/user;cmdline=ls -la");
assert_eq!(b"/home/user", c.read_str(Field::Cwd).unwrap());
assert_eq!(b"ls -la", c.read_str(Field::Cmdline).unwrap());
}
#[test]
fn osc_3008_start_command_with_no_fields() {
let c = cs("3008;start=simpleid");
assert_eq!(Action::Start, c.action);
assert_eq!(b"simpleid", &c.id[..]);
assert!(c.context_type().is_none());
assert!(c.read_str(Field::User).is_none());
assert!(c.exit().is_none());
}
}
@@ -0,0 +1,143 @@
//! OSC 8 hyperlinks (ZIG/src/terminal/osc/parsers/hyperlink.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 8 hyperlinks
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
let Some(s) = data.iter().position(|&b| b == b';') else {
parser.state = State::Invalid;
return None;
};
let uri = data[s + 1..].to_vec();
let mut id: Option<Vec<u8>> = None;
// The params section is `key=value` pairs separated by ':'. This follows
// the Zig implementation byte for byte: ':' and the ';' terminator are
// turned into NULs and the segments are walked by searching for the next
// NUL starting one past the segment start.
let mut kvs = data[..s].to_vec();
kvs.push(0);
for b in &mut kvs {
if *b == b':' {
*b = 0;
}
}
let mut kv_start = 0;
while kv_start < kvs.len() {
let Some(kv_end) = kvs
.iter()
.skip(kv_start + 1)
.position(|&b| b == 0)
.map(|i| i + kv_start + 1)
else {
break;
};
let kv = &kvs[kv_start..kv_end];
let Some(v) = kv.iter().position(|&b| b == b'=') else {
break;
};
let key = &kv[..v];
let value = &kv[v + 1..];
if key == b"id" {
if !value.is_empty() {
id = Some(value.to_vec());
}
} else {
log::warn!(
"unknown hyperlink option: '{}'",
String::from_utf8_lossy(key)
);
}
kv_start = kv_end + 1;
}
if uri.is_empty() {
if id.is_some() {
parser.state = State::Invalid;
return None;
}
return Some(Command::HyperlinkEnd);
}
Some(Command::HyperlinkStart { id, uri })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn run(input: &[u8]) -> Option<Command> {
let mut p = Parser::init(false);
for c in input {
p.next(*c);
}
p.end(Some(0x1b))
}
fn start(id: Option<&str>, uri: &str) -> Command {
Command::HyperlinkStart {
id: id.map(|s| s.as_bytes().to_vec()),
uri: uri.as_bytes().to_vec(),
}
}
#[test]
fn osc_8_hyperlink() {
assert_eq!(
run(b"8;;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_id_set() {
assert_eq!(
run(b"8;id=foo;http://example.com").unwrap(),
start(Some("foo"), "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_id() {
assert_eq!(
run(b"8;id=;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_incomplete_key() {
assert_eq!(
run(b"8;id;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_key() {
assert_eq!(
run(b"8;=value;http://example.com").unwrap(),
start(None, "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_key_and_id() {
assert_eq!(
run(b"8;=value:id=foo;http://example.com").unwrap(),
start(Some("foo"), "http://example.com")
);
}
#[test]
fn osc_8_hyperlink_with_empty_uri() {
assert!(run(b"8;id=foo;").is_none());
}
#[test]
fn osc_8_hyperlink_end() {
assert_eq!(run(b"8;;").unwrap(), Command::HyperlinkEnd);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! OSC 1337 (iTerm2) (ZIG/src/terminal/osc/parsers/iterm2.zig).
use crate::osc::{Command, Parser, State};
/// iTerm2 OSC 1337 keys. Lookup is ASCII case-insensitive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Key {
AddAnnotation,
AddHiddenAnnotation,
Block,
Button,
ClearCapturedOutput,
ClearScrollback,
Copy,
CopyToClipboard,
CurrentDir,
CursorShape,
Custom,
Disinter,
EndCopy,
File,
FileEnd,
FilePart,
HighlightCursorLine,
MultipartFile,
OpenURL,
PopKeyLabels,
PushKeyLabels,
RemoteHost,
ReportCellSize,
ReportVariable,
RequestAttention,
RequestUpload,
SetBackgroundImageFile,
SetBadgeFormat,
SetColors,
SetKeyLabel,
SetMark,
SetProfile,
SetUserVar,
ShellIntegrationVersion,
StealFocus,
UnicodeVersion,
}
const KEYS: &[(&str, Key)] = &[
("AddAnnotation", Key::AddAnnotation),
("AddHiddenAnnotation", Key::AddHiddenAnnotation),
("Block", Key::Block),
("Button", Key::Button),
("ClearCapturedOutput", Key::ClearCapturedOutput),
("ClearScrollback", Key::ClearScrollback),
("Copy", Key::Copy),
("CopyToClipboard", Key::CopyToClipboard),
("CurrentDir", Key::CurrentDir),
("CursorShape", Key::CursorShape),
("Custom", Key::Custom),
("Disinter", Key::Disinter),
("EndCopy", Key::EndCopy),
("File", Key::File),
("FileEnd", Key::FileEnd),
("FilePart", Key::FilePart),
("HighlightCursorLine", Key::HighlightCursorLine),
("MultipartFile", Key::MultipartFile),
("OpenURL", Key::OpenURL),
("PopKeyLabels", Key::PopKeyLabels),
("PushKeyLabels", Key::PushKeyLabels),
("RemoteHost", Key::RemoteHost),
("ReportCellSize", Key::ReportCellSize),
("ReportVariable", Key::ReportVariable),
("RequestAttention", Key::RequestAttention),
("RequestUpload", Key::RequestUpload),
("SetBackgroundImageFile", Key::SetBackgroundImageFile),
("SetBadgeFormat", Key::SetBadgeFormat),
("SetColors", Key::SetColors),
("SetKeyLabel", Key::SetKeyLabel),
("SetMark", Key::SetMark),
("SetProfile", Key::SetProfile),
("SetUserVar", Key::SetUserVar),
("ShellIntegrationVersion", Key::ShellIntegrationVersion),
("StealFocus", Key::StealFocus),
("UnicodeVersion", Key::UnicodeVersion),
];
fn lookup(s: &[u8]) -> Option<Key> {
KEYS.iter()
.find(|(name, _)| name.as_bytes().eq_ignore_ascii_case(s))
.map(|(_, k)| *k)
}
/// Parse OSC 1337
/// https://iterm2.com/documentation-escape-codes.html
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S1337);
let data = parser.data()?;
let (key_str, value): (&[u8], Option<&[u8]>) = match data.iter().position(|&b| b == b'=') {
None => (&data[..], None),
Some(index) => (&data[..index], Some(&data[index + 1..])),
};
// (Zig sets `parser.command = .invalid` and returns null on all of the
// failure paths below.)
let key = lookup(key_str)?;
match key {
Key::Copy => {
let value = value?;
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
// make sure that is invalid here.
if value.is_empty() {
return None;
}
// base64 value must be prefixed by a colon
if value[0] != b':' {
return None;
}
let value = &value[1..];
// Sending a blank entry to clear the clipboard is an OSC 52-ism,
// make sure that is invalid here.
if value.is_empty() {
return None;
}
// Sending a '?' to query the clipboard is an OSC 52-ism, make sure
// that is invalid here.
if value == b"?" {
return None;
}
// It would be better to check for valid base64 data here, but that
// would mean parsing the base64 data twice in the "normal" case.
Some(Command::ClipboardContents {
kind: b'c',
data: value.to_vec(),
})
}
Key::CurrentDir => {
let value = value?;
if value.is_empty() {
return None;
}
Some(Command::ReportPwd {
value: value.to_vec(),
})
}
_ => {
log::debug!("unimplemented OSC 1337: {key:?}");
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Option<Command> {
let mut p = Parser::init(true);
for ch in input.bytes() {
p.next(ch);
}
p.end(Some(0x1b))
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_no_value() {
assert!(run("1337;SetBadgeFormat").is_none());
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_empty_value() {
assert!(run("1337;SetBadgeFormat=").is_none());
}
#[test]
fn osc_1337_test_valid_unimplemented_key_with_non_empty_value() {
assert!(run("1337;SetBadgeFormat=abc123").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_no_value() {
assert!(run("1337;setbadgeformat").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_empty_value() {
assert!(run("1337;setbadgeformat=").is_none());
}
#[test]
fn osc_1337_test_valid_key_with_lower_case_and_with_non_empty_value() {
assert!(run("1337;setbadgeformat=abc123").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_no_value() {
assert!(run("1337;BobrKurwa").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_empty_value() {
assert!(run("1337;BobrKurwa=").is_none());
}
#[test]
fn osc_1337_test_invalid_key_with_non_empty_value() {
assert!(run("1337;BobrKurwa=abc123").is_none());
}
#[test]
fn osc_1337_test_copy_with_no_value() {
assert!(run("1337;Copy").is_none());
}
#[test]
fn osc_1337_test_copy_with_empty_value() {
assert!(run("1337;Copy=").is_none());
}
#[test]
fn osc_1337_test_copy_with_only_prefix_colon() {
assert!(run("1337;Copy=:").is_none());
}
#[test]
fn osc_1337_test_copy_with_question_mark() {
assert!(run("1337;Copy=:?").is_none());
}
// Skipped in Zig too (`return error.SkipZigTest`): for performance reasons
// base64 validity is not checked.
#[test]
#[ignore = "skipped in Zig: base64 is not validated for performance"]
fn osc_1337_test_copy_with_non_empty_value_that_is_invalid_base64() {
assert!(run("1337;Copy=:abc123").is_none());
}
#[test]
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64_but_not_prefixed_with_a_colon()
{
assert!(run("1337;Copy=YWJjMTIz").is_none());
}
#[test]
fn osc_1337_test_copy_with_non_empty_value_that_is_valid_base64() {
assert_eq!(
run("1337;Copy=:YWJjMTIz"),
Some(Command::ClipboardContents {
kind: b'c',
data: b"YWJjMTIz".to_vec()
})
);
}
#[test]
fn osc_1337_test_current_dir_with_no_value() {
assert!(run("1337;CurrentDir").is_none());
}
#[test]
fn osc_1337_test_current_dir_with_empty_value() {
assert!(run("1337;CurrentDir=").is_none());
}
#[test]
fn osc_1337_test_current_dir_with_non_empty_value() {
assert_eq!(
run("1337;CurrentDir=abc123"),
Some(Command::ReportPwd {
value: b"abc123".to_vec()
})
);
}
}
@@ -0,0 +1,658 @@
//! Kitty's clipboard protocol (OSC 5522)
//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/
//! https://rockorager.dev/misc/bracketed-paste-mime/
//! (ZIG/src/terminal/osc/parsers/kitty_clipboard_protocol.zig)
use crate::osc::{Command, Parser, State, Terminator};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OSC {
/// The raw metadata that was received. It can be parsed by using the
/// `read_option` method (or the typed helpers).
pub metadata: Vec<u8>,
/// The raw payload. It may be Base64 encoded, check the `e` option.
pub payload: Option<Vec<u8>>,
/// The terminator that was used in case we need to send a response.
pub terminator: Terminator,
}
/// Value of a decoded option (Zig `Option.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionValue<'a> {
Str(&'a [u8]),
Loc(Location),
Status(Status),
Type(Operation),
}
impl OSC {
/// Decode an option from the metadata.
pub fn read_option(&self, key: Opt) -> Option<OptionValue<'_>> {
key.read(&self.metadata)
}
pub fn id(&self) -> Option<&[u8]> {
match self.read_option(Opt::Id)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn loc(&self) -> Option<Location> {
match self.read_option(Opt::Loc)? {
OptionValue::Loc(v) => Some(v),
_ => None,
}
}
pub fn mime(&self) -> Option<&[u8]> {
match self.read_option(Opt::Mime)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn name(&self) -> Option<&[u8]> {
match self.read_option(Opt::Name)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn password(&self) -> Option<&[u8]> {
match self.read_option(Opt::Password)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn pw(&self) -> Option<&[u8]> {
match self.read_option(Opt::Pw)? {
OptionValue::Str(s) => Some(s),
_ => None,
}
}
pub fn status(&self) -> Option<Status> {
match self.read_option(Opt::Status)? {
OptionValue::Status(v) => Some(v),
_ => None,
}
}
pub fn type_(&self) -> Option<Operation> {
match self.read_option(Opt::Type)? {
OptionValue::Type(v) => Some(v),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Primary,
}
impl Location {
pub fn init(s: &[u8]) -> Option<Location> {
match s {
b"primary" => Some(Location::Primary),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Read,
Walias,
Wdata,
Write,
}
impl Operation {
pub fn init(s: &[u8]) -> Option<Operation> {
match s {
b"read" => Some(Operation::Read),
b"walias" => Some(Operation::Walias),
b"wdata" => Some(Operation::Wdata),
b"write" => Some(Operation::Write),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::upper_case_acronyms)]
pub enum Status {
DATA,
DONE,
EBUSY,
EINVAL,
EIO,
ENOSYS,
EPERM,
OK,
}
impl Status {
pub fn init(s: &[u8]) -> Option<Status> {
match s {
b"DATA" => Some(Status::DATA),
b"DONE" => Some(Status::DONE),
b"EBUSY" => Some(Status::EBUSY),
b"EINVAL" => Some(Status::EINVAL),
b"EIO" => Some(Status::EIO),
b"ENOSYS" => Some(Status::ENOSYS),
b"EPERM" => Some(Status::EPERM),
b"OK" => Some(Status::OK),
_ => None,
}
}
}
/// Option keys (Zig `Option`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Opt {
Id,
Loc,
Mime,
Name,
Password,
Pw,
Status,
Type,
}
/// Zig `std.ascii.isWhitespace`.
fn is_ws(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
}
fn trim_ws(mut s: &[u8]) -> &[u8] {
while let [first, rest @ ..] = s {
if is_ws(*first) {
s = rest;
} else {
break;
}
}
while let [rest @ .., last] = s {
if is_ws(*last) {
s = rest;
} else {
break;
}
}
s
}
fn index_of_from(hay: &[u8], from: usize, needle: u8) -> Option<usize> {
hay[from..]
.iter()
.position(|&c| c == needle)
.map(|i| i + from)
}
impl Opt {
pub fn tag_name(self) -> &'static str {
match self {
Opt::Id => "id",
Opt::Loc => "loc",
Opt::Mime => "mime",
Opt::Name => "name",
Opt::Password => "password",
Opt::Pw => "pw",
Opt::Status => "status",
Opt::Type => "type",
}
}
/// Read the option value from the raw metadata string.
pub fn read(self, metadata: &[u8]) -> Option<OptionValue<'_>> {
let tag = self.tag_name().as_bytes();
let value: &[u8] = 'value: {
let mut pos: usize = 0;
while pos < metadata.len() {
// skip any whitespace
while pos < metadata.len() && is_ws(metadata[pos]) {
pos += 1;
}
// bail if we are out of metadata
if pos >= metadata.len() {
return None;
}
if !metadata[pos..].starts_with(tag) {
// this isn't the key we are looking for, skip to the next option, or bail if
// there is no next option
pos = index_of_from(metadata, pos, b':')?;
pos += 1;
continue;
}
// skip past the key
pos += tag.len();
// skip any whitespace
while pos < metadata.len() && is_ws(metadata[pos]) {
pos += 1;
}
// bail if we are out of metadata
if pos >= metadata.len() {
return None;
}
// a valid option has an '='
if metadata[pos] != b'=' {
return None;
}
// the end of the value is bounded by a ':' or the end of the metadata
let end = index_of_from(metadata, pos, b':').unwrap_or(metadata.len());
let start = pos + 1;
// strip any leading or trailing whitespace
break 'value trim_ws(&metadata[start..end]);
}
// the key was not found
return None;
};
// return the parsed value
match self {
Opt::Id => parse_identifier(value).map(OptionValue::Str),
Opt::Loc => Location::init(value).map(OptionValue::Loc),
Opt::Mime | Opt::Name | Opt::Password | Opt::Pw => Some(OptionValue::Str(value)),
Opt::Status => Status::init(value).map(OptionValue::Status),
Opt::Type => Operation::init(value).map(OptionValue::Type),
}
}
}
/// Characters that are valid in identifiers.
const VALID_IDENTIFIER_CHARACTERS: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+.";
fn is_valid_identifier(s: &[u8]) -> bool {
if s.is_empty() {
return false;
}
s.iter().all(|c| VALID_IDENTIFIER_CHARACTERS.contains(c))
}
fn parse_identifier(s: &[u8]) -> Option<&[u8]> {
if is_valid_identifier(s) {
Some(s)
} else {
None
}
}
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S5522);
let data = parser.buffered()?;
let (metadata, payload) = match data.iter().position(|&c| c == b';') {
None => (data, None),
Some(start) => (data[..start].to_vec(), Some(data[start + 1..].to_vec())),
};
Some(Command::KittyClipboardProtocol(OSC {
metadata,
payload,
terminator: Terminator::init(terminator_ch),
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &str) -> OSC {
let mut p = Parser::init(true);
for &ch in input.as_bytes() {
p.next(ch);
}
match p.end(Some(0x1b)) {
Some(Command::KittyClipboardProtocol(o)) => o,
other => panic!("unexpected {other:?}"),
}
}
#[allow(clippy::option_option)]
/// Expected values for every option; `None` means "must be null".
#[derive(Default)]
struct Exp<'a> {
metadata: Option<&'a str>,
payload: Option<Option<&'a str>>,
id: Option<&'a str>,
loc: Option<Location>,
mime: Option<&'a str>,
name: Option<&'a str>,
password: Option<&'a str>,
pw: Option<&'a str>,
status: Option<Status>,
type_: Option<Operation>,
}
fn check(c: &OSC, e: &Exp) {
if let Some(m) = e.metadata {
assert_eq!(m.as_bytes(), &c.metadata[..]);
}
if let Some(p) = e.payload {
assert_eq!(p.map(|s| s.as_bytes().to_vec()), c.payload);
}
assert_eq!(e.id.map(str::as_bytes), c.id());
assert_eq!(e.loc, c.loc());
assert_eq!(e.mime.map(str::as_bytes), c.mime());
assert_eq!(e.name.map(str::as_bytes), c.name());
assert_eq!(e.password.map(str::as_bytes), c.password());
assert_eq!(e.pw.map(str::as_bytes), c.pw());
assert_eq!(e.status, c.status());
assert_eq!(e.type_, c.type_());
}
#[test]
fn osc_5522_empty_metadata_and_missing_payload() {
let c = run("5522;");
check(
&c,
&Exp {
metadata: Some(""),
payload: Some(None),
..Default::default()
},
);
}
#[test]
fn osc_5522_empty_metadata_and_empty_payload() {
let c = run("5522;;");
check(
&c,
&Exp {
metadata: Some(""),
payload: Some(Some("")),
..Default::default()
},
);
}
#[test]
fn osc_5522_non_empty_metadata_and_payload() {
let c = run("5522;type=read;dGV4dC9wbGFpbg==");
check(
&c,
&Exp {
metadata: Some("type=read"),
payload: Some(Some("dGV4dC9wbGFpbg==")),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_empty_id() {
let c = run("5522;id=");
assert!(c.id().is_none());
}
#[test]
fn osc_5522_valid_id() {
let c = run("5522;id=5c076ad9-d36f-4705-847b-d4dbf356cc0d");
assert_eq!(b"5c076ad9-d36f-4705-847b-d4dbf356cc0d", c.id().unwrap());
}
#[test]
fn osc_5522_invalid_id() {
let c = run("5522;id=*42*");
assert!(c.id().is_none());
}
#[test]
fn osc_5522_invalid_status() {
let c = run("5522;status=BOBR");
assert!(c.status().is_none());
}
#[test]
fn osc_5522_valid_status() {
let c = run("5522;status=DONE");
assert_eq!(Some(Status::DONE), c.status());
}
#[test]
fn osc_5522_invalid_location() {
let c = run("5522;loc=bobr");
assert!(c.loc().is_none());
}
#[test]
fn osc_5522_valid_location() {
let c = run("5522;loc=primary");
assert_eq!(Some(Location::Primary), c.loc());
}
#[test]
fn osc_5522_password_1() {
let c = run("5522;pw=R2hvc3R0eQ==:name=Qk9CUiBLVVJXQQ==");
assert_eq!(b"R2hvc3R0eQ==", c.pw().unwrap());
assert_eq!(b"Qk9CUiBLVVJXQQ==", c.name().unwrap());
}
#[test]
fn osc_5522_password_2() {
let c = run("5522;password=R2hvc3R0eQ==");
assert_eq!(b"R2hvc3R0eQ==", c.password().unwrap());
}
#[test]
fn osc_5522_example_1() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_2() {
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
check(
&c,
&Exp {
payload: Some(Some("R2hvc3R0eQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_3() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_4() {
let c = run("5522;type=write");
check(
&c,
&Exp {
payload: Some(None),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_5() {
let c = run("5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==");
check(
&c,
&Exp {
payload: Some(Some("R2hvc3R0eQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Wdata),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_6() {
let c = run("5522;type=wdata");
check(
&c,
&Exp {
payload: Some(None),
type_: Some(Operation::Wdata),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_7() {
let c = run("5522;type=write:status=DONE");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::DONE),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_8() {
let c = run("5522;type=write:status=EPERM");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::EPERM),
type_: Some(Operation::Write),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_9() {
let c = run("5522;type=walias:mime=dGV4dC9wbGFpbg==;dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=");
check(
&c,
&Exp {
payload: Some(Some("dGV4dC9odG1sIGFwcGxpY2F0aW9uL2pzb24=")),
mime: Some("dGV4dC9wbGFpbg=="),
type_: Some(Operation::Walias),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_10() {
let c = run("5522;type=read:status=OK:password=Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(None),
password: Some("Qk9CUiBLVVJXQQ=="),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_11() {
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==");
check(
&c,
&Exp {
payload: Some(None),
mime: Some("dGV4dC9wbGFpbg=="),
status: Some(Status::DATA),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_12() {
let c = run("5522;type=read:mime=dGV4dC9wbGFpbg==:password=Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(None),
mime: Some("dGV4dC9wbGFpbg=="),
password: Some("Qk9CUiBLVVJXQQ=="),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_13() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_14() {
let c = run("5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;Qk9CUiBLVVJXQQ==");
check(
&c,
&Exp {
payload: Some(Some("Qk9CUiBLVVJXQQ==")),
mime: Some("dGV4dC9wbGFpbg=="),
status: Some(Status::DATA),
type_: Some(Operation::Read),
..Default::default()
},
);
}
#[test]
fn osc_5522_example_15() {
let c = run("5522;type=read:status=OK");
check(
&c,
&Exp {
payload: Some(None),
status: Some(Status::OK),
type_: Some(Operation::Read),
..Default::default()
},
);
}
}
@@ -0,0 +1,197 @@
//! OSC 21, the Kitty Color Protocol (ZIG/src/terminal/osc/parsers/kitty_color.zig).
use crate::color::Rgb;
use crate::kitty::color::{Kind, OSC, Request};
use crate::osc::{Command, Parser, State, Terminator};
/// Parse OSC 21, the Kitty Color Protocol.
pub fn parse(parser: &mut Parser, terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S21);
if !parser.alloc {
parser.state = State::Invalid;
return None;
}
if !parser.has_writer() {
parser.state = State::Invalid;
return None;
}
let data = parser.buffered()?;
let mut osc = OSC {
list: Vec::new(),
terminator: Terminator::init(terminator_ch),
};
for kv in data.split(|&c| c == b';') {
if osc.list.len() >= Kind::MAX * 2 {
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
parser.state = State::Invalid;
return None;
}
let (k, rest) = match kv.iter().position(|&c| c == b'=') {
Some(p) => (&kv[..p], &kv[p + 1..]),
None => (kv, &kv[kv.len()..]),
};
if k.is_empty() {
log::warn!("zero length key in kitty color protocol");
continue;
}
let Some(key) = Kind::parse(k) else {
log::warn!(
"unknown key in kitty color protocol: {}",
String::from_utf8_lossy(k)
);
continue;
};
let value = rest.trim_ascii_matching(b' ');
if value.is_empty() {
osc.list.push(Request::Reset(key));
} else if value == b"?" {
osc.list.push(Request::Query(key));
} else if let Ok(color) = Rgb::parse_bytes(value) {
osc.list.push(Request::Set { key, color });
} else {
log::warn!(
"invalid color format in kitty color protocol: {}",
String::from_utf8_lossy(value)
);
}
}
Some(Command::KittyColorProtocol(osc))
}
trait TrimMatching {
fn trim_ascii_matching(&self, c: u8) -> &[u8];
}
impl TrimMatching for [u8] {
fn trim_ascii_matching(&self, c: u8) -> &[u8] {
let mut s = self;
while let [first, rest @ ..] = s {
if *first != c {
break;
}
s = rest;
}
while let [rest @ .., last] = s {
if *last != c {
break;
}
s = rest;
}
s
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::kitty::color::Special;
const INPUT: &str = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0";
fn feed(p: &mut Parser, s: &str) {
for ch in s.bytes() {
p.next(ch);
}
}
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
#[test]
fn osc_21_kitty_color_protocol() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
let list = osc.list;
assert_eq!(9, list.len());
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
assert_eq!(
Request::Set {
key: Kind::Special(Special::Background),
color: rgb(0xf0, 0xf8, 0xff)
},
list[1]
);
assert_eq!(
Request::Set {
key: Kind::Special(Special::Cursor),
color: rgb(0xf0, 0xf8, 0xff)
},
list[2]
);
assert_eq!(Request::Reset(Kind::Special(Special::CursorText)), list[3]);
assert_eq!(Request::Reset(Kind::Special(Special::VisualBell)), list[4]);
assert_eq!(
Request::Query(Kind::Special(Special::SelectionBackground)),
list[5]
);
assert_eq!(
Request::Set {
key: Kind::Special(Special::SelectionBackground),
color: rgb(0xaa, 0xbb, 0xcc)
},
list[6]
);
assert_eq!(Request::Query(Kind::Palette(2)), list[7]);
assert_eq!(
Request::Set {
key: Kind::Palette(3),
color: rgb(0xff, 0xff, 0xff)
},
list[8]
);
}
#[test]
fn osc_21_kitty_color_protocol_without_allocator() {
let mut p = Parser::init(false);
feed(&mut p, "21;foreground=?");
assert!(p.end(Some(0x1b)).is_none());
}
#[test]
fn osc_21_kitty_color_protocol_double_reset() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
p.reset();
p.reset();
}
#[test]
fn osc_21_kitty_color_protocol_reset_after_invalid() {
let mut p = Parser::init(true);
feed(&mut p, INPUT);
let cmd = p.end(Some(0x1b)).unwrap();
assert!(matches!(cmd, Command::KittyColorProtocol(_)));
p.reset();
assert_eq!(State::Start, p.state);
p.next(b'X');
assert_eq!(State::Invalid, p.state);
p.reset();
}
#[test]
fn osc_21_kitty_color_protocol_no_key() {
let mut p = Parser::init(true);
feed(&mut p, "21;");
let cmd = p.end(Some(0x1b)).unwrap();
let Command::KittyColorProtocol(osc) = cmd else {
panic!("wrong command");
};
assert_eq!(0, osc.list.len());
}
}
@@ -0,0 +1,266 @@
//! Kitty's text sizing protocol (OSC 66)
//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/
//! (ZIG/src/terminal/osc/parsers/kitty_text_sizing.zig)
use crate::osc::{Command, Parser, State, encoding};
pub const MAX_PAYLOAD_LENGTH: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VAlign {
#[default]
Top,
Bottom,
Center,
}
impl VAlign {
fn from_int(v: u8) -> Option<VAlign> {
match v {
0 => Some(VAlign::Top),
1 => Some(VAlign::Bottom),
2 => Some(VAlign::Center),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HAlign {
#[default]
Left,
Right,
Center,
}
impl HAlign {
fn from_int(v: u8) -> Option<HAlign> {
match v {
0 => Some(HAlign::Left),
1 => Some(HAlign::Right),
2 => Some(HAlign::Center),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OSC {
/// 1 - 7 (u3)
pub scale: u8,
/// 0 - 7 (u3), 0 means default
pub width: u8,
/// u4
pub numerator: u8,
/// u4
pub denominator: u8,
pub valign: VAlign,
pub halign: HAlign,
pub text: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateError {
UnknownKey,
InvalidValue,
}
/// Equivalent of Zig `std.fmt.parseInt(u4, value, 10)`.
fn parse_u4(value: &[u8]) -> Option<u8> {
let (neg, digits) = match value.first() {
Some(b'+') => (false, &value[1..]),
Some(b'-') => (true, &value[1..]),
_ => (false, value),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
if !c.is_ascii_digit() {
return None;
}
acc = acc * 10 + (c - b'0') as u32;
if acc > 15 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u8)
}
impl OSC {
fn update(&mut self, key: u8, value: &[u8]) -> Result<(), UpdateError> {
// All values are numeric, so we can do a small hack here
let v = parse_u4(value).ok_or(UpdateError::InvalidValue)?;
match key {
b's' => {
if v == 0 || v > 7 {
return Err(UpdateError::InvalidValue);
}
self.scale = v;
}
b'w' => {
if v > 7 {
return Err(UpdateError::InvalidValue);
}
self.width = v;
}
b'n' => self.numerator = v,
b'd' => self.denominator = v,
b'v' => self.valign = VAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
b'h' => self.halign = HAlign::from_int(v).ok_or(UpdateError::InvalidValue)?,
_ => return Err(UpdateError::UnknownKey),
}
Ok(())
}
}
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S66);
let data = parser.data()?;
let Some(payload_start) = data.iter().position(|&c| c == b';') else {
log::warn!("missing semicolon before payload");
parser.state = State::Invalid;
return None;
};
let payload = &data[payload_start + 1..];
// Payload has to be a URL-safe UTF-8 string,
// and be under the size limit.
if payload.len() > MAX_PAYLOAD_LENGTH {
log::warn!("payload is too long");
parser.state = State::Invalid;
return None;
}
if !encoding::is_safe_utf8(payload) {
log::warn!("payload is not escape code safe UTF-8");
parser.state = State::Invalid;
return None;
}
let mut cmd = OSC {
scale: 1,
width: 0,
numerator: 0,
denominator: 0,
valign: VAlign::Top,
halign: HAlign::Left,
text: payload.to_vec(),
};
// Parse any arguments if given
if payload_start > 0 {
for kv in data[..payload_start].split(|&c| c == b':') {
let mut it = kv.split(|&c| c == b'=');
let Some(k) = it.next() else {
log::warn!("missing key");
continue;
};
if k.len() != 1 {
log::warn!("key must be a single character");
continue;
}
let Some(value) = it.next() else {
log::warn!("missing value");
continue;
};
if let Err(err) = cmd.update(k[0], value) {
match err {
UpdateError::UnknownKey => log::warn!("unknown key: '{}'", k[0] as char),
UpdateError::InvalidValue => {
log::warn!("invalid value for key '{}': {:?}", k[0] as char, err);
}
}
}
}
}
Some(Command::KittyTextSizing(cmd))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn run(input: &[u8]) -> Option<Command> {
let mut p = Parser::init(false);
for &ch in input {
p.next(ch);
}
p.end(Some(0x1b))
}
fn sizing(input: &[u8]) -> OSC {
match run(input) {
Some(Command::KittyTextSizing(o)) => o,
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn osc_66_empty_parameters() {
let c = sizing(b"66;;bobr");
assert_eq!(1, c.scale);
assert_eq!(b"bobr", &c.text[..]);
}
#[test]
fn osc_66_single_parameter() {
let c = sizing(b"66;s=2;kurwa");
assert_eq!(2, c.scale);
assert_eq!(b"kurwa", &c.text[..]);
}
#[test]
fn osc_66_multiple_parameters() {
let c = sizing(b"66;s=2:w=7:n=13:d=15:v=1:h=2;long");
assert_eq!(2, c.scale);
assert_eq!(7, c.width);
assert_eq!(13, c.numerator);
assert_eq!(15, c.denominator);
assert_eq!(VAlign::Bottom, c.valign);
assert_eq!(HAlign::Center, c.halign);
assert_eq!(b"long", &c.text[..]);
}
#[test]
fn osc_66_scale_is_zero() {
let c = sizing(b"66;s=0;nope");
assert_eq!(1, c.scale);
}
#[test]
fn osc_66_invalid_parameters() {
let c = sizing(b"66;w=8:v=3:n=16;");
assert_eq!(0, c.width);
assert_eq!(VAlign::Top, c.valign);
assert_eq!(0, c.numerator);
}
#[test]
fn osc_66_utf8() {
let c = sizing("66;;👻魑魅魍魉ゴースッティ".as_bytes());
assert_eq!("👻魑魅魍魉ゴースッティ".as_bytes(), &c.text[..]);
}
#[test]
fn osc_66_unsafe_utf8() {
assert!(run(b"66;;\n").is_none());
}
#[test]
fn osc_66_overlong_utf8() {
let mut input = b"66;;".to_vec();
input.extend(b"bobr".repeat(1025));
assert!(run(&input).is_none());
}
}
+17
View File
@@ -0,0 +1,17 @@
//! OSC parsers (ZIG/src/terminal/osc/parsers.zig).
pub mod change_window_icon;
pub mod change_window_title;
pub mod clipboard_operation;
pub mod color;
pub mod context_signal;
pub mod hyperlink;
pub mod iterm2;
pub mod kitty_clipboard_protocol;
pub mod kitty_color;
pub mod kitty_text_sizing;
pub mod mouse_shape;
pub mod osc9;
pub mod report_pwd;
pub mod rxvt_extension;
pub mod semantic_prompt;
@@ -0,0 +1,29 @@
//! OSC 22 (ZIG/src/terminal/osc/parsers/mouse_shape.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 22
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
debug_assert_eq!(parser.state, State::S22);
let data = parser.data()?;
Some(Command::MouseShape { value: data })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_22_pointer_cursor() {
let mut p = Parser::init(false);
for c in b"22;pointer" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::MouseShape {
value: b"pointer".to_vec()
}
);
}
}
+557
View File
@@ -0,0 +1,557 @@
//! OSC 9 (iTerm2 notification / ConEmu extensions)
//! (ZIG/src/terminal/osc/parsers/osc9.zig).
use super::semantic_prompt;
use crate::osc::{Command, ConemuChangeTabTitle, Parser, ProgressReport, ProgressState};
/// Parse a base-10 unsigned integer from raw bytes (Zig `parseUnsigned`).
fn parse_unsigned<T: std::str::FromStr>(bytes: &[u8]) -> Option<T> {
std::str::from_utf8(bytes).ok()?.parse().ok()
}
/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension.
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
// Zig prologue: writer must exist. (The NUL-terminator capacity check is
// folded into `data()`.)
let data = parser.data()?;
// Check first to see if this is a ConEmu OSC
// https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
if let Some(cmd) = conemu(&data) {
return Some(cmd);
}
// If it's not a ConEmu OSC, it's an iTerm2 notification
Some(Command::ShowDesktopNotification {
title: Vec::new(),
body: data,
})
}
/// Returns None where the Zig code does `break :conemu`.
fn conemu(data: &[u8]) -> Option<Command> {
if data.is_empty() {
return None;
}
let msg = |skip: usize| data[skip..].to_vec();
match data[0] {
// Check for OSC 9;1 9;10 9;11 9;12
b'1' => {
if data.len() < 2 {
return None;
}
match data[1] {
// OSC 9;1 sleep
b';' => Some(Command::ConemuSleep {
duration_ms: match parse_unsigned::<u16>(&data[2..]) {
Some(num) => num.min(10_000),
None => 100,
},
}),
// OSC 9;10 xterm keyboard and output emulation
b'0' => {
if data.len() == 2 {
return Some(Command::ConemuXtermEmulation {
keyboard: Some(true),
output: Some(true),
});
}
if data.len() < 4 {
return None;
}
if data[2] != b';' {
return None;
}
let (keyboard, output) = match data[3] {
b'0' => (Some(false), Some(false)),
b'1' => (Some(true), Some(true)),
b'2' => (None, Some(false)),
b'3' => (None, Some(true)),
_ => return None,
};
Some(Command::ConemuXtermEmulation { keyboard, output })
}
// OSC 9;11 comment
b'1' => {
if data.len() < 3 {
return None;
}
if data[2] != b';' {
return None;
}
Some(Command::ConemuComment(msg(3)))
}
// OSC 9;12 mark prompt start
b'2' => Some(Command::SemanticPrompt(semantic_prompt::Command::init(
semantic_prompt::Action::FreshLineNewPrompt,
))),
_ => None,
}
}
// OSC 9;2 show message box
b'2' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuShowMessageBox(msg(2)))
}
// OSC 9;3 change tab title
b'3' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
if data.len() == 2 {
return Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset));
}
Some(Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(
msg(2),
)))
}
// OSC 9;4 progress report
b'4' => {
if data.len() < 2 || data[1] != b';' || data.len() < 3 {
return None;
}
let mut report = match data[2] {
b'0' => ProgressReport {
state: ProgressState::Remove,
progress: None,
},
b'1' => ProgressReport {
state: ProgressState::Set,
progress: Some(0),
},
b'2' => ProgressReport {
state: ProgressState::Error,
progress: None,
},
b'3' => ProgressReport {
state: ProgressState::Indeterminate,
progress: None,
},
b'4' => ProgressReport {
state: ProgressState::Pause,
progress: None,
},
_ => return None,
};
match report.state {
ProgressState::Remove | ProgressState::Indeterminate => {}
ProgressState::Set | ProgressState::Error | ProgressState::Pause => {
if data.len() >= 4 && data[3] == b';' {
// parse the progress value
report.progress =
parse_unsigned::<usize>(&data[4..]).map(|v| v.min(100) as u8);
}
}
}
Some(Command::ConemuProgressReport(report))
}
// OSC 9;5 wait for input
b'5' => Some(Command::ConemuWaitInput),
// OSC 9;6 guimacro
b'6' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuGuimacro(msg(2)))
}
// OSC 9;7 run process
b'7' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuRunProcess(msg(2)))
}
// OSC 9;8 output environment variable
b'8' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ConemuOutputEnvironmentVariable(msg(2)))
}
// OSC 9;9 current working directory
b'9' => {
if data.len() < 2 || data[1] != b';' {
return None;
}
Some(Command::ReportPwd { value: msg(2) })
}
_ => None,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Command {
let mut p = Parser::init(false);
for ch in input.bytes() {
p.next(ch);
}
p.end(Some(0x1b)).unwrap()
}
fn notif(input: &str, body: &str) {
match run(input) {
Command::ShowDesktopNotification { title, body: b } => {
assert_eq!(title, b"");
assert_eq!(b, body.as_bytes());
}
c => panic!("unexpected {c:?}"),
}
}
fn progress(input: &str, state: ProgressState, progress: Option<u8>) {
assert_eq!(
run(input),
Command::ConemuProgressReport(ProgressReport { state, progress })
);
}
fn xterm(input: &str, keyboard: Option<bool>, output: Option<bool>) {
assert_eq!(
run(input),
Command::ConemuXtermEmulation { keyboard, output }
);
}
#[test]
fn osc_9_show_desktop_notification() {
notif("9;Hello world", "Hello world");
}
#[test]
fn osc_9_show_single_character_desktop_notification() {
notif("9;H", "H");
}
#[test]
fn osc_9_1_conemu_sleep() {
assert_eq!(run("9;1;420"), Command::ConemuSleep { duration_ms: 420 });
}
#[test]
fn osc_9_1_conemu_sleep_with_no_value_default_to_100ms() {
assert_eq!(run("9;1;"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_cannot_exceed_10000ms() {
assert_eq!(
run("9;1;12345"),
Command::ConemuSleep { duration_ms: 10000 }
);
}
#[test]
fn osc_9_1_conemu_sleep_invalid_input() {
assert_eq!(run("9;1;foo"), Command::ConemuSleep { duration_ms: 100 });
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_1() {
notif("9;1", "1");
}
#[test]
fn osc_9_1_conemu_sleep_desktop_notification_2() {
notif("9;1a", "1a");
}
#[test]
fn osc_9_2_conemu_message_box() {
assert_eq!(
run("9;2;hello world"),
Command::ConemuShowMessageBox(b"hello world".to_vec())
);
}
#[test]
fn osc_9_2_conemu_message_box_invalid_input() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_conemu_message_box_empty_message() {
assert_eq!(run("9;2;"), Command::ConemuShowMessageBox(Vec::new()));
}
#[test]
fn osc_9_2_conemu_message_box_spaces_only_message() {
assert_eq!(
run("9;2; "),
Command::ConemuShowMessageBox(b" ".to_vec())
);
}
#[test]
fn osc_9_2_message_box_desktop_notification_1() {
notif("9;2", "2");
}
#[test]
fn osc_9_2_message_box_desktop_notification_2() {
notif("9;2a", "2a");
}
#[test]
fn osc_9_3_conemu_change_tab_title() {
assert_eq!(
run("9;3;foo bar"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b"foo bar".to_vec()))
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_reset() {
assert_eq!(
run("9;3;"),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Reset)
);
}
#[test]
fn osc_9_3_conemu_change_tab_title_spaces_only() {
assert_eq!(
run("9;3; "),
Command::ConemuChangeTabTitle(ConemuChangeTabTitle::Value(b" ".to_vec()))
);
}
#[test]
fn osc_9_3_change_tab_title_desktop_notification_1() {
notif("9;3", "3");
}
#[test]
fn osc_9_3_message_box_desktop_notification_2() {
notif("9;3a", "3a");
}
#[test]
fn osc_9_4_conemu_progress_set() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_overflow() {
progress("9;4;1;900", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_set_single_digit() {
progress("9;4;1;9", ProgressState::Set, Some(9));
}
#[test]
fn osc_9_4_conemu_progress_set_double_digit() {
progress("9;4;1;94", ProgressState::Set, Some(94));
}
#[test]
fn osc_9_4_conemu_progress_set_extra_semicolon_ignored() {
progress("9;4;1;100", ProgressState::Set, Some(100));
}
#[test]
fn osc_9_4_conemu_progress_remove_with_no_progress() {
progress("9;4;0;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_with_double_semicolon() {
progress("9;4;0;;", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_ignores_progress() {
progress("9;4;0;100", ProgressState::Remove, None);
}
#[test]
fn osc_9_4_conemu_progress_remove_extra_semicolon() {
match run("9;4;0;100;") {
Command::ConemuProgressReport(r) => assert_eq!(r.state, ProgressState::Remove),
c => panic!("unexpected {c:?}"),
}
}
#[test]
fn osc_9_4_conemu_progress_error() {
progress("9;4;2", ProgressState::Error, None);
}
#[test]
fn osc_9_4_conemu_progress_error_with_progress() {
progress("9;4;2;100", ProgressState::Error, Some(100));
}
#[test]
fn osc_9_4_progress_pause() {
progress("9;4;4", ProgressState::Pause, None);
}
#[test]
fn osc_9_4_conemu_progress_pause_with_progress() {
progress("9;4;4;100", ProgressState::Pause, Some(100));
}
#[test]
fn osc_9_4_progress_desktop_notification_1() {
notif("9;4", "4");
}
#[test]
fn osc_9_4_progress_desktop_notification_2() {
notif("9;4;", "4;");
}
#[test]
fn osc_9_4_progress_desktop_notification_3() {
notif("9;4;5", "4;5");
}
#[test]
fn osc_9_4_progress_desktop_notification_4() {
notif("9;4;5a", "4;5a");
}
#[test]
fn osc_9_5_conemu_wait_input() {
assert_eq!(run("9;5"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_5_conemu_wait_ignores_trailing_characters() {
assert_eq!(run("9;5;foo"), Command::ConemuWaitInput);
}
#[test]
fn osc_9_6_conemu_guimacro_1() {
assert_eq!(run("9;6;a"), Command::ConemuGuimacro(b"a".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_2() {
assert_eq!(run("9;6;ab"), Command::ConemuGuimacro(b"ab".to_vec()));
}
#[test]
fn osc_9_6_conemu_guimacro_3_incomplete_desktop_notification() {
notif("9;6", "6");
}
#[test]
fn osc_9_7_conemu_run_process_1() {
assert_eq!(run("9;7;ab"), Command::ConemuRunProcess(b"ab".to_vec()));
}
#[test]
fn osc_9_7_conemu_run_process_2() {
assert_eq!(run("9;7;"), Command::ConemuRunProcess(Vec::new()));
}
#[test]
fn osc_9_7_conemu_run_process_incomplete_desktop_notification() {
notif("9;7", "7");
}
#[test]
fn osc_9_8_conemu_output_environment_variable_1() {
assert_eq!(
run("9;8;ab"),
Command::ConemuOutputEnvironmentVariable(b"ab".to_vec())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_2() {
assert_eq!(
run("9;8;"),
Command::ConemuOutputEnvironmentVariable(Vec::new())
);
}
#[test]
fn osc_9_8_conemu_output_environment_variable_incomplete_desktop_notification() {
notif("9;8", "8");
}
#[test]
fn osc_9_9_conemu_set_current_working_directory() {
assert_eq!(
run("9;9;ab"),
Command::ReportPwd {
value: b"ab".to_vec()
}
);
}
#[test]
fn osc_9_9_conemu_set_current_working_directory_incomplete_desktop_notification() {
notif("9;9", "9");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_1() {
xterm("9;10", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_2() {
xterm("9;10;0", Some(false), Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_3() {
xterm("9;10;1", Some(true), Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_4() {
xterm("9;10;2", None, Some(false));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_5() {
xterm("9;10;3", None, Some(true));
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_6() {
notif("9;10;4", "10;4");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_7() {
notif("9;10;", "10;");
}
#[test]
fn osc_9_10_conemu_xterm_keyboard_and_output_emulation_8() {
notif("9;10;abc", "10;abc");
}
#[test]
fn osc_9_11_conemu_comment() {
assert_eq!(run("9;11;ab"), Command::ConemuComment(b"ab".to_vec()));
}
#[test]
fn osc_9_11_conemu_comment_incomplete_desktop_notification() {
notif("9;11", "11");
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_1() {
assert!(matches!(run("9;12"), Command::SemanticPrompt(_)));
}
#[test]
fn osc_9_12_conemu_mark_prompt_start_2() {
assert!(matches!(run("9;12;abc"), Command::SemanticPrompt(_)));
}
}
@@ -0,0 +1,40 @@
//! OSC 7 (ZIG/src/terminal/osc/parsers/report_pwd.zig).
use crate::osc::{Command, Parser};
/// Parse OSC 7
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
Some(Command::ReportPwd { value: data })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_7_report_pwd() {
let mut p = Parser::init(false);
for c in b"7;file:///tmp/example" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::ReportPwd {
value: b"file:///tmp/example".to_vec()
}
);
}
#[test]
fn osc_7_report_pwd_empty() {
let mut p = Parser::init(false);
for c in b"7;" {
p.next(*c);
}
assert_eq!(
p.end(None).unwrap(),
Command::ReportPwd { value: Vec::new() }
);
}
}
@@ -0,0 +1,51 @@
//! OSC 777 (ZIG/src/terminal/osc/parsers/rxvt_extension.zig).
use crate::osc::{Command, Parser, State};
/// Parse OSC 777
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<Command> {
let data = parser.data()?;
let Some(k) = data.iter().position(|&b| b == b';') else {
parser.state = State::Invalid;
return None;
};
let ext = &data[..k];
if ext != b"notify" {
log::warn!("unknown rxvt extension: {}", String::from_utf8_lossy(ext));
parser.state = State::Invalid;
return None;
}
let Some(t) = data[k + 1..]
.iter()
.position(|&b| b == b';')
.map(|i| i + k + 1)
else {
log::warn!("rxvt notify extension is missing the title");
parser.state = State::Invalid;
return None;
};
Some(Command::ShowDesktopNotification {
title: data[k + 1..t].to_vec(),
body: data[t + 1..].to_vec(),
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn osc_777_show_desktop_notification_with_title() {
let mut p = Parser::init(false);
for c in b"777;notify;Title;Body" {
p.next(*c);
}
assert_eq!(
p.end(Some(0x1b)).unwrap(),
Command::ShowDesktopNotification {
title: b"Title".to_vec(),
body: b"Body".to_vec()
}
);
}
}
@@ -0,0 +1,998 @@
//! OSC 133 semantic prompts
//! (ZIG/src/terminal/osc/parsers/semantic_prompt.zig).
//!
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
use crate::osc::{Command as OscCommand, Parser, State};
/// Error returned when decoding a command line fails (Zig
/// `error.DecodeError`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DecodeError;
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("decode error")
}
}
impl std::error::Error for DecodeError {}
/// A single semantic prompt command.
///
/// Technically according to the spec, not all commands have options
/// but it is easier to be "liberal in what we accept" here since
/// all except one do and the spec does also say to ignore unknown
/// options. So, I think this is a fair interpretation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub action: Action,
pub options_unvalidated: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
FreshLine, // 'L'
FreshLineNewPrompt, // 'A'
NewCommand, // 'N'
PromptStart, // 'P'
EndPromptStartInput, // 'B'
EndPromptStartInputTerminateEol, // 'I'
EndInputStartOutput, // 'C'
EndCommand, // 'D'
}
impl Command {
pub fn init(action: Action) -> Command {
Command {
action,
options_unvalidated: Vec::new(),
}
}
/// Read an option for this command. Returns None if unset or invalid.
pub fn read_option(&self, option: OptionKey) -> Option<OptionValue<'_>> {
option.read(&self.options_unvalidated)
}
/// Typed convenience: `aid` option.
pub fn aid(&self) -> Option<&[u8]> {
match self.read_option(OptionKey::Aid)? {
OptionValue::Bytes(b) => Some(b),
_ => None,
}
}
/// Typed convenience: `cl` option.
pub fn cl(&self) -> Option<Click> {
match self.read_option(OptionKey::Cl)? {
OptionValue::Click(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `k` option.
pub fn prompt_kind(&self) -> Option<PromptKind> {
match self.read_option(OptionKey::PromptKind)? {
OptionValue::PromptKind(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `err` option.
pub fn err(&self) -> Option<&[u8]> {
match self.read_option(OptionKey::Err)? {
OptionValue::Bytes(b) => Some(b),
_ => None,
}
}
/// Typed convenience: `redraw` option.
pub fn redraw(&self) -> Option<Redraw> {
match self.read_option(OptionKey::Redraw)? {
OptionValue::Redraw(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `special_key` option.
pub fn special_key(&self) -> Option<bool> {
match self.read_option(OptionKey::SpecialKey)? {
OptionValue::Bool(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `click_events` option.
pub fn click_events(&self) -> Option<bool> {
match self.read_option(OptionKey::ClickEvents)? {
OptionValue::Bool(c) => Some(c),
_ => None,
}
}
/// Typed convenience: `exit_code` (first positional value of 'D').
pub fn exit_code(&self) -> Option<i32> {
match self.read_option(OptionKey::ExitCode)? {
OptionValue::Int(c) => Some(c),
_ => None,
}
}
/// Write the decoded command line (if any) to the writer. If an error
/// occurs garbage may have been written to the writer.
///
/// TODO(integration): uses local copies of `printfQDecode` /
/// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to
/// `ghostty-os` once available (that crate would need to be a dependency
/// of ghostty-vt, so these remain local for now).
pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
return printf_q_decode(writer, command_line);
}
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::CmdlineUrl) {
return url_percent_decode(writer, command_line);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionKey {
Aid,
Cl,
PromptKind,
Err,
Cmdline,
CmdlineUrl,
// https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
// Kitty supports a "redraw" option for prompt_start. This is extended
// by Ghostty with the "last" option. See Redraw the type for more details.
Redraw,
// Use a special key instead of arrow keys to move the cursor on
// mouse click. Useful if arrow keys have side-effets like triggering
// auto-complete. The shell integration script should bind the special
// key as needed.
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
SpecialKey,
// If true, the shell is capable of handling mouse click events.
// Ghostty will then send a click event to the shell when the user
// clicks somewhere in the prompt. The shell can then move the cursor
// to that position or perform some other appropriate action. If false,
// Ghostty may generate a number of fake key events to move the cursor
// which is not very robust.
// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
ClickEvents,
// Not technically an option that can be set with k=v and only
// present currently with command 'D' but its easier to just
// parse it into our options.
ExitCode,
}
/// The value of an option; the variant is determined by the `Option` (Zig
/// `Option.Type()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionValue<'a> {
Bytes(&'a [u8]),
Click(Click),
PromptKind(PromptKind),
Redraw(Redraw),
Bool(bool),
Int(i32),
}
impl OptionKey {
fn key(self) -> &'static [u8] {
match self {
OptionKey::Aid => b"aid",
OptionKey::Cl => b"cl",
OptionKey::PromptKind => b"k",
OptionKey::Err => b"err",
OptionKey::Redraw => b"redraw",
OptionKey::SpecialKey => b"special_key",
OptionKey::ClickEvents => b"click_events",
OptionKey::Cmdline => b"cmdline",
OptionKey::CmdlineUrl => b"cmdline_url",
// special case, handled before ever calling key
OptionKey::ExitCode => unreachable!(),
}
}
/// Read the option value from the raw options string.
///
/// The raw options string is the raw unparsed data after the
/// OSC 133 command. e.g. for `133;A;aid=14;cl=line`, the
/// raw options string would be `aid=14;cl=line`.
///
/// Any errors in the raw string will return None since the OSC133
/// specification says to ignore unknown or malformed options.
pub fn read(self, raw: &[u8]) -> Option<OptionValue<'_>> {
let mut remaining = raw;
'outer: while !remaining.is_empty() {
// Length of the next value is up to the `;` or the
// end of the string.
let len = remaining
.iter()
.position(|&b| b == b';')
.unwrap_or(remaining.len());
// Grab our full value and move our cursor past the `;`
let full = &remaining[..len];
// If we're looking for exit_code we special case it.
// as the first value.
if self == OptionKey::ExitCode {
return std::str::from_utf8(full)
.ok()
.and_then(|s| s.parse::<i32>().ok())
.map(OptionValue::Int);
}
// Parse our key=value and verify our key matches our
// expectation.
let value = 'value: {
if let Some(eql_idx) = full
.iter()
.position(|&b| b == b'=')
.filter(|&i| full[..i] == *self.key())
{
break 'value &full[eql_idx + 1..];
}
// No match!
if len < remaining.len() {
remaining = &remaining[len + 1..];
continue 'outer;
}
break 'outer;
};
return match self {
OptionKey::Aid | OptionKey::Err | OptionKey::Cmdline | OptionKey::CmdlineUrl => {
Some(OptionValue::Bytes(value))
}
OptionKey::Cl => Click::init(value).map(OptionValue::Click),
OptionKey::PromptKind => {
if value.len() == 1 {
PromptKind::init(value[0]).map(OptionValue::PromptKind)
} else {
None
}
}
OptionKey::Redraw => match value {
b"0" => Some(OptionValue::Redraw(Redraw::False)),
b"1" => Some(OptionValue::Redraw(Redraw::True)),
b"last" => Some(OptionValue::Redraw(Redraw::Last)),
_ => None,
},
OptionKey::SpecialKey | OptionKey::ClickEvents => match value {
b"0" => Some(OptionValue::Bool(false)),
b"1" => Some(OptionValue::Bool(true)),
_ => None,
},
// Handled above
OptionKey::ExitCode => unreachable!(),
};
}
// Not found
None
}
}
/// The `cl` option specifies what kind of cursor key sequences are handled
/// by the application for click-to-move-cursor functionality.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Click {
/// Value: "line". Allows motion within a single input line using standard
/// left/right arrow escape sequences. Only a single left/right sequence
/// should be emitted for double-width characters.
Line,
/// Value: "m". Allows movement between different lines in the same group,
/// but only using left/right arrow escape sequences.
Multiple,
/// Value: "v". Like `multiple` but cursor up/down should be used. The
/// terminal should be conservative when moving between lines: move the
/// cursor left to the start of line, emit the needed up/down sequences,
/// then move the cursor right to the clicked destination.
ConservativeVertical,
/// Value: "w". Like `conservative_vertical` but specifies that there are
/// no spurious spaces at the end of the line, and the application editor
/// handles "smart vertical movement" (moving 2 lines up from position 20,
/// where the intermediate line is 15 chars wide and the destination is
/// 18 chars wide, ends at position 18).
SmartVertical,
}
impl Click {
pub fn init(value: &[u8]) -> Option<Click> {
if value.len() == 1 {
match value[0] {
b'm' => Some(Click::Multiple),
b'v' => Some(Click::ConservativeVertical),
b'w' => Some(Click::SmartVertical),
_ => None,
}
} else if value == b"line" {
Some(Click::Line)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
Initial,
Right,
Continuation,
Secondary,
}
impl PromptKind {
pub fn init(c: u8) -> Option<PromptKind> {
match c {
b'i' => Some(PromptKind::Initial),
b'r' => Some(PromptKind::Right),
b'c' => Some(PromptKind::Continuation),
b's' => Some(PromptKind::Secondary),
_ => None,
}
}
}
/// The values for the `redraw` extension to OSC133. This was
/// started by Kitty[1] and extended by Ghostty (the "last" option).
///
/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Redraw {
/// The shell supports redrawing the full prompt and all continuations.
/// This is the default value, it does not need to be explicitly set
/// unless it is to reset a prior other value.
True,
/// The shell does NOT support redrawing. In this case, Ghostty will NOT
/// clear any prompt lines on resize.
False,
/// The shell supports redrawing only the LAST line of the prompt.
/// Ghostty will only clear the last line of the prompt on resize.
///
/// This is specifically introduced because Bash only redraws the last
/// line. It is literally the only shell that does this and it does this
/// because its bad and they should feel bad. Don't be like Bash.
Last,
}
/// Parse OSC 133, semantic prompts
pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscCommand> {
let data = parser.buffered()?;
if data.is_empty() {
parser.state = State::Invalid;
return None;
}
// All valid cases terminate within this block. Any fallthroughs
// are invalid.
let action = match data[0] {
b'A' => Action::FreshLineNewPrompt,
b'B' => Action::EndPromptStartInput,
b'I' => Action::EndPromptStartInputTerminateEol,
b'C' => Action::EndInputStartOutput,
b'D' => Action::EndCommand,
b'N' => Action::NewCommand,
b'P' => Action::PromptStart,
b'L' => {
if data.len() > 1 {
parser.state = State::Invalid;
return None;
}
return Some(OscCommand::SemanticPrompt(Command::init(Action::FreshLine)));
}
_ => {
parser.state = State::Invalid;
return None;
}
};
let mut cmd = Command::init(action);
if data.len() > 1 {
if data[1] != b';' {
parser.state = State::Invalid;
return None;
}
cmd.options_unvalidated = data[2..].to_vec();
}
Some(OscCommand::SemanticPrompt(cmd))
}
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
/// Decode data that has been encoded in the same way that `bash`'s
/// `printf %q` encodes a string and write it to the writer. If an error is
/// returned garbage may have been written to the buffer.
pub fn printf_q_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(DecodeError);
}
match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c),
b'e' => writer.push(0x1b),
b'n' => writer.push(b'\n'),
b'r' => writer.push(b'\r'),
b't' => writer.push(b'\t'),
b'v' => writer.push(0x0b),
_ => return Err(DecodeError),
}
src += 2;
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
/// Decode data that has been URL percent encoded and write it to the given
/// buffer. If an error is returned garbage may have been written.
pub fn url_percent_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(DecodeError);
}
match (hex(buf[src + 1]), hex(buf[src + 2])) {
(Some(a), Some(b)) => {
writer.push((a << 4) | b);
src += 3;
}
_ => return Err(DecodeError),
}
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn run(input: &str) -> Option<Command> {
let mut p = Parser::init(false);
for ch in input.bytes() {
p.next(ch);
}
match p.end(None) {
Some(OscCommand::SemanticPrompt(c)) => Some(c),
Some(c) => panic!("unexpected {c:?}"),
None => None,
}
}
fn cmdline(input: &str) -> (Command, Result<Vec<u8>, DecodeError>) {
let cmd = run(input).unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
let mut w = Vec::new();
let r = cmd.write_command_line(&mut w).map(|()| w);
(cmd, r)
}
fn cmdline_ok(input: &str, expected: &str) {
assert_eq!(cmdline(input).1.unwrap(), expected.as_bytes());
}
fn cmdline_err(input: &str) {
assert_eq!(cmdline(input).1, Err(DecodeError));
}
#[test]
fn osc_133_end_input_start_output() {
let cmd = run("133;C").unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_end_input_start_output_extra_contents() {
assert!(run("133;Cextra").is_none());
}
#[test]
fn osc_133_end_input_start_output_with_options() {
let cmd = run("133;C;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndInputStartOutput);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline() {
cmdline_ok("133;C;cmdline=echo bobr kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_3() {
cmdline_ok("133;C;cmdline=echo bobr\\nkurwa", "echo bobr\nkurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_4() {
cmdline_ok("133;C;cmdline=$'echo bobr kurwa'", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_5() {
cmdline_ok("133;C;cmdline='echo bobr kurwa'", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_6() {
cmdline_err("133;C;cmdline='echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_7() {
cmdline_err("133;C;cmdline=$'echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_8() {
cmdline_err("133;C;cmdline=$'");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_9() {
cmdline_ok("133;C;cmdline=", "");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_1() {
cmdline_ok("133;C;cmdline_url=echo bobr kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_2() {
cmdline_ok("133;C;cmdline_url=echo bobr%20kurwa", "echo bobr kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_3() {
cmdline_ok("133;C;cmdline_url=echo bobr%3bkurwa", "echo bobr;kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_4() {
cmdline_err("133;C;cmdline_url=echo bobr%3kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_5() {
cmdline_err("133;C;cmdline_url=echo bobr%kurwa");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_6() {
cmdline_ok("133;C;cmdline_url=echo bobr kurwa%20", "echo bobr kurwa ");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_7() {
cmdline_err("133;C;cmdline_url=echo bobr kurwa%2");
}
#[test]
fn osc_133_end_input_start_output_with_cmdline_url_8() {
cmdline_err("133;C;cmdline_url=echo bobr kurwa%");
}
#[test]
fn osc_133_fresh_line() {
assert_eq!(run("133;L").unwrap().action, Action::FreshLine);
}
#[test]
fn osc_133_fresh_line_extra_contents() {
// Random
assert!(run("133;Lol").is_none());
// Options
assert!(run("133;L;aid=foo").is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt() {
let cmd = run("133;A").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_aid() {
let cmd = run("133;A;aid=14").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"14");
}
#[test]
fn osc_133_fresh_line_new_prompt_with_equals_in_aid() {
let cmd = run("133;A;aid=a=b").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"a=b");
}
#[test]
fn osc_133_fresh_line_new_prompt_with_cl_line() {
let cmd = run("133;A;cl=line").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_cl_m() {
let cmd = run("133;A;cl=m").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.cl(), Some(Click::Multiple));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_invalid_cl() {
let cmd = run("133;A;cl=invalid").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_trailing_semicolon() {
let cmd = run("133;A;").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
}
#[test]
fn osc_133_fresh_line_new_prompt_with_bare_key() {
let cmd = run("133;A;barekey").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_multiple_options() {
let cmd = run("133;A;aid=foo;cl=line").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_fresh_line_new_prompt_default_redraw() {
let cmd = run("133;A").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.redraw().is_none());
}
#[test]
fn osc_133_fresh_line_new_prompt_with_redraw_0() {
let cmd = run("133;A;redraw=0").unwrap();
assert_eq!(cmd.redraw(), Some(Redraw::False));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_redraw_1() {
let cmd = run("133;A;redraw=1").unwrap();
assert_eq!(cmd.redraw(), Some(Redraw::True));
}
#[test]
fn osc_133_fresh_line_new_prompt_with_invalid_redraw() {
let cmd = run("133;A;redraw=x").unwrap();
assert_eq!(cmd.action, Action::FreshLineNewPrompt);
assert!(cmd.redraw().is_none());
}
#[test]
fn osc_133_prompt_start() {
let cmd = run("133;P").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert!(cmd.prompt_kind().is_none());
}
#[test]
fn osc_133_prompt_start_with_k_i() {
let cmd = run("133;P;k=i").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Initial));
}
#[test]
fn osc_133_prompt_start_with_k_r() {
let cmd = run("133;P;k=r").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Right));
}
#[test]
fn osc_133_prompt_start_with_k_c() {
let cmd = run("133;P;k=c").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Continuation));
}
#[test]
fn osc_133_prompt_start_with_k_s() {
let cmd = run("133;P;k=s").unwrap();
assert_eq!(cmd.prompt_kind(), Some(PromptKind::Secondary));
}
#[test]
fn osc_133_prompt_start_with_invalid_k() {
let cmd = run("133;P;k=x").unwrap();
assert_eq!(cmd.action, Action::PromptStart);
assert!(cmd.prompt_kind().is_none());
}
#[test]
fn osc_133_prompt_start_extra_contents() {
assert!(run("133;Pextra").is_none());
}
#[test]
fn osc_133_new_command() {
let cmd = run("133;N").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert!(cmd.aid().is_none());
assert!(cmd.cl().is_none());
}
#[test]
fn osc_133_new_command_with_aid() {
let cmd = run("133;N;aid=foo").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_new_command_with_cl_line() {
let cmd = run("133;N;cl=line").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_new_command_with_multiple_options() {
let cmd = run("133;N;aid=foo;cl=line").unwrap();
assert_eq!(cmd.action, Action::NewCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.cl(), Some(Click::Line));
}
#[test]
fn osc_133_new_command_extra_contents() {
assert!(run("133;Nextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input() {
let cmd = run("133;B").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInput);
}
#[test]
fn osc_133_end_prompt_start_input_extra_contents() {
assert!(run("133;Bextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input_with_options() {
let cmd = run("133;B;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInput);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol() {
let cmd = run("133;I").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol_extra_contents() {
assert!(run("133;Iextra").is_none());
}
#[test]
fn osc_133_end_prompt_start_input_terminate_eol_with_options() {
let cmd = run("133;I;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndPromptStartInputTerminateEol);
assert_eq!(cmd.aid().unwrap(), b"foo");
}
#[test]
fn osc_133_end_command() {
let cmd = run("133;D").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert!(cmd.exit_code().is_none());
assert!(cmd.aid().is_none());
assert!(cmd.err().is_none());
}
#[test]
fn osc_133_end_command_extra_contents() {
assert!(run("133;Dextra").is_none());
}
#[test]
fn osc_133_end_command_with_exit_code_0() {
let cmd = run("133;D;0").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert_eq!(cmd.exit_code(), Some(0));
}
#[test]
fn osc_133_end_command_with_exit_code_and_aid() {
let cmd = run("133;D;12;aid=foo").unwrap();
assert_eq!(cmd.action, Action::EndCommand);
assert_eq!(cmd.aid().unwrap(), b"foo");
assert_eq!(cmd.exit_code(), Some(12));
}
fn bytes(v: Option<OptionValue<'_>>) -> Option<&[u8]> {
match v {
Some(OptionValue::Bytes(b)) => Some(b),
_ => None,
}
}
#[test]
fn option_read_aid() {
let r = |s: &'static str| bytes(OptionKey::Aid.read(s.as_bytes()));
assert_eq!(r("aid=test123").unwrap(), b"test123");
assert_eq!(r("cl=line;aid=myaid;k=i").unwrap(), b"myaid");
assert!(r("cl=line;k=i").is_none());
assert_eq!(r("aid=").unwrap(), b"");
assert_eq!(r("k=i;aid=last").unwrap(), b"last");
assert_eq!(r("aid=first;k=i").unwrap(), b"first");
assert!(r("").is_none());
assert!(r("aid").is_none());
assert_eq!(r(";;aid=value;;").unwrap(), b"value");
}
#[test]
fn option_read_cl() {
let r = |s: &'static str| OptionKey::Cl.read(s.as_bytes());
assert_eq!(r("cl=line"), Some(OptionValue::Click(Click::Line)));
assert_eq!(r("cl=m"), Some(OptionValue::Click(Click::Multiple)));
assert_eq!(
r("cl=v"),
Some(OptionValue::Click(Click::ConservativeVertical))
);
assert_eq!(r("cl=w"), Some(OptionValue::Click(Click::SmartVertical)));
assert!(r("cl=invalid").is_none());
assert!(r("aid=foo").is_none());
}
#[test]
fn option_read_prompt_kind() {
let r = |s: &'static str| OptionKey::PromptKind.read(s.as_bytes());
assert_eq!(r("k=i"), Some(OptionValue::PromptKind(PromptKind::Initial)));
assert_eq!(r("k=r"), Some(OptionValue::PromptKind(PromptKind::Right)));
assert_eq!(
r("k=c"),
Some(OptionValue::PromptKind(PromptKind::Continuation))
);
assert_eq!(
r("k=s"),
Some(OptionValue::PromptKind(PromptKind::Secondary))
);
assert!(r("k=x").is_none());
assert!(r("k=ii").is_none());
assert!(r("k=").is_none());
}
#[test]
fn option_read_err() {
assert_eq!(
bytes(OptionKey::Err.read(b"err=some_error")).unwrap(),
b"some_error"
);
assert!(OptionKey::Err.read(b"aid=foo").is_none());
}
#[test]
fn option_read_redraw() {
let r = |s: &'static str| OptionKey::Redraw.read(s.as_bytes());
assert_eq!(r("redraw=1"), Some(OptionValue::Redraw(Redraw::True)));
assert_eq!(r("redraw=0"), Some(OptionValue::Redraw(Redraw::False)));
assert_eq!(r("redraw=last"), Some(OptionValue::Redraw(Redraw::Last)));
assert!(r("redraw=2").is_none());
assert!(r("redraw=10").is_none());
assert!(r("redraw=").is_none());
}
#[test]
fn option_read_special_key() {
let r = |s: &'static str| OptionKey::SpecialKey.read(s.as_bytes());
assert_eq!(r("special_key=1"), Some(OptionValue::Bool(true)));
assert_eq!(r("special_key=0"), Some(OptionValue::Bool(false)));
assert!(r("special_key=x").is_none());
}
#[test]
fn option_read_click_events() {
let r = |s: &'static str| OptionKey::ClickEvents.read(s.as_bytes());
assert_eq!(r("click_events=1"), Some(OptionValue::Bool(true)));
assert_eq!(r("click_events=0"), Some(OptionValue::Bool(false)));
assert!(r("click_events=yes").is_none());
}
#[test]
fn option_read_exit_code() {
let r = |s: &'static str| OptionKey::ExitCode.read(s.as_bytes());
assert_eq!(r("42"), Some(OptionValue::Int(42)));
assert_eq!(r("0"), Some(OptionValue::Int(0)));
assert_eq!(r("-1"), Some(OptionValue::Int(-1)));
assert!(r("abc").is_none());
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127)));
}
#[test]
fn string_encoding_extra_printf_q_and_percent() {
let mut w = Vec::new();
printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap();
assert_eq!(w, b"bobr kurwa");
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"),
Err(DecodeError)
);
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"),
Err(DecodeError)
);
}
}