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:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user