Files
ghostty-rs/crates/ghostty-config/src/theme.rs
T
ddidderr 654acd55e6 fix: +show-face u21/UTF-8 semantics, Zig output buffering, isAbsolute
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
2026-10-12 15:15:18 +02:00

500 lines
16 KiB
Rust

//! Theme file lookup (port of `config/theme.zig`).
//!
//! Zig reads the resources dir from global state; here it is passed
//! explicitly (`resources_dir`, the value of `ResourcesDir.app()`).
use std::fs::File;
use ghostty_os::osbytes::EncodedOs;
use crate::diagnostics::{Diagnostic, DiagnosticList};
/// Location of possible themes. The order of this enum matters because it
/// defines the priority of theme search (from top to bottom).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
/// XDG config dir
User,
/// Ghostty resources dir
Resources,
}
impl Location {
const ALL: [Location; 2] = [Location::User, Location::Resources];
/// Returns the directory for the given theme based on this location type.
///
/// This will return None if the directory type doesn't exist or is
/// invalid for any reason. For example, it is perfectly valid to
/// install and run Ghostty without the resources directory.
#[must_use]
pub fn dir(self, resources_dir: Option<&EncodedOs>) -> Option<EncodedOs> {
match self {
Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options {
subdir: Some("ghostty/themes"),
home: None,
})
.ok(),
Location::Resources => {
let base = resources_dir?;
Some(EncodedOs::from_path(&base.to_path().ok()?.join("themes")))
}
}
}
}
/// An iterator that returns all possible directories for finding themes in
/// order of priority.
#[derive(Debug, Clone)]
pub struct LocationIterator {
pub resources_dir: Option<EncodedOs>,
pub i: usize,
}
impl LocationIterator {
#[must_use]
pub fn new(resources_dir: Option<&EncodedOs>) -> Self {
Self {
resources_dir: resources_dir.cloned(),
i: 0,
}
}
pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> {
while self.i < Location::ALL.len() {
let location = Location::ALL[self.i];
self.i += 1;
if let Some(dir) = location.dir(self.resources_dir.as_ref()) {
return Some((location, dir));
}
}
None
}
pub fn reset(&mut self) {
self.i = 0;
}
}
/// An opened theme.
#[derive(Debug)]
pub struct OpenedTheme {
pub path: EncodedOs,
pub file: File,
}
fn diag(diags: &mut DiagnosticList, message: String) {
diags.append(Diagnostic {
message,
..Diagnostic::default()
});
}
fn kind_name(ft: std::fs::FileType) -> &'static str {
if ft.is_file() {
"file"
} else if ft.is_dir() {
"directory"
} else if ft.is_symlink() {
"sym_link"
} else {
special_kind_name(ft)
}
}
#[cfg(unix)]
pub(crate) fn special_kind_name(ft: std::fs::FileType) -> &'static str {
use std::os::unix::fs::FileTypeExt;
if ft.is_block_device() {
"block_device"
} else if ft.is_char_device() {
"character_device"
} else if ft.is_fifo() {
"named_pipe"
} else if ft.is_socket() {
"unix_domain_socket"
} else {
"unknown"
}
}
/// wasm32 has no special file types (`std::os::unix` is unavailable).
#[cfg(not(unix))]
pub(crate) fn special_kind_name(_: std::fs::FileType) -> &'static str {
"unknown"
}
/// Verify that the file is a regular file, adding a diagnostic otherwise.
fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
match file.metadata() {
Err(err) => {
diag(
diags,
format!(
"not reading theme from \"{theme}\": {}",
ghostty_os::zig_error::io_error_name(&err)
),
);
false
}
Ok(m) if !m.is_file() => {
diag(
diags,
format!(
"not reading theme from \"{theme}\": it is a {}",
kind_name(m.file_type())
),
);
false
}
Ok(_) => true,
}
}
/// `std.fs.path.basename` for the host: `basenamePosix` or `basenameWindows`.
fn zig_basename(path: &str) -> &str {
if cfg!(windows) {
basename_windows(path)
} else {
basename_posix(path)
}
}
/// `std.fs.path.basenamePosix` (Zig 0.15.2). `.` and `..` are ordinary names.
fn basename_posix(path: &str) -> &str {
basename_impl(path, |b, _| b == b'/')
}
/// `std.fs.path.basenameWindows` (Zig 0.15.2): `/` and `\\` separate, and a
/// `:` at byte index 1 ends a drive designator of any first byte (`1:theme`
/// is `theme`).
fn basename_windows(path: &str) -> &str {
basename_impl(path, |b, i| {
b == b'/' || b == b'\\' || (b == b':' && i == 1)
})
}
/// The shared loop of both Zig functions. `is_stop(byte, index)` is true for
/// separators (and, on Windows, the drive colon).
fn basename_impl(path: &str, is_stop: impl Fn(u8, usize) -> bool) -> &str {
let bytes = path.as_bytes();
if bytes.is_empty() {
return "";
}
// Trailing separators (a drive colon at index 1 ends the path: empty).
let mut end_index = bytes.len() - 1;
loop {
let byte = bytes[end_index];
if is_stop(byte, end_index) {
// Zig's Windows loop only skips real separators; the colon case
// returns empty at once, which is the same as skipping to 0.
if byte == b':' || end_index == 0 {
return "";
}
end_index -= 1;
continue;
}
break;
}
let mut start_index = end_index;
end_index += 1;
while !is_stop(bytes[start_index], start_index) {
if start_index == 0 {
return &path[..end_index];
}
start_index -= 1;
}
&path[start_index + 1..end_index]
}
/// Open the given named theme. If there are any errors then messages
/// will be appended to the given error list and None is returned. If
/// a Some return value is returned, there are never any errors added.
///
/// This will never return anything other than a handle to a regular file. If
/// the theme resolves to something other than a regular file a diagnostic entry
/// will be added to the list and None will be returned.
pub fn open(
theme: &EncodedOs,
resources_dir: Option<&EncodedOs>,
diags: &mut DiagnosticList,
) -> Option<OpenedTheme> {
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.
if theme.is_zig_absolute() {
let file = open_absolute(theme, diags)?;
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme {
path: theme.clone(),
file,
});
}
if theme_text != zig_basename(theme_text) {
diag(
diags,
format!(
"theme \"{theme_text}\" cannot include path separators unless it is an absolute path"
),
);
return None;
}
// Iterate over the possible locations to try to find the
// 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().ok()?.join(&theme_path));
match path.to_syscall_path().and_then(File::open) {
Ok(file) => {
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme { path, file });
}
// Not an error, just continue to the next location.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
// Anything else is an error we log and give up on.
Err(err) => {
diag(
diags,
format!(
"failed to load theme \"{theme_text}\" from the file \"{}\": {}",
path.as_encoded(),
ghostty_os::zig_error::io_error_name(&err)
),
);
return None;
}
}
}
// Unlikely scenario: the theme doesn't exist. In this case, we reset
// 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().ok()?.join(&theme_path));
diag(
diags,
format!(
"theme \"{theme_text}\" not found, tried path \"{}\"",
path.as_encoded()
),
);
}
None
}
/// Open the given theme from an absolute path. If there are any errors
/// 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_syscall_path();
let theme = theme.as_encoded();
match path.and_then(File::open) {
Ok(f) => Some(f),
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
diag(
diags,
format!("failed to load theme from the path \"{theme}\""),
);
} else {
diag(
diags,
format!(
"failed to load theme from the path \"{theme}\": {}",
ghostty_os::zig_error::io_error_name(&err)
),
);
}
None
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn theme_path_separators_rejected() {
let mut d = DiagnosticList::default();
assert!(open(&e("a/b"), None, &mut d).is_none());
assert!(
d.items()[0]
.message
.contains("cannot include path separators")
);
}
#[cfg(unix)]
#[test]
fn theme_path_errors_use_zig_names() {
let mut d = DiagnosticList::default();
assert!(open(&e("/etc/passwd/x"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"failed to load theme from the path \"/etc/passwd/x\": error.NotDir"
);
}
#[cfg(unix)]
#[test]
fn theme_absolute_interior_nul_opens_prefix() {
let dir = std::env::temp_dir().join(format!("gtheme-nul-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let f = dir.join("t");
std::fs::write(&f, "background = #112233\n").unwrap();
let mut bytes = f.as_os_str().as_encoded_bytes().to_vec();
bytes.extend_from_slice(b"\0tail");
let t = EncodedOs::from_bytes(&bytes);
let mut d = DiagnosticList::default();
assert!(open(&t, None, &mut d).is_some());
assert_eq!(d.items().len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn theme_absolute_missing_and_dir() {
let mut d = DiagnosticList::default();
assert!(open(&e("/nonexistent/ghostty/theme"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"failed to load theme from the path \"/nonexistent/ghostty/theme\""
);
let mut d = DiagnosticList::default();
assert!(open(&e("/tmp"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"not reading theme from \"/tmp\": it is a directory"
);
}
#[test]
fn basename_windows_matches_zig_tests() {
// The `testBasenameWindows` cases of `std/fs/path.zig`.
for (input, expected) in [
("\\dir\\basename.ext", "basename.ext"),
("\\basename.ext", "basename.ext"),
("basename.ext", "basename.ext"),
("basename.ext\\", "basename.ext"),
("basename.ext\\\\", "basename.ext"),
("foo", "foo"),
("C:", ""),
("C:.", "."),
("C:\\", ""),
("C:\\dir\\base.ext", "base.ext"),
("C:\\basename.ext", "basename.ext"),
("C:basename.ext", "basename.ext"),
("C:basename.ext\\", "basename.ext"),
("C:basename.ext\\\\", "basename.ext"),
("C:foo", "foo"),
("file:stream", "file:stream"),
// Non-letter drive designators are drive roots too (the
// RV13 finding): `1:theme` is `theme`, not the whole name.
("1:theme", "theme"),
("1:", ""),
] {
assert_eq!(basename_windows(input), expected, "{input:?}");
}
}
#[test]
fn basename_posix_matches_zig_tests() {
for (input, expected) in [
("", ""),
("/", ""),
("/dir/basename.ext", "basename.ext"),
("/basename.ext", "basename.ext"),
("basename.ext/", "basename.ext"),
("basename.ext//", "basename.ext"),
("/aaa/", "aaa"),
("//a", "a"),
("\\dir\\basename.ext", "\\dir\\basename.ext"),
("basename.ext\\\\", "basename.ext\\\\"),
("1:theme", "1:theme"),
] {
assert_eq!(basename_posix(input), expected, "{input:?}");
}
}
#[test]
fn zig_basename_matches_std() {
assert_eq!(zig_basename(""), "");
assert_eq!(zig_basename("."), ".");
assert_eq!(zig_basename(".."), "..");
assert_eq!(zig_basename("a/"), "a");
assert_eq!(zig_basename("a/b"), "b");
assert_eq!(zig_basename("a//"), "a");
assert_eq!(zig_basename("./"), ".");
}
#[test]
fn windows_basename_matches_zig_0_15_2() {
// The drive colon is recognized only at byte index 1.
assert_eq!(basename_windows("\u{3bb}:theme"), "\u{3bb}:theme");
assert_eq!(
basename_windows("light:\u{3bb}:theme"),
"light:\u{3bb}:theme"
);
assert_eq!(basename_windows("1:theme"), "theme");
assert_eq!(basename_windows("c:"), "");
assert_eq!(basename_windows("a\\b/c\\"), "c");
assert_eq!(basename_windows("\\"), "");
assert_eq!(basename_windows("theme"), "theme");
}
#[test]
fn dot_theme_names_are_directories() {
let dir = std::env::temp_dir().join(format!("ghostty-theme-dot-{}", std::process::id()));
std::fs::create_dir_all(dir.join("themes")).unwrap();
for name in [".", ".."] {
let mut d = DiagnosticList::default();
assert!(open(&e(name), Some(&EncodedOs::from_path(&dir)), &mut d).is_none());
assert!(
d.items()[0].message.ends_with("it is a directory"),
"{}",
d.items()[0].message
);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn theme_found_in_resources() {
let dir = std::env::temp_dir().join(format!("ghostty-theme-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("themes")).unwrap();
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
let mut d = DiagnosticList::default();
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(t.path.ends_with_plain("themes/mytheme-zzz"));
assert!(d.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn theme_not_found_lists_tried_paths() {
let mut d = DiagnosticList::default();
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
assert!(d.items().iter().any(|x| x.message
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\""));
}
}