feat(config): port the configuration system and CLI argument parser
Ports ZIG/src/config (minus C/WASM APIs) and cli/{args,CommaSplitter,
diagnostics}.zig into ghostty-config:
- all 202 options with exact names, types, defaults and verbatim docs,
declared through one config_struct! macro; Zig's comptime reflection is
replaced by the ConfigField/CliTarget traits
- loading: default XDG files, -e, config-file includes (optional '?',
cycle detection), themes incl. light:/dark: pairs, conditional config,
replay, finalize() derived defaults, clone/changed diffing for reload
- Linux/GTK default keybinds built on ghostty_input::binding::Set
- formatter producing +show-config output
Verified against the installed Ghostty 1.3.1: `+show-config --default`
and `--default --docs` match byte for byte (except gtk-opengl-debug,
which differs between debug/release builds), as do two complex loaded
configs.
Open items (docs/rewrite/notes/ghostty-config.md): url regex needs an
Oniguruma-compatible engine (onig) in the renderer; some font/terminal
types are local copies awaiting integration; KeyRemapSet equality is
order sensitive unlike Zig.
Test Plan:
- cargo test -p ghostty-config (255 tests)
- compare `ghostty +show-config --default --docs` with our formatter
This commit is contained in:
1 parent
800fe5be5e
commit
33d15a2dc2
34 files changed
+14894
-1
No files matched your search
@@ -0,0 +1,271 @@
|
||||
//! A command to execute (port of `config/command.zig`).
|
||||
|
||||
use crate::cli_args::{ArgError, ConfigField};
|
||||
use crate::formatter::EntryFormatter;
|
||||
use std::fmt;
|
||||
|
||||
/// A command to execute (argv0 and args).
|
||||
///
|
||||
/// A command is specified as a simple string such as "nvim a b c".
|
||||
/// By default, we expect the downstream to do some sort of shell expansion
|
||||
/// on this string.
|
||||
///
|
||||
/// If a command is already expanded and the user does NOT want to do
|
||||
/// shell expansion (because this usually requires a round trip into
|
||||
/// /bin/sh or equivalent), specify a `direct:`-prefix. e.g.
|
||||
/// `direct:nvim a b c`.
|
||||
///
|
||||
/// The whitespace before or around the prefix is ignored. For example,
|
||||
/// ` direct:nvim a b c` and `direct: nvim a b c` are equivalent.
|
||||
///
|
||||
/// If the command is not absolute, it'll be looked up via the PATH.
|
||||
/// For the shell-expansion case, we let the shell do this. For the
|
||||
/// direct case, we do this directly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// Execute a command directly, e.g. via `exec`. The format here
|
||||
/// is already structured to be ready to passed directly to `exec`
|
||||
/// with index zero being the command to execute.
|
||||
///
|
||||
/// 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>),
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Command::Shell(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterates over each argument in the command.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArgIterator {
|
||||
args: std::vec::IntoIter<String>,
|
||||
}
|
||||
|
||||
impl Iterator for ArgIterator {
|
||||
type Item = String;
|
||||
|
||||
fn next(&mut self) -> Option<String> {
|
||||
self.args.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
loop {
|
||||
// skip whitespace
|
||||
while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
|
||||
i += 1;
|
||||
}
|
||||
if i >= b.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut cur: Vec<u8> = vec![];
|
||||
let mut quoted = false;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b' ' | b'\t' if !quoted => break,
|
||||
b'\\' => {
|
||||
let mut n = 0;
|
||||
while i < b.len() && b[i] == b'\\' {
|
||||
n += 1;
|
||||
i += 1;
|
||||
}
|
||||
if i < b.len() && b[i] == b'"' {
|
||||
cur.extend(std::iter::repeat_n(b'\\', n / 2));
|
||||
if n % 2 == 1 {
|
||||
cur.push(b'"');
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
cur.extend(std::iter::repeat_n(b'\\', n));
|
||||
}
|
||||
}
|
||||
b'"' => {
|
||||
quoted = !quoted;
|
||||
i += 1;
|
||||
}
|
||||
c => {
|
||||
cur.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(String::from_utf8_lossy(&cur).into_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Creates a command as a single string, joining arguments as
|
||||
/// necessary with spaces. Its not guaranteed that this is a valid
|
||||
/// command; it is only meant to be human readable.
|
||||
#[must_use]
|
||||
pub fn string(&self) -> String {
|
||||
match self {
|
||||
Command::Shell(v) => v.clone(),
|
||||
Command::Direct(v) => v.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an iterator over the arguments array.
|
||||
///
|
||||
/// For direct commands this just iterates over the array. For shell
|
||||
/// commands this uses a best effort shell string parser (like Zig's
|
||||
/// `ArgIteratorGeneral`). This is not guaranteed to be 100% accurate,
|
||||
/// but it works for common cases.
|
||||
#[must_use]
|
||||
pub fn arg_iterator(&self) -> ArgIterator {
|
||||
let args = match self {
|
||||
Command::Direct(v) => v.clone(),
|
||||
Command::Shell(v) => split_general(v),
|
||||
};
|
||||
ArgIterator {
|
||||
args: args.into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigField for Command {
|
||||
fn parse_cli(&mut self, input: Option<&str>) -> 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(' ');
|
||||
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..]),
|
||||
_ => (false, trimmed),
|
||||
};
|
||||
|
||||
let str = str.trim_matches(' ');
|
||||
*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())
|
||||
} else {
|
||||
Command::Shell(str.to_string())
|
||||
};
|
||||
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(" ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::formatter::format_entry;
|
||||
|
||||
#[test]
|
||||
fn command_parse_cli_errors() {
|
||||
let mut v = Command::default();
|
||||
assert_eq!(v.parse_cli(None), Err(ArgError::ValueRequired));
|
||||
assert_eq!(v.parse_cli(Some("")), Err(ArgError::ValueRequired));
|
||||
assert_eq!(v.parse_cli(Some(" ")), Err(ArgError::ValueRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_parse_cli_shell_expanded() {
|
||||
let mut v = Command::default();
|
||||
v.parse_cli(Some("echo hello")).unwrap();
|
||||
assert_eq!(v, Command::Shell("echo hello".into()));
|
||||
|
||||
// Spaces are stripped
|
||||
v.parse_cli(Some(" echo hello ")).unwrap();
|
||||
assert_eq!(v, Command::Shell("echo hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_parse_cli_direct() {
|
||||
let mut v = Command::default();
|
||||
v.parse_cli(Some("direct:echo hello")).unwrap();
|
||||
assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()]));
|
||||
|
||||
// Spaces around the prefix
|
||||
v.parse_cli(Some(" direct: echo hello")).unwrap();
|
||||
assert_eq!(v, Command::Direct(vec!["echo".into(), "hello".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_arg_iterator_shell() {
|
||||
let v = Command::Shell("echo hello world".into());
|
||||
let mut it = v.arg_iterator();
|
||||
assert_eq!(it.next().unwrap(), "echo");
|
||||
assert_eq!(it.next().unwrap(), "hello");
|
||||
assert_eq!(it.next().unwrap(), "world");
|
||||
assert!(it.next().is_none());
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert_eq!(args, ["echo", "hello world", "a\"b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_arg_iterator_direct() {
|
||||
let v = Command::Direct(vec!["echo".into(), "hello world".into()]);
|
||||
let mut it = v.arg_iterator();
|
||||
assert_eq!(it.next().unwrap(), "echo");
|
||||
assert_eq!(it.next().unwrap(), "hello world");
|
||||
assert!(it.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_string_shell() {
|
||||
let v = Command::Shell("echo hello world".into());
|
||||
assert_eq!(v.string(), "echo hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_string_direct() {
|
||||
let v = Command::Direct(vec!["echo".into(), "hello world".into()]);
|
||||
assert_eq!(v.string(), "echo hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_format_config_shell() {
|
||||
let mut v = Command::default();
|
||||
v.parse_cli(Some("echo hello")).unwrap();
|
||||
let mut s = String::new();
|
||||
format_entry("a", &v, &mut s).unwrap();
|
||||
assert_eq!(s, "a = echo hello\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_format_config_direct() {
|
||||
let mut v = Command::default();
|
||||
v.parse_cli(Some("direct: echo hello")).unwrap();
|
||||
let mut s = String::new();
|
||||
format_entry("a", &v, &mut s).unwrap();
|
||||
assert_eq!(s, "a = direct:echo hello\n");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user