diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-15 13:13:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-15 17:24:42 -0300 |
| commit | 8ec8924cd577f71953f8e420af1cd60da92f3300 (patch) | |
| tree | fb28f12321fd263a3dda07e9c82c24477df1f48f /src | |
| parent | ffc700d14d61fd5e6ca874a186932300c447c836 (diff) | |
| download | pardes-8ec8924cd577f71953f8e420af1cd60da92f3300.tar.gz pardes-8ec8924cd577f71953f8e420af1cd60da92f3300.zip | |
Add optional tree-sitter declaration context
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 20 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/dump.zig | 1 | ||||
| -rw-r--r-- | src/fs.zig | 3 | ||||
| -rw-r--r-- | src/panes.zig | 148 | ||||
| -rw-r--r-- | src/pardes.zig | 35 | ||||
| -rw-r--r-- | src/syntax.zig | 217 |
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 = "", @@ -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(); |
