// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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); }