summaryrefslogtreecommitdiff
path: root/test/hxdiff.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 05:04:26 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit5bf8d6dd077517270377e5d8551108ecf252374f (patch)
treec7783fa9fc133980d3d6fff0129e8de5a1b43601 /test/hxdiff.zig
parenteefac04995ffad847a4098f16d2e82ccab16438b (diff)
downloadpardes-5bf8d6dd077517270377e5d8551108ecf252374f.tar.gz
pardes-5bf8d6dd077517270377e5d8551108ecf252374f.zip
multiple cursors, regex selection, and Ctrl-c comments
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and sels[] holds helix's OTHER ranges. That split is why nothing moved at one cursor: with nsel == 0 not one line of the existing motion, operator, render or mouse code takes a different branch, which is what protects 800 differential cases and 67 goldens. paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's Selection::new (min width 1, sorted, overlaps merged, primary follows its range through a merge). An ordinary key runs the single-selection handler once per range, visited last-first so an edit never disturbs a range still waiting, and each finished pass is remembered as a distance from the END of the text, which an earlier edit cannot move — helix's change mapping without a change map. pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from the primary and stops the replay, which also closes the use-after-free window if it frees the pane. s and S reuse the / prompt wholesale rather than growing a second one: the pattern is typed into the tag tail, and every keystroke re-runs the match from the selection the prompt opened on, so the preview is live and Esc is just the empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size struct with no allocator — with 64 ops and 8 char classes per pattern, no case-insensitive flag (helix's smart case is done by folding a scratch copy), no captures, no multi-line anchors. The last two are the two waivers. Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides comment-vs-uncomment ONCE for the whole selection; replaying it would take that decision n times. Comment tokens are a table in config.zig keyed on the same extension syntax.zig picks grammars by. Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left the cursor one cell before where the append began. helix's restore_cursor can never walk past the origin; ours backed up unconditionally. hxdiff was green before AND after — the old one-selection contract could not see it. hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the harness contract now reports every range and its primary, omitted when there is one, so 359 of the 360 old goldens are byte-identical. The one that moved is o-count: helix's 2o really does leave two cursors and could not say so before.
Diffstat (limited to 'test/hxdiff.zig')
-rw-r--r--test/hxdiff.zig99
1 files changed, 96 insertions, 3 deletions
diff --git a/test/hxdiff.zig b/test/hxdiff.zig
index b04cc0b5..f6ab5c6f 100644
--- a/test/hxdiff.zig
+++ b/test/hxdiff.zig
@@ -8,6 +8,16 @@
// 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)
@@ -53,7 +63,21 @@ const pardes = @import("pardes");
// 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.
-pub const std_options: std.Options = .{ .log_level = .err };
+// 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;
@@ -152,7 +176,9 @@ pub fn main(init: std.process.Init) !void {
}
const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) });
if (goldens_path == null) {
- const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{});
+ // 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;
@@ -185,14 +211,20 @@ const Case = struct {
};
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)
+// 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 {
@@ -216,6 +248,35 @@ fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u
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;
}
@@ -296,6 +357,11 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
} 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")) {
@@ -351,12 +417,39 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result
// 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,
};
}