Add coding symbol rail

This commit is contained in:
slhx agent
2026-06-21 02:21:32 +02:00
parent cfbacb3cd2
commit f793ecd726
6 changed files with 324 additions and 2 deletions
+95 -2
View File
@@ -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());
}
+2
View File
@@ -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;
}
+46
View File
@@ -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);
}
+43
View File
@@ -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);
}
+85
View File
@@ -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(""));
}
+53
View File
@@ -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);
}