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

+234 -135
View File
@@ -30,6 +30,39 @@ pub fn trim_ws(s: &str) -> &str {
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
/// to type-specific parsing but these are always possible.
#[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
/// `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
/// 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
/// (Zig: `parseIntoField`). Returns `ArgError::InvalidField` if the key
/// 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).
fn valid_values(&self, _key: &str) -> Option<&'static [&'static str]> {
@@ -159,7 +197,7 @@ pub trait CliTarget {
/// stop parsing.
fn parse_manually_hook(
&mut self,
_arg: &str,
_arg: &[u8],
_iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> {
Ok(true)
@@ -177,14 +215,14 @@ pub trait CliTarget {
/// Compatibility handler (Zig: `compatibility` decl). Called when
/// 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
}
}
/// An iterator over arguments (Zig: any iterator with `next`).
pub trait ArgIter {
fn next_arg(&mut self) -> Option<String>;
fn next_arg(&mut self) -> Option<Vec<u8>>;
/// Location for diagnostics.
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
// 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()?;
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
// flag. We don't support positional arguments or configuration
// 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 Some(diags) = dst.diagnostics_mut() else {
return Err(ArgError::InvalidField);
};
diags.append(Diagnostic {
key: arg.clone(),
key: ghostty_os::osbytes::bytes_to_config_string(&arg),
message: "invalid field".to_string(),
location: loc,
});
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..])),
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) {
// Fall back to compatibility handlers if able.
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 => {
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(),
};
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>(
dst: &mut T,
to: &str,
value: Option<&str>,
value: Option<&[u8]>,
) -> bool {
match dst.parse_into_field(to, value) {
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`).
///
/// Entries are split by commas (with quoting / escaping, see
@@ -320,21 +372,39 @@ pub fn parse_auto_struct_with(
v: &str,
mut set: impl FnMut(&str, &str) -> Result<bool, ArgError>,
) -> Result<(), ArgError> {
let mut iter = CommaSplitter::new(v);
while let Some(entry) = iter.next_field()? {
let idx = entry.find(':').ok_or(ArgError::InvalidValue)?;
let key = trim_ws(&entry[..idx]);
let raw = trim_ws(&entry[idx + 1..]);
parse_auto_struct_bytes_with(v.as_bytes(), |k, v| {
set(
k,
std::str::from_utf8(v).map_err(|_| ArgError::InvalidValue)?,
)
})
}
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 value = if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
decoded = parse_zig_string_literal(raw).ok_or(ArgError::InvalidValue)?;
decoded.as_str()
let value = if raw.len() >= 2 && raw.starts_with(b"\"") && raw.ends_with(b"\"") {
decoded = parse_zig_string_literal_bytes(raw).ok_or(ArgError::InvalidValue)?;
decoded.as_slice()
} else {
raw
};
if !set(key, value)? {
if !set(
std::str::from_utf8(key).map_err(|_| ArgError::InvalidValue)?,
value,
)? {
return Err(ArgError::InvalidValue);
}
}
@@ -342,22 +412,18 @@ pub fn parse_auto_struct_with(
}
/// Decode a double-quoted Zig string literal (including the quotes).
/// Returns `None` on failure. Bytes produced by `\x` escapes that do not form
/// valid UTF-8 stay raw bytes: they are carried through the reversible
/// `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.
/// Returns `None` on failure or invalid UTF-8. Byte fields use
/// [`parse_zig_string_literal_bytes`] to retain arbitrary literal and escaped bytes.
#[must_use]
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`
/// decoding yields a `[]u8`).
#[must_use]
pub fn parse_zig_string_literal_bytes(s: &str) -> Option<Vec<u8>> {
let b = ghostty_os::osbytes::config_str_to_bytes(s);
let b = b.as_slice();
pub fn parse_zig_string_literal_bytes(s: impl AsRef<[u8]>) -> Option<Vec<u8>> {
let b = s.as_ref();
if b.len() < 2 || b[0] != b'"' || b[b.len() - 1] != b'"' {
return None;
}
@@ -494,7 +560,8 @@ impl ConfigField for bool {
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"))?;
Ok(())
}
@@ -512,7 +579,8 @@ macro_rules! impl_int_field {
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 n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?;
*self = <$t>::try_from(n).map_err(|_| ArgError::InvalidValue)?;
@@ -546,7 +614,8 @@ macro_rules! impl_float_field {
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)?;
*self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?;
Ok(())
@@ -562,20 +631,44 @@ impl_float_field!(f32 => CValue::F32, f64 => CValue::F64);
impl ConfigField for String {
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 {
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();
Ok(())
}
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.
if let Some(v) = self {
v.parse_cli(value)
v.parse_bytes(value)
} else {
let mut tmp = T::default();
tmp.parse_cli(value)?;
tmp.parse_bytes(value)?;
*self = Some(tmp);
Ok(())
}
@@ -622,7 +715,7 @@ impl<T: ConfigField> ConfigField for Option<T> {
///
/// This also ignores any argument that starts with `+`. It assumes that
/// 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.
pub iterator: I,
/// 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,
}
impl<I: Iterator<Item = String>> ArgsIterator<I> {
impl<I: Iterator> ArgsIterator<I>
where
I::Item: AsRef<[u8]>,
{
pub fn new(iterator: I) -> Self {
Self { iterator, index: 0 }
}
pub fn next(&mut self) -> Option<String> {
pub fn next(&mut self) -> Option<Vec<u8>> {
loop {
let value = self.iterator.next()?;
self.index += 1;
// We ignore any argument that starts with "+". This is used
// to indicate actions and are expected to be parsed out before
// this iterator is created.
if value.starts_with('+') {
if value.as_ref().starts_with(b"+") {
continue;
}
return Some(value);
return Some(value.as_ref().to_vec());
}
}
}
impl<I: Iterator<Item = String>> ArgIter for ArgsIterator<I> {
fn next_arg(&mut self) -> Option<String> {
impl<I: Iterator> ArgIter for ArgsIterator<I>
where
I::Item: AsRef<[u8]>,
{
fn next_arg(&mut self) -> Option<Vec<u8>> {
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.
#[must_use]
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<String>>> {
// Byte-safe: non-UTF-8 arguments keep their bytes (reversible escape
// encoding, see `ghostty_os::osbytes`).
ArgsIterator::new(
ghostty_os::args::collect_config_strings()
.into_iter()
.skip(1),
)
pub fn args_iterator() -> ArgsIterator<std::iter::Skip<std::vec::IntoIter<Vec<u8>>>> {
// Keep original bytes until each field chooses whether to validate UTF-8.
ArgsIterator::new(ghostty_os::args::collect_bytes().into_iter().skip(1))
}
/// An iterator valid for arg parsing from a slice.
pub struct SliceIterator<'a> {
pub slice: &'a [String],
pub struct SliceIterator<'a, T> {
pub slice: &'a [T],
pub idx: usize,
}
impl ArgIter for SliceIterator<'_> {
fn next_arg(&mut self) -> Option<String> {
impl<T: AsRef<[u8]>> ArgIter for SliceIterator<'_, T> {
fn next_arg(&mut self) -> Option<Vec<u8>> {
let v = self.slice.get(self.idx)?;
self.idx += 1;
Some(v.clone())
Some(v.as_ref().to_vec())
}
}
/// Construct a [`SliceIterator`] from a slice.
#[must_use]
pub fn slice_iterator(slice: &[String]) -> SliceIterator<'_> {
pub fn slice_iterator<T: AsRef<[u8]>>(slice: &[T]) -> SliceIterator<'_, T> {
SliceIterator { slice, idx: 0 }
}
/// A simple whitespace-splitting iterator used in tests and for
/// string-based argument parsing.
pub struct VecIterator {
items: std::vec::IntoIter<String>,
items: std::vec::IntoIter<Vec<u8>>,
}
impl VecIterator {
#[must_use]
pub fn new(items: Vec<String>) -> Self {
pub fn new<T: AsRef<[u8]>>(items: Vec<T>) -> 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 {
fn next_arg(&mut self) -> Option<String> {
fn next_arg(&mut self) -> Option<Vec<u8>> {
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 entry: String = loop {
let entry: Vec<u8> = loop {
buf.clear();
match self.r.read_until(b'\n', &mut buf) {
Ok(0) => return None,
@@ -807,42 +905,48 @@ impl<R: BufRead> LineIterator<R> {
self.line += 1;
// Raw bytes survive via the reversible escape encoding.
let line = ghostty_os::osbytes::bytes_to_config_string(&buf);
// Trim any whitespace (including CR) around it
let trim = line.trim_matches(|c| c == ' ' || c == '\t' || c == '\r');
// Zig trims spaces, tabs and carriage returns, without decoding text.
let start = buf
.iter()
.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
if trim.is_empty() || trim.starts_with('#') {
if trim.is_empty() || trim.starts_with(b"#") {
continue;
}
break trim.to_string();
break trim.to_vec();
};
let mut result = String::from("--");
if let Some(idx) = entry.find('=') {
let key = trim_ws(&entry[..idx]);
let mut value = trim_ws(&entry[idx + 1..]);
let mut result = b"--".to_vec();
if let Some(idx) = entry.iter().position(|b| *b == b'=') {
let key = trim_bytes(&entry[..idx]);
let mut value = trim_bytes(&entry[idx + 1..]);
// 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
// quotes to already be gone.
value = &value[1..value.len() - 1];
}
result.push_str(key);
result.push('=');
result.push_str(value);
result.extend_from_slice(key);
result.push(b'=');
result.extend_from_slice(value);
} else {
result.push_str(&entry);
result.extend_from_slice(&entry);
}
Some(result)
}
}
impl<R: BufRead> ArgIter for LineIterator<R> {
fn next_arg(&mut self) -> Option<String> {
fn next_arg(&mut self) -> Option<Vec<u8>> {
self.next()
}
@@ -889,8 +993,8 @@ mod tests {
}
}
impl CliTarget for Simple {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
}
@@ -901,8 +1005,8 @@ mod tests {
}
}
impl CliTarget for Quoted {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
}
@@ -913,8 +1017,8 @@ mod tests {
}
}
impl CliTarget for Reset {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
}
@@ -932,8 +1036,8 @@ mod tests {
}
}
impl CliTarget for Diag {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
fn valid_values(&self, key: &str) -> Option<&'static [&'static str]> {
Self::valid_values_for(key)
@@ -1029,15 +1133,15 @@ mod tests {
pub a: bool,
}
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" {
// Fails for anything but a valid bool.
return self.a.parse_cli(value);
return self.a.parse_bytes(value);
}
Err(ArgError::InvalidField)
}
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool {
if key == "a" && value == Some("yuh") {
fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
if key == "a" && value == Some(b"yuh") {
self.a = true;
return true;
}
@@ -1057,14 +1161,14 @@ mod tests {
pub b: bool,
}
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 {
"a" => self.a.parse_cli(value),
"b" => self.b.parse_cli(value),
"a" => self.a.parse_bytes(value),
"b" => self.b.parse_bytes(value),
_ => 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" {
return compatibility_renamed(self, "a", value);
}
@@ -1086,12 +1190,12 @@ mod tests {
}
}
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.
if key.starts_with('_') {
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() {
let mut data = Underscore::raw_default();
assert_eq!(
data.parse_into_field("_a", Some("42")),
data.parse_into_field("_a", Some(b"42")),
Err(ArgError::InvalidField)
);
assert_eq!(data.a, "12");
@@ -1117,7 +1221,8 @@ mod tests {
}
}
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(())
}
fn reset(&mut self, _default: Self) {
@@ -1272,7 +1377,8 @@ mod tests {
pub v: String,
}
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();
Ok(())
}
@@ -1313,7 +1419,8 @@ mod tests {
}
}
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 mut r = Basic::default();
let mut set = [false; 3];
@@ -1381,7 +1488,8 @@ mod tests {
D(String),
}
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 (tag, rest) = split_tagged_union(v);
*self = match tag {
@@ -1454,8 +1562,8 @@ mod tests {
fn args_iterator() {
let child = split_args("--what +list-things --a=42").into_iter();
let mut iter = ArgsIterator::new(child);
assert_eq!(iter.next().unwrap(), "--what");
assert_eq!(iter.next().unwrap(), "--a=42");
assert_eq!(iter.next().unwrap(), b"--what");
assert_eq!(iter.next().unwrap(), b"--a=42");
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);
}
@@ -1469,12 +1577,12 @@ mod tests {
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 \"",
);
assert_eq!(iter.next().unwrap(), "--A");
assert_eq!(iter.next().unwrap(), "--B=42");
assert_eq!(iter.next().unwrap(), "--C");
assert_eq!(iter.next().unwrap(), "--D");
assert_eq!(iter.next().unwrap(), "--E");
assert_eq!(iter.next().unwrap(), "--F=value ");
assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), b"--B=42");
assert_eq!(iter.next().unwrap(), b"--C");
assert_eq!(iter.next().unwrap(), b"--D");
assert_eq!(iter.next().unwrap(), b"--E");
assert_eq!(iter.next().unwrap(), b"--F=value ");
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);
}
@@ -1482,7 +1590,7 @@ mod tests {
#[test]
fn line_iterator_end_in_newline() {
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);
}
@@ -1490,7 +1598,7 @@ mod tests {
#[test]
fn line_iterator_spaces_around_equals() {
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);
}
@@ -1498,15 +1606,15 @@ mod tests {
#[test]
fn line_iterator_no_value() {
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);
}
#[test]
fn line_iterator_with_crlf_line_endings() {
let mut iter = line_iter("A\r\nB = C\r\n");
assert_eq!(iter.next().unwrap(), "--A");
assert_eq!(iter.next().unwrap(), "--B=C");
assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), b"--B=C");
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);
}
@@ -1515,8 +1623,8 @@ mod tests {
fn line_iterator_with_buffered_reader() {
let r = std::io::BufReader::with_capacity(2, Cursor::new(b"A\nB = C\n".to_vec()));
let mut iter = LineIterator::new(r);
assert_eq!(iter.next().unwrap(), "--A");
assert_eq!(iter.next().unwrap(), "--B=C");
assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), b"--B=C");
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()));
r.fill_buf().unwrap();
let mut iter = LineIterator::new(r);
assert_eq!(iter.next().unwrap(), "--A");
assert_eq!(iter.next().unwrap(), "--B=C");
assert_eq!(iter.next().unwrap(), b"--A");
assert_eq!(iter.next().unwrap(), b"--B=C");
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);
}
@@ -1558,21 +1666,16 @@ mod tests {
parse_zig_string_literal_bytes("\"a\\xFFb\"").unwrap(),
vec![b'a', 0xff, b'b']
);
let s = parse_zig_string_literal("\"a\\xFFb\"").unwrap();
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&s),
vec![b'a', 0xff, b'b']
);
assert!(!s.contains('\u{fffd}'));
assert!(parse_zig_string_literal("\"a\\xFFb\"").is_none());
// Valid multi-byte sequences spelled with escapes still decode.
assert_eq!(
parse_zig_string_literal("\"\\xC3\\xA9\"").unwrap(),
"\u{e9}"
);
// Raw (unescaped) invalid input bytes survive the escape encoding.
let raw = ghostty_os::osbytes::bytes_to_config_string(b"\"x\xfey\"");
// Raw (unescaped) invalid input bytes survive literal parsing.
let raw = b"\"x\xfey\"";
assert_eq!(
parse_zig_string_literal_bytes(&raw).unwrap(),
parse_zig_string_literal_bytes(raw).unwrap(),
vec![b'x', 0xfe, b'y']
);
}
@@ -1582,10 +1685,6 @@ mod tests {
let data: &[u8] = b"title = a\xffb\n";
let mut it = LineIterator::new(data);
let line = it.next().unwrap();
assert_eq!(
ghostty_os::osbytes::config_str_to_bytes(&line),
b"--title=a\xffb".to_vec()
);
assert!(!line.contains('\u{fffd}'));
assert_eq!(line, b"--title=a\xffb".to_vec());
}
}
+15 -5
View File
@@ -54,21 +54,31 @@ enum UnicodeState {
#[derive(Debug, Clone)]
pub struct CommaSplitter<'a> {
/// the string that we are splitting
str: &'a str,
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 new(str: &'a str) -> Self {
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_field(&mut self) -> Result<Option<&'a str>, Error> {
let bytes = self.str.as_bytes();
pub fn next_bytes(&mut self) -> Result<Option<&'a [u8]>, Error> {
let bytes = self.str;
if self.index >= bytes.len() {
return Ok(None);
}
@@ -213,7 +223,7 @@ impl<'a> CommaSplitter<'a> {
}
let r = &self.str[self.index..];
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 ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{ArgError, ConfigField},
formatter::EntryFormatter,
@@ -33,30 +35,30 @@ pub enum Command {
/// 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
/// delegation to something like `execvp`.
Direct(Vec<String>),
Direct(Vec<EncodedOs>),
/// Execute a command via shell expansion. This provides the command
/// as a single string that is expected to be expanded in some way
/// (up to the downstream). Usually `/bin/sh -c`.
Shell(String),
Shell(EncodedOs),
}
impl Default for Command {
fn default() -> Self {
Command::Shell(String::new())
Command::Shell(EncodedOs::new())
}
}
/// Iterates over each argument in the command.
#[derive(Debug, Clone)]
pub struct ArgIterator {
args: std::vec::IntoIter<String>,
args: std::vec::IntoIter<EncodedOs>,
}
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()
}
}
@@ -64,8 +66,7 @@ impl Iterator for ArgIterator {
/// Best-effort shell string splitter matching Zig's
/// `std.process.ArgIteratorGeneral(.{})` (whitespace separated, double
/// quotes group, backslashes only special before a double quote).
fn split_general(s: &str) -> Vec<String> {
let b = s.as_bytes();
fn split_general(b: &[u8]) -> Vec<EncodedOs> {
let mut out = vec![];
let mut i = 0;
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
}
@@ -120,8 +121,12 @@ impl Command {
#[must_use]
pub fn string(&self) -> String {
match self {
Command::Shell(v) => v.clone(),
Command::Direct(v) => v.join(" "),
Command::Shell(v) => v.as_encoded().to_owned(),
Command::Direct(v) => v
.iter()
.map(EncodedOs::as_encoded)
.collect::<Vec<_>>()
.join(" "),
}
}
@@ -135,7 +140,7 @@ impl Command {
pub fn arg_iterator(&self) -> ArgIterator {
let args = match self {
Command::Direct(v) => v.clone(),
Command::Shell(v) => split_general(v),
Command::Shell(v) => split_general(&v.to_bytes()),
};
ArgIterator {
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 {
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.
// Commands must be non-empty.
let input = input.ok_or(ArgError::ValueRequired)?;
let trimmed = input.trim_matches(' ');
let trimmed = trim_spaces(input);
if trimmed.is_empty() {
return Err(ArgError::ValueRequired);
}
// If we have a `:` then we MIGHT have a prefix to specify what
// tag we should use.
let (direct, str) = match trimmed.find(':') {
Some(idx) if &trimmed[..idx] == "direct" => (true, &trimmed[idx + 1..]),
Some(idx) if &trimmed[..idx] == "shell" => (false, &trimmed[idx + 1..]),
let (direct, str) = match trimmed.iter().position(|b| *b == b':') {
Some(idx) if &trimmed[..idx] == b"direct" => (true, &trimmed[idx + 1..]),
Some(idx) if &trimmed[..idx] == b"shell" => (false, &trimmed[idx + 1..]),
_ => (false, trimmed),
};
let str = str.trim_matches(' ');
let str = trim_spaces(str);
*self = if direct {
// We're not shell expanding, so the arguments are naively
// 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 {
Command::Shell(str.to_string())
Command::Shell(EncodedOs::from_bytes(str))
};
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
match self {
Command::Shell(v) => writeln!(f.writer, "{} = {v}", f.name),
Command::Direct(v) => writeln!(f.writer, "{} = direct:{}", f.name, v.join(" ")),
Command::Shell(v) => writeln!(f.writer, "{} = {}", f.name, v.as_encoded()),
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]
fn command_arg_iterator_shell_quotes() {
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"]);
}
+72 -58
View File
@@ -56,7 +56,7 @@ crate::config_struct! {
///
/// GTK only.
/// Available since 1.3.0.
"language" => language: Option<String> = None,
"language" => language: Option<EncodedOs> = None,
/// The font families to use.
///
@@ -1444,7 +1444,7 @@ crate::config_struct! {
/// sequence will be honored but previous changes will not retroactively
/// be set. This latter case may require you to restart programs such as Neovim
/// to get the new title.
"title" => title: Option<String> = None,
"title" => title: Option<EncodedOs> = None,
/// The setting that will change the application class value.
///
@@ -1466,7 +1466,7 @@ crate::config_struct! {
/// The default is `com.mitchellh.ghostty`.
///
/// 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
/// running under X11. It has no effect otherwise.
@@ -1474,7 +1474,7 @@ crate::config_struct! {
/// The default is `ghostty`.
///
/// 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.
///
@@ -2049,7 +2049,7 @@ crate::config_struct! {
/// required to be a fixed-width font.
///
/// 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:
///
@@ -2626,7 +2626,7 @@ crate::config_struct! {
/// GTK Wayland only.
///
/// 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.
///
@@ -3360,7 +3360,7 @@ crate::config_struct! {
/// Supported formats include PNG, JPEG, and 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.
///
@@ -3648,11 +3648,11 @@ crate::config_struct! {
/// features. An option exists in vim to modify this: `:set
/// keyprotocol=ghostty:kitty`, however a bug in the implementation prevents it
/// 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
/// 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.
///
@@ -3764,7 +3764,7 @@ pub enum Error {
#[derive(Debug, Clone, PartialEq)]
pub enum ReplayStep {
/// 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.
Expand(EncodedOs),
@@ -3774,7 +3774,7 @@ pub enum ReplayStep {
/// having multiple conditional arg entries.
ConditionalArg {
conditions: Vec<Conditional>,
arg: String,
arg: Vec<u8>,
},
/// 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
/// literal U+FDD0 round-trip exactly, like Zig's byte slice).
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 {
@@ -3886,13 +3886,13 @@ impl Config {
///
/// `path` must be resolved and absolute.
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) {
Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => {
log::warn!(
"config-file {}: not reading because it is not a file",
path.as_encoded()
path.to_text_lossy()
);
return Ok(());
}
@@ -3907,7 +3907,7 @@ impl Config {
/// Load config from the given File.
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);
self.load_reader(&mut reader, path)
}
@@ -3929,7 +3929,7 @@ impl Config {
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
self.load_iter(&mut iter)?;
let dir = path
.to_path()
.to_path()?
.parent()
.map(EncodedOs::from_path)
.unwrap_or_default();
@@ -3949,7 +3949,7 @@ impl Config {
Err(err) => {
log::warn!(
"error reading optional config file, not loading err={err} path={}",
path.as_encoded()
path.to_text_lossy()
);
OptionalFileAction::Error
}
@@ -3971,8 +3971,11 @@ impl Config {
}
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
log::info!("creating template config file: path={}", path.as_encoded());
let os_path = path.to_path();
log::info!(
"creating template config file: path={}",
path.to_text_lossy()
);
let os_path = path.to_path()?;
if let Some(dir) = os_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -3999,8 +4002,8 @@ impl Config {
{
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_xdg_path.as_encoded(),
xdg_path.as_encoded()
legacy_xdg_path.to_text_lossy(),
xdg_path.to_text_lossy()
);
log::warn!("loading them both in that order");
true
@@ -4034,8 +4037,8 @@ impl Config {
{
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_app_support_path.as_encoded(),
app_support_path.as_encoded()
legacy_app_support_path.to_text_lossy(),
app_support_path.to_text_lossy()
);
log::warn!("loading them both in that order");
true
@@ -4102,10 +4105,11 @@ impl Config {
// Next, take all remaining args and use that to build up
// a command to execute.
let mut builder: Vec<String> = Vec::new();
let mut builder: Vec<EncodedOs> = Vec::new();
for arg in args {
let copy = ghostty_os::osbytes::bytes_to_config_string(arg.to_bytes());
self.replay_steps.push(ReplayStep::Arg(copy.clone()));
let copy = EncodedOs::from_bytes(arg.to_bytes());
self.replay_steps
.push(ReplayStep::Arg(arg.to_bytes().to_vec()));
builder.push(copy);
}
@@ -4207,7 +4211,7 @@ impl Config {
// All paths should already be absolute at this point because
// 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
if !loaded.insert(path.clone()) {
@@ -4220,7 +4224,7 @@ impl Config {
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,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional {
@@ -4334,7 +4338,7 @@ impl Config {
ReplayStep::DashE => {
// 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() {
match &steps[i] {
ReplayStep::Expand(base) => {
@@ -4351,7 +4355,7 @@ impl Config {
}
}
ReplayStep::Arg(arg) => args.push(arg.clone()),
ReplayStep::DashE => args.push("-e".to_string()),
ReplayStep::DashE => args.push(b"-e".to_vec()),
}
i += 1;
}
@@ -4363,8 +4367,8 @@ impl Config {
Ok(())
}
fn load_one_arg(&mut self, arg: &str) -> Result<(), ArgError> {
let mut it = cli_args::VecIterator::new(vec![arg.to_string()]);
fn load_one_arg(&mut self, arg: &[u8]) -> Result<(), ArgError> {
let mut it = cli_args::VecIterator::new(vec![arg.to_vec()]);
self.load_iter(&mut it)
}
@@ -4391,7 +4395,7 @@ impl Config {
// Load the correct theme depending on the conditional state.
// Dark/light themes were programmed prior to conditional configuration
// 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::Dark => &theme.dark,
};
@@ -4399,7 +4403,7 @@ impl Config {
// Find our theme file and open it. See the open function for details.
let resources_dir = ghostty_os::resources_dir().ok();
let Some(themefile) = crate::theme::open(
&EncodedOs::from_encoded(name),
name,
resources_dir.as_ref().and_then(|r| r.app()),
&mut self.diagnostics,
) else {
@@ -4520,7 +4524,7 @@ impl Config {
// Prevent setting TERM to an empty string
if self.term.is_empty() {
// 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.
@@ -4647,7 +4651,7 @@ impl Config {
fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> {
if self.command.is_none() {
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 {
@@ -4665,8 +4669,8 @@ impl Config {
let pw = ghostty_os::passwd::get()?;
if self.command.is_none() {
if let Some(sh) = pw.shell {
log::info!("default shell src=passwd value={}", sh.as_encoded());
self.command = Some(Command::Shell(sh.into_encoded()));
log::info!("default shell src=passwd value={}", sh.to_text_lossy());
self.command = Some(Command::Shell(sh));
}
}
@@ -4674,7 +4678,7 @@ impl Config {
if let Some(home) = pw.home {
log::info!(
"default working directory src=passwd value={}",
home.as_encoded()
home.to_text_lossy()
);
*wd = WorkingDirectory::Path(home);
} else {
@@ -4809,8 +4813,8 @@ fn probable_cli_environment() -> bool {
}
impl CliTarget for Config {
fn parse_into_field(&mut self, key: &str, value: Option<&str>) -> Result<(), ArgError> {
self.parse_field(key, value)
fn parse_into_field(&mut self, key: &str, value: Option<&[u8]>) -> Result<(), ArgError> {
self.parse_field_bytes(key, value)
}
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
/// 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> {
if arg == "-e" {
fn parse_manually_hook(
&mut self,
arg: &[u8],
iter: &mut dyn ArgIter,
) -> Result<bool, ArgError> {
if arg == b"-e" {
// Add the special -e marker. This prevents:
// (1) config-file from adding args to the end (see #2908)
// (2) dark/light theme from making this conditional
self.replay_steps.push(ReplayStep::DashE);
// 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() {
self.replay_steps.push(ReplayStep::Arg(param.clone()));
command.push(param);
command.push(EncodedOs::from_bytes(&param));
}
if command.is_empty() {
self.diagnostics.append(Diagnostic {
location: iter.location().unwrap_or_default(),
message: format!("missing command after {arg}"),
message: "missing command after -e".to_owned(),
..Diagnostic::default()
});
@@ -4861,13 +4869,13 @@ impl CliTarget for Config {
}
// 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
Ok(true)
}
fn compatibility(&mut self, key: &str, value: Option<&str>) -> bool {
fn compatibility(&mut self, key: &str, value: Option<&[u8]>) -> bool {
match key {
// Ghostty 1.1 introduced background-blur support for Linux which
// 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
// moved it to `window-show-tab-bar`.
"gtk-tabs-location" => {
if value.unwrap_or("") == "hidden" {
if value.unwrap_or(b"") == b"hidden" {
self.window_show_tab_bar = WindowShowTabBar::Never;
return true;
}
@@ -4907,7 +4915,9 @@ impl CliTarget for Config {
// Realistically, these fields were mutually exclusive so anyone
// relying on that behavior should just upgrade to the new
// 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;
};
if set {
@@ -4917,7 +4927,9 @@ impl CliTarget for Config {
true
}
"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;
};
if set {
@@ -4930,7 +4942,9 @@ impl CliTarget for Config {
// Ghostty 1.2 merged `bold-is-bright` into the new `bold-color`
// by setting the value to "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;
};
if isset {
@@ -4942,7 +4956,7 @@ impl CliTarget for Config {
// Ghostty 1.2 removed the "desktop" option and renamed it to "detect".
// The semantics also changed slightly but this is the correct mapping.
"gtk-single-instance" => {
if value.unwrap_or("") == "desktop" {
if value.unwrap_or(b"") == b"desktop" {
self.gtk_single_instance = GtkSingleInstance::Detect;
return true;
}
@@ -4952,7 +4966,7 @@ impl CliTarget for Config {
// Ghostty 1.3 rename the "window" option to "new-window".
// See: https://github.com/ghostty-org/ghostty/pull/9764
"macos-dock-drop-behavior" => {
if value.unwrap_or("") == "window" {
if value.unwrap_or(b"") == b"window" {
self.macos_dock_drop_behavior = MacOSDockDropBehavior::NewWindow;
return true;
}
@@ -4983,7 +4997,7 @@ mod tests {
let Command::Shell(text) = shell_from_env(std::ffi::OsStr::from_bytes(raw)) else {
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 {
panic!()
@@ -5063,7 +5077,7 @@ mod tests {
fn parse_hook_invalid_command() {
let mut cfg = Config::default();
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());
}
@@ -5071,7 +5085,7 @@ mod tests {
fn parse_e_command_only() {
let mut cfg = Config::default();
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 {
panic!("expected direct");
@@ -5083,7 +5097,7 @@ mod tests {
fn parse_e_command_and_args() {
let mut cfg = Config::default();
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 {
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
// filesystem.
let real_path = config_path.to_path();
let real_path = config_path.to_path()?;
// Create config directory recursively.
if let Some(config_dir) = real_path.parent() {
@@ -55,7 +55,11 @@ fn config_path() -> io::Result<EncodedOs> {
// exists.
let mut exists: Option<&EncodedOs> = None;
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,
// File doesn't exist, 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());
temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || {
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!(real.is_file());
// 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
/// errors to a smaller set that we can explicitly handle.
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());
let file = File::open(&real).map_err(|err| {
@@ -221,16 +224,16 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let new = EncodedOs::from_path(&dir.join("config.ghostty"));
let legacy = EncodedOs::from_path(&dir.join("config"));
let _ = std::fs::remove_file(new.to_path());
let _ = std::fs::remove_file(legacy.to_path());
let _ = std::fs::remove_file(new.to_path().unwrap());
let _ = std::fs::remove_file(legacy.to_path().unwrap());
// Neither exists: the new one.
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
// 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);
// 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);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -241,17 +244,17 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let app = EncodedOs::from_path(&dir.join("app"));
let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
let _ = std::fs::remove_file(app.to_path());
let _ = std::fs::remove_file(xdg_p.to_path());
let _ = std::fs::remove_file(app.to_path().unwrap());
let _ = std::fs::remove_file(xdg_p.to_path().unwrap());
let xdg_fn = || Ok(xdg_p.clone());
// Neither exists: app support.
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
// 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);
// 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);
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
/// 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
/// string the font advertises. The "bold/italic" booleans later in this
/// struct filter by the style trait the font has, not the string, so
/// 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.
pub codepoint: u32,
@@ -184,11 +184,25 @@ mod tests {
let mut m = CodepointMap::default();
assert!(m.get(1).is_none());
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
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
assert!(m.get(0).is_none());
@@ -196,8 +210,29 @@ mod tests {
m.add(e(3, 4, "C"));
m.add(e(5, 6, "D"));
assert_eq!(m.get(3).unwrap().family.as_deref(), Some("C"));
assert_eq!(m.get(6).unwrap().family.as_deref(), Some("D"));
assert_eq!(m.get(2).unwrap().family.as_deref(), Some("B"));
assert_eq!(
m.get(3)
.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 {
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)
}
@@ -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)?;
*self = match v {
"false" => OptionAsAlt::False,
+21 -20
View File
@@ -5,7 +5,7 @@ use std::fmt;
use ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{ArgError, ConfigField, split_tagged_union},
cli_args::{ArgError, ConfigField},
formatter::EntryFormatter,
string,
};
@@ -18,16 +18,16 @@ use crate::{
/// might be produced in an intermediate state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadableIO {
/// Raw bytes in the reversible OS-byte encoding (decode with
/// `osbytes::config_str_to_bytes` after [`ReadableIO::clone_parsed`]).
Raw(String),
/// Raw bytes, available with `EncodedOs::to_bytes` after
/// [`ReadableIO::clone_parsed`] resolves string escapes.
Raw(EncodedOs),
/// A file path (decode with `EncodedOs::to_path` before opening).
Path(EncodedOs),
}
impl Default for ReadableIO {
fn default() -> Self {
ReadableIO::Raw(String::new())
ReadableIO::Raw(EncodedOs::new())
}
}
@@ -36,18 +36,20 @@ impl ReadableIO {
/// the final resulting value all at once.
#[must_use]
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 {
ReadableIO::Raw(v) => ReadableIO::Raw(string::parse(v).unwrap_or_default()),
ReadableIO::Path(v) => ReadableIO::Path(EncodedOs::from_encoded(
string::parse(v.as_encoded()).unwrap_or_default(),
ReadableIO::Raw(v) => ReadableIO::Raw(EncodedOs::from_bytes(
&string::parse_bytes(v.to_bytes()).unwrap_or_default(),
)),
ReadableIO::Path(v) => ReadableIO::Path(EncodedOs::from_bytes(
&string::parse_bytes(v.to_bytes()).unwrap_or_default(),
)),
}
}
}
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)?;
if input.is_empty() {
return Err(ArgError::ValueRequired);
@@ -55,22 +57,22 @@ impl ConfigField for ReadableIO {
// We only validate that string parsing works. We store the value
// 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
// 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 {
"raw" => ReadableIO::Raw(rest.to_string()),
"path" => ReadableIO::Path(EncodedOs::from_encoded(rest)),
_ => ReadableIO::Raw(input.to_string()),
b"raw" => ReadableIO::Raw(EncodedOs::from_bytes(rest)),
b"path" => ReadableIO::Path(EncodedOs::from_bytes(rest)),
_ => ReadableIO::Raw(EncodedOs::from_bytes(input)),
};
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
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()),
};
writeln!(f.writer, "{} = {tag}:{v}", f.name)
@@ -93,7 +95,7 @@ impl 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)?;
// Empty value resets the list
@@ -103,7 +105,7 @@ impl ConfigField for RepeatableReadableIO {
}
let mut io = ReadableIO::default();
io.parse_cli(Some(value))?;
io.parse_bytes(Some(value))?;
self.list.push(io);
Ok(())
}
@@ -139,8 +141,7 @@ mod tests {
#[test]
fn readable_io_path_decodes_to_the_original_bytes() {
let mut io = ReadableIO::default();
let enc = EncodedOs::from_bytes(b"/tmp/\xffin").into_encoded();
io.parse_cli(Some(&format!("path:{enc}"))).unwrap();
io.parse_bytes(Some(b"path:/tmp/\xffin")).unwrap();
let ReadableIO::Path(p) = io.clone_parsed() else {
panic!("path expected")
};
+45 -29
View File
@@ -24,6 +24,7 @@ use ghostty_input::{
set::{EntryFormatter as BindingEntryFormatter, Value},
},
};
use ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{ArgError, ConfigField},
@@ -38,7 +39,7 @@ use crate::{
pub enum ChainTarget {
#[default]
Root,
Table(String),
Table(EncodedOs),
}
#[derive(Debug, Clone, Default)]
@@ -48,7 +49,7 @@ pub struct Keybinds {
/// Defined key tables. The default key table is always the root "set",
/// which allows all table names to be available without reservation.
/// Insertion ordered.
pub tables: Vec<(String, Set)>,
pub tables: Vec<(EncodedOs, Set)>,
/// The most recent binding target for `chain=` additions.
pub chain_target: ChainTarget,
@@ -445,27 +446,30 @@ impl Keybinds {
// 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
// the keybinds to `unbind`.
set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".to_owned()));
set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".to_owned()));
set.put(phys(Key::Backspace, sup), Action::Text("\\x15".to_owned()));
set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".into()));
set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".into()));
set.put(phys(Key::Backspace, sup), Action::Text("\\x15".into()));
set.put(
phys(Key::ArrowLeft, mods(false, false, true, false)),
Action::Esc("b".to_owned()),
Action::Esc("b".into()),
);
set.put(
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)
}
/// Get a table by name.
#[must_use]
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.
@@ -483,14 +487,18 @@ impl Keybinds {
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)?;
}
// Format table bindings
for (table_name, table_set) in &self.tables {
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)?;
}
}
@@ -506,7 +514,9 @@ struct Adapter<'a, 'b> {
impl BindingEntryFormatter for Adapter<'_, '_> {
fn format_entry(&mut self, value: &str) {
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;
}
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;
};
if !equal_set(set, other_set) {
@@ -574,7 +589,7 @@ impl PartialEq 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)?;
// Check for special values
@@ -584,7 +599,7 @@ impl ConfigField for Keybinds {
return Ok(());
}
if value == "clear" {
if value == b"clear" {
log::info!("config has 'keybind = clear', all keybinds cleared");
self.set = Set::default();
self.tables = Vec::new();
@@ -595,28 +610,29 @@ impl ConfigField for Keybinds {
// Check for table syntax: "name/" or "name/binding"
// We look for '/' only before the first '=' to avoid matching
// action arguments like "foo=text:/hello".
let eq_idx = value.find('=').unwrap_or(value.len());
if let Some(slash_idx) = value[..eq_idx].find('/') {
let eq_idx = value.iter().position(|b| *b == b'=').unwrap_or(value.len());
if let Some(slash_idx) = value[..eq_idx].iter().position(|b| *b == b'/') {
let table_name = &value[..slash_idx];
// Length zero is valid, so you can set `/=action` for the slash key.
// Ignore '+', '>' because they can be part of sequences and
// 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 table_name = EncodedOs::from_bytes(table_name);
// 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
} else {
self.tables.push((table_name.to_string(), Set::default()));
self.tables.push((table_name.clone(), Set::default()));
self.tables.len() - 1
};
// If there's no binding after the slash, this is a table
// definition/clear command
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.chain_target = ChainTarget::Root;
return Ok(());
@@ -624,23 +640,23 @@ impl ConfigField for Keybinds {
// Chains are only allowed at the root level. Their target is
// 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"));
}
// Parse and add the binding to the table
self.tables[idx]
.1
.parse_and_put(binding)
.parse_and_put_bytes(binding)
.map_err(to_arg_error)?;
self.chain_target = ChainTarget::Table(table_name.to_string());
self.chain_target = ChainTarget::Table(table_name.clone());
return Ok(());
}
}
if value.starts_with("chain=") {
if value.starts_with(b"chain=") {
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) => {
let Some(idx) = self.table_index(&table_name) else {
self.chain_target = ChainTarget::Root;
@@ -648,7 +664,7 @@ impl ConfigField for Keybinds {
};
self.tables[idx]
.1
.parse_and_put(value)
.parse_and_put_bytes(value)
.map_err(to_arg_error)?;
}
}
@@ -656,7 +672,7 @@ impl ConfigField for Keybinds {
}
// 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;
Ok(())
}
@@ -1037,7 +1053,7 @@ mod tests {
let Some(Value::Leaf(l)) = lookup(&kb, phys(Key::ArrowLeft, sup)) else {
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.
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)]
/// Parse a single key/value into the matching field. Empty
/// values reset the field to its default.
pub fn parse_field(
pub fn parse_field_bytes(
&mut self,
key: &str,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::core::result::Result<(), $crate::cli_args::ArgError> {
match 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);
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),
@@ -215,11 +221,11 @@ macro_rules! config_enum {
const VALID_VALUES: ::core::option::Option<&'static [&'static str]> =
::core::option::Option::Some(Self::NAMES);
fn parse_cli(
fn parse_bytes(
&mut self,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::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)?;
::core::result::Result::Ok(())
}
@@ -295,11 +301,11 @@ macro_rules! config_packed {
$crate::c_value::CValue::Packed(bits)
}
fn parse_cli(
fn parse_bytes(
&mut self,
value: ::core::option::Option<&str>,
value: ::core::option::Option<&[u8]>,
) -> ::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();
$crate::cli_args::parse_packed_struct_with(v, Self::NAMES, |i, b| {
result.set_index(i, b);
+2 -1
View File
@@ -99,7 +99,8 @@ impl 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)?;
Ok(())
}
+24 -20
View File
@@ -33,8 +33,8 @@ impl Default for Path {
}
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
let joined = base.to_path().join(path.to_path());
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> std::io::Result<EncodedOs> {
let joined = base.to_path()?.join(path.to_path()?);
let mut out = PathBuf::new();
for c in joined.components() {
match c {
@@ -45,7 +45,7 @@ fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
other => out.push(other.as_os_str()),
}
}
EncodedOs::from_path(&out)
Ok(EncodedOs::from_path(&out))
}
impl Path {
@@ -75,27 +75,31 @@ impl Path {
///
/// Returns `Ok(None)` for an empty input.
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)?;
if value.is_empty() {
return Ok(None);
}
let optional = if let Some(rest) = value.strip_prefix('?') {
let optional = if let Some(rest) = value.strip_prefix(b"?") {
value = rest;
true
} else {
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];
}
Ok(Some(if optional {
Path::Optional(EncodedOs::from_encoded(value))
Path::Optional(EncodedOs::from_bytes(value))
} 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.
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Filesystem calls use the decoded OS paths.
let base_path = base.to_path();
let base_path = base.to_path()?;
assert!(base_path.is_absolute());
let path = self.path().clone();
// 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(());
}
@@ -126,7 +130,7 @@ impl Path {
Ok(expanded) => {
log::debug!(
"expanding file path from home directory: path={}",
expanded.as_encoded()
expanded.to_text_lossy()
);
*self.path_mut() = expanded;
}
@@ -150,13 +154,13 @@ impl Path {
// Make sure the base directory can be opened.
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) {
Ok(p) => EncodedOs::from_path(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path
// another way.
resolve_lexical(base, &path)
resolve_lexical(base, &path)?
}
Err(err) => {
diags.append(Diagnostic {
@@ -172,8 +176,8 @@ impl Path {
log::debug!(
"expanding file path relative={} abs={}",
path.as_encoded(),
abs.as_encoded()
path.to_text_lossy(),
abs.to_text_lossy()
);
*self.path_mut() = abs;
Ok(())
@@ -195,8 +199,8 @@ impl ConfigField for Path {
}
/// Parse CLI option. The value will be overwritten.
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let Some(item) = Path::parse(input)? else {
fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let Some(item) = Path::parse_bytes(input)? else {
return Ok(());
};
if item.is_empty() {
@@ -237,8 +241,8 @@ impl RepeatablePath {
impl ConfigField for RepeatablePath {
const KIND: crate::cli_args::FieldKind = crate::cli_args::FieldKind::RepeatablePath;
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let Some(item) = Path::parse(input)? else {
fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
let Some(item) = Path::parse_bytes(input)? else {
self.value.clear();
return Ok(());
};
@@ -403,7 +407,7 @@ mod tests {
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
assert_eq!(diags.items().len(), 0);
// The canonical (non-UTF-8) target is preserved byte for byte.
assert_eq!(p.path().to_path(), target);
assert!(std::fs::metadata(p.path().to_path()).is_ok());
assert_eq!(p.path().to_path().unwrap(), target);
assert!(std::fs::metadata(p.path().to_path().unwrap()).is_ok());
}
}
@@ -4,30 +4,31 @@
use std::fmt;
use ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{ArgError, ConfigField},
formatter::EntryFormatter,
};
/// std.ascii.whitespace
fn is_ascii_ws(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
}
/// An insertion-ordered string map.
#[derive(Debug, Clone, Default)]
pub struct RepeatableStringMap {
map: Vec<(String, String)>,
map: Vec<(EncodedOs, EncodedOs)>,
}
impl PartialEq for RepeatableStringMap {
/// Order-insensitive comparison (Zig: `equal`).
fn eq(&self, other: &Self) -> bool {
self.map.len() == other.map.len()
&& self
.map
.iter()
.all(|(k, v)| other.get(k).is_some_and(|ov| ov == v))
&& self.map.iter().all(|(k, v)| {
other
.map
.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v)
.is_some_and(|ov| ov == v)
})
}
}
@@ -39,29 +40,35 @@ impl RepeatableStringMap {
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
pub fn get(&self, key: &str) -> Option<&EncodedOs> {
self.map
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
.find(|(k, _)| k == &EncodedOs::from_plain_text(key))
.map(|(_, v)| v)
}
/// Insert or replace an entry (Zig: `map.put`).
pub fn put(&mut self, key: &str, value: &str) {
match self.map.iter_mut().find(|(k, _)| k == key) {
Some((_, v)) => value.clone_into(v),
None => self.map.push((key.to_owned(), value.to_owned())),
self.put_bytes(key.as_bytes(), value.as_bytes());
}
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).
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.map.iter().map(|(k, v)| (k.as_str(), v.as_str()))
pub fn iter(&self) -> impl Iterator<Item = (&EncodedOs, &EncodedOs)> {
self.map.iter().map(|(k, v)| (k, v))
}
}
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)?;
// Empty value resets the list.
@@ -70,21 +77,25 @@ impl ConfigField for RepeatableStringMap {
return Ok(());
}
let index = value.find('=').ok_or(ArgError::ValueRequired)?;
let key = value[..index].trim_matches(is_ascii_ws);
let val = value[index + 1..].trim_matches(is_ascii_ws);
let index = value
.iter()
.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.
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);
}
return Ok(());
}
match self.map.iter_mut().find(|(k, _)| k == key) {
Some(entry) => entry.1 = val.to_string(),
None => self.map.push((key.to_string(), val.to_string())),
match self.map.iter_mut().find(|(k, _)| k == &key) {
Some(entry) => entry.1 = val,
None => self.map.push((key, val)),
}
Ok(())
}
@@ -95,7 +106,13 @@ impl ConfigField for RepeatableStringMap {
return writeln!(f.writer, "{} = ", f.name);
}
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(())
}
@@ -130,7 +147,7 @@ mod tests {
assert_eq!(map.count(), 1);
map.parse_cli(Some("A=C")).unwrap();
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
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)
}
/// Parse a string literal into the original bytes (Zig `parse`'s output).
/// `input` is a config string in the reversible OS-byte encoding; the result
/// is decoded, so it can be written to the pty / OS directly.
pub fn parse_bytes(input: &str) -> Result<Vec<u8>, StringError> {
parse(input).map(|s| ghostty_os::osbytes::config_str_to_bytes(&s))
}
/// 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());
/// Parse literal escapes from the original bytes, copying unescaped bytes verbatim.
/// Like Zig, \xFF denotes U+00FF; only unescaped bytes bypass UTF-8 validation.
pub fn parse_bytes(input: impl AsRef<[u8]>) -> Result<Vec<u8>, StringError> {
let bytes = input.as_ref();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < b.len() {
if b[i] != b'\\' {
// Copy a whole UTF-8 character.
let ch = bytes[i..]
.chars()
.next()
.ok_or(StringError::InvalidString)?;
out.push(ch);
i += ch.len_utf8();
while i < bytes.len() {
if bytes[i] != b'\\' {
out.push(bytes[i]);
i += 1;
continue;
}
let cp = parse_escape_sequence(b, &mut i).ok_or(StringError::InvalidString)?;
// Surrogates cannot be encoded.
let cp = parse_escape_sequence(bytes, &mut i).ok_or(StringError::InvalidString)?;
let ch = char::from_u32(cp).ok_or(StringError::InvalidString)?;
if ch == '\u{FDD0}' {
out.push_str("\u{FDD0}\u{FDD0}");
} else {
out.push(ch);
}
out.extend_from_slice(ch.encode_utf8(&mut [0; 4]).as_bytes());
}
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
/// from the string literal, parsing escape sequences as it goes.
#[must_use]
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 }
}
pub struct CodepointIterator<'a> {
bytes: &'a str,
bytes: &'a [u8],
i: usize,
}
@@ -136,19 +121,31 @@ impl CodepointIterator<'_> {
if self.i >= self.bytes.len() {
return Ok(None);
}
if self.bytes.as_bytes()[self.i] == b'\\' {
if self.bytes[self.i] == b'\\' {
// 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)
.ok_or(StringError::InvalidString);
}
// 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()
.next()
.ok_or(StringError::InvalidString)?;
self.i += ch.len_utf8();
self.i += len;
Ok(Some(u32::from(ch)))
}
}
@@ -157,17 +154,23 @@ impl CodepointIterator<'_> {
#[allow(clippy::unwrap_used)]
mod tests {
#[test]
fn parse_bytes_decodes_the_reversible_encoding() {
fn parse_bytes_copies_original_bytes() {
use ghostty_os::osbytes::EncodedOs;
use super::{parse, parse_bytes};
// Raw non-UTF-8 bytes pass through as the same bytes.
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.
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());
// An escape producing U+FF is the UTF-8 encoding, not byte FF.
assert_eq!(parse_bytes("\\xff").unwrap(), "\u{ff}".as_bytes());
+13 -6
View File
@@ -37,7 +37,7 @@ impl Location {
.ok(),
Location::Resources => {
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>,
diags: &mut DiagnosticList,
) -> 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();
// Absolute themes are loaded a different path.
@@ -190,8 +196,8 @@ pub fn open(
// one that exists.
let mut it = LocationIterator::new(resources_dir);
while let Some((_, dir)) = it.next_location() {
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
match File::open(path.to_path()) {
let path = EncodedOs::from_path(&dir.to_path().ok()?.join(&theme_path));
match path.to_path().and_then(File::open) {
Ok(file) => {
if !check_regular(&file, theme_text, diags) {
return None;
@@ -218,7 +224,7 @@ pub fn open(
// our iterator, reiterate over in order to build a better error message.
it.reset();
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(
diags,
format!(
@@ -235,8 +241,9 @@ pub fn open(
/// then messages will be appended to the given error list and None is
/// returned.
pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
let path = theme.to_path();
let theme = theme.as_encoded();
match File::open(EncodedOs::from_encoded(theme).to_path()) {
match path.and_then(File::open) {
Ok(f) => Some(f),
Err(err) => {
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)?;
*self = match v {
"bar" => CursorStyle::Bar,
@@ -151,25 +152,22 @@ pub enum 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 mut input = input.trim_matches(|c: char| {
// std.ascii.whitespace
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
});
let mut input = crate::cli_args::trim_ascii_whitespace(input);
if input.is_empty() {
return Err(ArgError::ValueRequired);
}
// 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];
}
*self = match input {
"home" => WorkingDirectory::Home,
"inherit" => WorkingDirectory::Inherit,
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)),
b"home" => WorkingDirectory::Home,
b"inherit" => WorkingDirectory::Inherit,
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_bytes(input)),
};
Ok(())
}
@@ -199,7 +197,7 @@ impl WorkingDirectory {
Err(err) => {
log::warn!(
"error expanding home directory for working-directory path={}: {err}",
path.as_encoded()
path.to_text_lossy()
);
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)?;
Ok(())
}
@@ -355,7 +354,8 @@ impl 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)?;
Ok(())
}
@@ -394,7 +394,8 @@ impl 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)?;
Ok(())
}
@@ -417,7 +418,8 @@ impl ConfigField for ColorList {
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)?;
if input.is_empty() {
return Err(ArgError::ValueRequired);
@@ -485,7 +487,8 @@ impl ConfigField for Palette {
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 eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?;
@@ -522,7 +525,7 @@ impl ConfigField for Palette {
/// comma-separated values.
#[derive(Debug, Clone, Default)]
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
/// 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 {
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)?;
// Empty value resets the list
@@ -567,7 +570,8 @@ impl ConfigField for RepeatableString {
self.overwrite_next = false;
}
self.list.push(value.to_string());
self.list
.push(ghostty_os::osbytes::EncodedOs::from_bytes(value));
Ok(())
}
@@ -577,7 +581,7 @@ impl ConfigField for RepeatableString {
return write_void(f);
}
for v in &self.list {
write_str(f, v)?;
write_str(f, v.as_encoded())?;
}
Ok(())
}
@@ -631,12 +635,12 @@ impl Default 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)?;
// Always include null as first boundary
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)? {
list.push(cp);
}
@@ -657,7 +661,7 @@ impl ConfigField for SelectionWordChars {
}
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 {
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 eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?;
let key = trim_ws(&input[..eql_idx]);
let value = trim_ws(&input[eql_idx + 1..]);
let key: &[u8; 4] = key
.as_bytes()
.try_into()
let eql_idx = input
.iter()
.position(|b| *b == b'=')
.ok_or(ArgError::InvalidValue)?;
let key = crate::cli_args::trim_bytes(&input[..eql_idx]);
let value = std::str::from_utf8(crate::cli_args::trim_bytes(&input[eql_idx + 1..]))
.map_err(|_| ArgError::InvalidValue)?;
let key: &[u8; 4] = key.try_into().map_err(|_| ArgError::InvalidValue)?;
self.list.push(Variation {
id: VariationId::init(key),
value: value.parse::<f64>().map_err(|_| ArgError::InvalidValue)?,
@@ -704,7 +709,14 @@ impl ConfigField for RepeatableFontVariation {
}
for v in &self.list {
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(())
}
@@ -860,18 +872,22 @@ pub struct 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 eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?;
let key = trim_ws(&input[..eql_idx]);
let value = trim_ws(&input[eql_idx + 1..]);
let eql_idx = input
.iter()
.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()? {
self.map.add(CodepointMapEntry {
range,
descriptor: Descriptor {
family: Some(value.to_string()),
family: Some(ghostty_os::osbytes::EncodedOs::from_bytes(value)),
monospace: false, // we allow any font
..Descriptor::default()
},
@@ -886,7 +902,11 @@ impl ConfigField for RepeatableCodepointMap {
}
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] {
write_str(f, &format!("U+{:04X}={}", e.range[0], family))?;
} else {
@@ -907,7 +927,8 @@ pub struct 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 eql_idx = input.find('=').ok_or(ArgError::InvalidValue)?;
let key = trim_ws(&input[..eql_idx]);
@@ -941,7 +962,7 @@ impl ConfigField for RepeatableClipboardCodepointMap {
for e in &self.map.list {
let value_str = match &e.replacement {
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] {
@@ -971,14 +992,14 @@ pub enum FontStyle {
False,
/// A specific named font style to use for this style.
Name(String),
Name(ghostty_os::osbytes::EncodedOs),
}
impl FontStyle {
/// Returns the string name value that can be used with a font
/// descriptor.
#[must_use]
pub fn name_value(&self) -> Option<&str> {
pub fn name_value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> {
match self {
FontStyle::Default | FontStyle::False => None,
FontStyle::Name(n) => Some(n),
@@ -987,12 +1008,12 @@ impl 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)?;
*self = match value {
"default" => FontStyle::Default,
"false" => FontStyle::False,
_ => FontStyle::Name(value.to_string()),
b"default" => FontStyle::Default,
b"false" => FontStyle::False,
_ => FontStyle::Name(ghostty_os::osbytes::EncodedOs::from_bytes(value)),
};
Ok(())
}
@@ -1001,7 +1022,7 @@ impl ConfigField for FontStyle {
match self {
FontStyle::Default => write_str(f, "default"),
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 {
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"))
}
@@ -1477,7 +1499,14 @@ mod tests {
fn fam(map: &RepeatableCodepointMap, i: usize) -> (&[u32; 2], &str) {
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]
+49 -34
View File
@@ -6,6 +6,7 @@
use std::fmt;
use ghostty_input::binding::Action;
use ghostty_os::osbytes::EncodedOs;
use crate::{
c_value::{CValue, QuickTerminalSizeC},
@@ -92,17 +93,18 @@ use ghostty_input::command::Command;
/// Parse a command from `title:...,description:...,action:...`
/// (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 title: Option<String> = None;
let mut description = String::new();
parse_auto_struct_with(input, |k, v| {
let mut title: Option<EncodedOs> = None;
let mut description = EncodedOs::new();
crate::cli_args::parse_auto_struct_bytes_with(input, |k, v| {
match k {
"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()),
"description" => description = v.to_string(),
"title" => title = Some(EncodedOs::from_bytes(v)),
"description" => description = EncodedOs::from_bytes(v),
_ => return Ok(false),
}
Ok(true)
@@ -121,10 +123,10 @@ fn default_commands() -> Vec<Command> {
/// 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
/// 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;
let mut out = String::new();
for &b in s.as_bytes() {
for b in s.to_bytes() {
match b {
b'\n' => out.push_str("\\n"),
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
let input = input.unwrap_or("");
let input = input.unwrap_or(b"");
if input.is_empty() {
self.value.clear();
return Ok(());
@@ -395,7 +397,8 @@ impl 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)?;
// Unset fields inherit from the current value.
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 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 {
// Emulate behavior for bools
*self = BackgroundBlur::True;
@@ -919,7 +924,8 @@ impl ConfigField for WindowDecoration {
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)?;
Ok(())
}
@@ -932,12 +938,12 @@ impl ConfigField for WindowDecoration {
/// See theme
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Theme {
pub light: String,
pub dark: String,
pub light: EncodedOs,
pub dark: EncodedOs,
}
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)?;
if input.is_empty() {
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
// actually valid for setting a light/dark mode pair but I anticipate
// it'll be a common typo.
if input.contains([',', '=', ':']) {
let mut light: Option<String> = None;
let mut dark: Option<String> = None;
parse_auto_struct_with(input, |k, v| {
if input.iter().any(|b| matches!(b, b',' | b'=' | b':')) {
let mut light: Option<EncodedOs> = None;
let mut dark: Option<EncodedOs> = None;
crate::cli_args::parse_auto_struct_bytes_with(input, |k, v| {
match k {
"light" => light = Some(v.to_string()),
"dark" => dark = Some(v.to_string()),
"light" => light = Some(EncodedOs::from_bytes(v)),
"dark" => dark = Some(EncodedOs::from_bytes(v)),
_ => return Ok(false),
}
Ok(true)
@@ -967,19 +973,26 @@ impl ConfigField for Theme {
}
// 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 {
light: trimmed.to_string(),
dark: trimmed.to_string(),
light: EncodedOs::from_bytes(trimmed),
dark: EncodedOs::from_bytes(trimmed),
};
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
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)
}
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)?;
Ok(())
}
@@ -1207,7 +1221,8 @@ impl 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)?;
Ok(())
}
@@ -1602,17 +1617,17 @@ mod tests {
// Single
let mut v = Theme::default();
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
let mut v = Theme::default();
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
let mut v = Theme::default();
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();
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()
);
}
}
}