Create config/helpers.zig

This moves all the helper functions from Config.zig into their own file
This commit is contained in:
Ben Buhse 2026-02-16 12:01:47 -06:00
commit 8b43e491e7
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
2 changed files with 176 additions and 155 deletions

View file

@ -320,7 +320,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
// If it's a node, we check if it's a valid NodeName // If it's a node, we check if it's a valid NodeName
const node_name = std.meta.stringToEnum(NodeName, node.name); const node_name = std.meta.stringToEnum(NodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, &parser, hostname)) { if (!helpers.hostMatches(node, &parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -356,7 +356,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
}, },
.focus_follows_pointer => { .focus_follows_pointer => {
const focus_follows_pointer_str = utils.stripQuotes(node.arg(&parser, 0) orelse ""); const focus_follows_pointer_str = utils.stripQuotes(node.arg(&parser, 0) orelse "");
if (boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| { if (helpers.boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| {
config.focus_follows_pointer = focus_follows_pointer; config.focus_follows_pointer = focus_follows_pointer;
logDebugSettingNode(name, focus_follows_pointer_str); logDebugSettingNode(name, focus_follows_pointer_str);
} else { } else {
@ -366,7 +366,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
}, },
.pointer_warp_on_focus_change => { .pointer_warp_on_focus_change => {
const pointer_warp_on_focus_change_str = utils.stripQuotes(node.arg(&parser, 0) orelse ""); const pointer_warp_on_focus_change_str = utils.stripQuotes(node.arg(&parser, 0) orelse "");
if (boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| { if (helpers.boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| {
config.pointer_warp_on_focus_change = pointer_warp_on_focus_change; config.pointer_warp_on_focus_change = pointer_warp_on_focus_change;
logDebugSettingNode(name, pointer_warp_on_focus_change_str); logDebugSettingNode(name, pointer_warp_on_focus_change_str);
} else { } else {
@ -381,7 +381,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
} }
const path_str = utils.stripQuotes(node.arg(&parser, 0).?); const path_str = utils.stripQuotes(node.arg(&parser, 0).?);
config.wallpaper_image_path = expandTilde(path_str) catch { config.wallpaper_image_path = helpers.expandTilde(path_str) catch {
logWarnInvalidNodeArg(name, path_str); logWarnInvalidNodeArg(name, path_str);
continue; continue;
}; };
@ -429,7 +429,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
} }
next_child_block = null; next_child_block = null;
} else { } else {
try config.skipChildBlock(&parser); try helpers.skipChildBlock(&parser);
} }
}, },
.child_block_end => log.err("Reached unexpected .child_block_end. Ignoring it", .{}), .child_block_end => log.err("Reached unexpected .child_block_end. Ignoring it", .{}),
@ -444,7 +444,7 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]cons
// If it's a node, we check if it's a valid NodeName // If it's a node, we check if it's a valid NodeName
const node_name = std.meta.stringToEnum(BorderNodeName, node.name); const node_name = std.meta.stringToEnum(BorderNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -480,7 +480,7 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]cons
}, },
.child_block_begin => { .child_block_begin => {
// borders should never have a nested child block // borders should never have a nested child block
try config.skipChildBlock(parser); try helpers.skipChildBlock(parser);
}, },
.child_block_end => { .child_block_end => {
// Done parsing the borders block; return // Done parsing the borders block; return
@ -505,7 +505,7 @@ fn loadTagOverlayChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]c
} }
const node_name = std.meta.stringToEnum(TagOverlayNodeName, node.name); const node_name = std.meta.stringToEnum(TagOverlayNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -564,7 +564,7 @@ fn loadTagOverlayChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]c
} }
next_child_block = null; next_child_block = null;
} else { } else {
try config.skipChildBlock(parser); try helpers.skipChildBlock(parser);
} }
}, },
.child_block_end => return, .child_block_end => return,
@ -578,12 +578,12 @@ fn loadTagOverlayAnchorsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
.node => |node| { .node => |node| {
const node_name = std.meta.stringToEnum(TagOverlayAnchorsNodeName, node.name); const node_name = std.meta.stringToEnum(TagOverlayAnchorsNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); const val_str = utils.stripQuotes(node.arg(parser, 0) orelse "");
if (boolFromKdlStr(val_str)) |val| { if (helpers.boolFromKdlStr(val_str)) |val| {
switch (name) { switch (name) {
.top => config.tag_overlay.?.anchor_top = val, .top => config.tag_overlay.?.anchor_top = val,
.right => config.tag_overlay.?.anchor_right = val, .right => config.tag_overlay.?.anchor_right = val,
@ -598,7 +598,7 @@ fn loadTagOverlayAnchorsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
logWarnInvalidNode(node.name); logWarnInvalidNode(node.name);
} }
}, },
.child_block_begin => try config.skipChildBlock(parser), .child_block_begin => try helpers.skipChildBlock(parser),
.child_block_end => return, .child_block_end => return,
} }
} }
@ -610,7 +610,7 @@ fn loadTagOverlayMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
.node => |node| { .node => |node| {
const node_name = std.meta.stringToEnum(TagOverlayMarginsNodeName, node.name); const node_name = std.meta.stringToEnum(TagOverlayMarginsNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -630,7 +630,7 @@ fn loadTagOverlayMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
logWarnInvalidNode(node.name); logWarnInvalidNode(node.name);
} }
}, },
.child_block_begin => try config.skipChildBlock(parser), .child_block_begin => try helpers.skipChildBlock(parser),
.child_block_end => return, .child_block_end => return,
} }
} }
@ -642,7 +642,7 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con
.node => |node| { .node => |node| {
// tag_bind is a special case node name not in KeybindNodeName // tag_bind is a special case node name not in KeybindNodeName
if (mem.eql(u8, node.name, "tag_bind")) { if (mem.eql(u8, node.name, "tag_bind")) {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
log.debug("Skipping \"keybind.tag_bind\" (host mismatch)", .{}); log.debug("Skipping \"keybind.tag_bind\" (host mismatch)", .{});
continue; continue;
} }
@ -687,7 +687,7 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con
// Handle the rest of the possibilities like all the other types of block // Handle the rest of the possibilities like all the other types of block
const node_name = std.meta.stringToEnum(KeybindNodeName, node.name); const node_name = std.meta.stringToEnum(KeybindNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -721,7 +721,7 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con
var split_exec = try utils.tokenizeToOwnedSlices(exec_str, ' '); var split_exec = try utils.tokenizeToOwnedSlices(exec_str, ' ');
if (split_exec.len > 0) { if (split_exec.len > 0) {
// Expand ~ in executable paths // Expand ~ in executable paths
const expanded = expandTilde(split_exec[0]) catch |e| { const expanded = helpers.expandTilde(split_exec[0]) catch |e| {
if (e == error.HomeNotSet) { if (e == error.HomeNotSet) {
// No ~, just return what we had. // No ~, just return what we had.
break :sw .{ .spawn = split_exec }; break :sw .{ .spawn = split_exec };
@ -808,7 +808,7 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con
}, },
.child_block_begin => { .child_block_begin => {
// keybinds should never have a nested child block // keybinds should never have a nested child block
try config.skipChildBlock(parser); try helpers.skipChildBlock(parser);
}, },
.child_block_end => { .child_block_end => {
// Done parsing the keybinds block; return // Done parsing the keybinds block; return
@ -824,7 +824,7 @@ fn loadPointerBindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
.node => |node| { .node => |node| {
const node_name = std.meta.stringToEnum(PointerBindNodeName, node.name); const node_name = std.meta.stringToEnum(PointerBindNodeName, node.name);
if (node_name) |name| { if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name); logDebugHostMismatch(name);
continue; continue;
} }
@ -843,7 +843,7 @@ fn loadPointerBindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
logWarnMissingNodeArg(name, "button"); logWarnMissingNodeArg(name, "button");
continue; continue;
}); });
const button = parseButton(button_str) orelse { const button = helpers.parseButton(button_str) orelse {
logWarnInvalidNodeArg(name, button_str); logWarnInvalidNodeArg(name, button_str);
continue; continue;
}; };
@ -865,7 +865,7 @@ fn loadPointerBindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[
} }
}, },
.child_block_begin => { .child_block_begin => {
try config.skipChildBlock(parser); try helpers.skipChildBlock(parser);
}, },
.child_block_end => { .child_block_end => {
return; return;
@ -883,7 +883,7 @@ fn loadInputChildBlock(config: *Config, parser: *kdl.Parser, name: ?[]const u8,
.node => |node| { .node => |node| {
const node_name = std.meta.stringToEnum(InputConfigNodeName, node.name); const node_name = std.meta.stringToEnum(InputConfigNodeName, node.name);
if (node_name) |tag| { if (node_name) |tag| {
if (!hostMatches(node, parser, hostname)) { if (!helpers.hostMatches(node, parser, hostname)) {
logDebugHostMismatch(tag); logDebugHostMismatch(tag);
continue; continue;
} }
@ -901,7 +901,7 @@ fn loadInputChildBlock(config: *Config, parser: *kdl.Parser, name: ?[]const u8,
log.debug("input.accel_speed: {s}", .{val_str}); log.debug("input.accel_speed: {s}", .{val_str});
}, },
.scroll_button => { .scroll_button => {
const button = parseButton(val_str) orelse { const button = helpers.parseButton(val_str) orelse {
logWarnInvalidNodeArg(tag, val_str); logWarnInvalidNodeArg(tag, val_str);
continue; continue;
}; };
@ -951,7 +951,7 @@ fn loadInputChildBlock(config: *Config, parser: *kdl.Parser, name: ?[]const u8,
} }
}, },
.child_block_begin => { .child_block_begin => {
try config.skipChildBlock(parser); try helpers.skipChildBlock(parser);
}, },
.child_block_end => { .child_block_end => {
try config.input_configs.append(utils.gpa, input_config); try config.input_configs.append(utils.gpa, input_config);
@ -961,65 +961,6 @@ fn loadInputChildBlock(config: *Config, parser: *kdl.Parser, name: ?[]const u8,
} }
} }
fn parseButton(s: []const u8) ?u32 {
// Support both numeric and named buttons
var lower_buf: [16]u8 = undefined;
const len = @min(s.len, 16);
const lower = std.ascii.lowerString(lower_buf[0..len], s[0..len]);
if (mem.eql(u8, lower, "btn_left") or mem.eql(u8, lower, "button1")) {
return 0x110; // BTN_LEFT = 272
} else if (mem.eql(u8, lower, "btn_right") or mem.eql(u8, lower, "button3")) {
return 0x111; // BTN_RIGHT = 273
} else if (mem.eql(u8, lower, "btn_middle") or mem.eql(u8, lower, "button2")) {
return 0x112; // BTN_MIDDLE = 274
}
// Try parsing as hex or decimal
return fmt.parseInt(u32, s, 0) catch null;
}
/// Skips an entire child block including any nested child blocks
fn skipChildBlock(_: *Config, parser: *kdl.Parser) !void {
log.warn("Unexpected child block. Skipping it", .{});
var depth: usize = 0;
while (try parser.next()) |event| {
switch (event) {
// Nested child block
.child_block_begin => depth += 1,
.child_block_end => {
if (depth == 0) {
return;
} else {
depth -= 1;
}
},
else => {
// We don't care about any nodes in here
},
}
}
}
/// Convert a KDL argument into a bool
///
/// if arg_str in ["#true", "true"], return true
/// if arg_str in ["#false", "false"], return false
/// else, return null
fn boolFromKdlStr(arg_str: []const u8) ?bool {
if (mem.eql(u8, arg_str, "#true") or
mem.eql(u8, arg_str, "true"))
{
return true;
} else if (mem.eql(u8, arg_str, "#false") or
mem.eql(u8, arg_str, "false"))
{
return false;
}
return null;
}
fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void { fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void {
const node_name_type = @TypeOf(node_name); const node_name_type = @TypeOf(node_name);
switch (node_name_type) { switch (node_name_type) {
@ -1086,23 +1027,6 @@ fn logDebugSettingNode(node_name: anytype, node_value: []const u8) void {
} }
} }
fn expandTilde(path: []const u8) ![]const u8 {
if (path.len > 0 and path[0] == '~') {
const home = std.posix.getenv("HOME") orelse return error.HomeNotSet;
return std.fmt.allocPrint(utils.gpa, "{s}{s}", .{ home, path[1..] });
}
return utils.gpa.dupe(u8, path);
}
/// Check whether this machine's hostname matches the hostname property
/// Always returns true if the "host" property is missing (no host = config applies to
/// all hosts). Returns false if the hostname argument is null or does not match.
fn hostMatches(node: kdl.Parser.Node, parser: *kdl.Parser, hostname: ?[]const u8) bool {
const host_property = utils.stripQuotes(node.prop(parser, "host") orelse return true);
const hostname_str = hostname orelse return false;
return mem.eql(u8, host_property, hostname_str);
}
const std = @import("std"); const std = @import("std");
const fmt = std.fmt; const fmt = std.fmt;
const fs = std.fs; const fs = std.fs;
@ -1138,63 +1062,12 @@ const RiverColor = utils.RiverColor;
const TagOverlay = @import("TagOverlay.zig"); const TagOverlay = @import("TagOverlay.zig");
const XkbBindings = @import("XkbBindings.zig"); const XkbBindings = @import("XkbBindings.zig");
const helpers = @import("config/helpers.zig");
const log = std.log.scoped(.Config); const log = std.log.scoped(.Config);
const testing = std.testing; const testing = std.testing;
test "boolFromKdlStr" { test {
// True valid _ = helpers;
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("#true"));
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("true"));
// False valid
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("#false"));
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("false"));
// Invalid
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("yes"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("1"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr(""));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("TRUE"));
}
test "parseButton named buttons" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("btn_left"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("button1"));
try testing.expectEqual(@as(?u32, 0x111), parseButton("btn_right"));
try testing.expectEqual(@as(?u32, 0x111), parseButton("button3"));
try testing.expectEqual(@as(?u32, 0x112), parseButton("btn_middle"));
try testing.expectEqual(@as(?u32, 0x112), parseButton("button2"));
}
test "parseButton case insensitive" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("BTN_LEFT"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("Btn_Left"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("BUTTON1"));
}
test "parseButton numeric decimal" {
try testing.expectEqual(@as(?u32, 272), parseButton("272"));
try testing.expectEqual(@as(?u32, 0), parseButton("0"));
}
test "parseButton numeric hex" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("0x110"));
}
test "parseButton invalid" {
try testing.expectEqual(@as(?u32, null), parseButton("bogus"));
try testing.expectEqual(@as(?u32, null), parseButton(""));
}
test "expandTilde with tilde" {
const result = try expandTilde("~/foo/bar");
defer utils.gpa.free(result);
const home = std.posix.getenv("HOME") orelse return;
try testing.expect(mem.startsWith(u8, result, home));
try testing.expect(mem.endsWith(u8, result, "/foo/bar"));
}
test "expandTilde without tilde" {
const result = try expandTilde("/absolute/path");
defer utils.gpa.free(result);
try testing.expectEqualStrings("/absolute/path", result);
} }

