Resolves RV14 (docs/rewrite/notes/review-rv14-codex.md, final-Z17.md): - +show-face --cp parses as u21 (new U21 field type): out-of-range values give an invalid-value diagnostic and exit 1 instead of a panic; --string accepts raw bytes and fails with error.InvalidUtf8 after the codepoint lookup like Zig. - CLI actions write through ZigWriter, a model of Zig 0.15's buffered File.writer (fixed buffer, drain on overflow, explicit flush), so stdout/stderr interleave in Zig's flush order (+show-face, help, version, validate-config, show-config, list-*, ssh-cache, crash-report, edit-config, new-window). - Zig 0.15.2 std.fs.path.isAbsolute port (Windows: \theme and /theme are absolute) replaces Path::is_absolute at every Zig isAbsolute site. - io_uring is available iff a 256-entry ring can be created, like libxev; the reactor falls back to IORING_OP_TIMEOUT without EXT_ARG. - Windows basename (`λ:theme`) verified against 0.15.2 (test added). Test Plan: - cargo test --workspace --no-fail-fast - diff +show-face outputs (pipe) against /usr/bin/ghostty
446 lines
15 KiB
Rust
446 lines
15 KiB
Rust
//! Path config values (port of `config/path.zig`).
|
|
|
|
use std::{
|
|
fmt,
|
|
path::{Component, PathBuf},
|
|
};
|
|
|
|
use ghostty_os::osbytes::EncodedOs;
|
|
|
|
use crate::{
|
|
c_value::CValue,
|
|
cli_args::{ArgError, ConfigField},
|
|
diagnostics::{Diagnostic, DiagnosticList},
|
|
formatter::EntryFormatter,
|
|
};
|
|
|
|
/// Path is like a string that represents a path value. The difference is that
|
|
/// when loading the configuration the value for this will be automatically
|
|
/// expanded relative to the path of the config file (or the home directory).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Path {
|
|
/// No error if the file does not exist.
|
|
Optional(EncodedOs),
|
|
|
|
/// The file is required to exist.
|
|
Required(EncodedOs),
|
|
}
|
|
|
|
impl Default for Path {
|
|
fn default() -> Self {
|
|
Path::Required(EncodedOs::new())
|
|
}
|
|
}
|
|
|
|
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
|
|
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 {
|
|
Component::ParentDir => {
|
|
out.pop();
|
|
}
|
|
Component::CurDir => {}
|
|
other => out.push(other.as_os_str()),
|
|
}
|
|
}
|
|
Ok(EncodedOs::from_path(&out))
|
|
}
|
|
|
|
impl Path {
|
|
/// The inner path string.
|
|
#[must_use]
|
|
pub fn path(&self) -> &EncodedOs {
|
|
match self {
|
|
Path::Optional(p) | Path::Required(p) => p,
|
|
}
|
|
}
|
|
|
|
fn path_mut(&mut self) -> &mut EncodedOs {
|
|
match self {
|
|
Path::Optional(p) | Path::Required(p) => p,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.path().is_empty()
|
|
}
|
|
|
|
/// Parse the input and return a Path. A leading `?` indicates that the path
|
|
/// is _optional_ and an error should not be logged or displayed to the user
|
|
/// if that path does not exist. Otherwise the path is required and an error
|
|
/// should be logged if the path does not exist.
|
|
///
|
|
/// 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(b"?") {
|
|
value = rest;
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
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_bytes(value))
|
|
} else {
|
|
Path::Required(EncodedOs::from_bytes(value))
|
|
}))
|
|
}
|
|
|
|
/// Expand relative paths or paths prefixed with `~/`. The path will be
|
|
/// overwritten.
|
|
///
|
|
/// `base` is the base directory to expand relative paths; it must be an
|
|
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
|
|
/// 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()?;
|
|
assert!(base.is_zig_absolute());
|
|
|
|
let path = self.path().clone();
|
|
|
|
// If it is already absolute we can ignore it.
|
|
if path.is_empty() || {
|
|
path.to_path()?;
|
|
path.is_zig_absolute()
|
|
} {
|
|
return Ok(());
|
|
}
|
|
|
|
// Check if the path starts with a tilde and expand it to the
|
|
// home directory. We explicitly look for "~/" because we don't
|
|
// support alternate users such as "~alice/"
|
|
// (Windows isn't supported yet: Zig skips this there.)
|
|
if !cfg!(windows) && path.starts_with_plain("~/") {
|
|
match ghostty_os::expand_home(&path) {
|
|
Ok(expanded) => {
|
|
log::debug!(
|
|
"expanding file path from home directory: path={}",
|
|
expanded.to_text_lossy()
|
|
);
|
|
*self.path_mut() = expanded;
|
|
}
|
|
Err(err) => {
|
|
diags.append(Diagnostic {
|
|
message: format!(
|
|
"error expanding home directory for path {}: {err}",
|
|
path.as_encoded()
|
|
),
|
|
..Diagnostic::default()
|
|
});
|
|
|
|
// Blank this path so that we don't attempt to resolve it
|
|
// again
|
|
*self = Path::Required(EncodedOs::new());
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// Make sure the base directory can be opened.
|
|
std::fs::read_dir(&base_path)?;
|
|
|
|
let full = base_path.join(path.to_syscall_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)?
|
|
}
|
|
Err(err) => {
|
|
diags.append(Diagnostic {
|
|
message: format!(
|
|
"error resolving file path {}: {}",
|
|
path.as_encoded(),
|
|
ghostty_os::zig_error::io_error_name(&err)
|
|
),
|
|
..Diagnostic::default()
|
|
});
|
|
|
|
// Blank this path so that we don't attempt to resolve it again
|
|
*self = Path::Required(EncodedOs::new());
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
log::debug!(
|
|
"expanding file path relative={} abs={}",
|
|
path.to_text_lossy(),
|
|
abs.to_text_lossy()
|
|
);
|
|
*self.path_mut() = abs;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ConfigField for Path {
|
|
fn c_value(&self) -> CValue {
|
|
match self {
|
|
Path::Optional(p) => CValue::Path {
|
|
path: p.as_encoded().to_owned(),
|
|
optional: true,
|
|
},
|
|
Path::Required(p) => CValue::Path {
|
|
path: p.as_encoded().to_owned(),
|
|
optional: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Parse CLI option. The value will be overwritten.
|
|
fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
|
|
let Some(item) = Path::parse_bytes(input)? else {
|
|
return Ok(());
|
|
};
|
|
if item.is_empty() {
|
|
return Ok(());
|
|
}
|
|
*self = item;
|
|
Ok(())
|
|
}
|
|
|
|
/// Used by formatter.
|
|
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Path::Optional(p) => writeln!(f.writer, "{} = ?{}", f.name, p.as_encoded()),
|
|
Path::Required(p) => writeln!(f.writer, "{} = {}", f.name, p.as_encoded()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// RepeatablePath is like repeatable string but represents a path value. The
|
|
/// difference is that when loading the configuration any values for this will
|
|
/// be automatically expanded relative to the path of the config file (or the home
|
|
/// directory).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct RepeatablePath {
|
|
pub value: Vec<Path>,
|
|
}
|
|
|
|
impl RepeatablePath {
|
|
/// Expand all the paths relative to the base directory.
|
|
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
|
for path in &mut self.value {
|
|
path.expand(base, diags)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ConfigField for RepeatablePath {
|
|
const KIND: crate::cli_args::FieldKind = crate::cli_args::FieldKind::RepeatablePath;
|
|
|
|
fn parse_bytes(&mut self, input: Option<&[u8]>) -> Result<(), ArgError> {
|
|
let Some(item) = Path::parse_bytes(input)? else {
|
|
self.value.clear();
|
|
return Ok(());
|
|
};
|
|
|
|
if item.is_empty() {
|
|
// This handles the case of zero length paths after removing any ?
|
|
// prefixes or surrounding quotes. In this case, we don't reset the
|
|
// list.
|
|
return Ok(());
|
|
}
|
|
|
|
self.value.push(item);
|
|
Ok(())
|
|
}
|
|
|
|
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
|
|
if self.value.is_empty() {
|
|
return writeln!(f.writer, "{} = ", f.name);
|
|
}
|
|
for item in &self.value {
|
|
item.format_entry(f)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::formatter::format_entry;
|
|
|
|
fn e(s: &str) -> EncodedOs {
|
|
EncodedOs::from_encoded(s)
|
|
}
|
|
|
|
fn fmt<T: ConfigField>(v: &T) -> String {
|
|
let mut s = String::new();
|
|
format_entry("a", v, &mut s).unwrap();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn path_parse() {
|
|
let p = |s| Path::parse(Some(s)).unwrap().unwrap();
|
|
assert_eq!(p("config.1"), Path::Required(e("config.1")));
|
|
assert_eq!(p("?config.2"), Path::Optional(e("config.2")));
|
|
assert_eq!(p("\"?config.3\""), Path::Required(e("?config.3")));
|
|
assert_eq!(p("?\"config.4\""), Path::Optional(e("config.4")));
|
|
assert_eq!(p("?"), Path::Optional(EncodedOs::new()));
|
|
assert_eq!(p("\"\""), Path::Required(EncodedOs::new()));
|
|
assert_eq!(p("?\"\""), Path::Optional(EncodedOs::new()));
|
|
assert_eq!(Path::parse(None), Err(ArgError::ValueRequired));
|
|
assert_eq!(Path::parse(Some("")), Ok(None));
|
|
}
|
|
|
|
#[test]
|
|
fn path_parse_cli() {
|
|
let mut item = Path::default();
|
|
|
|
item.parse_cli(Some("config.1")).unwrap();
|
|
assert_eq!(item, Path::Required(e("config.1")));
|
|
|
|
item.parse_cli(Some("?config.2")).unwrap();
|
|
assert_eq!(item, Path::Optional(e("config.2")));
|
|
|
|
item.parse_cli(Some("\"?config.3\"")).unwrap();
|
|
assert_eq!(item, Path::Required(e("?config.3")));
|
|
|
|
// Zero-length values, ignored
|
|
item.parse_cli(Some("?")).unwrap();
|
|
assert_eq!(item, Path::Required(e("?config.3")));
|
|
item.parse_cli(Some("\"\"")).unwrap();
|
|
assert_eq!(item, Path::Required(e("?config.3")));
|
|
item.parse_cli(Some("?\"\"")).unwrap();
|
|
assert_eq!(item, Path::Required(e("?config.3")));
|
|
}
|
|
|
|
#[test]
|
|
fn path_format_config_single_item() {
|
|
let mut item = Path::default();
|
|
item.parse_cli(Some("A")).unwrap();
|
|
assert_eq!(fmt(&item), "a = A\n");
|
|
}
|
|
|
|
#[test]
|
|
fn path_format_config_multiple_items() {
|
|
let mut item = Path::default();
|
|
item.parse_cli(Some("A")).unwrap();
|
|
item.parse_cli(Some("?B")).unwrap();
|
|
assert_eq!(fmt(&item), "a = ?B\n");
|
|
}
|
|
|
|
#[test]
|
|
fn repeatable_path_parse_cli() {
|
|
let mut list = RepeatablePath::default();
|
|
list.parse_cli(Some("config.1")).unwrap();
|
|
list.parse_cli(Some("?config.2")).unwrap();
|
|
list.parse_cli(Some("\"?config.3\"")).unwrap();
|
|
|
|
assert_eq!(list.value.len(), 3);
|
|
|
|
// Zero-length values, ignored
|
|
list.parse_cli(Some("?")).unwrap();
|
|
list.parse_cli(Some("\"\"")).unwrap();
|
|
|
|
assert_eq!(list.value.len(), 3);
|
|
|
|
assert_eq!(list.value[0], Path::Required(e("config.1")));
|
|
assert_eq!(list.value[1], Path::Optional(e("config.2")));
|
|
assert_eq!(list.value[2], Path::Required(e("?config.3")));
|
|
|
|
list.parse_cli(Some("")).unwrap();
|
|
assert_eq!(list.value.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn repeatable_path_format_config_empty() {
|
|
assert_eq!(fmt(&RepeatablePath::default()), "a = \n");
|
|
}
|
|
|
|
#[test]
|
|
fn repeatable_path_format_config_single_item() {
|
|
let mut list = RepeatablePath::default();
|
|
list.parse_cli(Some("A")).unwrap();
|
|
assert_eq!(fmt(&list), "a = A\n");
|
|
}
|
|
|
|
#[test]
|
|
fn repeatable_path_format_config_multiple_items() {
|
|
let mut list = RepeatablePath::default();
|
|
list.parse_cli(Some("A")).unwrap();
|
|
list.parse_cli(Some("?B")).unwrap();
|
|
assert_eq!(fmt(&list), "a = A\na = ?B\n");
|
|
}
|
|
|
|
#[test]
|
|
fn path_expand_relative_nonexistent_and_absolute() {
|
|
let mut diags = DiagnosticList::default();
|
|
let mut p = Path::Required(e("../nope/x.conf"));
|
|
p.expand(&e("/tmp"), &mut diags).unwrap();
|
|
assert_eq!(p, Path::Required(e("/nope/x.conf")));
|
|
|
|
let mut p = Path::Optional(e("/abs/path"));
|
|
p.expand(&e("/tmp"), &mut diags).unwrap();
|
|
assert_eq!(p, Path::Optional(e("/abs/path")));
|
|
assert!(diags.is_empty());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn expand_interior_nul_resolves_the_c_string_prefix_like_zig() {
|
|
let tmp = ghostty_os::TempDir::init().unwrap();
|
|
let base = std::fs::canonicalize(tmp.path()).unwrap();
|
|
std::fs::write(base.join("exists"), b"x").unwrap();
|
|
|
|
// The syscall sees only the prefix, which exists: the real path of
|
|
// the prefix replaces the value.
|
|
let mut p = Path::Required(EncodedOs::from_bytes(b"exists\0tail"));
|
|
let mut diags = DiagnosticList::default();
|
|
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
|
|
assert_eq!(diags.items().len(), 0);
|
|
assert_eq!(p.path().to_path().unwrap(), base.join("exists"));
|
|
|
|
// The prefix is missing: the whole value is resolved lexically.
|
|
let mut p = Path::Required(EncodedOs::from_bytes(b"missing\0tail"));
|
|
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
|
|
assert_eq!(diags.items().len(), 0);
|
|
assert_eq!(
|
|
p.path().to_bytes(),
|
|
[base.as_os_str().as_encoded_bytes(), b"/missing\0tail"].concat()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn expand_symlink_to_non_utf8_target_round_trips() {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let tmp = ghostty_os::TempDir::init().unwrap();
|
|
let base = std::fs::canonicalize(tmp.path()).unwrap();
|
|
let target = base.join(std::ffi::OsStr::from_bytes(b"t\xffarget"));
|
|
std::fs::write(&target, b"x").unwrap();
|
|
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
|
|
|
|
let mut p = Path::Required(e("link"));
|
|
let mut diags = DiagnosticList::default();
|
|
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().unwrap(), target);
|
|
assert!(std::fs::metadata(p.path().to_path().unwrap()).is_ok());
|
|
}
|
|
}
|