Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}