Resolves the 8 gaps in docs/rewrite/notes/review-rv10-codex.md (final-Z13.md): - P1 memory safety: the C key-table action (and hovered-link/open-URL payloads) could advertise a length larger than the NUL-truncated allocation. A counted_bytes helper now passes the full bytes plus a trailing NUL with len == byte count, like Zig's backing slices; all C pointer+len structs were audited and tested to read exactly len. - ghostty-bench args follow cli/args.zig (ordered duplicates, empty value resets, first bad value errors, parseBool spellings); the C benchmark tokenizer uses the ArgIteratorGeneral port. - OSC 7 derived titles, desktop notifications and OSC 8 hover links forward raw bytes to the runtime; GTK converts only for display. - C diagnostic getter exports original bytes; escaped surrogates report Utf8CannotEncodeSurrogateHalf; config float formatters print `nan`. - Config::finalize_for(AppRuntime): GTK-only finalization (gtk-single- instance=detect) and termio's cgroup hooks apply only to the GTK runtime, so libghostty keeps `detect` like Zig. Test Plan: - cargo test --workspace --no-fail-fast (exit 0) - cargo test -p ghostty-lib --features test-platform - just check-wasm (macOS/Windows clippy re-run after the concurrent terminal changes land)
834 lines
27 KiB
Rust
834 lines
27 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 {
|
|
// The embedded library has no apprt (Zig `app_runtime = .none`).
|
|
ghostty_config::config::set_app_runtime(ghostty_config::config::AppRuntime::None);
|
|
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 over `bytes` that ends at the first NUL, like a C consumer
|
|
/// reading Zig's backing bytes (the rest is never visible through the pointer).
|
|
fn cstring_until_nul(bytes: &[u8]) -> CString {
|
|
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
|
CString::new(&bytes[..end]).unwrap_or_default()
|
|
}
|
|
|
|
/// A C string for a UTF-8 string, ending at the first NUL.
|
|
fn cstring(s: &str) -> CString {
|
|
cstring_until_nul(s.as_bytes())
|
|
}
|
|
|
|
/// 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 {
|
|
cstring_until_nul(&ghostty_os::osbytes::config_str_to_bytes(s))
|
|
}
|
|
|
|
/// 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.as_encoded()));
|
|
b.strings.push(cstring_value(c.description.as_encoded()));
|
|
}
|
|
// 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.to_text_lossy()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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_for(ghostty_config::config::AppRuntime::None)
|
|
{
|
|
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) };
|
|
match BindingAction::parse_bytes(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_value(&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");
|
|
}
|
|
|
|
#[test]
|
|
fn config_string_values_end_at_the_first_interior_nul() {
|
|
use ghostty_os::osbytes::EncodedOs;
|
|
let enc = EncodedOs::from_bytes(b"a\0b");
|
|
let b = build_backing(&CValue::String(enc.into_encoded()));
|
|
assert_eq!(b.strings[0].as_bytes(), b"a");
|
|
let b = build_backing(&CValue::String("x\0y".to_owned()));
|
|
assert_eq!(b.strings[0].as_bytes(), b"x");
|
|
let enc = EncodedOs::from_bytes(b"\0lead");
|
|
let b = build_backing(&CValue::Path {
|
|
path: enc.into_encoded(),
|
|
optional: false,
|
|
});
|
|
assert_eq!(b.strings[0].as_bytes(), b"");
|
|
assert_eq!(intern("ab\0c"), intern("ab"));
|
|
}
|
|
|
|
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) };
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod runtime_and_diag_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn embedded_config_keeps_gtk_single_instance_detect() {
|
|
let h = ghostty_config_new();
|
|
// SAFETY: just created.
|
|
let c = unsafe { cconfig(h) }.unwrap();
|
|
c.config.gtk_single_instance = ghostty_config::types_b::GtkSingleInstance::Detect;
|
|
// SAFETY: valid handle.
|
|
unsafe { ghostty_config_finalize(h) };
|
|
let mut out: *const c_char = std::ptr::null();
|
|
let key = "gtk-single-instance";
|
|
// SAFETY: the key's C type is a C string.
|
|
assert!(unsafe {
|
|
ghostty_config_get(h, (&raw mut out).cast(), key.as_ptr().cast(), key.len())
|
|
});
|
|
// SAFETY: valid NUL terminated string owned by the config.
|
|
assert_eq!(unsafe { CStr::from_ptr(out) }.to_bytes(), b"detect");
|
|
// SAFETY: valid handle.
|
|
unsafe { ghostty_config_free(h) };
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostic_getter_exports_original_bytes() {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let dir = std::env::temp_dir().join(format!("ghostty-z13-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let file = dir.join("cfg");
|
|
std::fs::write(&file, b"font-size = \xff\n").unwrap();
|
|
let path = CString::new(file.as_os_str().as_bytes()).unwrap();
|
|
let h = ghostty_config_new();
|
|
// SAFETY: valid handle and NUL terminated path.
|
|
unsafe { ghostty_config_load_file(h, path.as_ptr()) };
|
|
// SAFETY: valid handle.
|
|
assert!(unsafe { ghostty_config_diagnostics_count(h) } >= 1);
|
|
// SAFETY: valid handle, index in range.
|
|
let d = unsafe { ghostty_config_get_diagnostic(h, 0) };
|
|
// SAFETY: NUL terminated string owned by the config.
|
|
let msg = unsafe { CStr::from_ptr(d.message) }.to_bytes().to_vec();
|
|
assert!(msg.windows(1).any(|w| w == [0xff]), "{msg:?}");
|
|
assert!(
|
|
!msg.windows(5).any(|w| w == [0xef, 0xb7, 0x90, 0xc3, 0xbf]),
|
|
"{msg:?}"
|
|
);
|
|
// SAFETY: valid handle.
|
|
unsafe { ghostty_config_free(h) };
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|