diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 0000000..51722bd --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,365 @@ +const std = @import("std"); +const input = @import("input.zig"); + +// Source-patched keyboard layout facts, intentionally separate from commands. +// req: input/004, input/005, governance/001, testing/001, testing/002, testing/003, testing/004 + +test { + _ = ios_qwertz; +} + +pub const ProfileId = enum { + ios_qwertz, +}; + +pub const Kind = enum { + letter, + whitespace, + control, + punctuation, + unknown, +}; + +pub const Fact = struct { + raw: []const u8, + kind: Kind, +}; + +pub const Profile = struct { + id: ProfileId, + name: []const u8, + facts: []const Fact, +}; + +pub const Classified = struct { + event: input.Event, + kind: Kind, +}; + +pub const TraceResult = union(enum) { + ok, + diverged: Divergence, + + pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void { + switch (self) { + .ok => {}, + .diverged => |diverged| allocator.free(diverged.message), + } + } +}; + +pub const Divergence = struct { + step: usize, + message: []const u8, +}; + +pub const LayoutTraceRecorder = struct { + allocator: std.mem.Allocator, + profile: *const Profile, + lines: std.ArrayList(u8) = .empty, + + pub fn init(allocator: std.mem.Allocator, profile: *const Profile) LayoutTraceRecorder { + return .{ .allocator = allocator, .profile = profile }; + } + + pub fn deinit(self: *LayoutTraceRecorder) void { + self.lines.deinit(self.allocator); + self.* = undefined; + } + + pub fn capture(self: *LayoutTraceRecorder, raw: []const u8) !void { + const label = try classificationLabelAlloc(self.allocator, classifyRaw(self.profile, raw)); + defer self.allocator.free(label); + try appendHex(self.allocator, &self.lines, raw); + try self.lines.appendSlice(self.allocator, " => "); + try self.lines.appendSlice(self.allocator, @tagName(self.profile.id)); + try self.lines.append(self.allocator, ':'); + try self.lines.appendSlice(self.allocator, label); + try self.lines.append(self.allocator, '\n'); + } + + pub fn text(self: *const LayoutTraceRecorder) []const u8 { + return self.lines.items; + } +}; + +pub const ios_qwertz = Profile{ + .id = .ios_qwertz, + .name = "iOS QWERTZ", + .facts = &ios_qwertz_facts, +}; + +const ios_qwertz_facts = [_]Fact{ + .{ .raw = "a", .kind = .letter }, + .{ .raw = "b", .kind = .letter }, + .{ .raw = "c", .kind = .letter }, + .{ .raw = "d", .kind = .letter }, + .{ .raw = "e", .kind = .letter }, + .{ .raw = "f", .kind = .letter }, + .{ .raw = "g", .kind = .letter }, + .{ .raw = "h", .kind = .letter }, + .{ .raw = "i", .kind = .letter }, + .{ .raw = "j", .kind = .letter }, + .{ .raw = "k", .kind = .letter }, + .{ .raw = "l", .kind = .letter }, + .{ .raw = "m", .kind = .letter }, + .{ .raw = "n", .kind = .letter }, + .{ .raw = "o", .kind = .letter }, + .{ .raw = "p", .kind = .letter }, + .{ .raw = "q", .kind = .letter }, + .{ .raw = "r", .kind = .letter }, + .{ .raw = "s", .kind = .letter }, + .{ .raw = "t", .kind = .letter }, + .{ .raw = "u", .kind = .letter }, + .{ .raw = "v", .kind = .letter }, + .{ .raw = "w", .kind = .letter }, + .{ .raw = "x", .kind = .letter }, + .{ .raw = "y", .kind = .letter }, + .{ .raw = "z", .kind = .letter }, + .{ .raw = "ä", .kind = .letter }, + .{ .raw = "ö", .kind = .letter }, + .{ .raw = "ü", .kind = .letter }, + .{ .raw = "ß", .kind = .letter }, + + .{ .raw = ".", .kind = .punctuation }, + .{ .raw = ",", .kind = .punctuation }, + .{ .raw = ":", .kind = .punctuation }, + .{ .raw = ";", .kind = .punctuation }, + .{ .raw = "-", .kind = .punctuation }, + .{ .raw = "_", .kind = .punctuation }, + .{ .raw = "/", .kind = .punctuation }, + .{ .raw = "\\", .kind = .punctuation }, + .{ .raw = "'", .kind = .punctuation }, + .{ .raw = "\"", .kind = .punctuation }, + .{ .raw = "`", .kind = .punctuation }, + .{ .raw = "~", .kind = .punctuation }, + .{ .raw = "(", .kind = .punctuation }, + .{ .raw = ")", .kind = .punctuation }, + .{ .raw = "[", .kind = .punctuation }, + .{ .raw = "]", .kind = .punctuation }, + .{ .raw = "{", .kind = .punctuation }, + .{ .raw = "}", .kind = .punctuation }, + .{ .raw = "<", .kind = .punctuation }, + .{ .raw = ">", .kind = .punctuation }, + .{ .raw = "=", .kind = .punctuation }, + .{ .raw = "+", .kind = .punctuation }, + .{ .raw = "*", .kind = .punctuation }, + .{ .raw = "!", .kind = .punctuation }, + .{ .raw = "?", .kind = .punctuation }, + .{ .raw = "@", .kind = .punctuation }, + .{ .raw = "#", .kind = .punctuation }, + .{ .raw = "$", .kind = .punctuation }, + .{ .raw = "%", .kind = .punctuation }, + .{ .raw = "&", .kind = .punctuation }, + .{ .raw = "|", .kind = .punctuation }, +}; + +pub fn classifyRaw(profile: *const Profile, raw: []const u8) Classified { + return classifyEvent(profile, input.normalize(raw)); +} + +pub fn classifyEvent(profile: *const Profile, event: input.Event) Classified { + const kind: Kind = switch (event) { + .key => |key| switch (key) { + .space, .tab => .whitespace, + .enter, .backspace, .escape, .arrow_left, .arrow_right, .arrow_up, .arrow_down => .control, + }, + .text => |text| lookupFact(profile, text) orelse .unknown, + .unknown => .unknown, + }; + return .{ .event = event, .kind = kind }; +} + +pub fn replayLayoutTrace(allocator: std.mem.Allocator, profile: *const Profile, trace: []const u8) !TraceResult { + var step: usize = 0; + var lines = std.mem.splitScalar(u8, trace, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + step += 1; + const separator = std.mem.indexOf(u8, line, " => ") orelse return divergence(allocator, step, "layout trace line is missing separator"); + const raw_hex = line[0..separator]; + const expected = line[separator + 4 ..]; + var raw = std.ArrayList(u8).empty; + defer raw.deinit(allocator); + parseHexInto(allocator, &raw, raw_hex) catch |err| switch (err) { + error.InvalidHex => return divergence(allocator, step, "layout trace line has invalid hex bytes"), + else => return err, + }; + const actual = try classificationLabelWithProfileAlloc(allocator, profile, classifyRaw(profile, raw.items)); + defer allocator.free(actual); + if (!std.mem.eql(u8, expected, actual)) { + return divergenceFmt(allocator, step, "expected layout event '{s}' but got '{s}'", .{ expected, actual }); + } + } + return .ok; +} + +fn lookupFact(profile: *const Profile, text: []const u8) ?Kind { + for (profile.facts) |fact| { + if (std.mem.eql(u8, text, fact.raw)) return fact.kind; + } + return null; +} + +fn classificationLabelWithProfileAlloc(allocator: std.mem.Allocator, profile: *const Profile, classified: Classified) ![]u8 { + const label = try classificationLabelAlloc(allocator, classified); + defer allocator.free(label); + return std.fmt.allocPrint(allocator, "{s}:{s}", .{ @tagName(profile.id), label }); +} + +fn classificationLabelAlloc(allocator: std.mem.Allocator, classified: Classified) ![]u8 { + return switch (classified.event) { + .key => |key| std.fmt.allocPrint(allocator, "{s}:key:{s}", .{ @tagName(classified.kind), @tagName(key) }), + .text => |text| std.fmt.allocPrint(allocator, "{s}:text:{s}", .{ @tagName(classified.kind), text }), + .unknown => |raw| unknownLabelAlloc(allocator, raw), + }; +} + +fn unknownLabelAlloc(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, "unknown:raw:"); + try appendHex(allocator, &out, raw); + return out.toOwnedSlice(allocator); +} + +fn appendHex(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8) !void { + const alphabet = "0123456789abcdef"; + for (bytes) |byte| { + try out.append(allocator, alphabet[byte >> 4]); + try out.append(allocator, alphabet[byte & 0x0f]); + } +} + +fn parseHexInto(allocator: std.mem.Allocator, out: *std.ArrayList(u8), hex: []const u8) !void { + if (hex.len % 2 != 0) return error.InvalidHex; + var i: usize = 0; + while (i < hex.len) : (i += 2) { + const high = hexNibble(hex[i]) orelse return error.InvalidHex; + const low = hexNibble(hex[i + 1]) orelse return error.InvalidHex; + try out.append(allocator, (high << 4) | low); + } +} + +fn hexNibble(byte: u8) ?u8 { + return switch (byte) { + '0'...'9' => byte - '0', + 'a'...'f' => byte - 'a' + 10, + 'A'...'F' => byte - 'A' + 10, + else => null, + }; +} + +fn divergence(allocator: std.mem.Allocator, step: usize, message: []const u8) !TraceResult { + return .{ .diverged = .{ .step = step, .message = try allocator.dupe(u8, message) } }; +} + +fn divergenceFmt(allocator: std.mem.Allocator, step: usize, comptime fmt: []const u8, args: anytype) !TraceResult { + return .{ .diverged = .{ .step = step, .message = try std.fmt.allocPrint(allocator, fmt, args) } }; +} + +fn expectKind(raw: []const u8, expected: Kind) !void { + try std.testing.expectEqual(expected, classifyRaw(&ios_qwertz, raw).kind); +} + +fn expectReplayOk(result: TraceResult) !void { + switch (result) { + .ok => {}, + .diverged => return error.ExpectedTraceOk, + } +} + +test "regular: iOS QWERTZ classifies letters including QWERTZ y and z" { + try expectKind("a", .letter); + try expectKind("m", .letter); + try expectKind("z", .letter); + try expectKind("y", .letter); + try expectKind("ä", .letter); + try expectKind("ö", .letter); + try expectKind("ü", .letter); + try expectKind("ß", .letter); +} + +test "regular: iOS QWERTZ classifies whitespace and editing controls without commands" { + try expectKind(" ", .whitespace); + try expectKind("\t", .whitespace); + try expectKind("\r", .control); + try expectKind("\x7f", .control); + try expectKind("\x1b[D", .control); +} + +test "regular: iOS QWERTZ classifies key coding punctuation" { + inline for (.{ ".", ",", ":", ";", "-", "_", "/", "\\", "'", "\"", "`", "~", "(", ")", "[", "]", "{", "}", "<", ">", "=", "+", "*", "!", "?", "@", "#", "$", "%", "&", "|" }) |raw| { + try expectKind(raw, .punctuation); + } +} + +test "regular: iOS QWERTZ layout trace records raw bytes and replays" { + var recorder = LayoutTraceRecorder.init(std.testing.allocator, &ios_qwertz); + defer recorder.deinit(); + + try recorder.capture("z"); + try recorder.capture("y"); + try recorder.capture(" "); + try recorder.capture("\r"); + try recorder.capture("\x7f"); + try recorder.capture("{"); + try recorder.capture("|"); + + const result = try replayLayoutTrace(std.testing.allocator, &ios_qwertz, recorder.text()); + defer result.deinit(std.testing.allocator); + try expectReplayOk(result); +} + +test "regular: profile labels are facts, not editor command intents" { + var recorder = LayoutTraceRecorder.init(std.testing.allocator, &ios_qwertz); + defer recorder.deinit(); + try recorder.capture(" "); + + try std.testing.expectEqualStrings("20 => ios_qwertz:whitespace:key:space\n", recorder.text()); + try std.testing.expect(std.mem.indexOf(u8, recorder.text(), "leader") == null); + try std.testing.expect(std.mem.indexOf(u8, recorder.text(), "save") == null); +} + +test "adversarial: unknown printable text and unknown escapes stay unknown" { + try expectKind("🔥", .unknown); + try expectKind("\x1b[999~", .unknown); +} + +test "adversarial: layout trace reports malformed lines and invalid hex" { + { + const result = try replayLayoutTrace(std.testing.allocator, &ios_qwertz, "7a ios_qwertz:letter:text:z\n"); + defer result.deinit(std.testing.allocator); + switch (result) { + .diverged => |diverged| { + try std.testing.expectEqual(@as(usize, 1), diverged.step); + try std.testing.expectEqualStrings("layout trace line is missing separator", diverged.message); + }, + .ok => return error.ExpectedTraceDivergence, + } + } + + { + const result = try replayLayoutTrace(std.testing.allocator, &ios_qwertz, "zz => ios_qwertz:letter:text:z\n"); + defer result.deinit(std.testing.allocator); + switch (result) { + .diverged => |diverged| { + try std.testing.expectEqual(@as(usize, 1), diverged.step); + try std.testing.expectEqualStrings("layout trace line has invalid hex bytes", diverged.message); + }, + .ok => return error.ExpectedTraceDivergence, + } + } +} + +test "adversarial: layout trace names first classification mismatch" { + const result = try replayLayoutTrace(std.testing.allocator, &ios_qwertz, "7a => ios_qwertz:punctuation:text:z\n"); + defer result.deinit(std.testing.allocator); + switch (result) { + .diverged => |diverged| { + try std.testing.expectEqual(@as(usize, 1), diverged.step); + try std.testing.expect(std.mem.indexOf(u8, diverged.message, "expected layout event") != null); + }, + .ok => return error.ExpectedTraceDivergence, + } +} diff --git a/src/main.zig b/src/main.zig index f9aeb81..335ea3e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const input = @import("input.zig"); +const layout = @import("layout.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); @@ -118,6 +119,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It test { _ = input; + _ = layout; _ = protocol; _ = replay; _ = session;