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:
1 parent
70c250cf2d
commit
6f9b3129e1
100 files changed
+2310
-1054
No files matched your search
@@ -8,6 +8,8 @@ use std::{
|
||||
hash::{Hash, Hasher},
|
||||
};
|
||||
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use super::Error;
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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;
|
||||
for &b in s.as_bytes() {
|
||||
for &b in s {
|
||||
match b {
|
||||
b'\n' => out.push_str("\\n"),
|
||||
b'\r' => out.push_str("\\r"),
|
||||
@@ -353,18 +355,37 @@ impl Action {
|
||||
/// action name and value is the action parameter. The parameter
|
||||
/// is optional depending on the action.
|
||||
pub fn parse(input: &str) -> Result<Action, Error> {
|
||||
// Split our action by colon. A colon may not exist for some
|
||||
// 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())];
|
||||
Self::parse_bytes(input.as_bytes())
|
||||
}
|
||||
|
||||
// An action name is always required
|
||||
if action.is_empty() {
|
||||
/// Parse raw action bytes. Only enum/numeric parameters require UTF-8;
|
||||
/// 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);
|
||||
}
|
||||
|
||||
Action::parse_named(action, colon_idx.map(|i| &input[i + 1..]))
|
||||
let param = 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`).
|
||||
@@ -423,15 +444,15 @@ pub enum Action {
|
||||
/// `\x1b[`).
|
||||
///
|
||||
/// For example, `csi:0m` can be sent to reset all styles of the current text.
|
||||
Csi(String),
|
||||
Csi(EncodedOs),
|
||||
/// Send an `ESC` sequence.
|
||||
Esc(String),
|
||||
Esc(EncodedOs),
|
||||
/// Send the specified text.
|
||||
///
|
||||
/// Uses Zig string literal syntax. This is currently not validated.
|
||||
/// If the text is invalid (i.e. contains an invalid escape sequence),
|
||||
/// 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
|
||||
/// (`application`) or disabled (`normal`).
|
||||
CursorKey(CursorKey),
|
||||
@@ -478,7 +499,7 @@ pub enum Action {
|
||||
/// should be used.
|
||||
///
|
||||
/// 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
|
||||
/// selection, this does nothing. If a search is already active, this
|
||||
/// changes the search terms.
|
||||
@@ -639,11 +660,11 @@ pub enum Action {
|
||||
/// Set the title for the current focused surface.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// If the title is empty, the tab title override is cleared.
|
||||
SetTabTitle(String),
|
||||
SetTabTitle(EncodedOs),
|
||||
/// Create a new split in the specified direction.
|
||||
///
|
||||
/// Valid arguments:
|
||||
@@ -912,7 +933,7 @@ pub enum Action {
|
||||
///
|
||||
/// If the named key table is already the currently active key table,
|
||||
/// 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
|
||||
/// until the first valid keybinding from that table is used (including
|
||||
/// any defined `catch_all` bindings).
|
||||
@@ -921,7 +942,7 @@ pub enum Action {
|
||||
/// key table. If another key table is activated later, then this
|
||||
/// table will remain active until it pops back out to being the
|
||||
/// active key table.
|
||||
ActivateKeyTableOnce(String),
|
||||
ActivateKeyTableOnce(EncodedOs),
|
||||
/// Deactivate the currently active key table, if any. The next most
|
||||
/// recently activated key table (if any) will become active again.
|
||||
/// If no key table is active, this action has no effect.
|
||||
@@ -1353,15 +1374,15 @@ impl Action {
|
||||
}
|
||||
"csi" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::Csi(p.to_string()))
|
||||
Ok(Action::Csi(p.into()))
|
||||
}
|
||||
"esc" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::Esc(p.to_string()))
|
||||
Ok(Action::Esc(p.into()))
|
||||
}
|
||||
"text" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::Text(p.to_string()))
|
||||
Ok(Action::Text(p.into()))
|
||||
}
|
||||
"cursor_key" => Err(Error::InvalidAction),
|
||||
"reset" => {
|
||||
@@ -1426,7 +1447,7 @@ impl Action {
|
||||
}
|
||||
"search" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::Search(p.to_string()))
|
||||
Ok(Action::Search(p.into()))
|
||||
}
|
||||
"search_selection" => {
|
||||
if param.is_some() {
|
||||
@@ -1604,11 +1625,11 @@ impl Action {
|
||||
}
|
||||
"set_surface_title" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::SetSurfaceTitle(p.to_string()))
|
||||
Ok(Action::SetSurfaceTitle(p.into()))
|
||||
}
|
||||
"set_tab_title" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::SetTabTitle(p.to_string()))
|
||||
Ok(Action::SetTabTitle(p.into()))
|
||||
}
|
||||
"new_split" => {
|
||||
let Some(p) = param else {
|
||||
@@ -1798,11 +1819,11 @@ impl Action {
|
||||
}
|
||||
"activate_key_table" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::ActivateKeyTable(p.to_string()))
|
||||
Ok(Action::ActivateKeyTable(p.into()))
|
||||
}
|
||||
"activate_key_table_once" => {
|
||||
let p = param.ok_or(Error::InvalidFormat)?;
|
||||
Ok(Action::ActivateKeyTableOnce(p.to_string()))
|
||||
Ok(Action::ActivateKeyTableOnce(p.into()))
|
||||
}
|
||||
"deactivate_key_table" => {
|
||||
if param.is_some() {
|
||||
@@ -1892,15 +1913,15 @@ impl Action {
|
||||
| Action::Quit => {}
|
||||
Action::Csi(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::Esc(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::Text(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::CursorKey(_) => panic!("formatting not supported"),
|
||||
Action::CopyToClipboard(v) => {
|
||||
@@ -1921,7 +1942,7 @@ impl Action {
|
||||
}
|
||||
Action::Search(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::NavigateSearch(v) => {
|
||||
out.push(':');
|
||||
@@ -1969,11 +1990,11 @@ impl Action {
|
||||
}
|
||||
Action::SetSurfaceTitle(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::SetTabTitle(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::NewSplit(v) => {
|
||||
out.push(':');
|
||||
@@ -2001,11 +2022,11 @@ impl Action {
|
||||
}
|
||||
Action::ActivateKeyTable(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::ActivateKeyTableOnce(v) => {
|
||||
out.push(':');
|
||||
string_escape(v, out);
|
||||
string_escape(&v.to_bytes(), out);
|
||||
}
|
||||
Action::Crash(v) => {
|
||||
out.push(':');
|
||||
|
||||
@@ -129,7 +129,11 @@ pub struct Parser<'a> {
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
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..];
|
||||
|
||||
// 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
|
||||
// action after. The loop below is a total mess.
|
||||
let mut offset = 0usize;
|
||||
let bytes = input.as_bytes();
|
||||
while let Some(offset_idx) = input[offset..].find('=') {
|
||||
let bytes = input;
|
||||
while let Some(offset_idx) = input[offset..].iter().position(|b| *b == b'=') {
|
||||
// Find: '=+ctrl' or '==action'
|
||||
let idx = offset + offset_idx;
|
||||
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.
|
||||
let chain = &input[..eql_idx] == "chain";
|
||||
let chain = &input[..eql_idx] == b"chain";
|
||||
if chain && start_idx > 0 {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
@@ -169,10 +173,14 @@ impl<'a> Parser<'a> {
|
||||
// This is kind of hacky but we put a dummy trigger
|
||||
// for chained inputs. The `next` will never yield this
|
||||
// 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,
|
||||
},
|
||||
action: Action::parse(&input[eql_idx + 1..])?,
|
||||
action: Action::parse_bytes(&input[eql_idx + 1..])?,
|
||||
flags,
|
||||
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 start_idx = 0usize;
|
||||
let mut input = raw_input;
|
||||
// Find the next prefix
|
||||
while let Some(idx) = input.find(':') {
|
||||
while let Some(idx) = input.iter().position(|b| *b == b':') {
|
||||
let prefix = &input[..idx];
|
||||
|
||||
// If the prefix is one of our flags then set it.
|
||||
match prefix {
|
||||
"all" => {
|
||||
b"all" => {
|
||||
if flags.all {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
flags.all = true;
|
||||
}
|
||||
"global" => {
|
||||
b"global" => {
|
||||
if flags.global {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
flags.global = true;
|
||||
}
|
||||
"unconsumed" => {
|
||||
b"unconsumed" => {
|
||||
if !flags.consumed {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
flags.consumed = false;
|
||||
}
|
||||
"performable" => {
|
||||
b"performable" => {
|
||||
if flags.performable {
|
||||
return Err(Error::InvalidFormat);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,11 @@ impl Value {
|
||||
let pos = buffer.len();
|
||||
for (k, v) in &set.bindings {
|
||||
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);
|
||||
}
|
||||
buffer.truncate(pos);
|
||||
@@ -285,9 +289,13 @@ impl Set {
|
||||
///
|
||||
/// Any Error returned is validated before any set modifications are made.
|
||||
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
|
||||
// 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() {}
|
||||
it.reset();
|
||||
|
||||
|
||||
@@ -824,7 +824,7 @@ fn set_clone_with_leaf_chained_containing_allocated_data() {
|
||||
) else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert_ne!(orig.as_ptr(), copy.as_ptr());
|
||||
assert_ne!(orig.as_encoded().as_ptr(), copy.as_encoded().as_ptr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -31,16 +31,16 @@ use crate::binding::{
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Command {
|
||||
pub action: Action,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub title: ghostty_os::osbytes::EncodedOs,
|
||||
pub description: ghostty_os::osbytes::EncodedOs,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(action: Action, title: &str, description: &str) -> Command {
|
||||
Command {
|
||||
action,
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
title: title.into(),
|
||||
description: description.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +75,17 @@ impl Command {
|
||||
pub struct CommandC {
|
||||
pub action_key: String,
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub title: ghostty_os::osbytes::EncodedOs,
|
||||
pub description: ghostty_os::osbytes::EncodedOs,
|
||||
}
|
||||
|
||||
/// `std.ascii.orderIgnoreCase`
|
||||
fn cmp_ignore_case(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let a = a.bytes().map(|c| c.to_ascii_lowercase());
|
||||
let b = b.bytes().map(|c| c.to_ascii_lowercase());
|
||||
fn cmp_ignore_case(
|
||||
a: &ghostty_os::osbytes::EncodedOs,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -562,7 +565,7 @@ fn default_commands() -> Vec<Command> {
|
||||
Command::new(Action::Redo, "Redo", "Redo the last undone action."),
|
||||
Command::new(Action::Quit, "Quit", "Quit the application."),
|
||||
Command::new(
|
||||
Action::Text("👻".to_string()),
|
||||
Action::Text("👻".into()),
|
||||
"Ghostty",
|
||||
"Put a little Ghostty in your terminal.",
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user