Normalize terminal input traces
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
||||
const std = @import("std");
|
||||
|
||||
// Terminal input normalization and trace capture.
|
||||
// req: input/004, input/005, testing/001, testing/002, testing/003, testing/004
|
||||
|
||||
test {
|
||||
_ = normalize;
|
||||
_ = TraceRecorder;
|
||||
}
|
||||
|
||||
pub const Key = enum {
|
||||
space,
|
||||
enter,
|
||||
tab,
|
||||
backspace,
|
||||
escape,
|
||||
arrow_left,
|
||||
arrow_right,
|
||||
arrow_up,
|
||||
arrow_down,
|
||||
};
|
||||
|
||||
pub const Event = union(enum) {
|
||||
key: Key,
|
||||
text: []const u8,
|
||||
unknown: []const u8,
|
||||
};
|
||||
|
||||
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 TraceRecorder = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
lines: std.ArrayList(u8) = .empty,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator) TraceRecorder {
|
||||
return .{ .allocator = allocator };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *TraceRecorder) void {
|
||||
self.lines.deinit(self.allocator);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
pub fn capture(self: *TraceRecorder, raw: []const u8) !void {
|
||||
const label = try eventLabelAlloc(self.allocator, normalize(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, label);
|
||||
try self.lines.append(self.allocator, '\n');
|
||||
}
|
||||
|
||||
pub fn text(self: *const TraceRecorder) []const u8 {
|
||||
return self.lines.items;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn normalize(raw: []const u8) Event {
|
||||
if (raw.len == 0) return .{ .unknown = raw };
|
||||
|
||||
if (std.mem.eql(u8, raw, " ")) return .{ .key = .space };
|
||||
if (std.mem.eql(u8, raw, "\r") or std.mem.eql(u8, raw, "\n")) return .{ .key = .enter };
|
||||
if (std.mem.eql(u8, raw, "\t")) return .{ .key = .tab };
|
||||
if (std.mem.eql(u8, raw, "\x7f") or std.mem.eql(u8, raw, "\x08")) return .{ .key = .backspace };
|
||||
if (std.mem.eql(u8, raw, "\x1b")) return .{ .key = .escape };
|
||||
if (std.mem.eql(u8, raw, "\x1b[D")) return .{ .key = .arrow_left };
|
||||
if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right };
|
||||
if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up };
|
||||
if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down };
|
||||
|
||||
if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw };
|
||||
return .{ .unknown = raw };
|
||||
}
|
||||
|
||||
pub fn replayTrace(allocator: std.mem.Allocator, 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, "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, "trace line has invalid hex bytes"),
|
||||
else => return err,
|
||||
};
|
||||
const actual = try eventLabelAlloc(allocator, normalize(raw.items));
|
||||
defer allocator.free(actual);
|
||||
if (!std.mem.eql(u8, expected, actual)) {
|
||||
return divergenceFmt(allocator, step, "expected event '{s}' but got '{s}'", .{ expected, actual });
|
||||
}
|
||||
}
|
||||
return .ok;
|
||||
}
|
||||
|
||||
pub fn eventLabelAlloc(allocator: std.mem.Allocator, event: Event) ![]u8 {
|
||||
return switch (event) {
|
||||
.key => |key| std.fmt.allocPrint(allocator, "key:{s}", .{@tagName(key)}),
|
||||
.text => |text| std.fmt.allocPrint(allocator, "text:{s}", .{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:");
|
||||
try appendHex(allocator, &out, raw);
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn isPrintableText(raw: []const u8) bool {
|
||||
var i: usize = 0;
|
||||
while (i < raw.len) {
|
||||
const len = std.unicode.utf8ByteSequenceLength(raw[i]) catch return false;
|
||||
if (i + len > raw.len) return false;
|
||||
const cp = std.unicode.utf8Decode(raw[i .. i + len]) catch return false;
|
||||
if (cp < 0x20 or cp == 0x7f) return false;
|
||||
if (cp == 0x1b) return false;
|
||||
i += len;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 expectEventLabel(raw: []const u8, expected: []const u8) !void {
|
||||
const label = try eventLabelAlloc(std.testing.allocator, normalize(raw));
|
||||
defer std.testing.allocator.free(label);
|
||||
try std.testing.expectEqualStrings(expected, label);
|
||||
}
|
||||
|
||||
fn expectTraceOk(result: TraceResult) !void {
|
||||
switch (result) {
|
||||
.ok => {},
|
||||
.diverged => return error.ExpectedTraceOk,
|
||||
}
|
||||
}
|
||||
|
||||
test "regular: raw terminal bytes normalize to stable editor input events" {
|
||||
try expectEventLabel("a", "text:a");
|
||||
try expectEventLabel("ä", "text:ä");
|
||||
try expectEventLabel("🔥", "text:🔥");
|
||||
try expectEventLabel(" ", "key:space");
|
||||
try expectEventLabel("\r", "key:enter");
|
||||
try expectEventLabel("\x7f", "key:backspace");
|
||||
try expectEventLabel("\x1b[D", "key:arrow_left");
|
||||
try expectEventLabel("\x1b[C", "key:arrow_right");
|
||||
try expectEventLabel("\x1b[A", "key:arrow_up");
|
||||
try expectEventLabel("\x1b[B", "key:arrow_down");
|
||||
}
|
||||
|
||||
test "regular: captured trace can be replayed" {
|
||||
var recorder = TraceRecorder.init(std.testing.allocator);
|
||||
defer recorder.deinit();
|
||||
|
||||
try recorder.capture("a");
|
||||
try recorder.capture("ä");
|
||||
try recorder.capture(" ");
|
||||
try recorder.capture("\x1b[D");
|
||||
try recorder.capture("\x7f");
|
||||
|
||||
const result = try replayTrace(std.testing.allocator, recorder.text());
|
||||
defer result.deinit(std.testing.allocator);
|
||||
try expectTraceOk(result);
|
||||
}
|
||||
|
||||
test "regular: trace text preserves raw bytes rather than keyboard-layout guesses" {
|
||||
var recorder = TraceRecorder.init(std.testing.allocator);
|
||||
defer recorder.deinit();
|
||||
|
||||
try recorder.capture("z");
|
||||
try recorder.capture("y");
|
||||
|
||||
try std.testing.expectEqualStrings(
|
||||
\\7a => text:z
|
||||
\\79 => text:y
|
||||
\\
|
||||
, recorder.text());
|
||||
}
|
||||
|
||||
test "adversarial: invalid UTF-8 and control bytes normalize as unknown" {
|
||||
const bad_utf8 = [_]u8{ 0xc3, 0x28 };
|
||||
try expectEventLabel(&bad_utf8, "unknown:c328");
|
||||
try expectEventLabel("\x00", "unknown:00");
|
||||
try expectEventLabel("\x1b[999~", "unknown:1b5b3939397e");
|
||||
}
|
||||
|
||||
test "adversarial: replay names malformed trace lines" {
|
||||
const result = try replayTrace(std.testing.allocator, "61 text:a\n");
|
||||
defer result.deinit(std.testing.allocator);
|
||||
switch (result) {
|
||||
.diverged => |diverged| {
|
||||
try std.testing.expectEqual(@as(usize, 1), diverged.step);
|
||||
try std.testing.expectEqualStrings("trace line is missing separator", diverged.message);
|
||||
},
|
||||
.ok => return error.ExpectedTraceDivergence,
|
||||
}
|
||||
}
|
||||
|
||||
test "adversarial: replay names invalid hex in traces" {
|
||||
const result = try replayTrace(std.testing.allocator, "zz => text:a\n");
|
||||
defer result.deinit(std.testing.allocator);
|
||||
switch (result) {
|
||||
.diverged => |diverged| {
|
||||
try std.testing.expectEqual(@as(usize, 1), diverged.step);
|
||||
try std.testing.expectEqualStrings("trace line has invalid hex bytes", diverged.message);
|
||||
},
|
||||
.ok => return error.ExpectedTraceDivergence,
|
||||
}
|
||||
}
|
||||
|
||||
test "adversarial: replay names first event mismatch" {
|
||||
const result = try replayTrace(std.testing.allocator, "61 => key:space\n62 => text:b\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 event") != null);
|
||||
},
|
||||
.ok => return error.ExpectedTraceDivergence,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const std = @import("std");
|
||||
const input = @import("input.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session = @import("session.zig");
|
||||
@@ -116,6 +117,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It
|
||||
}
|
||||
|
||||
test {
|
||||
_ = input;
|
||||
_ = protocol;
|
||||
_ = replay;
|
||||
_ = session;
|
||||
|
||||
Reference in New Issue
Block a user