summaryrefslogtreecommitdiff
path: root/test/hxdiff.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/hxdiff.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-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.zig593
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);
+ }
}