Files
ghostty-rs/crates/ghostty-config/src/comma_splitter.rs
T
ddidderr 6f9b3129e1 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
2026-10-12 11:57:01 +02:00

365 lines
12 KiB
Rust

//! Iterator to split a string into fields by commas, taking into account
//! quotes and escapes.
//!
//! Supports the same escapes as in Zig literal strings.
//!
//! Quotes must begin and end with a double quote (`"`). It is an error to not
//! end a quote that was begun. To include a double quote inside a quote (or to
//! not have a double quote start a quoted section) escape it with a backslash.
//!
//! Single quotes (`'`) are not special, they do not begin a quoted block.
//!
//! Zig multiline string literals are NOT supported.
//!
//! Quotes and escapes are not stripped or decoded, that must be handled as a
//! separate step!
/// Errors the splitter can produce.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
UnclosedQuote,
UnfinishedEscape,
IllegalEscape,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Error::UnclosedQuote => "UnclosedQuote",
Error::UnfinishedEscape => "UnfinishedEscape",
Error::IllegalEscape => "IllegalEscape",
};
f.write_str(s)
}
}
impl std::error::Error for Error {}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Normal,
Quoted,
Escape,
HexEscape,
UnicodeEscape,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum UnicodeState {
Start,
Digits,
}
/// Splits a string on commas (see module docs).
#[derive(Debug, Clone)]
pub struct CommaSplitter<'a> {
/// the string that we are splitting
str: &'a [u8],
/// how much of the string has been consumed so far
index: usize,
}
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
#[must_use]
pub fn from_bytes(str: &'a [u8]) -> Self {
Self { str, index: 0 }
}
/// return the next field, `Ok(None)` if no more fields
pub fn next_bytes(&mut self) -> Result<Option<&'a [u8]>, Error> {
let bytes = self.str;
if self.index >= bytes.len() {
return Ok(None);
}
let start = self.index;
let mut state = State::Normal;
// state to return to when done processing an escape sequence
let mut last = State::Normal;
let mut hexescape_digits = 0usize;
let mut unicodeescape_state = UnicodeState::Start;
let mut unicodeescape_digits = 0usize;
let mut unicodeescape_value = 0usize;
loop {
match state {
State::Normal => {
if self.index >= bytes.len() {
return Ok(Some(&self.str[start..]));
}
match bytes[self.index] {
b',' => {
self.index += 1;
return Ok(Some(&self.str[start..self.index - 1]));
}
b'"' => {
self.index += 1;
state = State::Quoted;
}
b'\\' => {
self.index += 1;
last = State::Normal;
state = State::Escape;
}
_ => self.index += 1,
}
}
State::Quoted => {
if self.index >= bytes.len() {
return Err(Error::UnclosedQuote);
}
match bytes[self.index] {
b'"' => {
self.index += 1;
state = State::Normal;
}
b'\\' => {
self.index += 1;
last = State::Quoted;
state = State::Escape;
}
_ => self.index += 1,
}
}
State::Escape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
match bytes[self.index] {
b'n' | b'r' | b't' | b'\\' | b'\'' | b'"' => {
self.index += 1;
state = last;
}
b'x' => {
self.index += 1;
hexescape_digits = 0;
state = State::HexEscape;
}
b'u' => {
self.index += 1;
unicodeescape_state = UnicodeState::Start;
unicodeescape_digits = 0;
unicodeescape_value = 0;
state = State::UnicodeEscape;
}
_ => return Err(Error::IllegalEscape),
}
}
State::HexEscape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
if bytes[self.index].is_ascii_hexdigit() {
self.index += 1;
hexescape_digits += 1;
if hexescape_digits == 2 {
state = last;
}
} else {
return Err(Error::IllegalEscape);
}
}
State::UnicodeEscape => {
if self.index >= bytes.len() {
return Err(Error::UnfinishedEscape);
}
match unicodeescape_state {
UnicodeState::Start => {
if bytes[self.index] == b'{' {
self.index += 1;
unicodeescape_value = 0;
unicodeescape_state = UnicodeState::Digits;
} else {
return Err(Error::IllegalEscape);
}
}
UnicodeState::Digits => {
let c = bytes[self.index];
// Note: the Zig implementation does not add 10 for
// the a-f digits; this is preserved.
let d = match c {
b'}' => {
self.index += 1;
if unicodeescape_digits == 0 {
return Err(Error::IllegalEscape);
}
state = last;
continue;
}
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a',
b'A'..=b'F' => c - b'A',
_ => return Err(Error::IllegalEscape),
};
self.index += 1;
unicodeescape_digits += 1;
unicodeescape_value <<= 4;
unicodeescape_value += usize::from(d);
if unicodeescape_value > 0x10ffff {
return Err(Error::IllegalEscape);
}
}
}
}
}
}
}
/// Return any remaining string data, whether it has a comma or not.
pub fn rest(&mut self) -> Option<&'a str> {
if self.index >= self.str.len() {
return None;
}
let r = &self.str[self.index..];
self.index = self.str.len();
std::str::from_utf8(r).ok()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn all(s: &str) -> Result<Vec<&str>, Error> {
let mut sp = CommaSplitter::new(s);
let mut v = vec![];
while let Some(x) = sp.next_field()? {
v.push(x);
}
Ok(v)
}
fn first_err(s: &str) -> Error {
CommaSplitter::new(s).next_field().unwrap_err()
}
#[test]
fn splitter_1() {
assert_eq!(all("a,b,c").unwrap(), ["a", "b", "c"]);
}
#[test]
fn splitter_2() {
assert_eq!(all("").unwrap(), Vec::<&str>::new());
}
#[test]
fn splitter_3() {
assert_eq!(all("a").unwrap(), ["a"]);
}
#[test]
fn splitter_4() {
assert_eq!(all("\\x5a").unwrap(), ["\\x5a"]);
}
#[test]
fn splitter_5() {
assert_eq!(all("'a',b").unwrap(), ["'a'", "b"]);
}
#[test]
fn splitter_6() {
assert_eq!(all("'a,b',c").unwrap(), ["'a", "b'", "c"]);
}
#[test]
fn splitter_7() {
assert_eq!(all("\"a,b\",c").unwrap(), ["\"a,b\"", "c"]);
}
#[test]
fn splitter_8() {
assert_eq!(all(" a , b ").unwrap(), [" a ", " b "]);
}
#[test]
fn splitter_9() {
assert_eq!(first_err("\\x"), Error::UnfinishedEscape);
}
#[test]
fn splitter_10() {
assert_eq!(first_err("\\x5"), Error::UnfinishedEscape);
}
#[test]
fn splitter_11() {
assert_eq!(first_err("\\u"), Error::UnfinishedEscape);
}
#[test]
fn splitter_12() {
assert_eq!(first_err("\\u{"), Error::UnfinishedEscape);
}
#[test]
fn splitter_13() {
assert_eq!(first_err("\\u{}"), Error::IllegalEscape);
}
#[test]
fn splitter_14() {
assert_eq!(first_err("\\u{h1}"), Error::IllegalEscape);
}
#[test]
fn splitter_15() {
assert_eq!(all("\\u{10ffff}").unwrap(), ["\\u{10ffff}"]);
}
#[test]
fn splitter_16() {
assert_eq!(first_err("\\u{110000}"), Error::IllegalEscape);
}
#[test]
fn splitter_17() {
assert_eq!(first_err("\\d"), Error::IllegalEscape);
}
#[test]
fn splitter_18() {
assert_eq!(
all("\\n\\r\\t\\\"\\'\\\\").unwrap(),
["\\n\\r\\t\\\"\\'\\\\"]
);
}
#[test]
fn splitter_19() {
assert_eq!(all("\"abc'def'ghi\"").unwrap(), ["\"abc'def'ghi\""]);
}
#[test]
fn splitter_20() {
assert_eq!(all("\",\",abc").unwrap(), ["\",\"", "abc"]);
}
#[test]
fn splitter_21() {
assert_eq!(all("'a','b', 'c'").unwrap(), ["'a'", "'b'", " 'c'"]);
}
#[test]
fn splitter_22() {
assert_eq!(first_err("abc\"def"), Error::UnclosedQuote);
}
#[test]
fn splitter_23() {
assert_eq!(
all("title:\"Focus Split: Up\",description:\"Focus the split above, if it exists.\",action:goto_split:up").unwrap(),
[
"title:\"Focus Split: Up\"",
"description:\"Focus the split above, if it exists.\"",
"action:goto_split:up"
]
);
}
#[test]
fn splitter_24() {
let mut s = CommaSplitter::new("a,b,c,def");
assert_eq!(s.next_field().unwrap(), Some("a"));
assert_eq!(s.next_field().unwrap(), Some("b"));
assert_eq!(s.rest(), Some("c,def"));
assert_eq!(s.next_field().unwrap(), None);
}
#[test]
fn splitter_25() {
let mut s = CommaSplitter::new("a,\\u{10,df}");
assert_eq!(s.next_field().unwrap(), Some("a"));
assert_eq!(s.next_field(), Err(Error::IllegalEscape));
}
}