refactor(config): parse config values from raw bytes like Zig
Zig parses config values (cli/args.zig, Config.zig field parsers, config/string.zig) from raw byte slices, and each field type decides whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The port escape-encoded raw bytes into Strings at the line reader and let fields parse the escaped text, so fields could observe encoding artifacts; successive audits kept finding such leaks (RV5-RV8). Field parsers now receive the original bytes: text fields validate UTF-8 where Zig does, byte/path fields store bytes (EncodedOs/OsString). This resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md), each with a regression test: Windows path conversion fails with BadPathName instead of substituting U+FFFD; Windows process launch uses strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired- surrogate encodings like Zig; literal U+FDD0 Unicode key triggers; selection-word-chars and clipboard-codepoint-map reject invalid UTF-8; font style / codepoint-map family names use original bytes; C title actions forward bytes; D-Bus new-window/Flatpak conversions match Zig. Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md. Also moves the bundled iTerm2-Color-Schemes license out of resources/themes/ so +list-themes no longer lists it as a theme (output is now byte-identical to Ghostty 1.3.1). Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator. Test Plan: - cargo test --workspace --no-fail-fast (3428 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs, +list-keybinds, +list-actions --docs, +list-themes --plain compared with /usr/bin/ghostty
This commit is contained in:
1 parent
70c250cf2d
commit
6f9b3129e1
100 files changed
+2310
-1054
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¶m));
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user