diff options
Diffstat (limited to 'test/hxdiff.zig')
| -rw-r--r-- | test/hxdiff.zig | 355 |
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); +} |
