Files
ghostty-rs/crates/ghostty-lib/src/config_capi.rs
T
ddidderr f7fec6f44d refactor: type escaped OS strings and fix the final audit findings
Escaped config strings (ghostty_os::osbytes) kept reaching OS
boundaries undecoded, and decoding was occasionally applied to plain
text. The root cause was that both were plain `String`s. Now:
- `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only
  via to_os/to_path/to_bytes/to_wide; plain text enters via
  from_plain_text. OS helpers (env, home, xdg, passwd, resources dir,
  path expansion), config::Path, WorkingDirectory, theme/file_load/edit
  paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the
  compiler forces decoding at every boundary. os::open takes &OsStr.
  Config output is unchanged.

Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md):
screen-file copy; regex/OSC 8 URLs no longer decoded (regression);
lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows
resources dir/TERMINFO; color OSC request lists and X11 color lookup no
longer allocate globally (C zero-allocation test extended); macOS Mach
handler resumes all threads before KERN_FAILURE (hang regression) and
SIGABRT minidumps go through the exception thread; plus open-config,
background images, crash dir, ssh cache, exe dir/PATH, fontconfig
filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths.
The typing also surfaced and fixed gtk-custom-css, bell audio, bash
HISTFILE, OSC 52 writes and the C config getters.

Test Plan:
- cargo test --workspace (3376 passed)
- just check-macos (both), just check-windows, just check-wasm
- diff <(ghostty-rs +show-config --default --docs) \
  <(ghostty +show-config --default --docs)
2026-10-12 10:06:07 +02:00

759 lines
24 KiB
Rust

//! `ghostty_config_*` (ZIG/src/config/CApi.zig and c_get.zig).
#![allow(unsafe_code)]
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
ffi::{CStr, CString, c_char, c_int, c_short, c_uint, c_void},
rc::Rc,
sync::{Mutex, OnceLock},
};
use ghostty_config::{
c_value::{CValue, QuickTerminalSizeC},
config::Config,
};
use ghostty_input::binding::{Action as BindingAction, TriggerC, TriggerCTag};
use crate::{
main_c::{EMPTY_STRING, string_from_bytes},
types::{
ghostty_command_s,
ghostty_config_color_list_s,
ghostty_config_color_s,
ghostty_config_command_list_s,
ghostty_config_palette_s,
ghostty_config_path_s,
ghostty_config_quick_terminal_size_s,
ghostty_config_t,
ghostty_diagnostic_s,
ghostty_input_trigger_key_u,
ghostty_input_trigger_s,
ghostty_quick_terminal_size_s,
ghostty_quick_terminal_size_value_u,
ghostty_string_s,
quick_terminal_size_tag,
trigger_tag,
},
};
/// The C-owned configuration: the config plus the memory that pointers
/// handed out by `ghostty_config_get` / `ghostty_config_get_diagnostic` point
/// into (Zig: the config's arena). Everything stays valid until the config
/// is freed.
pub struct CConfig {
pub config: Config,
cache: RefCell<GetCache>,
diagnostics: RefCell<Vec<CString>>,
}
/// Backing memory of one `ghostty_config_get` result.
#[derive(Default)]
struct Backing {
strings: Vec<CString>,
colors: Box<[ghostty_config_color_s]>,
commands: Box<[ghostty_command_s]>,
}
#[derive(Default)]
struct GetCache {
/// The latest result per key; reused if the value didn't change so
/// repeated gets return the same pointers.
by_key: HashMap<String, (CValue, Rc<Backing>)>,
/// Results that were replaced; kept alive until the config is freed
/// since callers may still hold their pointers.
retired: Vec<Rc<Backing>>,
}
impl CConfig {
pub fn new(config: Config) -> CConfig {
CConfig {
config,
cache: RefCell::default(),
diagnostics: RefCell::default(),
}
}
/// Box the config and return the `ghostty_config_t` handle.
pub fn into_handle(self) -> ghostty_config_t {
Box::into_raw(Box::new(self)).cast::<c_void>()
}
/// The backing memory for `value`, reusing the cached one if equal.
fn backing(&self, key: &str, value: &CValue) -> Rc<Backing> {
let mut cache = self.cache.borrow_mut();
if let Some((old, backing)) = cache.by_key.get(key)
&& old == value
{
return Rc::clone(backing);
}
let backing = Rc::new(build_backing(value));
if let Some((_, old)) = cache
.by_key
.insert(key.to_owned(), (value.clone(), Rc::clone(&backing)))
{
cache.retired.push(old);
}
backing
}
}
/// A C string without interior NULs.
fn cstring(s: &str) -> CString {
CString::new(s.replace('\0', "")).unwrap_or_default()
}
/// A C string for a config *value*: config strings carry non-UTF-8 bytes in
/// the reversible [`ghostty_os::osbytes`] encoding, so the embedder gets the
/// decoded bytes (Zig hands out the original byte slice).
fn cstring_value(s: &str) -> CString {
let mut bytes = ghostty_os::osbytes::config_str_to_bytes(s);
bytes.retain(|b| *b != 0);
CString::new(bytes).unwrap_or_default()
}
/// Intern a string for the lifetime of the process (enum tag names).
fn intern(s: &str) -> *const c_char {
static POOL: OnceLock<Mutex<HashSet<Box<CStr>>>> = OnceLock::new();
let Ok(mut pool) = POOL.get_or_init(Mutex::default).lock() else {
return c"".as_ptr();
};
let c = cstring(s);
if let Some(existing) = pool.get(c.as_c_str()) {
return existing.as_ptr();
}
let boxed: Box<CStr> = c.into_boxed_c_str();
let ptr = boxed.as_ptr();
pool.insert(boxed);
ptr
}
fn build_backing(value: &CValue) -> Backing {
let mut b = Backing::default();
match value {
CValue::String(s) => b.strings.push(cstring_value(s)),
CValue::Path { path, .. } => b.strings.push(cstring_value(path)),
CValue::ColorList(list) => {
b.colors = list
.iter()
.map(|c| ghostty_config_color_s {
r: c[0],
g: c[1],
b: c[2],
})
.collect();
}
CValue::Commands(cmds) => {
for c in cmds {
b.strings.push(cstring_value(&c.action_key));
b.strings.push(cstring_value(&c.action));
b.strings.push(cstring_value(&c.title));
b.strings.push(cstring_value(&c.description));
}
// The CStrings' heap buffers don't move when the Vec grows.
b.commands = b
.strings
.as_chunks::<4>()
.0
.iter()
.map(|s| ghostty_command_s {
action_key: s[0].as_ptr(),
action: s[1].as_ptr(),
title: s[2].as_ptr(),
description: s[3].as_ptr(),
})
.collect();
}
_ => {}
}
b
}
/// Borrow the config behind a handle.
///
/// # Safety
/// `p` must be null or a handle returned by this library that wasn't freed,
/// and no other reference to it may be live.
pub(crate) unsafe fn cconfig<'a>(p: ghostty_config_t) -> Option<&'a mut CConfig> {
// SAFETY: per the function contract.
unsafe { p.cast::<CConfig>().as_mut() }
}
/// Create a new configuration filled with the initial default values.
#[unsafe(no_mangle)]
pub extern "C" fn ghostty_config_new() -> ghostty_config_t {
CConfig::new(Config::default()).into_handle()
}
/// Free a configuration.
///
/// # Safety
/// `ptr` must be null or a handle from this library, not used afterwards.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_free(ptr: ghostty_config_t) {
if ptr.is_null() {
return;
}
// SAFETY: the handle was created by `into_handle` (a leaked Box).
drop(unsafe { Box::from_raw(ptr.cast::<CConfig>()) });
}
/// Deep clone the configuration.
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_clone(ptr: ghostty_config_t) -> ghostty_config_t {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return std::ptr::null_mut();
};
CConfig::new(c.config.clone()).into_handle()
}
/// Load the configuration from the CLI args.
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_load_cli_args(ptr: ghostty_config_t) {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return;
};
if let Err(err) = c.config.load_cli_args() {
log::error!("error loading config err={err}");
}
}
/// Load the configuration from the default file locations. This is usually
/// done first. The default file locations are locations such as the home
/// directory.
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_load_default_files(ptr: ghostty_config_t) {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return;
};
if let Err(err) = c.config.load_default_files() {
log::error!("error loading config err={err}");
}
}
/// Load the configuration from a specific file path. The path must be null
/// terminated.
///
/// # Safety
/// `ptr` must be a valid config handle, `path` a NUL terminated string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *const c_char) {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return;
};
if path.is_null() {
return;
}
// SAFETY: `path` is NUL terminated.
// Non-UTF-8 file names are valid on Unix; keep the bytes (reversible
// escape understood by the config file loader).
let path =
ghostty_os::osbytes::EncodedOs::from_bytes(unsafe { CStr::from_ptr(path) }.to_bytes());
if let Err(err) = c.config.load_file(&path) {
log::error!(
"error loading config from file path={} err={err}",
path.as_encoded()
);
}
}
/// Load the configuration from the user-specified configuration file
/// locations in the previously loaded configuration. This will recursively
/// continue to load up to a built-in limit.
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_load_recursive_files(ptr: ghostty_config_t) {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return;
};
if let Err(err) = c.config.load_recursive_files() {
log::error!("error loading config err={err}");
}
}
/// Finalize the configuration (fill in computed defaults).
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_finalize(ptr: ghostty_config_t) {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return;
};
if let Err(err) = c.config.finalize() {
log::error!("error finalizing config err={err}");
}
}
/// Store `v` at `out` (`T` must be what the C caller expects for the key).
///
/// # Safety
/// `out` must be valid for a write of `T`.
unsafe fn put<T>(out: *mut c_void, v: T) {
// SAFETY: per the function contract; unaligned writes are tolerated.
unsafe { out.cast::<T>().write_unaligned(v) };
}
fn qt_size(v: QuickTerminalSizeC) -> ghostty_quick_terminal_size_s {
match v {
QuickTerminalSizeC::None => ghostty_quick_terminal_size_s {
tag: quick_terminal_size_tag::NONE,
value: ghostty_quick_terminal_size_value_u { pixels: 0 },
},
QuickTerminalSizeC::Percentage(p) => ghostty_quick_terminal_size_s {
tag: quick_terminal_size_tag::PERCENTAGE,
value: ghostty_quick_terminal_size_value_u { percentage: p },
},
QuickTerminalSizeC::Pixels(p) => ghostty_quick_terminal_size_s {
tag: quick_terminal_size_tag::PIXELS,
value: ghostty_quick_terminal_size_value_u { pixels: p },
},
}
}
impl CConfig {
/// `c_get.get`: write the value of `key` to `out`. Returns false if the
/// key is unknown, its type isn't supported by the C API yet or an
/// optional value is unset.
///
/// # Safety
/// `out` must point to storage of the C type of the key (see
/// `ghostty.h` and `c_get.zig`).
pub unsafe fn get(&self, key: &str, out: *mut c_void) -> bool {
let Some(value) = self.config.c_value(key) else {
return false;
};
// SAFETY (all writes below): `out` is valid for the key's C type by
// the function contract.
match &value {
CValue::Unsupported | CValue::Null => return false,
CValue::NullString => unsafe { put(out, std::ptr::null::<c_char>()) },
CValue::String(_) => {
let b = self.backing(key, &value);
unsafe { put(out, b.strings[0].as_ptr()) };
}
CValue::Bool(v) => unsafe { put(out, *v) },
CValue::Uint(v) => unsafe { put::<c_uint>(out, *v) },
CValue::Short(v) => unsafe { put::<c_short>(out, *v) },
CValue::F32(v) => unsafe { put(out, *v) },
CValue::F64(v) => unsafe { put(out, *v) },
CValue::Usize(v) => unsafe { put(out, *v) },
CValue::EnumName(name) => unsafe { put(out, intern(name)) },
CValue::Packed(bits) => unsafe { put::<c_uint>(out, *bits) },
CValue::Color { r, g, b } => unsafe {
put(
out,
ghostty_config_color_s {
r: *r,
g: *g,
b: *b,
},
);
},
CValue::ColorList(list) => {
let b = self.backing(key, &value);
unsafe {
put(
out,
ghostty_config_color_list_s {
colors: b.colors.as_ptr(),
len: list.len(),
},
);
}
}
CValue::Palette(colors) => {
let mut p = ghostty_config_palette_s {
colors: [ghostty_config_color_s { r: 0, g: 0, b: 0 }; 256],
};
for (dst, c) in p.colors.iter_mut().zip(colors.iter()) {
*dst = ghostty_config_color_s {
r: c[0],
g: c[1],
b: c[2],
};
}
unsafe { put(out, p) };
}
CValue::Commands(cmds) => {
let b = self.backing(key, &value);
unsafe {
put(
out,
ghostty_config_command_list_s {
commands: b.commands.as_ptr(),
len: cmds.len(),
},
);
}
}
CValue::QuickTerminalSize { primary, secondary } => unsafe {
put(
out,
ghostty_config_quick_terminal_size_s {
primary: qt_size(*primary),
secondary: qt_size(*secondary),
},
);
},
CValue::Path { optional, .. } => {
let b = self.backing(key, &value);
unsafe {
put(
out,
ghostty_config_path_s {
path: b.strings[0].as_ptr(),
optional: *optional,
},
);
}
}
}
true
}
}
/// Get the value of a config key (see `c_get.zig` for the C types).
///
/// # Safety
/// `ptr` must be a valid config handle, `key_str` must point to `len`
/// bytes and `out` to storage of the key's C type.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_get(
ptr: ghostty_config_t,
out: *mut c_void,
key_str: *const c_char,
len: usize,
) -> bool {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return false;
};
if out.is_null() || key_str.is_null() {
return false;
}
// SAFETY: `key_str` points to `len` bytes.
let key = unsafe { std::slice::from_raw_parts(key_str.cast::<u8>(), len) };
let Ok(key) = std::str::from_utf8(key) else {
return false;
};
// SAFETY: `out` is valid for the key's type per the contract.
unsafe { c.get(key, out) }
}
/// Convert the Rust trigger to the C struct.
pub(crate) fn trigger_to_c(t: &TriggerC) -> ghostty_input_trigger_s {
let (tag, key) = match t.tag {
TriggerCTag::Physical => (
trigger_tag::PHYSICAL,
ghostty_input_trigger_key_u {
physical: t.key as c_int,
},
),
TriggerCTag::Unicode => (
trigger_tag::UNICODE,
ghostty_input_trigger_key_u { unicode: t.key },
),
TriggerCTag::CatchAll => (
trigger_tag::CATCH_ALL,
ghostty_input_trigger_key_u { unicode: 0 },
),
};
ghostty_input_trigger_s {
tag,
key,
mods: c_int::from(t.mods.int()),
}
}
/// The default (unset) trigger (`Trigger.C{}`).
pub(crate) fn default_trigger() -> ghostty_input_trigger_s {
ghostty_input_trigger_s {
tag: trigger_tag::PHYSICAL,
key: ghostty_input_trigger_key_u { physical: 0 },
mods: 0,
}
}
/// Find the trigger bound to the given binding action string.
///
/// # Safety
/// `ptr` must be a valid config handle, `str` must point to `len` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_trigger(
ptr: ghostty_config_t,
str: *const c_char,
len: usize,
) -> ghostty_input_trigger_s {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return default_trigger();
};
if str.is_null() {
return default_trigger();
}
// SAFETY: `str` points to `len` bytes.
let s = unsafe { std::slice::from_raw_parts(str.cast::<u8>(), len) };
let Ok(s) = std::str::from_utf8(s) else {
log::error!("error finding trigger err=InvalidFormat");
return default_trigger();
};
match BindingAction::parse(s) {
Ok(action) => {
let trigger = c
.config
.keybind
.set
.get_trigger(&action)
.unwrap_or_default();
trigger_to_c(&trigger.cval())
}
Err(err) => {
log::error!("error finding trigger err={err:?}");
default_trigger()
}
}
}
/// The number of diagnostics.
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_diagnostics_count(ptr: ghostty_config_t) -> u32 {
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return 0;
};
u32::try_from(c.config.diagnostics.items().len()).unwrap_or(u32::MAX)
}
/// The diagnostic at the given index (an empty message if out of range).
///
/// # Safety
/// `ptr` must be a valid config handle.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_config_get_diagnostic(
ptr: ghostty_config_t,
idx: u32,
) -> ghostty_diagnostic_s {
let empty = ghostty_diagnostic_s {
message: c"".as_ptr(),
};
// SAFETY: per the function contract.
let Some(c) = (unsafe { cconfig(ptr) }) else {
return empty;
};
let items = c.config.diagnostics.items();
let idx = idx as usize;
if idx >= items.len() {
return empty;
}
// Precompute (like libghostty's `DiagnosticList.precompute`): the
// messages are formatted once per diagnostic and live as long as the
// config.
let mut msgs = c.diagnostics.borrow_mut();
while msgs.len() < items.len() {
let mut text = String::new();
let _ = items[msgs.len()].format(&mut text);
msgs.push(cstring(&text));
}
ghostty_diagnostic_s {
message: msgs[idx].as_ptr(),
}
}
/// The path of the config file to open in an editor (created if needed).
/// Free with `ghostty_string_free`.
#[unsafe(no_mangle)]
pub extern "C" fn ghostty_config_open_path() -> ghostty_string_s {
match ghostty_config::edit::open_path() {
// The embedder gets the real bytes of the path.
Ok(path) => string_from_bytes(&path.to_bytes()),
Err(err) => {
log::error!("error opening config in editor err={err}");
EMPTY_STRING
}
}
}
#[cfg(test)]
mod tests {
use std::ffi::c_uint;
use super::*;
#[test]
fn config_values_reach_c_as_decoded_bytes() {
use ghostty_os::osbytes::EncodedOs;
let enc = EncodedOs::from_bytes(b"/a\xffb");
assert_ne!(enc.as_encoded().as_bytes(), b"/a\xffb");
let b = build_backing(&CValue::Path {
path: enc.as_encoded().to_owned(),
optional: false,
});
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
let b = build_backing(&CValue::String(enc.into_encoded()));
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
// Plain text (even with the escape char) is unchanged.
let b = build_backing(&CValue::String("h\u{e9}llo".to_owned()));
assert_eq!(b.strings[0].to_str().unwrap(), "h\u{e9}llo");
}
fn get<T: Copy>(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) {
let mut out = init;
// SAFETY: `out` is a valid destination of the key's C type.
let ok = unsafe {
ghostty_config_get(cfg, (&raw mut out).cast(), key.as_ptr().cast(), key.len())
};
(ok, out)
}
fn with_config(f: impl FnOnce(&mut Config)) -> ghostty_config_t {
let h = ghostty_config_new();
// SAFETY: just created.
f(&mut unsafe { cconfig(h) }.unwrap().config);
h
}
#[test]
fn ghostty_config_get_bool() {
let h = with_config(|c| c.maximize = true);
assert_eq!(get(h, "maximize", false), (true, true));
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_enum() {
let h = with_config(|c| c.window_theme = ghostty_config::types_b::WindowTheme::Dark);
let (ok, p) = get::<*const c_char>(h, "window-theme", std::ptr::null());
assert!(ok);
// SAFETY: an interned NUL terminated string.
assert_eq!(unsafe { CStr::from_ptr(p) }.to_str().unwrap(), "dark");
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_optional_null_returns_false() {
let h = with_config(|c| c.unfocused_split_fill = None);
let (ok, _) = get(
h,
"unfocused-split-fill",
ghostty_config_color_s { r: 0, g: 0, b: 0 },
);
assert!(!ok);
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_unknown_key_returns_false() {
let h = ghostty_config_new();
assert!(!get(h, "not-a-real-key", false).0);
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_optional_string_null_returns_true() {
let h = with_config(|c| c.title = None);
let (ok, p) = get::<*const c_char>(h, "title", std::ptr::dangling::<c_char>());
assert!(ok);
assert!(p.is_null());
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_float() {
let h = with_config(|c| c.background_opacity = 0.42);
let (ok, v) = get(h, "background-opacity", 0.0f64);
assert!(ok);
assert!((v - 0.42).abs() < 1e-6);
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn ghostty_config_get_struct_cval_conversion() {
let h = with_config(|c| {
c.background = ghostty_config::types_a::Color {
r: 12,
g: 34,
b: 56,
};
});
let (ok, v) = get(h, "background", ghostty_config_color_s { r: 0, g: 0, b: 0 });
assert!(ok);
assert_eq!((v.r, v.g, v.b), (12, 34, 56));
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn c_get_u8() {
let h = with_config(|c| c.font_size = 24.0);
assert_eq!(get(h, "font-size", 0.0f32), (true, 24.0));
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn c_get_split_preserve_zoom() {
let h = ghostty_config_new();
assert_eq!(get(h, "split-preserve-zoom", 9 as c_uint), (true, 0));
// SAFETY: valid handle.
unsafe {
cconfig(h).unwrap().config.split_preserve_zoom.navigation = true;
}
assert_eq!(get(h, "split-preserve-zoom", 9 as c_uint), (true, 1));
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
#[test]
fn c_get_pointers_are_stable_for_equal_values() {
let h = ghostty_config_new();
let (_, a) = get::<ghostty_config_command_list_s>(
h,
"command-palette-entry",
ghostty_config_command_list_s {
commands: std::ptr::null(),
len: 0,
},
);
let (_, b) = get::<ghostty_config_command_list_s>(
h,
"command-palette-entry",
ghostty_config_command_list_s {
commands: std::ptr::null(),
len: 0,
},
);
assert!(a.len > 0);
assert_eq!(a.commands, b.commands);
// SAFETY: valid handle.
unsafe { ghostty_config_free(h) };
}
}