Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Finding the configuration file and evaluating it.//!//! A configuration can `@include("path")` another file, which stands for//! that file's value: a theme kept beside the configuration, say. A//! relative path is resolved against where the including file really is —//! symbolic links followed — so a configuration linked from somewhere else//! includes the files next to it there. Files are read when the//! configuration is, and read again on every reload.
const std = @import("std");const builtin = @import("builtin");const Io = std.Io;const Allocator = std.mem.Allocator;const struthio = @import("struthio");const known_folders = @import("known_folders");
const Config = @import("Config.zig");const settings = @import("../settings.zig");const host = @import("../os/host.zig");
pub const file_name = "config.struthio";
/// The largest configuration file read. Anything bigger is a mistake.const max_size = 1024 * 1024;
pub const Loaded = struct { parsed: struthio.Parsed(Config), /// The program it was evaluated from, kept to evaluate again for each /// pane's foreground program; the files it includes were read with it. program: struthio.Program, /// What it was evaluated with, its strings `program`'s. ctx: Config.Context, /// Which of `ctx`'s fields it read. One it did not read cannot change /// what it says, so without `.foreground` every pane has the app's. reads: struthio.ContextReads(Config.Context), /// Where it came from, or null for the built-in defaults. Owned by the /// parse's arena. path: ?[]const u8,
pub fn config(l: *const Loaded) *const Config { return &l.parsed.value; }
/// Whether the configuration can say something different for a pane /// with a foreground program than without. pub fn readsForeground(l: *const Loaded) bool { return l.reads.contains(.foreground); }
/// The configuration again, as a pane whose foreground program is `fg` /// sees it. pub fn evalFor(l: *const Loaded, gpa: Allocator, fg: Config.Foreground, diag: *struthio.Diagnostic) struthio.EvalError!struthio.Parsed(Config) { var ctx = l.ctx; ctx.foreground = fg; return l.program.eval(Config, gpa, &ctx, diag); }
pub fn deinit(l: *Loaded) void { l.parsed.deinit(); l.program.deinit(); }};
pub const Error = error{ /// The file could not be read; `Problem.read` says why. ReadFailed, /// The file did not evaluate; `Problem.diag` says where. Invalid, OutOfMemory,};
/// What went wrong, for the caller to report.pub const Problem = struct { diag: struthio.Diagnostic = .{}, read: ?anyerror = null, path_buf: [std.fs.max_path_bytes]u8 = undefined, path_len: usize = 0,
pub fn path(p: *const Problem) []const u8 { return p.path_buf[0..p.path_len]; }
fn setPath(p: *Problem, s: []const u8) void { p.path_len = @min(s.len, p.path_buf.len); @memcpy(p.path_buf[0..p.path_len], s[0..p.path_len]); }
/// `path: what went wrong`, the path the file the problem is in: the /// configuration's own, or one it includes. pub fn format(p: *const Problem, w: *std.Io.Writer) std.Io.Writer.Error!void { if (p.read) |err| { if (err == error.FileNotFound) return w.print("{s}: no such file", .{p.path()}); return w.print("{s}: {t}", .{ p.path(), err }); } const d = &p.diag; var buf: [std.fs.max_path_bytes * 2]u8 = undefined; const file = if (d.file()) |name| pathOfUri(&buf, name) else p.path(); try w.print("{s}:{d}:{d}: {s}", .{ file, d.pos.line, d.pos.column, d.message() }); for (renamed) |r| if (std.mem.endsWith(u8, d.message(), r[0])) try w.print("; {s}", .{r[1]}); }
/// What the configuration once called by another name, and the name it /// has now, so that a file written for the old one says what to change. const renamed = [_]struct { []const u8, []const u8 }{ .{ @typeName(Config) ++ " has no field 'palette'", "it is `profiles` now" }, .{ @typeName(Config.Window) ++ " has no field 'decorations'", "it is `title_bar` now" }, .{ @typeName(Config.Action) ++ " has no tag .toggle_palette", "it is `.toggle_profile_selector` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_left", "it is `.goto_pane_left` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_right", "it is `.goto_pane_right` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_up", "it is `.goto_pane_up` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_down", "it is `.goto_pane_down` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_next", "it is `.goto_pane_next` now" }, .{ @typeName(Config.Action) ++ " has no tag .goto_split_previous", "it is `.goto_pane_previous` now" }, .{ @typeName(Config.Action) ++ " has no tag .toggle_split_zoom", "it is `.toggle_pane_zoom` now" }, .{ "expected " ++ @typeName(Config.Sound) ++ ", found bool", "it is `.desktop` for `true` now, and `.none` for `false`; `.{ .file = .{ .path = \"...\" } }` plays a sound file" }, };
/// The path a diagnostic's `file:` URI names, to show a person; the /// URI itself if it is not one. fn pathOfUri(buf: []u8, name: []const u8) []const u8 { var fba: std.heap.FixedBufferAllocator = .init(buf); const a = fba.allocator(); const u = struthio.uri.Uri.parse(a, name) catch return name; return u.toPath(a, .{}) catch name; }};
/// The default path: `harrier/config.struthio` under the user's/// configuration directory as known-folders finds it — `$XDG_CONFIG_HOME`,/// or `~/.config`, on Linux; `%APPDATA%` on Windows. Null if there is none.pub fn defaultPath(gpa: Allocator, io: Io, env: *const std.process.Environ.Map) (Allocator.Error || known_folders.Error)!?[]u8 { const base = try known_folders.getPath(io, gpa, env, .roaming_configuration) orelse return null; defer gpa.free(base); return try std.fs.path.join(gpa, &.{ base, "harrier", file_name });}
pub const Options = struct { /// What the file sees as `ctx.hostname`, in place of this machine's /// name: for checking a file meant for another machine, or in a build /// sandbox, whose name is `localhost`. hostname: ?[]const u8 = null, /// The desktop's settings, for `ctx.appearance` and `@setting`; none /// gives the defaults and null from every `@setting`. settings: ?*const settings.Settings = null, /// `ctx.appearance` as given, whatever the settings say: for checking /// a file under each color scheme. appearance: ?Config.Appearance = null, /// Read no file at all, not even the default one: the built-in /// defaults, as `--no-config` asks. no_file: bool = false, /// `ctx.foreground`, for checking a file as a pane would see it; the /// app's own evaluation sees none. foreground: ?Config.Foreground = null,};
/// Loads `path`, or the default path when it is null. A default path with/// no file there gives the defaults; a path named explicitly must exist.pub fn load( gpa: Allocator, io: Io, env: *const std.process.Environ.Map, path: ?[]const u8, options: Options, problem: *Problem,) Error!Loaded { const default_path = if (path == null and !options.no_file) defaultPath(gpa, io, env) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // No configuration directory to look in is the same as no file // in it: the defaults. else => null, } else null; defer if (default_path) |p| gpa.free(p); const chosen = if (options.no_file) null else path orelse default_path;
if (chosen) |p| problem.setPath(p); const source: ?[]u8 = if (chosen) |p| Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(max_size)) catch |err| switch (err) { error.FileNotFound => if (path == null) null else { problem.read = err; return error.ReadFailed; }, else => { problem.read = err; return error.ReadFailed; }, } else null; defer if (source) |s| gpa.free(s);
var hostname_buf: [256]u8 = undefined; const hostname = host.name(&hostname_buf);
// The file's name, as struthio names files: a `file:` URI of where it // really is, which `@include` paths are resolved against. var name_arena: std.heap.ArenaAllocator = .init(gpa); defer name_arena.deinit(); const filename: ?[]const u8 = if (source != null) fileUri(name_arena.allocator(), io, chosen.?) catch null else null; var includer: Includer = .{ .io = io };
// Their contexts last as long as the program: it is evaluated again, // for each pane's foreground program, long after this returns. const functions = [_]struthio.Function{ .{ .name = "env", .min_args = 1, .max_args = 1, .context = @constCast(env), .call = envFn }, .{ .name = "setting", .min_args = 2, .max_args = 2, .context = if (options.settings) |s| @constCast(s) else null, .call = settingFn }, };
var program = struthio.Program.parse(gpa, source orelse ".{}", .{ .functions = &functions, .filename = filename, .include = if (filename != null) includer.include() else null, }, &problem.diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.Invalid, }; errdefer program.deinit();
const keep = program.arena.allocator(); var ctx: Config.Context = .{ .hostname = try keep.dupe(u8, options.hostname orelse if (hostname.len > 0) hostname else "localhost"), .os = @tagName(builtin.target.os.tag), .appearance = options.appearance orelse if (options.settings) |s| s.appearance() else .{}, .foreground = options.foreground, }; ctx.appearance.settle(); ctx.appearance = try dupeAppearance(keep, ctx.appearance);
var reads: struthio.ContextReads(Config.Context) = .empty; var parsed = program.evalReads(Config, gpa, &ctx, &reads, &problem.diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.Invalid, }; errdefer parsed.deinit();
const owned_path = if (source != null) try parsed.arena.allocator().dupe(u8, chosen.?) else null; // Kept without one: it is for the panes to set. ctx.foreground = null; return .{ .parsed = parsed, .program = program, .ctx = ctx, .reads = reads, .path = owned_path };}
/// `a` with its strings in `arena`.fn dupeAppearance(arena: Allocator, a: Config.Appearance) Allocator.Error!Config.Appearance { var r = a; if (a.accent_color) |c| r.accent_color = try arena.dupe(u8, c); return r;}
/// The `file:` URI of the file at `path`, symbolic links followed.fn fileUri(arena: Allocator, io: Io, path: []const u8) ![]const u8 { const real = try Io.Dir.cwd().realPathFileAlloc(io, path, arena); const u = try struthio.uri.Uri.fromPath(arena, real, .{}); return std.fmt.allocPrint(arena, "{f}", .{u});}
/// Reads the files a configuration includes.const Includer = struct { io: Io,
fn include(i: *Includer) struthio.Include { return .{ .context = i, .read = read }; }
fn read(context: ?*anyopaque, arena: Allocator, file_uri: []const u8) anyerror![]const u8 { const i: *Includer = @ptrCast(@alignCast(context.?)); const u = try struthio.uri.Uri.parse(arena, file_uri); const path = try u.toPath(arena, .{}); return Io.Dir.cwd().readFileAlloc(i.io, path, arena, .limited(max_size)); }};
/// `@setting("namespace", "key")`: one of the desktop's settings from the/// portal — `@setting("org.gnome.desktop.interface", "monospace-font-name")`/// — or null when the desktop has no such setting.fn settingFn(call: *struthio.Call, args: []const struthio.Value) struthio.EvalError!struthio.Value { if (args[0] != .string or args[1] != .string) return call.fail("@setting takes a namespace and a key, as strings", .{}); // No settings at all: every one is unset. const s: *const settings.Settings = @ptrCast(@alignCast(call.context orelse return .null)); const v = s.get(args[0].string, args[1].string) orelse return .null; return settings.toStruthio(call.arena, v) catch return error.OutOfMemory;}
/// `@env("NAME")`: an environment variable, or null.fn envFn(call: *struthio.Call, args: []const struthio.Value) struthio.EvalError!struthio.Value { if (args[0] != .string) return call.fail("@env takes the variable's name as a string", .{}); const env: *const std.process.Environ.Map = @ptrCast(@alignCast(call.context.?)); return if (env.get(args[0].string)) |v| .{ .string = v } else .null;}
// ---------------------------------------------------------------------------
const testing = std.testing;
fn evalString(source: []const u8, diag: *struthio.Diagnostic) !struthio.Parsed(Config) { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("HARRIER_TEST", "from-env"); const ctx: Config.Context = .{ .hostname = "laptop", .os = "linux" }; const functions = [_]struthio.Function{ .{ .name = "env", .min_args = 1, .max_args = 1, .context = @constCast(&env), .call = envFn }, }; return struthio.evalSource(Config, testing.allocator, source, &ctx, .{ .functions = &functions }, diag);}
test "a file can choose by machine and read the environment" { var diag: struthio.Diagnostic = .{}; var p = try evalString( \\.{ \\ .font = .{ .size = if (ctx.hostname == "laptop") 11 else 13 }, \\ .term = @env("HARRIER_TEST") orelse "xterm", \\ .keybind = .{ .{ .keys = "ctrl+shift+c", .action = .copy } }, \\ .serial = .{ .path = "/dev/ttyUSB0", .baud = 9600, .parity = .even }, \\} , &diag); defer p.deinit(); try testing.expectEqual(@as(f32, 11), p.value.font.size); try testing.expectEqualStrings("from-env", p.value.term); try testing.expectEqual(Config.Action.copy, p.value.keybind[0].action); try testing.expectEqual(@as(u32, 9600), p.value.serial.?.baud); try testing.expectEqualStrings("monospace", p.value.font.family);}
test "a mistake comes back with a position" { var diag: struthio.Diagnostic = .{}; try testing.expectError(error.Eval, evalString(".{ .fnot = .{} }", &diag)); var buf: [256]u8 = undefined; const msg = try std.mem.print(&buf, "{f}", .{&diag}); try testing.expect(std.mem.find(u8, msg, "1:") != null);}
test "a name the configuration no longer has says what it is now" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); const cases = [_]struct { []const u8, []const u8 }{ .{ ".{ .palette = .{} }", "; it is `profiles` now" }, .{ ".{ .window = .{ .decorations = .none } }", "; it is `title_bar` now" }, .{ ".{ .keybind = .{ .{ .keys = \"ctrl+left\", .action = .goto_split_left } } }", "; it is `.goto_pane_left` now" }, .{ ".{ .keybind = .{ .{ .keys = \"ctrl+p\", .action = .toggle_palette } } }", "; it is `.toggle_profile_selector` now" }, .{ ".{ .bell = .{ .sound = true } }", "; `.{ .file = .{ .path = \"...\" } }` plays a sound file" }, }; for (cases) |c| { try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.struthio", .data = c[0] }); const path = try tmp.dir.realPathFileAlloc(testing.io, "config.struthio", testing.allocator); defer testing.allocator.free(path); var problem: Problem = .{}; try testing.expectError(error.Invalid, load(testing.allocator, testing.io, &env, path, .{}, &problem)); var buf: [1024]u8 = undefined; const msg = try std.fmt.bufPrint(&buf, "{f}", .{&problem}); try testing.expect(std.mem.endsWith(u8, msg, c[1])); }}
test "a bell sound is nothing, the desktop's bell, or a file, for all panes or a profile's" { var diag: struthio.Diagnostic = .{}; var p = try evalString( \\.{ \\ .bell = .{ .sound = .{ .file = .{ .path = "~/bell.flac", .volume = 0.5 } } }, \\ .profiles = .{ \\ .{ .name = "serial", .bell_sound = .desktop }, \\ .{ .name = "quiet", .bell_sound = .none }, \\ .{ .name = "plain" }, \\ }, \\} , &diag); defer p.deinit(); try testing.expectEqualStrings("~/bell.flac", p.value.bell.sound.file.path); try testing.expectEqual(@as(f32, 0.5), p.value.bell.sound.file.volume); try testing.expect(p.value.profiles[0].bell_sound.? == .desktop); try testing.expect(p.value.profiles[1].bell_sound.? == .none); try testing.expectEqual(null, p.value.profiles[2].bell_sound);
var defaults = try evalString(".{}", &diag); defer defaults.deinit(); try testing.expect(defaults.value.bell.sound == .none);}
test "no file gives the defaults" { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("XDG_CONFIG_HOME", "/nonexistent-harrier-test"); var problem: Problem = .{}; var l = try load(testing.allocator, testing.io, &env, null, .{}, &problem); defer l.deinit(); try testing.expect(l.path == null); try testing.expectEqual(@as(u16, 80), l.config().window.columns);}
test "a file named explicitly must exist" { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); var problem: Problem = .{}; try testing.expectError(error.ReadFailed, load(testing.allocator, testing.io, &env, "/nonexistent-harrier-test/x", .{}, &problem)); var buf: [256]u8 = undefined; try testing.expectEqualStrings("/nonexistent-harrier-test/x: no such file", try std.mem.print(&buf, "{f}", .{&problem}));}
test "the desktop's settings, as ctx.appearance and through @setting" { var s: settings.Settings = .init(testing.allocator); defer s.deinit(); _ = try s.put("org.freedesktop.appearance", "color-scheme", .{ .int = 2 }); _ = try s.put("org.freedesktop.appearance", "accent-color", .{ .floats = &.{ 1, 0, 0 } }); _ = try s.put("org.gnome.desktop.interface", "monospace-font-name", .{ .string = "Iosevka Term 13" }); var ctx: Config.Context = .{ .hostname = "laptop", .os = "linux", .appearance = s.appearance() }; ctx.appearance.settle(); const functions = [_]struthio.Function{ .{ .name = "setting", .min_args = 2, .max_args = 2, .context = @constCast(&s), .call = settingFn }, }; var diag: struthio.Diagnostic = .{}; var p = try struthio.evalSource(Config, testing.allocator, \\.{ \\ .colors = .{ \\ .background = if (ctx.appearance.color_scheme == .light) "#ffffff" else "#1d1f21", \\ .cursor = ctx.appearance.accent_color, \\ }, \\ .font = .{ .family = @setting("org.gnome.desktop.interface", "monospace-font-name") orelse "monospace" }, \\ .term = @setting("org.example", "missing") orelse "xterm-256color", \\} , &ctx, .{ .functions = &functions }, &diag); defer p.deinit(); try testing.expectEqualStrings("#ffffff", p.value.colors.background); try testing.expectEqualStrings("#ff0000", p.value.colors.cursor.?); try testing.expectEqualStrings("Iosevka Term 13", p.value.font.family); try testing.expectEqualStrings("xterm-256color", p.value.term);}
test "a configuration includes the files beside it, and a mistake in one names it" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "themes"); try tmp.dir.writeFile(testing.io, .{ .sub_path = "themes/dark.struthio", .data = ".{ .background = \"#102030\" }" }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.struthio", .data = ".{ .colors = @include(\"themes/dark.struthio\") }" }); const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); defer testing.allocator.free(dir); const path = try std.fs.path.join(testing.allocator, &.{ dir, "config.struthio" }); defer testing.allocator.free(path);
var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); var problem: Problem = .{}; var l = try load(testing.allocator, testing.io, &env, path, .{}, &problem); defer l.deinit(); try testing.expectEqualStrings("#102030", l.config().colors.background);
// A mistake in the included file is reported there, by its path. try tmp.dir.writeFile(testing.io, .{ .sub_path = "themes/dark.struthio", .data = ".{ .bakground = \"#102030\" }" }); try testing.expectError(error.Invalid, load(testing.allocator, testing.io, &env, path, .{}, &problem)); var buf: [1024]u8 = undefined; const msg = try std.fmt.bufPrint(&buf, "{f}", .{&problem}); const named = msg[0 .. std.mem.find(u8, msg, ":1:") orelse 0]; try testing.expect(std.mem.endsWith(u8, named, "themes" ++ std.fs.path.sep_str ++ "dark.struthio") or std.mem.endsWith(u8, named, "themes/dark.struthio"));}
test "no file at all, even with one where the default would be" { // Windows keeps the configuration where the shell says, which no // variable moves. if (builtin.os.tag == .windows) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "harrier"); try tmp.dir.writeFile(testing.io, .{ .sub_path = "harrier/config.struthio", .data = ".{ .window = .{ .columns = 132 } }" }); const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", testing.allocator); defer testing.allocator.free(dir); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("XDG_CONFIG_HOME", dir); var problem: Problem = .{};
// The default file is read when nothing says otherwise... var with = try load(testing.allocator, testing.io, &env, null, .{}, &problem); defer with.deinit(); try testing.expectEqual(@as(u16, 132), with.config().window.columns);
// ...and not at all with `no_file`. var without = try load(testing.allocator, testing.io, &env, null, .{ .no_file = true }, &problem); defer without.deinit(); try testing.expect(without.path == null); try testing.expectEqual(@as(u16, 80), without.config().window.columns);}