// JSONL cases and results shared with Helix's hx-harness. // Default mode compares named goldens; --parity compares file and terminal panes. // --strict checks complete corpus coverage. Waivers pin both sides of a known difference. const std = @import("std"); 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. // The regex engine is dropped outright rather than cut back: an `s`/`S` case // types its pattern one key at a time, so mvzr is HANDED every incomplete // prefix of it and logs each one at error level. src/main.zig drops the same // scope for the same reason. pub const std_options: std.Options = .{ .log_level = .err, .logFn = logFn }; fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { if (scope == .mvzr) return; std.log.defaultLog(level, scope, format, args); } 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()); if (args.len == 2 and std.mem.eql(u8, args[1], "--self-test")) return testCli(init); var parity = false; var verbose = false; var strict = false; var pane_override: ?[]const u8 = null; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; const paths = try arena.alloc([]const u8, args.len - 1); var n_paths: usize = 0; var want_waivers = false; for (args[1..]) |a| { if (want_waivers) { if (std.mem.startsWith(u8, a, "--")) return error.MissingWaiverPath; waivers_path = a; want_waivers = false; } else if (std.mem.eql(u8, a, "--parity")) { parity = true; } else if (std.mem.eql(u8, a, "--verbose")) { verbose = true; } else if (std.mem.eql(u8, a, "--strict")) { strict = true; } else if (std.mem.startsWith(u8, a, "--pane=")) { const kind = a["--pane=".len..]; if (!std.mem.eql(u8, kind, "file") and !std.mem.eql(u8, kind, "tty")) return error.UnknownPane; pane_override = kind; } else if (std.mem.eql(u8, a, "--waivers")) { if (waivers_path != null) return error.DuplicateWaiverPath; want_waivers = true; } else if (std.mem.startsWith(u8, a, "--")) { return error.UnknownOption; } else { paths[n_paths] = a; n_paths += 1; } } if (n_paths == 0 or want_waivers) fatal("usage: pardes-hxdiff [--parity] ... [goldens.jsonl [waivers.jsonl]]", .{}); const case_paths = paths[0..if (parity) n_paths else 1]; if (!parity) { if (n_paths > 3) return error.TooManyPaths; if (n_paths >= 2) goldens_path = paths[1]; if (n_paths == 3) { if (waivers_path != null) return error.DuplicateWaiverPath; waivers_path = paths[2]; } } if (parity and pane_override != null) return error.ConflictingPaneModes; if (!parity and goldens_path == null and waivers_path != null) return error.WaiversNeedComparison; // goldens + waivers, keyed by name (diff mode) var goldens: std.StringHashMap(Result) = .init(arena); if (goldens_path) |gp| { const gsrc = std.Io.Dir.cwd().readFileAlloc(init.io, gp, arena, .limited(64 * 1024 * 1024)) 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 = !strict }) catch |e| fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) }); try validateResult(g); const entry = try goldens.getOrPut(g.name); if (g.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate golden name {s}", .{ gp, lno, g.name }); entry.value_ptr.* = g; } } var waivers: std.StringHashMap(Waiver) = .init(arena); if (waivers_path) |wp| { const wsrc = std.Io.Dir.cwd().readFileAlloc(init.io, wp, arena, .limited(64 * 1024 * 1024)) 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 = !strict }) 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 }); try validateResult(w.expected); try validateResult(w.reference); if (!std.mem.eql(u8, w.name, w.expected.name) or !std.mem.eql(u8, w.name, w.reference.name)) return error.WaiverNameMismatch; const entry = try waivers.getOrPut(w.name); if (w.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate waiver name {s}", .{ wp, lno, w.name }); entry.value_ptr.* = w; } } const stdout = std.Io.File.stdout(); var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; var names: std.StringHashMap(void) = .init(arena); for (case_paths) |path| { const src = std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, .limited(64 * 1024 * 1024)) 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 = !strict }) catch |e| fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) }); const entry = try names.getOrPut(case.name); if (case.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate case name {s}", .{ path, lineno, case.name }); n_cases += 1; const reference: ?Result = if (parity) try runCase(arena, case, "file") else if (goldens_path != null) goldens.get(case.name) orelse fatal("no golden for case {s}", .{case.name}) else null; const actual = try runCase(arena, case, if (parity) "tty" else pane_override orelse case.pane); try validateResult(actual); const want = reference orelse { const row = try std.json.Stringify.valueAlloc(arena, actual, .{ .emit_null_optional_fields = false }); try stdout.writeStreamingAll(init.io, row); try stdout.writeStreamingAll(init.io, "\n"); continue; }; try validateResult(want); const reference_name = if (parity) "file" else "helix"; const actual_name = if (parity) "pty" else "pardes"; const difference = try diffResult(arena, want, actual, reference_name, actual_name); if (difference.len == 0) { if (strict and waivers.contains(case.name)) fatal("stale waiver: {s}", .{case.name}); continue; } if (waivers.get(case.name)) |waiver| { const reference_change = try diffResult(arena, waiver.reference, want, "waiver", reference_name); const actual_change = try diffResult(arena, waiver.expected, actual, "waiver", actual_name); if (reference_change.len == 0 and actual_change.len == 0) { n_waived += 1; if (verbose) std.debug.print("WAIVED {s}: {s}\n", .{ case.name, waiver.reason }); continue; } std.debug.print("CHANGED WAIVER {s}\n{s}{s}", .{ case.name, reference_change, actual_change }); } n_bad += 1; std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, difference }); } } if (n_cases == 0) return error.NoCases; if (strict) { if (goldens_path != null and n_cases != goldens.count()) return error.UnusedReferenceCases; var entries = waivers.keyIterator(); while (entries.next()) |name| { if (!names.contains(name.*)) fatal("unused waiver: {s}", .{name.*}); } } if (!parity and goldens_path == null) 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 }; const Sel = struct { cursor: Pos, anchor: Pos }; // field order is the contract's emit order (Stringify writes declaration // order). The two multi-cursor fields are OPTIONAL and emitted with // .emit_null_optional_fields = false, so a single selection prints the // original five-field line and nothing else. const Result = struct { name: []const u8, text: []const u8, mode: []const u8, cursor: Pos, anchor: Pos, sels: ?[]const Sel = null, primary: ?usize = null, }; const Waiver = struct { name: []const u8, reason: []const u8, reference: Result, expected: Result, }; fn validateResult(result: Result) !void { if (result.name.len == 0) return error.EmptyCaseName; if (!std.mem.eql(u8, result.mode, "normal") and !std.mem.eql(u8, result.mode, "insert") and !std.mem.eql(u8, result.mode, "select") and !std.mem.eql(u8, result.mode, "tty")) return error.UnknownMode; try validatePosition(result.text, result.cursor); try validatePosition(result.text, result.anchor); const sels = result.sels orelse { if (result.primary != null) return error.PrimaryWithoutSelections; return; }; if (sels.len < 2) return error.NotMultipleSelections; const primary = result.primary orelse return error.MissingPrimary; if (primary >= sels.len) return error.InvalidPrimary; if (!std.meta.eql(sels[primary].cursor, result.cursor) or !std.meta.eql(sels[primary].anchor, result.anchor)) return error.PrimaryMismatch; for (sels) |selection| { try validatePosition(result.text, selection.cursor); try validatePosition(result.text, selection.anchor); } } fn validatePosition(text: []const u8, position: Pos) !void { if (position.row < 0 or position.col < 0) return error.InvalidPosition; var lines = std.mem.splitScalar(u8, text, '\n'); var row: usize = 0; while (lines.next()) |line| : (row += 1) { if (row != @as(usize, @intCast(position.row))) continue; if (@as(usize, @intCast(position.col)) > line.len) return error.InvalidPosition; return; } return error.InvalidPosition; } test "differential result positions include byte-length gaps and the empty EOF row" { const cases = [_]struct { text: []const u8, position: Pos, valid: bool }{ .{ .text = "", .position = .{ .row = 0, .col = 0 }, .valid = true }, .{ .text = "", .position = .{ .row = 0, .col = 1 }, .valid = false }, .{ .text = "abc", .position = .{ .row = 0, .col = 3 }, .valid = true }, .{ .text = "abc", .position = .{ .row = 1, .col = 0 }, .valid = false }, .{ .text = "abc\n", .position = .{ .row = 0, .col = 3 }, .valid = true }, .{ .text = "abc\n", .position = .{ .row = 1, .col = 0 }, .valid = true }, .{ .text = "abc\n", .position = .{ .row = 1, .col = 1 }, .valid = false }, .{ .text = "abc\n", .position = .{ .row = 2, .col = 0 }, .valid = false }, .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 9 }, .valid = true }, .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 10 }, .valid = false }, .{ .text = "abc\n", .position = .{ .row = -1, .col = 0 }, .valid = false }, .{ .text = "abc\n", .position = .{ .row = 0, .col = -1 }, .valid = false }, }; for (cases) |case| { const result: Result = .{ .name = "position", .text = case.text, .mode = "normal", .cursor = case.position, .anchor = case.position, }; if (case.valid) try validateResult(result) else try std.testing.expectError(error.InvalidPosition, validateResult(result)); } } test "differential result positions validate secondary selections and anchors" { const start: Pos = .{ .row = 0, .col = 0 }; const end: Pos = .{ .row = 1, .col = 0 }; var selections = [_]Sel{ .{ .cursor = start, .anchor = start }, .{ .cursor = end, .anchor = start }, }; var result: Result = .{ .name = "multiple-positions", .text = "λ界😀\n", .mode = "normal", .cursor = start, .anchor = start, .sels = &selections, .primary = 0, }; try validateResult(result); selections[1].cursor.col = 1; try std.testing.expectError(error.InvalidPosition, validateResult(result)); selections[1].cursor = end; selections[1].anchor.row = -1; try std.testing.expectError(error.InvalidPosition, validateResult(result)); selections[1].anchor = start; result.anchor.col = 10; try std.testing.expectError(error.InvalidPosition, validateResult(result)); } fn diffResult(allocator: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); if (!std.mem.eql(u8, want.text, got.text)) out.writer.print(" text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch return error.OutOfMemory; if (!std.mem.eql(u8, want.mode, got.mode)) out.writer.print(" mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch return error.OutOfMemory; if (!std.meta.eql(want.cursor, got.cursor)) out.writer.print(" cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.cursor.row, want.cursor.col, gl, got.cursor.row, got.cursor.col }) catch return error.OutOfMemory; if (!std.meta.eql(want.anchor, got.anchor)) out.writer.print(" anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.anchor.row, want.anchor.col, gl, got.anchor.row, got.anchor.col }) catch return error.OutOfMemory; const ws = want.sels orelse &.{}; const gs = got.sels orelse &.{}; var selections_differ = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); if (!selections_differ) for (ws, gs) |a, b| { if (!std.meta.eql(a, b)) { selections_differ = true; break; } }; if (selections_differ) out.writer.print(" sels: {s} {f} primary={d} != {s} {f} primary={d}\n", .{ wl, std.json.fmt(ws, .{}), want.primary orelse 0, gl, std.json.fmt(gs, .{}), got.primary orelse 0 }) catch return error.OutOfMemory; return out.toOwnedSlice(); } test "differential reports compare values and report every changed field" { const allocator = std.testing.allocator; const want: Result = .{ .name = "case", .text = "one\n", .mode = "normal", .cursor = .{ .row = 0, .col = 0 }, .anchor = .{ .row = 0, .col = 1 }, }; var got = want; got.text = try allocator.dupe(u8, want.text); defer allocator.free(got.text); const same = try diffResult(allocator, want, got, "want", "got"); defer allocator.free(same); try std.testing.expectEqualStrings("", same); const changed: Result = .{ .name = "case", .text = "two\n", .mode = "insert", .cursor = .{ .row = 1, .col = 2 }, .anchor = .{ .row = 1, .col = 0 }, .sels = &.{.{ .cursor = .{ .row = 1, .col = 2 }, .anchor = .{ .row = 1, .col = 0 } }}, }; const Check = struct { fn run(a: std.mem.Allocator, before: Result, after: Result) !void { const difference = try diffResult(a, before, after, "want", "got"); defer a.free(difference); for ([_][]const u8{ "text:", "mode:", "cursor:", "anchor:", "sels:" }) |field| try std.testing.expect(std.mem.indexOf(u8, difference, field) != null); } }; try std.testing.checkAllAllocationFailures(allocator, Check.run, .{ want, changed }); } 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.setTestFile(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 const cooked_len = std.math.add(usize, case.text.len, std.mem.count(u8, case.text, "\n")) catch return error.OutOfMemory; const cooked = try arena.alloc(u8, cooked_len); var cooked_i: usize = 0; for (case.text) |ch| { if (ch == '\n') { cooked[cooked_i] = '\r'; cooked_i += 1; } cooked[cooked_i] = ch; cooked_i += 1; } core.update(.{ .output = .{ .pane = 0, .bytes = cooked } }); 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 or (name.len > 1 and name.len <= 4 and name[0] >= 0x80)) { key.cp = std.unicode.utf8Decode(name) catch return error.InvalidKey; 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, "minus")) { // helix spells Alt-'-' this way: a bare `-` after a modifier // is a parse error in its own macro notation 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 { const len = std.unicode.utf8ByteSequenceLength(case.keys[ki]) catch return error.InvalidKey; if (len > case.keys.len - ki) return error.InvalidKey; const text = case.keys[ki..][0..len]; key.cp = std.unicode.utf8Decode(text) catch return error.InvalidKey; key.text = text; ki += len; } 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 lines = try core.paneCursorLines(pane); var text_len: usize = if (lines.len == 0) 0 else lines.len - 1; for (lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; const joined = try arena.alloc(u8, text_len); var offset: usize = 0; for (lines, 0..) |ln, i| { if (i > 0) { joined[offset] = '\n'; offset += 1; } @memcpy(joined[offset..][0..ln.len], ln); offset += ln.len; } text = joined; } 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); // multiple cursors: the primary (cur_row/cur_col + the anchor above) and // pane.sels, slotted together in document order. pane.sels is already // ordered, so the primary is the only one that needs placing. var sels: ?[]const Sel = null; var primary: ?usize = null; if (pane.nsel > 0) { const out = try arena.alloc(Sel, @as(usize, pane.nsel) + 1); var out_len: usize = 0; const prim: Sel = .{ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor }; const pr_row = @min(prim.cursor.row, prim.anchor.row); const pr_col = if (prim.cursor.row == prim.anchor.row) @min(prim.cursor.col, prim.anchor.col) else if (prim.cursor.row < prim.anchor.row) prim.cursor.col else prim.anchor.col; for (pane.sels[0..pane.nsel]) |s| { const s_row = @min(s.row, s.arow); const s_col = if (s.row == s.arow) @min(s.col, s.acol) else if (s.row < s.arow) s.col else s.acol; if (primary == null and (pr_row < s_row or (pr_row == s_row and pr_col <= s_col))) { primary = out_len; out[out_len] = prim; out_len += 1; } out[out_len] = .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } }; out_len += 1; } if (primary == null) { primary = out_len; out[out_len] = prim; } sels = out; } return .{ .name = case.name, .text = try arena.dupe(u8, text), .mode = mode, .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor, .sels = sels, .primary = primary, }; } /// 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 fatal(comptime fmt: []const u8, args: anytype) noreturn { std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); std.process.exit(1); } fn testCli(init: std.process.Init) !void { const io = init.io; const arena = init.arena.allocator(); const exe = try std.process.executablePathAlloc(io, arena); var random: [12]u8 = undefined; io.random(&random); const path = try std.fmt.allocPrint(arena, "hxdiff-{x}", .{random}); var parent = try std.Io.Dir.cwd().createDirPathOpen(io, ".zig-cache/tmp", .{}); defer parent.close(io); try parent.createDir(io, path, .default_dir); defer parent.deleteTree(io, path) catch {}; var dir = try parent.openDir(io, path, .{}); defer dir.close(io); const case_a = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\"}\n"; const case_b = "{\"name\":\"b\",\"text\":\"b\\n\",\"keys\":\"\"}\n"; const result_tail = ",\"mode\":\"normal\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}"; const result_a = "{\"name\":\"a\",\"text\":\"a\\n\"" ++ result_tail; const result_b = "{\"name\":\"b\",\"text\":\"b\\n\"" ++ result_tail; const different = "{\"name\":\"a\",\"text\":\"different\\n\"" ++ result_tail; const changed = "{\"name\":\"a\",\"text\":\"changed\\n\"" ++ result_tail; const waiver = "{\"name\":\"a\",\"reason\":\"fixture\",\"reference\":" ++ different ++ ",\"expected\":" ++ result_a ++ "}\n"; const missing_waiver = "{\"name\":\"b\",\"reason\":\"fixture\",\"reference\":" ++ result_b ++ ",\"expected\":" ++ result_b ++ "}\n"; const Fixture = struct { name: []const u8, cases: []const u8 = case_a, reference: []const u8 = result_a, waivers: []const u8 = "", args: []const []const u8 = &.{ "--strict", "cases", "reference", "waivers" }, message: []const u8 = "", }; const fixtures = [_]Fixture{ .{ .name = "equal" }, .{ .name = "subset", .reference = result_a ++ "\n" ++ result_b, .args = &.{ "cases", "reference" } }, .{ .name = "missing reference", .cases = case_a ++ case_b, .message = "no golden for case b" }, .{ .name = "unused reference", .reference = result_a ++ "\n" ++ result_b, .message = "UnusedReferenceCases" }, .{ .name = "empty corpus", .cases = "\n", .reference = "", .message = "NoCases" }, .{ .name = "duplicate cases", .cases = case_a ++ case_a, .message = "duplicate case name" }, .{ .name = "duplicate references", .reference = result_a ++ "\n" ++ result_a, .message = "duplicate golden name" }, .{ .name = "empty name", .cases = "{\"name\":\"\",\"text\":\"a\",\"keys\":\"\"}\n", .message = "empty or duplicate case name" }, .{ .name = "mismatch", .reference = different, .message = "MISMATCH a" }, .{ .name = "pinned waiver", .reference = different, .waivers = waiver }, .{ .name = "changed actual", .cases = "{\"name\":\"a\",\"text\":\"changed\\n\",\"keys\":\"\"}\n", .reference = different, .waivers = waiver, .message = "CHANGED WAIVER a" }, .{ .name = "changed reference", .reference = changed, .waivers = waiver, .message = "CHANGED WAIVER a" }, .{ .name = "stale waiver", .waivers = waiver, .message = "stale waiver: a" }, .{ .name = "unused waiver", .waivers = missing_waiver, .message = "unused waiver: b" }, .{ .name = "duplicate waivers", .reference = different, .waivers = waiver ++ waiver, .message = "duplicate waiver name" }, .{ .name = "unknown case field", .cases = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\",\"typo\":true}\n", .message = "UnknownField" }, .{ .name = "unknown reference field", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"typo\":true" ++ result_tail, .message = "UnknownField" }, .{ .name = "unknown mode", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"mode\":\"unknown\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}\n", .message = "UnknownMode" }, .{ .name = "primary without selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"primary\":0" ++ result_tail, .message = "PrimaryWithoutSelections" }, .{ .name = "empty selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"sels\":[]" ++ result_tail, .message = "NotMultipleSelections" }, .{ .name = "missing waiver argument", .args = &.{ "cases", "--waivers" }, .message = "usage:" }, .{ .name = "flag instead of waiver path", .args = &.{ "cases", "--waivers", "--strict" }, .message = "MissingWaiverPath" }, .{ .name = "duplicate waiver option", .args = &.{ "cases", "reference", "--waivers", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, .{ .name = "positional and named waiver", .args = &.{ "cases", "reference", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, .{ .name = "unknown option", .args = &.{ "cases", "--typo" }, .message = "UnknownOption" }, .{ .name = "too many paths", .args = &.{ "cases", "reference", "waivers", "extra" }, .message = "TooManyPaths" }, .{ .name = "waivers without comparison", .args = &.{ "cases", "--waivers", "waivers" }, .message = "WaiversNeedComparison" }, .{ .name = "conflicting pane modes", .args = &.{ "--parity", "--pane=file", "cases" }, .message = "ConflictingPaneModes" }, .{ .name = "unknown pane", .args = &.{ "--pane=unknown", "cases" }, .message = "UnknownPane" }, .{ .name = "parity", .args = &.{ "--parity", "--strict", "cases" } }, .{ .name = "parity option after path", .args = &.{ "cases", "--strict", "--parity" } }, .{ .name = "emit", .args = &.{"cases"} }, }; for (fixtures) |fixture| { try dir.writeFile(io, .{ .sub_path = "cases", .data = fixture.cases }); try dir.writeFile(io, .{ .sub_path = "reference", .data = fixture.reference }); try dir.writeFile(io, .{ .sub_path = "waivers", .data = fixture.waivers }); const argv = try arena.alloc([]const u8, fixture.args.len + 1); argv[0] = exe; @memcpy(argv[1..], fixture.args); const result = try std.process.run(arena, io, .{ .argv = argv, .cwd = .{ .dir = dir }, .stdout_limit = .limited(1024 * 1024), .stderr_limit = .limited(1024 * 1024), .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(5) } }, }); const passed = result.term == .exited and result.term.exited == 0; if (result.term != .exited or passed != (fixture.message.len == 0) or std.mem.indexOf(u8, result.stderr, fixture.message) == null) { std.debug.print("CLI fixture {s}: {s}\n{s}\n", .{ fixture.name, result.stdout, result.stderr }); return error.DifferentialDriverTestFailed; } if (std.mem.eql(u8, fixture.name, "emit") and !std.mem.eql(u8, result.stdout, result_a ++ "\n")) return error.WrongEmittedResult; } std.debug.print("hxdiff: {d} CLI regression fixtures passed\n", .{fixtures.len}); } test "differential cases deliver each Unicode scalar as one key event" { var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const finds = [_]struct { keys: []const u8, col: i32 }{ .{ .keys = "fλ", .col = 4 }, .{ .keys = "f<λ>", .col = 4 }, .{ .keys = "f界", .col = 7 }, .{ .keys = "f<界>", .col = 7 }, .{ .keys = "f😀", .col = 11 }, .{ .keys = "f<😀>", .col = 11 }, }; for (finds) |case| { const result = try runCase(arena, .{ .name = "unicode-find", .text = "one λ 界 😀 two\n", .keys = case.keys }, "file"); try std.testing.expectEqualStrings("one λ 界 😀 two\n", result.text); try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); } const inserts = [_]struct { keys: []const u8, mode: []const u8, col: i32 }{ .{ .keys = "iλ界😀", .mode = "insert", .col = "λ界😀".len }, .{ .keys = "i<λ><界><😀>", .mode = "insert", .col = "λ界😀".len }, .{ .keys = "iλ界😀", .mode = "normal", .col = "λ界😀".len }, .{ .keys = "i<λ><界><😀>", .mode = "normal", .col = "λ界😀".len }, .{ .keys = "iλ界😀h", .mode = "normal", .col = "λ界".len }, .{ .keys = "i<λ><界><😀>h", .mode = "normal", .col = "λ界".len }, }; for (inserts) |case| { const result = try runCase(arena, .{ .name = "unicode-insert", .text = "\n", .keys = case.keys }, "file"); try std.testing.expectEqualStrings("λ界😀\n", result.text); try std.testing.expectEqualStrings(case.mode, result.mode); try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); } }