// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Finding the configuration file and evaluating it. //! //! The file is `grebe/config.struthio` in the user's configuration //! directory, or the one `GREBE_CONFIG` names; `GREBE_CONFIG` set but //! empty reads none. A configuration can `@include("path")` another file, //! which stands for that file's value: colors kept beside it, say. A //! relative path is resolved against where the including file really is, //! symbolic links followed. //! //! The program is kept, to evaluate again when the desktop's settings //! change, against the new ones. 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 aerie = @import("aerie"); const desktop = aerie.desktop; const Config = @import("Config.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; the /// files it includes were read with it. program: struthio.Program, /// What it was evaluated with, its strings `program`'s. ctx: Config.Context, /// The desktop's settings `@setting` reads, owned; replaced on each /// evaluation, and kept as long as the program, whose functions point /// at it. settings: *desktop.Settings, /// 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; } /// Evaluates the program again against `settings`, which it takes; /// on failure, keeps what it had and says why in `diag`. pub fn reevaluate(l: *Loaded, gpa: Allocator, settings: desktop.Settings, diag: *struthio.Diagnostic) struthio.EvalError!void { var s = settings; var ctx = l.ctx; ctx.appearance = s.appearance(); ctx.appearance.settle(); // The new settings go where the functions look, for this // evaluation, and back if it fails. std.mem.swap(desktop.Settings, l.settings, &s); defer s.deinit(); var parsed = l.program.eval(Config, gpa, &ctx, diag) catch |err| { std.mem.swap(desktop.Settings, l.settings, &s); return err; }; // The path is in the old parse's arena. const path = if (l.path) |p| parsed.arena.allocator().dupe(u8, p) catch null else null; l.parsed.deinit(); l.parsed = parsed; l.path = path; l.ctx = ctx; l.ctx.appearance = dupeAppearance(l.program.arena.allocator(), ctx.appearance) catch ctx.appearance; } /// The appearance it was last evaluated against. pub fn appearance(l: *const Loaded) desktop.Appearance { return l.ctx.appearance; } pub fn deinit(l: *Loaded, gpa: Allocator) void { l.parsed.deinit(); l.program.deinit(); l.settings.deinit(); gpa.destroy(l.settings); } }; 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]; } pub 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() }); } /// 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: `grebe/config.struthio` under the user's /// configuration directory as known-folders finds it — `$XDG_CONFIG_HOME`, /// or `~/.config`. Null if there is none. const hostname_max = if (builtin.os.tag == .windows) 256 else std.posix.HOST_NAME_MAX; /// The machine's name, as `ctx.hostname` gives it: Windows' computer name, /// or what `gethostname` says; `localhost` if neither will say. fn hostName(env: *const std.process.Environ.Map, buf: *[hostname_max]u8) []const u8 { if (builtin.os.tag == .windows) return env.get("COMPUTERNAME") orelse "localhost"; return std.posix.gethostname(buf) catch "localhost"; } 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, "grebe", file_name }); } /// Loads the configuration for `repository`, against the desktop's /// `settings`, which it takes. With no file, the defaults. pub fn load( gpa: Allocator, io: Io, env: *const std.process.Environ.Map, repository: []const u8, settings: desktop.Settings, problem: *Problem, ) Error!Loaded { var owned_settings = settings; const named = env.get("GREBE_CONFIG"); const default_path = if (named == null) defaultPath(gpa, io, env) catch |err| switch (err) { error.OutOfMemory => { owned_settings.deinit(); 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: ?[]const u8 = if (named) |n| (if (n.len == 0) null else n) else default_path; return loadPath(gpa, io, env, chosen, named != null, repository, owned_settings, problem); } /// Loads `path`, or nothing: the defaults. A path that must exist is an /// error if it does not; one that need not is the defaults. pub fn loadPath( gpa: Allocator, io: Io, env: *const std.process.Environ.Map, path: ?[]const u8, must_exist: bool, repository: []const u8, settings: desktop.Settings, problem: *Problem, ) Error!Loaded { const s = gpa.create(desktop.Settings) catch { var dropped = settings; dropped.deinit(); return error.OutOfMemory; }; s.* = settings; errdefer { s.deinit(); gpa.destroy(s); } if (path) |p| problem.setPath(p); const source: ?[]u8 = if (path) |p| Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(max_size)) catch |err| switch (err) { error.FileNotFound => if (!must_exist) null else { problem.read = err; return error.ReadFailed; }, else => { problem.read = err; return error.ReadFailed; }, } else null; defer if (source) |src| gpa.free(src); // 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, path.?) catch null else null; var includer: Includer = .{ .io = io }; 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 = s, .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(); var hostname_buf: [hostname_max]u8 = undefined; const hostname = hostName(env, &hostname_buf); const keep = program.arena.allocator(); var ctx: Config.Context = .{ .hostname = try keep.dupe(u8, hostname), .os = @tagName(builtin.target.os.tag), .repository = try keep.dupe(u8, repository), .appearance = s.appearance(), }; ctx.appearance.settle(); ctx.appearance = try dupeAppearance(keep, ctx.appearance); var parsed = program.eval(Config, gpa, &ctx, &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, path.?) else null; return .{ .parsed = parsed, .program = program, .ctx = ctx, .settings = s, .path = owned_path }; } /// `a` with its strings in `arena`. fn dupeAppearance(arena: Allocator, a: desktop.Appearance) Allocator.Error!desktop.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", "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", .{}); const s: *const desktop.Settings = @ptrCast(@alignCast(call.context.?)); const v = s.get(args[0].string, args[1].string) orelse return .null; return toStruthio(call.arena, v) catch return error.OutOfMemory; } /// `v` as a struthio value, allocated from `arena`. fn toStruthio(arena: Allocator, v: desktop.settings.Value) Allocator.Error!struthio.Value { return switch (v) { .string => |x| .{ .string = x }, .int => |x| .{ .int = x }, .float => |x| .{ .float = x }, .bool => |x| .{ .bool = x }, .floats => |x| blk: { const list = try arena.alloc(struthio.Value, x.len); for (x, list) |f, *l| l.* = .{ .float = f }; break :blk .{ .list = list }; }, .strings => |x| blk: { const list = try arena.alloc(struthio.Value, x.len); for (x, list) |p, *l| l.* = .{ .string = p }; break :blk .{ .list = list }; }, }; } /// `@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 writeConfig(tmp: *testing.TmpDir, data: []const u8) ![:0]u8 { try tmp.dir.writeFile(testing.io, .{ .sub_path = file_name, .data = data }); return tmp.dir.realPathFileAlloc(testing.io, file_name, testing.allocator); } test "no file gives the defaults" { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("GREBE_CONFIG", ""); var problem: Problem = .{}; var l = try load(testing.allocator, testing.io, &env, "/repo", .init(testing.allocator), &problem); defer l.deinit(testing.allocator); try testing.expect(l.path == null); try testing.expectEqual(@as(u16, 3), l.config().diff.context); } test "a file named must exist" { var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("GREBE_CONFIG", "/nonexistent-grebe-test/x"); var problem: Problem = .{}; try testing.expectError(error.ReadFailed, load(testing.allocator, testing.io, &env, "/repo", .init(testing.allocator), &problem)); var buf: [256]u8 = undefined; try testing.expectEqualStrings("/nonexistent-grebe-test/x: no such file", try std.fmt.bufPrint(&buf, "{f}", .{&problem})); } test "a file sees the repository, the environment and the desktop" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const path = try writeConfig(&tmp, \\.{ \\ .diff = .{ .context = if (ctx.repository == "/src/linux") 10 else 3 }, \\ .font = .{ \\ .mono = .{ .family = @env("GREBE_TEST") orelse "monospace" }, \\ .ui = .{ .family = @setting("org.gnome.desktop.interface", "font-name") }, \\ }, \\ .colors = if (ctx.appearance.color_scheme == .dark) .{ .background = "#000000" } else .{}, \\ .keybind = .{ .{ .keys = "ctrl+r", .action = .reload } }, \\} ); defer testing.allocator.free(path); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); try env.put("GREBE_TEST", "Iosevka"); var s: desktop.Settings = .init(testing.allocator); _ = try s.put("org.gnome.desktop.interface", "font-name", .{ .string = "Cantarell 11" }); var problem: Problem = .{}; var l = loadPath(testing.allocator, testing.io, &env, path, true, "/src/linux", s, &problem) catch |err| { std.debug.print("{f}\n", .{&problem}); return err; }; defer l.deinit(testing.allocator); try testing.expectEqual(@as(u16, 10), l.config().diff.context); try testing.expectEqualStrings("Cantarell 11", l.config().font.ui.family.?); try testing.expectEqual(@as(?[]const u8, null), l.config().colors.background); try testing.expectEqual(Config.Action.reload, l.config().keybind[0].action); // The desktop goes dark. var dark: desktop.Settings = .init(testing.allocator); _ = try dark.put("org.freedesktop.appearance", "color-scheme", .{ .int = 1 }); var diag: struthio.Diagnostic = .{}; try l.reevaluate(testing.allocator, dark, &diag); try testing.expectEqualStrings("#000000", l.config().colors.background.?); try testing.expectEqual(.dark, l.appearance().color_scheme); // The font setting went with the old settings. try testing.expectEqual(@as(?[]const u8, null), l.config().font.ui.family); try testing.expectEqualStrings(path, l.path.?); } test "the README's example" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const path = try writeConfig(&tmp, \\.{ \\ // The interface's font and the diff's; unset is the desktop's. \\ .font = .{ .mono = .{ .family = "Iosevka", .size = 11 } }, \\ // Any of the palette's colors, by name, over the light or dark one. \\ .colors = if (ctx.appearance.color_scheme == .dark) \\ .{ .list_background = "#101418", .graph = .{ "#58a6ff", "#ff7b72" } } \\ else \\ .{ .selection_background = ctx.appearance.accent_color }, \\ // How a diff is shown at first. \\ .diff = .{ .context = if (ctx.repository == "/src/linux") 5 else 3 }, \\ // Over gitk's keys; `.unbind` takes one away. \\ .keybind = .{ \\ .{ .keys = "ctrl+r", .action = .reload }, \\ .{ .keys = "ctrl+q", .action = .unbind }, \\ }, \\} ); defer testing.allocator.free(path); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); var s: desktop.Settings = .init(testing.allocator); _ = try s.put("org.freedesktop.appearance", "accent-color", .{ .floats = &.{ 0, 0.5, 1 } }); var problem: Problem = .{}; var l = loadPath(testing.allocator, testing.io, &env, path, true, "/src/linux", s, &problem) catch |err| { std.debug.print("{f}\n", .{&problem}); return err; }; defer l.deinit(testing.allocator); try testing.expectEqual(@as(u16, 5), l.config().diff.context); try testing.expectEqualStrings("#0080ff", l.config().colors.selection_background.?); try testing.expectEqual(@as(?f32, 11), l.config().font.mono.size); } test "a mistake comes back with a position" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const path = try writeConfig(&tmp, ".{\n .fnot = .{},\n}"); defer testing.allocator.free(path); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); var problem: Problem = .{}; try testing.expectError(error.Invalid, loadPath(testing.allocator, testing.io, &env, path, true, "/repo", .init(testing.allocator), &problem)); var buf: [1024]u8 = undefined; const msg = try std.fmt.bufPrint(&buf, "{f}", .{&problem}); try testing.expect(std.mem.find(u8, msg, ":2:") != null); }