// 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 -- (results to stdout, no diff) // zig build hxparity (file-vs-pty parity, both corpora) // ./zig-out/bin/pardes-hxdiff [goldens.jsonl [waivers.jsonl]] // ./zig-out/bin/pardes-hxdiff --parity [--waivers w.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. // // PARITY MODE (--parity): no goldens at all. Each case is run TWICE, once in // a file pane and once in a pty pane, over the same starting text and the // same keys, and the two result lines must be identical. That is the whole // contract of "editing a shell pane behaves like editing a text file": the // oracle is the file pane itself, so it cannot drift. The case's own "pane" // field is ignored, and SEVERAL case files may be given — the gate runs the // whole helix corpus, not just the editing cases, because a parity suite // that only covers what its author remembered cannot catch the next // regression. Divergences that are not editing (pardes key bindings, pty // viewport geometry, text a terminal cannot hold verbatim) are waived by // name in test/hxcases/parity-waivers.jsonl, each with its reason. // // 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- 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, // // . const std = @import("std"); const libc = std.c; const pardes = @import("pardes"); // Its own root, so src/main.zig's logFn does not apply here: the core it // drives feeds every case's text through ghostty-vt, which narrates each // sequence it does not implement. Cut the libraries back to errors; the // per-case report goes through std.debug, so a mismatch stays loud. pub const std_options: std.Options = .{ .log_level = .err }; 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 parity = false; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; var case_paths: std.ArrayList([]const u8) = .empty; var want_waivers = false; for (args[1..]) |a| { if (want_waivers) { waivers_path = a; want_waivers = false; } else if (std.mem.eql(u8, a, "--parity")) { parity = true; } else if (std.mem.eql(u8, a, "--waivers")) { want_waivers = true; } else if (parity or case_paths.items.len == 0) { // diff mode is positional (cases, goldens, waivers); parity mode // takes any number of case files and nothing else case_paths.append(arena, a) catch fatal("oom", .{}); } else if (goldens_path == null) { goldens_path = a; } else if (waivers_path == null) { waivers_path = a; } else fatal("usage: pardes-hxdiff [goldens.jsonl [waivers.jsonl]]", .{}); } if (case_paths.items.len == 0) fatal("usage: pardes-hxdiff [--parity] ... [goldens.jsonl [waivers.jsonl]]", .{}); // 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; for (case_paths.items) |path| { const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); 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; if (parity) { // the file pane IS the oracle: same text, same keys, both kinds const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) }); const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) }); const diff = diffResult(arena, want, got, "file", "pty"); 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 }); continue; } const res = runCase(arena, case, case.pane) 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, "helix", "pardes"); 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 (!parity and goldens_path == null) { try std.Io.File.stdout().writeStreamingAll(init.io, out.items); return; } std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", 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). /// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty). fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 { var buf: std.ArrayList(u8) = .empty; if (!std.mem.eql(u8, want.text, got.text)) { appendf(arena, &buf, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }); } if (!std.mem.eql(u8, want.mode, got.mode)) { appendf(arena, &buf, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }); } if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { appendf(arena, &buf, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @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: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @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, pane_kind: []const u8) !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(); core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); pump(core); if (std.mem.eql(u8, pane_kind, "file")) { _ = try core.hxOpenFileContent(case.text); } else if (std.mem.eql(u8, pane_kind, "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); // leave tty mode the way a live session does: Ctrl-, // 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); 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, pane_kind }); // 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); } // 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, the edit buffer standing in. The surface keeps // the cursor's empty row below the output (see Pardes.shellRows), so // its line list is exactly a file content's — join it and you have the // same bytes, trailing newline included, with nothing to patch up. 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); } 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, all ignored (spawn, write, resize_pty, ...): no /// real pty, and p/P read the core's own yank register. fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } 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); }