summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig20
-rw-r--r--src/config.zig1
-rw-r--r--src/dump.zig1
-rw-r--r--src/fs.zig3
-rw-r--r--src/panes.zig148
-rw-r--r--src/pardes.zig35
-rw-r--r--src/syntax.zig217
7 files changed, 379 insertions, 46 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index c483482a..f4e22308 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -312,6 +312,26 @@ pub const FontSel = struct {
}
};
+pub const TreeContext = struct {
+ pub const enabled = pardes.syntax.enabled;
+ pub const takes_arg = true;
+
+ pub fn run(c: Ctx) void {
+ if (comptime !enabled) unreachable;
+ if (!panes.File.supportsContext(c.pane)) return;
+ const file = &c.pane.file.?;
+ const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ const on = if (arg.len == 0) !file.tree_context else if (std.mem.eql(u8, arg, "on")) true else if (std.mem.eql(u8, arg, "off")) false else {
+ c.p.reportError(c.id, "TreeContext", error.ExpectedOnOrOff);
+ return;
+ };
+ file.tree_context = on;
+ file.syntax_dirty = true;
+ c.pane.wrap_n = 0;
+ c.pane.context_rows = 0;
+ }
+};
+
pub const PdfFit = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
diff --git a/src/config.zig b/src/config.zig
index 5e835fa0..8e667ce2 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -118,6 +118,7 @@ pub const leader_path = paths: {
table.set(.Glitch, "tg");
}
if (builtins.EffectCode.enabled) table.set(.EffectCode, null);
+ if (builtins.TreeContext.enabled) table.set(.TreeContext, null);
if (builtins.Tty9p.enabled) table.set(.Tty9p, "n9");
if (pardes.hosted) {
table.set(.ThemeFile, null);
diff --git a/src/dump.zig b/src/dump.zig
index ff46205f..8200b0b7 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -79,6 +79,7 @@ pub const File = struct {
content: []const u8 = "",
content_b64: []const u8 = "",
dirty: bool = false,
+ tree_context: bool = false,
// Builtin names remain stable when enum ordinals change.
origin: []const u8 = "",
origin_arg: []const u8 = "",
diff --git a/src/fs.zig b/src/fs.zig
index 5ec09d3e..4fe32347 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -2957,6 +2957,9 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
f.highlights = &.{};
f.highlight_start = 0;
f.syntax_dirty = true;
+ f.context_revision = null;
+ pane.context_rows = 0;
+ pane.wrap_n = 0;
p.invalidateLookHover(id);
p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
}
diff --git a/src/panes.zig b/src/panes.zig
index b2f05cdf..32fe8c28 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -166,6 +166,9 @@ pub const Pane = struct {
wrap_line: [limits.wrap_rows]i32 = undefined,
wrap_col: [limits.wrap_rows]i32 = undefined,
wrap_n: u16 = 0,
+ /// A row map also exists for sticky headers when soft wrapping is off.
+ body_wrapped: bool = false,
+ context_rows: u16 = 0,
sel: [3]Sel = @splat(.{}),
pointer_selections: [3]?PointerSelection = @splat(null),
next_pointer_selection: u64 = 0,
@@ -352,30 +355,41 @@ pub const Pane = struct {
}
pub fn ensureCursorVisible(pane: *Pane) void {
- // scrolloff margin, shrunk on short panes so the band stays non-empty
- var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
const off = pane.scroll();
+ var in_context = false;
+ // Maps survive until the next render. A jump may have changed scroll
+ // already, so only treat an ancestor as pinned in the matching view.
+ if (pane.context_rows < pane.wrap_n and pane.wrap_line[pane.context_rows] == off) {
+ for (pane.wrap_line[0..pane.context_rows]) |row| {
+ if (row == pane.cur_row) {
+ in_context = true;
+ break;
+ }
+ }
+ }
+ // Scrolloff is measured in the ordinary body, excluding sticky rows.
+ var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
var last = off + @as(i32, pane.rows) - 1;
- if (pane.wrap_n > 0) {
- const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0];
+ if (pane.context_rows < pane.wrap_n) {
+ const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[pane.context_rows];
last = off + lines_shown;
margin = @min(margin, @divTrunc(@max(0, lines_shown), 2));
}
- if (pane.cur_row < off + margin) {
- pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0
- } else if (pane.cur_row > last - margin) {
- // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp);
- // terminals overshoot harmlessly — the vt clamps at the live bottom
- var to = pane.cur_row + margin;
- if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1)));
- if (comptime Pdf.enabled) {
- if (pane.pdf) |pv|
- to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
+ if (!in_context) {
+ if (pane.cur_row < off + margin) {
+ pane.scrollBy(pane.cur_row - margin - off);
+ } else if (pane.cur_row > last - margin) {
+ var to = pane.cur_row + margin;
+ if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1)));
+ if (comptime Pdf.enabled) {
+ if (pane.pdf) |pv|
+ to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
+ }
+ pane.scrollBy(@max(0, to - last));
}
- pane.scrollBy(@max(0, to - last));
}
if (pane.file) |f| {
- if (pane.wrap_n != 0) return;
+ if (pane.body_wrapped) return;
const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane)));
const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2));
const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)));
@@ -999,6 +1013,9 @@ pub const File = struct {
highlights: []u8 = &.{},
highlight_start: usize = 0,
syntax_dirty: bool = true,
+ tree_context: bool = false,
+ context_declarations: []syntax.ContextDeclaration = &.{},
+ context_revision: ?u32 = null,
history: *History,
};
@@ -1022,6 +1039,7 @@ pub const File = struct {
.vweight = pane.vweight,
.collapsed = pane.collapsed,
.file = .{
+ .tree_context = file.tree_context,
.path = file.path,
.content = file.content,
.content_b64 = try dump.encodeBytes(arena, file.content),
@@ -1502,6 +1520,7 @@ pub const File = struct {
.revision = @intFromBool(saved.dirty),
.history = history,
};
+ pane.file.?.tree_context = saved.tree_context and supportsContext(pane);
pane.cur_pinned = true;
pane.cur_row = @intCast(src.scroll);
pane.cols = @max(1, src.cols);
@@ -1519,6 +1538,7 @@ pub const File = struct {
if (file.mini) |*mini| mini.deinit(p.gpa);
if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
+ if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations);
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);
@@ -1656,6 +1676,12 @@ pub const File = struct {
const pane = slot orelse continue;
if (pane.file == null) continue;
const f = &pane.file.?;
+ if (f.tree_context and supportsContext(pane) and f.context_revision != f.revision) {
+ const declarations = syntax.contextDeclarations(p.tree_sitter_gpa, f.path, f.content) catch continue;
+ if (f.context_declarations.len > 0) p.tree_sitter_gpa.free(f.context_declarations);
+ f.context_declarations = declarations;
+ f.context_revision = f.revision;
+ }
if (f.mini != null) {
f.syntax_dirty = false;
continue;
@@ -1670,14 +1696,15 @@ pub const File = struct {
}
const need_start = lineStart(p.gpa, f, f.scroll);
const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS));
- if (f.highlights.len > 0 and need_start >= f.highlight_start and
+ if ((!f.tree_context or (f.highlight_start == 0 and f.highlights.len == f.content.len)) and
+ f.highlights.len > 0 and need_start >= f.highlight_start and
need_end <= f.highlight_start + f.highlights.len)
{
f.syntax_dirty = false;
continue;
}
const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
- const whole = pane.colorAlgo() == .locations;
+ const whole = pane.colorAlgo() == .locations or f.tree_context;
const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack);
const end = if (whole)
f.content.len
@@ -1698,6 +1725,28 @@ pub const File = struct {
}
}
+ pub fn supportsContext(pane: *const Pane) bool {
+ const file = pane.file orelse return false;
+ return file.output == null and file.mini == null and syntax.supportsPath(file.path);
+ }
+
+ fn prepareContextRows(pane: *Pane, file: *State) void {
+ pane.context_rows = 0;
+ if (!file.tree_context or !supportsContext(pane) or pane.rows > pane.wrap_line.len) return;
+ const capacity = pane.rows -| 1; // retain at least one ordinary body row
+ for (file.context_declarations) |declaration| {
+ if (declaration.start_line >= file.scroll) break;
+ if (declaration.end_line < file.scroll) continue;
+ var row = declaration.start_line;
+ while (row <= declaration.header_end_line and row < file.scroll) : (row += 1) {
+ if (pane.context_rows >= capacity) return;
+ if (pane.context_rows > 0 and row <= pane.wrap_line[pane.context_rows - 1]) continue;
+ pane.wrap_line[pane.context_rows] = @intCast(row);
+ pane.context_rows += 1;
+ }
+ }
+ }
+
pub fn wrapWidth(pane: *const Pane, wrap: bool) usize {
if (!wrap or pane.rows > pane.wrap_line.len) return 0;
return @max(1, @as(usize, pane.cols -| gutterWidth(pane)) -| 1);
@@ -1717,6 +1766,8 @@ pub const File = struct {
pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 {
const width = wrapWidth(pane, wrap);
+ pane.body_wrapped = width > 0;
+ prepareContextRows(pane, f);
pane.wrap_n = 0;
const len = fillBody(null, pane, f, width, false);
@@ -1731,32 +1782,37 @@ pub const File = struct {
fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize {
if (record_wrap) pane.wrap_n = 0;
const prefix_width = gutterWidth(pane);
- // start ON the first visible line instead of walking the file to it: this
- // walk was O(f.scroll) and recolorSyntax below ran the identical one again
- var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
- if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next();
- // the line the NEXT row comes from and the byte column of it that row
- // starts at — the two the map records, walked forward by the loop
- var abs: i32 = @intCast(f.scroll);
- var at: usize = 0;
- var cur = flines.next();
+ const total = nlines(pane.gpa, f);
+ var body_line: usize = f.scroll;
+ var body_at: usize = 0;
var written: usize = 0;
for (0..pane.rows) |i| {
if (i > 0) {
if (dst) |out| out[written] = '\n';
written += 1;
}
- if (width > 0 and record_wrap) {
- pane.wrap_line[i] = abs;
- pane.wrap_col[i] = @intCast(at);
+ const context = i < pane.context_rows;
+ const row = if (context) @as(usize, @intCast(pane.wrap_line[i])) else body_line;
+ const at = if (context) 0 else body_at;
+ const text = if (row < total) sourceLine(pane, @intCast(row)) else "";
+ // Headers use one screen row per source row, preserving signatures
+ // without allowing a long ancestor to consume the whole viewport.
+ const end = if (width == 0 or context) text.len else fitEnd(text, at, width);
+ const cut = if (pane.hscroll > 0 and width == 0)
+ modal.graphemeStart(text, @min(@as(usize, @intCast(pane.hscroll)), text.len))
+ else
+ at;
+ if ((width > 0 or pane.context_rows > 0) and record_wrap) {
+ pane.wrap_line[i] = @intCast(row);
+ pane.wrap_col[i] = @intCast(cut);
pane.wrap_n = @intCast(i + 1);
}
- if (cur) |text| {
+ if (row < total) {
var lbuf: [@max(config.PREFIX_W, 32)]u8 = undefined;
const prefix = lbuf[0..prefix_width];
@memset(prefix, ' ');
if (at == 0) {
- var lineno: usize = @intCast(abs + 1);
+ var lineno = row + 1;
var digit: usize = prefix.len - 1;
while (true) {
digit -= 1;
@@ -1767,24 +1823,18 @@ pub const File = struct {
}
if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix);
written += prefix.len;
-
- const end = if (width == 0) text.len else fitEnd(text, at, width);
- const take = end - at;
- const cut = if (pane.hscroll > 0 and width == 0)
- modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take))
- else
- 0;
- const shown = text[at + cut .. end];
+ const shown = text[cut..end];
if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
written += shown.len;
+ }
+ if (!context) {
if (width > 0 and end < text.len) {
- at = end;
+ body_at = end;
} else {
- abs += 1;
- at = 0;
- cur = flines.next();
+ body_line += 1;
+ body_at = 0;
}
- } else abs += 1;
+ }
}
return written;
}
@@ -1798,6 +1848,14 @@ pub const File = struct {
const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
// the body's first row, the way renderPane derives it (Tagbottom)
const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const context_bg = p.theme().tag_bg;
+ for (0..@min(pane.context_rows, body_h)) |context_row| {
+ for (0..tw) |col| {
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(context_row)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = context_bg };
+ }
+ }
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
const row_line: i32 = if (pane.wrap_n == 0)
diff --git a/src/pardes.zig b/src/pardes.zig
index 0938736f..8a25f8f9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -257,6 +257,7 @@ test {}
const pane_tail = " " ++ config.pane_builtins_str;
const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
+const context_pane_tail = file_pane_tail ++ " TreeContext";
const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
const dirty_marker = " *";
const legacy_pane_tail = " Del";
@@ -1088,6 +1089,37 @@ test "Collapse builtin keeps content and restores the pane from its default tag"
try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane));
}
+test "TreeContext command and custom tag survive a workspace round trip" {
+ if (comptime !syntax.enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("pub fn main() void {}\n");
+ try std.testing.expect(!panes.File.supportsContext(pane));
+ try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane));
+ const path = try p.gpa.dupe(u8, "/test.zig");
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = path;
+ if (!panes.File.supportsContext(pane)) return error.SkipZigTest;
+ try std.testing.expectEqualStrings(context_pane_tail, Pardes.curTail(pane));
+ try std.testing.expect(!pane.file.?.tree_context);
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on"));
+ try std.testing.expect(pane.file.?.tree_context);
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext off"));
+ try std.testing.expect(!pane.file.?.tree_context);
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext"));
+ try std.testing.expect(pane.file.?.tree_context);
+ try std.testing.expect(p.executeBuiltinLine(0, "TreeContext invalid"));
+ try std.testing.expect(pane.file.?.tree_context);
+ try std.testing.expect(pane.appendTag(" Save Keep Del"));
+ pane.tag_init = true;
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const saved = restored.panes[0].?;
+ try std.testing.expect(saved.file.?.tree_context);
+ try std.testing.expectEqualStrings(" Save Keep Del", Pardes.curTail(saved));
+}
+
test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
defer p.deinit();
@@ -7008,6 +7040,7 @@ pub const Pardes = struct {
fn defaultTail(pane: *const Pane) []const u8 {
if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail;
+ if (panes.File.supportsContext(pane)) return context_pane_tail;
if (pane.file != null) return file_pane_tail;
if (pane.isTerminal()) return terminal_pane_tail;
return pane_tail;
@@ -12230,7 +12263,7 @@ pub const Pardes = struct {
const said = std.mem.trimStart(u8, rest, " ");
const defaults: []const []const u8 = switch (class) {
.generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail },
- .file => &.{ file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
+ .file => &.{ context_pane_tail, file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
.output => &.{
file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail,
pane_tail, prev_pane_tail, legacy_pane_tail,
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();