148
src/config/helpers.zig Normal file
View file

@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
/// Convert a KDL argument into a bool
///
/// if arg_str in ["#true", "true"], return true
/// if arg_str in ["#false", "false"], return false
/// else, return null
pub fn boolFromKdlStr(arg_str: []const u8) ?bool {
if (mem.eql(u8, arg_str, "#true") or
mem.eql(u8, arg_str, "true"))
{
return true;
} else if (mem.eql(u8, arg_str, "#false") or
mem.eql(u8, arg_str, "false"))
{
return false;
}
return null;
}
pub fn parseButton(s: []const u8) ?u32 {
// Support both numeric and named buttons
var lower_buf: [16]u8 = undefined;
const len = @min(s.len, 16);
const lower = std.ascii.lowerString(lower_buf[0..len], s[0..len]);
if (mem.eql(u8, lower, "btn_left") or mem.eql(u8, lower, "button1")) {
return 0x110; // BTN_LEFT = 272
} else if (mem.eql(u8, lower, "btn_right") or mem.eql(u8, lower, "button3")) {
return 0x111; // BTN_RIGHT = 273
} else if (mem.eql(u8, lower, "btn_middle") or mem.eql(u8, lower, "button2")) {
return 0x112; // BTN_MIDDLE = 274
}
// Try parsing as hex or decimal
return fmt.parseInt(u32, s, 0) catch null;
}
pub fn expandTilde(path: []const u8) ![]const u8 {
if (path.len > 0 and path[0] == '~') {
const home = std.posix.getenv("HOME") orelse return error.HomeNotSet;
return std.fmt.allocPrint(utils.gpa, "{s}{s}", .{ home, path[1..] });
}
return utils.gpa.dupe(u8, path);
}
/// Check whether this machine's hostname matches the hostname property
/// Always returns true if the "host" property is missing (no host = config applies to
/// all hosts). Returns false if the hostname argument is null or does not match.
pub fn hostMatches(node: kdl.Parser.Node, parser: *kdl.Parser, hostname: ?[]const u8) bool {
const host_property = utils.stripQuotes(node.prop(parser, "host") orelse return true);
const hostname_str = hostname orelse return false;
return mem.eql(u8, host_property, hostname_str);
}
/// Skips an entire child block including any nested child blocks
pub fn skipChildBlock(parser: *kdl.Parser) !void {
log.warn("Unexpected child block. Skipping it", .{});
var depth: usize = 0;
while (try parser.next()) |event| {
switch (event) {
// Nested child block
.child_block_begin => depth += 1,
.child_block_end => {
if (depth == 0) {
return;
} else {
depth -= 1;
}
},
else => {
// We don't care about any nodes in here
},
}
}
}
const std = @import("std");
const fmt = std.fmt;
const mem = std.mem;
const kdl = @import("kdl");
const utils = @import("../utils.zig");
const log = std.log.scoped(.config_helpers);
const testing = std.testing;
test "boolFromKdlStr" {
// True valid
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("#true"));
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("true"));
// False valid
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("#false"));
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("false"));
// Invalid
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("yes"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("1"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr(""));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("TRUE"));
}
test "parseButton named buttons" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("btn_left"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("button1"));
try testing.expectEqual(@as(?u32, 0x111), parseButton("btn_right"));
try testing.expectEqual(@as(?u32, 0x111), parseButton("button3"));
try testing.expectEqual(@as(?u32, 0x112), parseButton("btn_middle"));
try testing.expectEqual(@as(?u32, 0x112), parseButton("button2"));
}
test "parseButton case insensitive" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("BTN_LEFT"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("Btn_Left"));
try testing.expectEqual(@as(?u32, 0x110), parseButton("BUTTON1"));
}
test "parseButton numeric decimal" {
try testing.expectEqual(@as(?u32, 272), parseButton("272"));
try testing.expectEqual(@as(?u32, 0), parseButton("0"));
}
test "parseButton numeric hex" {
try testing.expectEqual(@as(?u32, 0x110), parseButton("0x110"));
}
test "parseButton invalid" {
try testing.expectEqual(@as(?u32, null), parseButton("bogus"));
try testing.expectEqual(@as(?u32, null), parseButton(""));
}
test "expandTilde with tilde" {
const result = try expandTilde("~/foo/bar");
defer utils.gpa.free(result);
const home = std.posix.getenv("HOME") orelse return;
try testing.expect(mem.startsWith(u8, result, home));
try testing.expect(mem.endsWith(u8, result, "/foo/bar"));
}
test "expandTilde without tilde" {
const result = try expandTilde("/absolute/path");
defer utils.gpa.free(result);
try testing.expectEqualStrings("/absolute/path", result);
}