From f793ecd7264d776159637a86dc8c60b0c6fd2b6f Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 02:21:32 +0200 Subject: [PATCH] Add coding symbol rail --- src/leader.zig | 97 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.zig | 2 + src/protocol.zig | 46 +++++++++++++++++++++++ src/session.zig | 43 +++++++++++++++++++++ src/symbol.zig | 85 ++++++++++++++++++++++++++++++++++++++++++ src/tui.zig | 53 ++++++++++++++++++++++++++ 6 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 src/symbol.zig diff --git a/src/leader.zig b/src/leader.zig index 9dcd20b..34953ff 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -1,6 +1,7 @@ const std = @import("std"); const input = @import("input.zig"); const session_mod = @import("session.zig"); +const symbol_mod = @import("symbol.zig"); // Space leader command grammar, separate from keyboard layout facts. // req: input/001, input/002, input/004, testing/001, testing/002, testing/003, testing/004 @@ -19,6 +20,7 @@ pub const Action = union(enum) { save, quit, open: []u8, + symbol: symbol_mod.Symbol, not_built: Feature, pub fn deinit(self: Action, allocator: std.mem.Allocator) void { @@ -32,6 +34,7 @@ pub const Action = union(enum) { const Mode = enum { idle, rail, + symbol_rail, open_prompt, }; @@ -54,6 +57,7 @@ pub const Leader = struct { switch (self.mode) { .idle => return self.handleIdle(event), .rail => return self.handleRail(event), + .symbol_rail => return self.handleSymbolRail(event), .open_prompt => return self.handleOpenPrompt(event), } } @@ -62,7 +66,8 @@ pub const Leader = struct { if (self.message) |message| return message; return switch (self.mode) { .idle => "", - .rail => "leader: s save q quit o open / search x close", + .rail => "leader: s save q quit o open p symbols / search x close", + .symbol_rail => symbol_mod.rail_status, .open_prompt => "open: type path, Enter opens, Esc cancels", }; } @@ -104,6 +109,10 @@ pub const Leader = struct { self.mode = .open_prompt; return .none; } + if (std.mem.eql(u8, text, "p")) { + self.mode = .symbol_rail; + return .none; + } if (std.mem.eql(u8, text, "/")) { self.mode = .idle; self.message = "search is not built in this profile yet"; @@ -139,6 +148,38 @@ pub const Leader = struct { } } + fn handleSymbolRail(self: *Leader, event: input.Event) Action { + self.message = null; + switch (event) { + .text => |text| { + if (symbol_mod.lookup(text)) |entry| { + self.mode = .idle; + return .{ .symbol = entry.symbol }; + } + self.mode = .idle; + self.message = "unknown symbol key"; + return .none; + }, + .key => |key| switch (key) { + .escape, .backspace => { + self.mode = .idle; + self.message = "symbol rail cancelled"; + return .none; + }, + else => { + self.mode = .idle; + self.message = "unknown symbol key"; + return .none; + }, + }, + .unknown => { + self.mode = .idle; + self.message = "unknown symbol key"; + return .none; + }, + } + } + fn handleOpenPrompt(self: *Leader, event: input.Event) !Action { self.message = null; switch (event) { @@ -188,7 +229,7 @@ test "regular: space opens a visible leader rail and save dispatches" { defer leader.deinit(); try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); - try std.testing.expectEqualStrings("leader: s save q quit o open / search x close", leader.status()); + try std.testing.expectEqualStrings("leader: s save q quit o open p symbols / search x close", leader.status()); try expectActionTag(.save, try leader.handleEvent(input.normalize("s"))); try std.testing.expect(!leader.isActive()); @@ -263,3 +304,55 @@ test "adversarial: empty open and escape cancel without dispatching" { try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b"))); try std.testing.expectEqualStrings("open cancelled", leader.status()); } + +test "regular: leader symbol rail dispatches coding symbols by letters" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("p"))); + try std.testing.expectEqualStrings(symbol_mod.rail_status, leader.status()); + + const action = try leader.handleEvent(input.normalize("c")); + switch (action) { + .symbol => |symbol| try std.testing.expectEqual(symbol_mod.Symbol.braces, symbol), + else => return error.ExpectedSymbolAction, + } +} + +test "regular: leader symbol rail uses letters for hard-to-reach punctuation" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + inline for (.{ + .{ "s", symbol_mod.Symbol.slash }, + .{ "v", symbol_mod.Symbol.pipe }, + .{ "q", symbol_mod.Symbol.double_quote }, + .{ "e", symbol_mod.Symbol.single_quote }, + .{ "t", symbol_mod.Symbol.backtick }, + .{ "u", symbol_mod.Symbol.underscore }, + }) |case| { + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("p"))); + const action = try leader.handleEvent(input.normalize(case[0])); + switch (action) { + .symbol => |actual| try std.testing.expectEqual(case[1], actual), + else => return error.ExpectedSymbolAction, + } + } +} + +test "adversarial: unknown and cancelled symbol rail inputs do not dispatch" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("p"))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("?"))); + try std.testing.expectEqualStrings("unknown symbol key", leader.status()); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("p"))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b"))); + try std.testing.expectEqualStrings("symbol rail cancelled", leader.status()); +} diff --git a/src/main.zig b/src/main.zig index 4c0e44b..2ec91ae 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,6 +6,7 @@ const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); const socket = @import("socket.zig"); +const symbol = @import("symbol.zig"); const tui = @import("tui.zig"); pub const version = "0.1.0-dev"; @@ -126,6 +127,7 @@ test { _ = replay; _ = session; _ = socket; + _ = symbol; _ = tui; } diff --git a/src/protocol.zig b/src/protocol.zig index ecbc2d9..05d1b26 100644 --- a/src/protocol.zig +++ b/src/protocol.zig @@ -30,9 +30,23 @@ fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right); if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward); if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] }); + if (std.mem.startsWith(u8, command, "pair ")) { + const pair = pairByName(command[5..]) orelse return allocator.dupe(u8, "err unknown pair\n"); + return dispatchAndRespond(allocator, session, .{ .insert_pair = pair }); + } return allocator.dupe(u8, "err unknown command\n"); } +fn pairByName(name: []const u8) ?session_mod.Pair { + if (std.mem.eql(u8, name, "parens")) return .{ .open = "(", .close = ")" }; + if (std.mem.eql(u8, name, "brackets")) return .{ .open = "[", .close = "]" }; + if (std.mem.eql(u8, name, "braces")) return .{ .open = "{", .close = "}" }; + if (std.mem.eql(u8, name, "double_quote")) return .{ .open = "\"", .close = "\"" }; + if (std.mem.eql(u8, name, "single_quote")) return .{ .open = "'", .close = "'" }; + if (std.mem.eql(u8, name, "backtick")) return .{ .open = "`", .close = "`" }; + return null; +} + fn dispatchAndRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: session_mod.Command) ![]u8 { session.dispatch(command) catch |err| switch (err) { error.InvalidUtf8Insertion => return allocator.dupe(u8, "err invalid utf8\n"), @@ -117,3 +131,35 @@ test "adversarial: protocol reports no buffer and invalid utf8 without corruptin const after = try session.snapshot(); try std.testing.expectEqualStrings(before.bytes, after.bytes); } + +test "regular: protocol pair commands insert delimiters with cursor between them" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + + var response = try handleLine(std.testing.allocator, &session, "open call\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_right\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_right\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_right\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command move_right\n"); + std.testing.allocator.free(response); + response = try handleLine(std.testing.allocator, &session, "command pair parens\n"); + defer std.testing.allocator.free(response); + + try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=5 bytes_len=6\n", response); + try std.testing.expectEqualStrings("call()", (try session.snapshot()).bytes); +} + +test "adversarial: protocol rejects unknown pair names" { + var session = session_mod.Session.init(std.testing.allocator); + defer session.deinit(); + try session.openFixture("abc"); + + const response = try handleLine(std.testing.allocator, &session, "command pair snippet_everything\n"); + defer std.testing.allocator.free(response); + try std.testing.expectEqualStrings("err unknown pair\n", response); + try std.testing.expectEqualStrings("abc", (try session.snapshot()).bytes); +} diff --git a/src/session.zig b/src/session.zig index 8ff9f1a..7acc1f5 100644 --- a/src/session.zig +++ b/src/session.zig @@ -17,10 +17,16 @@ pub const Selection = struct { cursor: usize, }; +pub const Pair = struct { + open: []const u8, + close: []const u8, +}; + pub const Command = union(enum) { move_left, move_right, insert: []const u8, + insert_pair: Pair, delete_backward, }; @@ -62,6 +68,7 @@ pub const Buffer = struct { .move_left => self.moveLeft(), .move_right => self.moveRight(), .insert => |text| try self.insert(text), + .insert_pair => |pair| try self.insertPair(pair), .delete_backward => self.deleteBackward(), } } @@ -84,6 +91,16 @@ pub const Buffer = struct { self.selection = null; } + pub fn insertPair(self: *Buffer, pair: Pair) !void { + if (!std.unicode.utf8ValidateSlice(pair.open) or !std.unicode.utf8ValidateSlice(pair.close)) return error.InvalidUtf8Insertion; + const combined = try std.mem.concat(self.allocator, u8, &.{ pair.open, pair.close }); + defer self.allocator.free(combined); + try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined); + self.cursor.byte += pair.open.len; + self.refreshCell(); + self.selection = null; + } + pub fn deleteBackward(self: *Buffer) void { if (self.cursor.byte == 0) return; const start = previousBoundary(self.bytes.items, self.cursor.byte); @@ -265,3 +282,29 @@ test "adversarial: dispatch requires an open buffer" { try std.testing.expectError(error.NoBufferOpen, session.dispatch(.move_right)); try std.testing.expectError(error.NoBufferOpen, session.snapshot()); } + +test "regular: pair insertion places cursor between delimiters" { + var buffer = try Buffer.openFromBytes(std.testing.allocator, "call"); + defer buffer.deinit(); + + buffer.moveRight(); + buffer.moveRight(); + buffer.moveRight(); + buffer.moveRight(); + try buffer.insertPair(.{ .open = "(", .close = ")" }); + + const snap = buffer.snapshot(); + try std.testing.expectEqualStrings("call()", snap.bytes); + try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte); + try std.testing.expectEqual(@as(usize, 5), snap.cursor_cell); +} + +test "adversarial: invalid UTF-8 pair insertion is rejected and existing bytes are preserved" { + var buffer = try Buffer.openFromBytes(std.testing.allocator, "safe"); + defer buffer.deinit(); + + const bad = [_]u8{ 0xc3, 0x28 }; + try std.testing.expectError(error.InvalidUtf8Insertion, buffer.insertPair(.{ .open = &bad, .close = ")" })); + try std.testing.expectEqualStrings("safe", buffer.snapshot().bytes); + try std.testing.expectEqual(@as(usize, 0), buffer.snapshot().cursor_byte); +} diff --git a/src/symbol.zig b/src/symbol.zig new file mode 100644 index 0000000..46b4f69 --- /dev/null +++ b/src/symbol.zig @@ -0,0 +1,85 @@ +const std = @import("std"); + +// Source-patched coding symbol rail table. +// req: input/003, governance/001, testing/001, testing/002, testing/003, testing/004 + +test { + _ = lookup; +} + +pub const Symbol = enum { + parens, + brackets, + braces, + slash, + pipe, + double_quote, + single_quote, + backtick, + underscore, +}; + +pub const Entry = struct { + key: []const u8, + symbol: Symbol, + label: []const u8, + protocol_command: []const u8, +}; + +pub const entries = [_]Entry{ + .{ .key = "p", .symbol = .parens, .label = "()", .protocol_command = "command pair parens" }, + .{ .key = "b", .symbol = .brackets, .label = "[]", .protocol_command = "command pair brackets" }, + .{ .key = "c", .symbol = .braces, .label = "{}", .protocol_command = "command pair braces" }, + .{ .key = "s", .symbol = .slash, .label = "/", .protocol_command = "command insert /" }, + .{ .key = "v", .symbol = .pipe, .label = "|", .protocol_command = "command insert |" }, + .{ .key = "q", .symbol = .double_quote, .label = "\"\"", .protocol_command = "command pair double_quote" }, + .{ .key = "e", .symbol = .single_quote, .label = "''", .protocol_command = "command pair single_quote" }, + .{ .key = "t", .symbol = .backtick, .label = "``", .protocol_command = "command pair backtick" }, + .{ .key = "u", .symbol = .underscore, .label = "_", .protocol_command = "command insert _" }, +}; + +pub const rail_status = "symbols: p () b [] c {} s / v | q \"\" e '' t `` u _"; + +pub fn lookup(key: []const u8) ?Entry { + for (entries) |entry| { + if (std.mem.eql(u8, key, entry.key)) return entry; + } + return null; +} + +pub fn protocolCommand(symbol: Symbol) []const u8 { + for (entries) |entry| { + if (entry.symbol == symbol) return entry.protocol_command; + } + unreachable; +} + +test "regular: symbol rail maps mobile-friendly letters to coding punctuation" { + try std.testing.expectEqual(Symbol.parens, lookup("p").?.symbol); + try std.testing.expectEqual(Symbol.brackets, lookup("b").?.symbol); + try std.testing.expectEqual(Symbol.braces, lookup("c").?.symbol); + try std.testing.expectEqual(Symbol.slash, lookup("s").?.symbol); + try std.testing.expectEqual(Symbol.pipe, lookup("v").?.symbol); + try std.testing.expectEqual(Symbol.double_quote, lookup("q").?.symbol); + try std.testing.expectEqual(Symbol.single_quote, lookup("e").?.symbol); + try std.testing.expectEqual(Symbol.backtick, lookup("t").?.symbol); + try std.testing.expectEqual(Symbol.underscore, lookup("u").?.symbol); +} + +test "regular: symbol rail protocol commands stay tiny and explicit" { + try std.testing.expectEqualStrings("command pair parens", protocolCommand(.parens)); + try std.testing.expectEqualStrings("command pair brackets", protocolCommand(.brackets)); + try std.testing.expectEqualStrings("command pair braces", protocolCommand(.braces)); + try std.testing.expectEqualStrings("command insert /", protocolCommand(.slash)); + try std.testing.expectEqualStrings("command insert |", protocolCommand(.pipe)); + try std.testing.expectEqualStrings("command pair double_quote", protocolCommand(.double_quote)); + try std.testing.expectEqualStrings("command pair single_quote", protocolCommand(.single_quote)); + try std.testing.expectEqualStrings("command pair backtick", protocolCommand(.backtick)); + try std.testing.expectEqualStrings("command insert _", protocolCommand(.underscore)); +} + +test "adversarial: unknown symbol key does not map to a symbol" { + try std.testing.expectEqual(@as(?Entry, null), lookup("x")); + try std.testing.expectEqual(@as(?Entry, null), lookup("/")); + try std.testing.expectEqual(@as(?Entry, null), lookup("")); +} diff --git a/src/tui.zig b/src/tui.zig index 2239fdc..5ae7249 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -4,6 +4,7 @@ const leader_mod = @import("leader.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session_mod = @import("session.zig"); +const symbol_mod = @import("symbol.zig"); // First terminal thin client surface, scriptable for E2E-style tests. // req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 @@ -180,6 +181,7 @@ pub const Client = struct { defer self.allocator.free(line); try self.applyProtocol(line); }, + .symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)), .not_built => {}, } } @@ -414,3 +416,54 @@ test "adversarial: unknown leader input recovers without saving or quitting" { try std.testing.expect(!client.quit); try std.testing.expectError(Error.NothingSaved, client.saved()); } + +fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open base"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key p"); + const rail = try client.render(std.testing.allocator); + defer std.testing.allocator.free(rail); + try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null); + + const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key}); + defer std.testing.allocator.free(line); + try client.handleTraceLine(line); + + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings(expected_bytes, snap.bytes); + try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte); +} + +test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" { + try expectSymbolTrace("p", "()base", 1); + try expectSymbolTrace("b", "[]base", 1); + try expectSymbolTrace("c", "{}base", 1); + try expectSymbolTrace("q", "\"\"base", 1); + try expectSymbolTrace("e", "''base", 1); + try expectSymbolTrace("t", "``base", 1); +} + +test "regular: symbol rail inserts slash pipe and underscore as single symbols" { + try expectSymbolTrace("s", "/base", 1); + try expectSymbolTrace("v", "|base", 1); + try expectSymbolTrace("u", "_base", 1); +} + +test "adversarial: unknown symbol key does not mutate the buffer" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open base"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key p"); + try client.handleTraceLine("key ?"); + + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("base", snap.bytes); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null); +}