diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /test/hxdiff.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/hxdiff.zig')
| -rw-r--r-- | test/hxdiff.zig | 593 |
1 files changed, 369 insertions, 224 deletions
diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 94ee226e..22d8067d 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -1,62 +1,7 @@ -// pardes-hxdiff: the pardes half of the helix differential harness. Drives -// the 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}} -// -// MULTIPLE CURSORS extend that line with two more fields, and only when there -// is more than one selection: -// -// ...,"sels":[{"cursor":…,"anchor":…},…],"primary":1 -// -// `sels` is EVERY range in document order, each end measured exactly like the -// `cursor`/`anchor` pair above, and `primary` is the index of the one those -// two fields describe. Omitted entirely at one selection — which is why every -// golden written before multiple cursors existed is still byte-for-byte valid. -// -// zig build hxdiff (diff the checked-in corpus) -// zig build hxdiff -- <cases.jsonl> (results to stdout, no diff) -// zig build hxparity (file-vs-pty parity, both corpora) -// ./zig-out/bin/pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]] -// ./zig-out/bin/pardes-hxdiff --parity [--waivers w.jsonl] <cases.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-<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>. +// 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 libc = std.c; const pardes = @import("pardes"); // Its own root, so src/main.zig's logFn does not apply here: the core it @@ -86,63 +31,92 @@ pub fn main(init: std.process.Init) !void { 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 case_paths = arena.alloc([]const u8, args.len - 1) catch fatal("oom", .{}); - var n_case_paths: usize = 0; + 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 (parity or n_case_paths == 0) { - // diff mode is positional (cases, goldens, waivers); parity mode - // takes any number of case files and nothing else - case_paths[n_case_paths] = a; - n_case_paths += 1; - } 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]]", .{}); + } else if (std.mem.startsWith(u8, a, "--")) { + return error.UnknownOption; + } else { + paths[n_paths] = a; + n_paths += 1; + } } - if (n_case_paths == 0) + if (n_paths == 0 or want_waivers) fatal("usage: pardes-hxdiff [--parity] <cases.jsonl>... [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 = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) }); + 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 = true }) catch |e| + 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) }); - goldens.put(g.name, g) catch fatal("oom", .{}); + 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([]const u8) = .init(arena); + var waivers: std.StringHashMap(Waiver) = .init(arena); if (waivers_path) |wp| { - const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) }); + 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 = true }) catch |e| + 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 }); - waivers.put(w.name, w.reason) catch fatal("oom", .{}); + 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; } } @@ -150,51 +124,62 @@ pub fn main(init: std.process.Init) !void { var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; - for (case_paths[0..n_case_paths]) |path| { - const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); + 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 = true }) catch |e| + 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; - 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) { - // null sels/primary are DROPPED, not written: that is what - // keeps a one-selection line identical to the old contract - const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); - try stdout.writeStreamingAll(init.io, jl); + 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; - } - 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 }); + }; + 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, diff }); + 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; @@ -229,91 +214,155 @@ const Result = struct { const Waiver = struct { name: []const u8, reason: []const u8, + reference: Result, + expected: Result, }; -/// 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 parts: [5][]const u8 = undefined; - var n: usize = 0; - if (!std.mem.eql(u8, want.text, got.text)) { - parts[n] = std.fmt.allocPrint(arena, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch ""; - n += 1; - } - if (!std.mem.eql(u8, want.mode, got.mode)) { - parts[n] = std.fmt.allocPrint(arena, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch ""; - n += 1; - } - if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, " 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) }) catch ""; - n += 1; +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); } - if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { - parts[n] = std.fmt.allocPrint(arena, " 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) }) catch ""; - n += 1; +} + +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; } - // the whole selection: absent on both sides = one range, and the two - // fields above already said everything about it - const ws = want.sels orelse &.{}; - const gs = got.sels orelse &.{}; - var sel_diff = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); - if (!sel_diff) for (ws, gs) |a, b| { - if (a.cursor.row != b.cursor.row or a.cursor.col != b.cursor.col or - a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; + 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 }, }; - if (sel_diff) { - parts[n] = std.fmt.allocPrint(arena, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }) catch ""; - n += 1; + 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)); } - return concatParts(arena, parts[0..n]); } -/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to -/// one pair when they are the same cell), the primary starred -fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { - if (sels.len == 0) return "<one>"; - var part_count: usize = 2; - for (sels, 0..) |s, i| { - part_count += 1; - if (i > 0) part_count += 1; - if (i == (primary orelse 0)) part_count += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) part_count += 1; - } - const parts = arena.alloc([]const u8, part_count) catch return ""; - var n: usize = 0; - parts[n] = std.fmt.allocPrint(arena, "{d} of [", .{sels.len}) catch ""; - n += 1; - for (sels, 0..) |s, i| { - if (i > 0) { - parts[n] = " "; - n += 1; - } - if (i == (primary orelse 0)) { - parts[n] = "*"; - n += 1; - } - parts[n] = std.fmt.allocPrint(arena, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }) catch ""; - n += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }) catch ""; - n += 1; +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; } - } - parts[n] = "]"; - n += 1; - return concatParts(arena, parts[0..n]); + }; + 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(); } -fn concatParts(arena: std.mem.Allocator, parts: []const []const u8) []const u8 { - var len: usize = 0; - for (parts) |part| len = std.math.add(usize, len, part.len) catch return ""; - const out = arena.alloc(u8, len) catch return ""; - var offset: usize = 0; - for (parts) |part| { - @memcpy(out[offset..][0..part.len], part); - offset += part.len; - } - return out; +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 { @@ -325,7 +374,7 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result pump(core); if (std.mem.eql(u8, pane_kind, "file")) { - _ = try core.hxOpenFileContent(case.text); + _ = 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; @@ -372,8 +421,8 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result name = name[2..]; } else break; } - if (name.len == 1) { - key.cp = name[0]; + 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; @@ -417,9 +466,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result 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; + 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); @@ -436,12 +488,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // 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 text_len: usize = if (pl.lines.len == 0) 0 else pl.lines.len - 1; - for (pl.lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; + 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 (pl.lines, 0..) |ln, i| { + for (lines, 0..) |ln, i| { if (i > 0) { joined[offset] = '\n'; offset += 1; @@ -506,38 +558,131 @@ 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); - const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; - const size = std.math.cast(usize, end) orelse return error.FileTooLarge; - const buf = try arena.alloc(u8, size); - var offset: usize = 0; - while (offset < buf.len) { - const n = libc.read(fd, buf[offset..].ptr, buf.len - offset); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) return buf[0..offset]; - offset += @intCast(n); - } - var extra: [1]u8 = undefined; - while (true) { - const n = libc.read(fd, &extra, extra.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; +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 (n > 0) return error.FileChanged; - return buf; + 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}); } -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); - std.process.exit(1); +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λ界😀<esc>", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "i<λ><界><😀><esc>", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "iλ界😀<esc>h", .mode = "normal", .col = "λ界".len }, + .{ .keys = "i<λ><界><😀><esc>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); + } } |
