summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 17:04:53 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitdf4d11bb95bc7ea9554b55d2211c4704a2594f08 (patch)
tree0b3a41bac163f6dc1e5c403e0c7a5547c482ec60 /src
parent68e30f560e46bb9a30433eb23b4edb1b93d69f46 (diff)
downloadpardes-df4d11bb95bc7ea9554b55d2211c4704a2594f08.tar.gz
pardes-df4d11bb95bc7ea9554b55d2211c4704a2594f08.zip
]f, mif and the other textobject keys use helix's own queries
helix's textobjects.scm for pardes's grammars are vendored in vendor/queries under MPL-2.0, each with a header naming its source and what it inherits, inlined; a README says what is there and what is not. syntax.objectAt and objectNext run them over the file's kept parse, with their #eq? and #match? predicates, for ]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x and mi/ma with f t a c T e x. erlang's and zig's queries do not compile against the grammar versions pardes builds and are left out; fortran, markdown and powershell have none in helix. A test replays sixteen Python and JSON cases whose results were taken from the installed hx, and another holds that every vendored query compiles. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/config.zig7
-rw-r--r--src/edit.zig19
-rw-r--r--src/modal.zig12
-rw-r--r--src/normal.zig86
-rw-r--r--src/syntax.zig144
5 files changed, 260 insertions, 8 deletions
diff --git a/src/config.zig b/src/config.zig
index 3739d3f4..78787ad2 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -511,6 +511,13 @@ pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }};
pub const add_newline: []const Chord = &.{.{ .cp = ' ' }};
pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }};
pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }};
+pub const goto_function: []const Chord = &.{.{ .cp = 'f' }};
+pub const goto_class: []const Chord = &.{.{ .cp = 't' }};
+pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }};
+pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }};
+pub const goto_test: []const Chord = &.{.{ .cp = 'T' }};
+pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }};
+pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }};
pub const insert: []const Chord = &.{.{ .cp = 'i' }};
pub const append: []const Chord = &.{.{ .cp = 'a' }};
diff --git a/src/edit.zig b/src/edit.zig
index 5437a67d..1b947749 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -1858,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } {
pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void {
const range = t.primaryRange(text, 0);
+ // helix's tree-sitter objects, from the grammar's textobjects query
+ const object: ?modal.Normal.Object = switch (obj) {
+ 'f' => .function,
+ 't' => .class,
+ 'a' => .parameter,
+ 'c' => .comment,
+ 'T' => .@"test",
+ 'e' => .entry,
+ 'x' => .xml_element,
+ else => null,
+ };
+ if (object) |o| {
+ if (t.what != .body) return;
+ const pane = t.pane() orelse return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = normal.fileTree(p, f) orelse return;
+ const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return;
+ return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false);
+ }
switch (obj) {
'w', 'W' => {
const r = modal.selectWord(text, range, around, obj == 'W');
diff --git a/src/modal.zig b/src/modal.zig
index 6371475c..cc7f1ebd 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -54,6 +54,13 @@ pub const Normal = struct {
add_newline,
goto_diagnostic,
goto_diagnostic_end,
+ goto_function,
+ goto_class,
+ goto_parameter,
+ goto_comment,
+ goto_test,
+ goto_entry,
+ goto_xml_element,
move_left,
move_right,
@@ -229,6 +236,8 @@ pub const Normal = struct {
};
/// helix's tree-sitter selection keys: where a range goes in the tree
+ /// helix's tree-sitter textobjects, by what their queries call them
+ pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element };
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 };
@@ -351,6 +360,7 @@ pub const Normal = struct {
macro_record,
macro_replay: u32,
node: NodeWalk,
+ object_jump: struct { object: Object, direction: Direction, count: u32 },
repeat_insert: u32,
pub fn scope(value: Action) Scope {
@@ -540,6 +550,8 @@ pub const Normal = struct {
if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } });
if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } });
if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } });
+ inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair|
+ if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } });
return .ignored;
},
.register => {
diff --git a/src/normal.zig b/src/normal.zig
index 2aea2e54..b68f9ba6 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -168,13 +168,9 @@ 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.
-pub 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;
+/// The file's parse, made on the first key that walks it after an edit and
+/// kept until the next: never made again for a key that changed nothing.
+pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque {
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);
@@ -182,7 +178,40 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk
for (f.node_history.items) |step| p.gpa.free(step.ranges);
f.node_history.clearRetainingCapacity();
}
- const tree = f.node_tree orelse return;
+ return f.node_tree;
+}
+
+/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting
+/// after the cursor or the previous one ending before it, count times; select
+/// mode stretches the range to it.
+fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void {
+ if (t.what != .body) return;
+ const f = if (pane.file) |*file| file else return;
+ const tree = fileTree(p, f) orelse return;
+ var last = range;
+ for (0..count) |_| {
+ const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break;
+ const next: modal.Selection = .{ .anchor = s.from, .head = s.to };
+ if (std.meta.eql(next, last)) break;
+ last = next;
+ }
+ if (std.meta.eql(last, range)) return;
+ if (t.select) {
+ const head = if (last.head < range.anchor) last.anchor else last.head;
+ return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false);
+ }
+ const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor };
+ t.setRange(text, 0, out, false);
+}
+
+/// 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.
+pub 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;
+ const tree = fileTree(p, f) orelse return;
const text = f.content;
const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
const arena = p.scratch.allocator();
@@ -742,6 +771,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
),
.node => |walk| return nodeSelect(p, pane, t, walk),
.goto_word => return startJump(p, pane, t),
+ .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count),
.macro_record => {
const m = &p.macro;
if (m.recording == 0) {
@@ -1041,3 +1071,43 @@ test "one cursor holds no room for other ranges; a second range makes it and one
try std.testing.expectEqual(@as(u16, 0), pane.body.nsel);
try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len);
}
+
+test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" {
+ if (!syntax.supportsPath("a.py") or !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();
+ // each checked against the installed hx through a pty: the keys, then
+ // the text it saved
+ const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" },
+ .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" },
+ }) |c| {
+ const pane = try p.setTestFile(c.text);
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = try p.gpa.dupe(u8, c.path);
+ for (c.keys, 0..) |ch, i| {
+ 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} on {s}\n", .{ c.keys, c.path });
+ return err;
+ };
+ }
+}
diff --git a/src/syntax.zig b/src/syntax.zig
index 89fc4198..3a83800b 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -3,6 +3,7 @@ const config = @import("pardes_config");
const tracy = @import("tracy.zig");
const grammar_manifest = @import("grammar_manifest.zig");
const look = @import("look.zig");
+const regexp = @import("regexp.zig");
const c_heap = @import("c_heap");
pub const enabled = config.syntax_highlighting;
@@ -27,6 +28,10 @@ const Spec = struct {
exts: []const []const u8,
language: *const fn () callconv(.c) *const ts.Language,
query_src: []const u8,
+ /// helix's textobjects.scm for the grammar (vendor/queries), "" for none
+ objects_src: []const u8 = "",
+ objects: ?*ts.Query = null,
+ objects_failed: bool = false,
selected: ?Selected = null,
capture_styles: [256]u8 = undefined,
};
@@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec {
.exts = g.exts,
.language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }),
.query_src = querySrc(g),
+ .objects_src = @field(ts_queries, g.name ++ "_textobjects"),
};
i += 1;
}
@@ -173,6 +179,9 @@ pub fn stop() void {
selected.query.destroy();
}
spec.selected = null;
+ if (spec.objects) |q| q.destroy();
+ spec.objects = null;
+ spec.objects_failed = false;
}
ts_set_allocator(null, null, null, null);
syntax_started = false;
@@ -427,6 +436,141 @@ pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, a
}
+/// helix's textobjects, which its `]f`, `mif` and kin select: the name a
+/// query's captures begin with (`function.around`, `test.inside`).
+pub const Object = @import("modal.zig").Normal.Object;
+
+fn objectName(o: Object) []const u8 {
+ return if (o == .xml_element) "xml-element" else @tagName(o);
+}
+
+/// The grammar's textobject query for `path`, compiled the first time; null
+/// when the grammar has none.
+fn objectQuery(path: []const u8) ?*ts.Query {
+ const ext = std.fs.path.extension(path);
+ for (&specs) |*spec| for (spec.exts) |choice| {
+ if (!std.ascii.eqlIgnoreCase(ext, choice)) continue;
+ if (spec.objects) |q| return q;
+ if (spec.objects_src.len == 0 or spec.objects_failed) return null;
+ var error_offset: u32 = 0;
+ spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch {
+ spec.objects_failed = true;
+ return null;
+ };
+ return spec.objects;
+ };
+ return null;
+}
+
+fn captureId(q: *const ts.Query, name: []const u8) ?u32 {
+ for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i);
+ return null;
+}
+
+/// A match's `capture`, as one span over all its nodes (helix's grouped
+/// capture), when the match's `#eq?` and `#match?` hold.
+fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span {
+ var span: ?Span = null;
+ for (match.captures) |c| if (c.index == capture) {
+ const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() };
+ span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s;
+ };
+ const found = span orelse return null;
+ const steps = q.predicatesForPattern(match.pattern_index);
+ var i: usize = 0;
+ while (i < steps.len) {
+ var end = i;
+ while (end < steps.len and steps[end].type != .done) end += 1;
+ const pred = steps[i..end];
+ i = end + 1;
+ if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue;
+ const op = q.stringValueForId(pred[0].value_id) orelse continue;
+ const subject = for (match.captures) |c| {
+ if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)];
+ } else continue;
+ const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| {
+ if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)];
+ } else continue;
+ if (std.mem.eql(u8, op, "eq?")) {
+ if (!std.mem.eql(u8, subject, want)) return null;
+ } else if (std.mem.eql(u8, op, "match?")) {
+ var re = regexp.Regex.compile(want) catch continue;
+ if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null;
+ }
+ }
+ return found;
+}
+
+/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at`
+/// (helix textobject_treesitter). None without a query or such an object.
+pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span {
+ if (!enabled) return null;
+ const q = objectQuery(path) orelse return null;
+ var buf: [32]u8 = undefined;
+ const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null;
+ const capture = captureId(q, want) orelse return null;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(q, tree.rootNode());
+ var best: ?Span = null;
+ while (cursor.nextMatch()) |match| {
+ const s = matchSpan(q, match, capture, text) orelse continue;
+ if (!(s.from <= at and at < s.to)) continue;
+ if (best == null or s.to - s.from < best.?.to - best.?.from) best = s;
+ }
+ const s = best orelse return null;
+ if (s.from >= text.len or s.to >= text.len) return null;
+ return s;
+}
+
+/// `]f`/`[f` and kin: the next object starting after byte `at`, or the
+/// previous one ending before it (helix goto_treesitter_object), from the
+/// first of `.movement`, `.around`, `.inside` the query names.
+pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span {
+ if (!enabled) return null;
+ const q = objectQuery(path) orelse return null;
+ var buf: [32]u8 = undefined;
+ const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| {
+ const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null;
+ if (captureId(q, want)) |id| break id;
+ } else return null;
+ const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr));
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(q, tree.rootNode());
+ var best: ?Span = null;
+ while (cursor.nextMatch()) |match| {
+ const s = matchSpan(q, match, capture, text) orelse continue;
+ if (forward) {
+ if (s.from <= at) continue;
+ if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s;
+ } else {
+ if (s.to >= at) continue;
+ if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s;
+ }
+ }
+ const s = best orelse return null;
+ if (s.from >= text.len or s.to >= text.len) return null;
+ return s;
+}
+
+test "every vendored textobject query compiles against its grammar" {
+ if (!enabled) return;
+ var failed: usize = 0;
+ for (&specs) |*spec| {
+ if (spec.objects_src.len == 0) continue;
+ var error_offset: u32 = 0;
+ const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch {
+ std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset });
+ failed += 1;
+ continue;
+ };
+ q.destroy();
+ }
+ try std.testing.expectEqual(@as(usize, 0), failed);
+}
+
/// 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 {