summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
5 files changed, 257 insertions, 0 deletions
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 {