Files
ghostty-rs/crates/ghostty-vt-capi/src/sgr.rs
T
ddidderr 75252a7651 feat(vt-capi): port the libghostty-vt C API
Ports ZIG/src/lib_vt.zig and terminal/c/* as crates/ghostty-vt-capi
(cdylib + staticlib + rlib, soname libghostty-vt.so) implementing the
unchanged include/ghostty/vt.h + vt/*.h ABI: OSC parser, SGR parser,
key events and key encoding, paste safety, colors, result codes - all
38 native symbols, plus the ghostty_wasm_* helpers on wasm32.

Verification:
- tests/abi.rs generates a C probe from the headers, compiled with cc,
  that checks sizeof/alignof/offsetof of every struct/union, every
  enumerator and #define against Rust, and links every declared symbol;
  nm -D exports equal the declared functions.
- Ghostty's example/c-vt* programs compile unchanged against the Rust
  library (shared and static) and produce the expected output.
- The wasm32 build runs Ghostty's wasm-key-encode/wasm-sgr example
  scripts unmodified under node (upstream's wasm-sgr tag table is stale
  vs sgr.h; a regenerated copy checks full output).

Test Plan:
- cargo test -p ghostty-vt-capi (22 unit + 2 ABI + 5 C + 3 wasm tests)
2026-10-12 05:16:41 +02:00

545 lines
16 KiB
Rust

//! SGR parser C API (ZIG/src/terminal/c/sgr.zig, `include/ghostty/vt/sgr.h`).
#![allow(unsafe_code)]
use std::{alloc::Layout, ffi::c_int, ptr::NonNull};
use ghostty_vt::{
parser::{MAX_PARAMS, SepList},
sgr::{Attribute, Parser},
};
use crate::{
alloc::{Alloc, Allocator},
color::GhosttyColorRgb,
result::GhosttyResult,
};
/// C: `GhosttySgrParser` (opaque). Mirrors Zig's `ParserWrapper`: the SGR
/// parser state plus the allocator that owns the parameter copy.
pub struct SgrParser {
alloc: Alloc,
/// Owned copy of the parameters (allocated with `alloc`), or null.
params: *mut u16,
params_len: usize,
params_sep: SepList,
idx: usize,
}
impl SgrParser {
fn params_layout(len: usize) -> Layout {
Layout::array::<u16>(len).unwrap_or(Layout::new::<u16>())
}
fn free_params(&mut self) {
if !self.params.is_null() {
// SAFETY: allocated by `set_params` with this allocator/layout.
unsafe {
self.alloc
.free_raw(self.params.cast(), Self::params_layout(self.params_len));
}
self.params = std::ptr::null_mut();
self.params_len = 0;
}
}
fn params(&self) -> &[u16] {
if self.params.is_null() {
&[]
} else {
// SAFETY: `params` points to `params_len` initialized u16s that
// live until `free_params`.
unsafe { std::slice::from_raw_parts(self.params, self.params_len) }
}
}
}
/// C: `GhosttySgrAttributeTag`
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum SgrAttributeTag {
Unset = 0,
Unknown = 1,
Bold = 2,
ResetBold = 3,
Italic = 4,
ResetItalic = 5,
Faint = 6,
Underline = 7,
UnderlineColor = 8,
UnderlineColor256 = 9,
ResetUnderlineColor = 10,
Overline = 11,
ResetOverline = 12,
Blink = 13,
ResetBlink = 14,
Inverse = 15,
ResetInverse = 16,
Invisible = 17,
ResetInvisible = 18,
Strikethrough = 19,
ResetStrikethrough = 20,
DirectColorFg = 21,
DirectColorBg = 22,
Bg8 = 23,
Fg8 = 24,
ResetFg = 25,
ResetBg = 26,
BrightBg8 = 27,
BrightFg8 = 28,
Bg256 = 29,
Fg256 = 30,
}
/// C: `GhosttySgrUnknown`
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct SgrUnknown {
pub full_ptr: *const u16,
pub full_len: usize,
pub partial_ptr: *const u16,
pub partial_len: usize,
}
/// C: `GhosttySgrAttributeValue`
#[derive(Clone, Copy)]
#[repr(C)]
pub union SgrAttributeValue {
pub unknown: SgrUnknown,
/// A `GhosttySgrUnderline` (`int`).
pub underline: c_int,
pub underline_color: GhosttyColorRgb,
pub underline_color_256: u8,
pub direct_color_fg: GhosttyColorRgb,
pub direct_color_bg: GhosttyColorRgb,
pub bg_8: u8,
pub fg_8: u8,
pub bright_bg_8: u8,
pub bright_fg_8: u8,
pub bg_256: u8,
pub fg_256: u8,
pub _padding: [u64; 8],
}
/// C: `GhosttySgrAttribute`
#[derive(Clone, Copy)]
#[repr(C)]
pub struct SgrAttribute {
/// A [`SgrAttributeTag`].
pub tag: c_int,
pub value: SgrAttributeValue,
}
impl SgrAttribute {
fn from_attr(attr: &Attribute<'_>) -> SgrAttribute {
use SgrAttributeTag as T;
let mut value = SgrAttributeValue { _padding: [0; 8] };
let tag = match *attr {
Attribute::Unset => T::Unset,
Attribute::Unknown(u) => {
value.unknown = SgrUnknown {
full_ptr: u.full.as_ptr(),
full_len: u.full.len(),
partial_ptr: u.partial.as_ptr(),
partial_len: u.partial.len(),
};
T::Unknown
}
Attribute::Bold => T::Bold,
Attribute::ResetBold => T::ResetBold,
Attribute::Italic => T::Italic,
Attribute::ResetItalic => T::ResetItalic,
Attribute::Faint => T::Faint,
Attribute::Underline(u) => {
value.underline = c_int::from(u as u8);
T::Underline
}
Attribute::UnderlineColor(c) => {
value.underline_color = c.into();
T::UnderlineColor
}
Attribute::UnderlineColor256(v) => {
value.underline_color_256 = v;
T::UnderlineColor256
}
Attribute::ResetUnderlineColor => T::ResetUnderlineColor,
Attribute::Overline => T::Overline,
Attribute::ResetOverline => T::ResetOverline,
Attribute::Blink => T::Blink,
Attribute::ResetBlink => T::ResetBlink,
Attribute::Inverse => T::Inverse,
Attribute::ResetInverse => T::ResetInverse,
Attribute::Invisible => T::Invisible,
Attribute::ResetInvisible => T::ResetInvisible,
Attribute::Strikethrough => T::Strikethrough,
Attribute::ResetStrikethrough => T::ResetStrikethrough,
Attribute::DirectColorFg(c) => {
value.direct_color_fg = c.into();
T::DirectColorFg
}
Attribute::DirectColorBg(c) => {
value.direct_color_bg = c.into();
T::DirectColorBg
}
Attribute::Bg8(n) => {
value.bg_8 = n.0;
T::Bg8
}
Attribute::Fg8(n) => {
value.fg_8 = n.0;
T::Fg8
}
Attribute::ResetFg => T::ResetFg,
Attribute::ResetBg => T::ResetBg,
Attribute::Bg8Bright(n) => {
value.bright_bg_8 = n.0;
T::BrightBg8
}
Attribute::Fg8Bright(n) => {
value.bright_fg_8 = n.0;
T::BrightFg8
}
Attribute::Bg256(v) => {
value.bg_256 = v;
T::Bg256
}
Attribute::Fg256(v) => {
value.fg_256 = v;
T::Fg256
}
};
SgrAttribute {
tag: tag as c_int,
value,
}
}
}
/// # Safety
/// `allocator` must be null or valid; `result` must be valid for writes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_new(
allocator: *const Allocator,
result: *mut *mut SgrParser,
) -> GhosttyResult {
// SAFETY: forwarded caller contract.
let alloc = unsafe { Alloc::from_c(allocator) };
let Some(p) = alloc.create(SgrParser {
alloc,
params: std::ptr::null_mut(),
params_len: 0,
params_sep: SepList::init_empty(),
idx: 0,
}) else {
return GhosttyResult::OutOfMemory;
};
// SAFETY: caller guarantees `result` is valid for writes.
unsafe { *result = p.as_ptr() };
GhosttyResult::Success
}
/// # Safety
/// `parser` must be null or a live parser from `ghostty_sgr_new`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_free(parser: *mut SgrParser) {
let Some(mut p) = NonNull::new(parser) else {
return;
};
// SAFETY: `p` came from `Alloc::create` in `ghostty_sgr_new`.
unsafe {
let alloc = p.as_ref().alloc;
p.as_mut().free_params();
alloc.destroy(p);
}
}
/// # Safety
/// `parser` must be null or a live parser.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_reset(parser: *mut SgrParser) {
// SAFETY: caller guarantees a live parser (or null).
if let Some(p) = unsafe { parser.as_mut() } {
p.idx = 0;
}
}
/// # Safety
/// `parser` must be null or live; `params` must point to `len` `u16`s;
/// `separators`, if non-null, must point to `len` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_set_params(
parser: *mut SgrParser,
params: *const u16,
separators: *const u8,
len: usize,
) -> GhosttyResult {
// SAFETY: caller guarantees a live parser (or null).
let Some(p) = (unsafe { parser.as_mut() }) else {
return GhosttyResult::InvalidValue;
};
if params.is_null() && len > 0 {
return GhosttyResult::InvalidValue;
}
// Copy the new parameters (Zig: `alloc.dupe`).
let layout = SgrParser::params_layout(len);
let new = p.alloc.alloc_raw(layout).cast::<u16>();
if new.is_null() {
return GhosttyResult::OutOfMemory;
}
if len > 0 {
// SAFETY: `params` has `len` readable elements; `new` has room for `len`.
unsafe { std::ptr::copy_nonoverlapping(params, new, len) };
}
p.free_params();
p.params = new;
p.params_len = len;
// If we have separators, set that state too.
p.params_sep = SepList::init_empty();
if !separators.is_null() {
if len > MAX_PARAMS {
log::warn!(
"ghostty_sgr_set_params: separators length {len} exceeds max supported length {MAX_PARAMS}"
);
return GhosttyResult::InvalidValue;
}
// SAFETY: caller guarantees `len` readable separator bytes.
let seps = unsafe { std::slice::from_raw_parts(separators, len) };
for (i, sep) in seps.iter().enumerate() {
if *sep == b':' {
p.params_sep.set(i);
}
}
}
// Reset our parsing state.
p.idx = 0;
GhosttyResult::Success
}
/// # Safety
/// `parser` must be null or live; `result` must be valid for writes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_next(
parser: *mut SgrParser,
result: *mut SgrAttribute,
) -> bool {
// SAFETY: caller guarantees a live parser (or null).
let Some(p) = (unsafe { parser.as_mut() }) else {
return false;
};
let mut sp = Parser {
params: p.params(),
params_sep: p.params_sep,
idx: p.idx,
};
let attr = sp.next();
let idx = sp.idx;
// `attr` borrows `p.params`; convert before touching `p` mutably.
let Some(attr) = attr else {
p.idx = idx;
return false;
};
let c = SgrAttribute::from_attr(&attr);
p.idx = idx;
// SAFETY: caller guarantees `result` is valid for writes.
unsafe { result.write(c) };
true
}
/// # Safety
/// `ptr`, if non-null, must be valid for writes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_unknown_full(
unknown: SgrUnknown,
ptr: *mut *const u16,
) -> usize {
if !ptr.is_null() {
// SAFETY: non-null and valid per the contract.
unsafe { *ptr = unknown.full_ptr };
}
unknown.full_len
}
/// # Safety
/// `ptr`, if non-null, must be valid for writes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_unknown_partial(
unknown: SgrUnknown,
ptr: *mut *const u16,
) -> usize {
if !ptr.is_null() {
// SAFETY: non-null and valid per the contract.
unsafe { *ptr = unknown.partial_ptr };
}
unknown.partial_len
}
/// Returns the `GhosttySgrAttributeTag` of `attr`.
#[unsafe(no_mangle)]
pub extern "C" fn ghostty_sgr_attribute_tag(attr: SgrAttribute) -> c_int {
attr.tag
}
/// # Safety
/// `attr` must point to a valid attribute; the result points into it.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_sgr_attribute_value(
attr: *mut SgrAttribute,
) -> *mut SgrAttributeValue {
// SAFETY: caller guarantees `attr` is valid; no reference is created.
unsafe { &raw mut (*attr).value }
}
#[cfg(test)]
mod tests {
use super::*;
fn new() -> *mut SgrParser {
let mut p = std::ptr::null_mut();
// SAFETY: valid out pointer, default allocator.
assert_eq!(
unsafe { ghostty_sgr_new(std::ptr::null(), &mut p) },
GhosttyResult::Success
);
p
}
fn zeroed() -> SgrAttribute {
SgrAttribute {
tag: 0,
value: SgrAttributeValue { _padding: [0; 8] },
}
}
#[test]
fn alloc() {
let p = new();
// SAFETY: live parser.
unsafe { ghostty_sgr_free(p) };
}
#[test]
fn simple_params_no_seps() {
let p = new();
let mut attr = zeroed();
// SAFETY: live parser; valid buffers.
unsafe {
let params = [1u16];
// Set it twice on purpose to make sure we don't leak.
for _ in 0..2 {
assert_eq!(
ghostty_sgr_set_params(p, params.as_ptr(), std::ptr::null(), 1),
GhosttyResult::Success
);
}
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::Bold as c_int);
assert!(!ghostty_sgr_next(p, &mut attr));
ghostty_sgr_free(p);
}
}
#[test]
fn colon_separated_underline_and_colors() {
let p = new();
let params: [u16; 17] = [
4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136,
];
let mut seps = [b';'; 17];
seps[0] = b':';
let mut attr = zeroed();
// SAFETY: live parser; valid buffers.
unsafe {
assert_eq!(
ghostty_sgr_set_params(p, params.as_ptr(), seps.as_ptr(), 17),
GhosttyResult::Success
);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::Underline as c_int);
assert_eq!(attr.value.underline, 3);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::DirectColorFg as c_int);
assert_eq!(
attr.value.direct_color_fg,
GhosttyColorRgb {
r: 51,
g: 51,
b: 51
}
);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::DirectColorBg as c_int);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::UnderlineColor as c_int);
assert_eq!(
attr.value.underline_color,
GhosttyColorRgb {
r: 255,
g: 97,
b: 136
}
);
assert!(!ghostty_sgr_next(p, &mut attr));
ghostty_sgr_reset(p);
assert!(ghostty_sgr_next(p, &mut attr));
ghostty_sgr_free(p);
}
}
#[test]
fn empty_params_is_unset_and_too_many_seps_is_invalid() {
let p = new();
let mut attr = zeroed();
// SAFETY: live parser; valid buffers.
unsafe {
assert_eq!(
ghostty_sgr_set_params(p, [].as_ptr(), std::ptr::null(), 0),
GhosttyResult::Success
);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::Unset as c_int);
assert!(!ghostty_sgr_next(p, &mut attr));
let params = [0u16; 25];
let seps = [b';'; 25];
assert_eq!(
ghostty_sgr_set_params(p, params.as_ptr(), seps.as_ptr(), 25),
GhosttyResult::InvalidValue
);
assert_eq!(
ghostty_sgr_set_params(std::ptr::null_mut(), params.as_ptr(), std::ptr::null(), 1),
GhosttyResult::InvalidValue
);
ghostty_sgr_free(p);
}
}
#[test]
fn unknown_attribute_exposes_params() {
let p = new();
let params = [9999u16];
let mut attr = zeroed();
// SAFETY: live parser; valid buffers.
unsafe {
ghostty_sgr_set_params(p, params.as_ptr(), std::ptr::null(), 1);
assert!(ghostty_sgr_next(p, &mut attr));
assert_eq!(attr.tag, SgrAttributeTag::Unknown as c_int);
let mut ptr: *const u16 = std::ptr::null();
let n = ghostty_sgr_unknown_full(attr.value.unknown, &mut ptr);
assert_eq!(std::slice::from_raw_parts(ptr, n), &[9999]);
let n = ghostty_sgr_unknown_partial(attr.value.unknown, std::ptr::null_mut());
assert!(n <= 1);
assert_eq!(
ghostty_sgr_attribute_tag(attr),
SgrAttributeTag::Unknown as c_int
);
assert_eq!(
ghostty_sgr_attribute_value(&mut attr).cast::<u8>(),
(&raw mut attr).cast::<u8>().add(8)
);
ghostty_sgr_free(p);
}
}
}