summaryrefslogtreecommitdiff
path: root/src/syntax.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 13:13:36 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit8ec8924cd577f71953f8e420af1cd60da92f3300 (patch)
treefb28f12321fd263a3dda07e9c82c24477df1f48f /src/syntax.zig
parentffc700d14d61fd5e6ca874a186932300c447c836 (diff)
downloadpardes-8ec8924cd577f71953f8e420af1cd60da92f3300.tar.gz
pardes-8ec8924cd577f71953f8e420af1cd60da92f3300.zip
Add optional tree-sitter declaration context
Diffstat (limited to 'src/syntax.zig')
-rw-r--r--src/syntax.zig217
1 files changed, 217 insertions, 0 deletions
diff --git a/src/syntax.zig b/src/syntax.zig
index 77ef80d7..69a69bda 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -355,6 +355,140 @@ pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []c
return styles;
}
+/// Source spans are independent of editor rows. Locations output uses the same
+/// declarations as sticky editor headers. Byte ends are exclusive; rows are
+/// zero-based and inclusive, including the last signature/opening-brace row.
+pub const ContextDeclaration = struct {
+ start_byte: usize,
+ end_byte: usize,
+ start_line: usize,
+ end_line: usize,
+ header_end_byte: usize,
+ header_end_line: usize,
+};
+
+pub fn supportsPath(path: []const u8) bool {
+ if (!enabled) return false;
+ const ext = std.fs.path.extension(path);
+ for (specs) |spec| for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return true;
+ };
+ return false;
+}
+
+pub fn contextDeclarations(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]ContextDeclaration {
+ if (!enabled or content.len == 0) return &.{};
+ const selected = (try forExt(std.fs.path.extension(path))) orelse return &.{};
+ const tree = selected.parser.parseString(content, null) orelse return &.{};
+ defer tree.destroy();
+ var result: std.ArrayList(ContextDeclaration) = .empty;
+ errdefer result.deinit(gpa);
+ // Iterative preorder keeps declaration order and avoids recursive traversal
+ // on deeply nested, partly edited source.
+ var node = tree.rootNode();
+ while (true) {
+ if (contextSpan(node, content)) |span| {
+ if (result.items.len == 0 or result.items[result.items.len - 1].start_line != span.start_line or
+ result.items[result.items.len - 1].end_line != span.end_line)
+ try result.append(gpa, span);
+ }
+ if (node.namedChild(0)) |child| {
+ node = child;
+ continue;
+ }
+ while (node.nextNamedSibling() == null) node = node.parent() orelse return result.toOwnedSlice(gpa);
+ node = node.nextNamedSibling().?;
+ }
+}
+
+fn contextSpan(node: ts.Node, source: []const u8) ?ContextDeclaration {
+ const kind = node.kind();
+ var body: ?ts.Node = null;
+ var accepted = false;
+ if (std.mem.eql(u8, kind, "Decl")) {
+ // Zig's FnProto excludes its body: the surrounding Decl owns both.
+ accepted = childOfKind(node, "FnProto") != null;
+ body = childOfKind(node, "Block");
+ if (body == null) return null;
+ } else if (std.mem.eql(u8, kind, "ContainerDecl") or std.mem.eql(u8, kind, "TestDecl")) {
+ accepted = true;
+ body = childOfKind(node, "Block");
+ } else {
+ const kinds = [_][]const u8{
+ "function_definition", "function_declaration", "function_item", "function", "method_definition",
+ "method_declaration", "method", "singleton_method", "constructor_declaration", "destructor_definition",
+ "class_definition", "class_declaration", "class_specifier", "class", "singleton_class",
+ "object_declaration", "struct_item", "struct_specifier", "struct_declaration", "union_specifier",
+ "union_item", "union_declaration", "enum_item", "enum_specifier", "enum_declaration",
+ "interface_declaration", "trait_item", "trait_definition", "impl_item", "mod_item",
+ "module_definition", "module_declaration", "module", "namespace_definition", "namespace_declaration",
+ "record_declaration", "type_declaration", "extension_declaration", "protocol_declaration",
+ };
+ for (kinds) |candidate| if (std.mem.eql(u8, kind, candidate)) {
+ accepted = true;
+ break;
+ };
+ body = node.childByFieldName("body");
+ }
+ if (!accepted) return null;
+ var owner = node;
+ if (std.mem.eql(u8, kind, "ContainerDecl")) {
+ // A multiline `const Name = struct` starts at the binding, not at the
+ // struct token. Stop at real scopes so returned anonymous containers
+ // do not inherit an enclosing function's signature.
+ var ancestor = node.parent();
+ while (ancestor) |parent| {
+ const parent_kind = parent.kind();
+ if (std.mem.eql(u8, parent_kind, "VarDecl")) {
+ owner = parent;
+ break;
+ }
+ if (std.mem.eql(u8, parent_kind, "Block") or std.mem.eql(u8, parent_kind, "Decl") or
+ std.mem.eql(u8, parent_kind, "ContainerDecl") or std.mem.eql(u8, parent_kind, "FnProto") or
+ std.mem.eql(u8, parent_kind, "ContainerField") or std.mem.eql(u8, parent_kind, "ParamDecl")) break;
+ ancestor = parent.parent();
+ }
+ }
+ const beginning = owner.startPoint();
+ const end = node.endPoint();
+ if (beginning.row >= end.row) return null;
+ var header_end: usize = node.startByte();
+ if (body) |block| {
+ header_end = block.startByte();
+ // Indentation grammars start their body at its first statement.
+ if (header_end < source.len and source[header_end] == '{') {
+ header_end += 1;
+ } else {
+ while (header_end > node.startByte() and std.ascii.isWhitespace(source[header_end - 1])) header_end -= 1;
+ }
+ } else {
+ // Containers often expose their braces directly, without a body node.
+ var i: u32 = 0;
+ while (i < node.childCount()) : (i += 1) {
+ const child = node.child(i) orelse continue;
+ if (std.mem.eql(u8, child.kind(), "{")) {
+ header_end = child.endByte();
+ break;
+ }
+ }
+ // Ruby and similar grammars have a body_statement without a field.
+ if (header_end == node.startByte()) {
+ header_end = std.mem.indexOfScalarPos(u8, source, header_end, '\n') orelse source.len;
+ }
+ }
+ const line_start = @as(usize, owner.startByte()) - beginning.column;
+ const header_line = beginning.row + std.mem.count(u8, source[line_start..@min(header_end, source.len)], "\n");
+ const header_line_end = std.mem.indexOfScalarPos(u8, source, @min(header_end, source.len), '\n') orelse source.len;
+ return .{
+ .start_byte = line_start,
+ .end_byte = node.endByte(),
+ .start_line = beginning.row,
+ .end_line = end.row - @as(usize, if (end.column == 0) 1 else 0),
+ .header_end_byte = header_line_end,
+ .header_end_line = header_line,
+ };
+}
+
fn paint(styles: []u8, source: []const u8, selected: Selected) void {
const tree = selected.parser.parseString(source, null) orelse return;
defer tree.destroy();
@@ -366,6 +500,89 @@ fn paint(styles: []u8, source: []const u8, selected: Selected) void {
}
}
+test "syntax context nested Zig containers and multiline function signatures" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const source =
+ "pub const Outer = struct {\n" ++
+ " const Inner = struct {\n" ++
+ " pub fn run(\n" ++
+ " value: u32,\n" ++
+ " ) void {\n" ++
+ " const ignored = .{\n" ++
+ " value,\n" ++
+ " };\n" ++
+ " }\n" ++
+ " };\n" ++
+ "};\n";
+ const declarations = try contextDeclarations(gpa, "nested.zig", source);
+ defer gpa.free(declarations);
+ try std.testing.expectEqual(@as(usize, 3), declarations.len);
+ try std.testing.expectEqual(@as(usize, 0), declarations[0].start_line);
+ try std.testing.expectEqual(@as(usize, 10), declarations[0].end_line);
+ try std.testing.expectEqual(@as(usize, 1), declarations[1].start_line);
+ try std.testing.expectEqual(@as(usize, 2), declarations[2].start_line);
+ try std.testing.expectEqual(@as(usize, 4), declarations[2].header_end_line);
+ try std.testing.expectEqual(@as(usize, 8), declarations[2].end_line);
+ try std.testing.expectEqualStrings(" pub fn run(\n value: u32,\n ) void {", source[declarations[2].start_byte..declarations[2].header_end_byte]);
+ const unsupported = try contextDeclarations(gpa, "notes.unknown", source);
+ defer gpa.free(unsupported);
+ try std.testing.expectEqual(@as(usize, 0), unsupported.len);
+}
+
+test "syntax context Python class and function body excludes first statement" {
+ if (!enabled or !full_grammars) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const source = "class Outer:\n def run(\n self, value,\n ):\n return value\n";
+ const declarations = try contextDeclarations(gpa, "nested.py", source);
+ defer gpa.free(declarations);
+ try std.testing.expectEqual(@as(usize, 2), declarations.len);
+ try std.testing.expectEqual(@as(usize, 0), declarations[0].header_end_line);
+ try std.testing.expectEqual(@as(usize, 3), declarations[1].header_end_line);
+}
+
+test "syntax context multiline bindings and C++ namespaces preserve declaration starts" {
+ if (!enabled) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const zig_source = "const Outer =\n struct {\n field: u8,\n };\n";
+ const zig_declarations = try contextDeclarations(gpa, "nested.zig", zig_source);
+ defer gpa.free(zig_declarations);
+ try std.testing.expectEqual(@as(usize, 1), zig_declarations.len);
+ try std.testing.expectEqual(@as(usize, 0), zig_declarations[0].start_line);
+ try std.testing.expectEqual(@as(usize, 1), zig_declarations[0].header_end_line);
+ if (!minimal_grammars and !full_grammars) return;
+ const cpp_source = "namespace example {\nstruct Outer {\n int run() {\n return 1;\n }\n};\n}\n";
+ const cpp_declarations = try contextDeclarations(gpa, "nested.cpp", cpp_source);
+ defer gpa.free(cpp_declarations);
+ try std.testing.expectEqual(@as(usize, 3), cpp_declarations.len);
+ for (cpp_declarations, 0..) |declaration, row| {
+ try std.testing.expectEqual(row, declaration.start_line);
+ try std.testing.expectEqual(row, declaration.header_end_line);
+ }
+}
+
+test "syntax context Rust modules impls and methods remain nested" {
+ if (!enabled or !full_grammars) return;
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const source = "mod outer {\n impl Example {\n fn run(&self) {\n work();\n }\n }\n}\n";
+ const declarations = try contextDeclarations(gpa, "nested.rs", source);
+ defer gpa.free(declarations);
+ try std.testing.expectEqual(@as(usize, 3), declarations.len);
+ for (declarations, 0..) |declaration, row| {
+ try std.testing.expectEqual(row, declaration.start_line);
+ try std.testing.expectEqual(row, declaration.header_end_line);
+ try std.testing.expectEqual(@as(usize, 6) - row, declaration.end_line);
+ }
+}
+
pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
const tz = tracy.zone(@src(), "highlightLocations");
defer tz.end();