summaryrefslogtreecommitdiff
path: root/test/hxdiff.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/hxdiff.zig')
-rw-r--r--test/hxdiff.zig355
1 files changed, 355 insertions, 0 deletions
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
new file mode 100644
index 00000000..62638e23
--- /dev/null
+++ b/test/hxdiff.zig
@@ -0,0 +1,355 @@
+// pardes-hxdiff: the pardes half of the helix differential harness. Drives
+// the sans-IO core (src/pardes.zig) headlessly over JSON-Lines cases and
+// prints one contract result line per case on stdout, input order — the same
+// case/result contract helix's hx-harness speaks (pardes-harness branch in
+// the genizah checkout), so the two editors diff key-for-key.
+//
+// case: {"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"}
+// result: {"name":"w-basic","text":"alpha beta\n","mode":"normal",
+// "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
+//
+// zig build hxdiff (diff the checked-in corpus)
+// zig build hxdiff -- <cases.jsonl> (results to stdout, no diff)
+// ./zig-out/bin/pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]
+//
+// DIFF MODE (goldens given): every result is compared field-by-field
+// (text/mode/cursor/anchor) against the same-named golden line. Waivers
+// ({"name":...,"reason":...}) exempt named cases — each must carry a reason.
+// Any unwaivered mismatch prints a per-case report and exits 1.
+//
+// Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body
+// rows (topbar + tag take two; helix: statusline + commandline take two).
+// pane "file" swaps the boot pane for a file pane holding the case text
+// (Pardes.hxOpenFileContent, the test-only entry). pane "tty" keeps the
+// tty_only boot shell — no real pty, the spawn effect is ignored — feeds the
+// text as pty output with \n cooked to \r\n (ONLCR), then leaves tty mode
+// with Ctrl-<tty_toggle> exactly like a live session and parks the cursor at
+// (0,0), mirroring the helix harness's initial Selection::single(0,1). The
+// final tty text is the motion surface (paneCursorLines) re-joined, plus the
+// trailing '\n' ghostty's dump trims off the cursor's empty row — the case
+// text always ends in one, so this restores byte-for-byte comparability.
+// Keys are helix notation: printables verbatim, <esc> <ret> <tab> <space>
+// <backspace> <del> <up/down/left/right> <home/end> <pageup/pagedown>
+// <C-x> <A-x> <lt> <gt>. The clipboard round-trips through a fake:
+// set_clipboard stores the core's yank register, get_clipboard answers with
+// Event.paste, so y-then-p behaves like a live session.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes");
+
+const gpa = std.heap.page_allocator;
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+
+ var cases_path: ?[]const u8 = null;
+ var goldens_path: ?[]const u8 = null;
+ var waivers_path: ?[]const u8 = null;
+ for (args[1..]) |a| {
+ if (cases_path == null) {
+ cases_path = a;
+ } else if (goldens_path == null) {
+ goldens_path = a;
+ } else if (waivers_path == null) {
+ waivers_path = a;
+ } else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
+ }
+ const path = cases_path orelse fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
+
+ const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
+
+ // goldens + waivers, keyed by name (diff mode)
+ var goldens: std.StringHashMap(Result) = .init(arena);
+ if (goldens_path) |gp| {
+ const gsrc = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) });
+ var glines = std.mem.splitScalar(u8, gsrc, '\n');
+ var lno: usize = 0;
+ while (glines.next()) |raw| {
+ lno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) });
+ goldens.put(g.name, g) catch fatal("oom", .{});
+ }
+ }
+ var waivers: std.StringHashMap([]const u8) = .init(arena);
+ if (waivers_path) |wp| {
+ const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) });
+ var wlines = std.mem.splitScalar(u8, wsrc, '\n');
+ var lno: usize = 0;
+ while (wlines.next()) |raw| {
+ lno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad waiver line: {s}", .{ wp, lno, @errorName(e) });
+ if (w.reason.len == 0) fatal("{s}:{d}: waiver {s} MUST carry a reason", .{ wp, lno, w.name });
+ waivers.put(w.name, w.reason) catch fatal("oom", .{});
+ }
+ }
+
+ var out: std.ArrayList(u8) = .empty;
+ var n_cases: usize = 0;
+ var n_bad: usize = 0;
+ var n_waived: usize = 0;
+ var lines = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines.next()) |raw| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
+ fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) });
+ n_cases += 1;
+ const res = runCase(arena, case) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
+ if (goldens_path == null) {
+ const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{});
+ out.appendSlice(arena, jl) catch fatal("oom", .{});
+ out.append(arena, '\n') catch fatal("oom", .{});
+ continue;
+ }
+ const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
+ const diff = diffResult(arena, g, res);
+ if (diff.len == 0) continue;
+ if (waivers.get(case.name)) |reason| {
+ n_waived += 1;
+ std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason });
+ continue;
+ }
+ n_bad += 1;
+ std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
+ }
+ if (goldens_path == null) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, out.items);
+ return;
+ }
+ std.debug.print("hxdiff: {d} cases, {d} mismatches, {d} waived\n", .{ n_cases, n_bad, n_waived });
+ if (n_bad > 0) std.process.exit(1);
+}
+
+const Case = struct {
+ name: []const u8,
+ pane: []const u8 = "file",
+ text: []const u8,
+ keys: []const u8,
+};
+
+const Pos = struct { row: i32, col: i32 };
+
+// field order is the contract's emit order (Stringify writes declaration order)
+const Result = struct {
+ name: []const u8,
+ text: []const u8,
+ mode: []const u8,
+ cursor: Pos,
+ anchor: Pos,
+};
+
+const Waiver = struct {
+ name: []const u8,
+ reason: []const u8,
+};
+
+/// field-by-field compare; returns a human-readable report ("" = match)
+fn diffResult(arena: std.mem.Allocator, want: Result, got: Result) []const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ if (!std.mem.eql(u8, want.text, got.text)) {
+ appendf(arena, &buf, " text: helix {f} != pardes {f}\n", .{ std.json.fmt(want.text, .{}), std.json.fmt(got.text, .{}) });
+ }
+ if (!std.mem.eql(u8, want.mode, got.mode)) {
+ appendf(arena, &buf, " mode: helix {s} != pardes {s}\n", .{ want.mode, got.mode });
+ }
+ if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) {
+ appendf(arena, &buf, " cursor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.cursor.row), @as(i64, want.cursor.col), @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
+ }
+ if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) {
+ appendf(arena, &buf, " anchor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.anchor.row), @as(i64, want.anchor.col), @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
+ }
+ return buf.items;
+}
+
+fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []const u8, fargs: anytype) void {
+ const s = std.fmt.allocPrint(arena, fmt, fargs) catch return;
+ buf.appendSlice(arena, s) catch {};
+}
+
+fn runCase(arena: std.mem.Allocator, case: Case) !Result {
+ // fresh core per case: the tty_only boot — one shell pane, tty mode, no
+ // greeting. Live-session contract: sizes arrive as the first resize event.
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ var clip: []const u8 = "";
+ core.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ pump(core, arena, &clip);
+
+ if (std.mem.eql(u8, case.pane, "file")) {
+ _ = try core.hxOpenFileContent(case.text);
+ } else if (std.mem.eql(u8, case.pane, "tty")) {
+ // the harness is the pty here: cook \n to \r\n the way ONLCR would
+ var cooked: std.ArrayList(u8) = .empty;
+ for (case.text) |ch| {
+ if (ch == '\n') try cooked.append(arena, '\r');
+ try cooked.append(arena, ch);
+ }
+ core.update(.{ .output = .{ .pane = 0, .bytes = cooked.items } });
+ pump(core, arena, &clip);
+ // leave tty mode the way a live session does: Ctrl-<tty_toggle>,
+ // then park the cursor at (0,0) — the helix harness's initial
+ // Selection::single(0,1) equivalent (a live exit would leave the
+ // modal cursor tracking the shell cursor below the output)
+ core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } });
+ pump(core, arena, &clip);
+ if (core.panes[core.active]) |pane| {
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ }
+ } else fatal("case {s}: unknown pane {s}", .{ case.name, case.pane });
+
+ // keys: helix notation — printables verbatim, <...> specials
+ var ki: usize = 0;
+ while (ki < case.keys.len) {
+ var key: pardes.Key = .{ .cp = 0 };
+ if (case.keys[ki] == '<') {
+ const close = std.mem.indexOfScalarPos(u8, case.keys, ki, '>') orelse
+ fatal("case {s}: unterminated < in keys", .{case.name});
+ var name = case.keys[ki + 1 .. close];
+ ki = close + 1;
+ while (true) {
+ if (std.mem.startsWith(u8, name, "C-")) {
+ key.ctrl = true;
+ name = name[2..];
+ } else if (std.mem.startsWith(u8, name, "A-")) {
+ key.alt = true;
+ name = name[2..];
+ } else break;
+ }
+ if (name.len == 1) {
+ key.cp = name[0];
+ if (!key.ctrl and !key.alt) key.text = name;
+ } else if (std.mem.eql(u8, name, "esc")) {
+ key.cp = pardes.Key.escape;
+ } else if (std.mem.eql(u8, name, "ret")) {
+ key.cp = pardes.Key.enter;
+ } else if (std.mem.eql(u8, name, "tab")) {
+ key.cp = pardes.Key.tab;
+ } else if (std.mem.eql(u8, name, "space")) {
+ key.cp = ' ';
+ if (!key.ctrl and !key.alt) key.text = " ";
+ } else if (std.mem.eql(u8, name, "backspace")) {
+ key.cp = pardes.Key.backspace;
+ } else if (std.mem.eql(u8, name, "del")) {
+ key.cp = pardes.Key.delete;
+ } else if (std.mem.eql(u8, name, "lt")) {
+ key.cp = '<';
+ if (!key.ctrl and !key.alt) key.text = "<";
+ } else if (std.mem.eql(u8, name, "gt")) {
+ key.cp = '>';
+ if (!key.ctrl and !key.alt) key.text = ">";
+ } else if (std.mem.eql(u8, name, "up")) {
+ key.cp = pardes.Key.up;
+ } else if (std.mem.eql(u8, name, "down")) {
+ key.cp = pardes.Key.down;
+ } else if (std.mem.eql(u8, name, "left")) {
+ key.cp = pardes.Key.left;
+ } else if (std.mem.eql(u8, name, "right")) {
+ key.cp = pardes.Key.right;
+ } else if (std.mem.eql(u8, name, "home")) {
+ key.cp = pardes.Key.home;
+ } else if (std.mem.eql(u8, name, "end")) {
+ key.cp = pardes.Key.end;
+ } else if (std.mem.eql(u8, name, "pageup")) {
+ key.cp = pardes.Key.page_up;
+ } else if (std.mem.eql(u8, name, "pagedown")) {
+ key.cp = pardes.Key.page_down;
+ } else fatal("case {s}: unknown key <{s}>", .{ case.name, name });
+ } else {
+ key.cp = case.keys[ki];
+ key.text = case.keys[ki .. ki + 1];
+ ki += 1;
+ }
+ core.update(.{ .key = key });
+ pump(core, arena, &clip);
+ }
+
+ // one contract result line off the active pane's final state
+ const pane = core.panes[core.active] orelse return error.NoActivePane;
+ var text: []const u8 = undefined;
+ if (pane.file) |f| {
+ text = f.content;
+ } else {
+ // the tty motion surface, dumped honestly: whole scrollback+grid,
+ // prompt rows blanked, typed runs spliced — plus the trailing '\n'
+ // ghostty's dump trims off the cursor's empty final row
+ const pl = try core.paneCursorLines(pane);
+ var buf: std.ArrayList(u8) = .empty;
+ for (pl.lines, 0..) |ln, i| {
+ if (i > 0) try buf.append(arena, '\n');
+ try buf.appendSlice(arena, ln);
+ }
+ if (buf.items.len > 0 and buf.items[buf.items.len - 1] != '\n') try buf.append(arena, '\n');
+ text = buf.items;
+ }
+ var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col };
+ if (pane.vsel.active) {
+ anchor = .{ .row = pane.vsel.row, .col = pane.vsel.col };
+ } else if (pane.msel.active) {
+ // line selection: the far end (the r0/r1 not holding the cursor), col 0
+ anchor = .{ .row = if (pane.cur_row == pane.msel.r1) pane.msel.r0 else pane.msel.r1, .col = 0 };
+ }
+ // mode "select" ONLY while v-extend is on (helix keeps x/%/mi selections
+ // in normal mode); pane.mode itself never holds a select state
+ const mode = if (pane.mode == .normal and pane.select) "select" else @tagName(pane.mode);
+ return .{
+ .name = case.name,
+ .text = try arena.dupe(u8, text),
+ .mode = mode,
+ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col },
+ .anchor = anchor,
+ };
+}
+
+/// Drain queued effects. The fake clipboard: set_clipboard snapshots the
+/// core's yank register, get_clipboard answers with Event.paste — the async
+/// OSC 52 round trip of a live shell, collapsed to a synchronous reply.
+/// Everything else (spawn, write, resize_pty, ...) is ignored: no real pty.
+fn pump(core: *pardes.Pardes, arena: std.mem.Allocator, clip: *[]const u8) void {
+ while (true) {
+ var want_paste = false;
+ while (core.nextEffect()) |eff| switch (eff) {
+ .set_clipboard => clip.* = if (core.yank) |y| (arena.dupe(u8, y) catch "") else "",
+ .get_clipboard => want_paste = true,
+ else => {},
+ };
+ if (!want_paste) return;
+ core.update(.{ .paste = clip.* });
+ }
+}
+
+fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
+ const path_z = try arena.dupeZ(u8, path);
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &chunk, chunk.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args);
+ std.process.exit(1);
+}