summaryrefslogtreecommitdiff
path: root/test/hxdiff.zig
blob: f6ab5c6f3b4da1c034aac2b73def0c958ef25783 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
// pardes-hxdiff: the pardes half of the helix differential harness. Drives
// the sans-IO core (src/pardes.zig) headlessly over JSON-Lines cases and
// prints one contract result line per case on stdout, input order — the same
// case/result contract helix's hx-harness speaks (pardes-harness branch in
// the genizah checkout), so the two editors diff key-for-key.
//
//   case:   {"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"}
//   result: {"name":"w-basic","text":"alpha beta\n","mode":"normal",
//            "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}}
//
// 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>.
const std = @import("std");
const libc = std.c;
const pardes = @import("pardes");

// Its own root, so src/main.zig's logFn does not apply here: the core it
// drives feeds every case's text through ghostty-vt, which narrates each
// sequence it does not implement. Cut the libraries back to errors; the
// per-case report goes through std.debug, so a mismatch stays loud.
// 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());

    var parity = false;
    var goldens_path: ?[]const u8 = null;
    var waivers_path: ?[]const u8 = null;
    var case_paths: std.ArrayList([]const u8) = .empty;
    var want_waivers = false;
    for (args[1..]) |a| {
        if (want_waivers) {
            waivers_path = a;
            want_waivers = false;
        } else if (std.mem.eql(u8, a, "--parity")) {
            parity = true;
        } else if (std.mem.eql(u8, a, "--waivers")) {
            want_waivers = true;
        } else if (parity or case_paths.items.len == 0) {
            // diff mode is positional (cases, goldens, waivers); parity mode
            // takes any number of case files and nothing else
            case_paths.append(arena, a) catch fatal("oom", .{});
        } else if (goldens_path == null) {
            goldens_path = a;
        } else if (waivers_path == null) {
            waivers_path = a;
        } else fatal("usage: pardes-hxdiff <cases.jsonl> [goldens.jsonl [waivers.jsonl]]", .{});
    }
    if (case_paths.items.len == 0)
        fatal("usage: pardes-hxdiff [--parity] <cases.jsonl>... [goldens.jsonl [waivers.jsonl]]", .{});

    // goldens + waivers, keyed by name (diff mode)
    var goldens: std.StringHashMap(Result) = .init(arena);
    if (goldens_path) |gp| {
        const gsrc = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) });
        var glines = std.mem.splitScalar(u8, gsrc, '\n');
        var lno: usize = 0;
        while (glines.next()) |raw| {
            lno += 1;
            const line = std.mem.trim(u8, raw, " \t\r");
            if (line.len == 0) continue;
            const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
                fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) });
            goldens.put(g.name, g) catch fatal("oom", .{});
        }
    }
    var waivers: std.StringHashMap([]const u8) = .init(arena);
    if (waivers_path) |wp| {
        const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) });
        var wlines = std.mem.splitScalar(u8, wsrc, '\n');
        var lno: usize = 0;
        while (wlines.next()) |raw| {
            lno += 1;
            const line = std.mem.trim(u8, raw, " \t\r");
            if (line.len == 0) continue;
            const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
                fatal("{s}:{d}: bad waiver line: {s}", .{ wp, lno, @errorName(e) });
            if (w.reason.len == 0) fatal("{s}:{d}: waiver {s} MUST carry a reason", .{ wp, lno, w.name });
            waivers.put(w.name, w.reason) catch fatal("oom", .{});
        }
    }

    var out: std.ArrayList(u8) = .empty;
    var n_cases: usize = 0;
    var n_bad: usize = 0;
    var n_waived: usize = 0;
    for (case_paths.items) |path| {
        const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) });
        var lines = std.mem.splitScalar(u8, src, '\n');
        var lineno: usize = 0;
        while (lines.next()) |raw| {
            lineno += 1;
            const line = std.mem.trim(u8, raw, " \t\r");
            if (line.len == 0) continue;
            const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e|
                fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) });
            n_cases += 1;
            if (parity) {
                // the file pane IS the oracle: same text, same keys, both kinds
                const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) });
                const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) });
                const diff = diffResult(arena, want, got, "file", "pty");
                if (diff.len == 0) continue;
                if (waivers.get(case.name)) |reason| {
                    n_waived += 1;
                    std.debug.print("WAIVED  {s}: {s}\n", .{ case.name, reason });
                    continue;
                }
                n_bad += 1;
                std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
                continue;
            }
            const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
            if (goldens_path == null) {
                // 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", .{});
                out.appendSlice(arena, jl) catch fatal("oom", .{});
                out.append(arena, '\n') catch fatal("oom", .{});
                continue;
            }
            const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name});
            const diff = diffResult(arena, g, res, "helix", "pardes");
            if (diff.len == 0) continue;
            if (waivers.get(case.name)) |reason| {
                n_waived += 1;
                std.debug.print("WAIVED  {s}: {s}\n", .{ case.name, reason });
                continue;
            }
            n_bad += 1;
            std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff });
        }
    }
    if (!parity and goldens_path == null) {
        try std.Io.File.stdout().writeStreamingAll(init.io, out.items);
        return;
    }
    std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", n_cases, n_bad, n_waived });
    if (n_bad > 0) std.process.exit(1);
}

