summaryrefslogtreecommitdiff
path: root/src/normal.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 16:35:27 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commite87e16547a17864b2a0cd574c1aa42d44125bc6c (patch)
tree45a35aec572bc141c008bc6c17c7a2d5acf6a653 /src/normal.zig
parent79cdf76fd2dd0ba03622bfe742b525b770d51684 (diff)
downloadpardes-e87e16547a17864b2a0cd574c1aa42d44125bc6c.tar.gz
pardes-e87e16547a17864b2a0cd574c1aa42d44125bc6c.zip
Alt-o, Alt-i and the other tree-sitter keys walk the file's syntax tree
helix's expand/shrink, previous/next sibling, all siblings, all children and parent-node end/start (Alt-o/up, Alt-i/down, Alt-p/left, Alt-right, Alt-a, Alt-I, Alt-e, Alt-b; Alt-n stays the new terminal) walk a whole-file parse the file keeps (File.node_tree), made on the first such key after an edit and reused until the next, never per key. syntax.walkNodes follows helix's TreeCursor over every node, anonymous ones included, and Alt-i goes back to what Alt-o grew from while the selection still holds it. The harness runs without grammars, so the cases prove the plain-text no-op; a test replays eleven JSON walks whose results were taken from the installed hx. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/normal.zig')
-rw-r--r--src/normal.zig124
1 files changed, 124 insertions, 0 deletions
diff --git a/src/normal.zig b/src/normal.zig
index d6c646ef..bc450581 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -7,6 +7,7 @@ const exec = @import("exec.zig");
const look = @import("look.zig");
const std = @import("std");
const regexp = @import("regexp.zig");
+const syntax = @import("syntax.zig");
const modal = @import("modal.zig");
const panes = @import("panes.zig");
const edit = @import("edit.zig");
@@ -166,6 +167,83 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m
t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO)
}
+/// helix's tree-sitter selection keys: every range walks the file's parse,
+/// which is made on the first of these keys after an edit and kept, never
+/// made again for a key that changed nothing. A range the tree has nothing
+/// to say about stays as it was; a text without a grammar, all of them.
+fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void {
+ if (t.what != .body) return;
+ const f = if (pane.file) |*file| file else return;
+ if (f.node_tree == null or f.node_tree_revision != f.revision) {
+ if (f.node_tree) |old| syntax.freeTree(old);
+ f.node_tree = syntax.parseTree(f.path, f.content);
+ f.node_tree_revision = f.revision;
+ for (f.node_history.items) |step| p.gpa.free(step.ranges);
+ f.node_history.clearRetainingCapacity();
+ }
+ const tree = f.node_tree orelse return;
+ const text = f.content;
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ const arena = p.scratch.allocator();
+ const rs = arena.alloc(modal.Selection, Text.max_selections) catch return;
+ const got = t.ranges(text, 0, rs[0..Text.max_selections]);
+ if (walk == .shrink) if (f.node_history.pop()) |prev| {
+ defer p.gpa.free(prev.ranges);
+ // back to what Alt-o grew from, while the selection still holds it
+ const holds = for (prev.ranges) |pr| {
+ const pf = @min(pr.anchor, pr.head);
+ const pt = @max(pr.anchor, pr.head);
+ if (for (rs[0..got.n]) |r| {
+ if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true;
+ } else false) continue;
+ break false;
+ } else true;
+ if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl);
+ for (f.node_history.items) |step| p.gpa.free(step.ranges);
+ f.node_history.clearRetainingCapacity();
+ };
+ const out = arena.alloc(modal.Selection, Text.max_selections) catch return;
+ const spans = arena.alloc(syntax.Span, Text.max_selections) catch return;
+ var m: usize = 0;
+ for (rs[0..got.n]) |r| {
+ if (m == out.len) break;
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ const back = r.head < r.anchor;
+ const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]);
+ if (n == 0) {
+ out[m] = r;
+ m += 1;
+ continue;
+ }
+ for (spans[0..n]) |s| {
+ out[m] = switch (walk) {
+ .next_sibling => .{ .anchor = s.from, .head = s.to },
+ .prev_sibling => .{ .anchor = s.to, .head = s.from },
+ // helix move_parent_node_end: a one-character range on the
+ // bound, or stretched to it in select mode
+ .parent_end, .parent_start => bound: {
+ const head = s.from;
+ if (t.select) {
+ const anchor = r.anchor;
+ break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head };
+ }
+ const next = modal.nextGrapheme(text, head);
+ break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next };
+ },
+ else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to },
+ };
+ m += 1;
+ }
+ }
+ const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m]));
+ if (walk == .expand and !same) remember: {
+ const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember;
+ f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges);
+ }
+ t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
+}
+
const KeyState = struct {
mode: Text.Mode,
select: bool,
@@ -613,6 +691,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
p,
if (direction == .forward) @as(i32, 1) else -1,
),
+ .node => |walk| return nodeSelect(p, pane, t, walk),
.macro_record => {
const m = &p.macro;
if (m.recording == 0) {
@@ -840,3 +919,48 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi
}
}
}
+
+test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" {
+ if (!syntax.supportsPath("a.json")) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const text = "{\"a\": [1, 2, 3], \"b\": true}\n";
+ // each checked against the installed hx through a pty: the keys, then
+ // the text it saved
+ const Case = struct { keys: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" },
+ .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" },
+ .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" },
+ .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" },
+ .{ .keys = "6lx\x1bid", .want = "\n" },
+ .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" },
+ .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" },
+ }) |c| {
+ const pane = try p.setTestFile(text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, "/test.json");
+ // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right
+ var i: usize = 0;
+ while (i < c.keys.len) : (i += 1) {
+ const ch = c.keys[i];
+ if (ch == 0x1b and i + 1 < c.keys.len) {
+ i += 1;
+ const k = c.keys[i];
+ p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } });
+ } else if (ch == 0x1b) {
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } });
+ while (p.nextEffect()) |_| {}
+ }
+ std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| {
+ std.debug.print("keys {s}\n", .{c.keys});
+ return err;
+ };
+ }
+}