summaryrefslogtreecommitdiff
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
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]>
-rw-r--r--docs/helix-keys.md4
-rw-r--r--src/File.zig13
-rw-r--r--src/config.zig10
-rw-r--r--src/modal.zig20
-rw-r--r--src/normal.zig124
-rw-r--r--src/syntax.zig90
-rw-r--r--test/hxcases/cases.jsonl1
-rw-r--r--test/hxcases/goldens.jsonl1
8 files changed, 261 insertions, 2 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 5c3972a5..f2901b92 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -127,7 +127,7 @@ language-backend queries, and the shell pipe.
| `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific |
| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific |
| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific |
-| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
+| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` (select next sibling), which pardes spells `Alt-right` alone | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
| `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. Neither `Exit` (quits the editor, acme's Exit) nor `Kill` (stops the commands pardes typed into terminals, acme's Kill) has a leader path: both are the topbar's and the root `ctl`'s. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words |
| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the same as `"+y`, `"+p`, `"+P`, `"+R`: register `+` (and `*`) IS the desktop's clipboard, the only register that reaches it. `SPC y` writes `+` alone, as helix's does, not the default register too. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. The clipboard holds one text, so N values go out joined by newlines, as helix sends them. On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
@@ -213,6 +213,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`,
| `;` | collapse selection to cursor | drops vsel/msel, cursor stays | helix-verified (phase 5) |
| `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) |
| `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified |
+| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) |
| `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) |
| `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) |
| `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) |
@@ -352,7 +353,6 @@ text.
| `gw` | word-label jump | label-jump overlay machinery, not core editing |
| `g.` | goto last modification | jumplist/history position tracking |
| Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags |
-| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-n`/`Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | syntax-node selection | tree-sitter |
| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter |
| `]g [g ]G [G` | git change jumps | needs VCS diff state |
| insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped |
diff --git a/src/File.zig b/src/File.zig
index 8ba58fbc..2c4f0aec 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -69,9 +69,19 @@ pub const State = struct {
tree_context: bool = false,
context_declarations: []syntax.ContextDeclaration = &.{},
context_revision: ?u32 = null,
+ /// The whole file's parse for the keys that walk its nodes (Alt-o and
+ /// kin), made on the first of them after an edit and kept until the next
+ /// one: `syntax.parseTree`'s, opaque here.
+ node_tree: ?*anyopaque = null,
+ node_tree_revision: u32 = 0,
+ /// helix's object_selections: what Alt-o grew from, for Alt-i to go back
+ /// to; gpa-owned ranges and the primary's index.
+ node_history: std.ArrayList(NodeStep) = .empty,
history: *History,
};
+pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
+
pub fn dumpPane(
arena: std.mem.Allocator,
pane: *const Pane,
@@ -816,6 +826,9 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
locations.freeRows(p.gpa, file.location_rows);
if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations);
+ if (file.node_tree) |tree| syntax.freeTree(tree);
+ for (file.node_history.items) |step| p.gpa.free(step.ranges);
+ file.node_history.deinit(p.gpa);
for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
p.gpa.destroy(file.history);
diff --git a/src/config.zig b/src/config.zig
index 8c5c9771..959f4c4a 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -537,6 +537,16 @@ pub const split_regex: []const Chord = &.{.{ .cp = 'S' }};
pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }};
pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }};
pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }};
+// helix's tree-sitter selection; Alt-n stays the new terminal, so the next
+// sibling is Alt-right alone
+pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } };
+pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } };
+pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } };
+pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }};
+pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }};
+pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }};
+pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }};
+pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }};
pub const delete: []const Chord = &.{.{ .cp = 'd' }};
pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }};
diff --git a/src/modal.zig b/src/modal.zig
index f5f3081e..ae008f60 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -100,6 +100,14 @@ pub const Normal = struct {
keep_regex,
remove_regex,
ensure_forward,
+ expand_node,
+ shrink_node,
+ next_sibling,
+ prev_sibling,
+ all_siblings,
+ all_children,
+ parent_node_end,
+ parent_node_start,
delete,
delete_noyank,
@@ -213,6 +221,8 @@ pub const Normal = struct {
}
};
+ /// helix's tree-sitter selection keys: where a range goes in the tree
+ pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start };
/// what the s/S/K/Alt-K prompt does with its pattern's matches
pub const RegexMode = enum { select, split, keep, remove };
pub const Scope = enum { once, per_selection };
@@ -331,6 +341,7 @@ pub const Normal = struct {
search_step: Direction,
macro_record,
macro_replay: u32,
+ node: NodeWalk,
repeat_insert: u32,
pub fn scope(value: Action) Scope {
@@ -345,6 +356,7 @@ pub const Normal = struct {
.search_step,
.macro_record,
.macro_replay,
+ .node,
.repeat_insert,
=> .once,
.edit => |edit| switch (edit.kind) {
@@ -588,6 +600,14 @@ pub const Normal = struct {
if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep });
if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove });
if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } });
+ if (key.has(.expand_node)) return resultAction(.{ .node = .expand });
+ if (key.has(.shrink_node)) return resultAction(.{ .node = .shrink });
+ if (key.has(.next_sibling)) return resultAction(.{ .node = .next_sibling });
+ if (key.has(.prev_sibling)) return resultAction(.{ .node = .prev_sibling });
+ if (key.has(.all_siblings)) return resultAction(.{ .node = .all_siblings });
+ if (key.has(.all_children)) return resultAction(.{ .node = .all_children });
+ if (key.has(.parent_node_end)) return resultAction(.{ .node = .parent_end });
+ if (key.has(.parent_node_start)) return resultAction(.{ .node = .parent_start });
if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } });
if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } });
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;
+ };
+ }
+}
diff --git a/src/syntax.zig b/src/syntax.zig
index 9c1a4f4b..89fc4198 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -337,6 +337,96 @@ pub fn supportsPath(path: []const u8) bool {
return false;
}
+/// A whole file's parse, kept by the file for the keys that walk its nodes
+/// (helix's Alt-o and kin). Opaque outside this file; null without a grammar.
+pub fn parseTree(path: []const u8, content: []const u8) ?*anyopaque {
+ if (!enabled) return null;
+ const selected = (forExt(std.fs.path.extension(path)) catch return null) orelse return null;
+ const tree = selected.parser.parseString(content, null) orelse return null;
+ return @ptrCast(tree);
+}
+
+pub fn freeTree(tree: *anyopaque) void {
+ if (!enabled) return;
+ const t: *ts.Tree = @ptrCast(@alignCast(tree));
+ t.destroy();
+}
+
+/// Where a node walk goes from a range (helix object.rs and movement.rs).
+pub const NodeWalk = @import("modal.zig").Normal.NodeWalk;
+pub const Span = struct { from: usize, to: usize };
+
+/// The spans `walk` takes the byte range [from, to) to, into `out`; `at` is
+/// the range's cursor. None when the tree has nothing to say there.
+pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, at: usize, out: []Span) usize {
+ if (!enabled or out.len == 0) return 0;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const lo: u32 = @intCast(@min(from, std.math.maxInt(u32)));
+ const hi: u32 = @intCast(@min(to, std.math.maxInt(u32)));
+ if (walk == .parent_end or walk == .parent_start) {
+ var node = tree.rootNode().namedDescendantForByteRange(lo, hi) orelse return 0;
+ if (walk == .parent_end) {
+ out[0] = .{ .from = node.endByte(), .to = node.endByte() };
+ return 1;
+ }
+ // already at the node's lo: its first ancestor that starts earlier
+ if (node.startByte() == at) {
+ const first = node.startByte();
+ while (node.startByte() >= first or !node.isNamed()) node = node.parent() orelse break;
+ }
+ out[0] = .{ .from = node.startByte(), .to = node.startByte() };
+ return 1;
+ }
+ var cursor = tree.walk();
+ defer cursor.destroy();
+ // helix TreeCursor.reset_to_byte_range: the smallest node holding the range
+ while (true) {
+ const node = cursor.node();
+ if (lo < node.startByte() or hi > node.endByte()) {
+ _ = cursor.gotoParent();
+ break;
+ }
+ if (cursor.gotoFirstChildForByte(lo) == null) break;
+ }
+ switch (walk) {
+ .expand => while (cursor.node().startByte() == lo and cursor.node().endByte() == hi) {
+ if (!cursor.gotoParent()) break;
+ },
+ .shrink => _ = cursor.gotoFirstChild(),
+ .next_sibling => while (!cursor.gotoNextSibling()) {
+ if (!cursor.gotoParent()) break;
+ },
+ .prev_sibling => while (!cursor.gotoPreviousSibling()) {
+ if (!cursor.gotoParent()) break;
+ },
+ .all_siblings, .all_children => {
+ if (walk == .all_siblings) {
+ while (true) {
+ if (!cursor.gotoParent()) return 0;
+ if (cursor.node().childCount() > 1) break;
+ }
+ }
+ // the named children, or nothing to say
+ var n: usize = 0;
+ if (!cursor.gotoFirstChild()) return 0;
+ while (true) {
+ const child = cursor.node();
+ if (child.isNamed() and n < out.len) {
+ out[n] = .{ .from = child.startByte(), .to = child.endByte() };
+ n += 1;
+ }
+ if (!cursor.gotoNextSibling()) break;
+ }
+ return n;
+ },
+ .parent_end, .parent_start => unreachable,
+ }
+ const node = cursor.node();
+ out[0] = .{ .from = node.startByte(), .to = node.endByte() };
+ return 1;
+}
+
+
/// Owned source analysis. Both slices use the allocator passed to analyzeSource.
/// The parsed tree is released before returning; callers can cache this result.
pub const SourceAnalysis = struct {
diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl
index 43cbbf11..8a8f8a94 100644
--- a/test/hxcases/cases.jsonl
+++ b/test/hxcases/cases.jsonl
@@ -440,6 +440,7 @@
{"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%<A-s>K^b<ret>"}
{"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+<ret><A-;><A-:>"}
{"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll<A-;><A-:>"}
+{"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w<A-o><A-i><A-p><A-right><A-a><A-I><A-e><A-b>"}
{"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=<ret>&"}
{"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,<ret>&"}
{"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,<ret>&"}
diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl
index 9839335a..f1df5c0a 100644
--- a/test/hxcases/goldens.jsonl
+++ b/test/hxcases/goldens.jsonl
@@ -440,6 +440,7 @@
{"name":"keep-regex-lines","text":"foo\nbar\nbaz\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":0}
{"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0}
{"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
{"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0}
{"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0}
{"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0}