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 70c250cf2d
commit 6f9b3129e1
100 files changed
+2310 -1054

No files matched your search

+13 -5
View File
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
/// Detect the action from the process CLI args (Zig: `detectArgs`).
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
@@ -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>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
S: AsRef<[u8]>,
{
let mut fallback: Option<E> = None;
let mut pending: Option<E> = None;
@@ -69,7 +69,10 @@ where
let arg = arg.as_ref();
// 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 {
SpecialCase::Action(a) => return Ok(Some(a)),
SpecialCase::Fallback(a) => fallback = Some(a),
@@ -82,13 +85,18 @@ where
}
// Commands must start with "+"
let Some(name) = arg.strip_prefix('+') else {
let Some(name) = arg.strip_prefix(b"+") else {
continue;
};
if pending.is_some() {
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
+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
pub fn run(args: &[String]) -> Result<u8, Error> {
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
// Disable on non-desktop systems.
if cfg!(not(any(
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
/// 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;
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`,
/// if both are set. If neither are set, it will print an error
/// 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 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.
#[cfg(windows)]
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
write!(
stderr,
let message = format!(
"The `ghostty +edit-config` command is not supported on Windows.\n\
Please edit the configuration file manually at the following path:\n\
\n\
{}\n",
path.as_encoded()
)?;
);
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&message))?;
Ok(1)
}
+10 -13
View File
@@ -158,7 +158,7 @@ impl Action {
///
/// If the action requests its help text (`--help`/`-h`), the help text
/// 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) {
Err(Error::ActionHelpRequested) => {
// 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 {
Action::Version => crate::version::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).
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())
}
@@ -248,21 +248,21 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
/// layer (`cli_args`) decodes back to the original bytes.
#[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()
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
.map(|a| ghostty_os::osbytes::os_bytes(a))
.collect()
}
/// [`detect`] over raw OS arguments.
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).
#[must_use]
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
@@ -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
/// terminal), or the exit code the process should terminate with.
#[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) {
Ok(a) => a?,
Err(err) => {
@@ -370,11 +370,8 @@ mod tests {
std::ffi::OsString::from("+version"),
];
assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
let strings = os_args_to_strings(&args);
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&strings[1]),
b"--title=\xff"
);
let strings = os_args_to_bytes(&args);
assert_eq!(strings[1], b"--title=\xff");
}
#[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
/// `+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();
crate::parse_options(&mut opts, args)?;
+1 -1
View File
@@ -22,7 +22,7 @@ cli_options!(Options, help);
/// Flags:
///
/// * `--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();
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
/// 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();
crate::parse_options(&mut opts, args)?;
+3 -3
View File
@@ -8,10 +8,10 @@ config_struct! {
pub struct Options {
/// The font family to search for. If this is set, then only fonts
/// 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.
"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
/// 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
/// 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.
pub fn run(args: &[String]) -> Result<u8, Error> {
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
let mut opts = Options::default();
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
/// 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();
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 {
let (mut table_bindings, table_width) = iterate_bindings(table_set);
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);
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.
+36 -14
View File
@@ -62,11 +62,21 @@ impl ThemeListElement {
/// TODO: use Unicode-aware comparison (as in Zig).
#[must_use]
pub fn order(&self, other: &ThemeListElement) -> Ordering {
let a = self.theme.bytes().map(|c| c.to_ascii_lowercase());
let b = other.theme.bytes().map(|c| c.to_ascii_lowercase());
let a = config_str_to_bytes(&self.theme)
.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)
}
/// 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`).
#[must_use]
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 it = LocationIterator::new(resources_dir);
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,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
eprintln!("error trying to open {}: {e}", dir.as_encoded());
eprintln!("error trying to open {}: {e}", dir.to_text_lossy());
continue;
}
};
@@ -145,7 +155,7 @@ pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement
continue;
}
themes.push(ThemeListElement {
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)),
path: dir.join(&EncodedOs::from_os(&raw_name)),
location,
theme: name,
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).
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> {
@@ -216,7 +228,7 @@ fn resources_dir() -> Option<EncodedOs> {
///
/// * `--color`: Specify the color scheme of the themes included in the list.
/// 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();
crate::parse_options(&mut opts, args)?;
@@ -316,12 +328,26 @@ mod tests {
assert_eq!(1, themes.len());
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.
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();
write_plain(&mut out, &themes, &Options::default()).unwrap();
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]
fn collect_sort_and_filter() {
let tmp = theme_dir(&[
@@ -407,13 +433,9 @@ mod tests {
#[test]
fn options_color() {
let mut opts = Options::default();
crate::parse_options(
&mut opts,
&["g".into(), "--color=dark".into(), "--path".into()],
)
.unwrap();
crate::parse_options(&mut opts, &["g", "--color=dark", "--path"]).unwrap();
assert_eq!(ColorScheme::Dark, opts.color);
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;
}
if searching {
theme.rank = rank(&theme.theme, &tokens);
theme.rank = rank(&theme.display_name(), &tokens);
if theme.rank.is_some() {
self.filtered.push(i);
}
@@ -771,7 +771,7 @@ impl Preview {
theme_list.print("\u{276f} ", self.ui_selected(), row, 0);
}
theme_list.print_ex(
&theme.theme,
&theme.display_name(),
match style {
St::Normal => self.ui_standard(),
St::Highlighted => self.ui_highlighted(),
@@ -783,7 +783,7 @@ impl Preview {
None,
);
if style == St::Selected {
let name_len = gw(&theme.theme);
let name_len = gw(&theme.display_name());
if name_len + 4 < LIST_WIDTH {
for i in (2 + name_len)..(LIST_WIDTH - 2) {
theme_list.print(" ", self.ui_selected(), row, i);
@@ -869,7 +869,7 @@ impl Preview {
height,
self.ui_standard(),
);
let theme_line = format!("theme = {}", theme.theme);
let theme_line = format!("theme = {}", theme.display_name());
let save_instructions: [&str; 14] = [
"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);
child.fill(ui_std);
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(
&text,
ui_err,
@@ -966,10 +966,10 @@ impl Preview {
(child.area.width / 2).saturating_sub(gw(&text) / 2),
);
child.print_ex(
theme.path.as_encoded(),
&theme.path.to_text_lossy(),
ui_err,
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()),
None,
);
@@ -1017,18 +1017,18 @@ impl Preview {
child.fill(standard);
let uri = theme.to_uri();
child.print_ex(
&theme.theme,
&theme.display_name(),
standard_bold_italic,
1,
(child.area.width / 2).saturating_sub(gw(&theme.theme) / 2),
(child.area.width / 2).saturating_sub(gw(&theme.display_name()) / 2),
Some(&uri),
None,
);
child.print_ex(
theme.path.as_encoded(),
&theme.path.to_text_lossy(),
standard,
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),
None,
);
@@ -1048,7 +1048,7 @@ impl Preview {
);
for (i, diag) in config.diagnostics.items().iter().enumerate() {
child.print(
&diag.to_string(),
&ghostty_os::osbytes::config_str_to_text(&diag.to_string()),
ui_err,
2 + u16::try_from(i).unwrap_or(0),
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> {
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
.map_err(|e| Error::Other(e.to_string()))?
.to_path();
.to_path()?;
let base_dir = main_cfg_path
.parent()
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
@@ -1957,6 +1957,21 @@ mod tests {
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]
fn draw_layout() {
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
/// for processing.
pub arguments: Vec<String>,
}
fn is_ascii_ws(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
pub arguments: Vec<Vec<u8>>,
}
impl Options {
fn check_arg(&mut self, arg: &str) -> Result<Option<String>, ArgError> {
if let Some(rest) = arg.strip_prefix("--class=") {
self.class = Some(rest.trim_matches(is_ascii_ws).to_string());
fn check_arg(&mut self, arg: &[u8]) -> Result<Option<Vec<u8>>, ArgError> {
if let Some(rest) = arg.strip_prefix(b"--class=") {
self.class = Some(
std::str::from_utf8(ghostty_config::cli_args::trim_ascii_whitespace(rest))
.map_err(|_| ArgError::InvalidValue)?
.to_owned(),
);
return Ok(None);
}
if let Some(rest) = arg.strip_prefix("--working-directory=") {
let stripped = rest.trim_matches(is_ascii_ws);
if stripped == "home" || stripped == "inherit" {
return Ok(Some(arg.to_string()));
if let Some(rest) = arg.strip_prefix(b"--working-directory=") {
let stripped = ghostty_config::cli_args::trim_ascii_whitespace(rest);
if stripped == b"home" || stripped == b"inherit" {
return Ok(Some(arg.to_vec()));
}
// The CLI argument is an encoded OS string.
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped))
// Preserve the original argument bytes while expanding its path.
let expanded = ghostty_os::expand_home(&EncodedOs::from_bytes(stripped))
.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()))?;
self.working_directory_seen = true;
return Ok(Some(format!(
"--working-directory={}",
EncodedOs::from_path(&realpath).as_encoded()
)));
return Ok(Some(
[
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 {
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)
}
/// 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> {
let mut e_seen = arg == "-e";
fn parse_manually_hook(
&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.
if let Some(a) = self.check_arg(arg)? {
@@ -93,7 +102,7 @@ impl CliTarget for Options {
self.arguments.push(param);
continue;
}
if param == "-e" {
if param == b"-e" {
e_seen = true;
self.arguments.push(param);
continue;
@@ -177,9 +186,10 @@ impl CliTarget for Options {
/// default command.
///
/// 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 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) {
Ok(()) => {}
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(".")?;
opts.arguments.insert(
0,
format!(
"--working-directory={}",
ghostty_os::osbytes::path_to_config_string(&wd)
),
[
b"--working-directory=".as_slice(),
&EncodedOs::from_path(&wd).to_bytes(),
]
.concat(),
);
}
@@ -259,7 +270,13 @@ mod tests {
fn gathers_arguments_and_class() {
let o = parse_str("--class=foo.bar --title=hi --command=ls");
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);
}
@@ -275,6 +292,9 @@ mod tests {
"--working-directory=/nonexistent"
],
o.arguments
.iter()
.map(|v| std::str::from_utf8(v).unwrap())
.collect::<Vec<_>>()
);
}
@@ -282,17 +302,35 @@ mod tests {
fn working_directory_is_resolved() {
let o = parse_str("--working-directory=/");
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");
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]
fn help_is_gathered_not_special() {
// Like Zig, the manual hook runs before the help check.
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]
+9 -9
View File
@@ -15,9 +15,9 @@ macro_rules! cli_options {
fn parse_into_field(
&mut self,
key: &str,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::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]> {
Self::valid_values_for(key)
@@ -43,9 +43,9 @@ macro_rules! cli_options {
fn parse_into_field(
&mut self,
key: &str,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::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]> {
Self::valid_values_for(key)
@@ -66,9 +66,9 @@ macro_rules! cli_options {
fn parse_into_field(
&mut self,
key: &str,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::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]> {
Self::valid_values_for(key)
@@ -87,8 +87,8 @@ macro_rules! cli_options {
/// Parse the CLI arguments `args` (the full argv, including `argv[0]`,
/// which is skipped; `+action` arguments are ignored) into `opts`
/// (Zig: `args.argsIterator` + `args.parse`).
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[String]) -> Result<(), Error> {
let mut iter = ArgsIterator::new(args.iter().skip(1).cloned());
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[impl AsRef<[u8]>]) -> Result<(), Error> {
let mut iter = ArgsIterator::new(args.iter().skip(1).map(AsRef::as_ref));
cli_args::parse(opts, &mut iter)?;
Ok(())
}
@@ -101,7 +101,7 @@ impl CliTarget for NoOptions {
fn parse_into_field(
&mut self,
_key: &str,
_value: Option<&str>,
_value: Option<&[u8]>,
) -> Result<(), cli_args::ArgError> {
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,
/// This is very noisy but is very useful to learn about available
/// 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();
crate::parse_options(&mut opts, args)?;
@@ -113,6 +113,81 @@ mod tests {
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)]
#[test]
fn show_config_round_trips_non_utf8_and_fdd0_paths() {
@@ -152,7 +227,7 @@ mod tests {
assert!(opts.default);
assert!(!opts.changes_only);
assert!(matches!(
crate::parse_options(&mut opts, &["g".into(), "--help".into()]),
crate::parse_options(&mut opts, &["g", "--help"]),
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
/// style. Valid options are `text` and `emoji`. If unset, the presentation
/// 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();
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 --clear # Clear entire cache
/// 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();
crate::parse_options(&mut opts, args)?;
@@ -59,7 +59,7 @@ impl DiskCache {
home: None,
})
.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.
+7 -7
View File
@@ -11,7 +11,7 @@ config_struct! {
pub struct Options {
/// The path of the config file to validate. If this isn't specified,
/// 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);
@@ -25,7 +25,7 @@ cli_options!(Options, help);
///
/// * `--config-file`: can be passed to validate a specific target config file in
/// 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();
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 {
// Byte-preserving: the option string carries non-UTF-8 bytes via the
// 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_recursive_files()?;
} else {
@@ -83,7 +83,7 @@ mod tests {
fn validate_good_file() {
let p = write_tmp("good", "font-size = 14\n");
let opts = Options {
config_file: Some(p),
config_file: Some(p.into()),
};
let mut out = Vec::new();
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
@@ -94,7 +94,7 @@ mod tests {
fn validate_bad_file_prints_diagnostics() {
let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n");
let opts = Options {
config_file: Some(p.clone()),
config_file: Some(p.clone().into()),
};
let mut out = Vec::new();
assert_eq!(1, run_inner(&opts, &mut out).unwrap());
@@ -117,7 +117,7 @@ mod tests {
let path = tmp.path().join("config");
std::fs::write(&path, b"bad\xffkey = 1\n").unwrap();
let opts = Options {
config_file: Some(path.to_string_lossy().into_owned()),
config_file: Some(EncodedOs::from_path(&path)),
};
let mut out = Vec::new();
assert_eq!(1, run_inner(&opts, &mut out).unwrap());
@@ -148,7 +148,7 @@ mod tests {
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let opts = Options {
config_file: Some(link.to_string_lossy().into_owned()),
config_file: Some(EncodedOs::from_path(&link)),
};
let mut out = Vec::new();
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
/// 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 tty = stdout.is_terminal();
let mut out = std::io::BufWriter::new(stdout.lock());