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,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());
}
}