// SPDX-FileCopyrightText: © 2026 mtmn // // SPDX-License-Identifier: BSD-3-Clause const Command = @This(); const std = @import("std"); pub const arguments_max = 32; pub const size_max = 4096; // Word end offsets are u16, so the buffer must always fit. comptime { std.debug.assert(size_max <= std.math.maxInt(u16)); } /// fromText and fromSlices fill every field. They reset only count: just /// the bytes up to the last word end are ever read, so the 4 KiB buffer /// needs no clearing between uses. buffer: [size_max]u8, /// End offset of each word inside buffer. Offsets, not pointers, so the /// struct stays valid after a by-value copy into the pending queue. ends: [arguments_max]u16, count: usize, /// Set by the queueing site, not by fromText or fromSlices: commands built /// from the static config file must not trigger the tag OSD, so a session /// start or reload never flashes it. from_config: bool = false, pub const Cursor = struct { command: *const Command, index: usize = 0, start: usize = 0, pub fn next(cursor: *Cursor) ?[]const u8 { if (cursor.index == cursor.command.count) return null; const end = cursor.command.ends[cursor.index]; const argument = cursor.command.buffer[cursor.start..end]; cursor.start = end; cursor.index += 1; return argument; } }; pub fn arguments(command: *const Command) Cursor { return .{ .command = command }; } test "fromText splits arguments" { const testing = std.testing; var command: Command = undefined; try command.fromText("map normal Super Q close"); try testing.expectEqual(@as(usize, 5), command.count); var cursor = command.arguments(); try testing.expectEqualStrings("map", cursor.next().?); try testing.expectEqualStrings("normal", cursor.next().?); try testing.expectEqualStrings("Super", cursor.next().?); try testing.expectEqualStrings("Q", cursor.next().?); try testing.expectEqualStrings("close", cursor.next().?); try testing.expectEqual(@as(?[]const u8, null), cursor.next()); } test "fromSlices stores arguments" { const testing = std.testing; var command: Command = undefined; try command.fromSlices(&.{ "map", "normal", "Super", "Q", "close" }); try testing.expectEqual(@as(usize, 5), command.count); var cursor = command.arguments(); try testing.expectEqualStrings("map", cursor.next().?); try testing.expectEqualStrings("normal", cursor.next().?); try testing.expectEqualStrings("Super", cursor.next().?); try testing.expectEqualStrings("Q", cursor.next().?); try testing.expectEqualStrings("close", cursor.next().?); try testing.expectEqual(@as(?[]const u8, null), cursor.next()); } test "command survives a by-value copy" { const testing = std.testing; var command: Command = undefined; try command.fromSlices(&.{ "map", "normal", "Super", "Q", "close" }); const copied = command; var cursor = copied.arguments(); try testing.expectEqualStrings("map", cursor.next().?); try testing.expectEqualStrings("normal", cursor.next().?); try testing.expectEqualStrings("Super", cursor.next().?); try testing.expectEqualStrings("Q", cursor.next().?); try testing.expectEqualStrings("close", cursor.next().?); try testing.expectEqual(@as(?[]const u8, null), cursor.next()); } /// Split text on spaces into an owned command. Used to run bound /// commands, which may be shell spawns or window-manager commands. pub fn fromText( command: *Command, text: []const u8, ) error{ CommandTooLong, TooManyArguments }!void { command.count = 0; if (text.len > size_max) return error.CommandTooLong; var used: usize = 0; var parts = std.mem.splitScalar(u8, text, ' '); while (parts.next()) |part| { if (part.len == 0) continue; if (command.count == arguments_max) return error.TooManyArguments; if (part.len > size_max - used) return error.CommandTooLong; @memcpy(command.buffer[used..][0..part.len], part); used += part.len; command.ends[command.count] = @intCast(used); command.count += 1; } } /// Store argument slices in the command buffer. Used to build commands /// programmatically, for example from the static config file. pub fn fromSlices( command: *Command, argument_list: []const []const u8, ) error{ CommandTooLong, TooManyArguments }!void { command.count = 0; if (argument_list.len > arguments_max) return error.TooManyArguments; var used: usize = 0; for (argument_list) |argument| { if (argument.len > size_max - used) return error.CommandTooLong; @memcpy(command.buffer[used..][0..argument.len], argument); used += argument.len; command.ends[command.count] = @intCast(used); command.count += 1; } }