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 864ef82acb
commit b2b248e003
44 files changed
+15040 -6

No files matched your search

@@ -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)
);
}
}