// SPDX-FileCopyrightText: © 2026 mtmn // // SPDX-License-Identifier: BSD-3-Clause const Config = @This(); const std = @import("std"); pub const XcursorTheme = struct { name: []const u8, size: u32 = 24, }; pub const Repeat = struct { rate: i32, delay: i32, }; pub const KeyboardLayout = struct { layout: []const u8, rules: ?[]const u8 = null, model: ?[]const u8 = null, variant: ?[]const u8 = null, options: ?[]const u8 = null, }; pub const Binding = struct { keysym: []const u8, command: []const u8, }; pub const BindingGroup = struct { modifiers: []const []const u8, bindings: []const Binding = &.{}, }; pub const PointerBinding = struct { button: []const u8, action: []const u8, }; pub const PointerBindingGroup = struct { modifiers: []const []const u8, bindings: []const PointerBinding = &.{}, }; pub const Mode = struct { name: []const u8, bindings: []const BindingGroup = &.{}, pointer_bindings: []const PointerBindingGroup = &.{}, }; pub const InputRule = struct { pattern: []const u8, category: ?[]const u8 = null, setting: []const u8, value: ?[]const u8 = null, }; pub const Rule = struct { app_id: ?[]const u8 = null, title: ?[]const u8 = null, action: []const u8, tags: ?u32 = null, }; pub const Osd = struct { enabled: ?bool = null, timeout_ms: ?u32 = null, size: ?u32 = null, background_color: ?[]const u8 = null, text_color: ?[]const u8 = null, }; main_ratio: ?f32 = null, main_count: ?u32 = null, main_location: ?[]const u8 = null, attach_mode: ?[]const u8 = null, output_attach_mode: ?[]const u8 = null, spawn_tagmask: ?u32 = null, border_width: ?i32 = null, border_color_focused: ?[]const u8 = null, border_color_unfocused: ?[]const u8 = null, focus_follows_cursor: ?[]const u8 = null, csd: ?bool = null, allow_tearing: ?bool = null, cursor_warp: ?[]const u8 = null, xcursor_theme: ?XcursorTheme = null, repeat: ?Repeat = null, keyboard_layout: ?KeyboardLayout = null, keyboard_layout_file: ?[]const u8 = null, modes: []const Mode = &.{}, inputs: []const InputRule = &.{}, rules: []const Rule = &.{}, osd: ?Osd = null, /// Parse config source. Unknown fields are rejected so typos fail loudly. /// Diagnostics are left to the caller so tests stay silent on purpose. /// The returned value borrows from both source and allocator-owned memory. /// The source must be a ZON document. pub fn parse( allocator: std.mem.Allocator, source: [:0]const u8, diagnostics: ?*std.zon.parse.Diagnostics, ) !Config { @setEvalBranchQuota(10000); // SAFETY: fromSlice initializes diagnostics before any read. var fallback_diagnostics: std.zon.parse.Diagnostics = undefined; return std.zon.parse.fromSlice(Config, .{ .gpa = allocator, .arena = allocator, .source = source, .diagnostics = diagnostics orelse &fallback_diagnostics, }); } /// Default config path: $XDG_CONFIG_HOME/river/laguna.zon, falling back to /// $HOME/.config/river/laguna.zon. Returns null when neither variable exists. pub fn defaultPath(allocator: std.mem.Allocator) !?[]const u8 { if (std.c.getenv("XDG_CONFIG_HOME")) |home| { return try std.Io.Dir.path.join(allocator, &.{ std.mem.sliceTo(home, 0), "river/laguna.zon", }); } if (std.c.getenv("HOME")) |home| { return try std.Io.Dir.path.join(allocator, &.{ std.mem.sliceTo(home, 0), ".config/river/laguna.zon", }); } return null; } test "parses a representative config" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const source = \\.{ \\ .main_ratio = 0.5, \\ .border_width = 1, \\ .xcursor_theme = .{ .name = "McMojave", .size = 16 }, \\ .modes = .{ \\ .{ .name = "normal", .bindings = .{ \\ .{ .modifiers = .{ "Mod1+Shift" }, .bindings = .{ \\ .{ .keysym = "Return", .command = "spawn foot" }, \\ } }, \\ } }, \\ .{ .name = "locked" }, \\ }, \\ .inputs = .{ \\ .{ .pattern = "*", .category = "touchpad", .setting = "tap", .value = "disable" }, \\ }, \\ .rules = .{ \\ .{ .app_id = "float*", .action = "float" }, \\ }, \\} ; const owned = try arena.allocator().dupeSentinel(u8, source, 0); const config = try parse(arena.allocator(), owned, null); try testing.expectEqual(@as(?f32, 0.5), config.main_ratio); try testing.expectEqual(@as(?i32, 1), config.border_width); try testing.expectEqualStrings("McMojave", config.xcursor_theme.?.name); try testing.expectEqual(@as(usize, 2), config.modes.len); try testing.expectEqualStrings("normal", config.modes[0].name); try testing.expectEqualStrings("spawn foot", config.modes[0].bindings[0].bindings[0].command); try testing.expectEqualStrings("touchpad", config.inputs[0].category.?); try testing.expectEqual(@as(?u32, null), config.rules[0].tags); } test "shipped example parses" { const testing = std.testing; const io = std.Io.Threaded.global_single_threaded.io(); var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const source = std.Io.Dir.cwd().readFileAlloc( io, "example/laguna.zon", arena.allocator(), .limited(1024 * 1024), ) catch |err| { if (err == error.FileNotFound) return error.SkipZigTest; return err; }; const owned = try arena.allocator().dupeSentinel(u8, source, 0); const config = try parse(arena.allocator(), owned, null); var binding_count: usize = 0; for (config.modes) |mode| { for (mode.bindings) |group| { binding_count += group.bindings.len * group.modifiers.len; } } try testing.expectEqual(@as(usize, 130), binding_count); try testing.expectEqual(@as(usize, 1), config.modes.len); try testing.expectEqual(@as(usize, 7), config.inputs.len); try testing.expectEqual(@as(?f32, 0.5), config.main_ratio); } test "parses the osd block" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const source = \\.{ \\ .osd = .{ \\ .enabled = false, \\ .timeout_ms = 700, \\ .size = 128, \\ .background_color = "0x111111b4", \\ .text_color = "0xffffff", \\ }, \\} ; const owned = try arena.allocator().dupeSentinel(u8, source, 0); const config = try parse(arena.allocator(), owned, null); const osd = config.osd orelse return error.TestUnexpectedResult; try testing.expectEqual(false, osd.enabled.?); try testing.expectEqual(@as(?u32, 700), osd.timeout_ms); try testing.expectEqual(@as(?u32, 128), osd.size); try testing.expectEqualStrings("0x111111b4", osd.background_color.?); try testing.expectEqualStrings("0xffffff", osd.text_color.?); } test "osd block is optional" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const owned = try arena.allocator().dupeSentinel(u8, ".{}", 0); const config = try parse(arena.allocator(), owned, null); try testing.expectEqual(@as(?Config.Osd, null), config.osd); } test "rejects unknown osd fields" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const owned = try arena.allocator().dupeSentinel( u8, ".{ .osd = .{ .colour = \"0xffffff\" } }", 0, ); try testing.expectError(error.ParseZon, parse(arena.allocator(), owned, null)); } test "defaultPath uses laguna zon" { const allocator = std.testing.allocator; const path = try defaultPath(allocator); defer allocator.free(path.?); try std.testing.expect(std.mem.endsWith(u8, path.?, "/river/laguna.zon")); } test "rejects unknown fields" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const owned = try arena.allocator().dupeSentinel(u8, ".{ .main_ration = 0.5, }", 0); try testing.expectError(error.ParseZon, parse(arena.allocator(), owned, null)); }