Files
ghostty-rs/crates/ghostty-vt/src/utf8_decoder.rs
T
ddidderr 800fe5be5e feat(vt): port the stream layer between parser and handlers
Ports ZIG/src/terminal/stream.zig: the Stream feeds bytes through the
UTF-8 decoder and parser and dispatches every CSI/ESC/OSC/DCS/APC and
C0/C1 control to a Handler as a high-level Action (all 95 Zig
variants). Zig's comptime handler interface becomes a Handler trait with
an associated error type, a required vt() and an optional vt_raw() hook.

Performance: next_slice() processes 4 KiB chunks, finds ESC with memchr
and decodes printable runs in bulk (scalar port of ZIG/src/simd's
index_of / utf8_decode_until_control_seq), plus the CSI parameter fast
path.

Terminals must construct the stream with Stream::new_alloc so the OSC
parser accepts allocating commands (colors, OSC 133), as in Zig.

Minor differences: after a stray UTF-8 byte the rest of the chunk
returns to the fast path, and a spurious Zig warning after CSI 2/5 W is
dropped.

Test Plan:
- cargo test -p ghostty-vt (448 passed; 37/38 stream tests ported,
  the skipped one only exercises the C ABI)
2026-10-11 21:59:15 +02:00

164 lines
5.4 KiB
Rust

//! DFA-based non-allocating error-replacing UTF-8 decoder
//! (ZIG/src/terminal/UTF8Decoder.zig).
//!
//! This implementation is based largely on the excellent work of
//! Bjoern Hoehrmann, with slight modifications to support error-
//! replacement.
//!
//! For details on Bjoern's DFA-based UTF-8 decoder, see
//! http://bjoern.hoehrmann.de/utf-8/decoder/dfa (MIT licensed)
#[rustfmt::skip]
const CHAR_CLASSES: [u8; 256] = [
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8,
];
#[rustfmt::skip]
const TRANSITIONS: [u8; 108] = [
0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
12,36,12,12,12,12,12,12,12,12,12,12,
];
// DFA states
const ACCEPT_STATE: u8 = 0;
const REJECT_STATE: u8 = 12;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Utf8Decoder {
// This is where we accumulate our current codepoint.
accumulator: u32,
// The internal state of the DFA.
state: u8,
}
impl Utf8Decoder {
pub const fn new() -> Self {
Self {
accumulator: 0,
state: ACCEPT_STATE,
}
}
/// True if the decoder is in the middle of a multi-byte sequence
/// (Zig: `state != 0`).
#[inline]
pub const fn in_sequence(&self) -> bool {
self.state != ACCEPT_STATE
}
/// Takes the next byte in the utf-8 sequence and emits a tuple of
/// - The codepoint that was generated, if there is one.
/// - A boolean that indicates whether the provided byte was consumed.
///
/// The only case where the byte is not consumed is if an ill-formed
/// sequence is reached, in which case a replacement character will be
/// emitted and the byte will not be consumed.
///
/// If the byte is not consumed, the caller is responsible for calling
/// again with the same byte before continuing.
#[inline]
pub fn next(&mut self, byte: u8) -> (Option<u32>, bool) {
let char_class = CHAR_CLASSES[byte as usize];
let initial_state = self.state;
if self.state == ACCEPT_STATE {
self.accumulator = (0xFFu32 >> char_class) & (byte as u32);
} else {
self.accumulator <<= 6;
self.accumulator |= (byte & 0x3F) as u32;
}
self.state = TRANSITIONS[(self.state + char_class) as usize];
if self.state == ACCEPT_STATE {
let cp = self.accumulator;
self.accumulator = 0;
// Emit the fully decoded codepoint.
(Some(cp), true)
} else if self.state == REJECT_STATE {
self.accumulator = 0;
self.state = ACCEPT_STATE;
// Emit a replacement character. If we rejected the first byte
// in a sequence, then it was consumed, otherwise it was not.
(Some(0xFFFD), initial_state == ACCEPT_STATE)
} else {
// Emit nothing, we're in the middle of a sequence.
(None, true)
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn ascii() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
for &byte in b"Hello, World!" {
let res = d.next(byte);
assert!(res.1);
if let Some(cp) = res.0 {
out.push(cp as u8);
}
}
assert_eq!(out, b"Hello, World!");
}
#[test]
fn well_formed_utf8() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
// 4 bytes, 3 bytes, 2 bytes, 1 byte
for &byte in "😄✤ÁA".as_bytes() {
let mut consumed = false;
while !consumed {
let res = d.next(byte);
consumed = res.1;
// There are no errors in this sequence, so
// every byte should be consumed first try.
assert!(consumed);
if let Some(cp) = res.0 {
out.push(cp);
}
}
}
assert_eq!(out, vec![0x1F604, 0x2724, 0xC1, 0x41]);
}
#[test]
fn partially_invalid_utf8() {
let mut d = Utf8Decoder::new();
let mut out = Vec::new();
// Illegally terminated sequence, valid sequence, illegal surrogate pair.
let mut input = vec![0xF0, 0x9F];
input.extend("😄".as_bytes());
input.extend([0xED, 0xA0, 0x80]);
for byte in input {
let mut consumed = false;
while !consumed {
let res = d.next(byte);
consumed = res.1;
if let Some(cp) = res.0 {
out.push(cp);
}
}
}
assert_eq!(out, vec![0xFFFD, 0x1F604, 0xFFFD, 0xFFFD, 0xFFFD]);
}
}