const Case = struct {
    name: []const u8,
    pane: []const u8 = "file",
    text: []const u8,
    keys: []const u8,
};

const Pos = struct { row: i32, col: i32 };
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,
};

/// field-by-field compare; returns a human-readable report ("" = match).
/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty).
fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 {
    var buf: std.ArrayList(u8) = .empty;
    if (!std.mem.eql(u8, want.text, got.text)) {
        appendf(arena, &buf, "  text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) });
    }
    if (!std.mem.eql(u8, want.mode, got.mode)) {
        appendf(arena, &buf, "  mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode });
    }
    if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) {
        appendf(arena, &buf, "  cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) });
    }
    if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) {
        appendf(arena, &buf, "  anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) });
    }
    // 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;
    };
    if (sel_diff) {
        appendf(arena, &buf, "  sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) });
    }
    return buf.items;
}

/// "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 buf: std.ArrayList(u8) = .empty;
    appendf(arena, &buf, "{d} of [", .{sels.len});
    for (sels, 0..) |s, i| {
        if (i > 0) appendf(arena, &buf, " ", .{});
        if (i == (primary orelse 0)) appendf(arena, &buf, "*", .{});
        appendf(arena, &buf, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) });
        if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col)
            appendf(arena, &buf, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) });
    }
    appendf(arena, &buf, "]", .{});
    return buf.items;
}

fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []const u8, fargs: anytype) void {
    const s = std.fmt.allocPrint(arena, fmt, fargs) catch return;
    buf.appendSlice(arena, s) catch {};
}

fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result {
    // fresh core per case: the tty_only boot — one shell pane, tty mode, no
    // greeting. Live-session contract: sizes arrive as the first resize event.
    const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
    defer core.deinit();
    core.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
    pump(core);

    if (std.mem.eql(u8, pane_kind, "file")) {
        _ = try core.hxOpenFileContent(case.text);
    } else if (std.mem.eql(u8, pane_kind, "tty")) {
        // the harness is the pty here: cook \n to \r\n the way ONLCR would
        var cooked: std.ArrayList(u8) = .empty;
        for (case.text) |ch| {
            if (ch == '\n') try cooked.append(arena, '\r');
            try cooked.append(arena, ch);
        }
        core.update(.{ .output = .{ .pane = 0, .bytes = cooked.items } });
        pump(core);
        // leave tty mode the way a live session does: Ctrl-<tty_toggle>,
        // then park the cursor at (0,0) — the helix harness's initial
        // Selection::single(0,1) equivalent (a live exit would leave the
        // modal cursor tracking the shell cursor below the output)
        core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } });
        pump(core);
        if (core.panes[core.active]) |pane| {
            pane.cur_row = 0;
            pane.cur_col = 0;
            pane.cur_pinned = true;
        }
    } else fatal("case {s}: unknown pane {s}", .{ case.name, pane_kind });

    // keys: helix notation — printables verbatim, <...> specials
    var ki: usize = 0;
    while (ki < case.keys.len) {
        var key: pardes.Key = .{ .cp = 0 };
        if (case.keys[ki] == '<') {
            const close = std.mem.indexOfScalarPos(u8, case.keys, ki, '>') orelse
                fatal("case {s}: unterminated < in keys", .{case.name});
            var name = case.keys[ki + 1 .. close];
            ki = close + 1;
            while (true) {
                if (std.mem.startsWith(u8, name, "C-")) {
                    key.ctrl = true;
                    name = name[2..];
                } else if (std.mem.startsWith(u8, name, "A-")) {
                    key.alt = true;
                    name = name[2..];
                } else break;
            }
            if (name.len == 1) {
                key.cp = name[0];
                if (!key.ctrl and !key.alt) key.text = name;
            } else if (std.mem.eql(u8, name, "esc")) {
                key.cp = pardes.Key.escape;
            } else if (std.mem.eql(u8, name, "ret")) {
                key.cp = pardes.Key.enter;
            } else if (std.mem.eql(u8, name, "tab")) {
                key.cp = pardes.Key.tab;
            } else if (std.mem.eql(u8, name, "space")) {
                key.cp = ' ';
                if (!key.ctrl and !key.alt) key.text = " ";
            } else if (std.mem.eql(u8, name, "backspace")) {
                key.cp = pardes.Key.backspace;
            } else if (std.mem.eql(u8, name, "del")) {
                key.cp = pardes.Key.delete;
            } else if (std.mem.eql(u8, name, "lt")) {
                key.cp = '<';
                if (!key.ctrl and !key.alt) key.text = "<";
            } else if (std.mem.eql(u8, name, "gt")) {
                key.cp = '>';
                if (!key.ctrl and !key.alt) key.text = ">";
            } else if (std.mem.eql(u8, name, "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 {
            key.cp = case.keys[ki];
            key.text = case.keys[ki .. ki + 1];
            ki += 1;
        }
        core.update(.{ .key = key });
        pump(core);
    }

    // one contract result line off the active pane's final state
    const pane = core.panes[core.active] orelse return error.NoActivePane;
    var text: []const u8 = undefined;
    if (pane.file) |f| {
        text = f.content;
    } else {
        // the tty motion surface, dumped honestly: whole scrollback+grid,
        // prompt rows blanked, the edit buffer standing in. The surface keeps
        // the cursor's empty row below the output (see Pardes.shellRows), so
        // its line list is exactly a file content's — join it and you have the
        // same bytes, trailing newline included, with nothing to patch up.
        const pl = try core.paneCursorLines(pane);
        var buf: std.ArrayList(u8) = .empty;
        for (pl.lines, 0..) |ln, i| {
            if (i > 0) try buf.append(arena, '\n');
            try buf.appendSlice(arena, ln);
        }
        text = buf.items;
    }
    var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col };
    if (pane.vsel.active) {
        anchor = .{ .row = pane.vsel.row, .col = pane.vsel.col };
    } else if (pane.msel.active) {
        // line selection: the far end (the r0/r1 not holding the cursor), col 0
        anchor = .{ .row = if (pane.cur_row == pane.msel.r1) pane.msel.r0 else pane.msel.r1, .col = 0 };
    }
    // mode "select" ONLY while v-extend is on (helix keeps x/%/mi selections
    // in normal mode); pane.mode itself never holds a select state
    const mode = if (pane.mode == .normal and pane.select) "select" else @tagName(pane.mode);
    // 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) {
        var out: std.ArrayList(Sel) = .empty;
        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.items.len;
                try out.append(arena, prim);
            }
            try out.append(arena, .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } });
        }
        if (primary == null) {
            primary = out.items.len;
            try out.append(arena, prim);
        }
        sels = out.items;
    }
    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 readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
    const path_z = try arena.dupeZ(u8, path);
    const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
    if (fd < 0) return error.OpenFailed;
    defer _ = libc.close(fd);
    var buf: std.ArrayList(u8) = .empty;
    var chunk: [16384]u8 = undefined;
    while (true) {
        const n = libc.read(fd, &chunk, chunk.len);
        if (n < 0) {
            if (libc.errno(n) == .INTR) continue;
            return error.ReadFailed;
        }
        if (n == 0) break;
        try buf.appendSlice(arena, chunk[0..@intCast(n)]);
    }
    return buf.items;
}

fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
    std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args);
    std.process.exit(1);
}