refactor(config): parse config values from raw bytes like Zig

Zig parses config values (cli/args.zig, Config.zig field parsers,
config/string.zig) from raw byte slices, and each field type decides
whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The
port escape-encoded raw bytes into Strings at the line reader and let
fields parse the escaped text, so fields could observe encoding
artifacts; successive audits kept finding such leaks (RV5-RV8).

Field parsers now receive the original bytes: text fields validate UTF-8
where Zig does, byte/path fields store bytes (EncodedOs/OsString). This
resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md),
each with a regression test: Windows path conversion fails with
BadPathName instead of substituting U+FFFD; Windows process launch uses
strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired-
surrogate encodings like Zig; literal U+FDD0 Unicode key triggers;
selection-word-chars and clipboard-codepoint-map reject invalid UTF-8;
font style / codepoint-map family names use original bytes; C title
actions forward bytes; D-Bus new-window/Flatpak conversions match Zig.
Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md.

Also moves the bundled iTerm2-Color-Schemes license out of
resources/themes/ so +list-themes no longer lists it as a theme
(output is now byte-identical to Ghostty 1.3.1).

Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator.

Test Plan:
- cargo test --workspace --no-fail-fast (3428 passed, exit 0)
- just check-macos (both), just check-windows, just check-wasm
- +show-config --default --docs, +list-keybinds, +list-actions --docs,
  +list-themes --plain compared with /usr/bin/ghostty
This commit is contained in:
ddidderr committed 2026-10-12 11:57:01 +02:00
1 parent 37a1646611
commit 672e8a4ae1
100 files changed
+2310 -1054

No files matched your search

Generated
+1
View File
@@ -1553,6 +1553,7 @@ name = "ghostty-input"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ghostty-helpgen", "ghostty-helpgen",
"ghostty-os",
"ghostty-unicode", "ghostty-unicode",
"ghostty-vt", "ghostty-vt",
"indexmap", "indexmap",
@@ -401,10 +401,7 @@ impl Application {
// Keep the inherited bytes: LANG need not be UTF-8. // Keep the inherited bytes: LANG need not be UTF-8.
let saved_language = std::env::var_os("LANG"); let saved_language = std::env::var_os("LANG");
if let Some(language) = &config.language { if let Some(language) = &config.language {
set_env( set_env("LANG", language.to_os());
"LANG",
ghostty_os::osbytes::EncodedOs::from_encoded(language.clone()).to_os(),
);
} }
// Set gettext global domain to be our app so that our unqualified // Set gettext global domain to be our app so that our unqualified
@@ -447,7 +444,9 @@ impl Application {
// Our app ID determines uniqueness and maps to our desktop file. // Our app ID determines uniqueness and maps to our desktop file.
let app_id: String = match &config.class { let app_id: String = match &config.class {
Some(class) if gio::Application::id_is_valid(class) => class.clone(), Some(class) if gio::Application::id_is_valid(&class.to_text_lossy()) => {
class.to_text_lossy()
}
Some(_) => { Some(_) => {
log::warn!("invalid 'class' in config, ignoring"); log::warn!("invalid 'class' in config, ignoring");
crate::APPLICATION_ID.to_owned() crate::APPLICATION_ID.to_owned()
@@ -1102,7 +1101,7 @@ impl Application {
let mut working_directory: Option<ghostty_os::osbytes::EncodedOs> = None; let mut working_directory: Option<ghostty_os::osbytes::EncodedOs> = None;
let mut title: Option<String> = None; let mut title: Option<String> = None;
let mut command: Option<Command> = None; let mut command: Option<Command> = None;
let mut args: Vec<String> = Vec::new(); let mut args: Vec<ghostty_os::osbytes::EncodedOs> = Vec::new();
'overrides: { 'overrides: {
// were we given a parameter? // were we given a parameter?
@@ -1121,7 +1120,7 @@ impl Application {
log::debug!("new-window argument: {i} {s}"); log::debug!("new-window argument: {i} {s}");
if e_seen { if e_seen {
args.push(s.clone()); args.push(ghostty_os::osbytes::EncodedOs::from_plain_text(s));
continue; continue;
} }
if s == "-e" { if s == "-e" {
@@ -1138,9 +1137,9 @@ impl Application {
continue; continue;
} }
if let Some(v) = s.strip_prefix("--working-directory=") { if let Some(v) = s.strip_prefix("--working-directory=") {
// IPC carries the encoded form of the OS string. // D-Bus strings are plain UTF-8, including literal U+FDD0.
working_directory = working_directory =
Some(ghostty_os::osbytes::EncodedOs::from_encoded(v.trim())); Some(ghostty_os::osbytes::EncodedOs::from_plain_text(v.trim()));
continue; continue;
} }
if let Some(v) = s.strip_prefix("--title=") { if let Some(v) = s.strip_prefix("--title=") {
@@ -1449,7 +1448,7 @@ impl Application {
true true
} }
Action::SetTitle(v) => { Action::SetTitle(v) => {
tgt.with_surface_void("set_title", |s| s.set_title(Some(&v.title))); tgt.with_surface_void("set_title", |s| s.set_title(Some(&v.title.to_text_lossy())));
true true
} }
Action::SetTabTitle(v) => { Action::SetTabTitle(v) => {
@@ -1462,11 +1461,8 @@ impl Application {
log::warn!("surface is not in a tab, ignoring set_tab_title"); log::warn!("surface is not in a tab, ignoring set_tab_title");
return false; return false;
}; };
tab.set_title_override(if v.title.is_empty() { let title = v.title.to_text_lossy();
None tab.set_title_override(if title.is_empty() { None } else { Some(&title) });
} else {
Some(&v.title)
});
true true
}) })
} }
@@ -110,9 +110,7 @@ mod command_imp {
fn prop_get_title(&self) -> Option<String> { fn prop_get_title(&self) -> Option<String> {
let mut data = self.data.borrow_mut(); let mut data = self.data.borrow_mut();
match &mut *data { match &mut *data {
CommandData::Regular { command } => { CommandData::Regular { command } => Some(command.title.to_text_lossy()),
Some(ghostty_os::osbytes::config_str_to_text(&command.title))
}
CommandData::Jump { surface, title, .. } => { CommandData::Jump { surface, title, .. } => {
if let Some(t) = title { if let Some(t) = title {
return Some(t.clone()); return Some(t.clone());
@@ -127,9 +125,7 @@ mod command_imp {
fn prop_get_description(&self) -> Option<String> { fn prop_get_description(&self) -> Option<String> {
let mut data = self.data.borrow_mut(); let mut data = self.data.borrow_mut();
match &mut *data { match &mut *data {
CommandData::Regular { command } => Some(ghostty_os::osbytes::config_str_to_text( CommandData::Regular { command } => Some(command.description.to_text_lossy()),
&command.description,
)),
CommandData::Jump { CommandData::Jump {
surface, surface,
description, description,
+36 -9
View File
@@ -8,6 +8,27 @@ use std::{cell::RefCell, sync::Arc};
use ghostty_config::config::Config as CoreConfig; use ghostty_config::config::Config as CoreConfig;
use gtk::{glib, glib::subclass::prelude::*}; use gtk::{glib, glib::subclass::prelude::*};
/// GTK accepts UTF-8, while the diagnostic formatter transports original bytes.
/// Upstream inserts each diagnostic separately, skipping invalid text but still
/// inserting its newline, and skips diagnostics exceeding its 4095-byte buffer.
fn diagnostics_text(config: &CoreConfig) -> String {
let mut text = String::new();
for diag in config.diagnostics.items() {
let bytes = ghostty_os::osbytes::config_str_to_bytes(&diag.to_string());
if bytes.len() > 4095 {
log::warn!("diagnostic exceeds GTK buffer capacity");
continue;
}
if !bytes.contains(&0)
&& let Ok(line) = std::str::from_utf8(&bytes)
{
text.push_str(line);
}
text.push('\n');
}
text
}
mod imp { mod imp {
use gtk::{ use gtk::{
glib, glib,
@@ -57,15 +78,7 @@ mod imp {
return None; return None;
} }
let buf = gtk::TextBuffer::new(None); let buf = gtk::TextBuffer::new(None);
let mut text = String::new(); buf.set_text(&super::diagnostics_text(&config));
for diag in config.diagnostics.items() {
if let Err(err) = diag.format(&mut text) {
log::warn!("error writing diagnostic to buffer err={err}");
continue;
}
text.push('\n');
}
buf.set_text(&text);
Some(buf) Some(buf)
} }
} }
@@ -125,6 +138,20 @@ impl Default for Config {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn diagnostics_display_original_text_and_skip_invalid_utf8() {
let mut config = CoreConfig::default();
config
.load_iter(&mut ghostty_config::cli_args::LineIterator::new(
b"font-size=\xef\xb7\x90\xc3\xa9\nfont-size=\xff\n".as_slice(),
))
.expect("parse diagnostics");
let text = diagnostics_text(&config);
assert!(text.contains("invalid value \"\u{fdd0}é\""));
assert!(!text.contains("\u{fdd0}\u{fdd0}"));
assert!(text.ends_with("\n\n"));
}
#[test] #[test]
fn ghostty_config() { fn ghostty_config() {
// No display needed; GObject only. // No display needed; GObject only.
+28 -19
View File
@@ -278,7 +278,7 @@ impl Surface {
if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get()) if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get())
&& let Some(pwd) = parent.get_pwd() && let Some(pwd) = parent.get_pwd()
{ {
imp.pwd.replace(Some(pwd.into_encoded())); imp.pwd.replace(Some(pwd));
self.notify("pwd"); self.notify("pwd");
} }
} }
@@ -391,7 +391,7 @@ impl Surface {
self.notify("key-table"); self.notify("key-table");
match value { match value {
KeyTable::Activate(name) => imp.key_tables.borrow_mut().push(name.clone()), KeyTable::Activate(name) => imp.key_tables.borrow_mut().push(name.to_text_lossy()),
KeyTable::Deactivate => { KeyTable::Deactivate => {
imp.key_tables.borrow_mut().pop(); imp.key_tables.borrow_mut().pop();
} }
@@ -1615,21 +1615,13 @@ impl Surface {
/// Returns the pwd property. /// Returns the pwd property.
pub fn get_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> { pub fn get_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
// The GObject `pwd` property is a (UTF-8) string, so it stores the // The typed backing retains bytes; the GObject property renders text.
// encoded form of the OS string; this accessor is the only place it self.imp().pwd.borrow().clone()
// is converted back to the typed value.
self.imp()
.pwd
.borrow()
.clone()
.map(ghostty_os::osbytes::EncodedOs::from_encoded)
} }
/// Set the pwd for this surface, copies the value. /// Set the pwd for this surface, copies the value.
pub fn set_pwd(&self, pwd: Option<&ghostty_os::osbytes::EncodedOs>) { pub fn set_pwd(&self, pwd: Option<&ghostty_os::osbytes::EncodedOs>) {
self.imp() self.imp().pwd.replace(pwd.cloned());
.pwd
.replace(pwd.map(|p| p.as_encoded().to_owned()));
self.notify("pwd"); self.notify("pwd");
} }
@@ -1822,7 +1814,10 @@ impl Surface {
let volume = config.bell_audio_volume.clamp(0.0, 1.0); let volume = config.bell_audio_volume.clamp(0.0, 1.0);
// The config path is an encoded OS string: decode it first. // The config path is an encoded OS string: decode it first.
let path = path.to_path(); let Ok(path) = path.to_path() else {
log::warn!("invalid bell audio path: BadPathName");
return;
};
debug_assert!(path.is_absolute()); debug_assert!(path.is_absolute());
let media_file = gtk::MediaFile::for_filename(&path); let media_file = gtk::MediaFile::for_filename(&path);
@@ -2403,8 +2398,10 @@ impl RtSurface for RtSurfaceHandle {
None None
} }
fn get_title(&self) -> Option<String> { fn get_title(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
self.gobj().and_then(|s| s.get_title()) self.gobj()
.and_then(|s| s.get_title())
.map(ghostty_os::osbytes::EncodedOs::from)
} }
fn get_content_scale(&self) -> ContentScale { fn get_content_scale(&self) -> ContentScale {
@@ -2843,8 +2840,8 @@ mod imp {
/// If this is set prior to initialization then the surface will /// If this is set prior to initialization then the surface will
/// start in this pwd. If it is set after, it has no impact on the /// start in this pwd. If it is set after, it has no impact on the
/// core surface. /// core surface.
#[property(get, set, nullable)] #[property(get = Self::pwd_text, set = Self::set_pwd_text, type = Option<String>, nullable)]
pub pwd: RefCell<Option<String>>, pub pwd: RefCell<Option<ghostty_os::osbytes::EncodedOs>>,
/// The title of this surface, if any has been set. /// The title of this surface, if any has been set.
#[property(get, set, nullable)] #[property(get, set, nullable)]
@@ -3068,6 +3065,18 @@ mod imp {
} }
impl Surface { impl Surface {
fn pwd_text(&self) -> Option<String> {
self.pwd
.borrow()
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::to_text_lossy)
}
fn set_pwd_text(&self, value: Option<String>) {
self.pwd
.replace(value.map(ghostty_os::osbytes::EncodedOs::from));
}
fn mouse_shape_prop(&self) -> i32 { fn mouse_shape_prop(&self) -> i32 {
self.mouse_shape.get() as i32 self.mouse_shape.get() as i32
} }
@@ -3388,7 +3397,7 @@ mod imp {
// Binding payloads are config strings: the needle is plain // Binding payloads are config strings: the needle is plain
// UI text, so escape it instead of decoding it. // UI text, so escape it instead of decoding it.
&ghostty_input::binding::Action::Search( &ghostty_input::binding::Action::Search(
ghostty_os::osbytes::valid_str_to_config_string(&needle.unwrap_or_default()), ghostty_os::osbytes::EncodedOs::from_plain_text(&needle.unwrap_or_default()),
), ),
) { ) {
log::warn!("unable to perform search action err={err}"); log::warn!("unable to perform search action err={err}");
+6 -1
View File
@@ -315,7 +315,12 @@ mod imp {
// exists, otherwise the overridden title if it exists, // exists, otherwise the overridden title if it exists,
// otherwise the terminal title if it exists, otherwise a // otherwise the terminal title if it exists, otherwise a
// default string. // default string.
let config_title = config.as_ref().and_then(|c| c.get().title.clone()); let config_title = config.as_ref().and_then(|c| {
c.get()
.title
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::to_text_lossy)
});
let plain = tab_override let plain = tab_override
.or(surface_override) .or(surface_override)
.or(terminal) .or(terminal)
+1 -1
View File
@@ -313,7 +313,7 @@ mod imp {
// so that any applications inspecting the window states see // so that any applications inspecting the window states see
// an immediate title set when the window appears. // an immediate title set when the window appears.
if let Some(title) = &config.title { if let Some(title) = &config.title {
obj.set_title(Some(&ghostty_os::osbytes::config_str_to_text(title))); obj.set_title(Some(&title.to_text_lossy()));
} }
} }
+8 -1
View File
@@ -113,6 +113,13 @@ impl CssProviders {
Path::Optional(path) => (path.to_path(), true), Path::Optional(path) => (path.to_path(), true),
Path::Required(path) => (path.to_path(), false), Path::Required(path) => (path.to_path(), false),
}; };
let path = match path {
Ok(path) => path,
Err(error) => {
log::warn!("invalid custom CSS path: {error}");
continue;
}
};
let shown = path.display(); let shown = path.display();
let meta = match std::fs::metadata(&path) { let meta = match std::fs::metadata(&path) {
Ok(m) => m, Ok(m) => m,
@@ -215,7 +222,7 @@ pub fn runtime_css(config: &Config, gtk_416: bool) -> String {
} }
if let Some(family) = &config.window_title_font_family { if let Some(family) = &config.window_title_font_family {
let family = ghostty_os::osbytes::config_str_to_text(family); let family = family.to_text_lossy();
let _ = write!( let _ = write!(
out, out,
".window headerbar {{\n font-family: \"{family}\";\n}}\n" ".window headerbar {{\n font-family: \"{family}\";\n}}\n"
+71 -2
View File
@@ -16,7 +16,7 @@
//! ``` //! ```
use ghostty_apprt::ipc::{Error, NewWindow, Target}; use ghostty_apprt::ipc::{Error, NewWindow, Target};
use gtk::prelude::*; use gtk::glib;
use super::dbus::DBus; use super::dbus::DBus;
@@ -35,9 +35,78 @@ pub fn new_window(target: &Target, value: &NewWindow) -> Result<bool, Error> {
// parameter is an array of strings that contain the arguments. They // parameter is an array of strings that contain the arguments. They
// will be sent to the main Ghostty instance and interpreted as CLI // will be sent to the main Ghostty instance and interpreted as CLI
// arguments. // arguments.
dbus.add_parameter(&arguments.to_variant()); dbus.add_parameter(&arguments_variant(arguments));
} }
dbus.send()?; dbus.send()?;
Ok(true) Ok(true)
} }
/// Zig constructs each GVariant string from serialized bytes including its NUL.
/// Keep valid wire bytes unchanged. Zig marks these strings trusted, causing
/// GIO to abort when serializing invalid UTF-8 or an interior NUL. Fail the same
/// way explicitly: an untrusted GVariant would silently substitute an empty
/// string, while a malformed trusted variant would violate Rust GLib's contract.
fn arguments_variant(arguments: &[Vec<u8>]) -> glib::Variant {
glib::Variant::array_from_iter_with_type(
glib::VariantTy::STRING,
arguments.iter().map(|arg| {
if arg.contains(&0) || std::str::from_utf8(arg).is_err() {
log::error!("invalid D-Bus string in new-window arguments");
std::process::abort();
}
let mut bytes = arg.clone();
bytes.push(0);
glib::Variant::from_bytes_with_type(
&glib::Bytes::from_owned(bytes),
glib::VariantTy::STRING,
)
}),
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[cfg(unix)]
#[test]
fn invalid_dbus_strings_fail_without_substitution() {
use std::os::unix::process::ExitStatusExt;
const CHILD: &str = "GHOSTTY_TEST_INVALID_DBUS_VALUE";
if let Ok(kind) = std::env::var(CHILD) {
let value: &[u8] = if kind == "utf8" {
b"--title=bad\xff"
} else {
b"--title=bad\0tail"
};
arguments_variant(&[value.to_vec()]);
panic!("invalid D-Bus argument must abort like Zig/GIO");
}
for kind in ["utf8", "nul"] {
// Disable core dumps in the child without changing this test process.
let result = std::process::Command::new("/bin/sh")
.args(["-c", "ulimit -c 0; exec \"$@\"", "ghostty-dbus-test"])
.arg(std::env::current_exe().unwrap())
.args([
"--exact",
"ipc::new_window::tests::invalid_dbus_strings_fail_without_substitution",
])
.env(CHILD, kind)
.output()
.unwrap();
assert_eq!(result.status.signal(), Some(libc::SIGABRT));
}
}
#[test]
fn arguments_are_plain_dbus_strings_not_escape_encoded() {
let argument = "--working-directory=/tmp/\u{fdd0}é";
let variant = arguments_variant(&[argument.as_bytes().to_vec()]);
assert_eq!(variant.get::<Vec<String>>().unwrap(), [argument]);
assert_eq!(
variant.child_value(0).data(),
[argument.as_bytes(), b"\0"].concat()
);
}
}
@@ -525,7 +525,7 @@ impl Window {
QuickTerminalLayer::Background => layer_shell::Layer::Background, QuickTerminalLayer::Background => layer_shell::Layer::Background,
}); });
window.set_namespace(Some( window.set_namespace(Some(
ghostty_os::osbytes::config_str_to_text(&config.gtk_quick_terminal_namespace).as_str(), config.gtk_quick_terminal_namespace.to_text_lossy().as_str(),
)); ));
window.set_keyboard_mode(match config.quick_terminal_keyboard_interactivity { window.set_keyboard_mode(match config.quick_terminal_keyboard_interactivity {
+1 -1
View File
@@ -118,7 +118,7 @@ impl App {
let xlib_display = unsafe { x11_display.xdisplay() }; let xlib_display = unsafe { x11_display.xdisplay() };
let program_name = match &config.x11_instance_name { let program_name = match &config.x11_instance_name {
Some(pn) => pn.clone(), Some(pn) => pn.to_text_lossy(),
None if cfg!(debug_assertions) => "ghostty-debug".to_string(), None if cfg!(debug_assertions) => "ghostty-debug".to_string(),
None => "ghostty".to_string(), None => "ghostty".to_string(),
}; };
+4 -4
View File
@@ -638,12 +638,12 @@ pub struct CellSize {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetTitle { pub struct SetTitle {
pub title: String, pub title: ghostty_os::osbytes::EncodedOs,
} }
impl std::fmt::Display for SetTitle { impl std::fmt::Display for SetTitle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SetTitle{{ {} }}", self.title) write!(f, "SetTitle{{ {} }}", self.title.to_text_lossy())
} }
} }
@@ -657,7 +657,7 @@ pub struct Pwd {
impl std::fmt::Display for Pwd { impl std::fmt::Display for Pwd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Pwd{{ {} }}", self.pwd.as_encoded()) write!(f, "Pwd{{ {} }}", self.pwd.to_text_lossy())
} }
} }
@@ -686,7 +686,7 @@ pub enum KeySequence {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyTable { pub enum KeyTable {
Activate(String), Activate(ghostty_os::osbytes::EncodedOs),
Deactivate, Deactivate,
DeactivateAll, DeactivateAll,
} }
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct NewWindow {
/// shell should be launched. /// shell should be launched.
/// ///
/// It is an error for this to be `Some`, but zero length. /// It is an error for this to be `Some`, but zero length.
pub arguments: Option<Vec<String>>, pub arguments: Option<Vec<Vec<u8>>>,
} }
/// IPC actions. /// IPC actions.
+1 -1
View File
@@ -291,7 +291,7 @@ pub trait RtSurface {
fn close(&self, process_active: bool); fn close(&self, process_active: bool);
/// Cgroup path of the surface (Linux, `gtk-single-instance` scopes). /// Cgroup path of the surface (Linux, `gtk-single-instance` scopes).
fn cgroup(&self) -> Option<String>; fn cgroup(&self) -> Option<String>;
fn get_title(&self) -> Option<String>; fn get_title(&self) -> Option<ghostty_os::osbytes::EncodedOs>;
fn get_content_scale(&self) -> ContentScale; fn get_content_scale(&self) -> ContentScale;
fn get_size(&self) -> SurfaceSize; fn get_size(&self) -> SurfaceSize;
fn get_cursor_pos(&self) -> CursorPos; fn get_cursor_pos(&self) -> CursorPos;
+13 -5
View File
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
/// Detect the action from the process CLI args (Zig: `detectArgs`). /// Detect the action from the process CLI args (Zig: `detectArgs`).
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> { pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
detect_iter(ghostty_os::args::collect_config_strings()) detect_iter(ghostty_os::args::collect_bytes())
} }
/// Detect the action from any iterator. Each iterator value should yield /// Detect the action from any iterator. Each iterator value should yield
@@ -61,7 +61,7 @@ pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
pub fn detect_iter<E: ActionEnum, I, S>(iter: I) -> Result<Option<E>, DetectError> pub fn detect_iter<E: ActionEnum, I, S>(iter: I) -> Result<Option<E>, DetectError>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
S: AsRef<str>, S: AsRef<[u8]>,
{ {
let mut fallback: Option<E> = None; let mut fallback: Option<E> = None;
let mut pending: Option<E> = None; let mut pending: Option<E> = None;
@@ -69,7 +69,10 @@ where
let arg = arg.as_ref(); let arg = arg.as_ref();
// Allow handling of special cases. // Allow handling of special cases.
if let Some(special) = E::detect_special_case(arg) { if let Some(special) = std::str::from_utf8(arg)
.ok()
.and_then(E::detect_special_case)
{
match special { match special {
SpecialCase::Action(a) => return Ok(Some(a)), SpecialCase::Action(a) => return Ok(Some(a)),
SpecialCase::Fallback(a) => fallback = Some(a), SpecialCase::Fallback(a) => fallback = Some(a),
@@ -82,13 +85,18 @@ where
} }
// Commands must start with "+" // Commands must start with "+"
let Some(name) = arg.strip_prefix('+') else { let Some(name) = arg.strip_prefix(b"+") else {
continue; continue;
}; };
if pending.is_some() { if pending.is_some() {
return Err(DetectError::MultipleActions); return Err(DetectError::MultipleActions);
} }
pending = Some(E::from_name(name).ok_or(DetectError::InvalidAction)?); pending = Some(
std::str::from_utf8(name)
.ok()
.and_then(E::from_name)
.ok_or(DetectError::InvalidAction)?,
);
} }
// If we have an action, we always return that action, even if we've // If we have an action, we always return that action, even if we've
+1 -1
View File
@@ -179,7 +179,7 @@ impl Boo {
} }
/// The `boo` command is used to display the animation from the Ghostty website in the terminal /// The `boo` command is used to display the animation from the Ghostty website in the terminal
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
// Disable on non-desktop systems. // Disable on non-desktop systems.
if cfg!(not(any( if cfg!(not(any(
windows, windows,
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn list_reports(dir: &std::path::Path) -> Result<Vec<Report>, Error> {
/// ///
/// This command currently only supports listing crash reports. Viewing /// This command currently only supports listing crash reports. Viewing
/// and sending crash reports is unimplemented and will be added in the future. /// and sending crash reports is unimplemented and will be added in the future.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options; let mut opts = Options;
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+4 -4
View File
@@ -30,7 +30,7 @@ pub use crate::options::NoOptions as Options;
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`, /// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
/// if both are set. If neither are set, it will print an error /// if both are set. If neither are set, it will print an error
/// and exit. /// and exit.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut stderr = std::io::stderr().lock(); let mut stderr = std::io::stderr().lock();
let mut opts = Options; let mut opts = Options;
@@ -78,14 +78,14 @@ fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
/// Windows branch of `run`: print the path and fail. /// Windows branch of `run`: print the path and fail.
#[cfg(windows)] #[cfg(windows)]
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> { fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
write!( let message = format!(
stderr,
"The `ghostty +edit-config` command is not supported on Windows.\n\ "The `ghostty +edit-config` command is not supported on Windows.\n\
Please edit the configuration file manually at the following path:\n\ Please edit the configuration file manually at the following path:\n\
\n\ \n\
{}\n", {}\n",
path.as_encoded() path.as_encoded()
)?; );
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&message))?;
Ok(1) Ok(1)
} }
+10 -13
View File
@@ -158,7 +158,7 @@ impl Action {
/// ///
/// If the action requests its help text (`--help`/`-h`), the help text /// If the action requests its help text (`--help`/`-h`), the help text
/// is printed to stdout and 0 is returned. /// is printed to stdout and 0 is returned.
pub fn run(self, args: &[String]) -> Result<u8, Error> { pub fn run(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
match self.run_main(args) { match self.run_main(args) {
Err(Error::ActionHelpRequested) => { Err(Error::ActionHelpRequested) => {
// Future note: for now we just output the help text directly // Future note: for now we just output the help text directly
@@ -180,7 +180,7 @@ impl Action {
} }
} }
fn run_main(self, args: &[String]) -> Result<u8, Error> { fn run_main(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
match self { match self {
Action::Version => crate::version::run(args), Action::Version => crate::version::run(args),
Action::Help => crate::help::run(args), Action::Help => crate::help::run(args),
@@ -239,7 +239,7 @@ impl std::fmt::Display for Action {
} }
/// Detect the CLI action from `args` (full argv, `argv[0]` included). /// Detect the CLI action from `args` (full argv, `argv[0]` included).
pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> { pub fn detect(args: &[impl AsRef<[u8]>]) -> Result<Option<Action>, DetectError> {
detect_iter::<Action, _, _>(args.iter()) detect_iter::<Action, _, _>(args.iter())
} }
@@ -248,21 +248,21 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
/// reversible escape encoding of `ghostty_os::osbytes`, which the config /// reversible escape encoding of `ghostty_os::osbytes`, which the config
/// layer (`cli_args`) decodes back to the original bytes. /// layer (`cli_args`) decodes back to the original bytes.
#[must_use] #[must_use]
pub fn os_args_to_strings(args: &[std::ffi::OsString]) -> Vec<String> { pub fn os_args_to_bytes(args: &[std::ffi::OsString]) -> Vec<Vec<u8>> {
args.iter() args.iter()
.map(|a| ghostty_os::osbytes::os_to_config_string(a)) .map(|a| ghostty_os::osbytes::os_bytes(a))
.collect() .collect()
} }
/// [`detect`] over raw OS arguments. /// [`detect`] over raw OS arguments.
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> { pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
detect(&os_args_to_strings(args)) detect(&os_args_to_bytes(args))
} }
/// [`run_from_args`] over raw OS arguments (byte-preserving). /// [`run_from_args`] over raw OS arguments (byte-preserving).
#[must_use] #[must_use]
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> { pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
run_from_args(&os_args_to_strings(args)) run_from_args(&os_args_to_bytes(args))
} }
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the /// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
@@ -272,7 +272,7 @@ pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
/// Returns `None` if no action was requested (the caller should launch the /// Returns `None` if no action was requested (the caller should launch the
/// terminal), or the exit code the process should terminate with. /// terminal), or the exit code the process should terminate with.
#[must_use] #[must_use]
pub fn run_from_args(args: &[String]) -> Option<u8> { pub fn run_from_args(args: &[impl AsRef<[u8]>]) -> Option<u8> {
let action = match detect(args) { let action = match detect(args) {
Ok(a) => a?, Ok(a) => a?,
Err(err) => { Err(err) => {
@@ -370,11 +370,8 @@ mod tests {
std::ffi::OsString::from("+version"), std::ffi::OsString::from("+version"),
]; ];
assert_eq!(Some(Action::Version), detect_os(&args).unwrap()); assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
let strings = os_args_to_strings(&args); let strings = os_args_to_bytes(&args);
assert_eq!( assert_eq!(strings[1], b"--title=\xff");
ghostty_os::osbytes::config_str_to_bytes(&strings[1]),
b"--title=\xff"
);
} }
#[test] #[test]
+1 -1
View File
@@ -22,7 +22,7 @@ cli_options!(Options);
/// ///
/// You can also specify `--help` or `-h` along with any action such as /// You can also specify `--help` or `-h` along with any action such as
/// `+list-themes` to see help for a specific action. /// `+list-themes` to see help for a specific action.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+1 -1
View File
@@ -22,7 +22,7 @@ cli_options!(Options, help);
/// Flags: /// Flags:
/// ///
/// * `--docs`: will print out the documentation for each action. /// * `--docs`: will print out the documentation for each action.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn sorted_colors() -> Vec<(String, (u8, u8, u8))> {
/// ///
/// * `--plain`: will disable formatting and make the output more /// * `--plain`: will disable formatting and make the output more
/// friendly for Unix tooling. This is default when not printing to a tty. /// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+3 -3
View File
@@ -8,10 +8,10 @@ config_struct! {
pub struct Options { pub struct Options {
/// The font family to search for. If this is set, then only fonts /// The font family to search for. If this is set, then only fonts
/// matching this family will be listed. /// matching this family will be listed.
"family" => family: Option<String> = None, "family" => family: Option<ghostty_os::osbytes::EncodedOs> = None,
/// The style name to search for. /// The style name to search for.
"style" => style: Option<String> = None, "style" => style: Option<ghostty_os::osbytes::EncodedOs> = None,
/// Font styles to search for. If this is set, then only fonts that /// Font styles to search for. If this is set, then only fonts that
/// match the given styles will be listed. /// match the given styles will be listed.
@@ -45,7 +45,7 @@ cli_options!(Options, help);
/// * `--family`: Filter results to a specific font family. The family handling /// * `--family`: Filter results to a specific font family. The family handling
/// is identical to the `font-family` set of Ghostty configuration values, so /// is identical to the `font-family` set of Ghostty configuration values, so
/// this can be used to debug why your desired font may not be loading. /// this can be used to debug why your desired font may not be loading.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+4 -3
View File
@@ -57,7 +57,7 @@ cli_options!(Options, help);
/// ///
/// * `--plain`: will disable formatting and make the output more /// * `--plain`: will disable formatting and make the output more
/// friendly for Unix tooling. This is default when not printing to a tty. /// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
@@ -274,12 +274,13 @@ pub fn collect_bindings(keybinds: &Keybinds) -> (Vec<ChordBinding>, u16, u16) {
for (table_name, table_set) in &keybinds.tables { for (table_name, table_set) in &keybinds.tables {
let (mut table_bindings, table_width) = iterate_bindings(table_set); let (mut table_bindings, table_width) = iterate_bindings(table_set);
for b in &mut table_bindings { for b in &mut table_bindings {
b.table_name = Some(table_name.clone()); b.table_name = Some(table_name.to_text_lossy());
} }
bindings.extend(table_bindings); bindings.extend(table_bindings);
widest_chord = widest_chord.max(table_width); widest_chord = widest_chord.max(table_width);
widest_table_prefix = widest_table_prefix.max(gwidth(table_name) + gwidth("/")); widest_table_prefix =
widest_table_prefix.max(gwidth(&table_name.to_text_lossy()) + gwidth("/"));
} }
// std.mem.sort is stable. // std.mem.sort is stable.
+36 -14
View File
@@ -62,11 +62,21 @@ impl ThemeListElement {
/// TODO: use Unicode-aware comparison (as in Zig). /// TODO: use Unicode-aware comparison (as in Zig).
#[must_use] #[must_use]
pub fn order(&self, other: &ThemeListElement) -> Ordering { pub fn order(&self, other: &ThemeListElement) -> Ordering {
let a = self.theme.bytes().map(|c| c.to_ascii_lowercase()); let a = config_str_to_bytes(&self.theme)
let b = other.theme.bytes().map(|c| c.to_ascii_lowercase()); .into_iter()
.map(|c| c.to_ascii_lowercase());
let b = config_str_to_bytes(&other.theme)
.into_iter()
.map(|c| c.to_ascii_lowercase());
a.cmp(b) a.cmp(b)
} }
/// Plain text for TUI display/search, separate from byte-preserving export.
#[must_use]
pub fn display_name(&self) -> String {
ghostty_os::osbytes::config_str_to_text(&self.theme)
}
/// `file://` URI of the theme file (Zig: `toUri`). /// `file://` URI of the theme file (Zig: `toUri`).
#[must_use] #[must_use]
pub fn to_uri(&self) -> String { pub fn to_uri(&self) -> String {
@@ -122,11 +132,11 @@ pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement
let mut themes = vec![]; let mut themes = vec![];
let mut it = LocationIterator::new(resources_dir); let mut it = LocationIterator::new(resources_dir);
while let Some((location, dir)) = it.next_location() { while let Some((location, dir)) = it.next_location() {
let rd = match std::fs::read_dir(dir.to_path()) { let rd = match dir.to_path().and_then(std::fs::read_dir) {
Ok(rd) => rd, Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => { Err(e) => {
eprintln!("error trying to open {}: {e}", dir.as_encoded()); eprintln!("error trying to open {}: {e}", dir.to_text_lossy());
continue; continue;
} }
}; };
@@ -145,7 +155,7 @@ pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement
continue; continue;
} }
themes.push(ThemeListElement { themes.push(ThemeListElement {
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)), path: dir.join(&EncodedOs::from_os(&raw_name)),
location, location,
theme: name, theme: name,
rank: None, rank: None,
@@ -170,7 +180,9 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
/// Make `path` absolute (`Config::load_file` requires absolute paths). /// Make `path` absolute (`Config::load_file` requires absolute paths).
pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs { pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs {
std::path::absolute(path.to_path()).map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p)) path.to_path()
.and_then(std::path::absolute)
.map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
} }
fn resources_dir() -> Option<EncodedOs> { fn resources_dir() -> Option<EncodedOs> {
@@ -216,7 +228,7 @@ fn resources_dir() -> Option<EncodedOs> {
/// ///
/// * `--color`: Specify the color scheme of the themes included in the list. /// * `--color`: Specify the color scheme of the themes included in the list.
/// This can be `dark`, `light`, or `all`. The default is `all`. /// This can be `dark`, `light`, or `all`. The default is `all`.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
@@ -316,12 +328,26 @@ mod tests {
assert_eq!(1, themes.len()); assert_eq!(1, themes.len());
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec()); assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
// The path points at the real file and the listing loads it. // The path points at the real file and the listing loads it.
assert_eq!(themes[0].path.to_path(), dir.join(raw)); assert_eq!(themes[0].path.to_path().unwrap(), dir.join(raw));
let mut out = Vec::new(); let mut out = Vec::new();
write_plain(&mut out, &themes, &Options::default()).unwrap(); write_plain(&mut out, &themes, &Options::default()).unwrap();
assert_eq!(out, b"th\xffeme (resources)\n"); assert_eq!(out, b"th\xffeme (resources)\n");
} }
#[test]
fn theme_display_and_sort_use_original_names() {
let theme = |name: EncodedOs| ThemeListElement {
theme: name.into_encoded(),
path: EncodedOs::new(),
location: Location::Resources,
rank: None,
};
let literal = theme(EncodedOs::from_plain_text("\u{fdd0}é"));
let invalid = theme(EncodedOs::from_bytes(b"\xff"));
assert_eq!(literal.display_name(), "\u{fdd0}é");
assert_eq!(literal.order(&invalid), Ordering::Less);
}
#[test] #[test]
fn collect_sort_and_filter() { fn collect_sort_and_filter() {
let tmp = theme_dir(&[ let tmp = theme_dir(&[
@@ -407,13 +433,9 @@ mod tests {
#[test] #[test]
fn options_color() { fn options_color() {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options( crate::parse_options(&mut opts, &["g", "--color=dark", "--path"]).unwrap();
&mut opts,
&["g".into(), "--color=dark".into(), "--path".into()],
)
.unwrap();
assert_eq!(ColorScheme::Dark, opts.color); assert_eq!(ColorScheme::Dark, opts.color);
assert!(opts.path); assert!(opts.path);
assert!(crate::parse_options(&mut opts, &["g".into(), "--color=blue".into()]).is_err()); assert!(crate::parse_options(&mut opts, &["g", "--color=blue"]).is_err());
} }
} }
+28 -13
View File
@@ -442,7 +442,7 @@ impl Preview {
continue; continue;
} }
if searching { if searching {
theme.rank = rank(&theme.theme, &tokens); theme.rank = rank(&theme.display_name(), &tokens);
if theme.rank.is_some() { if theme.rank.is_some() {
self.filtered.push(i); self.filtered.push(i);
} }
@@ -771,7 +771,7 @@ impl Preview {
theme_list.print("\u{276f} ", self.ui_selected(), row, 0); theme_list.print("\u{276f} ", self.ui_selected(), row, 0);
} }
theme_list.print_ex( theme_list.print_ex(
&theme.theme, &theme.display_name(),
match style { match style {
St::Normal => self.ui_standard(), St::Normal => self.ui_standard(),
St::Highlighted => self.ui_highlighted(), St::Highlighted => self.ui_highlighted(),
@@ -783,7 +783,7 @@ impl Preview {
None, None,
); );
if style == St::Selected { if style == St::Selected {
let name_len = gw(&theme.theme); let name_len = gw(&theme.display_name());
if name_len + 4 < LIST_WIDTH { if name_len + 4 < LIST_WIDTH {
for i in (2 + name_len)..(LIST_WIDTH - 2) { for i in (2 + name_len)..(LIST_WIDTH - 2) {
theme_list.print(" ", self.ui_selected(), row, i); theme_list.print(" ", self.ui_selected(), row, i);
@@ -869,7 +869,7 @@ impl Preview {
height, height,
self.ui_standard(), self.ui_standard(),
); );
let theme_line = format!("theme = {}", theme.theme); let theme_line = format!("theme = {}", theme.display_name());
let save_instructions: [&str; 14] = [ let save_instructions: [&str; 14] = [
"To apply this theme, add the following line to your Ghostty configuration:", "To apply this theme, add the following line to your Ghostty configuration:",
"", "",
@@ -958,7 +958,7 @@ impl Preview {
let child = win.child(x_off, 0, width, area.height); let child = win.child(x_off, 0, width, area.height);
child.fill(ui_std); child.fill(ui_std);
let middle = child.area.height / 2; let middle = child.area.height / 2;
let text = format!("Unable to open {} from:", theme.theme); let text = format!("Unable to open {} from:", theme.display_name());
child.print( child.print(
&text, &text,
ui_err, ui_err,
@@ -966,10 +966,10 @@ impl Preview {
(child.area.width / 2).saturating_sub(gw(&text) / 2), (child.area.width / 2).saturating_sub(gw(&text) / 2),
); );
child.print_ex( child.print_ex(
theme.path.as_encoded(), &theme.path.to_text_lossy(),
ui_err, ui_err,
middle, middle,
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2), (child.area.width / 2).saturating_sub(gw(&theme.path.to_text_lossy()) / 2),
Some(&theme.to_uri()), Some(&theme.to_uri()),
None, None,
); );
@@ -1017,18 +1017,18 @@ impl Preview {
child.fill(standard); child.fill(standard);
let uri = theme.to_uri(); let uri = theme.to_uri();
child.print_ex( child.print_ex(
&theme.theme, &theme.display_name(),
standard_bold_italic, standard_bold_italic,
1, 1,
(child.area.width / 2).saturating_sub(gw(&theme.theme) / 2), (child.area.width / 2).saturating_sub(gw(&theme.display_name()) / 2),
Some(&uri), Some(&uri),
None, None,
); );
child.print_ex( child.print_ex(
theme.path.as_encoded(), &theme.path.to_text_lossy(),
standard, standard,
2, 2,
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2), (child.area.width / 2).saturating_sub(gw(&theme.path.to_text_lossy()) / 2),
Some(&uri), Some(&uri),
None, None,
); );
@@ -1048,7 +1048,7 @@ impl Preview {
); );
for (i, diag) in config.diagnostics.items().iter().enumerate() { for (i, diag) in config.diagnostics.items().iter().enumerate() {
child.print( child.print(
&diag.to_string(), &ghostty_os::osbytes::config_str_to_text(&diag.to_string()),
ui_err, ui_err,
2 + u16::try_from(i).unwrap_or(0), 2 + u16::try_from(i).unwrap_or(0),
2, 2,
@@ -1409,7 +1409,7 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> { pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> {
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path() let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))? .map_err(|e| Error::Other(e.to_string()))?
.to_path(); .to_path()?;
let base_dir = main_cfg_path let base_dir = main_cfg_path
.parent() .parent()
.ok_or_else(|| Error::Other("BadPathName".to_string()))?; .ok_or_else(|| Error::Other("BadPathName".to_string()))?;
@@ -1957,6 +1957,21 @@ mod tests {
assert_eq!(Mode::Normal, p.mode); assert_eq!(Mode::Normal, p.mode);
} }
#[test]
fn theme_preview_renders_plain_unicode_names() {
let tmp = tempfile::tempdir().unwrap();
let mut p = app(&tmp);
let name = "Alpha\u{fdd0}é";
p.themes[0].theme = ghostty_os::osbytes::EncodedOs::from_plain_text(name).into_encoded();
render(&mut p, 120, 60);
assert!(p.overlays().iter().any(|overlay| overlay.text == name));
assert!(
!p.overlays()
.iter()
.any(|overlay| overlay.text.contains("\u{fdd0}\u{fdd0}"))
);
}
#[test] #[test]
fn draw_layout() { fn draw_layout() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+72 -34
View File
@@ -38,49 +38,58 @@ pub struct Options {
/// All of the arguments after `+new-window`. They will be sent to Ghostty /// All of the arguments after `+new-window`. They will be sent to Ghostty
/// for processing. /// for processing.
pub arguments: Vec<String>, pub arguments: Vec<Vec<u8>>,
}
fn is_ascii_ws(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
} }
impl Options { impl Options {
fn check_arg(&mut self, arg: &str) -> Result<Option<String>, ArgError> { fn check_arg(&mut self, arg: &[u8]) -> Result<Option<Vec<u8>>, ArgError> {
if let Some(rest) = arg.strip_prefix("--class=") { if let Some(rest) = arg.strip_prefix(b"--class=") {
self.class = Some(rest.trim_matches(is_ascii_ws).to_string()); self.class = Some(
std::str::from_utf8(ghostty_config::cli_args::trim_ascii_whitespace(rest))
.map_err(|_| ArgError::InvalidValue)?
.to_owned(),
);
return Ok(None); return Ok(None);
} }
if let Some(rest) = arg.strip_prefix("--working-directory=") { if let Some(rest) = arg.strip_prefix(b"--working-directory=") {
let stripped = rest.trim_matches(is_ascii_ws); let stripped = ghostty_config::cli_args::trim_ascii_whitespace(rest);
if stripped == "home" || stripped == "inherit" { if stripped == b"home" || stripped == b"inherit" {
return Ok(Some(arg.to_string())); return Ok(Some(arg.to_vec()));
} }
// The CLI argument is an encoded OS string. // Preserve the original argument bytes while expanding its path.
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped)) let expanded = ghostty_os::expand_home(&EncodedOs::from_bytes(stripped))
.map_err(|e| ArgError::Other(e.to_string()))?; .map_err(|e| ArgError::Other(e.to_string()))?;
let realpath = std::fs::canonicalize(expanded.to_path()) let realpath = expanded
.to_path()
.and_then(std::fs::canonicalize)
.map_err(|e| ArgError::Other(e.to_string()))?; .map_err(|e| ArgError::Other(e.to_string()))?;
self.working_directory_seen = true; self.working_directory_seen = true;
return Ok(Some(format!( return Ok(Some(
"--working-directory={}", [
EncodedOs::from_path(&realpath).as_encoded() b"--working-directory=".as_slice(),
))); &EncodedOs::from_path(&realpath).to_bytes(),
]
.concat(),
));
} }
Ok(Some(arg.to_string())) Ok(Some(arg.to_vec()))
} }
} }
impl CliTarget for Options { impl CliTarget for Options {
fn parse_into_field(&mut self, _key: &str, _value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, _key: &str, _value: Option<&[u8]>) -> Result<(), ArgError> {
Err(ArgError::InvalidField) Err(ArgError::InvalidField)
} }
/// Manual parse hook, collect all of the arguments after `+new-window`. /// Manual parse hook, collect all of the arguments after `+new-window`.
fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result<bool, ArgError> { fn parse_manually_hook(
let mut e_seen = arg == "-e"; &mut self,
arg: &[u8],
iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> {
let mut e_seen = arg == b"-e";
// Include the argument that triggered the manual parse hook. // Include the argument that triggered the manual parse hook.
if let Some(a) = self.check_arg(arg)? { if let Some(a) = self.check_arg(arg)? {
@@ -93,7 +102,7 @@ impl CliTarget for Options {
self.arguments.push(param); self.arguments.push(param);
continue; continue;
} }
if param == "-e" { if param == b"-e" {
e_seen = true; e_seen = true;
self.arguments.push(param); self.arguments.push(param);
continue; continue;
@@ -177,9 +186,10 @@ impl CliTarget for Options {
/// default command. /// default command.
/// ///
/// Available since: 1.2.0 /// Available since: 1.2.0
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
let mut iter = ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).cloned()); let mut iter =
ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).map(AsRef::as_ref));
match ghostty_config::cli_args::parse(&mut opts, &mut iter) { match ghostty_config::cli_args::parse(&mut opts, &mut iter) {
Ok(()) => {} Ok(()) => {}
Err(ArgError::Other(n)) if n == crate::error::HELP_ERROR_NAME => { Err(ArgError::Other(n)) if n == crate::error::HELP_ERROR_NAME => {
@@ -196,10 +206,11 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
let wd = std::fs::canonicalize(".")?; let wd = std::fs::canonicalize(".")?;
opts.arguments.insert( opts.arguments.insert(
0, 0,
format!( [
"--working-directory={}", b"--working-directory=".as_slice(),
ghostty_os::osbytes::path_to_config_string(&wd) &EncodedOs::from_path(&wd).to_bytes(),
), ]
.concat(),
); );
} }
@@ -259,7 +270,13 @@ mod tests {
fn gathers_arguments_and_class() { fn gathers_arguments_and_class() {
let o = parse_str("--class=foo.bar --title=hi --command=ls"); let o = parse_str("--class=foo.bar --title=hi --command=ls");
assert_eq!(Some("foo.bar".to_string()), o.class); assert_eq!(Some("foo.bar".to_string()), o.class);
assert_eq!(vec!["--title=hi", "--command=ls"], o.arguments); assert_eq!(
vec!["--title=hi", "--command=ls"],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
);
assert!(!o.working_directory_seen); assert!(!o.working_directory_seen);
} }
@@ -275,6 +292,9 @@ mod tests {
"--working-directory=/nonexistent" "--working-directory=/nonexistent"
], ],
o.arguments o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
); );
} }
@@ -282,17 +302,35 @@ mod tests {
fn working_directory_is_resolved() { fn working_directory_is_resolved() {
let o = parse_str("--working-directory=/"); let o = parse_str("--working-directory=/");
assert!(o.working_directory_seen); assert!(o.working_directory_seen);
assert_eq!(vec!["--working-directory=/"], o.arguments); assert_eq!(
vec!["--working-directory=/"],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
);
let o = parse_str("--working-directory=home"); let o = parse_str("--working-directory=home");
assert!(!o.working_directory_seen); assert!(!o.working_directory_seen);
assert_eq!(vec!["--working-directory=home"], o.arguments); assert_eq!(
vec!["--working-directory=home"],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
);
} }
#[test] #[test]
fn help_is_gathered_not_special() { fn help_is_gathered_not_special() {
// Like Zig, the manual hook runs before the help check. // Like Zig, the manual hook runs before the help check.
let o = parse_str("--help"); let o = parse_str("--help");
assert_eq!(vec!["--help"], o.arguments); assert_eq!(
vec!["--help"],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
);
} }
#[test] #[test]
+9 -9
View File
@@ -15,9 +15,9 @@ macro_rules! cli_options {
fn parse_into_field( fn parse_into_field(
&mut self, &mut self,
key: &str, key: &str,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
Self::valid_values_for(key) Self::valid_values_for(key)
@@ -43,9 +43,9 @@ macro_rules! cli_options {
fn parse_into_field( fn parse_into_field(
&mut self, &mut self,
key: &str, key: &str,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
Self::valid_values_for(key) Self::valid_values_for(key)
@@ -66,9 +66,9 @@ macro_rules! cli_options {
fn parse_into_field( fn parse_into_field(
&mut self, &mut self,
key: &str, key: &str,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> { ) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> { fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
Self::valid_values_for(key) Self::valid_values_for(key)
@@ -87,8 +87,8 @@ macro_rules! cli_options {
/// Parse the CLI arguments `args` (the full argv, including `argv[0]`, /// Parse the CLI arguments `args` (the full argv, including `argv[0]`,
/// which is skipped; `+action` arguments are ignored) into `opts` /// which is skipped; `+action` arguments are ignored) into `opts`
/// (Zig: `args.argsIterator` + `args.parse`). /// (Zig: `args.argsIterator` + `args.parse`).
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[String]) -> Result<(), Error> { pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[impl AsRef<[u8]>]) -> Result<(), Error> {
let mut iter = ArgsIterator::new(args.iter().skip(1).cloned()); let mut iter = ArgsIterator::new(args.iter().skip(1).map(AsRef::as_ref));
cli_args::parse(opts, &mut iter)?; cli_args::parse(opts, &mut iter)?;
Ok(()) Ok(())
} }
@@ -101,7 +101,7 @@ impl CliTarget for NoOptions {
fn parse_into_field( fn parse_into_field(
&mut self, &mut self,
_key: &str, _key: &str,
_value: Option<&str>, _value: Option<&[u8]>,
) -> Result<(), cli_args::ArgError> { ) -> Result<(), cli_args::ArgError> {
Err(cli_args::ArgError::InvalidField) Err(cli_args::ArgError::InvalidField)
} }
+77 -2
View File
@@ -49,7 +49,7 @@ cli_options!(Options, help);
/// * `--docs`: Print the documentation above each option as a comment, /// * `--docs`: Print the documentation above each option as a comment,
/// This is very noisy but is very useful to learn about available /// This is very noisy but is very useful to learn about available
/// options, especially paired with `--default`. /// options, especially paired with `--default`.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
@@ -113,6 +113,81 @@ mod tests {
assert!(s.contains("\nfont-size = 12\n")); assert!(s.contains("\nfont-size = 12\n"));
} }
#[test]
fn show_config_preserves_raw_and_text_fields_on_reload() {
use ghostty_config::cli_args::LineIterator;
let raw = b"title=x\xff\xef\xb7\x90\xc3\xa9\nfont-style=style\xfe\xef\xb7\x90\nfont-codepoint-map=U+0041=family\xff\nselection-word-chars=\xef\xb7\x90\xc3\xa9\nclipboard-codepoint-map=U+0041=\xef\xb7\x90\xc3\xa9\nkeybind=\xef\xb7\x90=set_surface_title:payload\ncommand-palette-entry=title:\"name\\xff\",action:\"text:payload\\xfe\"\n";
let mut config = Config::new_default();
config
.load_iter(&mut LineIterator::new(raw.as_slice()))
.unwrap();
assert_eq!(config.diagnostics.items(), []);
let output = format_config_bytes(&config, &Options::default()).unwrap();
for line in [
b"title = x\xff\xef\xb7\x90\xc3\xa9".as_slice(),
b"font-style = style\xfe\xef\xb7\x90",
b"font-codepoint-map = U+0041=family\xff",
b"selection-word-chars = \xef\xb7\x90\xc3\xa9",
b"clipboard-codepoint-map = U+0041=\xef\xb7\x90\xc3\xa9",
b"keybind = \xef\xb7\x90=set_surface_title:payload",
] {
assert!(
output.split(|b| *b == b'\n').any(|value| value == line),
"missing {line:?} in {output:?}"
);
}
let mut reloaded = Config::new_default();
reloaded
.load_iter(&mut LineIterator::new(output.as_slice()))
.unwrap();
assert_eq!(reloaded.diagnostics.items(), []);
assert_eq!(reloaded.title, config.title);
assert_eq!(reloaded.font_style, config.font_style);
assert_eq!(reloaded.font_codepoint_map, config.font_codepoint_map);
assert_eq!(reloaded.selection_word_chars, config.selection_word_chars);
assert_eq!(
reloaded.clipboard_codepoint_map,
config.clipboard_codepoint_map
);
let trigger = ghostty_input::binding::Trigger::parse("\u{fdd0}").unwrap();
assert_eq!(
reloaded
.keybind
.set
.get(trigger)
.unwrap()
.as_leaf()
.unwrap()
.action,
config
.keybind
.set
.get(trigger)
.unwrap()
.as_leaf()
.unwrap()
.action,
);
assert_eq!(
reloaded.command_palette_entry.value.last(),
config.command_palette_entry.value.last()
);
}
#[test]
fn show_config_action_escaping_uses_original_bytes() {
use ghostty_config::cli_args::LineIterator;
let mut config = Config::new_default();
config
.load_iter(&mut LineIterator::new(
b"keybind=a=set_surface_title:payload\xff\xef\xb7\x90".as_slice(),
))
.unwrap();
let output = format_config_bytes(&config, &Options::default()).unwrap();
// Zig formats action byte strings with std.zig.stringEscape.
assert!(output.ends_with(b"keybind = a=set_surface_title:payload\\xff\\xef\\xb7\\x90\n"));
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn show_config_round_trips_non_utf8_and_fdd0_paths() { fn show_config_round_trips_non_utf8_and_fdd0_paths() {
@@ -152,7 +227,7 @@ mod tests {
assert!(opts.default); assert!(opts.default);
assert!(!opts.changes_only); assert!(!opts.changes_only);
assert!(matches!( assert!(matches!(
crate::parse_options(&mut opts, &["g".into(), "--help".into()]), crate::parse_options(&mut opts, &["g", "--help"]),
Err(Error::ActionHelpRequested) Err(Error::ActionHelpRequested)
)); ));
} }
+1 -1
View File
@@ -78,7 +78,7 @@ cli_options!(Options, help, diagnostics);
/// * `--presentation`: If set, force searching for a specific presentation /// * `--presentation`: If set, force searching for a specific presentation
/// style. Valid options are `text` and `emoji`. If unset, the presentation /// style. Valid options are `text` and `emoji`. If unset, the presentation
/// style of a codepoint will be inferred from the Unicode standard. /// style of a codepoint will be inferred from the Unicode standard.
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
+1 -1
View File
@@ -46,7 +46,7 @@ cli_options!(Options, help);
/// ghostty +ssh-cache --remove=example.com # Remove host from cache /// ghostty +ssh-cache --remove=example.com # Remove host from cache
/// ghostty +ssh-cache --clear # Clear entire cache /// ghostty +ssh-cache --clear # Clear entire cache
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period /// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
@@ -59,7 +59,7 @@ impl DiskCache {
home: None, home: None,
}) })
.map_err(|_| Error::XdgLookupFailed)?; .map_err(|_| Error::XdgLookupFailed)?;
Ok(state_dir.to_path().join("ssh_cache")) Ok(state_dir.to_path()?.join("ssh_cache"))
} }
/// Clear all cache data stored in the disk cache. /// Clear all cache data stored in the disk cache.
+7 -7
View File
@@ -11,7 +11,7 @@ config_struct! {
pub struct Options { pub struct Options {
/// The path of the config file to validate. If this isn't specified, /// The path of the config file to validate. If this isn't specified,
/// then the default config file paths will be validated. /// then the default config file paths will be validated.
"config-file" => config_file: Option<String> = None, "config-file" => config_file: Option<EncodedOs> = None,
} }
} }
cli_options!(Options, help); cli_options!(Options, help);
@@ -25,7 +25,7 @@ cli_options!(Options, help);
/// ///
/// * `--config-file`: can be passed to validate a specific target config file in /// * `--config-file`: can be passed to validate a specific target config file in
/// a non-default location /// a non-default location
pub fn run(args: &[String]) -> Result<u8, Error> { pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default(); let mut opts = Options::default();
crate::parse_options(&mut opts, args)?; crate::parse_options(&mut opts, args)?;
@@ -42,7 +42,7 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
if let Some(config_path) = &opts.config_file { if let Some(config_path) = &opts.config_file {
// Byte-preserving: the option string carries non-UTF-8 bytes via the // Byte-preserving: the option string carries non-UTF-8 bytes via the
// reversible `osbytes` escape and the canonical path may too. // reversible `osbytes` escape and the canonical path may too.
let abs_path = std::fs::canonicalize(EncodedOs::from_encoded(config_path).to_path())?; let abs_path = std::fs::canonicalize(config_path.to_path()?)?;
cfg.load_file(&EncodedOs::from_path(&abs_path))?; cfg.load_file(&EncodedOs::from_path(&abs_path))?;
cfg.load_recursive_files()?; cfg.load_recursive_files()?;
} else { } else {
@@ -83,7 +83,7 @@ mod tests {
fn validate_good_file() { fn validate_good_file() {
let p = write_tmp("good", "font-size = 14\n"); let p = write_tmp("good", "font-size = 14\n");
let opts = Options { let opts = Options {
config_file: Some(p), config_file: Some(p.into()),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
assert_eq!(0, run_inner(&opts, &mut out).unwrap()); assert_eq!(0, run_inner(&opts, &mut out).unwrap());
@@ -94,7 +94,7 @@ mod tests {
fn validate_bad_file_prints_diagnostics() { fn validate_bad_file_prints_diagnostics() {
let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n"); let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n");
let opts = Options { let opts = Options {
config_file: Some(p.clone()), config_file: Some(p.clone().into()),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
assert_eq!(1, run_inner(&opts, &mut out).unwrap()); assert_eq!(1, run_inner(&opts, &mut out).unwrap());
@@ -117,7 +117,7 @@ mod tests {
let path = tmp.path().join("config"); let path = tmp.path().join("config");
std::fs::write(&path, b"bad\xffkey = 1\n").unwrap(); std::fs::write(&path, b"bad\xffkey = 1\n").unwrap();
let opts = Options { let opts = Options {
config_file: Some(path.to_string_lossy().into_owned()), config_file: Some(EncodedOs::from_path(&path)),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
assert_eq!(1, run_inner(&opts, &mut out).unwrap()); assert_eq!(1, run_inner(&opts, &mut out).unwrap());
@@ -148,7 +148,7 @@ mod tests {
let link = tmp.path().join("link"); let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap();
let opts = Options { let opts = Options {
config_file: Some(link.to_string_lossy().into_owned()), config_file: Some(EncodedOs::from_path(&link)),
}; };
let mut out = Vec::new(); let mut out = Vec::new();
assert_eq!(0, run_inner(&opts, &mut out).unwrap()); assert_eq!(0, run_inner(&opts, &mut out).unwrap());
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::{
/// The `version` command is used to display information about Ghostty. Recognized as /// The `version` command is used to display information about Ghostty. Recognized as
/// either `+version` or `--version`. /// either `+version` or `--version`.
pub fn run(_args: &[String]) -> Result<u8, Error> { pub fn run(_args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let stdout = std::io::stdout(); let stdout = std::io::stdout();
let tty = stdout.is_terminal(); let tty = stdout.is_terminal();
let mut out = std::io::BufWriter::new(stdout.lock()); let mut out = std::io::BufWriter::new(stdout.lock());
+234 -135
View File
@@ -30,6 +30,39 @@ pub fn trim_ws(s: &str) -> &str {
s.trim_matches(|c| c == ' ' || c == '\t') s.trim_matches(|c| c == ' ' || c == '\t')
} }
/// Trim Zig's `std.ascii.whitespace`, including vertical tab.
pub fn trim_ascii_whitespace(value: &[u8]) -> &[u8] {
let is_whitespace = |b: &u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c);
let start = value
.iter()
.position(|b| !is_whitespace(b))
.unwrap_or(value.len());
let end = value
.iter()
.rposition(|b| !is_whitespace(b))
.map_or(start, |i| i + 1);
&value[start..end]
}
/// Validate at a text field's parsing boundary, never in the line/argument reader.
pub fn text_value(value: Option<&[u8]>) -> Result<Option<&str>, ArgError> {
value
.map(|v| std::str::from_utf8(v).map_err(|_| ArgError::InvalidValue))
.transpose()
}
pub fn trim_bytes(value: &[u8]) -> &[u8] {
let start = value
.iter()
.position(|b| !matches!(b, b' ' | b'\t'))
.unwrap_or(value.len());
let end = value
.iter()
.rposition(|b| !matches!(b, b' ' | b'\t'))
.map_or(start, |i| i + 1);
&value[start..end]
}
/// The base errors for arg parsing. Additional errors can be returned due /// The base errors for arg parsing. Additional errors can be returned due
/// to type-specific parsing but these are always possible. /// to type-specific parsing but these are always possible.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -103,7 +136,12 @@ pub trait ConfigField: Clone + PartialEq + Default + fmt::Debug {
/// Parse `value` (the text after `=`, `None` if there was no `=`) into /// Parse `value` (the text after `=`, `None` if there was no `=`) into
/// `self`. Repeatable types accumulate; scalar types overwrite. /// `self`. Repeatable types accumulate; scalar types overwrite.
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError>; fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError>;
/// Convenience for plain UTF-8 input. Never accepts the OS escape encoding.
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
self.parse_bytes(value.map(str::as_bytes))
}
/// Called when the value was set to the empty string: reset to the /// Called when the value was set to the empty string: reset to the
/// field's default value (Zig: the `init` function or the declared /// field's default value (Zig: the `init` function or the declared
@@ -143,7 +181,7 @@ pub trait CliTarget {
/// Parse a single key/value pair into the destination /// Parse a single key/value pair into the destination
/// (Zig: `parseIntoField`). Returns `ArgError::InvalidField` if the key /// (Zig: `parseIntoField`). Returns `ArgError::InvalidField` if the key
/// is unknown. /// is unknown.
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError>; fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError>;
/// For enum-typed fields, the valid values (used for diagnostics). /// For enum-typed fields, the valid values (used for diagnostics).
fn valid_values(&self, _key: &str) -> Option<&'static [&'static str]> { fn valid_values(&self, _key: &str) -> Option<&'static [&'static str]> {
@@ -159,7 +197,7 @@ pub trait CliTarget {
/// stop parsing. /// stop parsing.
fn parse_manually_hook( fn parse_manually_hook(
&mut self, &mut self,
_arg: &str, _arg: &[u8],
_iter: &mut dyn ArgIter, _iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> { ) -> Result<bool, ArgError> {
Ok(true) Ok(true)
@@ -177,14 +215,14 @@ pub trait CliTarget {
/// Compatibility handler (Zig: `compatibility` decl). Called when /// Compatibility handler (Zig: `compatibility` decl). Called when
/// parsing a field fails. Return true if handled. /// parsing a field fails. Return true if handled.
fn compatibility(&mut self, _key: &str, _value: Option<&str>) -> bool { fn compatibility(&mut self, _key: &str, _value: Option<&[u8]>) -> bool {
false false
} }
} }
/// An iterator over arguments (Zig: any iterator with `next`). /// An iterator over arguments (Zig: any iterator with `next`).
pub trait ArgIter { pub trait ArgIter {
fn next_arg(&mut self) -> Option<String>; fn next_arg(&mut self) -> Option<Vec<u8>>;
/// Location for diagnostics. /// Location for diagnostics.
fn location(&self) -> Option<Location> { fn location(&self) -> Option<Location> {
@@ -206,7 +244,7 @@ pub fn parse<T: CliTarget + ?Sized>(dst: &mut T, iter: &mut dyn ArgIter) -> Resu
// If the destination supports help then we check for it, call // If the destination supports help then we check for it, call
// the help function and return. // the help function and return.
if dst.supports_help() && (arg == "--help" || arg == "-h") { if dst.supports_help() && (arg == b"--help" || arg == b"-h") {
dst.help()?; dst.help()?;
return Ok(()); return Ok(());
} }
@@ -214,24 +252,27 @@ pub fn parse<T: CliTarget + ?Sized>(dst: &mut T, iter: &mut dyn ArgIter) -> Resu
// If this doesn't start with "--" then it isn't a config // If this doesn't start with "--" then it isn't a config
// flag. We don't support positional arguments or configuration // flag. We don't support positional arguments or configuration
// values set with spaces so this is an error. // values set with spaces so this is an error.
let Some(rest) = arg.strip_prefix("--") else { let Some(rest) = arg.strip_prefix(b"--") else {
let loc = iter.location().unwrap_or_default(); let loc = iter.location().unwrap_or_default();
let Some(diags) = dst.diagnostics_mut() else { let Some(diags) = dst.diagnostics_mut() else {
return Err(ArgError::InvalidField); return Err(ArgError::InvalidField);
}; };
diags.append(Diagnostic { diags.append(Diagnostic {
key: arg.clone(), key: ghostty_os::osbytes::bytes_to_config_string(&arg),
message: "invalid field".to_string(), message: "invalid field".to_string(),
location: loc, location: loc,
}); });
continue; continue;
}; };
let (key, value): (&str, Option<&str>) = match rest.find('=') { let (key, value) = match rest.iter().position(|b| *b == b'=') {
Some(idx) => (&rest[..idx], Some(&rest[idx + 1..])), Some(idx) => (&rest[..idx], Some(&rest[idx + 1..])),
None => (rest, None), None => (rest, None),
}; };
// Keys are ASCII names. Keep arbitrary unknown keys byte-faithful in diagnostics.
let key = ghostty_os::osbytes::bytes_to_config_string(key);
let key = key.as_str();
if let Err(err) = dst.parse_into_field(key, value) { if let Err(err) = dst.parse_into_field(key, value) {
// Fall back to compatibility handlers if able. // Fall back to compatibility handlers if able.
if dst.compatibility(key, value) { if dst.compatibility(key, value) {
@@ -251,7 +292,10 @@ pub fn parse<T: CliTarget + ?Sized>(dst: &mut T, iter: &mut dyn ArgIter) -> Resu
} }
ArgError::InvalidValue => { ArgError::InvalidValue => {
let mut m = match value { let mut m = match value {
Some(v) => format!("invalid value \"{v}\""), Some(v) => format!(
"invalid value \"{}\"",
ghostty_os::osbytes::bytes_to_config_string(v)
),
None => "invalid value \"null\"".to_string(), None => "invalid value \"null\"".to_string(),
}; };
format_values(dst.valid_values(key), &mut m); format_values(dst.valid_values(key), &mut m);
@@ -286,7 +330,7 @@ fn format_values(values: Option<&[&str]>, out: &mut String) {
pub fn compatibility_renamed<T: CliTarget + ?Sized>( pub fn compatibility_renamed<T: CliTarget + ?Sized>(
dst: &mut T, dst: &mut T,
to: &str, to: &str,
value: Option<&str>, value: Option<&[u8]>,
) -> bool { ) -> bool {
match dst.parse_into_field(to, value) { match dst.parse_into_field(to, value) {
Ok(()) => true, Ok(()) => true,
@@ -307,6 +351,14 @@ pub fn split_tagged_union(v: &str) -> (&str, &str) {
} }
} }
/// Split an ASCII union tag while preserving arbitrary payload bytes.
pub fn split_tagged_union_bytes(v: &[u8]) -> (&[u8], &[u8]) {
match v.iter().position(|b| *b == b':') {
Some(i) => (trim_bytes(&v[..i]), &v[i + 1..]),
None => (trim_bytes(v), b""),
}
}
/// Parse an "auto struct" value like `a:hello,b:42` (Zig: `parseAutoStruct`). /// Parse an "auto struct" value like `a:hello,b:42` (Zig: `parseAutoStruct`).
/// ///
/// Entries are split by commas (with quoting / escaping, see /// Entries are split by commas (with quoting / escaping, see
@@ -320,21 +372,39 @@ pub fn parse_auto_struct_with(
v: &str, v: &str,
mut set: impl FnMut(&str, &str) -> Result<bool, ArgError>, mut set: impl FnMut(&str, &str) -> Result<bool, ArgError>,
) -> Result<(), ArgError> { ) -> Result<(), ArgError> {
let mut iter = CommaSplitter::new(v); parse_auto_struct_bytes_with(v.as_bytes(), |k, v| {
while let Some(entry) = iter.next_field()? { set(
let idx = entry.find(':').ok_or(ArgError::InvalidValue)?; k,
let key = trim_ws(&entry[..idx]); std::str::from_utf8(v).map_err(|_| ArgError::InvalidValue)?,
let raw = trim_ws(&entry[idx + 1..]); )
})
}
pub fn parse_auto_struct_bytes_with(
v: &[u8],
mut set: impl FnMut(&str, &[u8]) -> Result<bool, ArgError>,
) -> Result<(), ArgError> {
let mut iter = CommaSplitter::from_bytes(v);
while let Some(entry) = iter.next_bytes()? {
let idx = entry
.iter()
.position(|b| *b == b':')
.ok_or(ArgError::InvalidValue)?;
let key = trim_bytes(&entry[..idx]);
let raw = trim_bytes(&entry[idx + 1..]);
let decoded; let decoded;
let value = if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') { let value = if raw.len() >= 2 && raw.starts_with(b"\"") && raw.ends_with(b"\"") {
decoded = parse_zig_string_literal(raw).ok_or(ArgError::InvalidValue)?; decoded = parse_zig_string_literal_bytes(raw).ok_or(ArgError::InvalidValue)?;
decoded.as_str() decoded.as_slice()
} else { } else {
raw raw
}; };
if !set(key, value)? { if !set(
std::str::from_utf8(key).map_err(|_| ArgError::InvalidValue)?,
value,
)? {
return Err(ArgError::InvalidValue); return Err(ArgError::InvalidValue);
} }
} }
@@ -342,22 +412,18 @@ pub fn parse_auto_struct_with(
} }
/// Decode a double-quoted Zig string literal (including the quotes). /// Decode a double-quoted Zig string literal (including the quotes).
/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form /// Returns `None` on failure or invalid UTF-8. Byte fields use
/// valid UTF-8 stay raw bytes: they are carried through the reversible /// [`parse_zig_string_literal_bytes`] to retain arbitrary literal and escaped bytes.
/// `ghostty_os::osbytes` escape encoding (`\xFF` -> `U+FDD0 U+00FF`, decoded back
/// to `0xFF` by `config_str_to_bytes`). See [`parse_zig_string_literal_bytes`]
/// for the raw bytes.
#[must_use] #[must_use]
pub fn parse_zig_string_literal(s: &str) -> Option<String> { pub fn parse_zig_string_literal(s: &str) -> Option<String> {
parse_zig_string_literal_bytes(s).map(|b| ghostty_os::osbytes::bytes_to_config_string(&b)) String::from_utf8(parse_zig_string_literal_bytes(s)?).ok()
} }
/// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal` /// Byte-exact [`parse_zig_string_literal`] (Zig `std.zig.string_literal`
/// decoding yields a `[]u8`). /// decoding yields a `[]u8`).
#[must_use] #[must_use]
pub fn parse_zig_string_literal_bytes(s: &str) -> Option<Vec<u8>> { pub fn parse_zig_string_literal_bytes(s: impl AsRef<[u8]>) -> Option<Vec<u8>> {
let b = ghostty_os::osbytes::config_str_to_bytes(s); let b = s.as_ref();
let b = b.as_slice();
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' { if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
return None; return None;
} }
@@ -494,7 +560,8 @@ impl ConfigField for bool {
CValue::Bool(*self) CValue::Bool(*self)
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
*self = parse_bool(value.unwrap_or("t"))?; *self = parse_bool(value.unwrap_or("t"))?;
Ok(()) Ok(())
} }
@@ -512,7 +579,8 @@ macro_rules! impl_int_field {
f(*self) f(*self)
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?; let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?;
*self = <$t>::try_from(n).map_err(|_| ArgError::InvalidValue)?; *self = <$t>::try_from(n).map_err(|_| ArgError::InvalidValue)?;
@@ -546,7 +614,8 @@ macro_rules! impl_float_field {
f(*self) f(*self)
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
*self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?; *self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?;
Ok(()) Ok(())
@@ -562,20 +631,44 @@ impl_float_field!(f32 => CValue::F32, f64 => CValue::F64);
impl ConfigField for String { impl ConfigField for String {
fn c_value_optional(&self) -> CValue { fn c_value_optional(&self) -> CValue {
CValue::String(self.clone()) CValue::String(ghostty_os::osbytes::valid_str_to_config_string(self))
} }
fn c_value_none() -> CValue { fn c_value_none() -> CValue {
CValue::NullString CValue::NullString
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
*self = value.ok_or(ArgError::ValueRequired)?.to_string(); *self = value.ok_or(ArgError::ValueRequired)?.to_string();
Ok(()) Ok(())
} }
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
writeln!(f.writer, "{} = {}", f.name, self) writeln!(
f.writer,
"{} = {}",
f.name,
ghostty_os::osbytes::valid_str_to_config_string(self)
)
}
}
impl ConfigField for ghostty_os::osbytes::EncodedOs {
fn c_value_optional(&self) -> CValue {
CValue::String(self.as_encoded().to_owned())
}
fn c_value_none() -> CValue {
CValue::NullString
}
fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
*self = Self::from_bytes(value.ok_or(ArgError::ValueRequired)?);
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
writeln!(f.writer, "{} = {}", f.name, self.as_encoded())
} }
} }
@@ -593,13 +686,13 @@ impl<T: ConfigField> ConfigField for Option<T> {
} }
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
// For optional fields, we just treat it as the child type. // For optional fields, we just treat it as the child type.
if let Some(v) = self { if let Some(v) = self {
v.parse_cli(value) v.parse_bytes(value)
} else { } else {
let mut tmp = T::default(); let mut tmp = T::default();
tmp.parse_cli(value)?; tmp.parse_bytes(value)?;
*self = Some(tmp); *self = Some(tmp);
Ok(()) Ok(())
} }
@@ -622,7 +715,7 @@ impl<T: ConfigField> ConfigField for Option<T> {
/// ///
/// This also ignores any argument that starts with `+`. It assumes that /// This also ignores any argument that starts with `+`. It assumes that
/// actions were parsed out before this iterator was created. /// actions were parsed out before this iterator was created.
pub struct ArgsIterator<I: Iterator<Item = String>> { pub struct ArgsIterator<I: Iterator> {
/// The underlying args iterator. /// The underlying args iterator.
pub iterator: I, pub iterator: I,
/// Our current index into the iterator. This is 1-indexed because /// Our current index into the iterator. This is 1-indexed because
@@ -630,28 +723,34 @@ pub struct ArgsIterator<I: Iterator<Item = String>> {
pub index: usize, pub index: usize,
} }
impl<I: Iterator<Item = String>> ArgsIterator<I> { impl<I: Iterator> ArgsIterator<I>
where
I::Item: AsRef<[u8]>,
{
pub fn new(iterator: I) -> Self { pub fn new(iterator: I) -> Self {
Self { iterator, index: 0 } Self { iterator, index: 0 }
} }
pub fn next(&mut self) -> Option<String> { pub fn next(&mut self) -> Option<Vec<u8>> {
loop { loop {
let value = self.iterator.next()?; let value = self.iterator.next()?;
self.index += 1; self.index += 1;
// We ignore any argument that starts with "+". This is used // We ignore any argument that starts with "+". This is used
// to indicate actions and are expected to be parsed out before // to indicate actions and are expected to be parsed out before
// this iterator is created. // this iterator is created.
if value.starts_with('+') { if value.as_ref().starts_with(b"+") {
continue; continue;
} }
return Some(value); return Some(value.as_ref().to_vec());
} }
} }
} }
impl<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> { impl<I: Iterator> ArgIter for ArgsIterator<I>
fn next_arg(&mut self) -> Option<String> { where
I::Item: AsRef<[u8]>,
{
fn next_arg(&mut self) -> Option<Vec<u8>> {
self.next() self.next()
} }
@@ -662,47 +761,46 @@ impl<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
/// Create an args iterator for the process args. This will skip argv0. /// Create an args iterator for the process args. This will skip argv0.
#[must_use] #[must_use]
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> { pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<Vec<u8>>>> {
// Byte-safe: non-UTF-8 arguments keep their bytes (reversible escape // Keep original bytes until each field chooses whether to validate UTF-8.
// encoding, see `ghostty_os::osbytes`). ArgsIterator::new(ghostty_os::args::collect_bytes().into_iter().skip(1))
ArgsIterator::new(
ghostty_os::args::collect_config_strings()
.into_iter()
.skip(1),
)
} }
/// An iterator valid for arg parsing from a slice. /// An iterator valid for arg parsing from a slice.
pub struct SliceIterator<'a> { pub struct SliceIterator<'a, T> {
pub slice: &'a [String], pub slice: &'a [T],
pub idx: usize, pub idx: usize,
} }
impl ArgIter for SliceIterator<'_> { impl<T: AsRef<[u8]>> ArgIter for SliceIterator<'_, T> {
fn next_arg(&mut self) -> Option<String> { fn next_arg(&mut self) -> Option<Vec<u8>> {
let v = self.slice.get(self.idx)?; let v = self.slice.get(self.idx)?;
self.idx += 1; self.idx += 1;
Some(v.clone()) Some(v.as_ref().to_vec())
} }
} }
/// Construct a [`SliceIterator`] from a slice. /// Construct a [`SliceIterator`] from a slice.
#[must_use] #[must_use]
pub fn slice_iterator(slice: &[String]) -> SliceIterator<'_> { pub fn slice_iterator<T: AsRef<[u8]>>(slice: &[T]) -> SliceIterator<'_, T> {
SliceIterator { slice, idx: 0 } SliceIterator { slice, idx: 0 }
} }
/// A simple whitespace-splitting iterator used in tests and for /// A simple whitespace-splitting iterator used in tests and for
/// string-based argument parsing. /// string-based argument parsing.
pub struct VecIterator { pub struct VecIterator {
items: std::vec::IntoIter<String>, items: std::vec::IntoIter<Vec<u8>>,
} }
impl VecIterator { impl VecIterator {
#[must_use] #[must_use]
pub fn new(items: Vec<String>) -> Self { pub fn new<T: AsRef<[u8]>>(items: Vec<T>) -> Self {
Self { Self {
items: items.into_iter(), items: items
.into_iter()
.map(|v| v.as_ref().to_vec())
.collect::<Vec<_>>()
.into_iter(),
} }
} }
@@ -714,7 +812,7 @@ impl VecIterator {
} }
impl ArgIter for VecIterator { impl ArgIter for VecIterator {
fn next_arg(&mut self) -> Option<String> { fn next_arg(&mut self) -> Option<Vec<u8>> {
self.items.next() self.items.next()
} }
} }
@@ -785,9 +883,9 @@ impl<R: BufRead> LineIterator<R> {
} }
} }
pub fn next(&mut self) -> Option<String> { pub fn next(&mut self) -> Option<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
let entry: String = loop { let entry: Vec<u8> = loop {
buf.clear(); buf.clear();
match self.r.read_until(b'\n', &mut buf) { match self.r.read_until(b'\n', &mut buf) {
Ok(0) => return None, Ok(0) => return None,
@@ -807,42 +905,48 @@ impl<R: BufRead> LineIterator<R> {
self.line += 1; self.line += 1;
// Raw bytes survive via the reversible escape encoding. // Zig trims spaces, tabs and carriage returns, without decoding text.
let line = ghostty_os::osbytes::bytes_to_config_string(&buf); let start = buf
// Trim any whitespace (including CR) around it .iter()
let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r'); .position(|b| !matches!(b, b' ' | b'\t' | b'\r'))
.unwrap_or(buf.len());
let end = buf
.iter()
.rposition(|b| !matches!(b, b' ' | b'\t' | b'\r'))
.map_or(start, |i| i + 1);
let trim = &buf[start..end];
// Ignore blank lines and comments // Ignore blank lines and comments
if trim.is_empty() || trim.starts_with('#') { if trim.is_empty() || trim.starts_with(b"#") {
continue; continue;
} }
break trim.to_string(); break trim.to_vec();
}; };
let mut result = String::from("--"); let mut result = b"--".to_vec();
if let Some(idx) = entry.find('=') { if let Some(idx) = entry.iter().position(|b| *b == b'=') {
let key = trim_ws(&entry[..idx]); let key = trim_bytes(&entry[..idx]);
let mut value = trim_ws(&entry[idx + 1..]); let mut value = trim_bytes(&entry[idx + 1..]);
// Detect a quoted string. // Detect a quoted string.
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { if value.len() >= 2 && value.starts_with(b"\"") && value.ends_with(b"\"") {
// Trim quotes since our CLI args processor expects // Trim quotes since our CLI args processor expects
// quotes to already be gone. // quotes to already be gone.
value = &value[1..value.len() - 1]; value = &value[1..value.len() - 1];
} }
result.push_str(key); result.extend_from_slice(key);
result.push('='); result.push(b'=');
result.push_str(value); result.extend_from_slice(value);
} else { } else {
result.push_str(&entry); result.extend_from_slice(&entry);
} }
Some(result) Some(result)
} }
} }
impl<R: BufRead> ArgIter for LineIterator<R> { impl<R: BufRead> ArgIter for LineIterator<R> {
fn next_arg(&mut self) -> Option<String> { fn next_arg(&mut self) -> Option<Vec<u8>> {
self.next() self.next()
} }
@@ -889,8 +993,8 @@ mod tests {
} }
} }
impl CliTarget for Simple { impl CliTarget for Simple {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
} }
@@ -901,8 +1005,8 @@ mod tests {
} }
} }
impl CliTarget for Quoted { impl CliTarget for Quoted {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
} }
@@ -913,8 +1017,8 @@ mod tests {
} }
} }
impl CliTarget for Reset { impl CliTarget for Reset {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
} }
@@ -932,8 +1036,8 @@ mod tests {
} }
} }
impl CliTarget for Diag { impl CliTarget for Diag {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> { fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> {
Self::valid_values_for(key) Self::valid_values_for(key)
@@ -1029,15 +1133,15 @@ mod tests {
pub a: bool, pub a: bool,
} }
impl CliTarget for Compat { impl CliTarget for Compat {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
if key == "a" { if key == "a" {
// Fails for anything but a valid bool. // Fails for anything but a valid bool.
return self.a.parse_cli(value); return self.a.parse_bytes(value);
} }
Err(ArgError::InvalidField) Err(ArgError::InvalidField)
} }
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
if key == "a" && value == Some("yuh") { if key == "a" && value == Some(b"yuh") {
self.a = true; self.a = true;
return true; return true;
} }
@@ -1057,14 +1161,14 @@ mod tests {
pub b: bool, pub b: bool,
} }
impl CliTarget for Renamed { impl CliTarget for Renamed {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
match key { match key {
"a" => self.a.parse_cli(value), "a" => self.a.parse_bytes(value),
"b" => self.b.parse_cli(value), "b" => self.b.parse_bytes(value),
_ => Err(ArgError::InvalidField), _ => Err(ArgError::InvalidField),
} }
} }
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
if key == "old" { if key == "old" {
return compatibility_renamed(self, "a", value); return compatibility_renamed(self, "a", value);
} }
@@ -1086,12 +1190,12 @@ mod tests {
} }
} }
impl CliTarget for Underscore { impl CliTarget for Underscore {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
// Underscore-prefixed fields are never parseable. // Underscore-prefixed fields are never parseable.
if key.starts_with('_') { if key.starts_with('_') {
return Err(ArgError::InvalidField); return Err(ArgError::InvalidField);
} }
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
} }
@@ -1099,7 +1203,7 @@ mod tests {
fn parse_into_field_ignore_underscore_prefixed_fields() { fn parse_into_field_ignore_underscore_prefixed_fields() {
let mut data = Underscore::raw_default(); let mut data = Underscore::raw_default();
assert_eq!( assert_eq!(
data.parse_into_field("_a", Some("42")), data.parse_into_field("_a", Some(b"42")),
Err(ArgError::InvalidField) Err(ArgError::InvalidField)
); );
assert_eq!(data.a, "12"); assert_eq!(data.a, "12");
@@ -1117,7 +1221,8 @@ mod tests {
} }
} }
impl ConfigField for WithInit { impl ConfigField for WithInit {
fn parse_cli(&mut self, _value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, _value: Option<&[u8]>) -> Result<(), ArgError> {
let _value = crate::cli_args::text_value(_value)?;
Ok(()) Ok(())
} }
fn reset(&mut self, _default: Self) { fn reset(&mut self, _default: Self) {
@@ -1272,7 +1377,8 @@ mod tests {
pub v: String, pub v: String,
} }
impl ConfigField for WithParse { impl ConfigField for WithParse {
fn parse_cli(&mut self, _value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, _value: Option<&[u8]>) -> Result<(), ArgError> {
let _value = crate::cli_args::text_value(_value)?;
self.v = "HELLO!".into(); self.v = "HELLO!".into();
Ok(()) Ok(())
} }
@@ -1313,7 +1419,8 @@ mod tests {
} }
} }
impl ConfigField for Basic { impl ConfigField for Basic {
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
let mut r = Basic::default(); let mut r = Basic::default();
let mut set = [false; 3]; let mut set = [false; 3];
@@ -1381,7 +1488,8 @@ mod tests {
D(String), D(String),
} }
impl ConfigField for Tu { impl ConfigField for Tu {
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
let (tag, rest) = split_tagged_union(v); let (tag, rest) = split_tagged_union(v);
*self = match tag { *self = match tag {
@@ -1454,8 +1562,8 @@ mod tests {
fn args_iterator() { fn args_iterator() {
let child = split_args("--what +list-things --a=42").into_iter(); let child = split_args("--what +list-things --a=42").into_iter();
let mut iter = ArgsIterator::new(child); let mut iter = ArgsIterator::new(child);
assert_eq!(iter.next().unwrap(), "--what"); assert_eq!(iter.next().unwrap(), b"--what");
assert_eq!(iter.next().unwrap(), "--a=42"); assert_eq!(iter.next().unwrap(), b"--a=42");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1469,12 +1577,12 @@ mod tests {
let mut iter = line_iter( let mut iter = line_iter(
"A\nB=42\nC\n\n# A comment\nD\n\n # An indented comment\n E\n\n# A quoted string with whitespace\nF= \"value \"", "A\nB=42\nC\n\n# A comment\nD\n\n # An indented comment\n E\n\n# A quoted string with whitespace\nF= \"value \"",
); );
assert_eq!(iter.next().unwrap(), "--A"); assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), "--B=42"); assert_eq!(iter.next().unwrap(), b"--B=42");
assert_eq!(iter.next().unwrap(), "--C"); assert_eq!(iter.next().unwrap(), b"--C");
assert_eq!(iter.next().unwrap(), "--D"); assert_eq!(iter.next().unwrap(), b"--D");
assert_eq!(iter.next().unwrap(), "--E"); assert_eq!(iter.next().unwrap(), b"--E");
assert_eq!(iter.next().unwrap(), "--F=value "); assert_eq!(iter.next().unwrap(), b"--F=value ");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1482,7 +1590,7 @@ mod tests {
#[test] #[test]
fn line_iterator_end_in_newline() { fn line_iterator_end_in_newline() {
let mut iter = line_iter("A\n\n"); let mut iter = line_iter("A\n\n");
assert_eq!(iter.next().unwrap(), "--A"); assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1490,7 +1598,7 @@ mod tests {
#[test] #[test]
fn line_iterator_spaces_around_equals() { fn line_iterator_spaces_around_equals() {
let mut iter = line_iter("A = B\n\n"); let mut iter = line_iter("A = B\n\n");
assert_eq!(iter.next().unwrap(), "--A=B"); assert_eq!(iter.next().unwrap(), b"--A=B");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1498,15 +1606,15 @@ mod tests {
#[test] #[test]
fn line_iterator_no_value() { fn line_iterator_no_value() {
let mut iter = line_iter("A = \n\n"); let mut iter = line_iter("A = \n\n");
assert_eq!(iter.next().unwrap(), "--A="); assert_eq!(iter.next().unwrap(), b"--A=");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
#[test] #[test]
fn line_iterator_with_crlf_line_endings() { fn line_iterator_with_crlf_line_endings() {
let mut iter = line_iter("A\r\nB = C\r\n"); let mut iter = line_iter("A\r\nB = C\r\n");
assert_eq!(iter.next().unwrap(), "--A"); assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), "--B=C"); assert_eq!(iter.next().unwrap(), b"--B=C");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1515,8 +1623,8 @@ mod tests {
fn line_iterator_with_buffered_reader() { fn line_iterator_with_buffered_reader() {
let r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); let r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec()));
let mut iter = LineIterator::new(r); let mut iter = LineIterator::new(r);
assert_eq!(iter.next().unwrap(), "--A"); assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), "--B=C"); assert_eq!(iter.next().unwrap(), b"--B=C");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1527,8 +1635,8 @@ mod tests {
let mut r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec())); let mut r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec()));
r.fill_buf().unwrap(); r.fill_buf().unwrap();
let mut iter = LineIterator::new(r); let mut iter = LineIterator::new(r);
assert_eq!(iter.next().unwrap(), "--A"); assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), "--B=C"); assert_eq!(iter.next().unwrap(), b"--B=C");
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); assert_eq!(iter.next(), None);
} }
@@ -1558,21 +1666,16 @@ mod tests {
parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(), parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(),
vec![b'a', 0xff, b'b'] vec![b'a', 0xff, b'b']
); );
let s = parse_zig_string_literal("\"a\\xFFb\"").unwrap(); assert!(parse_zig_string_literal("\"a\\xFFb\"").is_none());
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&s),
vec![b'a', 0xff, b'b']
);
assert!(!s.contains('\u{fffd}'));
// Valid multi-byte sequences spelled with escapes still decode. // Valid multi-byte sequences spelled with escapes still decode.
assert_eq!( assert_eq!(
parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(), parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(),
"\u{e9}" "\u{e9}"
); );
// Raw (unescaped) invalid input bytes survive the escape encoding. // Raw (unescaped) invalid input bytes survive literal parsing.
let raw = ghostty_os::osbytes::bytes_to_config_string(b"\"x\xfey\""); let raw = b"\"x\xfey\"";
assert_eq!( assert_eq!(
parse_zig_string_literal_bytes(&raw).unwrap(), parse_zig_string_literal_bytes(raw).unwrap(),
vec![b'x', 0xfe, b'y'] vec![b'x', 0xfe, b'y']
); );
} }
@@ -1582,10 +1685,6 @@ mod tests {
let data: &[u8] = b"title = a\xffb\n"; let data: &[u8] = b"title = a\xffb\n";
let mut it = LineIterator::new(data); let mut it = LineIterator::new(data);
let line = it.next().unwrap(); let line = it.next().unwrap();
assert_eq!( assert_eq!(line, b"--title=a\xffb".to_vec());
ghostty_os::osbytes::config_str_to_bytes(&line),
b"--title=a\xffb".to_vec()
);
assert!(!line.contains('\u{fffd}'));
} }
} }
+15 -5
View File
@@ -54,21 +54,31 @@ enum UnicodeState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CommaSplitter<'a> { pub struct CommaSplitter<'a> {
/// the string that we are splitting /// the string that we are splitting
str: &'a str, str: &'a [u8],
/// how much of the string has been consumed so far /// how much of the string has been consumed so far
index: usize, index: usize,
} }
impl<'a> CommaSplitter<'a> { impl<'a> CommaSplitter<'a> {
pub fn new(value: &'a str) -> Self {
Self::from_bytes(value.as_bytes())
}
pub fn next_field(&mut self) -> Result<Option<&'a str>, Error> {
self.next_bytes()?
.map(|b| std::str::from_utf8(b).map_err(|_| Error::IllegalEscape))
.transpose()
}
/// initialize a splitter with the given string /// initialize a splitter with the given string
#[must_use] #[must_use]
pub fn new(str: &'a str) -> Self { pub fn from_bytes(str: &'a [u8]) -> Self {
Self { str, index: 0 } Self { str, index: 0 }
} }
/// return the next field, `Ok(None)` if no more fields /// return the next field, `Ok(None)` if no more fields
pub fn next_field(&mut self) -> Result<Option<&'a str>, Error> { pub fn next_bytes(&mut self) -> Result<Option<&'a [u8]>, Error> {
let bytes = self.str.as_bytes(); let bytes = self.str;
if self.index >= bytes.len() { if self.index >= bytes.len() {
return Ok(None); return Ok(None);
} }
@@ -213,7 +223,7 @@ impl<'a> CommaSplitter<'a> {
} }
let r = &self.str[self.index..]; let r = &self.str[self.index..];
self.index = self.str.len(); self.index = self.str.len();
Some(r) std::str::from_utf8(r).ok()
} }
} }
+49 -23
View File
@@ -2,6 +2,8 @@
use std::fmt; use std::fmt;
use ghostty_os::osbytes::EncodedOs;
use crate::{ use crate::{
cli_args::{ArgError, ConfigField}, cli_args::{ArgError, ConfigField},
formatter::EntryFormatter, formatter::EntryFormatter,
@@ -33,30 +35,30 @@ pub enum Command {
/// Index zero is not guaranteed to be an absolute path, and may require /// Index zero is not guaranteed to be an absolute path, and may require
/// PATH lookup. It is up to the downstream to do this, usually via /// PATH lookup. It is up to the downstream to do this, usually via
/// delegation to something like `execvp`. /// delegation to something like `execvp`.
Direct(Vec<String>), Direct(Vec<EncodedOs>),
/// Execute a command via shell expansion. This provides the command /// Execute a command via shell expansion. This provides the command
/// as a single string that is expected to be expanded in some way /// as a single string that is expected to be expanded in some way
/// (up to the downstream). Usually `/bin/sh -c`. /// (up to the downstream). Usually `/bin/sh -c`.
Shell(String), Shell(EncodedOs),
} }
impl Default for Command { impl Default for Command {
fn default() -> Self { fn default() -> Self {
Command::Shell(String::new()) Command::Shell(EncodedOs::new())
} }
} }
/// Iterates over each argument in the command. /// Iterates over each argument in the command.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ArgIterator { pub struct ArgIterator {
args: std::vec::IntoIter<String>, args: std::vec::IntoIter<EncodedOs>,
} }
impl Iterator for ArgIterator { impl Iterator for ArgIterator {
type Item = String; type Item = EncodedOs;
fn next(&mut self) -> Option<String> { fn next(&mut self) -> Option<EncodedOs> {
self.args.next() self.args.next()
} }
} }
@@ -64,8 +66,7 @@ impl Iterator for ArgIterator {
/// Best-effort shell string splitter matching Zig's /// Best-effort shell string splitter matching Zig's
/// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double /// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double
/// quotes group, backslashes only special before a double quote). /// quotes group, backslashes only special before a double quote).
fn split_general(s: &str) -> Vec<String> { fn split_general(b: &[u8]) -> Vec<EncodedOs> {
let b = s.as_bytes();
let mut out = vec![]; let mut out = vec![];
let mut i = 0; let mut i = 0;
loop { loop {
@@ -108,7 +109,7 @@ fn split_general(s: &str) -> Vec<String> {
} }
} }
} }
out.push(String::from_utf8_lossy(&cur).into_owned()); out.push(EncodedOs::from_bytes(&cur));
} }
out out
} }
@@ -120,8 +121,12 @@ impl Command {
#[must_use] #[must_use]
pub fn string(&self) -> String { pub fn string(&self) -> String {
match self { match self {
Command::Shell(v) => v.clone(), Command::Shell(v) => v.as_encoded().to_owned(),
Command::Direct(v) => v.join(" "), Command::Direct(v) => v
.iter()
.map(EncodedOs::as_encoded)
.collect::<Vec<_>>()
.join(" "),
} }
} }
@@ -135,7 +140,7 @@ impl Command {
pub fn arg_iterator(&self) -> ArgIterator { pub fn arg_iterator(&self) -> ArgIterator {
let args = match self { let args = match self {
Command::Direct(v) => v.clone(), Command::Direct(v) => v.clone(),
Command::Shell(v) => split_general(v), Command::Shell(v) => split_general(&v.to_bytes()),
}; };
ArgIterator { ArgIterator {
args: args.into_iter(), args: args.into_iter(),
@@ -143,39 +148,60 @@ impl Command {
} }
} }
fn trim_spaces(value: &[u8]) -> &[u8] {
let start = value.iter().position(|b| *b != b' ').unwrap_or(value.len());
let end = value
.iter()
.rposition(|b| *b != b' ')
.map_or(start, |i| i + 1);
&value[start..end]
}
impl ConfigField for Command { impl ConfigField for Command {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
// Input is required. Whitespace on the edges isn't needed. // Input is required. Whitespace on the edges isn't needed.
// Commands must be non-empty. // Commands must be non-empty.
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let trimmed = input.trim_matches(' '); let trimmed = trim_spaces(input);
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(ArgError::ValueRequired); return Err(ArgError::ValueRequired);
} }
// If we have a `:` then we MIGHT have a prefix to specify what // If we have a `:` then we MIGHT have a prefix to specify what
// tag we should use. // tag we should use.
let (direct, str) = match trimmed.find(':') { let (direct, str) = match trimmed.iter().position(|b| *b == b':') {
Some(idx) if &trimmed[..idx] == "direct" => (true, &trimmed[idx + 1..]), Some(idx) if &trimmed[..idx] == b"direct" => (true, &trimmed[idx + 1..]),
Some(idx) if &trimmed[..idx] == "shell" => (false, &trimmed[idx + 1..]), Some(idx) if &trimmed[..idx] == b"shell" => (false, &trimmed[idx + 1..]),
_ => (false, trimmed), _ => (false, trimmed),
}; };
let str = str.trim_matches(' '); let str = trim_spaces(str);
*self = if direct { *self = if direct {
// We're not shell expanding, so the arguments are naively // We're not shell expanding, so the arguments are naively
// split on spaces. // split on spaces.
Command::Direct(str.split(' ').map(str::to_string).collect()) Command::Direct(
str.split(|b| *b == b' ')
.map(EncodedOs::from_bytes)
.collect(),
)
} else { } else {
Command::Shell(str.to_string()) Command::Shell(EncodedOs::from_bytes(str))
}; };
Ok(()) Ok(())
} }
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
match self { match self {
Command::Shell(v) => writeln!(f.writer, "{} = {v}", f.name), Command::Shell(v) => writeln!(f.writer, "{} = {}", f.name, v.as_encoded()),
Command::Direct(v) => writeln!(f.writer, "{} = direct:{}", f.name, v.join(" ")), Command::Direct(v) => writeln!(
f.writer,
"{} = direct:{}",
f.name,
v.iter()
.map(EncodedOs::as_encoded)
.collect::<Vec<_>>()
.join(" ")
),
} }
} }
} }
@@ -229,7 +255,7 @@ mod tests {
#[test] #[test]
fn command_arg_iterator_shell_quotes() { fn command_arg_iterator_shell_quotes() {
let v = Command::Shell("echo \"hello world\" a\\\"b".into()); let v = Command::Shell("echo \"hello world\" a\\\"b".into());
let args: Vec<String> = v.arg_iterator().collect(); let args: Vec<EncodedOs> = v.arg_iterator().collect();
assert_eq!(args, ["echo", "hello world", "a\"b"]); assert_eq!(args, ["echo", "hello world", "a\"b"]);
} }
+72 -58
View File
@@ -56,7 +56,7 @@ crate::config_struct! {
/// ///
/// GTK only. /// GTK only.
/// Available since 1.3.0. /// Available since 1.3.0.
"language" => language: Option<String> = None, "language" => language: Option<EncodedOs> = None,
/// The font families to use. /// The font families to use.
/// ///
@@ -1444,7 +1444,7 @@ crate::config_struct! {
/// sequence will be honored but previous changes will not retroactively /// sequence will be honored but previous changes will not retroactively
/// be set. This latter case may require you to restart programs such as Neovim /// be set. This latter case may require you to restart programs such as Neovim
/// to get the new title. /// to get the new title.
"title" => title: Option<String> = None, "title" => title: Option<EncodedOs> = None,
/// The setting that will change the application class value. /// The setting that will change the application class value.
/// ///
@@ -1466,7 +1466,7 @@ crate::config_struct! {
/// The default is `com.mitchellh.ghostty`. /// The default is `com.mitchellh.ghostty`.
/// ///
/// This only affects GTK builds. /// This only affects GTK builds.
"class" => class: Option<String> = None, "class" => class: Option<EncodedOs> = None,
/// This controls the instance name field of the `WM_CLASS` X11 property when /// This controls the instance name field of the `WM_CLASS` X11 property when
/// running under X11. It has no effect otherwise. /// running under X11. It has no effect otherwise.
@@ -1474,7 +1474,7 @@ crate::config_struct! {
/// The default is `ghostty`. /// The default is `ghostty`.
/// ///
/// This only affects GTK builds. /// This only affects GTK builds.
"x11-instance-name" => x11_instance_name: Option<String> = None, "x11-instance-name" => x11_instance_name: Option<EncodedOs> = None,
/// The directory to change to after starting the command. /// The directory to change to after starting the command.
/// ///
@@ -2049,7 +2049,7 @@ crate::config_struct! {
/// required to be a fixed-width font. /// required to be a fixed-width font.
/// ///
/// Available since: 1.0.0 on macOS, 1.1.0 on GTK /// Available since: 1.0.0 on macOS, 1.1.0 on GTK
"window-title-font-family" => window_title_font_family: Option<String> = None, "window-title-font-family" => window_title_font_family: Option<EncodedOs> = None,
/// The text that will be displayed in the subtitle of the window. Valid values: /// The text that will be displayed in the subtitle of the window. Valid values:
/// ///
@@ -2626,7 +2626,7 @@ crate::config_struct! {
/// GTK Wayland only. /// GTK Wayland only.
/// ///
/// Available since: 1.2.0 /// Available since: 1.2.0
"gtk-quick-terminal-namespace" => gtk_quick_terminal_namespace: String = "ghostty-quick-terminal".to_string(), "gtk-quick-terminal-namespace" => gtk_quick_terminal_namespace: EncodedOs = EncodedOs::from_plain_text("ghostty-quick-terminal"),
/// The screen where the quick terminal should show up. /// The screen where the quick terminal should show up.
/// ///
@@ -3360,7 +3360,7 @@ crate::config_struct! {
/// Supported formats include PNG, JPEG, and ICNS. /// Supported formats include PNG, JPEG, and ICNS.
/// ///
/// Defaults to `~/.config/ghostty/Ghostty.icns` /// Defaults to `~/.config/ghostty/Ghostty.icns`
"macos-custom-icon" => macos_custom_icon: Option<String> = None, "macos-custom-icon" => macos_custom_icon: Option<EncodedOs> = None,
/// The material to use for the frame of the macOS app icon. /// The material to use for the frame of the macOS app icon.
/// ///
@@ -3648,11 +3648,11 @@ crate::config_struct! {
/// features. An option exists in vim to modify this: `:set /// features. An option exists in vim to modify this: `:set
/// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it /// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it
/// from working properly. https://github.com/vim/vim/pull/13211 fixes this. /// from working properly. https://github.com/vim/vim/pull/13211 fixes this.
"term" => term: String = "xterm-ghostty".to_string(), "term" => term: EncodedOs = EncodedOs::from_plain_text("xterm-ghostty"),
/// String to send when we receive `ENQ` (`0x05`) from the command that we are /// String to send when we receive `ENQ` (`0x05`) from the command that we are
/// running. Defaults to an empty string if not set. /// running. Defaults to an empty string if not set.
"enquiry-response" => enquiry_response: String = String::new(), "enquiry-response" => enquiry_response: EncodedOs = EncodedOs::new(),
/// Configures the low-level API to use for async IO, eventing, etc. /// Configures the low-level API to use for async IO, eventing, etc.
/// ///
@@ -3764,7 +3764,7 @@ pub enum Error {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ReplayStep { pub enum ReplayStep {
/// An argument to parse as if it came from the CLI or file. /// An argument to parse as if it came from the CLI or file.
Arg(String), Arg(Vec<u8>),
/// A base path to expand relative paths against. /// A base path to expand relative paths against.
Expand(EncodedOs), Expand(EncodedOs),
@@ -3774,7 +3774,7 @@ pub enum ReplayStep {
/// having multiple conditional arg entries. /// having multiple conditional arg entries.
ConditionalArg { ConditionalArg {
conditions: Vec<Conditional>, conditions: Vec<Conditional>,
arg: String, arg: Vec<u8>,
}, },
/// A diagnostic to be added to the new configuration when /// A diagnostic to be added to the new configuration when
@@ -3815,7 +3815,7 @@ impl Default for Config {
/// bytes are carried as encoded command text (so non-UTF-8 bytes and a /// bytes are carried as encoded command text (so non-UTF-8 bytes and a
/// literal U+FDD0 round-trip exactly, like Zig's byte slice). /// literal U+FDD0 round-trip exactly, like Zig's byte slice).
fn shell_from_env(value: &std::ffi::OsStr) -> Command { fn shell_from_env(value: &std::ffi::OsStr) -> Command {
Command::Shell(ghostty_os::osbytes::EncodedOs::from_os(value).into_encoded()) Command::Shell(ghostty_os::osbytes::EncodedOs::from_os(value))
} }
impl Config { impl Config {
@@ -3886,13 +3886,13 @@ impl Config {
/// ///
/// `path` must be resolved and absolute. /// `path` must be resolved and absolute.
pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> { pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> {
assert!(path.to_path().is_absolute()); assert!(path.to_path()?.is_absolute());
let file = match crate::file_load::open(path) { let file = match crate::file_load::open(path) {
Ok(f) => f, Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => { Err(crate::file_load::OpenFileError::NotAFile) => {
log::warn!( log::warn!(
"config-file {}: not reading because it is not a file", "config-file {}: not reading because it is not a file",
path.as_encoded() path.to_text_lossy()
); );
return Ok(()); return Ok(());
} }
@@ -3907,7 +3907,7 @@ impl Config {
/// Load config from the given File. /// Load config from the given File.
fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> { fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> {
log::info!("reading configuration file path={}", path.as_encoded()); log::info!("reading configuration file path={}", path.to_text_lossy());
let mut reader = std::io::BufReader::with_capacity(2048, file); let mut reader = std::io::BufReader::with_capacity(2048, file);
self.load_reader(&mut reader, path) self.load_reader(&mut reader, path)
} }
@@ -3929,7 +3929,7 @@ impl Config {
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded()); let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
self.load_iter(&mut iter)?; self.load_iter(&mut iter)?;
let dir = path let dir = path
.to_path() .to_path()?
.parent() .parent()
.map(EncodedOs::from_path) .map(EncodedOs::from_path)
.unwrap_or_default(); .unwrap_or_default();
@@ -3949,7 +3949,7 @@ impl Config {
Err(err) => { Err(err) => {
log::warn!( log::warn!(
"error reading optional config file, not loading err={err} path={}", "error reading optional config file, not loading err={err} path={}",
path.as_encoded() path.to_text_lossy()
); );
OptionalFileAction::Error OptionalFileAction::Error
} }
@@ -3971,8 +3971,11 @@ impl Config {
} }
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> { fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
log::info!("creating template config file: path={}", path.as_encoded()); log::info!(
let os_path = path.to_path(); "creating template config file: path={}",
path.to_text_lossy()
);
let os_path = path.to_path()?;
if let Some(dir) = os_path.parent() { if let Some(dir) = os_path.parent() {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
@@ -3999,8 +4002,8 @@ impl Config {
{ {
log::warn!( log::warn!(
"both config files `{}` and `{}` exist.", "both config files `{}` and `{}` exist.",
legacy_xdg_path.as_encoded(), legacy_xdg_path.to_text_lossy(),
xdg_path.as_encoded() xdg_path.to_text_lossy()
); );
log::warn!("loading them both in that order"); log::warn!("loading them both in that order");
true true
@@ -4034,8 +4037,8 @@ impl Config {
{ {
log::warn!( log::warn!(
"both config files `{}` and `{}` exist.", "both config files `{}` and `{}` exist.",
legacy_app_support_path.as_encoded(), legacy_app_support_path.to_text_lossy(),
app_support_path.as_encoded() app_support_path.to_text_lossy()
); );
log::warn!("loading them both in that order"); log::warn!("loading them both in that order");
true true
@@ -4102,10 +4105,11 @@ impl Config {
// Next, take all remaining args and use that to build up // Next, take all remaining args and use that to build up
// a command to execute. // a command to execute.
let mut builder: Vec<String> = Vec::new(); let mut builder: Vec<EncodedOs> = Vec::new();
for arg in args { for arg in args {
let copy = ghostty_os::osbytes::bytes_to_config_string(arg.to_bytes()); let copy = EncodedOs::from_bytes(arg.to_bytes());
self.replay_steps.push(ReplayStep::Arg(copy.clone())); self.replay_steps
.push(ReplayStep::Arg(arg.to_bytes().to_vec()));
builder.push(copy); builder.push(copy);
} }
@@ -4207,7 +4211,7 @@ impl Config {
// All paths should already be absolute at this point because // All paths should already be absolute at this point because
// they're fixed up after each load. // they're fixed up after each load.
assert!(path.to_path().is_absolute()); assert!(path.to_path()?.is_absolute());
// We must only load a unique file once // We must only load a unique file once
if !loaded.insert(path.clone()) { if !loaded.insert(path.clone()) {
@@ -4220,7 +4224,7 @@ impl Config {
continue; continue;
} }
let file = match std::fs::File::open(path.to_path()) { let file = match path.to_path().and_then(std::fs::File::open) {
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional { if err.kind() != std::io::ErrorKind::NotFound || !optional {
@@ -4334,7 +4338,7 @@ impl Config {
ReplayStep::DashE => { ReplayStep::DashE => {
// The "-e" hook consumes every remaining argument. // The "-e" hook consumes every remaining argument.
let mut args = vec!["-e".to_string()]; let mut args = vec![b"-e".to_vec()];
while i < steps.len() { while i < steps.len() {
match &steps[i] { match &steps[i] {
ReplayStep::Expand(base) => { ReplayStep::Expand(base) => {
@@ -4351,7 +4355,7 @@ impl Config {
} }
} }
ReplayStep::Arg(arg) => args.push(arg.clone()), ReplayStep::Arg(arg) => args.push(arg.clone()),
ReplayStep::DashE => args.push("-e".to_string()), ReplayStep::DashE => args.push(b"-e".to_vec()),
} }
i += 1; i += 1;
} }
@@ -4363,8 +4367,8 @@ impl Config {
Ok(()) Ok(())
} }
fn load_one_arg(&mut self, arg: &str) -> Result<(), ArgError> { fn load_one_arg(&mut self, arg: &[u8]) -> Result<(), ArgError> {
let mut it = cli_args::VecIterator::new(vec![arg.to_string()]); let mut it = cli_args::VecIterator::new(vec![arg.to_vec()]);
self.load_iter(&mut it) self.load_iter(&mut it)
} }
@@ -4391,7 +4395,7 @@ impl Config {
// Load the correct theme depending on the conditional state. // Load the correct theme depending on the conditional state.
// Dark/light themes were programmed prior to conditional configuration // Dark/light themes were programmed prior to conditional configuration
// so when we introduce that we probably want to replace this. // so when we introduce that we probably want to replace this.
let name: &str = match self.conditional_state.theme { let name = match self.conditional_state.theme {
conditional::Theme::Light => &theme.light, conditional::Theme::Light => &theme.light,
conditional::Theme::Dark => &theme.dark, conditional::Theme::Dark => &theme.dark,
}; };
@@ -4399,7 +4403,7 @@ impl Config {
// Find our theme file and open it. See the open function for details. // Find our theme file and open it. See the open function for details.
let resources_dir = ghostty_os::resources_dir().ok(); let resources_dir = ghostty_os::resources_dir().ok();
let Some(themefile) = crate::theme::open( let Some(themefile) = crate::theme::open(
&EncodedOs::from_encoded(name), name,
resources_dir.as_ref().and_then(|r| r.app()), resources_dir.as_ref().and_then(|r| r.app()),
&mut self.diagnostics, &mut self.diagnostics,
) else { ) else {
@@ -4520,7 +4524,7 @@ impl Config {
// Prevent setting TERM to an empty string // Prevent setting TERM to an empty string
if self.term.is_empty() { if self.term.is_empty() {
// HACK: See comment above at definition // HACK: See comment above at definition
self.term = "xterm-ghostty".to_string(); self.term = "xterm-ghostty".into();
} }
// The default for the working directory depends on the system. // The default for the working directory depends on the system.
@@ -4647,7 +4651,7 @@ impl Config {
fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> { fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> {
if self.command.is_none() { if self.command.is_none() {
log::warn!("no default shell found, will default to using cmd"); log::warn!("no default shell found, will default to using cmd");
self.command = Some(Command::Shell("cmd.exe".to_owned())); self.command = Some(Command::Shell("cmd.exe".into()));
} }
if *wd == WorkingDirectory::Home { if *wd == WorkingDirectory::Home {
@@ -4665,8 +4669,8 @@ impl Config {
let pw = ghostty_os::passwd::get()?; let pw = ghostty_os::passwd::get()?;
if self.command.is_none() { if self.command.is_none() {
if let Some(sh) = pw.shell { if let Some(sh) = pw.shell {
log::info!("default shell src=passwd value={}", sh.as_encoded()); log::info!("default shell src=passwd value={}", sh.to_text_lossy());
self.command = Some(Command::Shell(sh.into_encoded())); self.command = Some(Command::Shell(sh));
} }
} }
@@ -4674,7 +4678,7 @@ impl Config {
if let Some(home) = pw.home { if let Some(home) = pw.home {
log::info!( log::info!(
"default working directory src=passwd value={}", "default working directory src=passwd value={}",
home.as_encoded() home.to_text_lossy()
); );
*wd = WorkingDirectory::Path(home); *wd = WorkingDirectory::Path(home);
} else { } else {
@@ -4809,8 +4813,8 @@ fn probable_cli_environment() -> bool {
} }
impl CliTarget for Config { impl CliTarget for Config {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> { fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field(key, value) self.parse_field_bytes(key, value)
} }
fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> { fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> {
@@ -4823,24 +4827,28 @@ impl CliTarget for Config {
/// Callback for the arg parser to allow us to handle special cases /// Callback for the arg parser to allow us to handle special cases
/// like `-e`. Returns `Ok(false)` if the CLI parsing should halt. /// like `-e`. Returns `Ok(false)` if the CLI parsing should halt.
fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result<bool, ArgError> { fn parse_manually_hook(
if arg == "-e" { &mut self,
arg: &[u8],
iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> {
if arg == b"-e" {
// Add the special -e marker. This prevents: // Add the special -e marker. This prevents:
// (1) config-file from adding args to the end (see #2908) // (1) config-file from adding args to the end (see #2908)
// (2) dark/light theme from making this conditional // (2) dark/light theme from making this conditional
self.replay_steps.push(ReplayStep::DashE); self.replay_steps.push(ReplayStep::DashE);
// Build up the command. // Build up the command.
let mut command: Vec<String> = Vec::new(); let mut command: Vec<EncodedOs> = Vec::new();
while let Some(param) = iter.next_arg() { while let Some(param) = iter.next_arg() {
self.replay_steps.push(ReplayStep::Arg(param.clone())); self.replay_steps.push(ReplayStep::Arg(param.clone()));
command.push(param); command.push(EncodedOs::from_bytes(&param));
} }
if command.is_empty() { if command.is_empty() {
self.diagnostics.append(Diagnostic { self.diagnostics.append(Diagnostic {
location: iter.location().unwrap_or_default(), location: iter.location().unwrap_or_default(),
message: format!("missing command after {arg}"), message: "missing command after -e".to_owned(),
..Diagnostic::default() ..Diagnostic::default()
}); });
@@ -4861,13 +4869,13 @@ impl CliTarget for Config {
} }
// Keep track of our input args for replay // Keep track of our input args for replay
self.replay_steps.push(ReplayStep::Arg(arg.to_string())); self.replay_steps.push(ReplayStep::Arg(arg.to_vec()));
// If we didn't find a special case, continue parsing normally // If we didn't find a special case, continue parsing normally
Ok(true) Ok(true)
} }
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool { fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
match key { match key {
// Ghostty 1.1 introduced background-blur support for Linux which // Ghostty 1.1 introduced background-blur support for Linux which
// doesn't support a specific radius value. The renaming is to let // doesn't support a specific radius value. The renaming is to let
@@ -4886,7 +4894,7 @@ impl CliTarget for Config {
// Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and
// moved it to `window-show-tab-bar`. // moved it to `window-show-tab-bar`.
"gtk-tabs-location" => { "gtk-tabs-location" => {
if value.unwrap_or("") == "hidden" { if value.unwrap_or(b"") == b"hidden" {
self.window_show_tab_bar = WindowShowTabBar::Never; self.window_show_tab_bar = WindowShowTabBar::Never;
return true; return true;
} }
@@ -4907,7 +4915,9 @@ impl CliTarget for Config {
// Realistically, these fields were mutually exclusive so anyone // Realistically, these fields were mutually exclusive so anyone
// relying on that behavior should just upgrade to the new // relying on that behavior should just upgrade to the new
// cursor-color/cursor-text fields. // cursor-color/cursor-text fields.
let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else { let Ok(set) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false; return false;
}; };
if set { if set {
@@ -4917,7 +4927,9 @@ impl CliTarget for Config {
true true
} }
"selection-invert-fg-bg" => { "selection-invert-fg-bg" => {
let Ok(set) = cli_args::parse_bool(value.unwrap_or("t")) else { let Ok(set) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false; return false;
}; };
if set { if set {
@@ -4930,7 +4942,9 @@ impl CliTarget for Config {
// Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color`
// by setting the value to "bright". // by setting the value to "bright".
"bold-is-bright" => { "bold-is-bright" => {
let Ok(isset) = cli_args::parse_bool(value.unwrap_or("t")) else { let Ok(isset) = cli_args::text_value(value)
.and_then(|v| cli_args::parse_bool(v.unwrap_or("t")))
else {
return false; return false;
}; };
if isset { if isset {
@@ -4942,7 +4956,7 @@ impl CliTarget for Config {
// Ghostty 1.2 removed the "desktop" option and renamed it to "detect". // Ghostty 1.2 removed the "desktop" option and renamed it to "detect".
// The semantics also changed slightly but this is the correct mapping. // The semantics also changed slightly but this is the correct mapping.
"gtk-single-instance" => { "gtk-single-instance" => {
if value.unwrap_or("") == "desktop" { if value.unwrap_or(b"") == b"desktop" {
self.gtk_single_instance = GtkSingleInstance::Detect; self.gtk_single_instance = GtkSingleInstance::Detect;
return true; return true;
} }
@@ -4952,7 +4966,7 @@ impl CliTarget for Config {
// Ghostty 1.3 rename the "window" option to "new-window". // Ghostty 1.3 rename the "window" option to "new-window".
// See: https://github.com/ghostty-org/ghostty/pull/9764 // See: https://github.com/ghostty-org/ghostty/pull/9764
"macos-dock-drop-behavior" => { "macos-dock-drop-behavior" => {
if value.unwrap_or("") == "window" { if value.unwrap_or(b"") == b"window" {
self.macos_dock_drop_behavior = MacOSDockDropBehavior::NewWindow; self.macos_dock_drop_behavior = MacOSDockDropBehavior::NewWindow;
return true; return true;
} }
@@ -4983,7 +4997,7 @@ mod tests {
let Command::Shell(text) = shell_from_env(std::ffi::OsStr::from_bytes(raw)) else { let Command::Shell(text) = shell_from_env(std::ffi::OsStr::from_bytes(raw)) else {
panic!("not a shell command"); panic!("not a shell command");
}; };
assert_eq!(ghostty_os::osbytes::config_str_to_bytes(&text), raw); assert_eq!(text.to_bytes(), raw);
} }
let Command::Shell(plain) = shell_from_env(std::ffi::OsStr::new("/bin/zsh")) else { let Command::Shell(plain) = shell_from_env(std::ffi::OsStr::new("/bin/zsh")) else {
panic!() panic!()
@@ -5063,7 +5077,7 @@ mod tests {
fn parse_hook_invalid_command() { fn parse_hook_invalid_command() {
let mut cfg = Config::default(); let mut cfg = Config::default();
let mut i = it(&["foo"]); let mut i = it(&["foo"]);
assert!(cfg.parse_manually_hook("--command", &mut i).unwrap()); assert!(cfg.parse_manually_hook(b"--command", &mut i).unwrap());
assert!(cfg.command.is_none()); assert!(cfg.command.is_none());
} }
@@ -5071,7 +5085,7 @@ mod tests {
fn parse_e_command_only() { fn parse_e_command_only() {
let mut cfg = Config::default(); let mut cfg = Config::default();
let mut i = it(&["foo"]); let mut i = it(&["foo"]);
assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap()); assert!(!cfg.parse_manually_hook(b"-e", &mut i).unwrap());
let Some(Command::Direct(cmd)) = &cfg.initial_command else { let Some(Command::Direct(cmd)) = &cfg.initial_command else {
panic!("expected direct"); panic!("expected direct");
@@ -5083,7 +5097,7 @@ mod tests {
fn parse_e_command_and_args() { fn parse_e_command_and_args() {
let mut cfg = Config::default(); let mut cfg = Config::default();
let mut i = it(&["echo", "foo", "bar baz"]); let mut i = it(&["echo", "foo", "bar baz"]);
assert!(!cfg.parse_manually_hook("-e", &mut i).unwrap()); assert!(!cfg.parse_manually_hook(b"-e", &mut i).unwrap());
let Some(Command::Direct(cmd)) = &cfg.initial_command else { let Some(Command::Direct(cmd)) = &cfg.initial_command else {
panic!("expected direct"); panic!("expected direct");
+7 -3
View File
@@ -21,7 +21,7 @@ pub fn open_path() -> io::Result<EncodedOs> {
// The config path is an encoded OS string: decode before touching the // The config path is an encoded OS string: decode before touching the
// filesystem. // filesystem.
let real_path = config_path.to_path(); let real_path = config_path.to_path()?;
// Create config directory recursively. // Create config directory recursively.
if let Some(config_dir) = real_path.parent() { if let Some(config_dir) = real_path.parent() {
@@ -55,7 +55,11 @@ fn config_path() -> io::Result<EncodedOs> {
// exists. // exists.
let mut exists: Option<&EncodedOs> = None; let mut exists: Option<&EncodedOs> = None;
for path in &paths { for path in &paths {
let meta = match std::fs::File::open(path.to_path()).and_then(|f| f.metadata()) { let meta = match path
.to_path()
.and_then(std::fs::File::open)
.and_then(|f| f.metadata())
{
Ok(m) => m, Ok(m) => m,
// File doesn't exist, continue. // File doesn't exist, continue.
Err(e) if e.kind() == io::ErrorKind::NotFound => continue, Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
@@ -115,7 +119,7 @@ mod tests {
let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec()); let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec());
temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || { temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || {
let p = open_path().unwrap(); let p = open_path().unwrap();
let real = p.to_path(); let real = p.to_path().unwrap();
assert_eq!(real, xdg.join("ghostty/config.ghostty")); assert_eq!(real, xdg.join("ghostty/config.ghostty"));
assert!(real.is_file()); assert!(real.is_file());
// Nothing was created at the escaped (encoded) pathname. // Nothing was created at the escaped (encoded) pathname.
+12 -9
View File
@@ -159,7 +159,10 @@ impl std::error::Error for OpenFileError {}
/// if it exists and is non-empty. This also constrains the possible /// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle. /// errors to a smaller set that we can explicitly handle.
pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> { pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> {
let real = path.to_path(); let real = path.to_path().map_err(|err| {
log::warn!("invalid config path: {err}");
OpenFileError::FileOpenFailed
})?;
assert!(real.is_absolute()); assert!(real.is_absolute());
let file = File::open(&real).map_err(|err| { let file = File::open(&real).map_err(|err| {
@@ -221,16 +224,16 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let new = EncodedOs::from_path(&dir.join("config.ghostty")); let new = EncodedOs::from_path(&dir.join("config.ghostty"));
let legacy = EncodedOs::from_path(&dir.join("config")); let legacy = EncodedOs::from_path(&dir.join("config"));
let _ = std::fs::remove_file(new.to_path()); let _ = std::fs::remove_file(new.to_path().unwrap());
let _ = std::fs::remove_file(legacy.to_path()); let _ = std::fs::remove_file(legacy.to_path().unwrap());
// Neither exists: the new one. // Neither exists: the new one.
assert_eq!(preferred_of(new.clone(), legacy.clone()), new); assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
// Only legacy exists: the legacy one. // Only legacy exists: the legacy one.
std::fs::write(legacy.to_path(), "a=b").unwrap(); std::fs::write(legacy.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy); assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy);
// Both exist: the new one. // Both exist: the new one.
std::fs::write(new.to_path(), "a=b").unwrap(); std::fs::write(new.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), new); assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
@@ -241,17 +244,17 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let app = EncodedOs::from_path(&dir.join("app")); let app = EncodedOs::from_path(&dir.join("app"));
let xdg_p = EncodedOs::from_path(&dir.join("xdg")); let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
let _ = std::fs::remove_file(app.to_path()); let _ = std::fs::remove_file(app.to_path().unwrap());
let _ = std::fs::remove_file(xdg_p.to_path()); let _ = std::fs::remove_file(xdg_p.to_path().unwrap());
let xdg_fn = || Ok(xdg_p.clone()); let xdg_fn = || Ok(xdg_p.clone());
// Neither exists: app support. // Neither exists: app support.
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
// XDG exists only: XDG. // XDG exists only: XDG.
std::fs::write(xdg_p.to_path(), "a=b").unwrap(); std::fs::write(xdg_p.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p); assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p);
// App support exists: app support wins. // App support exists: app support wins.
std::fs::write(app.to_path(), "a=b").unwrap(); std::fs::write(app.to_path().unwrap(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
+42 -7
View File
@@ -40,13 +40,13 @@ pub struct Descriptor {
/// ///
/// On systems that use fontconfig (Linux), this can be a full /// On systems that use fontconfig (Linux), this can be a full
/// fontconfig pattern, such as "Fira Code-14:bold". /// fontconfig pattern, such as "Fira Code-14:bold".
pub family: Option<String>, pub family: Option<ghostty_os::osbytes::EncodedOs>,
/// Specific font style to search for. This will filter the style /// Specific font style to search for. This will filter the style
/// string the font advertises. The "bold/italic" booleans later in this /// string the font advertises. The "bold/italic" booleans later in this
/// struct filter by the style trait the font has, not the string, so /// struct filter by the style trait the font has, not the string, so
/// these can be used in conjunction or not. /// these can be used in conjunction or not.
pub style: Option<String>, pub style: Option<ghostty_os::osbytes::EncodedOs>,
/// A codepoint that this font must be able to render. /// A codepoint that this font must be able to render.
pub codepoint: u32, pub codepoint: u32,
@@ -184,11 +184,25 @@ mod tests {
let mut m = CodepointMap::default(); let mut m = CodepointMap::default();
assert!(m.get(1).is_none()); assert!(m.get(1).is_none());
m.add(e(1, 1, "A")); m.add(e(1, 1, "A"));
assert_eq!(m.get(1).unwrap().family.as_deref(), Some("A")); assert_eq!(
m.get(1)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded),
Some("A")
);
// Later entry takes priority // Later entry takes priority
m.add(e(1, 2, "B")); m.add(e(1, 2, "B"));
assert_eq!(m.get(1).unwrap().family.as_deref(), Some("B")); assert_eq!(
m.get(1)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded),
Some("B")
);
// Non-matching // Non-matching
assert!(m.get(0).is_none()); assert!(m.get(0).is_none());
@@ -196,8 +210,29 @@ mod tests {
m.add(e(3, 4, "C")); m.add(e(3, 4, "C"));
m.add(e(5, 6, "D")); m.add(e(5, 6, "D"));
assert_eq!(m.get(3).unwrap().family.as_deref(), Some("C")); assert_eq!(
assert_eq!(m.get(6).unwrap().family.as_deref(), Some("D")); m.get(3)
assert_eq!(m.get(2).unwrap().family.as_deref(), Some("B")); .unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded),
Some("C")
);
assert_eq!(
m.get(6)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded),
Some("D")
);
assert_eq!(
m.get(2)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded),
Some("B")
);
} }
} }
+4 -2
View File
@@ -12,7 +12,8 @@ use crate::{
}; };
impl ConfigField for KeyRemapSet { impl ConfigField for KeyRemapSet {
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
self.parse_cli(value).map_err(|_| ArgError::InvalidValue) self.parse_cli(value).map_err(|_| ArgError::InvalidValue)
} }
@@ -40,7 +41,8 @@ impl ConfigField for OptionAsAlt {
}) })
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
*self = match v { *self = match v {
"false" => OptionAsAlt::False, "false" => OptionAsAlt::False,
+21 -20
View File
@@ -5,7 +5,7 @@ use std::fmt;
use ghostty_os::osbytes::EncodedOs; use ghostty_os::osbytes::EncodedOs;
use crate::{ use crate::{
cli_args::{ArgError, ConfigField, split_tagged_union}, cli_args::{ArgError, ConfigField},
formatter::EntryFormatter, formatter::EntryFormatter,
string, string,
}; };
@@ -18,16 +18,16 @@ use crate::{
/// might be produced in an intermediate state. /// might be produced in an intermediate state.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadableIO { pub enum ReadableIO {
/// Raw bytes in the reversible OS-byte encoding (decode with /// Raw bytes, available with `EncodedOs::to_bytes` after
/// `osbytes::config_str_to_bytes` after [`ReadableIO::clone_parsed`]). /// [`ReadableIO::clone_parsed`] resolves string escapes.
Raw(String), Raw(EncodedOs),
/// A file path (decode with `EncodedOs::to_path` before opening). /// A file path (decode with `EncodedOs::to_path` before opening).
Path(EncodedOs), Path(EncodedOs),
} }
impl Default for ReadableIO { impl Default for ReadableIO {
fn default() -> Self { fn default() -> Self {
ReadableIO::Raw(String::new()) ReadableIO::Raw(EncodedOs::new())
} }
} }
@@ -36,18 +36,20 @@ impl ReadableIO {
/// the final resulting value all at once. /// the final resulting value all at once.
#[must_use] #[must_use]
pub fn clone_parsed(&self) -> Self { pub fn clone_parsed(&self) -> Self {
// Parsing can't fail because we validate it in parse_cli // Parsing can't fail because we validate it in parse_bytes
match self { match self {
ReadableIO::Raw(v) => ReadableIO::Raw(string::parse(v).unwrap_or_default()), ReadableIO::Raw(v) => ReadableIO::Raw(EncodedOs::from_bytes(
ReadableIO::Path(v) => ReadableIO::Path(EncodedOs::from_encoded( &string::parse_bytes(v.to_bytes()).unwrap_or_default(),
string::parse(v.as_encoded()).unwrap_or_default(), )),
ReadableIO::Path(v) => ReadableIO::Path(EncodedOs::from_bytes(
&string::parse_bytes(v.to_bytes()).unwrap_or_default(),
)), )),
} }
} }
} }
impl ConfigField for ReadableIO { impl ConfigField for ReadableIO {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
if input.is_empty() { if input.is_empty() {
return Err(ArgError::ValueRequired); return Err(ArgError::ValueRequired);
@@ -55,22 +57,22 @@ impl ConfigField for ReadableIO {
// We only validate that string parsing works. We store the value // We only validate that string parsing works. We store the value
// as raw so that our formatting can recreate it. // as raw so that our formatting can recreate it.
string::parse(input)?; string::parse_bytes(input)?;
// Next, parse the tagged union using normal rules. Invalid values in // Next, parse the tagged union using normal rules. Invalid values in
// the tagged union are interpreted as raw values. // the tagged union are interpreted as raw values.
let (tag, rest) = split_tagged_union(input); let (tag, rest) = crate::cli_args::split_tagged_union_bytes(input);
*self = match tag { *self = match tag {
"raw" => ReadableIO::Raw(rest.to_string()), b"raw" => ReadableIO::Raw(EncodedOs::from_bytes(rest)),
"path" => ReadableIO::Path(EncodedOs::from_encoded(rest)), b"path" => ReadableIO::Path(EncodedOs::from_bytes(rest)),
_ => ReadableIO::Raw(input.to_string()), _ => ReadableIO::Raw(EncodedOs::from_bytes(input)),
}; };
Ok(()) Ok(())
} }
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
let (tag, v) = match self { let (tag, v) = match self {
ReadableIO::Raw(v) => ("raw", v.as_str()), ReadableIO::Raw(v) => ("raw", v.as_encoded()),
ReadableIO::Path(v) => ("path", v.as_encoded()), ReadableIO::Path(v) => ("path", v.as_encoded()),
}; };
writeln!(f.writer, "{} = {tag}:{v}", f.name) writeln!(f.writer, "{} = {tag}:{v}", f.name)
@@ -93,7 +95,7 @@ impl RepeatableReadableIO {
} }
impl ConfigField for RepeatableReadableIO { impl ConfigField for RepeatableReadableIO {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
// Empty value resets the list // Empty value resets the list
@@ -103,7 +105,7 @@ impl ConfigField for RepeatableReadableIO {
} }
let mut io = ReadableIO::default(); let mut io = ReadableIO::default();
io.parse_cli(Some(value))?; io.parse_bytes(Some(value))?;
self.list.push(io); self.list.push(io);
Ok(()) Ok(())
} }
@@ -139,8 +141,7 @@ mod tests {
#[test] #[test]
fn readable_io_path_decodes_to_the_original_bytes() { fn readable_io_path_decodes_to_the_original_bytes() {
let mut io = ReadableIO::default(); let mut io = ReadableIO::default();
let enc = EncodedOs::from_bytes(b"/tmp/\xffin").into_encoded(); io.parse_bytes(Some(b"path:/tmp/\xffin")).unwrap();
io.parse_cli(Some(&format!("path:{enc}"))).unwrap();
let ReadableIO::Path(p) = io.clone_parsed() else { let ReadableIO::Path(p) = io.clone_parsed() else {
panic!("path expected") panic!("path expected")
}; };
+45 -29
View File
@@ -24,6 +24,7 @@ use ghostty_input::{
set::{EntryFormatter as BindingEntryFormatter, Value}, set::{EntryFormatter as BindingEntryFormatter, Value},
}, },
}; };
use ghostty_os::osbytes::EncodedOs;
use crate::{ use crate::{
cli_args::{ArgError, ConfigField}, cli_args::{ArgError, ConfigField},
@@ -38,7 +39,7 @@ use crate::{
pub enum ChainTarget { pub enum ChainTarget {
#[default] #[default]
Root, Root,
Table(String), Table(EncodedOs),
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -48,7 +49,7 @@ pub struct Keybinds {
/// Defined key tables. The default key table is always the root "set", /// Defined key tables. The default key table is always the root "set",
/// which allows all table names to be available without reservation. /// which allows all table names to be available without reservation.
/// Insertion ordered. /// Insertion ordered.
pub tables: Vec<(String, Set)>, pub tables: Vec<(EncodedOs, Set)>,
/// The most recent binding target for `chain=` additions. /// The most recent binding target for `chain=` additions.
pub chain_target: ChainTarget, pub chain_target: ChainTarget,
@@ -445,27 +446,30 @@ impl Keybinds {
// others are used to these keys so we set them as defaults. If // others are used to these keys so we set them as defaults. If
// people want to get back to the fixterm encoding they can set // people want to get back to the fixterm encoding they can set
// the keybinds to `unbind`. // the keybinds to `unbind`.
set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".to_owned())); set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".into()));
set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".to_owned())); set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".into()));
set.put(phys(Key::Backspace, sup), Action::Text("\\x15".to_owned())); set.put(phys(Key::Backspace, sup), Action::Text("\\x15".into()));
set.put( set.put(
phys(Key::ArrowLeft, mods(false, false, true, false)), phys(Key::ArrowLeft, mods(false, false, true, false)),
Action::Esc("b".to_owned()), Action::Esc("b".into()),
); );
set.put( set.put(
phys(Key::ArrowRight, mods(false, false, true, false)), phys(Key::ArrowRight, mods(false, false, true, false)),
Action::Esc("f".to_owned()), Action::Esc("f".into()),
); );
} }
fn table_index(&self, name: &str) -> Option<usize> { fn table_index(&self, name: &EncodedOs) -> Option<usize> {
self.tables.iter().position(|(n, _)| n == name) self.tables.iter().position(|(n, _)| n == name)
} }
/// Get a table by name. /// Get a table by name.
#[must_use] #[must_use]
pub fn table(&self, name: &str) -> Option<&Set> { pub fn table(&self, name: &str) -> Option<&Set> {
self.tables.iter().find(|(n, _)| n == name).map(|(_, s)| s) self.tables
.iter()
.find(|(n, _)| n == &EncodedOs::from_plain_text(name))
.map(|(_, s)| s)
} }
/// Like format_entry but has an option to include docs. /// Like format_entry but has an option to include docs.
@@ -483,14 +487,18 @@ impl Keybinds {
f.writer.write_str("\n")?; f.writer.write_str("\n")?;
} }
let mut buf = format!("{}", k.0); let mut buf = ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string());
emit(v, &mut buf, f)?; emit(v, &mut buf, f)?;
} }
// Format table bindings // Format table bindings
for (table_name, table_set) in &self.tables { for (table_name, table_set) in &self.tables {
for (k, v) in &table_set.bindings { for (k, v) in &table_set.bindings {
let mut buf = format!("{}/{}", table_name, k.0); let mut buf = format!(
"{}/{}",
table_name.as_encoded(),
ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string())
);
emit(v, &mut buf, f)?; emit(v, &mut buf, f)?;
} }
} }
@@ -506,7 +514,9 @@ struct Adapter<'a, 'b> {
impl BindingEntryFormatter for Adapter<'_, '_> { impl BindingEntryFormatter for Adapter<'_, '_> {
fn format_entry(&mut self, value: &str) { fn format_entry(&mut self, value: &str) {
if self.result.is_ok() { if self.result.is_ok() {
self.result = self.f.format_entry(&value.to_string()); self.result = self
.f
.format_entry(&ghostty_os::osbytes::EncodedOs::from_encoded(value));
} }
} }
} }
@@ -562,7 +572,12 @@ impl PartialEq for Keybinds {
return false; return false;
} }
for (name, set) in &self.tables { for (name, set) in &self.tables {
let Some(other_set) = other.table(name) else { let Some(other_set) = other
.tables
.iter()
.find(|(n, _)| n == name)
.map(|(_, set)| set)
else {
return false; return false;
}; };
if !equal_set(set, other_set) { if !equal_set(set, other_set) {
@@ -574,7 +589,7 @@ impl PartialEq for Keybinds {
} }
impl ConfigField for Keybinds { impl ConfigField for Keybinds {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
// Check for special values // Check for special values
@@ -584,7 +599,7 @@ impl ConfigField for Keybinds {
return Ok(()); return Ok(());
} }
if value == "clear" { if value == b"clear" {
log::info!("config has 'keybind = clear', all keybinds cleared"); log::info!("config has 'keybind = clear', all keybinds cleared");
self.set = Set::default(); self.set = Set::default();
self.tables = Vec::new(); self.tables = Vec::new();
@@ -595,28 +610,29 @@ impl ConfigField for Keybinds {
// Check for table syntax: "name/" or "name/binding" // Check for table syntax: "name/" or "name/binding"
// We look for '/' only before the first '=' to avoid matching // We look for '/' only before the first '=' to avoid matching
// action arguments like "foo=text:/hello". // action arguments like "foo=text:/hello".
let eq_idx = value.find('=').unwrap_or(value.len()); let eq_idx = value.iter().position(|b| *b == b'=').unwrap_or(value.len());
if let Some(slash_idx) = value[..eq_idx].find('/') { if let Some(slash_idx) = value[..eq_idx].iter().position(|b| *b == b'/') {
let table_name = &value[..slash_idx]; let table_name = &value[..slash_idx];
// Length zero is valid, so you can set `/=action` for the slash key. // Length zero is valid, so you can set `/=action` for the slash key.
// Ignore '+', '>' because they can be part of sequences and // Ignore '+', '>' because they can be part of sequences and
// triggers. This lets things like `ctrl+/=action` work. // triggers. This lets things like `ctrl+/=action` work.
if !table_name.is_empty() && !table_name.contains(['+', '>']) { if !table_name.is_empty() && !table_name.iter().any(|b| matches!(b, b'+' | b'>')) {
let binding = &value[slash_idx + 1..]; let binding = &value[slash_idx + 1..];
let table_name = EncodedOs::from_bytes(table_name);
// Get or create the table // Get or create the table
let idx = if let Some(i) = self.table_index(table_name) { let idx = if let Some(i) = self.table_index(&table_name) {
i i
} else { } else {
self.tables.push((table_name.to_string(), Set::default())); self.tables.push((table_name.clone(), Set::default()));
self.tables.len() - 1 self.tables.len() - 1
}; };
// If there's no binding after the slash, this is a table // If there's no binding after the slash, this is a table
// definition/clear command // definition/clear command
if binding.is_empty() { if binding.is_empty() {
log::debug!("config has 'keybind = {table_name}/', table cleared"); log::debug!("config has 'keybind = {table_name:?}/', table cleared");
self.tables[idx].1 = Set::default(); self.tables[idx].1 = Set::default();
self.chain_target = ChainTarget::Root; self.chain_target = ChainTarget::Root;
return Ok(()); return Ok(());
@@ -624,23 +640,23 @@ impl ConfigField for Keybinds {
// Chains are only allowed at the root level. Their target is // Chains are only allowed at the root level. Their target is
// tracked globally by parse order in `self.chain_target`. // tracked globally by parse order in `self.chain_target`.
if binding.starts_with("chain=") { if binding.starts_with(b"chain=") {
return Err(ArgError::other("InvalidFormat")); return Err(ArgError::other("InvalidFormat"));
} }
// Parse and add the binding to the table // Parse and add the binding to the table
self.tables[idx] self.tables[idx]
.1 .1
.parse_and_put(binding) .parse_and_put_bytes(binding)
.map_err(to_arg_error)?; .map_err(to_arg_error)?;
self.chain_target = ChainTarget::Table(table_name.to_string()); self.chain_target = ChainTarget::Table(table_name.clone());
return Ok(()); return Ok(());
} }
} }
if value.starts_with("chain=") { if value.starts_with(b"chain=") {
match self.chain_target.clone() { match self.chain_target.clone() {
ChainTarget::Root => self.set.parse_and_put(value).map_err(to_arg_error)?, ChainTarget::Root => self.set.parse_and_put_bytes(value).map_err(to_arg_error)?,
ChainTarget::Table(table_name) => { ChainTarget::Table(table_name) => {
let Some(idx) = self.table_index(&table_name) else { let Some(idx) = self.table_index(&table_name) else {
self.chain_target = ChainTarget::Root; self.chain_target = ChainTarget::Root;
@@ -648,7 +664,7 @@ impl ConfigField for Keybinds {
}; };
self.tables[idx] self.tables[idx]
.1 .1
.parse_and_put(value) .parse_and_put_bytes(value)
.map_err(to_arg_error)?; .map_err(to_arg_error)?;
} }
} }
@@ -656,7 +672,7 @@ impl ConfigField for Keybinds {
} }
// Parse into default set // Parse into default set
self.set.parse_and_put(value).map_err(to_arg_error)?; self.set.parse_and_put_bytes(value).map_err(to_arg_error)?;
self.chain_target = ChainTarget::Root; self.chain_target = ChainTarget::Root;
Ok(()) Ok(())
} }
@@ -1037,7 +1053,7 @@ mod tests {
let Some(Value::Leaf(l)) = lookup(&kb, phys(Key::ArrowLeft, sup)) else { let Some(Value::Leaf(l)) = lookup(&kb, phys(Key::ArrowLeft, sup)) else {
panic!() panic!()
}; };
assert_eq!(l.action, Action::Text("\\x01".to_owned())); assert_eq!(l.action, Action::Text("\\x01".into()));
// ctrlOrSuper picks super: super+comma opens the config. // ctrlOrSuper picks super: super+comma opens the config.
let Some(Value::Leaf(l)) = lookup(&kb, uni(',', sup)) else { let Some(Value::Leaf(l)) = lookup(&kb, uni(',', sup)) else {
+16 -10
View File
@@ -103,22 +103,28 @@ macro_rules! config_struct {
} }
} }
/// Parse a plain UTF-8 value supplied programmatically.
#[allow(dead_code)]
pub fn parse_field(&mut self, key: &str, value: Option<&str>) -> Result<(), $crate::cli_args::ArgError> {
self.parse_field_bytes(key, value.map(str::as_bytes))
}
#[allow(dead_code)] #[allow(dead_code)]
/// Parse a single key/value into the matching field. Empty /// Parse a single key/value into the matching field. Empty
/// values reset the field to its default. /// values reset the field to its default.
pub fn parse_field( pub fn parse_field_bytes(
&mut self, &mut self,
key: &str, key: &str,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), $crate::cli_args::ArgError> { ) -> ::core::result::Result<(), $crate::cli_args::ArgError> {
match key { match key {
$( $(
$key => { $key => {
if value == ::core::option::Option::Some("") { if value == ::core::option::Option::Some(b"".as_slice()) {
$crate::cli_args::ConfigField::reset(&mut self.$field, $default); $crate::cli_args::ConfigField::reset(&mut self.$field, $default);
return ::core::result::Result::Ok(()); return ::core::result::Result::Ok(());
} }
$crate::cli_args::ConfigField::parse_cli(&mut self.$field, value) $crate::cli_args::ConfigField::parse_bytes(&mut self.$field, value)
} }
)* )*
_ => ::core::result::Result::Err($crate::cli_args::ArgError::InvalidField), _ => ::core::result::Result::Err($crate::cli_args::ArgError::InvalidField),
@@ -215,11 +221,11 @@ macro_rules! config_enum {
const VALID_VALUES: ::core::option::Option<&'static [&'static str]> = const VALID_VALUES: ::core::option::Option<&'static [&'static str]> =
::core::option::Option::Some(Self::NAMES); ::core::option::Option::Some(Self::NAMES);
fn parse_cli( fn parse_bytes(
&mut self, &mut self,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), $crate::cli_args::ArgError> { ) -> ::core::result::Result<(), $crate::cli_args::ArgError> {
let v = value.ok_or($crate::cli_args::ArgError::ValueRequired)?; let v = $crate::cli_args::text_value(value)?.ok_or($crate::cli_args::ArgError::ValueRequired)?;
*self = Self::from_name(v).ok_or($crate::cli_args::ArgError::InvalidValue)?; *self = Self::from_name(v).ok_or($crate::cli_args::ArgError::InvalidValue)?;
::core::result::Result::Ok(()) ::core::result::Result::Ok(())
} }
@@ -295,11 +301,11 @@ macro_rules! config_packed {
$crate::c_value::CValue::Packed(bits) $crate::c_value::CValue::Packed(bits)
} }
fn parse_cli( fn parse_bytes(
&mut self, &mut self,
value: ::core::option::Option<&str>, value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), $crate::cli_args::ArgError> { ) -> ::core::result::Result<(), $crate::cli_args::ArgError> {
let v = value.ok_or($crate::cli_args::ArgError::ValueRequired)?; let v = $crate::cli_args::text_value(value)?.ok_or($crate::cli_args::ArgError::ValueRequired)?;
let mut result = <$name as ::core::default::Default>::default(); let mut result = <$name as ::core::default::Default>::default();
$crate::cli_args::parse_packed_struct_with(v, Self::NAMES, |i, b| { $crate::cli_args::parse_packed_struct_with(v, Self::NAMES, |i, b| {
result.set_index(i, b); result.set_index(i, b);
+2 -1
View File
@@ -99,7 +99,8 @@ impl MetricModifier {
} }
impl ConfigField for MetricModifier { impl ConfigField for MetricModifier {
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
*self = Self::parse_cli_value(value)?; *self = Self::parse_cli_value(value)?;
Ok(()) Ok(())
} }
+24 -20
View File
@@ -33,8 +33,8 @@ impl Default for Path {
} }
/// Lexically resolve `base/path` like `std.fs.path.resolve`. /// Lexically resolve `base/path` like `std.fs.path.resolve`.
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs { fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> std::io::Result<EncodedOs> {
let joined = base.to_path().join(path.to_path()); let joined = base.to_path()?.join(path.to_path()?);
let mut out = PathBuf::new(); let mut out = PathBuf::new();
for c in joined.components() { for c in joined.components() {
match c { match c {
@@ -45,7 +45,7 @@ fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
other => out.push(other.as_os_str()), other => out.push(other.as_os_str()),
} }
} }
EncodedOs::from_path(&out) Ok(EncodedOs::from_path(&out))
} }
impl Path { impl Path {
@@ -75,27 +75,31 @@ impl Path {
/// ///
/// Returns `Ok(None)` for an empty input. /// Returns `Ok(None)` for an empty input.
pub fn parse(input: Option<&str>) -> Result<Option<Path>, ArgError> { pub fn parse(input: Option<&str>) -> Result<Option<Path>, ArgError> {
Self::parse_bytes(input.map(str::as_bytes))
}
pub fn parse_bytes(input: Option<&[u8]>) -> Result<Option<Path>, ArgError> {
let mut value = input.ok_or(ArgError::ValueRequired)?; let mut value = input.ok_or(ArgError::ValueRequired)?;
if value.is_empty() { if value.is_empty() {
return Ok(None); return Ok(None);
} }
let optional = if let Some(rest) = value.strip_prefix('?') { let optional = if let Some(rest) = value.strip_prefix(b"?") {
value = rest; value = rest;
true true
} else { } else {
false false
}; };
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { if value.len() >= 2 && value.starts_with(b"\"") && value.ends_with(b"\"") {
value = &value[1..value.len() - 1]; value = &value[1..value.len() - 1];
} }
Ok(Some(if optional { Ok(Some(if optional {
Path::Optional(EncodedOs::from_encoded(value)) Path::Optional(EncodedOs::from_bytes(value))
} else { } else {
Path::Required(EncodedOs::from_encoded(value)) Path::Required(EncodedOs::from_bytes(value))
})) }))
} }
@@ -107,13 +111,13 @@ impl Path {
/// only returned if the base directory cannot be opened. /// only returned if the base directory cannot be opened.
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> { pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Filesystem calls use the decoded OS paths. // Filesystem calls use the decoded OS paths.
let base_path = base.to_path(); let base_path = base.to_path()?;
assert!(base_path.is_absolute()); assert!(base_path.is_absolute());
let path = self.path().clone(); let path = self.path().clone();
// If it is already absolute we can ignore it. // If it is already absolute we can ignore it.
if path.is_empty() || path.to_path().is_absolute() { if path.is_empty() || path.to_path()?.is_absolute() {
return Ok(()); return Ok(());
} }
@@ -126,7 +130,7 @@ impl Path {
Ok(expanded) => { Ok(expanded) => {
log::debug!( log::debug!(
"expanding file path from home directory: path={}", "expanding file path from home directory: path={}",
expanded.as_encoded() expanded.to_text_lossy()
); );
*self.path_mut() = expanded; *self.path_mut() = expanded;
} }
@@ -150,13 +154,13 @@ impl Path {
// Make sure the base directory can be opened. // Make sure the base directory can be opened.
std::fs::read_dir(&base_path)?; std::fs::read_dir(&base_path)?;
let full = base_path.join(path.to_path()); let full = base_path.join(path.to_path()?);
let abs = match std::fs::canonicalize(&full) { let abs = match std::fs::canonicalize(&full) {
Ok(p) => EncodedOs::from_path(&p), Ok(p) => EncodedOs::from_path(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path // The file doesn't exist. Try to resolve the relative path
// another way. // another way.
resolve_lexical(base, &path) resolve_lexical(base, &path)?
} }
Err(err) => { Err(err) => {
diags.append(Diagnostic { diags.append(Diagnostic {
@@ -172,8 +176,8 @@ impl Path {
log::debug!( log::debug!(
"expanding file path relative={} abs={}", "expanding file path relative={} abs={}",
path.as_encoded(), path.to_text_lossy(),
abs.as_encoded() abs.to_text_lossy()
); );
*self.path_mut() = abs; *self.path_mut() = abs;
Ok(()) Ok(())
@@ -195,8 +199,8 @@ impl ConfigField for Path {
} }
/// Parse CLI option. The value will be overwritten. /// Parse CLI option. The value will be overwritten.
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let Some(item) = Path::parse(input)? else { let Some(item) = Path::parse_bytes(input)? else {
return Ok(()); return Ok(());
}; };
if item.is_empty() { if item.is_empty() {
@@ -237,8 +241,8 @@ impl RepeatablePath {
impl ConfigField for RepeatablePath { impl ConfigField for RepeatablePath {
const KIND: crate::cli_args::FieldKind = crate::cli_args::FieldKind::RepeatablePath; const KIND: crate::cli_args::FieldKind = crate::cli_args::FieldKind::RepeatablePath;
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let Some(item) = Path::parse(input)? else { let Some(item) = Path::parse_bytes(input)? else {
self.value.clear(); self.value.clear();
return Ok(()); return Ok(());
}; };
@@ -403,7 +407,7 @@ mod tests {
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap(); p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
assert_eq!(diags.items().len(), 0); assert_eq!(diags.items().len(), 0);
// The canonical (non-UTF-8) target is preserved byte for byte. // The canonical (non-UTF-8) target is preserved byte for byte.
assert_eq!(p.path().to_path(), target); assert_eq!(p.path().to_path().unwrap(), target);
assert!(std::fs::metadata(p.path().to_path()).is_ok()); assert!(std::fs::metadata(p.path().to_path().unwrap()).is_ok());
} }
} }
@@ -4,30 +4,31 @@
use std::fmt; use std::fmt;
use ghostty_os::osbytes::EncodedOs;
use crate::{ use crate::{
cli_args::{ArgError, ConfigField}, cli_args::{ArgError, ConfigField},
formatter::EntryFormatter, formatter::EntryFormatter,
}; };
/// std.ascii.whitespace
fn is_ascii_ws(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
}
/// An insertion-ordered string map. /// An insertion-ordered string map.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct RepeatableStringMap { pub struct RepeatableStringMap {
map: Vec<(String, String)>, map: Vec<(EncodedOs, EncodedOs)>,
} }
impl PartialEq for RepeatableStringMap { impl PartialEq for RepeatableStringMap {
/// Order-insensitive comparison (Zig: `equal`). /// Order-insensitive comparison (Zig: `equal`).
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.map.len() == other.map.len() self.map.len() == other.map.len()
&& self && self.map.iter().all(|(k, v)| {
.map other
.iter() .map
.all(|(k, v)| other.get(k).is_some_and(|ov| ov == v)) .iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v)
.is_some_and(|ov| ov == v)
})
} }
} }
@@ -39,29 +40,35 @@ impl RepeatableStringMap {
} }
#[must_use] #[must_use]
pub fn get(&self, key: &str) -> Option<&str> { pub fn get(&self, key: &str) -> Option<&EncodedOs> {
self.map self.map
.iter() .iter()
.find(|(k, _)| k == key) .find(|(k, _)| k == &EncodedOs::from_plain_text(key))
.map(|(_, v)| v.as_str()) .map(|(_, v)| v)
} }
/// Insert or replace an entry (Zig: `map.put`). /// Insert or replace an entry (Zig: `map.put`).
pub fn put(&mut self, key: &str, value: &str) { pub fn put(&mut self, key: &str, value: &str) {
match self.map.iter_mut().find(|(k, _)| k == key) { self.put_bytes(key.as_bytes(), value.as_bytes());
Some((_, v)) => value.clone_into(v), }
None => self.map.push((key.to_owned(), value.to_owned())),
pub fn put_bytes(&mut self, key: &[u8], value: &[u8]) {
let key = EncodedOs::from_bytes(key);
let value = EncodedOs::from_bytes(value);
match self.map.iter_mut().find(|(k, _)| k == &key) {
Some((_, v)) => *v = value,
None => self.map.push((key, value)),
} }
} }
/// Iterator over the entries in the map (insertion order). /// Iterator over the entries in the map (insertion order).
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { pub fn iter(&self) -> impl Iterator<Item = (&EncodedOs, &EncodedOs)> {
self.map.iter().map(|(k, v)| (k.as_str(), v.as_str())) self.map.iter().map(|(k, v)| (k, v))
} }
} }
impl ConfigField for RepeatableStringMap { impl ConfigField for RepeatableStringMap {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
// Empty value resets the list. // Empty value resets the list.
@@ -70,21 +77,25 @@ impl ConfigField for RepeatableStringMap {
return Ok(()); return Ok(());
} }
let index = value.find('=').ok_or(ArgError::ValueRequired)?; let index = value
let key = value[..index].trim_matches(is_ascii_ws); .iter()
let val = value[index + 1..].trim_matches(is_ascii_ws); .position(|b| *b == b'=')
.ok_or(ArgError::ValueRequired)?;
let key = EncodedOs::from_bytes(crate::cli_args::trim_ascii_whitespace(&value[..index]));
let val =
EncodedOs::from_bytes(crate::cli_args::trim_ascii_whitespace(&value[index + 1..]));
// Empty value removes the key from the map. // Empty value removes the key from the map.
if val.is_empty() { if val.is_empty() {
if let Some(pos) = self.map.iter().position(|(k, _)| k == key) { if let Some(pos) = self.map.iter().position(|(k, _)| k == &key) {
self.map.remove(pos); self.map.remove(pos);
} }
return Ok(()); return Ok(());
} }
match self.map.iter_mut().find(|(k, _)| k == key) { match self.map.iter_mut().find(|(k, _)| k == &key) {
Some(entry) => entry.1 = val.to_string(), Some(entry) => entry.1 = val,
None => self.map.push((key.to_string(), val.to_string())), None => self.map.push((key, val)),
} }
Ok(()) Ok(())
} }
@@ -95,7 +106,13 @@ impl ConfigField for RepeatableStringMap {
return writeln!(f.writer, "{} = ", f.name); return writeln!(f.writer, "{} = ", f.name);
} }
for (k, v) in &self.map { for (k, v) in &self.map {
writeln!(f.writer, "{} = {k}={v}", f.name)?; writeln!(
f.writer,
"{} = {}={}",
f.name,
k.as_encoded(),
v.as_encoded()
)?;
} }
Ok(()) Ok(())
} }
@@ -130,7 +147,7 @@ mod tests {
assert_eq!(map.count(), 1); assert_eq!(map.count(), 1);
map.parse_cli(Some("A=C")).unwrap(); map.parse_cli(Some("A=C")).unwrap();
assert_eq!(map.count(), 1); assert_eq!(map.count(), 1);
assert_eq!(map.get("A"), Some("C")); assert_eq!(map.get("A").map(EncodedOs::as_encoded), Some("C"));
// Empty value removes the key // Empty value removes the key
map.parse_cli(Some("A=")).unwrap(); map.parse_cli(Some("A=")).unwrap();
+46 -43
View File
@@ -75,58 +75,43 @@ fn parse_escape_sequence(bytes: &[u8], i: &mut usize) -> Option<u32> {
Some(cp) Some(cp)
} }
/// Parse a string literal into the original bytes (Zig `parse`'s output). /// Parse literal escapes from the original bytes, copying unescaped bytes verbatim.
/// `input` is a config string in the reversible OS-byte encoding; the result /// Like Zig, \xFF denotes U+00FF; only unescaped bytes bypass UTF-8 validation.
/// is decoded, so it can be written to the pty / OS directly. pub fn parse_bytes(input: impl AsRef<[u8]>) -> Result<Vec<u8>, StringError> {
pub fn parse_bytes(input: &str) -> Result<Vec<u8>, StringError> { let bytes = input.as_ref();
parse(input).map(|s| ghostty_os::osbytes::config_str_to_bytes(&s)) let mut out = Vec::with_capacity(bytes.len());
}
/// Parse a string literal into a string. The string can contain
/// any valid Zig string literal escape sequences.
///
/// The input and output are in the reversible OS-byte encoding (see
/// `ghostty_os::osbytes`): bytes are copied through unchanged, and a
/// code point produced by an escape is encoded as plain text, so a
/// literal `U+FDD0` from `\u{fdd0}` stays distinct from the escape
/// character. Use [`parse_bytes`] for the raw bytes.
pub fn parse(bytes: &str) -> Result<String, StringError> {
let b = bytes.as_bytes();
let mut out = String::with_capacity(bytes.len());
let mut i = 0; let mut i = 0;
while i < b.len() { while i < bytes.len() {
if b[i] != b'\\' { if bytes[i] != b'\\' {
// Copy a whole UTF-8 character. out.push(bytes[i]);
let ch = bytes[i..] i += 1;
.chars()
.next()
.ok_or(StringError::InvalidString)?;
out.push(ch);
i += ch.len_utf8();
continue; continue;
} }
let cp = parse_escape_sequence(bytes, &mut i).ok_or(StringError::InvalidString)?;
let cp = parse_escape_sequence(b, &mut i).ok_or(StringError::InvalidString)?;
// Surrogates cannot be encoded.
let ch = char::from_u32(cp).ok_or(StringError::InvalidString)?; let ch = char::from_u32(cp).ok_or(StringError::InvalidString)?;
if ch == '\u{FDD0}' { out.extend_from_slice(ch.encode_utf8(&mut [0; 4]).as_bytes());
out.push_str("\u{FDD0}\u{FDD0}");
} else {
out.push(ch);
}
} }
Ok(out) Ok(out)
} }
/// Plain-text convenience; byte consumers use [`parse_bytes`].
pub fn parse(input: &str) -> Result<String, StringError> {
String::from_utf8(parse_bytes(input)?).map_err(|_| StringError::InvalidString)
}
/// Creates an iterator that requires no allocation to extract codepoints /// Creates an iterator that requires no allocation to extract codepoints
/// from the string literal, parsing escape sequences as it goes. /// from the string literal, parsing escape sequences as it goes.
#[must_use] #[must_use]
pub fn codepoint_iterator(bytes: &str) -> CodepointIterator<'_> { pub fn codepoint_iterator(bytes: &str) -> CodepointIterator<'_> {
codepoint_iterator_bytes(bytes.as_bytes())
}
pub fn codepoint_iterator_bytes(bytes: &[u8]) -> CodepointIterator<'_> {
CodepointIterator { bytes, i: 0 } CodepointIterator { bytes, i: 0 }
} }
pub struct CodepointIterator<'a> { pub struct CodepointIterator<'a> {
bytes: &'a str, bytes: &'a [u8],
i: usize, i: usize,
} }
@@ -136,19 +121,31 @@ impl CodepointIterator<'_> {
if self.i >= self.bytes.len() { if self.i >= self.bytes.len() {
return Ok(None); return Ok(None);
} }
if self.bytes.as_bytes()[self.i] == b'\\' { if self.bytes[self.i] == b'\\' {
// An escape sequence // An escape sequence
return parse_escape_sequence(self.bytes.as_bytes(), &mut self.i) return parse_escape_sequence(self.bytes, &mut self.i)
.map(Some) .map(Some)
.ok_or(StringError::InvalidString); .ok_or(StringError::InvalidString);
} }
// Not an escape, parse as UTF-8 // Not an escape, parse as UTF-8
let ch = self.bytes[self.i..] let len = match self.bytes[self.i] {
0..=0x7f => 1,
0xc2..=0xdf => 2,
0xe0..=0xef => 3,
0xf0..=0xf4 => 4,
_ => return Err(StringError::InvalidString),
};
let raw = self
.bytes
.get(self.i..self.i + len)
.ok_or(StringError::InvalidString)?;
let ch = std::str::from_utf8(raw)
.map_err(|_| StringError::InvalidString)?
.chars() .chars()
.next() .next()
.ok_or(StringError::InvalidString)?; .ok_or(StringError::InvalidString)?;
self.i += ch.len_utf8(); self.i += len;
Ok(Some(u32::from(ch))) Ok(Some(u32::from(ch)))
} }
} }
@@ -157,17 +154,23 @@ impl CodepointIterator<'_> {
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
#[test] #[test]
fn parse_bytes_decodes_the_reversible_encoding() { fn parse_bytes_copies_original_bytes() {
use ghostty_os::osbytes::EncodedOs; use ghostty_os::osbytes::EncodedOs;
use super::{parse, parse_bytes}; use super::{parse, parse_bytes};
// Raw non-UTF-8 bytes pass through as the same bytes. // Raw non-UTF-8 bytes pass through as the same bytes.
let enc = EncodedOs::from_bytes(b"a\xffb\\n").into_encoded(); let enc = EncodedOs::from_bytes(b"a\xffb\\n").into_encoded();
assert_eq!(parse_bytes(&enc).unwrap(), b"a\xffb\n"); assert_eq!(
parse_bytes(EncodedOs::from_encoded(enc).to_bytes()).unwrap(),
b"a\xffb\n"
);
// A literal U+FDD0 (typed or via an escape) is that character. // A literal U+FDD0 (typed or via an escape) is that character.
let lit = EncodedOs::from_plain_text("\u{fdd0}").into_encoded(); let lit = EncodedOs::from_plain_text("\u{fdd0}").into_encoded();
assert_eq!(parse_bytes(&lit).unwrap(), "\u{fdd0}".as_bytes()); assert_eq!(
parse_bytes(EncodedOs::from_encoded(lit).to_bytes()).unwrap(),
"\u{fdd0}".as_bytes()
);
assert_eq!(parse_bytes("\\u{fdd0}").unwrap(), "\u{fdd0}".as_bytes()); assert_eq!(parse_bytes("\\u{fdd0}").unwrap(), "\u{fdd0}".as_bytes());
// An escape producing U+FF is the UTF-8 encoding, not byte FF. // An escape producing U+FF is the UTF-8 encoding, not byte FF.
assert_eq!(parse_bytes("\\xff").unwrap(), "\u{ff}".as_bytes()); assert_eq!(parse_bytes("\\xff").unwrap(), "\u{ff}".as_bytes());
+13 -6
View File
@@ -37,7 +37,7 @@ impl Location {
.ok(), .ok(),
Location::Resources => { Location::Resources => {
let base = resources_dir?; let base = resources_dir?;
Some(EncodedOs::from_path(&base.to_path().join("themes"))) Some(EncodedOs::from_path(&base.to_path().ok()?.join("themes")))
} }
} }
} }
@@ -157,7 +157,13 @@ pub fn open(
resources_dir: Option<&EncodedOs>, resources_dir: Option<&EncodedOs>,
diags: &mut DiagnosticList, diags: &mut DiagnosticList,
) -> Option<OpenedTheme> { ) -> Option<OpenedTheme> {
let theme_path = theme.to_path(); let theme_path = match theme.to_path() {
Ok(path) => path,
Err(error) => {
diag(diags, format!("failed to load theme: {error}"));
return None;
}
};
let theme_text = theme.as_encoded(); let theme_text = theme.as_encoded();
// Absolute themes are loaded a different path. // Absolute themes are loaded a different path.
@@ -190,8 +196,8 @@ pub fn open(
// one that exists. // one that exists.
let mut it = LocationIterator::new(resources_dir); let mut it = LocationIterator::new(resources_dir);
while let Some((_, dir)) = it.next_location() { while let Some((_, dir)) = it.next_location() {
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os())); let path = EncodedOs::from_path(&dir.to_path().ok()?.join(&theme_path));
match File::open(path.to_path()) { match path.to_path().and_then(File::open) {
Ok(file) => { Ok(file) => {
if !check_regular(&file, theme_text, diags) { if !check_regular(&file, theme_text, diags) {
return None; return None;
@@ -218,7 +224,7 @@ pub fn open(
// our iterator, reiterate over in order to build a better error message. // our iterator, reiterate over in order to build a better error message.
it.reset(); it.reset();
while let Some((_, dir)) = it.next_location() { while let Some((_, dir)) = it.next_location() {
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os())); let path = EncodedOs::from_path(&dir.to_path().ok()?.join(&theme_path));
diag( diag(
diags, diags,
format!( format!(
@@ -235,8 +241,9 @@ pub fn open(
/// then messages will be appended to the given error list and None is /// then messages will be appended to the given error list and None is
/// returned. /// returned.
pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> { pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
let path = theme.to_path();
let theme = theme.as_encoded(); let theme = theme.as_encoded();
match File::open(EncodedOs::from_encoded(theme).to_path()) { match path.and_then(File::open) {
Ok(f) => Some(f), Ok(f) => Some(f),
Err(err) => { Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound { if err.kind() == std::io::ErrorKind::NotFound {
+78 -49
View File
@@ -45,7 +45,8 @@ impl ConfigField for CursorStyle {
}) })
} }
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, value: Option<&[u8]>) -> Result<(), ArgError> {
let value = crate::cli_args::text_value(value)?;
let v = value.ok_or(ArgError::ValueRequired)?; let v = value.ok_or(ArgError::ValueRequired)?;
*self = match v { *self = match v {
"bar" => CursorStyle::Bar, "bar" => CursorStyle::Bar,
@@ -151,25 +152,22 @@ pub enum WorkingDirectory {
} }
impl ConfigField for WorkingDirectory { impl ConfigField for WorkingDirectory {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let mut input = input.trim_matches(|c: char| { let mut input = crate::cli_args::trim_ascii_whitespace(input);
// std.ascii.whitespace
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
});
if input.is_empty() { if input.is_empty() {
return Err(ArgError::ValueRequired); return Err(ArgError::ValueRequired);
} }
// Match path.zig behavior for quoted values. // Match path.zig behavior for quoted values.
if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') { if input.len() >= 2 && input.starts_with(b"\"") && input.ends_with(b"\"") {
input = &input[1..input.len() - 1]; input = &input[1..input.len() - 1];
} }
*self = match input { *self = match input {
"home" => WorkingDirectory::Home, b"home" => WorkingDirectory::Home,
"inherit" => WorkingDirectory::Inherit, b"inherit" => WorkingDirectory::Inherit,
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)), _ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_bytes(input)),
}; };
Ok(()) Ok(())
} }
@@ -199,7 +197,7 @@ impl WorkingDirectory {
Err(err) => { Err(err) => {
log::warn!( log::warn!(
"error expanding home directory for working-directory path={}: {err}", "error expanding home directory for working-directory path={}: {err}",
path.as_encoded() path.to_text_lossy()
); );
return; return;
} }
@@ -309,7 +307,8 @@ impl ConfigField for Color {
} }
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = Color::from_cli(input)?; *self = Color::from_cli(input)?;
Ok(()) Ok(())
} }
@@ -355,7 +354,8 @@ impl TerminalColor {
} }
impl ConfigField for TerminalColor { impl ConfigField for TerminalColor {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = TerminalColor::from_cli(input)?; *self = TerminalColor::from_cli(input)?;
Ok(()) Ok(())
} }
@@ -394,7 +394,8 @@ impl BoldColor {
} }
impl ConfigField for BoldColor { impl ConfigField for BoldColor {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = BoldColor::from_cli(input)?; *self = BoldColor::from_cli(input)?;
Ok(()) Ok(())
} }
@@ -417,7 +418,8 @@ impl ConfigField for ColorList {
CValue::ColorList(self.colors.iter().map(|c| [c.r, c.g, c.b]).collect()) CValue::ColorList(self.colors.iter().map(|c| [c.r, c.g, c.b]).collect())
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
if input.is_empty() { if input.is_empty() {
return Err(ArgError::ValueRequired); return Err(ArgError::ValueRequired);
@@ -485,7 +487,8 @@ impl ConfigField for Palette {
CValue::Palette(out) CValue::Palette(out)
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?; let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?;
@@ -522,7 +525,7 @@ impl ConfigField for Palette {
/// comma-separated values. /// comma-separated values.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct RepeatableString { pub struct RepeatableString {
pub list: Vec<String>, pub list: Vec<ghostty_os::osbytes::EncodedOs>,
/// If true, then the next value will clear the list and start over /// If true, then the next value will clear the list and start over
/// rather than append. This is a bit of a hack but is here to make /// rather than append. This is a bit of a hack but is here to make
@@ -552,7 +555,7 @@ impl RepeatableString {
} }
impl ConfigField for RepeatableString { impl ConfigField for RepeatableString {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
// Empty value resets the list // Empty value resets the list
@@ -567,7 +570,8 @@ impl ConfigField for RepeatableString {
self.overwrite_next = false; self.overwrite_next = false;
} }
self.list.push(value.to_string()); self.list
.push(ghostty_os::osbytes::EncodedOs::from_bytes(value));
Ok(()) Ok(())
} }
@@ -577,7 +581,7 @@ impl ConfigField for RepeatableString {
return write_void(f); return write_void(f);
} }
for v in &self.list { for v in &self.list {
write_str(f, v)?; write_str(f, v.as_encoded())?;
} }
Ok(()) Ok(())
} }
@@ -631,12 +635,12 @@ impl Default for SelectionWordChars {
} }
impl ConfigField for SelectionWordChars { impl ConfigField for SelectionWordChars {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
// Always include null as first boundary // Always include null as first boundary
let mut list = vec![0u32]; let mut list = vec![0u32];
let mut it = crate::string::codepoint_iterator(value); let mut it = crate::string::codepoint_iterator_bytes(value);
while let Some(cp) = it.next().map_err(|_| ArgError::InvalidValue)? { while let Some(cp) = it.next().map_err(|_| ArgError::InvalidValue)? {
list.push(cp); list.push(cp);
} }
@@ -657,7 +661,7 @@ impl ConfigField for SelectionWordChars {
} }
s.push(ch); s.push(ch);
} }
write_str(f, &s) write_str(f, &ghostty_os::osbytes::valid_str_to_config_string(&s))
} }
} }
@@ -682,15 +686,16 @@ pub struct RepeatableFontVariation {
} }
impl ConfigField for RepeatableFontVariation { impl ConfigField for RepeatableFontVariation {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; let eql_idx = input
let key = trim_ws(&input[..eql_idx]); .iter()
let value = trim_ws(&input[eql_idx + 1..]); .position(|b| *b == b'=')
let key: &[u8; 4] = key .ok_or(ArgError::InvalidValue)?;
.as_bytes() let key = crate::cli_args::trim_bytes(&input[..eql_idx]);
.try_into() let value = std::str::from_utf8(crate::cli_args::trim_bytes(&input[eql_idx + 1..]))
.map_err(|_| ArgError::InvalidValue)?; .map_err(|_| ArgError::InvalidValue)?;
let key: &[u8; 4] = key.try_into().map_err(|_| ArgError::InvalidValue)?;
self.list.push(Variation { self.list.push(Variation {
id: VariationId::init(key), id: VariationId::init(key),
value: value.parse::<f64>().map_err(|_| ArgError::InvalidValue)?, value: value.parse::<f64>().map_err(|_| ArgError::InvalidValue)?,
@@ -704,7 +709,14 @@ impl ConfigField for RepeatableFontVariation {
} }
for v in &self.list { for v in &self.list {
let id = v.id.str(); let id = v.id.str();
write_str(f, &format!("{}={}", String::from_utf8_lossy(&id), v.value))?; write_str(
f,
&format!(
"{}={}",
ghostty_os::osbytes::bytes_to_config_string(&id),
v.value
),
)?;
} }
Ok(()) Ok(())
} }
@@ -860,18 +872,22 @@ pub struct RepeatableCodepointMap {
} }
impl ConfigField for RepeatableCodepointMap { impl ConfigField for RepeatableCodepointMap {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; let eql_idx = input
let key = trim_ws(&input[..eql_idx]); .iter()
let value = trim_ws(&input[eql_idx + 1..]); .position(|b| *b == b'=')
.ok_or(ArgError::InvalidValue)?;
let key = crate::cli_args::trim_bytes(&input[..eql_idx]);
let value = crate::cli_args::trim_bytes(&input[eql_idx + 1..]);
let mut p = UnicodeRangeParser::new(key); let mut p =
UnicodeRangeParser::new(std::str::from_utf8(key).map_err(|_| ArgError::InvalidValue)?);
while let Some(range) = p.next_range()? { while let Some(range) = p.next_range()? {
self.map.add(CodepointMapEntry { self.map.add(CodepointMapEntry {
range, range,
descriptor: Descriptor { descriptor: Descriptor {
family: Some(value.to_string()), family: Some(ghostty_os::osbytes::EncodedOs::from_bytes(value)),
monospace: false, // we allow any font monospace: false, // we allow any font
..Descriptor::default() ..Descriptor::default()
}, },
@@ -886,7 +902,11 @@ impl ConfigField for RepeatableCodepointMap {
} }
for e in &self.map.list { for e in &self.map.list {
let family = e.descriptor.family.as_deref().unwrap_or(""); let family = e
.descriptor
.family
.as_ref()
.map_or("", ghostty_os::osbytes::EncodedOs::as_encoded);
if e.range[0] == e.range[1] { if e.range[0] == e.range[1] {
write_str(f, &format!("U+{:04X}={}", e.range[0], family))?; write_str(f, &format!("U+{:04X}={}", e.range[0], family))?;
} else { } else {
@@ -907,7 +927,8 @@ pub struct RepeatableClipboardCodepointMap {
} }
impl ConfigField for RepeatableClipboardCodepointMap { impl ConfigField for RepeatableClipboardCodepointMap {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?; let eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?;
let key = trim_ws(&input[..eql_idx]); let key = trim_ws(&input[..eql_idx]);
@@ -941,7 +962,7 @@ impl ConfigField for RepeatableClipboardCodepointMap {
for e in &self.map.list { for e in &self.map.list {
let value_str = match &e.replacement { let value_str = match &e.replacement {
Replacement::Codepoint(cp) => format!("U+{cp:04X}"), Replacement::Codepoint(cp) => format!("U+{cp:04X}"),
Replacement::String(s) => s.clone(), Replacement::String(s) => ghostty_os::osbytes::valid_str_to_config_string(s),
}; };
if e.range[0] == e.range[1] { if e.range[0] == e.range[1] {
@@ -971,14 +992,14 @@ pub enum FontStyle {
False, False,
/// A specific named font style to use for this style. /// A specific named font style to use for this style.
Name(String), Name(ghostty_os::osbytes::EncodedOs),
} }
impl FontStyle { impl FontStyle {
/// Returns the string name value that can be used with a font /// Returns the string name value that can be used with a font
/// descriptor. /// descriptor.
#[must_use] #[must_use]
pub fn name_value(&self) -> Option<&str> { pub fn name_value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> {
match self { match self {
FontStyle::Default | FontStyle::False => None, FontStyle::Default | FontStyle::False => None,
FontStyle::Name(n) => Some(n), FontStyle::Name(n) => Some(n),
@@ -987,12 +1008,12 @@ impl FontStyle {
} }
impl ConfigField for FontStyle { impl ConfigField for FontStyle {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?; let value = input.ok_or(ArgError::ValueRequired)?;
*self = match value { *self = match value {
"default" => FontStyle::Default, b"default" => FontStyle::Default,
"false" => FontStyle::False, b"false" => FontStyle::False,
_ => FontStyle::Name(value.to_string()), _ => FontStyle::Name(ghostty_os::osbytes::EncodedOs::from_bytes(value)),
}; };
Ok(()) Ok(())
} }
@@ -1001,7 +1022,7 @@ impl ConfigField for FontStyle {
match self { match self {
FontStyle::Default => write_str(f, "default"), FontStyle::Default => write_str(f, "default"),
FontStyle::False => write_str(f, "false"), FontStyle::False => write_str(f, "false"),
FontStyle::Name(n) => write_str(f, n), FontStyle::Name(n) => write_str(f, n.as_encoded()),
} }
} }
} }
@@ -1032,7 +1053,8 @@ pub struct RepeatableLink {
} }
impl ConfigField for RepeatableLink { impl ConfigField for RepeatableLink {
fn parse_cli(&mut self, _input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, _input: Option<&[u8]>) -> Result<(), ArgError> {
let _input = crate::cli_args::text_value(_input)?;
Err(ArgError::other("NotImplemented")) Err(ArgError::other("NotImplemented"))
} }
@@ -1477,7 +1499,14 @@ mod tests {
fn fam(map: &RepeatableCodepointMap, i: usize) -> (&[u32; 2], &str) { fn fam(map: &RepeatableCodepointMap, i: usize) -> (&[u32; 2], &str) {
let e = &map.map.list[i]; let e = &map.map.list[i];
(&e.range, e.descriptor.family.as_deref().unwrap()) (
&e.range,
e.descriptor
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded)
.unwrap(),
)
} }
#[test] #[test]
+49 -34
View File
@@ -6,6 +6,7 @@
use std::fmt; use std::fmt;
use ghostty_input::binding::Action; use ghostty_input::binding::Action;
use ghostty_os::osbytes::EncodedOs;
use crate::{ use crate::{
c_value::{CValue, QuickTerminalSizeC}, c_value::{CValue, QuickTerminalSizeC},
@@ -92,17 +93,18 @@ use ghostty_input::command::Command;
/// Parse a command from `title:...,description:...,action:...` /// Parse a command from `title:...,description:...,action:...`
/// (Zig: `parseAutoStruct(inputpkg.Command, alloc, input, null)`). /// (Zig: `parseAutoStruct(inputpkg.Command, alloc, input, null)`).
pub fn parse_command_auto(input: &str) -> Result<Command, ArgError> { pub fn parse_command_auto(input: &[u8]) -> Result<Command, ArgError> {
let mut action: Option<Action> = None; let mut action: Option<Action> = None;
let mut title: Option<String> = None; let mut title: Option<EncodedOs> = None;
let mut description = String::new(); let mut description = EncodedOs::new();
parse_auto_struct_with(input, |k, v| { crate::cli_args::parse_auto_struct_bytes_with(input, |k, v| {
match k { match k {
"action" => { "action" => {
action = Some(Action::parse(v).map_err(|e| ArgError::Other(format!("{e:?}")))?); action =
Some(Action::parse_bytes(v).map_err(|e| ArgError::Other(format!("{e:?}")))?);
} }
"title" => title = Some(v.to_string()), "title" => title = Some(EncodedOs::from_bytes(v)),
"description" => description = v.to_string(), "description" => description = EncodedOs::from_bytes(v),
_ => return Ok(false), _ => return Ok(false),
} }
Ok(true) Ok(true)
@@ -121,10 +123,10 @@ fn default_commands() -> Vec<Command> {
/// Escape a string like Zig's `std.zig.fmtString`: `\n \r \t \\ \"` are /// Escape a string like Zig's `std.zig.fmtString`: `\n \r \t \\ \"` are
/// escaped by name and every other byte outside printable ASCII is written /// escaped by name and every other byte outside printable ASCII is written
/// as `\xNN` (so multi-byte UTF-8 is escaped per byte). /// as `\xNN` (so multi-byte UTF-8 is escaped per byte).
fn zig_fmt_string(s: &str) -> String { fn zig_fmt_string(s: &EncodedOs) -> String {
use std::fmt::Write; use std::fmt::Write;
let mut out = String::new(); let mut out = String::new();
for &b in s.as_bytes() { for b in s.to_bytes() {
match b { match b {
b'\n' => out.push_str("\\n"), b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"), b'\r' => out.push_str("\\r"),
@@ -162,9 +164,9 @@ impl ConfigField for RepeatableCommand {
) )
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
// Unset or empty input clears the list // Unset or empty input clears the list
let input = input.unwrap_or(""); let input = input.unwrap_or(b"");
if input.is_empty() { if input.is_empty() {
self.value.clear(); self.value.clear();
return Ok(()); return Ok(());
@@ -395,7 +397,8 @@ impl MouseScrollMultiplier {
} }
impl ConfigField for MouseScrollMultiplier { impl ConfigField for MouseScrollMultiplier {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
// Unset fields inherit from the current value. // Unset fields inherit from the current value.
let mut result = *self; let mut result = *self;
@@ -626,7 +629,8 @@ impl ConfigField for QuickTerminalSize {
} }
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
let mut it = input.split(','); let mut it = input.split(',');
@@ -819,7 +823,8 @@ impl ConfigField for BackgroundBlur {
}) })
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
let Some(input) = input else { let Some(input) = input else {
// Emulate behavior for bools // Emulate behavior for bools
*self = BackgroundBlur::True; *self = BackgroundBlur::True;
@@ -919,7 +924,8 @@ impl ConfigField for WindowDecoration {
CValue::EnumName(self.as_str()) CValue::EnumName(self.as_str())
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = Self::parse(input)?; *self = Self::parse(input)?;
Ok(()) Ok(())
} }
@@ -932,12 +938,12 @@ impl ConfigField for WindowDecoration {
/// See theme /// See theme
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Theme { pub struct Theme {
pub light: String, pub light: EncodedOs,
pub dark: String, pub dark: EncodedOs,
} }
impl ConfigField for Theme { impl ConfigField for Theme {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?; let input = input.ok_or(ArgError::ValueRequired)?;
if input.is_empty() { if input.is_empty() {
return Err(ArgError::ValueRequired); return Err(ArgError::ValueRequired);
@@ -947,13 +953,13 @@ impl ConfigField for Theme {
// we're parsing a light/dark mode theme pair. Note that "=" isn't // we're parsing a light/dark mode theme pair. Note that "=" isn't
// actually valid for setting a light/dark mode pair but I anticipate // actually valid for setting a light/dark mode pair but I anticipate
// it'll be a common typo. // it'll be a common typo.
if input.contains([',', '=', ':']) { if input.iter().any(|b| matches!(b, b',' | b'=' | b':')) {
let mut light: Option<String> = None; let mut light: Option<EncodedOs> = None;
let mut dark: Option<String> = None; let mut dark: Option<EncodedOs> = None;
parse_auto_struct_with(input, |k, v| { crate::cli_args::parse_auto_struct_bytes_with(input, |k, v| {
match k { match k {
"light" => light = Some(v.to_string()), "light" => light = Some(EncodedOs::from_bytes(v)),
"dark" => dark = Some(v.to_string()), "dark" => dark = Some(EncodedOs::from_bytes(v)),
_ => return Ok(false), _ => return Ok(false),
} }
Ok(true) Ok(true)
@@ -967,19 +973,26 @@ impl ConfigField for Theme {
} }
// Trim our value and set the value to the specified value directly. // Trim our value and set the value to the specified value directly.
let trimmed = trim_ws(input); let trimmed = crate::cli_args::trim_bytes(input);
*self = Theme { *self = Theme {
light: trimmed.to_string(), light: EncodedOs::from_bytes(trimmed),
dark: trimmed.to_string(), dark: EncodedOs::from_bytes(trimmed),
}; };
Ok(()) Ok(())
} }
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
if self.light == self.dark { if self.light == self.dark {
return fmt_str(f, &self.light); return fmt_str(f, self.light.as_encoded());
} }
fmt_str(f, &format!("light:{},dark:{}", self.light, self.dark)) fmt_str(
f,
&format!(
"light:{},dark:{}",
self.light.as_encoded(),
self.dark.as_encoded()
),
)
} }
} }
@@ -1168,7 +1181,8 @@ impl ConfigField for Duration {
CValue::Usize(self.as_milliseconds() as usize) CValue::Usize(self.as_milliseconds() as usize)
} }
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = Duration::parse(input)?; *self = Duration::parse(input)?;
Ok(()) Ok(())
} }
@@ -1207,7 +1221,8 @@ impl WindowPadding {
} }
impl ConfigField for WindowPadding { impl ConfigField for WindowPadding {
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> { fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let input = crate::cli_args::text_value(input)?;
*self = WindowPadding::parse(input)?; *self = WindowPadding::parse(input)?;
Ok(()) Ok(())
} }
@@ -1602,17 +1617,17 @@ mod tests {
// Single // Single
let mut v = Theme::default(); let mut v = Theme::default();
v.parse_cli(Some("foo")).unwrap(); v.parse_cli(Some("foo")).unwrap();
assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "foo")); assert_eq!((v.light.as_encoded(), v.dark.as_encoded()), ("foo", "foo"));
// Single whitespace // Single whitespace
let mut v = Theme::default(); let mut v = Theme::default();
v.parse_cli(Some(" foo ")).unwrap(); v.parse_cli(Some(" foo ")).unwrap();
assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "foo")); assert_eq!((v.light.as_encoded(), v.dark.as_encoded()), ("foo", "foo"));
// Light/dark // Light/dark
let mut v = Theme::default(); let mut v = Theme::default();
v.parse_cli(Some(" light:foo, dark : bar ")).unwrap(); v.parse_cli(Some(" light:foo, dark : bar ")).unwrap();
assert_eq!((v.light.as_str(), v.dark.as_str()), ("foo", "bar")); assert_eq!((v.light.as_encoded(), v.dark.as_encoded()), ("foo", "bar"));
let mut v = Theme::default(); let mut v = Theme::default();
assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired)); assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired));
+186
View File
@@ -0,0 +1,186 @@
#![allow(clippy::unwrap_used, clippy::assert_is_empty)]
use ghostty_config::{
cli_args::{ArgError, ArgsIterator, LineIterator},
config::Config,
};
use ghostty_input::binding::{Action, Trigger};
fn load(bytes: &[u8], cli: bool) -> Config {
let mut config = Config::default();
if cli {
let arg = [b"--".as_slice(), bytes].concat();
config
.load_iter(&mut ArgsIterator::new([arg].into_iter()))
.unwrap();
} else {
config.load_iter(&mut LineIterator::new(bytes)).unwrap();
}
config
}
#[test]
fn literal_fdd0_is_a_single_unicode_trigger() {
for cli in [false, true] {
let config = load("keybind=\u{fdd0}=text:ok".as_bytes(), cli);
assert!(config.diagnostics.items().is_empty());
let trigger = Trigger::parse("\u{fdd0}").unwrap();
assert_eq!(
config
.keybind
.set
.get(trigger)
.unwrap()
.as_leaf()
.unwrap()
.action,
Action::Text("ok".into())
);
}
}
#[test]
fn selection_word_chars_reject_invalid_utf8() {
for cli in [false, true] {
let config = load(b"selection-word-chars=\xff", cli);
assert_eq!(config.diagnostics.items().len(), 1);
assert!(
config.diagnostics.items()[0]
.message
.starts_with("invalid value")
);
}
let mut config = Config::default();
assert_eq!(
config.parse_field_bytes("selection-word-chars", Some(b"\xed\xa0\x80")),
Err(ArgError::InvalidValue)
);
config
.parse_field("selection-word-chars", Some("\u{fdd0}é"))
.unwrap();
assert_eq!(config.selection_word_chars.codepoints, [0, 0xfdd0, 0xe9]);
}
#[test]
fn clipboard_replacements_require_utf8_and_keep_literal_fdd0() {
for cli in [false, true] {
let config = load(b"clipboard-codepoint-map=U+0041=\xff", cli);
assert_eq!(config.diagnostics.items().len(), 1);
assert!(config.clipboard_codepoint_map.map.list.is_empty());
}
let config = load("clipboard-codepoint-map=U+0041=\u{fdd0}é".as_bytes(), false);
assert_eq!(
config.clipboard_codepoint_map.map.list[0].replacement,
ghostty_terminal::formatter::Replacement::String("\u{fdd0}é".into())
);
}
#[test]
fn font_style_and_codepoint_family_keep_original_bytes() {
let config = load(
"font-style=Name\u{fdd0}é\nfont-codepoint-map=U+0041=Family\u{fdd0}é".as_bytes(),
false,
);
assert_eq!(
config.font_style.name_value().unwrap().to_bytes(),
"Name\u{fdd0}é".as_bytes()
);
assert_eq!(
config.font_codepoint_map.map.list[0]
.descriptor
.family
.as_ref()
.unwrap()
.to_bytes(),
"Family\u{fdd0}é".as_bytes()
);
let config = load(b"font-codepoint-map=U+0041=Family\xff", true);
assert!(config.diagnostics.items().is_empty());
assert_eq!(
config.font_codepoint_map.map.list[0]
.descriptor
.family
.as_ref()
.unwrap()
.to_bytes(),
b"Family\xff"
);
}
#[test]
fn byte_fields_and_auto_struct_values_never_parse_escape_encoding() {
let raw = "x\u{fdd0}é".as_bytes();
for cli in [false, true] {
for key in ["title", "term", "enquiry-response", "language"] {
let config = load(&[key.as_bytes(), b"=", raw, b"\xff"].concat(), cli);
assert!(config.diagnostics.items().is_empty(), "{key}");
let value = match key {
"title" => config.title.unwrap(),
"term" => config.term,
"enquiry-response" => config.enquiry_response,
_ => config.language.unwrap(),
};
assert_eq!(value.to_bytes(), [raw, b"\xff"].concat());
}
}
let config = load(b"env=K\xff=V\xfe\ncommand=direct:exe arg\xff\ninput=raw:in\xff\\n\ncommand-palette-entry=title:\"name\\xff\",action:\"text:payload\\xfe\"", false);
assert!(config.diagnostics.items().is_empty());
let (key, value) = config.env.iter().next().unwrap();
assert_eq!(key.to_bytes(), b"K\xff");
assert_eq!(value.to_bytes(), b"V\xfe");
assert_eq!(
config
.command
.unwrap()
.arg_iterator()
.last()
.unwrap()
.to_bytes(),
b"arg\xff"
);
let ghostty_config::io::ReadableIO::Raw(input) = config.input.list[0].clone_parsed() else {
panic!("raw input")
};
assert_eq!(input.to_bytes(), b"in\xff\n");
let entry = config.command_palette_entry.value.last().unwrap();
assert_eq!(entry.title.to_bytes(), b"name\xff");
assert_eq!(
entry.action,
Action::Text(ghostty_os::osbytes::EncodedOs::from_bytes(b"payload\xfe"))
);
}
#[test]
fn byte_fields_trim_zig_ascii_whitespace() {
for cli in [false, true] {
let config = load(b"env=\x0bKEY\x0b=\x0bVALUE\x0b", cli);
assert_eq!(config.env.get("KEY").unwrap().to_bytes(), b"VALUE");
let config = load(b"working-directory=\x0binherit\x0b", cli);
assert_eq!(
config.working_directory,
Some(ghostty_config::types_a::WorkingDirectory::Inherit)
);
}
}
#[test]
fn font_variation_tags_are_four_original_bytes() {
for cli in [false, true] {
for tag in [b"a\xffcd".as_slice(), "\u{fdd0}x".as_bytes()] {
let config = load(&[b"font-variation=", tag, b"=12"].concat(), cli);
assert!(config.diagnostics.items().is_empty());
assert_eq!(config.font_variation.list[0].id.str(), tag);
assert_eq!(config.font_variation.list[0].value, 12.0);
let mut formatted = String::new();
ghostty_config::formatter::format_entry(
"font-variation",
&config.font_variation,
&mut formatted,
)
.unwrap();
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&formatted),
[b"font-variation = ", tag, b"=12\n"].concat()
);
}
}
}
+4 -1
View File
@@ -234,7 +234,10 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat
// Setup i18n // Setup i18n
if let Some(v) = resources_dir.app() if let Some(v) = resources_dir.app()
&& let Err(err) = ghostty_os::i18n::init(&v.to_path()) && let Err(err) = v
.to_path()
.map_err(|e| e.to_string())
.and_then(|path| ghostty_os::i18n::init(&path).map_err(|e| e.to_string()))
{ {
log::warn!("failed to init i18n, translations will not be available err={err}"); log::warn!("failed to init i18n, translations will not be available err={err}");
} }
+7 -13
View File
@@ -720,7 +720,7 @@ impl Surface {
}; };
// The payload is a config string in the reversible OS-byte // The payload is a config string in the reversible OS-byte
// encoding: the pty gets the original bytes. // encoding: the pty gets the original bytes.
full.extend(ghostty_os::osbytes::config_str_to_bytes(data)); full.extend(data.to_bytes());
// Zig formats into a fixed 128 byte buffer (`NoSpaceLeft`). // Zig formats into a fixed 128 byte buffer (`NoSpaceLeft`).
if full.len() > 128 { if full.len() > 128 {
return Err("csi/esc binding sequence too long (max 128 bytes)".into()); return Err("csi/esc binding sequence too long (max 128 bytes)".into());
@@ -734,10 +734,10 @@ impl Surface {
Action::Text(data) => { Action::Text(data) => {
// Decoded bytes: escapes produce UTF-8, everything else is // Decoded bytes: escapes produce UTF-8, everything else is
// copied byte for byte (Zig `config/string.zig`). // copied byte for byte (Zig `config/string.zig`).
let text = match ghostty_config::string::parse_bytes(data) { let text = match ghostty_config::string::parse_bytes(data.to_bytes()) {
Ok(t) => t, Ok(t) => t,
Err(err) => { Err(err) => {
log::warn!("error parsing text binding text={data} err={err:?}"); log::warn!("error parsing text binding text={data:?} err={err:?}");
return Ok(true); return Ok(true);
} }
}; };
@@ -824,9 +824,7 @@ impl Surface {
s.handle s.handle
// The payload is a config string in the reversible // The payload is a config string in the reversible
// encoding: search for the original bytes. // encoding: search for the original bytes.
.send(SearchMessage::ChangeNeedle( .send(SearchMessage::ChangeNeedle(text.to_bytes()));
ghostty_os::osbytes::config_str_to_bytes(text),
));
s.handle.wakeup(); s.handle.wakeup();
} }
} }
@@ -934,15 +932,11 @@ impl Surface {
} }
Action::SetSurfaceTitle(v) => { Action::SetSurfaceTitle(v) => {
return Ok(self.perform(RtAction::SetTitle(SetTitle { return Ok(self.perform(RtAction::SetTitle(SetTitle { title: v.clone() })));
title: ghostty_os::osbytes::config_str_to_text(v),
})));
} }
Action::SetTabTitle(v) => { Action::SetTabTitle(v) => {
return Ok(self.perform(RtAction::SetTabTitle(SetTitle { return Ok(self.perform(RtAction::SetTabTitle(SetTitle { title: v.clone() })));
title: ghostty_os::osbytes::config_str_to_text(v),
})));
} }
Action::ClearScreen => { Action::ClearScreen => {
@@ -1330,7 +1324,7 @@ mod tests {
// action's EncodedOs decodes back to the same path. // action's EncodedOs decodes back to the same path.
assert_eq!(path_bytes(&path), path.as_os_str().as_bytes()); assert_eq!(path_bytes(&path), path.as_os_str().as_bytes());
let url = ghostty_os::osbytes::EncodedOs::from_path(&path); let url = ghostty_os::osbytes::EncodedOs::from_path(&path);
assert_eq!(url.to_path(), path); assert_eq!(url.to_path().unwrap(), path);
} }
#[test] #[test]
+4 -17
View File
@@ -409,21 +409,9 @@ impl Surface {
} }
} }
/// The clipboard codepoint map with string replacements decoded from the /// Clipboard replacements are validated UTF-8 by the config field parser.
/// config encoding (a literal `U+FDD0` is restored; bytes that are not
/// UTF-8 can't be represented in the formatter's text output).
fn decode_codepoint_map(list: &[formatter::CodepointMap]) -> Vec<formatter::CodepointMap> { fn decode_codepoint_map(list: &[formatter::CodepointMap]) -> Vec<formatter::CodepointMap> {
list.iter() list.to_vec()
.map(|e| formatter::CodepointMap {
range: e.range,
replacement: match &e.replacement {
formatter::Replacement::String(s) => {
formatter::Replacement::String(ghostty_os::osbytes::config_str_to_text(s))
}
r @ formatter::Replacement::Codepoint(_) => r.clone(),
},
})
.collect()
} }
#[cfg(test)] #[cfg(test)]
@@ -431,9 +419,8 @@ mod decode_map_tests {
use super::*; use super::*;
#[test] #[test]
fn string_replacements_are_decoded() { fn string_replacements_are_plain_text() {
let enc = ghostty_os::osbytes::valid_str_to_config_string("a\u{fdd0}"); let enc = "a\u{fdd0}".to_owned();
assert_ne!(enc, "a\u{fdd0}");
let list = [formatter::CodepointMap { let list = [formatter::CodepointMap {
range: [1, 2], range: [1, 2],
replacement: formatter::Replacement::String(enc), replacement: formatter::Replacement::String(enc),
+1 -1
View File
@@ -55,7 +55,7 @@ impl DerivedConfig {
let mut links = Vec::with_capacity(config.link.links.len()); let mut links = Vec::with_capacity(config.link.links.len());
for link in &config.link.links { for link in &config.link.links {
links.push(Link { links.push(Link {
regex: Regex::new(&ghostty_os::osbytes::config_str_to_text(&link.regex))?, regex: Regex::new(&link.regex)?,
action: link.action, action: link.action,
highlight: link.highlight, highlight: link.highlight,
}); });
+13 -8
View File
@@ -24,6 +24,7 @@ use ghostty_input::{
key_encode, key_encode,
}; };
use ghostty_inspector as inspector; use ghostty_inspector as inspector;
use ghostty_os::osbytes::EncodedOs;
use ghostty_terminal::terminal::{MouseEvents, ScrollViewport, Terminal}; use ghostty_terminal::terminal::{MouseEvents, ScrollViewport, Terminal};
use ghostty_termio::message::Message as IoMessage; use ghostty_termio::message::Message as IoMessage;
use ghostty_vt::{modes::Mode, mouse_shape::MouseShape}; use ghostty_vt::{modes::Mode, mouse_shape::MouseShape};
@@ -40,7 +41,7 @@ pub enum SetRoot {
/// The root keybinding set. /// The root keybinding set.
Root, Root,
/// A named key table. /// A named key table.
Table(String), Table(EncodedOs),
} }
/// The currently active key sequence: the set it started in plus the /// The currently active key sequence: the set it started in plus the
@@ -54,7 +55,7 @@ pub struct SequencePath {
/// An entry of the key table stack. /// An entry of the key table stack.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableEntry { pub struct TableEntry {
pub name: String, pub name: EncodedOs,
pub once: bool, pub once: bool,
} }
@@ -796,12 +797,16 @@ impl Surface {
/// Activate a key table by name (Zig: `activate_key_table[_once]`). /// Activate a key table by name (Zig: `activate_key_table[_once]`).
/// Returns whether the table was activated. /// Returns whether the table was activated.
#[allow(dead_code, clippy::unnecessary_wraps)] // used by the binding-actions module #[allow(dead_code, clippy::unnecessary_wraps)] // used by the binding-actions module
pub(crate) fn activate_key_table(&self, name: &str, once: bool) -> Result<bool, CoreError> { pub(crate) fn activate_key_table(
&self,
name: &EncodedOs,
once: bool,
) -> Result<bool, CoreError> {
let cfg = self.cfg(); let cfg = self.cfg();
// Look up the table in our config // Look up the table in our config
if root_set(&cfg, &SetRoot::Table(name.to_owned())).is_none() { if root_set(&cfg, &SetRoot::Table(name.to_owned())).is_none() {
log::debug!("key table not found: {name}"); log::debug!("key table not found: {name:?}");
return Ok(false); return Ok(false);
} }
@@ -810,14 +815,14 @@ impl Surface {
// If this is the same table as is currently active, then do // If this is the same table as is currently active, then do
// nothing. // nothing.
if kb.table_stack.last().is_some_and(|t| t.name == name) { if kb.table_stack.last().is_some_and(|t| &t.name == name) {
log::debug!("ignoring duplicate activate table: {name}"); log::debug!("ignoring duplicate activate table: {name:?}");
return Ok(false); return Ok(false);
} }
// If we're already at the max, ignore it. // If we're already at the max, ignore it.
if kb.table_stack.len() >= MAX_ACTIVE_KEY_TABLES { if kb.table_stack.len() >= MAX_ACTIVE_KEY_TABLES {
log::info!("ignoring activate table, max depth reached: {name}"); log::info!("ignoring activate table, max depth reached: {name:?}");
return Ok(false); return Ok(false);
} }
@@ -831,7 +836,7 @@ impl Surface {
// Notify the UI. // Notify the UI.
self.perform(RtAction::KeyTable(KeyTable::Activate(name.to_owned()))); self.perform(RtAction::KeyTable(KeyTable::Activate(name.to_owned())));
log::debug!("key table activated: {name}"); log::debug!("key table activated: {name:?}");
Ok(true) Ok(true)
} }
+14 -14
View File
@@ -459,10 +459,7 @@ impl Surface {
// don't leak GHOSTTY_LOG to any subprocesses // don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG"); env.remove("GHOSTTY_LOG");
let mut ecfg = exec::Config::new( let mut ecfg = exec::Config::new(env, config.term.clone());
env,
ghostty_os::osbytes::EncodedOs::from_encoded(config.term.clone()),
);
ecfg.command = command.clone(); ecfg.command = command.clone();
ecfg.env_override = config.env.clone(); ecfg.env_override = config.env.clone();
ecfg.shell_integration = config.shell_integration; ecfg.shell_integration = config.shell_integration;
@@ -607,7 +604,7 @@ impl Surface {
rt_app.perform_action( rt_app.perform_action(
Target::Surface(id), Target::Surface(id),
RtAction::SetTitle(SetTitle { RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(title), title: title.clone(),
}), }),
); );
} else if config.xdg_terminal_exec } else if config.xdg_terminal_exec
@@ -620,7 +617,7 @@ impl Surface {
rt_app.perform_action( rt_app.perform_action(
Target::Surface(id), Target::Surface(id),
RtAction::SetTitle(SetTitle { RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(&cmd.string()), title: ghostty_os::osbytes::EncodedOs::from_encoded(cmd.string()),
}), }),
); );
} else if let Some(cmd) = &command { } else if let Some(cmd) = &command {
@@ -636,7 +633,7 @@ impl Surface {
rt_app.perform_action( rt_app.perform_action(
Target::Surface(id), Target::Surface(id),
RtAction::SetTitle(SetTitle { RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(first), title: first.clone(),
}), }),
); );
} }
@@ -872,8 +869,8 @@ impl Surface {
} }
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let title = String::from_utf8_lossy(&buf[..end]).into_owned(); let title = ghostty_os::osbytes::EncodedOs::from_bytes(&buf[..end]);
log::debug!("changing title \"{title}\""); log::debug!("changing title {title:?}");
self.perform(RtAction::SetTitle(SetTitle { title })); self.perform(RtAction::SetTitle(SetTitle { title }));
} }
@@ -885,11 +882,14 @@ impl Surface {
let title = self.rt_surface.get_title(); let title = self.rt_surface.get_title();
let data = match style { let data = match style {
ghostty_apprt::surface::ReportTitleStyle::Csi21T => { ghostty_apprt::surface::ReportTitleStyle::Csi21T => [
format!("\x1b]l{}\x1b\\", title.unwrap_or_default()) b"\x1b]l".as_slice(),
} &title.unwrap_or_default().to_bytes(),
b"\x1b\\",
]
.concat(),
}; };
self.queue_io(IoMessage::write_req(data.as_bytes())); self.queue_io(IoMessage::write_req(data.as_slice()));
} }
Message::ColorChange(change) => { Message::ColorChange(change) => {
@@ -1306,7 +1306,7 @@ impl Surface {
// configured title. // configured title.
if let Some(title) = &config.title { if let Some(title) = &config.title {
self.perform(RtAction::SetTitle(SetTitle { self.perform(RtAction::SetTitle(SetTitle {
title: ghostty_os::osbytes::config_str_to_text(title), title: title.clone(),
})); }));
} }
+2 -2
View File
@@ -2543,8 +2543,8 @@ mod tests {
// The OSC 7 pwd is raw bytes. // The OSC 7 pwd is raw bytes.
let pwd = dir.as_os_str().as_bytes().to_vec(); let pwd = dir.as_os_str().as_bytes().to_vec();
let t = open_link_target(Some(&pwd), "file.txt"); let t = open_link_target(Some(&pwd), "file.txt");
assert_eq!(t.to_path(), dir.join("file.txt")); assert_eq!(t.to_path().unwrap(), dir.join("file.txt"));
assert!(t.to_path().exists()); assert!(t.to_path().unwrap().exists());
} }
#[test] #[test]
+8 -4
View File
@@ -92,7 +92,7 @@ impl RtApp for FakeRt {
let ret = match &action { let ret = match &action {
Action::ShowChildExited(_) => self.show_child_exited.get(), Action::ShowChildExited(_) => self.show_child_exited.get(),
Action::SetTitle(t) => { Action::SetTitle(t) => {
*self.title.borrow_mut() = Some(t.title.clone()); *self.title.borrow_mut() = Some(t.title.to_text_lossy());
true true
} }
_ => true, _ => true,
@@ -109,8 +109,8 @@ impl RtSurface for FakeRt {
fn cgroup(&self) -> Option<String> { fn cgroup(&self) -> Option<String> {
None None
} }
fn get_title(&self) -> Option<String> { fn get_title(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
self.title.borrow().clone() self.title.borrow().clone().map(Into::into)
} }
fn get_content_scale(&self) -> ContentScale { fn get_content_scale(&self) -> ContentScale {
self.scale.get() self.scale.get()
@@ -262,7 +262,11 @@ impl Harness {
config.load_iter(&mut it).expect("config args"); config.load_iter(&mut it).expect("config args");
if let Some(script) = &opts.script { if let Some(script) = &opts.script {
let script = script.replace("{out}", out.to_str().unwrap()); let script = script.replace("{out}", out.to_str().unwrap());
config.command = Some(Command::Direct(vec!["sh".into(), "-c".into(), script])); config.command = Some(Command::Direct(vec![
"sh".into(),
"-c".into(),
script.into(),
]));
} }
config.finalize().expect("finalize"); config.finalize().expect("finalize");
+2 -4
View File
@@ -369,10 +369,7 @@ fn text_csi_esc_and_ignore_bindings() {
#[test] #[test]
fn config_loaded_text_csi_esc_write_decoded_bytes() { fn config_loaded_text_csi_esc_write_decoded_bytes() {
use ghostty_os::osbytes::EncodedOs;
let h = harness!(Harness::recorder("", &[])); let h = harness!(Harness::recorder("", &[]));
let enc = |raw: &[u8]| EncodedOs::from_bytes(raw).into_encoded();
// Byte FF and a literal U+FDD0 must reach the pty unchanged, not as // Byte FF and a literal U+FDD0 must reach the pty unchanged, not as
// the reversible escape encoding. // the reversible escape encoding.
for (kind, raw, want) in [ for (kind, raw, want) in [
@@ -386,7 +383,8 @@ fn config_loaded_text_csi_esc_write_decoded_bytes() {
("esc", "\u{fdd0}".as_bytes(), b"\x1b\xef\xb7\x90".to_vec()), ("esc", "\u{fdd0}".as_bytes(), b"\x1b\xef\xb7\x90".to_vec()),
] { ] {
h.mark(); h.mark();
let a = ghostty_input::binding::Action::parse(&format!("{kind}:{}", enc(raw))).unwrap(); let a = ghostty_input::binding::Action::parse_bytes(&[kind.as_bytes(), b":", raw].concat())
.unwrap();
assert!(h.surface.perform_binding_action(&a).unwrap()); assert!(h.surface.perform_binding_action(&a).unwrap());
h.expect_input(&want); h.expect_input(&want);
} }
+1 -1
View File
@@ -20,7 +20,7 @@ pub fn default_dir() -> io::Result<Dir> {
.map_err(|e| io::Error::other(e.to_string()))?; .map_err(|e| io::Error::other(e.to_string()))?;
Ok(Dir { Ok(Dir {
// The XDG helpers return the encoded form of the OS string. // The XDG helpers return the encoded form of the OS string.
path: path.to_path(), path: path.to_path()?,
}) })
} }
+37 -5
View File
@@ -16,7 +16,7 @@ mod tests {
fn desc(family: &str) -> Descriptor { fn desc(family: &str) -> Descriptor {
Descriptor { Descriptor {
family: Some(family.to_string()), family: Some(family.into()),
..Default::default() ..Default::default()
} }
} }
@@ -35,11 +35,27 @@ mod tests {
// Exact range // Exact range
assert!(m.get(1).is_none()); assert!(m.get(1).is_none());
m.add(entry(1, 1, "A")); m.add(entry(1, 1, "A"));
assert_eq!("A", m.get(1).unwrap().family.as_deref().unwrap()); assert_eq!(
"A",
m.get(1)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded)
.unwrap()
);
// Later entry takes priority // Later entry takes priority
m.add(entry(1, 2, "B")); m.add(entry(1, 2, "B"));
assert_eq!("B", m.get(1).unwrap().family.as_deref().unwrap()); assert_eq!(
"B",
m.get(1)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded)
.unwrap()
);
// Non-matching // Non-matching
assert!(m.get(0).is_none()); assert!(m.get(0).is_none());
@@ -47,8 +63,24 @@ mod tests {
m.add(entry(3, 4, "C")); m.add(entry(3, 4, "C"));
m.add(entry(5, 6, "D")); m.add(entry(5, 6, "D"));
assert_eq!("C", m.get(3).unwrap().family.as_deref().unwrap()); assert_eq!(
assert_eq!("B", m.get(1).unwrap().family.as_deref().unwrap()); "C",
m.get(3)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded)
.unwrap()
);
assert_eq!(
"B",
m.get(1)
.unwrap()
.family
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::as_encoded)
.unwrap()
);
} }
#[test] #[test]
@@ -385,7 +385,9 @@ impl CodepointResolver {
{ {
log::debug!( log::debug!(
"codepoint override based on config codepoint={cp} family={}", "codepoint override based on config codepoint={cp} family={}",
desc.family.as_deref().unwrap_or("") desc.family
.as_ref()
.map_or("", ghostty_os::osbytes::EncodedOs::as_encoded)
); );
return Some(idx); return Some(idx);
+39 -2
View File
@@ -37,10 +37,10 @@ mod fc_impl {
pub fn to_fc_pattern(desc: &Descriptor) -> fc::Pattern { pub fn to_fc_pattern(desc: &Descriptor) -> fc::Pattern {
let mut pat = fc::Pattern::create(); let mut pat = fc::Pattern::create();
if let Some(family) = &desc.family { if let Some(family) = &desc.family {
assert!(pat.add_string(fc::Property::Family, family)); assert!(pat.add_string_bytes(fc::Property::Family, &family.to_bytes()));
} }
if let Some(style) = &desc.style { if let Some(style) = &desc.style {
assert!(pat.add_string(fc::Property::Style, style)); assert!(pat.add_string_bytes(fc::Property::Style, &style.to_bytes()));
} }
if desc.codepoint > 0 { if desc.codepoint > 0 {
let mut cs = fc::CharSet::create(); let mut cs = fc::CharSet::create();
@@ -162,6 +162,43 @@ mod fc_impl {
mod tests { mod tests {
use super::*; use super::*;
#[cfg(font_fontconfig)]
#[test]
fn configured_font_names_reach_fontconfig_as_original_bytes() {
use ghostty_config::config::Config;
use ghostty_os::osbytes::os_bytes;
use crate::fontconfig::Property;
let mut config = Config::default();
config
.parse_field("font-family", Some("Family\u{fdd0}é"))
.unwrap();
config
.parse_field("font-style", Some("Style\u{fdd0}é"))
.unwrap();
config
.parse_field_bytes("font-codepoint-map", Some(b"U+0041=Family\xff"))
.unwrap();
let pat = to_fc_pattern(&Descriptor {
family: Some(config.font_family.list[0].clone()),
style: config.font_style.name_value().cloned(),
..Descriptor::default()
});
assert_eq!(
os_bytes(&pat.get_os_string(Property::Family, 0).unwrap()),
"Family\u{fdd0}é".as_bytes()
);
assert_eq!(
os_bytes(&pat.get_os_string(Property::Style, 0).unwrap()),
"Style\u{fdd0}é".as_bytes()
);
let pat = to_fc_pattern(&config.font_codepoint_map.map.list[0].descriptor);
assert_eq!(
os_bytes(&pat.get_os_string(Property::Family, 0).unwrap()),
b"Family\xff"
);
}
#[test] #[test]
fn descriptor_hash() { fn descriptor_hash() {
let d = Descriptor::default(); let d = Descriptor::default();
+11 -3
View File
@@ -71,7 +71,9 @@ pub fn to_coretext_descriptor(
// Family // Family
if let Some(family) = &desc.family { if let Some(family) = &desc.family {
let family = CFString::from_str(family); let family = CFString::from_str(
std::str::from_utf8(&family.to_bytes()).map_err(|_| DiscoverError::DescriptorFailed)?,
);
// SAFETY: valid static key. // SAFETY: valid static key.
keys.push(unsafe { kCTFontFamilyNameAttribute }); keys.push(unsafe { kCTFontFamilyNameAttribute });
values.push(as_type(&*family)); values.push(as_type(&*family));
@@ -79,7 +81,9 @@ pub fn to_coretext_descriptor(
// Style // Style
if let Some(style) = &desc.style { if let Some(style) = &desc.style {
let style = CFString::from_str(style); let style = CFString::from_str(
std::str::from_utf8(&style.to_bytes()).map_err(|_| DiscoverError::DescriptorFailed)?,
);
// SAFETY: valid static key. // SAFETY: valid static key.
keys.push(unsafe { kCTFontStyleNameAttribute }); keys.push(unsafe { kCTFontStyleNameAttribute });
values.push(as_type(&*style)); values.push(as_type(&*style));
@@ -414,7 +418,11 @@ fn score(desc: &Descriptor, ct_desc: &CTFontDescriptor) -> Score {
// The first string in this slice will be used for the exact match, // The first string in this slice will be used for the exact match,
// and for the fuzzy match, all matching substrings will increase // and for the fuzzy match, all matching substrings will increase
// the rank. // the rank.
let desired_styles = math::desired_styles(desc.style.as_deref(), desc.bold, desc.italic); let style = desc
.style
.as_ref()
.map(ghostty_os::osbytes::EncodedOs::to_text_lossy);
let desired_styles = math::desired_styles(style.as_deref(), desc.bold, desc.italic);
let (exact, fuzzy) = math::style_scores(&style_str, &desired_styles); let (exact, fuzzy) = math::style_scores(&style_str, &desired_styles);
this.exact_style = exact; this.exact_style = exact;
this.fuzzy_style = fuzzy; this.fuzzy_style = fuzzy;
+1 -1
View File
@@ -59,5 +59,5 @@ pub struct Options {
/// Note: eventually, this will move to font.Face probably as we may /// Note: eventually, this will move to font.Face probably as we may
/// want to support per-face feature configuration. For now, we only /// want to support per-face feature configuration. For now, we only
/// support applying features globally. /// support applying features globally.
pub features: Vec<String>, pub features: Vec<ghostty_os::osbytes::EncodedOs>,
} }
+13 -6
View File
@@ -74,12 +74,17 @@ pub enum ShapeRunError {
/// Create a CoreFoundation Dictionary suitable for settings the font /// Create a CoreFoundation Dictionary suitable for settings the font
/// features of a CoreText font (`makeFeaturesDict`). /// features of a CoreText font (`makeFeaturesDict`).
fn make_features_dict(feats: &[Feature]) -> CfBox<CFDictionary> { fn make_features_dict(feats: &[Feature]) -> Result<CfBox<CFDictionary>, ShapeError> {
let mut items: Vec<CFRetained<CFDictionary<CFString, CFType>>> = Vec::new(); let mut items: Vec<CFRetained<CFDictionary<CFString, CFType>>> = Vec::new();
for feat in feats { for feat in feats {
// Keys can only be ASCII. // Keys can only be ASCII.
let key = CFString::from_str(&String::from_utf8_lossy(&feat.tag)); if !feat.tag.is_ascii() {
return Err(ShapeError::CoreTextFailed);
}
let key = CFString::from_str(
std::str::from_utf8(&feat.tag).map_err(|_| ShapeError::CoreTextFailed)?,
);
let value = CFNumber::new_i32(feat.value as i32); let value = CFNumber::new_i32(feat.value as i32);
// SAFETY: valid static keys. // SAFETY: valid static keys.
@@ -98,7 +103,9 @@ fn make_features_dict(feats: &[Feature]) -> CfBox<CFDictionary> {
let k = unsafe { kCTFontFeatureSettingsAttribute }; let k = unsafe { kCTFontFeatureSettingsAttribute };
let dict = CFDictionary::<CFString, CFType>::from_slices(&[k], &[list.as_ref()]); let dict = CFDictionary::<CFString, CFType>::from_slices(&[k], &[list.as_ref()]);
// SAFETY: the attribute dictionary is used as an untyped CFDictionary. // SAFETY: the attribute dictionary is used as an untyped CFDictionary.
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFDictionary>(dict) }) Ok(CfBox::new(unsafe {
CFRetained::cast_unchecked::<CFDictionary>(dict)
}))
} }
/// Shaper that uses CoreText. /// Shaper that uses CoreText.
@@ -146,7 +153,7 @@ impl Shaper {
pub fn new(opts: &shape::Options) -> Result<Shaper, ShapeError> { pub fn new(opts: &shape::Options) -> Result<Shaper, ShapeError> {
let mut feature_list = FeatureList::default(); let mut feature_list = FeatureList::default();
for feature_str in &opts.features { for feature_str in &opts.features {
feature_list.append_from_string(feature_str); feature_list.append_from_bytes(&feature_str.to_bytes());
} }
// We need to construct two attrs dictionaries for font features; // We need to construct two attrs dictionaries for font features;
@@ -155,8 +162,8 @@ impl Shaper {
let mut feats_df: Vec<Feature> = DEFAULT_FEATURES.to_vec(); let mut feats_df: Vec<Feature> = DEFAULT_FEATURES.to_vec();
feats_df.extend_from_slice(feats); feats_df.extend_from_slice(feats);
let features = make_features_dict(&feats_df); let features = make_features_dict(&feats_df)?;
let features_no_default = make_features_dict(feats); let features_no_default = make_features_dict(feats)?;
// For now we only support LTR text. If we shape RTL text then // For now we only support LTR text. If we shape RTL text then
// rendering will be very wrong so we need to explicitly force // rendering will be very wrong so we need to explicitly force
+6 -2
View File
@@ -344,7 +344,11 @@ impl FeatureList {
/// Append features to this list from a string with a comma separated list. /// Append features to this list from a string with a comma separated list.
/// See `Feature::from_reader` for more docs. /// See `Feature::from_reader` for more docs.
pub fn append_from_string(&mut self, s: &str) { pub fn append_from_string(&mut self, s: &str) {
let mut reader = Reader::new(s.as_bytes()); self.append_from_bytes(s.as_bytes());
}
pub fn append_from_bytes(&mut self, s: &[u8]) {
let mut reader = Reader::new(s);
while !reader.at_end() { while !reader.at_end() {
let i = reader.pos; let i = reader.pos;
if let Some(feature) = Feature::from_reader(&mut reader) { if let Some(feature) = Feature::from_reader(&mut reader) {
@@ -352,7 +356,7 @@ impl FeatureList {
} else { } else {
log::warn!( log::warn!(
"failed to parse font feature setting: \"{}\"", "failed to parse font feature setting: \"{}\"",
String::from_utf8_lossy(&s.as_bytes()[i..reader.pos]) String::from_utf8_lossy(&s[i..reader.pos])
); );
} }
} }
+2 -2
View File
@@ -76,7 +76,7 @@ impl Shaper {
let mut feature_list = FeatureList::default(); let mut feature_list = FeatureList::default();
feature_list.features.extend_from_slice(&DEFAULT_FEATURES); feature_list.features.extend_from_slice(&DEFAULT_FEATURES);
for feature_str in &opts.features { for feature_str in &opts.features {
feature_list.append_from_string(feature_str); feature_list.append_from_bytes(&feature_str.to_bytes());
} }
let hb_feats = feature_list let hb_feats = feature_list
@@ -351,7 +351,7 @@ mod tests {
fn shape_ligatures_disabled_by_feature() { fn shape_ligatures_disabled_by_feature() {
let f = face(embedded::INCONSOLATA); let f = face(embedded::INCONSOLATA);
let mut s = Shaper::new(&shape::Options { let mut s = Shaper::new(&shape::Options {
features: vec!["-liga".to_string()], features: vec!["-liga".into()],
}) })
.unwrap(); .unwrap();
let cells = s.shape_text(Some(&f), &text("===")).unwrap(); let cells = s.shape_text(Some(&f), &text("===")).unwrap();
+2 -2
View File
@@ -93,7 +93,7 @@ pub(crate) fn test_shaper_with_font(font_req: TestFont) -> TestShaper {
let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap(); let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap();
let shaper = Shaper::new(&shape::Options { let shaper = Shaper::new(&shape::Options {
// Some of our tests rely on dlig being enabled by default // Some of our tests rely on dlig being enabled by default
features: vec!["dlig".to_string()], features: vec!["dlig".into()],
}) })
.unwrap(); .unwrap();
@@ -112,7 +112,7 @@ pub(crate) fn test_shaper_with_discovered_font(font_req: &str) -> Option<TestSha
let disco = Fontconfig::init(); let disco = Fontconfig::init();
let mut it = disco let mut it = disco
.discover(&Descriptor { .discover(&Descriptor {
family: Some(font_req.to_string()), family: Some(font_req.into()),
size: 12.0, size: 12.0,
monospace: false, monospace: false,
..Descriptor::default() ..Descriptor::default()
+11 -12
View File
@@ -251,7 +251,9 @@ impl SharedGridSet {
log::warn!( log::warn!(
"font-family {style:?} not found: {}", "font-family {style:?} not found: {}",
desc.family.as_deref().unwrap_or("") desc.family
.as_ref()
.map_or("", ghostty_os::osbytes::EncodedOs::as_encoded)
); );
} }
} }
@@ -501,8 +503,8 @@ impl Key {
let mut descriptors: Vec<Descriptor> = Vec::new(); let mut descriptors: Vec<Descriptor> = Vec::new();
for family in &config.font_family.list { for family in &config.font_family.list {
descriptors.push(Descriptor { descriptors.push(Descriptor {
family: Some(ghostty_os::osbytes::config_str_to_text(family)), family: Some(family.clone()),
style: config.font_style.name_value().map(str::to_string), style: config.font_style.name_value().cloned(),
size: font_size.points, size: font_size.points,
variations: config.font_variation.list.clone(), variations: config.font_variation.list.clone(),
..Descriptor::default() ..Descriptor::default()
@@ -518,9 +520,9 @@ impl Key {
// no results would be found if we restrict to ALSO searching for // no results would be found if we restrict to ALSO searching for
// italic. // italic.
for family in &config.font_family_bold.list { for family in &config.font_family_bold.list {
let style = config.font_style_bold.name_value().map(str::to_string); let style = config.font_style_bold.name_value().cloned();
descriptors.push(Descriptor { descriptors.push(Descriptor {
family: Some(ghostty_os::osbytes::config_str_to_text(family)), family: Some(family.clone()),
bold: style.is_none(), bold: style.is_none(),
style, style,
size: font_size.points, size: font_size.points,
@@ -529,9 +531,9 @@ impl Key {
}); });
} }
for family in &config.font_family_italic.list { for family in &config.font_family_italic.list {
let style = config.font_style_italic.name_value().map(str::to_string); let style = config.font_style_italic.name_value().cloned();
descriptors.push(Descriptor { descriptors.push(Descriptor {
family: Some(ghostty_os::osbytes::config_str_to_text(family)), family: Some(family.clone()),
italic: style.is_none(), italic: style.is_none(),
style, style,
size: font_size.points, size: font_size.points,
@@ -540,12 +542,9 @@ impl Key {
}); });
} }
for family in &config.font_family_bold_italic.list { for family in &config.font_family_bold_italic.list {
let style = config let style = config.font_style_bold_italic.name_value().cloned();
.font_style_bold_italic
.name_value()
.map(str::to_string);
descriptors.push(Descriptor { descriptors.push(Descriptor {
family: Some(ghostty_os::osbytes::config_str_to_text(family)), family: Some(family.clone()),
bold: style.is_none(), bold: style.is_none(),
italic: style.is_none(), italic: style.is_none(),
style, style,
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies] [dependencies]
ghostty-vt.workspace = true ghostty-vt.workspace = true
ghostty-os.workspace = true
ghostty-unicode.workspace = true ghostty-unicode.workspace = true
indexmap = "2.14.2" indexmap = "2.14.2"
thiserror = "2.0.21" thiserror = "2.0.21"
+56 -35
View File
@@ -8,6 +8,8 @@ use std::{
hash::{Hash, Hasher}, hash::{Hash, Hasher},
}; };
use ghostty_os::osbytes::EncodedOs;
use super::Error; use super::Error;
/// Define a fieldless enum whose variants are named in snake_case in the /// Define a fieldless enum whose variants are named in snake_case in the
@@ -314,9 +316,9 @@ macro_rules! num_param {
num_param!(f32, usize, isize, i16); num_param!(f32, usize, isize, i16);
/// Escape in the style of Zig's `std.zig.stringEscape`. /// Escape in the style of Zig's `std.zig.stringEscape`.
pub(super) fn string_escape(s: &str, out: &mut String) { pub(super) fn string_escape(s: &[u8], out: &mut String) {
use std::fmt::Write; use std::fmt::Write;
for &b in s.as_bytes() { for &b in s {
match b { match b {
b'\n' => out.push_str("\\n"), b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"), b'\r' => out.push_str("\\r"),
@@ -353,18 +355,37 @@ impl Action {
/// action name and value is the action parameter. The parameter /// action name and value is the action parameter. The parameter
/// is optional depending on the action. /// is optional depending on the action.
pub fn parse(input: &str) -> Result<Action, Error> { pub fn parse(input: &str) -> Result<Action, Error> {
// Split our action by colon. A colon may not exist for some Self::parse_bytes(input.as_bytes())
// actions so it is optional. The part preceding the colon is the }
// action name.
let colon_idx = input.find(':');
let action = &input[..colon_idx.unwrap_or(input.len())];
// An action name is always required /// Parse raw action bytes. Only enum/numeric parameters require UTF-8;
if action.is_empty() { /// byte payloads are stored without interpreting the OS escape encoding.
pub fn parse_bytes(input: &[u8]) -> Result<Action, Error> {
let idx = input.iter().position(|b| *b == b':');
let name = std::str::from_utf8(&input[..idx.unwrap_or(input.len())])
.map_err(|_| Error::InvalidAction)?;
if name.is_empty() {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
let param = idx.map(|i| &input[i + 1..]);
Action::parse_named(action, colon_idx.map(|i| &input[i + 1..])) let bytes = || param.map(EncodedOs::from_bytes).ok_or(Error::InvalidFormat);
match name {
"csi" => Ok(Self::Csi(bytes()?)),
"esc" => Ok(Self::Esc(bytes()?)),
"text" => Ok(Self::Text(bytes()?)),
"search" => Ok(Self::Search(bytes()?)),
"set_surface_title" => Ok(Self::SetSurfaceTitle(bytes()?)),
"set_tab_title" => Ok(Self::SetTabTitle(bytes()?)),
"activate_key_table" => Ok(Self::ActivateKeyTable(bytes()?)),
"activate_key_table_once" => Ok(Self::ActivateKeyTableOnce(bytes()?)),
_ => Self::parse_named(
name,
param
.map(std::str::from_utf8)
.transpose()
.map_err(|_| Error::InvalidAction)?,
),
}
} }
/// The action name (Zig `@tagName`). /// The action name (Zig `@tagName`).
@@ -423,15 +444,15 @@ pub enum Action {
/// `\x1b[`). /// `\x1b[`).
/// ///
/// For example, `csi:0m` can be sent to reset all styles of the current text. /// For example, `csi:0m` can be sent to reset all styles of the current text.
Csi(String), Csi(EncodedOs),
/// Send an `ESC` sequence. /// Send an `ESC` sequence.
Esc(String), Esc(EncodedOs),
/// Send the specified text. /// Send the specified text.
/// ///
/// Uses Zig string literal syntax. This is currently not validated. /// Uses Zig string literal syntax. This is currently not validated.
/// If the text is invalid (i.e. contains an invalid escape sequence), /// If the text is invalid (i.e. contains an invalid escape sequence),
/// the error will currently only show up in logs. /// the error will currently only show up in logs.
Text(String), Text(EncodedOs),
/// Send data to the pty depending on whether cursor key mode is enabled /// Send data to the pty depending on whether cursor key mode is enabled
/// (`application`) or disabled (`normal`). /// (`application`) or disabled (`normal`).
CursorKey(CursorKey), CursorKey(CursorKey),
@@ -478,7 +499,7 @@ pub enum Action {
/// should be used. /// should be used.
/// ///
/// If a previous search is active, it is replaced. /// If a previous search is active, it is replaced.
Search(String), Search(EncodedOs),
/// Start a search for the current text selection. If there is no /// Start a search for the current text selection. If there is no
/// selection, this does nothing. If a search is already active, this /// selection, this does nothing. If a search is already active, this
/// changes the search terms. /// changes the search terms.
@@ -639,11 +660,11 @@ pub enum Action {
/// Set the title for the current focused surface. /// Set the title for the current focused surface.
/// ///
/// If the title is empty, the surface title is reset to an empty title. /// If the title is empty, the surface title is reset to an empty title.
SetSurfaceTitle(String), SetSurfaceTitle(EncodedOs),
/// Set the title for the current focused tab. /// Set the title for the current focused tab.
/// ///
/// If the title is empty, the tab title override is cleared. /// If the title is empty, the tab title override is cleared.
SetTabTitle(String), SetTabTitle(EncodedOs),
/// Create a new split in the specified direction. /// Create a new split in the specified direction.
/// ///
/// Valid arguments: /// Valid arguments:
@@ -912,7 +933,7 @@ pub enum Action {
/// ///
/// If the named key table is already the currently active key table, /// If the named key table is already the currently active key table,
/// this action has no effect and performable will report false. /// this action has no effect and performable will report false.
ActivateKeyTable(String), ActivateKeyTable(EncodedOs),
/// Same as activate_key_table, but the key table will only be active /// Same as activate_key_table, but the key table will only be active
/// until the first valid keybinding from that table is used (including /// until the first valid keybinding from that table is used (including
/// any defined `catch_all` bindings). /// any defined `catch_all` bindings).
@@ -921,7 +942,7 @@ pub enum Action {
/// key table. If another key table is activated later, then this /// key table. If another key table is activated later, then this
/// table will remain active until it pops back out to being the /// table will remain active until it pops back out to being the
/// active key table. /// active key table.
ActivateKeyTableOnce(String), ActivateKeyTableOnce(EncodedOs),
/// Deactivate the currently active key table, if any. The next most /// Deactivate the currently active key table, if any. The next most
/// recently activated key table (if any) will become active again. /// recently activated key table (if any) will become active again.
/// If no key table is active, this action has no effect. /// If no key table is active, this action has no effect.
@@ -1353,15 +1374,15 @@ impl Action {
} }
"csi" => { "csi" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Csi(p.to_string())) Ok(Action::Csi(p.into()))
} }
"esc" => { "esc" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Esc(p.to_string())) Ok(Action::Esc(p.into()))
} }
"text" => { "text" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Text(p.to_string())) Ok(Action::Text(p.into()))
} }
"cursor_key" => Err(Error::InvalidAction), "cursor_key" => Err(Error::InvalidAction),
"reset" => { "reset" => {
@@ -1426,7 +1447,7 @@ impl Action {
} }
"search" => { "search" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::Search(p.to_string())) Ok(Action::Search(p.into()))
} }
"search_selection" => { "search_selection" => {
if param.is_some() { if param.is_some() {
@@ -1604,11 +1625,11 @@ impl Action {
} }
"set_surface_title" => { "set_surface_title" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::SetSurfaceTitle(p.to_string())) Ok(Action::SetSurfaceTitle(p.into()))
} }
"set_tab_title" => { "set_tab_title" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::SetTabTitle(p.to_string())) Ok(Action::SetTabTitle(p.into()))
} }
"new_split" => { "new_split" => {
let Some(p) = param else { let Some(p) = param else {
@@ -1798,11 +1819,11 @@ impl Action {
} }
"activate_key_table" => { "activate_key_table" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::ActivateKeyTable(p.to_string())) Ok(Action::ActivateKeyTable(p.into()))
} }
"activate_key_table_once" => { "activate_key_table_once" => {
let p = param.ok_or(Error::InvalidFormat)?; let p = param.ok_or(Error::InvalidFormat)?;
Ok(Action::ActivateKeyTableOnce(p.to_string())) Ok(Action::ActivateKeyTableOnce(p.into()))
} }
"deactivate_key_table" => { "deactivate_key_table" => {
if param.is_some() { if param.is_some() {
@@ -1892,15 +1913,15 @@ impl Action {
| Action::Quit => {} | Action::Quit => {}
Action::Csi(v) => { Action::Csi(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::Esc(v) => { Action::Esc(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::Text(v) => { Action::Text(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::CursorKey(_) => panic!("formatting not supported"), Action::CursorKey(_) => panic!("formatting not supported"),
Action::CopyToClipboard(v) => { Action::CopyToClipboard(v) => {
@@ -1921,7 +1942,7 @@ impl Action {
} }
Action::Search(v) => { Action::Search(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::NavigateSearch(v) => { Action::NavigateSearch(v) => {
out.push(':'); out.push(':');
@@ -1969,11 +1990,11 @@ impl Action {
} }
Action::SetSurfaceTitle(v) => { Action::SetSurfaceTitle(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::SetTabTitle(v) => { Action::SetTabTitle(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::NewSplit(v) => { Action::NewSplit(v) => {
out.push(':'); out.push(':');
@@ -2001,11 +2022,11 @@ impl Action {
} }
Action::ActivateKeyTable(v) => { Action::ActivateKeyTable(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::ActivateKeyTableOnce(v) => { Action::ActivateKeyTableOnce(v) => {
out.push(':'); out.push(':');
string_escape(v, out); string_escape(&v.to_bytes(), out);
} }
Action::Crash(v) => { Action::Crash(v) => {
out.push(':'); out.push(':');
+20 -12
View File
@@ -129,7 +129,11 @@ pub struct Parser<'a> {
impl<'a> Parser<'a> { impl<'a> Parser<'a> {
pub fn new(raw_input: &'a str) -> Result<Parser<'a>, Error> { pub fn new(raw_input: &'a str) -> Result<Parser<'a>, Error> {
let (flags, start_idx) = parse_flags(raw_input)?; Self::from_bytes(raw_input.as_bytes())
}
pub fn from_bytes(raw_input: &'a [u8]) -> Result<Parser<'a>, Error> {
let (flags, start_idx) = parse_flags_bytes(raw_input)?;
let input = &raw_input[start_idx..]; let input = &raw_input[start_idx..];
// Find the equal sign. This is more complicated than it seems on // Find the equal sign. This is more complicated than it seems on
@@ -140,8 +144,8 @@ impl<'a> Parser<'a> {
// based parser that parses the trigger fully, then yields the // based parser that parses the trigger fully, then yields the
// action after. The loop below is a total mess. // action after. The loop below is a total mess.
let mut offset = 0usize; let mut offset = 0usize;
let bytes = input.as_bytes(); let bytes = input;
while let Some(offset_idx) = input[offset..].find('=') { while let Some(offset_idx) = input[offset..].iter().position(|b| *b == b'=') {
// Find: '=+ctrl' or '==action' // Find: '=+ctrl' or '==action'
let idx = offset + offset_idx; let idx = offset + offset_idx;
if idx + 1 < bytes.len() && (bytes[idx + 1] == b'+' || bytes[idx + 1] == b'=') { if idx + 1 < bytes.len() && (bytes[idx + 1] == b'+' || bytes[idx + 1] == b'=') {
@@ -157,7 +161,7 @@ impl<'a> Parser<'a> {
}; };
// Detect chains. Chains must not have flags. // Detect chains. Chains must not have flags.
let chain = &input[..eql_idx] == "chain"; let chain = &input[..eql_idx] == b"chain";
if chain && start_idx > 0 { if chain && start_idx > 0 {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
@@ -169,10 +173,14 @@ impl<'a> Parser<'a> {
// This is kind of hacky but we put a dummy trigger // This is kind of hacky but we put a dummy trigger
// for chained inputs. The `next` will never yield this // for chained inputs. The `next` will never yield this
// because we have chain set. // because we have chain set.
input: if chain { "a" } else { &input[..eql_idx] }, input: if chain {
"a"
} else {
std::str::from_utf8(&input[..eql_idx]).map_err(|_| Error::InvalidFormat)?
},
i: 0, i: 0,
}, },
action: Action::parse(&input[eql_idx + 1..])?, action: Action::parse_bytes(&input[eql_idx + 1..])?,
flags, flags,
chain, chain,
}) })
@@ -211,36 +219,36 @@ impl<'a> Parser<'a> {
} }
} }
fn parse_flags(raw_input: &str) -> Result<(Flags, usize), Error> { fn parse_flags_bytes(raw_input: &[u8]) -> Result<(Flags, usize), Error> {
let mut flags = Flags::default(); let mut flags = Flags::default();
let mut start_idx = 0usize; let mut start_idx = 0usize;
let mut input = raw_input; let mut input = raw_input;
// Find the next prefix // Find the next prefix
while let Some(idx) = input.find(':') { while let Some(idx) = input.iter().position(|b| *b == b':') {
let prefix = &input[..idx]; let prefix = &input[..idx];
// If the prefix is one of our flags then set it. // If the prefix is one of our flags then set it.
match prefix { match prefix {
"all" => { b"all" => {
if flags.all { if flags.all {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
flags.all = true; flags.all = true;
} }
"global" => { b"global" => {
if flags.global { if flags.global {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
flags.global = true; flags.global = true;
} }
"unconsumed" => { b"unconsumed" => {
if !flags.consumed { if !flags.consumed {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
flags.consumed = false; flags.consumed = false;
} }
"performable" => { b"performable" => {
if flags.performable { if flags.performable {
return Err(Error::InvalidFormat); return Err(Error::InvalidFormat);
} }
+10 -2
View File
@@ -162,7 +162,11 @@ impl Value {
let pos = buffer.len(); let pos = buffer.len();
for (k, v) in &set.bindings { for (k, v) in &set.bindings {
buffer.truncate(pos); buffer.truncate(pos);
let _ = write!(buffer, ">{}", k.0); let _ = write!(
buffer,
">{}",
ghostty_os::osbytes::valid_str_to_config_string(&k.0.to_string())
);
v.format_entries(buffer, formatter); v.format_entries(buffer, formatter);
} }
buffer.truncate(pos); buffer.truncate(pos);
@@ -285,9 +289,13 @@ impl Set {
/// ///
/// Any Error returned is validated before any set modifications are made. /// Any Error returned is validated before any set modifications are made.
pub fn parse_and_put(&mut self, input: &str) -> Result<(), Error> { pub fn parse_and_put(&mut self, input: &str) -> Result<(), Error> {
self.parse_and_put_bytes(input.as_bytes())
}
pub fn parse_and_put_bytes(&mut self, input: &[u8]) -> Result<(), Error> {
// To make cleanup easier, we ensure that the full sequence is // To make cleanup easier, we ensure that the full sequence is
// valid before making any set modifications. // valid before making any set modifications.
let mut it = Parser::new(input)?; let mut it = Parser::from_bytes(input)?;
while it.next_elem()?.is_some() {} while it.next_elem()?.is_some() {}
it.reset(); it.reset();
+1 -1
View File
@@ -824,7 +824,7 @@ fn set_clone_with_leaf_chained_containing_allocated_data() {
) else { ) else {
panic!("expected text"); panic!("expected text");
}; };
assert_ne!(orig.as_ptr(), copy.as_ptr()); assert_ne!(orig.as_encoded().as_ptr(), copy.as_encoded().as_ptr());
} }
#[test] #[test]
+13 -10
View File
@@ -31,16 +31,16 @@ use crate::binding::{
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Command { pub struct Command {
pub action: Action, pub action: Action,
pub title: String, pub title: ghostty_os::osbytes::EncodedOs,
pub description: String, pub description: ghostty_os::osbytes::EncodedOs,
} }
impl Command { impl Command {
pub fn new(action: Action, title: &str, description: &str) -> Command { pub fn new(action: Action, title: &str, description: &str) -> Command {
Command { Command {
action, action,
title: title.to_string(), title: title.into(),
description: description.to_string(), description: description.into(),
} }
} }
@@ -75,14 +75,17 @@ impl Command {
pub struct CommandC { pub struct CommandC {
pub action_key: String, pub action_key: String,
pub action: String, pub action: String,
pub title: String, pub title: ghostty_os::osbytes::EncodedOs,
pub description: String, pub description: ghostty_os::osbytes::EncodedOs,
} }
/// `std.ascii.orderIgnoreCase` /// `std.ascii.orderIgnoreCase`
fn cmp_ignore_case(a: &str, b: &str) -> std::cmp::Ordering { fn cmp_ignore_case(
let a = a.bytes().map(|c| c.to_ascii_lowercase()); a: &ghostty_os::osbytes::EncodedOs,
let b = b.bytes().map(|c| c.to_ascii_lowercase()); b: &ghostty_os::osbytes::EncodedOs,
) -> std::cmp::Ordering {
let a = a.to_bytes().into_iter().map(|c| c.to_ascii_lowercase());
let b = b.to_bytes().into_iter().map(|c| c.to_ascii_lowercase());
a.cmp(b) a.cmp(b)
} }
@@ -562,7 +565,7 @@ fn default_commands() -> Vec<Command> {
Command::new(Action::Redo, "Redo", "Redo the last undone action."), Command::new(Action::Redo, "Redo", "Redo the last undone action."),
Command::new(Action::Quit, "Quit", "Quit the application."), Command::new(Action::Quit, "Quit", "Quit the application."),
Command::new( Command::new(
Action::Text("👻".to_string()), Action::Text("👻".into()),
"Ghostty", "Ghostty",
"Put a little Ghostty in your terminal.", "Put a little Ghostty in your terminal.",
), ),
+38 -4
View File
@@ -127,8 +127,8 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
v.desktop_notification.title = keep.cstr(&n.title); v.desktop_notification.title = keep.cstr(&n.title);
v.desktop_notification.body = keep.cstr(&n.body); v.desktop_notification.body = keep.cstr(&n.body);
} }
Action::SetTitle(t) => v.set_title.title = keep.cstr(&t.title), Action::SetTitle(t) => v.set_title.title = keep.cstr_bytes(&t.title.to_bytes()),
Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title), Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr_bytes(&t.title.to_bytes()),
Action::PromptTitle(p) => v.prompt_title = *p as c_int, Action::PromptTitle(p) => v.prompt_title = *p as c_int,
Action::Pwd(p) => { Action::Pwd(p) => {
v.pwd.pwd = keep.cstr_bytes(&p.pwd.to_bytes()); v.pwd.pwd = keep.cstr_bytes(&p.pwd.to_bytes());
@@ -158,8 +158,9 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
} }
Action::KeyTable(KeyTable::Activate(name)) => { Action::KeyTable(KeyTable::Activate(name)) => {
v.key_table.tag = 0; v.key_table.tag = 0;
v.key_table.value.activate.name = keep.cstr(name); let bytes = name.to_bytes();
v.key_table.value.activate.len = name.len(); v.key_table.value.activate.name = keep.cstr_bytes(&bytes);
v.key_table.value.activate.len = bytes.len();
} }
Action::KeyTable(KeyTable::Deactivate) => v.key_table.tag = 1, Action::KeyTable(KeyTable::Deactivate) => v.key_table.tag = 1,
Action::KeyTable(KeyTable::DeactivateAll) => v.key_table.tag = 2, Action::KeyTable(KeyTable::DeactivateAll) => v.key_table.tag = 2,
@@ -240,6 +241,39 @@ mod tests {
use super::*; use super::*;
#[test]
fn c_title_actions_and_key_table_names_forward_original_bytes() {
use ghostty_os::osbytes::EncodedOs;
let raw = b"name\xff\xef\xb7\x90\xc3\xa9";
let mut keep = Keep::default();
for tab in [false, true] {
let title = SetTitle {
title: EncodedOs::from_bytes(raw),
};
let action = if tab {
Action::SetTabTitle(title)
} else {
Action::SetTitle(title)
};
let c = action_to_c(&action, &mut keep);
// SAFETY: both title union members have identical layout, pointer owned by keep.
let title = unsafe { std::ffi::CStr::from_ptr(c.action.set_title.title) };
assert_eq!(title.to_bytes(), raw);
}
let c = action_to_c(
&Action::KeyTable(KeyTable::Activate(EncodedOs::from_bytes(raw))),
&mut keep,
);
// SAFETY: the tag is key_table.activate and keep owns the buffer.
let name = unsafe {
std::slice::from_raw_parts(
c.action.key_table.value.activate.name.cast::<u8>(),
c.action.key_table.value.activate.len,
)
};
assert_eq!(name, raw);
}
#[test] #[test]
fn target_cval() { fn target_cval() {
let t = target_to_c(&Target::App); let t = target_to_c(&Target::App);
+4 -8
View File
@@ -147,8 +147,8 @@ fn build_backing(value: &CValue) -> Backing {
for c in cmds { for c in cmds {
b.strings.push(cstring_value(&c.action_key)); b.strings.push(cstring_value(&c.action_key));
b.strings.push(cstring_value(&c.action)); b.strings.push(cstring_value(&c.action));
b.strings.push(cstring_value(&c.title)); b.strings.push(cstring_value(c.title.as_encoded()));
b.strings.push(cstring_value(&c.description)); b.strings.push(cstring_value(c.description.as_encoded()));
} }
// The CStrings' heap buffers don't move when the Vec grows. // The CStrings' heap buffers don't move when the Vec grows.
b.commands = b b.commands = b
@@ -265,7 +265,7 @@ pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *
if let Err(err) = c.config.load_file(&path) { if let Err(err) = c.config.load_file(&path) {
log::error!( log::error!(
"error loading config from file path={} err={err}", "error loading config from file path={} err={err}",
path.as_encoded() path.to_text_lossy()
); );
} }
} }
@@ -511,11 +511,7 @@ pub unsafe extern "C" fn ghostty_config_trigger(
} }
// SAFETY: `str` points to `len` bytes. // SAFETY: `str` points to `len` bytes.
let s = unsafe { std::slice::from_raw_parts(str.cast::<u8>(), len) }; let s = unsafe { std::slice::from_raw_parts(str.cast::<u8>(), len) };
let Ok(s) = std::str::from_utf8(s) else { match BindingAction::parse_bytes(s) {
log::error!("error finding trigger err=InvalidFormat");
return default_trigger();
};
match BindingAction::parse(s) {
Ok(action) => { Ok(action) => {
let trigger = c let trigger = c
.config .config
+23 -43
View File
@@ -17,7 +17,6 @@ use std::{
cell::{Cell, OnceCell, RefCell}, cell::{Cell, OnceCell, RefCell},
collections::HashMap, collections::HashMap,
ffi::{CStr, CString, c_char, c_int, c_void}, ffi::{CStr, CString, c_char, c_int, c_void},
fmt::Write as _,
rc::{Rc, Weak}, rc::{Rc, Weak},
sync::Arc, sync::Arc,
}; };
@@ -547,7 +546,7 @@ pub struct SurfaceRt {
cursor_pos: Cell<CursorPos>, cursor_pos: Cell<CursorPos>,
/// The current title of the surface. The embedded apprt saves this so /// The current title of the surface. The embedded apprt saves this so
/// that `get_title` works without the implementer needing to save it. /// that `get_title` works without the implementer needing to save it.
title: RefCell<Option<String>>, title: RefCell<Option<ghostty_os::osbytes::EncodedOs>>,
} }
impl SurfaceRt { impl SurfaceRt {
@@ -570,7 +569,7 @@ impl RtSurface for SurfaceRt {
None None
} }
fn get_title(&self) -> Option<String> { fn get_title(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
self.title.borrow().clone() self.title.borrow().clone()
} }
@@ -854,36 +853,21 @@ unsafe fn platform_view_from_c(
} }
} }
/// Zig's `std.zig.stringEscape` with non-ASCII code points written as /// Escape initial input for config/string while retaining arbitrary original bytes.
/// `\u{...}` (the config value is parsed as a Zig string; the bytes of fn string_escape(input: &[u8]) -> ghostty_os::osbytes::EncodedOs {
/// valid UTF-8 round trip either way). Bytes that are not valid UTF-8 are let mut out = Vec::with_capacity(input.len());
/// emitted in the reversible OS-byte encoding, which the string parser for &byte in input {
/// copies through unchanged, so `string::parse_bytes(string_escape(b)) == b`. match byte {
fn string_escape(input: &[u8]) -> String { b'\n' => out.extend_from_slice(b"\\n"),
let mut out = String::with_capacity(input.len()); b'\r' => out.extend_from_slice(b"\\r"),
for chunk in input.utf8_chunks() { b'\t' => out.extend_from_slice(b"\\t"),
for c in chunk.valid().chars() { b'\\' => out.extend_from_slice(b"\\\\"),
match c { b'"' => out.extend_from_slice(b"\\\""),
'\n' => out.push_str("\\n"), 0..=0x1f | 0x7f => out.extend_from_slice(format!("\\x{byte:02x}").as_bytes()),
'\r' => out.push_str("\\r"), _ => out.push(byte),
'\t' => out.push_str("\\t"),
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
' ' | '!' | '#'..='&' | '\'' | '('..='[' | ']'..='~' => out.push(c),
c if c.is_ascii() => {
let _ = write!(out, "\\x{:02x}", c as u32);
}
c => {
let _ = write!(out, "\\u{{{:x}}}", c as u32);
}
}
}
for &b in chunk.invalid() {
out.push('\u{FDD0}');
out.push(char::from(b));
} }
} }
out ghostty_os::osbytes::EncodedOs::from_bytes(&out)
} }
/// Apply the surface options to the config (the first half of Zig's /// Apply the surface options to the config (the first half of Zig's
@@ -921,7 +905,7 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
if let Some(cmd) = unsafe { cstr(opts.command) }.map(CStr::to_bytes) if let Some(cmd) = unsafe { cstr(opts.command) }.map(CStr::to_bytes)
&& !cmd.is_empty() && !cmd.is_empty()
{ {
config.command = Some(Command::Shell(osbytes::bytes_to_config_string(cmd))); config.command = Some(Command::Shell(osbytes::EncodedOs::from_bytes(cmd)));
config.wait_after_command = true; config.wait_after_command = true;
} }
@@ -934,10 +918,7 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
let (Some(k), Some(v)) = (unsafe { cstr(var.key) }, unsafe { cstr(var.value) }) else { let (Some(k), Some(v)) = (unsafe { cstr(var.key) }, unsafe { cstr(var.value) }) else {
continue; continue;
}; };
config.env.put( config.env.put_bytes(k.to_bytes(), v.to_bytes());
&osbytes::bytes_to_config_string(k.to_bytes()),
&osbytes::bytes_to_config_string(v.to_bytes()),
);
} }
} }
@@ -1946,11 +1927,10 @@ pub unsafe extern "C" fn ghostty_surface_binding_action(
let bytes = unsafe { std::slice::from_raw_parts(action.cast::<u8>(), len) }; let bytes = unsafe { std::slice::from_raw_parts(action.cast::<u8>(), len) };
// The action text is bytes (Zig `[]const u8`); the parser works on the // The action text is bytes (Zig `[]const u8`); the parser works on the
// reversible OS-byte encoding so a `text:` payload keeps invalid UTF-8. // reversible OS-byte encoding so a `text:` payload keeps invalid UTF-8.
let action_str = ghostty_os::osbytes::bytes_to_config_string(bytes); let action = match BindingAction::parse_bytes(bytes) {
let action = match BindingAction::parse(&action_str) {
Ok(a) => a, Ok(a) => a,
Err(err) => { Err(err) => {
log::error!("error parsing binding action action={action_str} err={err:?}"); log::error!("error parsing binding action action={bytes:?} err={err:?}");
return false; return false;
} }
}; };
@@ -2194,7 +2174,7 @@ mod tests {
] { ] {
let esc = string_escape(raw); let esc = string_escape(raw);
assert_eq!( assert_eq!(
ghostty_config::string::parse_bytes(&esc).unwrap(), ghostty_config::string::parse_bytes(esc.to_bytes()).unwrap(),
raw, raw,
"{esc:?}" "{esc:?}"
); );
@@ -2241,10 +2221,10 @@ mod tests {
let Some(Command::Shell(c)) = &config.command else { let Some(Command::Shell(c)) = &config.command else {
panic!("no command"); panic!("no command");
}; };
assert_eq!(osbytes::config_str_to_bytes(c), b"echo \xfe"); assert_eq!(c.to_bytes(), b"echo \xfe");
let (k, v) = config.env.iter().next().unwrap(); let (k, v) = config.env.iter().next().unwrap();
assert_eq!(osbytes::config_str_to_bytes(k), b"K\xfd"); assert_eq!(k.to_bytes(), b"K\xfd");
assert_eq!(osbytes::config_str_to_bytes(v), b"v\xfc"); assert_eq!(v.to_bytes(), b"v\xfc");
assert!(matches!( assert!(matches!(
config.input.list.first(), config.input.list.first(),
Some(ReadableIO::Raw(_)) Some(ReadableIO::Raw(_))
+29 -2
View File
@@ -45,6 +45,7 @@ struct Rec {
/// (action tag, target tag, surface pointer) /// (action tag, target tag, surface pointer)
actions: Vec<(i32, i32, usize)>, actions: Vec<(i32, i32, usize)>,
titles: Vec<String>, titles: Vec<String>,
raw_titles: Vec<Vec<u8>>,
cell_size: Option<(u32, u32)>, cell_size: Option<(u32, u32)>,
writes: Vec<(i32, String, bool)>, writes: Vec<(i32, String, bool)>,
reads: Vec<(i32, usize)>, reads: Vec<(i32, usize)>,
@@ -86,6 +87,7 @@ unsafe extern "C" fn on_action(
// set_title // set_title
let t = unsafe { CStr::from_ptr(action.action.set_title.title) }; let t = unsafe { CStr::from_ptr(action.action.set_title.title) };
r.titles.push(t.to_string_lossy().into_owned()); r.titles.push(t.to_string_lossy().into_owned());
r.raw_titles.push(t.to_bytes().to_vec());
} }
25 => { 25 => {
let c = unsafe { action.action.cell_size }; let c = unsafe { action.action.cell_size };
@@ -167,7 +169,7 @@ fn cs(s: &str) -> CString {
} }
impl Host { impl Host {
fn new(extra_config: &str) -> Option<Host> { fn new(extra_config: impl AsRef<[u8]>) -> Option<Host> {
rec(|r| *r = Rec::default()); rec(|r| *r = Rec::default());
let gl = match Headless::new() { let gl = match Headless::new() {
Ok(g) => Rc::new(g), Ok(g) => Rc::new(g),
@@ -186,9 +188,11 @@ impl Host {
write!( write!(
conf, conf,
"shell-integration = none\nwindow-padding-x = 0\nwindow-padding-y = 0\n\ "shell-integration = none\nwindow-padding-x = 0\nwindow-padding-y = 0\n\
font-size = 12\nscrollback-limit = 100000\n{extra_config}\n" font-size = 12\nscrollback-limit = 100000\n"
) )
.unwrap(); .unwrap();
conf.write_all(extra_config.as_ref()).unwrap();
conf.write_all(b"\n").unwrap();
let cfg = ghostty_config_new(); let cfg = ghostty_config_new();
let path = cs(conf.path().to_str().unwrap()); let path = cs(conf.path().to_str().unwrap());
unsafe { unsafe {
@@ -856,3 +860,26 @@ fn selection_clipboard_support_is_reported() {
}); });
unsafe { ghostty_surface_free(s) }; unsafe { ghostty_surface_free(s) };
} }
#[test]
fn configured_and_binding_titles_reach_c_as_original_bytes() {
let Some(h) = Host::new(b"title=config\xff\xef\xb7\x90\xc3\xa9") else {
return;
};
let surface = h.surface("sleep 30");
assert!(rec(|r| r
.raw_titles
.iter()
.any(|v| v == b"config\xff\xef\xb7\x90\xc3\xa9")));
let action = b"set_surface_title:binding\xfe\xef\xb7\x90\xc3\xa9";
// SAFETY: a live surface and the action slice is valid for the entire call.
assert!(unsafe {
ghostty_surface_binding_action(surface, action.as_ptr().cast(), action.len())
});
assert_eq!(
rec(|r| r.raw_titles.last().cloned()).unwrap(),
b"binding\xfe\xef\xb7\x90\xc3\xa9"
);
// SAFETY: the surface is live and owned by this test.
unsafe { ghostty_surface_free(surface) };
}
-10
View File
@@ -57,16 +57,6 @@ pub fn collect_bytes() -> Vec<Vec<u8>> {
iterator().map(|a| crate::osbytes::os_bytes(&a)).collect() iterator().map(|a| crate::osbytes::os_bytes(&a)).collect()
} }
/// Collect the process arguments as `String`s using the reversible
/// byte-escape encoding of [`crate::osbytes`]: no bytes are lost, invalid
/// UTF-8 bytes are carried as private-use escapes that the config layer
/// decodes back to the original bytes.
pub fn collect_config_strings() -> Vec<String> {
iterator()
.map(|a| crate::osbytes::os_to_config_string(&a))
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+27 -6
View File
@@ -1,3 +1,12 @@
/// GLib's g_variant_new_string substitutes the entire invalid value (Zig's {ss}).
fn glib_env_string(value: &std::ffi::OsStr) -> String {
let bytes = crate::osbytes::os_bytes(value);
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
std::str::from_utf8(bytes)
.unwrap_or("[Invalid UTF-8]")
.to_owned()
}
use std::{ use std::{
collections::HashMap, collections::HashMap,
os::fd::{AsFd, OwnedFd}, os::fd::{AsFd, OwnedFd},
@@ -147,12 +156,7 @@ impl FlatpakHostCommand {
.env .env
.iter() .iter()
.flat_map(crate::env::EnvMap::iter) .flat_map(crate::env::EnvMap::iter)
.map(|(k, v)| { .map(|(k, v)| (glib_env_string(k), glib_env_string(v)))
(
k.to_string_lossy().into_owned(),
v.to_string_lossy().into_owned(),
)
})
.collect(), .collect(),
fds: [ fds: [
self.stdin self.stdin
@@ -491,3 +495,20 @@ mod tests {
assert_eq!(((3u32 << 8) >> 8) & 0xff, 3); assert_eq!(((3u32 << 8) >> 8) & 0xff, 3);
} }
} }
#[cfg(test)]
mod byte_string_tests {
#[test]
fn glib_env_replaces_the_whole_invalid_value() {
use crate::osbytes::EncodedOs;
assert_eq!(
super::glib_env_string(&EncodedOs::from_bytes(b"before\xffafter").to_os()),
"[Invalid UTF-8]"
);
let literal = "x\u{fdd0}é";
assert_eq!(
super::glib_env_string(std::ffi::OsStr::new(literal)),
literal
);
}
}
+15
View File
@@ -154,6 +154,13 @@ pub fn expand_home_with_capacity(
path: &osbytes::EncodedOs, path: &osbytes::EncodedOs,
cap: usize, cap: usize,
) -> Result<osbytes::EncodedOs, ExpandError> { ) -> Result<osbytes::EncodedOs, ExpandError> {
#[cfg(windows)]
{
// Zig returns the original byte slice on Windows without converting it.
let _ = cap;
Ok(path.clone())
}
#[cfg(not(windows))]
expand_home_os_with_capacity(&path.to_os(), cap).map(osbytes::EncodedOs::from) expand_home_os_with_capacity(&path.to_os(), cap).map(osbytes::EncodedOs::from)
} }
@@ -186,6 +193,14 @@ pub fn expand_home_os_with_capacity(path: &OsStr, cap: usize) -> Result<OsString
mod tests { mod tests {
use super::*; use super::*;
#[cfg(windows)]
#[test]
fn windows_home_expansion_keeps_malformed_path_bytes() {
let path = osbytes::EncodedOs::from_bytes(b"~/bad\xff");
assert_eq!(expand_home(&path).unwrap(), path);
assert_eq!(path.to_path().unwrap_err().to_string(), "BadPathName");
}
#[test] #[test]
fn home_windows_joins_drive_and_path() { fn home_windows_joins_drive_and_path() {
assert_eq!( assert_eq!(
+153 -28
View File
@@ -1,12 +1,11 @@
//! Byte-preserving conversions between OS strings and Rust `String`s. //! Byte-preserving conversions between OS strings and Rust `String`s.
//! //!
//! Zig treats paths, arguments and environment values as byte slices. Rust //! Zig treats paths, arguments and environment values as byte slices. Rust
//! `String` cannot hold invalid UTF-8, so the String based layers (the config //! Configuration and CLI parsers receive original bytes. Byte-valued fields
//! system) use a reversible encoding that is a true bijection on byte //! use [`EncodedOs`] for storage; legacy string-based OS plumbing and config
//! strings: //! output formatting use its reversible encoding, a bijection on byte strings:
//! //!
//! * the escape character is the Unicode noncharacter `U+FDD0` (never used //! * the escape character is the Unicode noncharacter `U+FDD0`;
//! in real text);
//! * every byte that is not part of a valid UTF-8 sequence (always //! * every byte that is not part of a valid UTF-8 sequence (always
//! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb` //! `0x80..=0xFF`) is encoded as `U+FDD0` followed by the char `U+00bb`
//! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`; //! (`bb` = the byte), e.g. `0xFF` becomes `U+FDD0 U+00FF`;
@@ -83,7 +82,8 @@ pub fn os_bytes(s: &OsStr) -> Vec<u8> {
} }
/// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not /// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not
/// well-formed WTF-8 become U+FFFD, like Zig's `wtf8ToWtf16Le`/`utf8ToUtf16`). /// well-formed WTF-8 become U+FFFD). Filesystem and process operations must
/// use their checked conversions instead.
#[cfg(unix)] #[cfg(unix)]
pub fn os_from_bytes(b: Vec<u8>) -> OsString { pub fn os_from_bytes(b: Vec<u8>) -> OsString {
use std::os::unix::ffi::OsStringExt; use std::os::unix::ffi::OsStringExt;
@@ -182,18 +182,56 @@ pub struct InvalidWtf8;
/// Strict [`wtf8_to_wide`]: malformed input is an error instead of U+FFFD. /// Strict [`wtf8_to_wide`]: malformed input is an error instead of U+FFFD.
/// Lone surrogates encoded as 3-byte sequences are accepted (WTF-8). This is /// Lone surrogates encoded as 3-byte sequences are accepted (WTF-8). This is
/// the conversion used where Zig's is fallible (process launch). /// the conversion used by Windows filesystem paths; process launch requires UTF-8.
pub fn try_wtf8_to_wide(b: &[u8]) -> Result<Vec<u16>, InvalidWtf8> { pub fn try_wtf8_to_wide(b: &[u8]) -> Result<Vec<u16>, InvalidWtf8> {
let wide = wtf8_to_wide(b); let mut result = Vec::with_capacity(b.len());
// The lossy decoder yields U+FFFD only for malformed bytes; a genuine let mut rest = b;
// U+FFFD in the input is the 3-byte sequence EF BF BD. Re-encoding is while !rest.is_empty() {
// the cheapest exact check: it reproduces the input iff it was match std::str::from_utf8(rest) {
// well-formed. Ok(text) => {
if wide_to_wtf8(&wide) == b { result.extend(text.encode_utf16());
Ok(wide) break;
} else { }
Err(InvalidWtf8) Err(error) => {
let (valid, tail) = rest.split_at(error.valid_up_to());
// valid_up_to is guaranteed to be a valid UTF-8 prefix.
result.extend(
std::str::from_utf8(valid)
.map_err(|_| InvalidWtf8)?
.encode_utf16(),
);
// Zig's WTF-8 validation accepts both lone and paired surrogate encodings.
if tail.len() < 3
|| tail[0] != 0xed
|| !(0xa0..=0xbf).contains(&tail[1])
|| !(0x80..=0xbf).contains(&tail[2])
{
return Err(InvalidWtf8);
}
result.push(0xd000 | (u16::from(tail[1] & 0x3f) << 6) | u16::from(tail[2] & 0x3f));
rest = &tail[3..];
}
}
} }
Ok(result)
}
/// Strict UTF-8 conversion used by Zig's Windows Command (including its environment).
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid utf-8")]
pub struct InvalidUtf8;
pub fn try_utf8_to_wide(bytes: &[u8]) -> Result<Vec<u16>, InvalidUtf8> {
Ok(std::str::from_utf8(bytes)
.map_err(|_| InvalidUtf8)?
.encode_utf16()
.collect())
}
/// Zig's Windows filesystem conversion has a different error and accepts WTF-8.
pub fn windows_path_wide(bytes: &[u8]) -> std::io::Result<Vec<u16>> {
try_wtf8_to_wide(bytes)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "BadPathName"))
} }
/// OS string to reversible `String` (see the module docs). Lossless on every /// OS string to reversible `String` (see the module docs). Lossless on every
@@ -258,8 +296,8 @@ impl EncodedOs {
EncodedOs(bytes_to_config_string(b)) EncodedOs(bytes_to_config_string(b))
} }
/// Adopt a string that is *already* in the escape encoding (config /// Adopt a string already in the internal escape encoding. Config and CLI
/// values, CLI arguments collected with `args::collect_config_strings`). /// input must use `from_bytes`; plain Rust text must use `from_plain_text`.
#[must_use] #[must_use]
pub fn from_encoded(s: impl Into<String>) -> Self { pub fn from_encoded(s: impl Into<String>) -> Self {
EncodedOs(s.into()) EncodedOs(s.into())
@@ -272,8 +310,8 @@ impl EncodedOs {
EncodedOs(valid_str_to_config_string(s)) EncodedOs(valid_str_to_config_string(s))
} }
/// The encoded text (config files, `+show-config`, display of config /// Internal encoded text for storage/output formatting. Decode before
/// values). Never pass this to the OS; decode first. /// writing config output or passing it to OS APIs and field parsers.
#[must_use] #[must_use]
pub fn as_encoded(&self) -> &str { pub fn as_encoded(&self) -> &str {
&self.0 &self.0
@@ -302,6 +340,11 @@ impl EncodedOs {
config_str_to_bytes(&self.0) config_str_to_bytes(&self.0)
} }
/// Render bytes for a UI or log boundary which requires Unicode text.
pub fn to_text_lossy(&self) -> String {
String::from_utf8_lossy(&self.to_bytes()).into_owned()
}
/// Decode to an OS string. /// Decode to an OS string.
#[must_use] #[must_use]
pub fn to_os(&self) -> OsString { pub fn to_os(&self) -> OsString {
@@ -316,12 +359,30 @@ impl EncodedOs {
wtf8_to_wide(&self.to_bytes()) wtf8_to_wide(&self.to_bytes())
} }
/// Strict [`EncodedOs::to_wide`] for process launch: malformed WTF-8 is /// Strict [`EncodedOs::to_wide`] for WTF-8 data: malformed WTF-8 is
/// an error (Zig's `utf8ToUtf16Le*` fail) instead of becoming U+FFFD. /// an error (Zig's `utf8ToUtf16Le*` fail) instead of becoming U+FFFD.
pub fn try_to_wide(&self) -> Result<Vec<u16>, InvalidWtf8> { pub fn try_to_wide(&self) -> Result<Vec<u16>, InvalidWtf8> {
try_wtf8_to_wide(&self.to_bytes()) try_wtf8_to_wide(&self.to_bytes())
} }
/// Windows process inputs use strict UTF-8, unlike filesystem paths.
pub fn try_to_utf16(&self) -> Result<Vec<u16>, InvalidUtf8> {
try_utf8_to_wide(&self.to_bytes())
}
pub fn try_to_utf16_nul(&self) -> Result<Vec<u16>, InvalidUtf8> {
let mut wide = self.try_to_utf16()?;
wide.push(0);
Ok(wide)
}
pub fn try_to_utf8_os(&self) -> Result<OsString, InvalidUtf8> {
let bytes = self.to_bytes();
Ok(OsString::from(
std::str::from_utf8(&bytes).map_err(|_| InvalidUtf8)?,
))
}
/// [`EncodedOs::try_to_wide`] plus a trailing NUL. /// [`EncodedOs::try_to_wide`] plus a trailing NUL.
pub fn try_to_wide_nul(&self) -> Result<Vec<u16>, InvalidWtf8> { pub fn try_to_wide_nul(&self) -> Result<Vec<u16>, InvalidWtf8> {
let mut w = self.try_to_wide()?; let mut w = self.try_to_wide()?;
@@ -357,10 +418,17 @@ impl EncodedOs {
Self::from_bytes(&wide_to_wtf8(w)) Self::from_bytes(&wide_to_wtf8(w))
} }
/// Decode to a path. /// Decode to a filesystem path, rejecting malformed Windows WTF-8 as BadPathName.
#[must_use] pub fn to_path(&self) -> std::io::Result<std::path::PathBuf> {
pub fn to_path(&self) -> std::path::PathBuf { #[cfg(windows)]
config_str_to_path(&self.0) {
use std::os::windows::ffi::OsStringExt;
Ok(OsString::from_wide(&windows_path_wide(&self.to_bytes())?).into())
}
#[cfg(not(windows))]
{
Ok(self.to_os().into())
}
} }
/// Whether the decoded value starts with the given plain prefix. /// Whether the decoded value starts with the given plain prefix.
@@ -408,6 +476,18 @@ impl PartialEq<&str> for EncodedOs {
} }
} }
/// Plain UTF-8 constructors: string literals never carry escape-encoded bytes.
impl From<&str> for EncodedOs {
fn from(s: &str) -> Self {
Self::from_plain_text(s)
}
}
impl From<String> for EncodedOs {
fn from(s: String) -> Self {
Self::from_plain_text(&s)
}
}
impl From<&OsStr> for EncodedOs { impl From<&OsStr> for EncodedOs {
fn from(s: &OsStr) -> Self { fn from(s: &OsStr) -> Self {
Self::from_os(s) Self::from_os(s)
@@ -438,14 +518,59 @@ pub fn path_to_config_string(p: &std::path::Path) -> String {
} }
/// Inverse of [`path_to_config_string`]. /// Inverse of [`path_to_config_string`].
pub fn config_str_to_path(s: &str) -> std::path::PathBuf { pub fn config_str_to_path(s: &str) -> std::io::Result<std::path::PathBuf> {
std::path::PathBuf::from(config_str_to_os(s)) EncodedOs::from_encoded(s).to_path()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn windows_paths_fail_with_bad_path_name_instead_of_replacement() {
let error = windows_path_wide(b"C:\\bad\xff").unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(error.to_string(), "BadPathName");
assert_eq!(
windows_path_wide("C:\\bad\u{fffd}".as_bytes())
.unwrap()
.last(),
Some(&0xfffd)
);
assert_eq!(windows_path_wide(b"\xed\xa0\x80").unwrap(), [0xd800]);
}
#[test]
fn wtf8_accepts_separately_encoded_surrogate_pairs_like_zig() {
assert_eq!(
try_wtf8_to_wide(b"\xed\xa0\xbd\xed\xb8\x80"),
Ok(vec![0xd83d, 0xde00])
);
assert_eq!(
windows_path_wide(b"x\xed\xa0\xbd\xed\xb8\x80y").unwrap(),
[0x78, 0xd83d, 0xde00, 0x79]
);
assert_eq!(try_wtf8_to_wide(b"\xed\xa0\xbd\xff"), Err(InvalidWtf8));
}
#[test]
fn windows_command_inputs_require_strict_utf8() {
for bytes in [
b"\xff".as_slice(),
b"\xed\xa0\x80",
b"\xed\xa0\xbd\xed\xb8\x80",
] {
let value = EncodedOs::from_bytes(bytes);
assert_eq!(value.try_to_utf16(), Err(InvalidUtf8));
assert_eq!(value.try_to_utf16_nul(), Err(InvalidUtf8));
assert_eq!(value.try_to_utf8_os(), Err(InvalidUtf8));
}
assert_eq!(
try_utf8_to_wide("😀\u{fdd0}".as_bytes()).unwrap(),
[0xd83d, 0xde00, 0xfdd0]
);
}
#[test] #[test]
fn roundtrip_invalid_bytes() { fn roundtrip_invalid_bytes() {
let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3]; let raw = vec![b'a', 0xff, b'b', 0xc3, 0xa9, 0xc3];
@@ -634,7 +759,7 @@ mod tests {
let o = os_from_bytes(vec![b'/', 0xff, b'x']); let o = os_from_bytes(vec![b'/', 0xff, b'x']);
let e = EncodedOs::from_os(&o); let e = EncodedOs::from_os(&o);
assert_eq!(e.to_os(), o); assert_eq!(e.to_os(), o);
assert_eq!(e.to_path(), std::path::PathBuf::from(&o)); assert_eq!(e.to_path().unwrap(), std::path::PathBuf::from(&o));
assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b"); assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b");
} }
+12 -2
View File
@@ -28,7 +28,7 @@ pub enum Error {
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8 /// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`]. /// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> { pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> {
Ok(expand_os(&cmd.to_os())?.map(crate::osbytes::EncodedOs::from)) Ok(expand_os(cmd.to_path()?.as_os_str())?.map(crate::osbytes::EncodedOs::from))
} }
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw /// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
@@ -61,7 +61,7 @@ pub fn expand_os(cmd: &OsStr) -> Result<Option<PathBuf>, Error> {
let mut full = search_path.to_vec(); let mut full = search_path.to_vec();
full.push(SEP as u8); full.push(SEP as u8);
full.extend_from_slice(&cmd_bytes); full.extend_from_slice(&cmd_bytes);
let full_path = PathBuf::from(crate::osbytes::os_from_bytes(full)); let full_path = crate::osbytes::EncodedOs::from_bytes(&full).to_path()?;
// Stat it // Stat it
let f = match std::fs::File::open(&full_path) { let f = match std::fs::File::open(&full_path) {
@@ -111,6 +111,16 @@ mod tests {
} }
// `uname -n` is the *nix equivalent of `hostname.exe` on Windows // `uname -n` is the *nix equivalent of `hostname.exe` on Windows
#[cfg(windows)]
#[test]
fn expand_rejects_malformed_windows_path_bytes() {
let path = crate::osbytes::EncodedOs::from_bytes(b"bad\xff");
let Error::Io(error) = super::expand(&path).unwrap_err() else {
panic!("path conversion error")
};
assert_eq!(error.to_string(), "BadPathName");
}
#[test] #[test]
fn expand_hostname() { fn expand_hostname() {
let executable = if cfg!(windows) { let executable = if cfg!(windows) {
+2 -2
View File
@@ -37,12 +37,12 @@ impl ResourcesDir {
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry /// Byte-preserving [`ResourcesDir::app`]: the string fields carry
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them. /// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
pub fn app_os(&self) -> Option<PathBuf> { pub fn app_os(&self) -> Option<PathBuf> {
self.app().map(EncodedOs::to_path) self.app().and_then(|p| p.to_path().ok())
} }
/// Byte-preserving [`ResourcesDir::host`]. /// Byte-preserving [`ResourcesDir::host`].
pub fn host_os(&self) -> Option<PathBuf> { pub fn host_os(&self) -> Option<PathBuf> {
self.host().map(EncodedOs::to_path) self.host().and_then(|p| p.to_path().ok())
} }
} }
+20 -7
View File
@@ -200,7 +200,7 @@ enum HighlightTag {
pub struct DerivedConfig { pub struct DerivedConfig {
pub font_thicken: bool, pub font_thicken: bool,
pub font_thicken_strength: u8, pub font_thicken_strength: u8,
pub font_features: Vec<String>, pub font_features: Vec<ghostty_os::osbytes::EncodedOs>,
pub font_styles: [bool; 4], pub font_styles: [bool; 4],
pub font_shaping_break: FontShapingBreak, pub font_shaping_break: FontShapingBreak,
pub cursor_color: Option<TerminalColor>, pub cursor_color: Option<TerminalColor>,
@@ -243,10 +243,18 @@ impl DerivedConfig {
.custom_shader .custom_shader
.value .value
.iter() .iter()
.map(|p| match p { .filter_map(|p| {
// Decode the config path (encoded OS string) here, once. let path = match p.path().to_path() {
ConfigPath::Optional(s) => PathItem::Optional(s.to_path()), Ok(path) => path,
ConfigPath::Required(s) => PathItem::Required(s.to_path()), Err(error) => {
log::warn!("invalid shader path: {error}");
return None;
}
};
Some(match p {
ConfigPath::Optional(_) => PathItem::Optional(path),
ConfigPath::Required(_) => PathItem::Required(path),
})
}) })
.collect(); .collect();
@@ -859,8 +867,13 @@ impl<G: GraphicsApi> DrawState<G> {
fn prep_background_image(&mut self) { fn prep_background_image(&mut self) {
// Then we try to load the background image if we have a path. // Then we try to load the background image if we have a path.
if let Some(p) = &self.config.bg_image if let Some(p) = &self.config.bg_image
&& let Some(image) = && let Some(image) = p
load_background_image(&p.path().to_path()).map(image::Image::Pending) .path()
.to_path()
.map_err(|error| log::warn!("invalid background image path: {error}"))
.ok()
.and_then(|path| load_background_image(&path))
.map(image::Image::Pending)
{ {
// If we have an existing background image, replace it. // If we have an existing background image, replace it.
// Otherwise, set this as our background image directly. // Otherwise, set this as our background image directly.
+1 -1
View File
@@ -43,7 +43,7 @@ impl Set {
let mut links = Vec::with_capacity(config.len()); let mut links = Vec::with_capacity(config.len());
for link in config { for link in config {
links.push(Link { links.push(Link {
regex: Regex::new(&ghostty_os::osbytes::config_str_to_text(&link.regex))?, regex: Regex::new(&link.regex)?,
highlight: link.highlight, highlight: link.highlight,
}); });
} }
+6 -6
View File
@@ -50,8 +50,8 @@ use crate::windows_args::{self, create_windows_env_block, windows_create_command
/// Errors from [`Command::start`]. /// Errors from [`Command::start`].
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum StartError { pub enum StartError {
#[error("malformed wtf-8 in a path, argument or environment value")] #[error("invalid utf-8 in a path, argument or environment value")]
InvalidWtf8(#[from] ghostty_os::osbytes::InvalidWtf8), InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
#[error("string or environment is not valid unicode")] #[error("string or environment is not valid unicode")]
Encode(#[from] windows_args::EncodeError), Encode(#[from] windows_args::EncodeError),
#[error("windows error: {0}")] #[error("windows error: {0}")]
@@ -132,14 +132,14 @@ impl Command {
#[allow(unsafe_code)] #[allow(unsafe_code)]
pub fn start(&mut self) -> Result<(), StartError> { pub fn start(&mut self) -> Result<(), StartError> {
// `path` and `args` are the config command's argv strings (encoded // `path` and `args` are the config command's argv strings (encoded
// OS strings); decode them to WTF-16 for the Windows API. // OS strings); validate UTF-8 and convert to UTF-16 for the Windows API.
// Like Zig's fallible `utf8ToUtf16Le*`, malformed input fails the // Like Zig's fallible `utf8ToUtf16Le*`, malformed input fails the
// launch instead of selecting a U+FFFD-substituted path or argument. // launch instead of selecting a U+FFFD-substituted path or argument.
let application_w = EncodedOs::from_encoded(&self.path).try_to_wide_nul()?; let application_w = EncodedOs::from_encoded(&self.path).try_to_utf16_nul()?;
let cwd_w = self let cwd_w = self
.cwd .cwd
.as_ref() .as_ref()
.map(EncodedOs::try_to_wide_nul) .map(EncodedOs::try_to_utf16_nul)
.transpose()?; .transpose()?;
let mut command_line_w = if self.args.is_empty() { let mut command_line_w = if self.args.is_empty() {
None None
@@ -147,7 +147,7 @@ impl Command {
let wide: Vec<Vec<u16>> = self let wide: Vec<Vec<u16>> = self
.args .args
.iter() .iter()
.map(|a| EncodedOs::from_encoded(a).try_to_wide()) .map(|a| EncodedOs::from_encoded(a).try_to_utf16())
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut line = windows_create_command_line_wide(&wide); let mut line = windows_create_command_line_wide(&wide);
line.push(0); line.push(0);
+9 -12
View File
@@ -790,7 +790,7 @@ impl Subprocess {
// If we have a resources dir then set our env var // If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir { if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {}", dir.as_encoded()); log::info!("found Ghostty resources dir: {}", dir.to_text_lossy());
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os()); env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
} }
@@ -900,10 +900,7 @@ impl Subprocess {
// Add the environment variables that override any others. // Add the environment variables that override any others.
for (k, v) in cfg.env_override.iter() { for (k, v) in cfg.env_override.iter() {
env.insert( env.insert(k.to_os(), v.to_os());
ghostty_os::osbytes::config_str_to_os(k),
ghostty_os::osbytes::config_str_to_os(v),
);
} }
// Build our args list // Build our args list
@@ -1033,10 +1030,10 @@ impl Subprocess {
} }
} }
Some(proposed) => { Some(proposed) => {
if proposed.to_path().exists() { if proposed.to_path().is_ok_and(|p| p.exists()) {
Some(proposed.clone()) Some(proposed.clone())
} else { } else {
log::warn!("cannot access cwd, ignoring: {}", proposed.as_encoded()); log::warn!("cannot access cwd, ignoring: {}", proposed.to_text_lossy());
None None
} }
} }
@@ -1399,7 +1396,7 @@ pub fn exec_command_with(
match command { match command {
// We need to clone the command since there's no guarantee the config remains valid. // We need to clone the command since there's no guarantee the config remains valid.
ConfigCommand::Direct(args) => args.clone(), ConfigCommand::Direct(args) => args.iter().map(|v| v.as_encoded().to_owned()).collect(),
ConfigCommand::Shell(v) => { ConfigCommand::Shell(v) => {
let mut args: Vec<String> = Vec::with_capacity(4); let mut args: Vec<String> = Vec::with_capacity(4);
@@ -1414,7 +1411,7 @@ pub fn exec_command_with(
args.push("-l".into()); args.push("-l".into());
} }
args.push("-c".into()); args.push("-c".into());
args.push(v.clone()); args.push(v.as_encoded().to_owned());
args args
} }
} }
@@ -1442,7 +1439,7 @@ fn darwin_login_command(
let hush = match &passwd.home { let hush = match &passwd.home {
Some(home) => { Some(home) => {
// The passwd home is an encoded OS string: decode it first. // The passwd home is an encoded OS string: decode it first.
let home = home.to_path(); let home = home.to_path().ok()?;
if std::fs::read_dir(&home).is_err() { if std::fs::read_dir(&home).is_err() {
log::warn!("failed to open home dir, not checking for hushlogin"); log::warn!("failed to open home dir, not checking for hushlogin");
false false
@@ -1517,13 +1514,13 @@ fn darwin_login_command(
// Direct args can be passed directly to login, since // Direct args can be passed directly to login, since
// login uses execvp we don't need to worry about PATH // login uses execvp we don't need to worry about PATH
// searching. // searching.
ConfigCommand::Direct(v) => args.extend(v.iter().cloned()), ConfigCommand::Direct(v) => args.extend(v.iter().map(|v| v.as_encoded().to_owned())),
ConfigCommand::Shell(v) => { ConfigCommand::Shell(v) => {
// Use "exec" to replace the bash process with // Use "exec" to replace the bash process with
// our intended command so we don't have a parent // our intended command so we don't have a parent
// process hanging around. // process hanging around.
let cmd = format!("exec -l {v}"); let cmd = format!("exec -l {}", v.as_encoded());
// We execute bash with "--noprofile --norc" so that it doesn't // We execute bash with "--noprofile --norc" so that it doesn't
// load startup files so that (1) our shell integration doesn't // load startup files so that (1) our shell integration doesn't
+36 -50
View File
@@ -80,9 +80,9 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
ShellIntegration(#[from] shell_integration::Error), ShellIntegration(#[from] shell_integration::Error),
/// A configured path, argument or environment value is not well-formed /// A configured path, argument or environment value is not well-formed
/// WTF-8 (Zig's process launch conversions fail on such input). /// UTF-8 (Zig's process launch conversions fail on such input).
#[error(transparent)] #[error(transparent)]
InvalidWtf8(#[from] ghostty_os::osbytes::InvalidWtf8), InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
#[error("io error: {0}")] #[error("io error: {0}")]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error("failed to get WINDIR, cannot run shell command")] #[error("failed to get WINDIR, cannot run shell command")]
@@ -588,19 +588,16 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver<Vec<u8>>) {
// Subprocess // Subprocess
/// Insert the config `env` overrides, decoding the encoded key/value text /// Insert the config `env` overrides, decoding the encoded key/value text
/// to OS strings (WTF-16 on Windows) like the Unix implementation. /// to OS strings after strict UTF-8 validation, as in Zig.
/// ///
/// Malformed WTF-8 fails (Zig's conversions for the environment block are /// Invalid UTF-8 fails (Zig's conversions for the environment block are
/// fallible) instead of being substituted with U+FFFD. /// fallible) instead of being substituted with U+FFFD.
fn apply_env_overrides( fn apply_env_overrides(
env: &mut EnvMap, env: &mut EnvMap,
overrides: &RepeatableStringMap, overrides: &RepeatableStringMap,
) -> Result<(), ghostty_os::osbytes::InvalidWtf8> { ) -> Result<(), ghostty_os::osbytes::InvalidUtf8> {
for (k, v) in overrides.iter() { for (k, v) in overrides.iter() {
env.insert( env.insert(k.try_to_utf8_os()?, v.try_to_utf8_os()?);
EncodedOs::from_encoded(k).try_to_os()?,
EncodedOs::from_encoded(v).try_to_os()?,
);
} }
Ok(()) Ok(())
} }
@@ -625,10 +622,10 @@ impl Subprocess {
// If we have a resources dir then set our env var // If we have a resources dir then set our env var
if let Some(dir) = &cfg.resources_dir { if let Some(dir) = &cfg.resources_dir {
log::info!("found Ghostty resources dir: {}", dir.as_encoded()); log::info!("found Ghostty resources dir: {}", dir.to_text_lossy());
// The resources dir is an encoded OS string: decode it for the // The resources dir is an encoded OS string: decode it for the
// child's environment. // child's environment.
env.insert("GHOSTTY_RESOURCES_DIR", dir.try_to_os()?); env.insert("GHOSTTY_RESOURCES_DIR", dir.try_to_utf8_os()?);
} }
// Set our TERM var. This is a bit complicated because we want to use // Set our TERM var. This is a bit complicated because we want to use
@@ -636,13 +633,13 @@ impl Subprocess {
// ghostty in the TERMINFO database. // ghostty in the TERMINFO database.
if let Some(base) = &cfg.resources_dir { if let Some(base) = &cfg.resources_dir {
// The configured TERM is an encoded OS string: export its bytes. // The configured TERM is an encoded OS string: export its bytes.
env.insert("TERM", cfg.term.try_to_os()?); env.insert("TERM", cfg.term.try_to_utf8_os()?);
env.insert("COLORTERM", "truecolor"); env.insert("COLORTERM", "truecolor");
// Assume that the resources directory is adjacent to the terminfo // Assume that the resources directory is adjacent to the terminfo
// database // database
base.try_to_os()?; // malformed WTF-8 fails like Zig base.try_to_utf8_os()?; // invalid UTF-8 fails like Zig
env.insert("TERMINFO", terminfo_dir(base)); env.insert("TERMINFO", terminfo_dir(base)?);
} else { } else {
env.insert("TERM", "xterm-256color"); env.insert("TERM", "xterm-256color");
env.insert("COLORTERM", "truecolor"); env.insert("COLORTERM", "truecolor");
@@ -716,7 +713,7 @@ impl Subprocess {
// Build our args list. If we fail to find the shell, we'd still like // Build our args list. If we fail to find the shell, we'd still like
// to try to run something so Ghostty can launch. // to try to run something so Ghostty can launch.
// %WINDIR% may hold lone surrogates: carry it in the encoded form the // %WINDIR% may hold lone surrogates: carry it in the encoded form the
// argv strings use (decoded to WTF-16 when the process is created). // argv strings use (validated as UTF-8 when the process is created).
let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded()); let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded());
let args = match exec_command(&shell_command, windir.as_deref()) { let args = match exec_command(&shell_command, windir.as_deref()) {
Ok(args) => args, Ok(args) => args,
@@ -733,7 +730,7 @@ impl Subprocess {
// Propagate the current working directory (CWD) to the shell. // Propagate the current working directory (CWD) to the shell.
if let Some(pwd) = &cwd { if let Some(pwd) = &cwd {
env.insert("PWD", pwd.try_to_os()?); env.insert("PWD", pwd.try_to_utf8_os()?);
} }
Ok(Subprocess { Ok(Subprocess {
@@ -804,7 +801,7 @@ impl Subprocess {
// and we don't want to break new windows. // and we don't want to break new windows.
let cwd: Option<EncodedOs> = match &self.cwd { let cwd: Option<EncodedOs> = match &self.cwd {
None => None, None => None,
Some(proposed) => match std::fs::metadata(proposed.to_path()) { Some(proposed) => match proposed.to_path().and_then(std::fs::metadata) {
Ok(_) => Some(proposed.clone()), Ok(_) => Some(proposed.clone()),
Err(err) => { Err(err) => {
log::warn!("cannot access cwd, ignoring: {err}"); log::warn!("cannot access cwd, ignoring: {err}");
@@ -912,13 +909,13 @@ fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
/// The `TERMINFO` value for a resources dir: `<parent of base>\\terminfo` as /// The `TERMINFO` value for a resources dir: `<parent of base>\\terminfo` as
/// an OS string (the resources dir is an encoded OS string). /// an OS string (the resources dir is an encoded OS string).
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString { fn terminfo_dir(base: &EncodedOs) -> std::io::Result<std::ffi::OsString> {
let base = base.to_path(); let base = base.to_path()?;
let mut out = base let mut out = base
.parent() .parent()
.map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned()); .map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned());
out.push("\\terminfo"); out.push("\\terminfo");
out Ok(out)
} }
/// Terminate the underlying subprocess and wait for it to exit /// Terminate the underlying subprocess and wait for it to exit
@@ -937,11 +934,11 @@ fn kill_command(command: &Command) -> Result<(), Error> {
pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec<String>, Error> { pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec<String>, Error> {
match command { match command {
// We need to clone the command since there's no guarantee the config remains valid. // We need to clone the command since there's no guarantee the config remains valid.
ConfigCommand::Direct(args) => Ok(args.clone()), ConfigCommand::Direct(args) => Ok(args.iter().map(|v| v.as_encoded().to_owned()).collect()),
ConfigCommand::Shell(v) => { ConfigCommand::Shell(v) => {
let windir = windir.ok_or(Error::SystemError)?; let windir = windir.ok_or(Error::SystemError)?;
Ok(windows_shell_args(windir, v)) Ok(windows_shell_args(windir, v.as_encoded()))
} }
} }
} }
@@ -949,35 +946,24 @@ pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn env_overrides_are_decoded() { fn env_overrides_keep_literal_fdd0_and_reject_surrogates() {
use std::os::windows::ffi::OsStringExt;
use ghostty_config::cli_args::ConfigField; use ghostty_config::cli_args::ConfigField;
let mut overrides = RepeatableStringMap::default();
// A lone surrogate (escaped) value and a literal U+FDD0 key. overrides
let lone = EncodedOs::from_wide(&[0x61, 0xD800]).into_encoded(); .parse_cli(Some("K\u{fdd0}é=value\u{fdd0}é"))
let lit = EncodedOs::from_plain_text("K\u{fdd0}").into_encoded(); .unwrap();
let mut m = RepeatableStringMap::default();
m.parse_cli(Some(&format!("A={lone}"))).unwrap();
m.parse_cli(Some(&format!("{lit}=v"))).unwrap();
let mut env = EnvMap::new(); let mut env = EnvMap::new();
super::apply_env_overrides(&mut env, &m).unwrap(); super::apply_env_overrides(&mut env, &overrides).unwrap();
assert_eq!( assert_eq!(env.get("K\u{fdd0}é").unwrap(), "value\u{fdd0}é");
env.get("A").unwrap(), for raw in [
std::ffi::OsString::from_wide(&[0x61, 0xD800]) b"A=\xff".as_slice(),
); b"A=\xed\xa0\x80",
assert_eq!(env.get("K\u{fdd0}").unwrap(), "v"); b"\xed\xa0\x80=value",
} ] {
let mut overrides = RepeatableStringMap::default();
#[test] overrides.parse_bytes(Some(raw)).unwrap();
fn malformed_wtf8_env_overrides_fail() { assert!(super::apply_env_overrides(&mut env, &overrides).is_err());
use ghostty_config::cli_args::ConfigField; }
let bad = EncodedOs::from_bytes(b"a\xff").into_encoded();
let mut m = RepeatableStringMap::default();
m.parse_cli(Some(&format!("A={bad}"))).unwrap();
let mut env = EnvMap::new();
assert!(super::apply_env_overrides(&mut env, &m).is_err());
} }
#[test] #[test]
@@ -989,7 +975,7 @@ mod tests {
wide.push(0xD800); wide.push(0xD800);
wide.extend("\\share\\ghostty".encode_utf16()); wide.extend("\\share\\ghostty".encode_utf16());
let base = EncodedOs::from_wide(&wide); let base = EncodedOs::from_wide(&wide);
let got = super::terminfo_dir(&base); let got = super::terminfo_dir(&base).unwrap();
let mut want: Vec<u16> = "C:\\Users\\".encode_utf16().collect(); let mut want: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
want.push(0xD800); want.push(0xD800);
want.extend("\\share\\terminfo".encode_utf16()); want.extend("\\share\\terminfo".encode_utf16());
+70 -28
View File
@@ -48,6 +48,9 @@ pub use ghostty_config::{command::Command, types_b::ShellIntegrationFeatures};
pub enum Error { pub enum Error {
#[error("unable to determine home directory: {0}")] #[error("unable to determine home directory: {0}")]
Home(#[from] homedir::Error), Home(#[from] homedir::Error),
#[cfg(windows)]
#[error(transparent)]
InvalidUtf8(#[from] ghostty_os::osbytes::InvalidUtf8),
} }
/// The result of setting up a shell integration. /// The result of setting up a shell integration.
@@ -65,11 +68,17 @@ pub struct ShellIntegration {
/// `<resource_dir>/<rel>` as an OS string. `resource_dir` carries the /// `<resource_dir>/<rel>` as an OS string. `resource_dir` carries the
/// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is /// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is
/// decoded before reaching the filesystem or the child environment. /// decoded before reaching the filesystem or the child environment.
fn resource_path(resource_dir: &EncodedOs, rel: &str) -> std::ffi::OsString { fn resource_path(resource_dir: &EncodedOs, rel: &str) -> Option<std::ffi::OsString> {
let mut p = resource_dir.to_os(); let mut path = resource_dir.to_bytes();
p.push("/"); path.push(b'/');
p.push(rel); path.extend_from_slice(rel.as_bytes());
p match EncodedOs::from_bytes(&path).to_path() {
Ok(path) => Some(path.into_os_string()),
Err(error) => {
log::warn!("invalid shell integration path: {error}");
None
}
}
} }
/// Set up the command execution environment for automatic /// Set up the command execution environment for automatic
@@ -118,7 +127,7 @@ fn detect_shell(command: &Command) -> Option<Shell> {
fn detect_shell_for(command: &Command, darwin: bool) -> Option<Shell> { fn detect_shell_for(command: &Command, darwin: bool) -> Option<Shell> {
let mut it = command.arg_iterator(); let mut it = command.arg_iterator();
let arg0 = it.next()?; let arg0 = it.next()?;
let exe = basename(&arg0); let exe = basename(arg0.as_encoded());
match exe { match exe {
"bash" => { "bash" => {
@@ -195,7 +204,7 @@ fn setup_bash(
// Start accumulating arguments with the executable and initial flags. // Start accumulating arguments with the executable and initial flags.
match iter.next() { match iter.next() {
Some(exe) => cmd.append_arg(&exe), Some(exe) => cmd.append_arg(exe.as_encoded()),
None => return Ok(None), None => return Ok(None),
} }
cmd.append_arg("--posix"); cmd.append_arg("--posix");
@@ -216,6 +225,7 @@ fn setup_bash(
// --posix POSIX mode (a la /bin/sh) // --posix POSIX mode (a la /bin/sh)
let mut rcfile: Option<String> = None; let mut rcfile: Option<String> = None;
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
let arg = arg.as_encoded();
if arg == "--posix" { if arg == "--posix" {
return Ok(None); return Ok(None);
} else if arg == "--norc" { } else if arg == "--norc" {
@@ -223,23 +233,23 @@ fn setup_bash(
} else if arg == "--noprofile" { } else if arg == "--noprofile" {
inject.push_str(" --noprofile"); inject.push_str(" --noprofile");
} else if arg == "--rcfile" || arg == "--init-file" { } else if arg == "--rcfile" || arg == "--init-file" {
rcfile = iter.next(); rcfile = iter.next().map(EncodedOs::into_encoded);
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') { } else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
// '-c command' is always non-interactive // '-c command' is always non-interactive
if arg.contains('c') { if arg.contains('c') {
return Ok(None); return Ok(None);
} }
cmd.append_arg(&arg); cmd.append_arg(arg);
} else if arg == "-" || arg == "--" { } else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell // All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing. // command. We shouldn't perform any further option processing.
cmd.append_arg(&arg); cmd.append_arg(arg);
for remaining in iter.by_ref() { for remaining in iter.by_ref() {
cmd.append_arg(&remaining); cmd.append_arg(remaining.as_encoded());
} }
break; break;
} else { } else {
cmd.append_arg(&arg); cmd.append_arg(arg);
} }
} }
@@ -249,7 +259,11 @@ fn setup_bash(
} }
// Set our new ENV to point to our integration script. // Set our new ENV to point to our integration script.
let script_path = resource_path(resource_dir, "shell-integration/bash/ghostty.bash"); let Some(script_path) = resource_path(resource_dir, "shell-integration/bash/ghostty.bash")
else {
env.remove("GHOSTTY_BASH_ENV");
return Ok(None);
};
match std::fs::File::open(&script_path) { match std::fs::File::open(&script_path) {
Ok(_) => { Ok(_) => {
env.insert("ENV", script_path); env.insert("ENV", script_path);
@@ -265,7 +279,12 @@ fn setup_bash(
if let Some(v) = rcfile { if let Some(v) = rcfile {
// The intercepted argument is an encoded OS string; the env var gets // The intercepted argument is an encoded OS string; the env var gets
// its original bytes (Zig inserts the argument slice unchanged). // its original bytes (Zig inserts the argument slice unchanged).
env.insert("GHOSTTY_BASH_RCFILE", EncodedOs::from_encoded(v).to_os()); let value = EncodedOs::from_encoded(v);
#[cfg(windows)]
let value = value.try_to_utf8_os()?;
#[cfg(not(windows))]
let value = value.to_os();
env.insert("GHOSTTY_BASH_RCFILE", value);
} }
// In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're
@@ -281,7 +300,7 @@ fn setup_bash(
} }
// Return a copy of our modified command line to use as the shell command. // Return a copy of our modified command line to use as the shell command.
Ok(Some(Command::Shell(cmd.written().to_owned()))) Ok(Some(Command::Shell(EncodedOs::from_encoded(cmd.written()))))
} }
/// Setup automatic shell integration for shells that include /// Setup automatic shell integration for shells that include
@@ -293,7 +312,9 @@ fn setup_bash(
/// from `XDG_DATA_DIRS` when integration is complete. /// from `XDG_DATA_DIRS` when integration is complete.
fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool { fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool {
// Get our path to the shell integration directory. // Get our path to the shell integration directory.
let integ_path = resource_path(resource_dir, "shell-integration"); let Some(integ_path) = resource_path(resource_dir, "shell-integration") else {
return false;
};
if let Err(err) = std::fs::read_dir(&integ_path) { if let Err(err) = std::fs::read_dir(&integ_path) {
log::warn!("unable to open {}: {err}", integ_path.to_string_lossy()); log::warn!("unable to open {}: {err}", integ_path.to_string_lossy());
return false; return false;
@@ -336,7 +357,7 @@ fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap)
let mut iter = command.arg_iterator(); let mut iter = command.arg_iterator();
// Start accumulating arguments with the executable and initial flags. // Start accumulating arguments with the executable and initial flags.
cmd.append_arg(&iter.next()?); cmd.append_arg(iter.next()?.as_encoded());
// Tell nu to immediately "use" all of the exported functions in our // Tell nu to immediately "use" all of the exported functions in our
// 'ghostty' module. // 'ghostty' module.
@@ -355,28 +376,29 @@ fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap)
// -c / --command -c is always non-interactive // -c / --command -c is always non-interactive
// --lsp --lsp starts the language server // --lsp --lsp starts the language server
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
let arg = arg.as_encoded();
if arg == "--command" || arg == "--lsp" { if arg == "--command" || arg == "--lsp" {
return None; return None;
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') { } else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
if arg.contains('c') { if arg.contains('c') {
return None; return None;
} }
cmd.append_arg(&arg); cmd.append_arg(arg);
} else if arg == "-" || arg == "--" { } else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell // All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing. // command. We shouldn't perform any further option processing.
cmd.append_arg(&arg); cmd.append_arg(arg);
for remaining in iter.by_ref() { for remaining in iter.by_ref() {
cmd.append_arg(&remaining); cmd.append_arg(remaining.as_encoded());
} }
break; break;
} else { } else {
cmd.append_arg(&arg); cmd.append_arg(arg);
} }
} }
// Return a copy of our modified command line to use as the shell command. // Return a copy of our modified command line to use as the shell command.
Some(Command::Shell(cmd.written().to_owned())) Some(Command::Shell(EncodedOs::from_encoded(cmd.written())))
} }
/// Setup the zsh automatic shell integration. This works by setting /// Setup the zsh automatic shell integration. This works by setting
@@ -389,7 +411,7 @@ fn setup_zsh(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> O
} }
// Set our new ZDOTDIR to point to our shell resource directory. // Set our new ZDOTDIR to point to our shell resource directory.
let integ_path = resource_path(resource_dir, "shell-integration/zsh"); let integ_path = resource_path(resource_dir, "shell-integration/zsh")?;
if !Path::new(&integ_path).is_dir() { if !Path::new(&integ_path).is_dir() {
// Mirrors openDirAbsolute failing (missing or not a directory). // Mirrors openDirAbsolute failing (missing or not a directory).
log::warn!("unable to open {}", integ_path.to_string_lossy()); log::warn!("unable to open {}", integ_path.to_string_lossy());
@@ -449,12 +471,12 @@ mod tests {
} }
fn sh(s: &str) -> Command { fn sh(s: &str) -> Command {
Command::Shell(s.to_owned()) Command::Shell(s.into())
} }
fn shell_str(c: Option<&Command>) -> &str { fn shell_str(c: Option<&Command>) -> &str {
match c.unwrap() { match c.unwrap() {
Command::Shell(s) => s, Command::Shell(s) => s.as_encoded(),
Command::Direct(_) => panic!("expected shell"), Command::Direct(_) => panic!("expected shell"),
} }
} }
@@ -682,15 +704,35 @@ mod tests {
for raw in [&b"/home/x/\xffrc"[..], "/home/x/\u{fdd0}rc".as_bytes()] { for raw in [&b"/home/x/\xffrc"[..], "/home/x/\u{fdd0}rc".as_bytes()] {
let mut env = EnvMap::new(); let mut env = EnvMap::new();
let command = Command::Direct(vec![ let command = Command::Direct(vec![
"bash".to_owned(), "bash".into(),
"--rcfile".to_owned(), "--rcfile".into(),
EncodedOs::from_bytes(raw).into_encoded(), EncodedOs::from_bytes(raw),
]); ]);
setup_bash(&command, &res.enc(), &mut env).unwrap(); setup_bash(&command, &res.enc(), &mut env).unwrap();
assert_eq!(env.get("GHOSTTY_BASH_RCFILE").unwrap().as_bytes(), raw); assert_eq!(env.get("GHOSTTY_BASH_RCFILE").unwrap().as_bytes(), raw);
} }
} }
#[cfg(windows)]
#[test]
fn windows_bash_rcfile_rejects_invalid_utf8_before_env_conversion() {
let res = TmpResourcesDir::new(Shell::Bash);
for raw in [b"bad\xff".as_slice(), b"bad\xed\xa0\x80"] {
let command = Command::Direct(vec![
"bash".into(),
"--rcfile".into(),
EncodedOs::from_bytes(raw),
]);
let mut env = EnvMap::new();
assert!(matches!(
setup_bash(&command, &res.enc(), &mut env),
Err(Error::InvalidUtf8(_))
));
assert!(!env.contains_key("GHOSTTY_BASH_RCFILE"));
}
assert!(resource_path(&EncodedOs::from_bytes(b"bad\xff"), "shell-integration").is_none());
}
#[test] #[test]
fn bash_histfile() { fn bash_histfile() {
let res = TmpResourcesDir::new(Shell::Bash); let res = TmpResourcesDir::new(Shell::Bash);
+14 -9
View File
@@ -143,7 +143,7 @@ impl DerivedConfig {
background: config.background, background: config.background,
osc_color_report_format: config.osc_color_report_format, osc_color_report_format: config.osc_color_report_format,
clipboard_write: config.clipboard_write, clipboard_write: config.clipboard_write,
enquiry_response: ghostty_os::osbytes::config_str_to_bytes(&config.enquiry_response), enquiry_response: config.enquiry_response.to_bytes(),
conditional_state: config.conditional_state, conditional_state: config.conditional_state,
} }
} }
@@ -174,13 +174,19 @@ impl ThreadEnterState {
let mut out = Vec::with_capacity(self.input.len()); let mut out = Vec::with_capacity(self.input.len());
for item in &self.input { for item in &self.input {
match item { match item {
ReadableIO::Raw(v) => out.push(ghostty_os::osbytes::config_str_to_bytes(v)), ReadableIO::Raw(v) => out.push(v.to_bytes()),
ReadableIO::Path(path) => { ReadableIO::Path(path) => {
// The path is an encoded OS string: decode it to open. // The path is an encoded OS string: decode it to open.
let f = std::fs::File::open(path.to_path()).map_err(|err| { let f = path
log::warn!("failed to open input file={} err={err}", path.as_encoded()); .to_path()
Error::InputNotFound .and_then(std::fs::File::open)
})?; .map_err(|err| {
log::warn!(
"failed to open input file={} err={err}",
path.to_text_lossy()
);
Error::InputNotFound
})?;
let mut buf = Vec::new(); let mut buf = Vec::new();
// 10 MiB max // 10 MiB max
let limit = 10 * 1024 * 1024; let limit = 10 * 1024 * 1024;
@@ -803,8 +809,7 @@ mod derived_config_tests {
let mut cfg = Config { let mut cfg = Config {
enquiry_response: ghostty_os::osbytes::EncodedOs::from_bytes( enquiry_response: ghostty_os::osbytes::EncodedOs::from_bytes(
"a\u{fdd0}\u{ff}\u{e9}".as_bytes(), "a\u{fdd0}\u{ff}\u{e9}".as_bytes(),
) ),
.into_encoded(),
..Config::default() ..Config::default()
}; };
assert_eq!( assert_eq!(
@@ -812,7 +817,7 @@ mod derived_config_tests {
"a\u{fdd0}\u{ff}\u{e9}".as_bytes() "a\u{fdd0}\u{ff}\u{e9}".as_bytes()
); );
let raw = b"x\xffy"; let raw = b"x\xffy";
cfg.enquiry_response = ghostty_os::osbytes::EncodedOs::from_bytes(raw).into_encoded(); cfg.enquiry_response = ghostty_os::osbytes::EncodedOs::from_bytes(raw);
assert_eq!(DerivedConfig::new(&cfg).enquiry_response, raw); assert_eq!(DerivedConfig::new(&cfg).enquiry_response, raw);
} }
} }
+17 -14
View File
@@ -15,21 +15,11 @@ pub enum EncodeError {
InvalidUtf8, InvalidUtf8,
} }
/// Convert an OS string to UTF-16 code units. On Windows this is lossless /// Zig's Windows Command validates UTF-8, including inherited environment strings.
/// (`encode_wide`); elsewhere the string must be valid UTF-8. /// An unpaired UTF-16 surrogate in an OS string is therefore an error.
pub fn os_str_to_utf16(s: &OsStr) -> Result<Vec<u16>, EncodeError> { pub fn os_str_to_utf16(s: &OsStr) -> Result<Vec<u16>, EncodeError> {
#[cfg(windows)] ghostty_os::osbytes::try_utf8_to_wide(&ghostty_os::osbytes::os_bytes(s))
{ .map_err(|_| EncodeError::InvalidUtf8)
use std::os::windows::ffi::OsStrExt;
Ok(s.encode_wide().collect())
}
#[cfg(not(windows))]
{
Ok(s.to_str()
.ok_or(EncodeError::InvalidUtf8)?
.encode_utf16()
.collect())
}
} }
/// Create a UTF-16 environment block for `CreateProcessW` with /// Create a UTF-16 environment block for `CreateProcessW` with
@@ -157,6 +147,19 @@ mod tests {
windows_create_command_line(args) windows_create_command_line(args)
} }
#[test]
fn inherited_environment_rejects_non_utf8() {
let bad = ghostty_os::osbytes::EncodedOs::from_wide(&[0xd800]).to_os();
assert_eq!(
create_windows_env_block([(std::ffi::OsStr::new("KEY"), bad.as_os_str())]),
Err(EncodeError::InvalidUtf8)
);
assert_eq!(
create_windows_env_block([(bad.as_os_str(), std::ffi::OsStr::new("value"))]),
Err(EncodeError::InvalidUtf8)
);
}
#[test] #[test]
fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() { fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() {
let w = |s: &str| s.encode_utf16().collect::<Vec<u16>>(); let w = |s: &str| s.encode_utf16().collect::<Vec<u16>>();
+1 -1
View File
@@ -3,7 +3,7 @@
## Resources (self-contained) ## Resources (self-contained)
- `resources/` mirrors Ghostty's `share/ghostty` plus its sibling `share/terminfo`: - `resources/` mirrors Ghostty's `share/ghostty` plus its sibling `share/terminfo`:
`shell-integration/`, `themes/` (463 files copied from the installed Ghostty 1.3.1, `shell-integration/`, `themes/` (463 files copied from the installed Ghostty 1.3.1,
`themes/LICENSE-iTerm2-Color-Schemes` = MIT attribution, written from the upstream license; `LICENSE-iTerm2-Color-Schemes` = MIT attribution, written from the upstream license;
no upstream copy existed on this machine, check the copyright line if it matters) and no upstream copy existed on this machine, check the copyright line if it matters) and
`terminfo/` (compiled `xterm-ghostty`, `g/ghostty`). `terminfo/` (compiled `xterm-ghostty`, `g/ghostty`).
- `scripts/gen-terminfo.sh` (`just terminfo`): `cargo run -p ghostty-termio --example terminfo` - `scripts/gen-terminfo.sh` (`just terminfo`): `cargo run -p ghostty-termio --example terminfo`
+147
View File
@@ -0,0 +1,147 @@
# Raw-byte config parsing refactor
Scope: Ghostty 1.3.1, the nine remaining groups in `review-rv8-codex.md`, and
the config/CLI-to-consumer encoding sweep. No version change or commit.
## Design
`ArgIter` yields original byte vectors. `LineIterator` trims and splits bytes;
it never escape-encodes values. Process arguments use `collect_bytes`, including
arguments after `-e`. Replay and conditional replay retain byte vectors.
`CliTarget::parse_into_field`, generated `parse_field_bytes`, and
`ConfigField::parse_bytes` take `Option<&[u8]>`. Each field chooses its contract:
- Text/numeric/enum grammars validate UTF-8 at the field boundary and return
`InvalidValue` for invalid input. Selection delimiters decode codepoints from
original bytes and validate each unescaped UTF-8 sequence. Clipboard string
replacements are validated UTF-8, stored as ordinary Rust strings.
- Byte strings, paths, environment entries, commands, font family/style names,
binding payloads, key-table names, themes, readable input, and command-palette
labels retain their bytes in `EncodedOs`. Font variation identifiers retain
their four original bytes.
- Tagged unions, comma splitting, and automatic structs split ASCII syntax
without interpreting payload bytes. Quoted automatic-struct values use the
raw Zig string-literal decoder. `config/string.zig` parsing independently
copies unescaped bytes and UTF-8-encodes escaped codepoints, as upstream does.
- `parse_cli(&str)` and `parse_field(..., &str)` remain convenience APIs for
**plain UTF-8**. They forward `str::as_bytes`; they never decode OS escapes.
`EncodedOs` remains a distinct storage/OS-plumbing type. `from_bytes` and
`from_plain_text` are input constructors; `from_encoded` is reserved for
already encoded internal data. Runtime font, title, key-table, and command
consumers now carry the typed values to their final boundary. GTK display
conversion is explicit and separate from C callbacks and byte output.
The existing `fmt::Write` config formatter uses an internal encoded string.
Plain text is encoded exactly once when inserted; `+show-config` decodes once
to bytes at the writer. This preserves normal output and arbitrary byte-valued
fields. Action strings retain Zig's `std.zig.stringEscape` formatting, now
applied to the original bytes. Upstream escaping/reset/append semantics are
preserved; this does not promise that every formatted action or repeatable list
is an identity operation when loaded over existing defaults.
## RV8 finding → fix → regression coverage
| # | Fix | Tests |
|---|---|---|
| 1 | `EncodedOs::to_path` is fallible. Windows paths validate WTF-8 and report `BadPathName`; config files, themes, renderer assets and other path consumers propagate/handle the error. | `osbytes::tests::windows_paths_fail_with_bad_path_name_instead_of_replacement` |
| 2 | Windows application, cwd, argv, configured environment, and inherited environment use strict UTF-8 conversion before UTF-16/process creation. Surrogate encodings fail. | `windows_command_inputs_require_strict_utf8`; `windows_args::tests::inherited_environment_rejects_non_utf8`; Windows `env_overrides_keep_literal_fdd0_and_reject_surrogates` |
| 3 | WTF-8 validation accepts separately encoded surrogate pairs without requiring canonical re-encoding equality. The Kitty path conversion uses this corrected decoder. | `wtf8_accepts_separately_encoded_surrogate_pairs_like_zig` |
| 4 | Binding triggers parse original UTF-8, so literal U+FDD0 is one codepoint. Only action payloads and table names become typed byte storage. | `raw_bytes::literal_fdd0_is_a_single_unicode_trigger`, through both file and CLI readers |
| 5 | Selection delimiter codepoint iteration rejects invalid original UTF-8 with `InvalidValue`. | `raw_bytes::selection_word_chars_reject_invalid_utf8` |
| 6 | Clipboard replacements validate UTF-8 before storage; the consumer no longer decodes an escape representation or substitutes U+FFFD. | `raw_bytes::clipboard_replacements_require_utf8_and_keep_literal_fdd0`; clipboard plain-text consumer test |
| 7 | Font styles and codepoint-map families remain typed bytes through descriptors. Fontconfig receives original bytes; CoreText validates UTF-8 when constructing its CF strings. | `raw_bytes::font_style_and_codepoint_family_keep_original_bytes`; `discovery::tests::configured_font_names_reach_fontconfig_as_original_bytes` |
| 8 | Configured, dynamic and binding-action titles remain bytes through runtime actions and C string construction. | `c_title_actions_and_key_table_names_forward_original_bytes`; embedded integration `configured_and_binding_titles_reach_c_as_original_bytes` |
| 9 | New-window preserves valid original argument strings; incoming D-Bus text is plain UTF-8. Invalid UTF-8/interior-NUL arguments abort as Zig/GIO does, without untrusted-GVariant empty-string substitution. Flatpak environment construction reproduces GLib's whole-value `[Invalid UTF-8]` substitution. | `arguments_are_plain_dbus_strings_not_escape_encoded`; subprocess `invalid_dbus_strings_fail_without_substitution`; `glib_env_replaces_the_whole_invalid_value` |
## Additional sweep
Reviewed uses of `from_encoded`, `as_encoded`, `config_str_to_bytes`,
`config_str_to_text`, lossy UTF-8 conversion, config field implementations,
formatters, and downstream consumers across the workspace.
Additional fixes from this sweep:
- Raw command parsing, environment maps, initial input, and auto-struct quoted
payloads no longer see encoding artifacts.
- Key-table names, command-palette titles/descriptions and C name lengths
retain original bytes; command-palette sorting compares original bytes.
- Font feature tags pass original bytes to their backends. CoreText's ASCII
conversion is checked. Font variation tags accept any four bytes, and their
formatter no longer uses lossy UTF-8.
- Selection, clipboard, trigger and composed keybind formatting distinguish
plain text from internal encoded output, preventing double escaping.
- GTK diagnostics decode the formatted bytes before insertion and mirror
GTK's rejection of invalid UTF-8. The working-directory GObject property
renders plain text from typed byte storage, including in tab tooltips and
window subtitles. Theme preview display/search and theme-name sorting use
decoded text/original bytes. Logs render decoded text; Windows edit-config
output writes the original path bytes.
- Link regex strings are plain text and are no longer mistakenly decoded as
OS escapes. Clipboard replacement consumers likewise use validated text.
- C binding-action input and embedded initial input enter parsing as raw
bytes. Text/CSI/ESC output retains original payload bytes and byte lengths.
- Theme lookup carries typed paths without encode/decode detours or an empty
path fallback. Windows filesystem and process conversion contracts are
deliberately separate.
- Windows home expansion returns the original byte value, as Zig does. PATH
lookup and shell-integration resource paths use checked filesystem conversion.
Bash's extracted rcfile argument validates UTF-8 before entering a Windows
environment string. Windows-specific regressions cover these three paths.
- Byte-based trimming matches each Zig parser: space/tab for config syntax;
all Zig ASCII whitespace, including vertical tab, for working directories,
environment entries, and new-window options.
Remaining encoding uses are storage/output, diagnostics, existing command
execution plumbing, and explicitly tagged GTK clipboard transport.
They do not feed escape-encoded values to config field parsers. Lossy conversion
is confined to display APIs or legacy OS helpers; Windows path and process
operations use checked conversions.
A direct GLib/GIO C probe compared trusted and untrusted serialized strings:
untrusted invalid UTF-8 became an empty D-Bus string; Zig's trusted construction
aborted in `g_dbus_message_to_blob`. The Rust IPC path explicitly aborts for
these malformed strings before creating a variant, preserving that failure
without violating the Rust GLib binding's trusted-data safety contract.
Additional regressions cover raw byte fields/auto structs, whitespace,
four-byte variation tags, `+show-config` output/reload for both byte and text
fields, and action escaping from original bytes. Existing byte-path, shell,
clipboard, key-text and preedit tests remain in the workspace suite.
## Validation
All required gates passed on the completed refactor:
| Gate | Result |
|---|---|
| `timeout 2400 cargo test --workspace --no-fail-fast` | Exit 0; 3,428 passed, 0 failed, 7 ignored across reported test summaries |
| `cargo clippy --workspace --all-targets -- -D warnings` | Exit 0 |
| `cargo +nightly fmt --all` | Exit 0 |
| `just check-macos` | Exit 0 |
| `just check-macos x86_64-apple-darwin` | Exit 0 |
| `just check-windows` | Exit 0 |
| `just check-wasm` | Exit 0, including Node execution |
| `cargo build --bin ghostty` | Exit 0 |
| `git diff --check` | Exit 0 |
The exact required comparison,
`diff <(target/debug/ghostty +show-config --default --docs) <(ghostty +show-config --default --docs)`,
reports only line 3837: `gtk-opengl-debug = true` in Rust versus `false` in the
installed Ghostty binary. Its exit code is 1 for this single expected difference.
Eight differential `+show-config` probes against `/usr/bin/ghostty` match byte
for byte, each exiting 0: ordinary config; raw byte strings/font names;
literal U+FDD0 delimiters/replacements/triggers; rejected invalid UTF-8;
raw title/text/search actions; quoted automatic structs; environment ASCII
whitespace; and four-byte font variation tags. These probes explicitly set
`auto-update-channel = stable`: without this setting the pre-existing loaded
config default differs (`stable` in the port versus `tip` in the installed
reference). The required `--default --docs` comparison is unnormalized.
The macOS and Windows checks compile/lint platform-specific paths; they are
not native hardware execution. The WASM check also executes its Node host
smoke test. Windows conversion regressions execute as platform-independent
unit tests on Linux, with additional Windows-only tests cross-compiled. No new Zig tests were skipped
for these fixes; this refactor adapts existing ports and adds regressions.