summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/file_pane.zig19
-rw-r--r--src/pardes.zig18
-rw-r--r--src/syntax.zig218
3 files changed, 242 insertions, 13 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig
index 5c7f4b77..9b828030 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -722,10 +722,25 @@ pub fn refreshHighlights(p: *Pardes) void {
// a fidelity gain, and one that cannot reach a file shown from the top
// (scroll 0 clamps the window to exactly what it always was).
const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
- const start = lineStart(p.gpa, f, f.scroll -| slack);
- const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
+ // A RESULTS BUFFER IS COLOURED WHOLE, ONCE. Its rows are independent —
+ // each is parsed in isolation against its own grammar — so a window
+ // pass buys no fidelity, only amortisation, and pays for it with a
+ // burst on every scroll that outran the covered range: a fresh parser,
+ // a fresh query cursor and a tree per row, plus the first compile of
+ // any grammar the new rows introduce, all inside `render`. Colouring
+ // the whole buffer when it is FILLED makes the covered-range check
+ // above true forever after, so scrolling one costs nothing at all.
+ // Bounded by what fills them: `look.find_max_hits` caps a grep at 512
+ // rows, and a rendering is never typed into.
+ const whole = pane.colorAlgo() == .locations;
+ const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack);
+ const end = if (whole)
+ f.content.len
+ else
+ @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
const new_highlights = (switch (pane.colorAlgo()) {
.diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end),
+ .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end),
else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end),
}) catch {
f.syntax_dirty = false;
diff --git a/src/pardes.zig b/src/pardes.zig
index 357a6b13..6edf3600 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4790,11 +4790,23 @@ pub const Pane = struct {
/// The one coloring choice keyed on what a pane IS, so the highlight
/// producer (refreshHighlights) and the render pass agree on the algorithm.
- pub const ColorAlgo = enum { none, tty, source, diff };
+ pub const ColorAlgo = enum { none, tty, source, diff, locations };
pub fn colorAlgo(pane: *const Pane) ColorAlgo {
if (pane.isTerminal()) return .tty;
if (pane.file) |f| {
if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff;
+ // A RENDERING, not a document: +Grep, +Search, +Lsp and their kin
+ // have no language of their own, and their rows quote several at
+ // once. Colour each row by the file its location names instead.
+ // Buffers with no locations in them (+Help, +Config) match nothing
+ // and stay plain, so this needs no table of which origins qualify.
+ //
+ // `saves` is the line between the two: a New scratch and a real
+ // file are output-shaped but ARE documents, with one language and
+ // an edit on every keystroke — they keep `.source`, which is both
+ // right for them and what keeps a megabyte of scratch off the
+ // whole-buffer pass below.
+ if (f.output != null and !output_pane.fileTraits(f.output).saves) return .locations;
return .source;
}
return .none;
@@ -16745,7 +16757,9 @@ pub const Pardes = struct {
// to stay on the right glyph when the emulator and this surface
// disagree about how many columns a cluster is worth.
.tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body),
- .source, .diff => {
+ // `.locations` joins them because it feeds the same `f.highlights`
+ // — only the pass that FILLED it differs (refreshHighlights).
+ .source, .diff, .locations => {
const f = &pane.file.?;
file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
diff --git a/src/syntax.zig b/src/syntax.zig
index 09ff12cc..f6e7cae2 100644
--- a/src/syntax.zig
+++ b/src/syntax.zig
@@ -6,6 +6,7 @@ const std = @import("std");
const config = @import("pardes_config");
const tracy = @import("tracy.zig");
const grammar_manifest = @import("grammar_manifest.zig");
+const look = @import("look.zig");
pub const enabled = config.syntax_highlighting;
const zig_grammar = config.syntax_zig_grammar;
@@ -226,17 +227,19 @@ fn forLang(name: []const u8) !?Selected {
return null;
}
-fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize) void {
- const cursor = ts.QueryCursor.create();
- defer cursor.destroy();
+/// The caller owns the cursor: `ts_query_cursor_exec` fully resets its state,
+/// so one cursor serves any number of trees, and the per-row pass would
+/// otherwise create and destroy one — three allocations against the shared
+/// tree-sitter arena — for every row of a results buffer.
+fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize, cursor: *ts.QueryCursor) void {
cursor.exec(sel.query, tree.rootNode());
while (cursor.nextMatch()) |match| {
for (match.captures) |cap| {
const syn = synFor(sel.query.captureNameForId(cap.index) orelse "");
if (syn == .none) continue;
- var b: usize = base + cap.node.startByte();
+ const b = @min(base + cap.node.startByte(), styles.len);
const end = @min(base + @as(usize, cap.node.endByte()), styles.len);
- while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ if (end > b) @memset(styles[b..end], @intFromEnum(syn));
}
}
}
@@ -307,18 +310,140 @@ pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []c
const parser = ts.Parser.create();
defer parser.destroy();
parser.setLanguage(selected.lang) catch return styles;
- const tree = parser.parseString(source, null) orelse return styles;
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ paintWith(styles, source, selected, parser, cursor, true);
+ return styles;
+}
+
+/// Parse `source` and write its style bytes into `styles`, with a parser and a
+/// query cursor the caller owns, so the per-row pass below can run a whole
+/// results buffer through one of each.
+///
+/// `inject` is off for a single row. Both injection passes build a SECOND
+/// parser of their own — per fenced block, per `inline` node — which is
+/// amortised over a document and absurd over one truncated grep row that
+/// almost never contains a fenced block to begin with.
+fn paintWith(
+ styles: []u8,
+ source: []const u8,
+ selected: Selected,
+ parser: *ts.Parser,
+ cursor: *ts.QueryCursor,
+ inject: bool,
+) void {
+ const tree = parser.parseString(source, null) orelse return;
defer tree.destroy();
- runQuery(styles, selected, tree, 0);
+ runQuery(styles, selected, tree, 0, cursor);
+ if (!inject) return;
if (InjectSite.forGrammar(selected.name)) |site| injectCodeBlocks(styles, source, tree.rootNode(), site);
// Disjoint from the fenced-block pass above: `code_fence_content` is never
// an `inline` node, so the two never write the same byte.
if (std.mem.eql(u8, selected.name, "markdown")) injectMarkdownInline(styles, source, tree.rootNode());
+}
+
+/// A results buffer — every search, grep and language answer in this program —
+/// coloured as the CODE it is quoting.
+///
+/// The rows look like `src/look.zig:718:12-16 fn grepText(path: []const u8...`:
+/// a location, a space, and a piece of some file. The location names the file,
+/// the file names the grammar, and the rest of the row is a fragment of that
+/// language — so a +Grep over Zig reads as Zig and one over Markdown does not
+/// pretend to. `look.parsePathLine` decides what counts as a location, which is
+/// the same primitive n/N walks these buffers with, so the two agree by
+/// construction about which rows are locations.
+///
+/// ONE PARSER AND ONE CURSOR for the whole buffer, and the buffer is coloured
+/// once when it is filled rather than per visible window (file_pane
+/// `refreshHighlights`) — the rows are independent, so a window pass buys no
+/// fidelity and pays a burst of parses, cursors and first-time query compiles
+/// on every scroll that outran the covered range.
+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();
+ const start_byte = @min(start_byte_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
+ const source = content[start_byte..end_byte];
+ if (!enabled) return &.{};
+ const styles = try gpa.alloc(u8, source.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ // Both are created on the first row that needs them and kept for the rest;
+ // `held` is the language the parser is currently set to.
+ var parser: ?*ts.Parser = null;
+ defer if (parser) |ptr| ptr.destroy();
+ var cursor: ?*ts.QueryCursor = null;
+ defer if (cursor) |ptr| ptr.destroy();
+ var held: ?Selected = null;
+ // Consecutive rows of a results buffer are overwhelmingly the same file,
+ // and `forExt` is a linear walk of 29 specs and their extension lists. One
+ // remembered answer collapses that to a string compare — including for the
+ // rows that match NOTHING (a jumplist `@p3:10`, a `.lock`, a `.txt`),
+ // which otherwise pay the whole failing scan every time.
+ var memo_ext: []const u8 = "\x00";
+ var memo: ?Selected = null;
+ var painted = false;
+
+ var offset: usize = 0;
+ var lines = std.mem.splitScalar(u8, source, '\n');
+ while (lines.next()) |line| {
+ defer offset += line.len + 1;
+ const code = codeAfterLocation(line) orelse continue;
+ const ext = std.fs.path.extension(code.path);
+ if (!std.mem.eql(u8, ext, memo_ext)) {
+ memo_ext = ext;
+ memo = forExt(ext) catch null;
+ }
+ const selected = memo orelse continue;
+ if (parser == null) parser = ts.Parser.create();
+ if (cursor == null) cursor = ts.QueryCursor.create();
+ if (held == null or held.?.lang != selected.lang) {
+ // `held` is cleared FIRST: a failed `setLanguage` has already set
+ // the parser's language to null, so leaving `held` on the previous
+ // grammar makes every later row of it skip the call and parse
+ // against nothing — the rest of the buffer silently loses colour.
+ held = null;
+ parser.?.setLanguage(selected.lang) catch continue;
+ held = selected;
+ }
+ paintWith(styles[offset + code.at ..][0..code.text.len], code.text, selected, parser.?, cursor.?, false);
+ painted = true;
+ }
+ // NOTHING TO PAINT IS NOTHING TO KEEP. `recolorSyntax` skips a pane whose
+ // highlights are empty, and every output buffer without locations in it —
+ // +Help, +Config, +Messages, +Errors — would otherwise hand the renderer a
+ // full-length run of zeroes and make it walk every visible grapheme, every
+ // frame, to paint nothing.
+ if (!painted) {
+ gpa.free(styles);
+ return &.{};
+ }
return styles;
}
+/// The `<path> <code>` split of one results row, or null when the row is not
+/// one. A row qualifies when its FIRST whitespace-delimited token is entirely a
+/// look target — the whole token, so `see:` in prose does not count — and
+/// something follows it.
+fn codeAfterLocation(line: []const u8) ?struct { path: []const u8, at: usize, text: []const u8 } {
+ const token_end = std.mem.indexOfAny(u8, line, " \t") orelse return null;
+ if (token_end == 0) return null;
+ const token = line[0..token_end];
+ const target = look.parsePathLine(token);
+ if (target.end != token.len) return null;
+ // A bare word is not a location: `main.zig` alone is a filename, but a
+ // results row is `main.zig:12:3`, and without that a prose line whose
+ // first word happens to end in `.md` would colour the rest of a sentence.
+ if (target.at.line == 0) return null;
+ var at = token_end;
+ while (at < line.len and (line[at] == ' ' or line[at] == '\t')) at += 1;
+ if (at >= line.len) return null;
+ return .{ .path = target.path, .at = at, .text = line[at..] };
+}
+
// Markdown fenced blocks and Typst raw blocks are the same construct — a
// language tag plus a literal payload — under different node shapes, so one
// walker drives both and only the (lang, content) extraction differs.
@@ -399,7 +524,9 @@ fn highlightCodeBlock(styles: []u8, source: []const u8, block: ts.Node, site: In
// outer colour would survive as a wash behind the injected code. The sub
// grammar owns the payload outright: clear it first.
@memset(styles[cs..ce], @intFromEnum(Syn.none));
- runQuery(styles, sub_sel, tree, cs);
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ runQuery(styles, sub_sel, tree, cs, cursor);
}
// tree-sitter-markdown is a split grammar: the block parser bottoms out at named
@@ -445,7 +572,9 @@ fn highlightInline(styles: []u8, source: []const u8, node: ts.Node, sel: Selecte
// the colour the heading must keep wherever the inline pass captures
// nothing. Painting over instead of resetting is what makes *italic* inside
// a heading recolour while the rest of the heading stays heading-coloured.
- runQuery(styles, sel, tree, s);
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ runQuery(styles, sel, tree, s, cursor);
}
/// One Syn byte per byte of content[start, end) for unified diffs/patches.
@@ -481,6 +610,77 @@ fn diffLineSyn(line: []const u8) Syn {
return .none;
}
+test "a results row is coloured by the file its location names" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ const gpa = std.testing.allocator;
+
+ // Two rows quoting two languages, plus a row that is not a location and a
+ // location with nothing after it.
+ const content =
+ "src/a.zig:1:1 const S = struct {};\n" ++
+ "src/b.md:2:1 # heading\n" ++
+ "just some prose with a colon: here\n" ++
+ "src/c.zig:3:1\n";
+ const styles = try highlightLocations(gpa, content, 0, content.len);
+ defer gpa.free(styles);
+ try std.testing.expectEqual(content.len, styles.len);
+
+ // The LOCATION itself is left alone — it is not code, and colouring it as
+ // code is how a path starts looking like a keyword.
+ for (styles[0.."src/a.zig:1:1".len]) |b| try std.testing.expectEqual(@as(u8, 0), b);
+
+ // ...and `struct` in the Zig row is a keyword, which is only true if the
+ // grammar was chosen from `a.zig` rather than from the buffer's own name.
+ //
+ // `struct` and not `const`: tree-sitter-zig captures `const` as
+ // `@type.qualifier`, which `synFor` maps to nothing — a real property of
+ // the shipped query rather than of this pass, and the reason the first
+ // version of this test failed.
+ const zig_kw = std.mem.indexOf(u8, content, "struct").?;
+ try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw]);
+ try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw + 5]);
+
+ // A row with no location contributes nothing...
+ const prose = std.mem.indexOf(u8, content, "just some prose").?;
+ for (styles[prose .. prose + 14]) |b| try std.testing.expectEqual(@as(u8, 0), b);
+ // ...and neither does a location with no code after it.
+ const bare = std.mem.indexOf(u8, content, "src/c.zig").?;
+ for (styles[bare..]) |b| try std.testing.expectEqual(@as(u8, 0), b);
+}
+
+test "a buffer with no locations in it keeps no highlights at all" {
+ if (!enabled) return;
+ start(std.testing.allocator);
+ defer stop();
+ // +Help, +Config, +Messages: prose. An all-zero run of styles is not the
+ // same as none — `recolorSyntax` skips a pane whose highlights are EMPTY,
+ // and returning a full-length run of zeroes made it walk every visible
+ // grapheme every frame to paint nothing.
+ const content = "nothing has been said yet\n0: save: AccessDenied (x2)\n";
+ const styles = try highlightLocations(std.testing.allocator, content, 0, content.len);
+ defer std.testing.allocator.free(styles);
+ try std.testing.expectEqual(@as(usize, 0), styles.len);
+}
+
+test "codeAfterLocation takes whole-token locations and nothing else" {
+ // A grep row: path, line, column range, then the quoted source.
+ const got = codeAfterLocation("src/x.zig:7:2-9 fn main() void {") orelse
+ return error.ShouldBeALocation;
+ try std.testing.expectEqualStrings("src/x.zig", got.path);
+ try std.testing.expectEqualStrings("fn main() void {", got.text);
+
+ // Not locations: a bare filename (no line), prose with a colon, a token
+ // that only PARTLY parses, and a location with nothing after it.
+ try std.testing.expect(codeAfterLocation("main.zig some words") == null);
+ try std.testing.expect(codeAfterLocation("note: this is prose") == null);
+ try std.testing.expect(codeAfterLocation("src/x.zig:7:2x rest") == null);
+ try std.testing.expect(codeAfterLocation("src/x.zig:7:2") == null);
+ try std.testing.expect(codeAfterLocation("") == null);
+ try std.testing.expect(codeAfterLocation(" leading space") == null);
+}
+
test "tree-sitter allocator callbacks preserve and free exact allocations" {
syntax_allocator = std.testing.allocator;
defer syntax_allocator = undefined;