diff options
| -rw-r--r-- | build.zig | 17 | ||||
| -rw-r--r-- | docs/config.md | 3 | ||||
| -rw-r--r-- | docs/locations-performance.md | 45 | ||||
| -rw-r--r-- | src/locations.zig | 27 | ||||
| -rw-r--r-- | src/locations_cache.zig | 213 | ||||
| -rw-r--r-- | src/main.zig | 1 | ||||
| -rw-r--r-- | src/pardes.zig | 2 | ||||
| -rw-r--r-- | src/syntax.zig | 123 | ||||
| -rw-r--r-- | test/locations_bench.zig | 61 | ||||
| -rw-r--r-- | test/output.zig | 139 |
10 files changed, 607 insertions, 24 deletions
@@ -1009,6 +1009,23 @@ pub fn build(b: *std.Build) void { const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- <kind> <file> <line>:<col> [arg] [--reps N])"); lspprobe_step.dependOn(&run_lspprobe.step); + const locations_bench_step = b.step("locations-bench", "measure cold/warm result formatting (-Doptimize=ReleaseFast; CSV on stderr)"); + if (optimize != .ReleaseFast) { + locations_bench_step.dependOn(&b.addFail("locations-bench requires -Doptimize=ReleaseFast").step); + } else { + const locations_bench_module = b.createModule(.{ + .target = target, + .optimize = .ReleaseFast, + .root_source_file = b.path("test/locations_bench.zig"), + .link_libc = true, + }); + locations_bench_module.addImport("pardes", core_module); + const locations_bench = b.addTest(.{ .root_module = locations_bench_module, .filters = &.{"locations benchmark cold and warm formatting"} }); + const run_locations_bench = b.addRunArtifact(locations_bench); + run_locations_bench.has_side_effects = true; + locations_bench_step.dependOn(&run_locations_bench.step); + } + const perf_optimize: std.builtin.OptimizeMode = .ReleaseFast; const perf_options = b.addOptions(); const perf_target = std.Target.Query.fromTarget(&target.result); diff --git a/docs/config.md b/docs/config.md index 6f3273b9..972f9403 100644 --- a/docs/config.md +++ b/docs/config.md @@ -177,6 +177,9 @@ depth (for example, `main.zig:42 ***`). Ordinary neighboring source lines retain syntax colors; declaration headers do not. A declaration context line ends with ` ...` when source lines are omitted before the next displayed row from that file. +Source analysis is reused across result queries while the source bytes stay the +same. The cache retains at most 64 files and 64 MiB; it checks current buffer or +filesystem contents on each refresh. Open file buffers supply context from their current edits. Missing files still leave the original result available. These settings apply to subsequent result generation and survive `Dump`/`Restore`. Invalid fields reject the entire diff --git a/docs/locations-performance.md b/docs/locations-performance.md new file mode 100644 index 00000000..32ee08bd --- /dev/null +++ b/docs/locations-performance.md @@ -0,0 +1,45 @@ +# Location results performance + +Run `zig build locations-bench -Doptimize=ReleaseFast` from the repository root. +CSV measurements are written to stderr. The benchmark covers 100, 500 and +1,500 matches, both layouts, and ordinary/tree-sitter context separately and +together. Each configuration starts with an empty analysis cache and then +repeats twice with unchanged source and producer results. There are no timing +thresholds in the tests. + +The fixtures are open, unsaved Zig buffers: one module containing functions +with 20 local declarations and one match per function. The largest is +1,813,921 bytes. Timings cover `Output.fillResults`, including sorting, +formatting, metadata and refresh comparison. Source generation, input +allocation, filesystem I/O, subprocess startup and GUI drawing are excluded. + +## Measurement on September 15, 2026 + +Milliseconds for 1,500 matches on the development Linux machine: + +| Layout / context | Before optimization, repeated | After, cold | After, warm repeats | +| --- | ---: | ---: | ---: | +| Inline / declaration headers | 1,259 | 224 | 2.32 / 2.14 | +| Inline / headers and two neighboring lines | 1,250 | 291 | 4.80 / 4.66 | +| Stacked / declaration headers | — | 224 | 2.04 / 1.88 | +| Stacked / headers and two neighboring lines | — | 294 | 3.92 / 3.65 | + +The before figures are medians of three runs of the same fixture with the +previous parser/traversal path. After figures are one cold and two warm +samples from the repository benchmark. Stacked layout has no before figure. +These are focused formatter measurements, not end-to-end GUI latency promises. + +## Changes + +- Declaration traversal uses a tree cursor that retains its ancestor stack. +- Declarations and ordinary-context colors share a single parse. +- Muted declaration headers do not request unused source colors. +- Exact source snapshots cache complete analysis across queries. Unsaved + buffers take precedence; filesystem sources are reread to detect changes. +- The cache retains at most 64 entries and 64 MiB, evicting the least recently + used entry. Oversized analysis is used for the current result without being + retained. Pending context owns its selected color bytes so later eviction + cannot invalidate rows already collected. + +Cold parsing remains proportional to source size. Unchanged refreshes still +read/compare source bytes and assemble results, but skip parsing and analysis. diff --git a/src/locations.zig b/src/locations.zig index 97caf1d9..b34f7a6f 100644 --- a/src/locations.zig +++ b/src/locations.zig @@ -158,17 +158,19 @@ const Pending = struct { } }; -fn sourceText(p: *pardes.Pardes, arena: std.mem.Allocator, dir: []const u8, path: []const u8) ?[]const u8 { +const Source = struct { path: []const u8, bytes: []const u8 }; + +fn sourceText(p: *pardes.Pardes, arena: std.mem.Allocator, dir: []const u8, path: []const u8) ?Source { if (std.ascii.eqlIgnoreCase(std.fs.path.extension(path), ".pdf")) return null; const lexical = std.fs.path.resolvePosix(arena, &.{ dir, path }) catch return null; var pathbuf: [4096]u8 = undefined; const full = if (filesystem.resolve(p, path, dir, &pathbuf)) |resolved| resolved.path else lexical; for (p.panes) |slot| if (slot) |pane| { - if (pane.file) |file| if (file.output == null and (std.mem.eql(u8, file.path, full) or std.mem.eql(u8, file.path, lexical))) return file.content; + if (pane.file) |file| if (file.output == null and (std.mem.eql(u8, file.path, full) or std.mem.eql(u8, file.path, lexical))) return .{ .path = lexical, .bytes = file.content }; }; const bytes = filesystem.read(p, full) catch return null; defer p.gpa.free(bytes); - return arena.dupe(u8, bytes) catch null; + return .{ .path = lexical, .bytes = arena.dupe(u8, bytes) catch return null }; } /// Inputs are sorted results. Expand each source group, merge context with @@ -193,18 +195,22 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us const group_start = pending.items.len; try pending.appendSlice(arena, matches.items[first..end]); if (expand and (p.locations_config.context > 0 or p.locations_config.tscontext)) { - if (sourceText(p, arena, dir, path)) |source| { + if (sourceText(p, arena, dir, path)) |text| { + const source = text.bytes; var source_lines: std.ArrayList([]const u8) = .empty; var source_split = std.mem.splitScalar(u8, source, '\n'); while (source_split.next()) |line| try source_lines.append(arena, line); const count = source_lines.items.len; const wanted = try arena.alloc(u8, count); @memset(wanted, 0); - const source_colors = syntax.highlightFileRange(arena, path, source, 0, source.len) catch &.{}; - const declarations = if (p.locations_config.tscontext) - syntax.contextDeclarations(arena, path, source) catch &.{} - else - &.{}; + // Muted declaration headers do not need a source-color pass. + // Exact bytes include unsaved edits and virtual/remote sources. + var cached = p.locations_cache.get(p.tree_sitter_gpa, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch + @import("locations_cache.zig").Result{ .analysis = syntax.analyzeSource(arena, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch .{} }; + defer cached.deinit(p.tree_sitter_gpa); + const analysis = cached.analysis; + const source_colors = analysis.colors; + const declarations = if (p.locations_config.tscontext) analysis.declarations else &.{}; for (pending.items[group_start..]) |*match| { const row = match.at.line -| 1; if (row >= count) continue; @@ -235,7 +241,8 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us const declaration = kind & declaration_context != 0; const colors = if (!declaration and offset + line.len <= source_colors.len) source_colors[offset..][0..line.len] else &.{}; const hidden = !declaration or !p.locations_config.tslocations or kind & declaration_start == 0; - try appendContext(arena, &pending, path, line, row, declaration, hidden, colors); + // Later source groups may evict this analysis from the cache. + try appendContext(arena, &pending, path, line, row, declaration, hidden, try arena.dupe(u8, colors)); } } } diff --git a/src/locations_cache.zig b/src/locations_cache.zig new file mode 100644 index 00000000..a079226b --- /dev/null +++ b/src/locations_cache.zig @@ -0,0 +1,213 @@ +const std = @import("std"); +const syntax = @import("syntax.zig"); + +/// A cache hit borrows its analysis; an entry too large to retain transfers +/// ownership instead. Call deinit after consuming either result. +pub const Result = struct { + analysis: syntax.SourceAnalysis, + owned: bool = false, + + pub fn deinit(result: *Result, gpa: std.mem.Allocator) void { + if (result.owned) result.analysis.deinit(gpa); + result.* = undefined; + } +}; + +/// Owned source snapshots keep analysis valid across filesystem changes and +/// borrowed editor buffers. Entries are ordered from least to most recent. +pub const Cache = struct { + pub const max_bytes = 64 * 1024 * 1024; + pub const max_entries = 64; + + const Entry = struct { + path: []u8, + source: []u8, + analysis: syntax.SourceAnalysis, + declarations_ready: bool, + colors_ready: bool, + + fn size(entry: Entry) usize { + return entry.path.len + entry.source.len + entry.analysis.colors.len + + entry.analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); + } + + fn deinit(entry: *Entry, gpa: std.mem.Allocator) void { + gpa.free(entry.path); + gpa.free(entry.source); + entry.analysis.deinit(gpa); + } + }; + + entries: [max_entries]Entry = undefined, + len: usize = 0, + bytes: usize = 0, + analyses: usize = 0, + hits: usize = 0, + + pub fn deinit(cache: *Cache, gpa: std.mem.Allocator) void { + for (cache.entries[0..cache.len]) |*entry| entry.deinit(gpa); + cache.* = .{}; + } + + /// Borrowed slices remain valid until the next get or cache deinit; an + /// owned result remains valid until Result.deinit. All calls for this + /// cache must use the same allocator. Unsupported languages + /// are cached too, including their empty analysis arrays. + pub fn get(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool) !Result { + return cache.getBounded(gpa, path, source, want_declarations, want_colors, max_bytes); + } + + fn getBounded(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool, budget: usize) !Result { + if (source.len > budget or path.len > budget - source.len) return error.SourceTooLarge; + var previous: ?usize = null; + var declarations = want_declarations; + var colors = want_colors; + for (cache.entries[0..cache.len], 0..) |entry, index| { + if (!std.mem.eql(u8, entry.path, path)) continue; + previous = index; + if (!std.mem.eql(u8, entry.source, source)) break; + if ((!want_declarations or entry.declarations_ready) and (!want_colors or entry.colors_ready)) { + // Moving the small ownership record keeps the underlying + // allocations intact and avoids timestamp/overflow state. + std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); + cache.entries[cache.len - 1] = entry; + cache.hits +|= 1; + return .{ .analysis = entry.analysis }; + } + declarations = declarations or entry.declarations_ready; + colors = colors or entry.colors_ready; + break; + } + // A supported full-source color map needs one byte per source byte. + // Reject that known minimum before parsing, so the caller's uncached + // path does not repeat an expensive analysis for oversized inputs. + const input_bytes = path.len + source.len; + if (colors and syntax.supportsPath(path) and source.len > budget - input_bytes) return error.SourceTooLarge; + var analysis = try syntax.analyzeSource(gpa, path, source, declarations, colors); + errdefer analysis.deinit(gpa); + cache.analyses +|= 1; + const analysis_bytes = analysis.colors.len + analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); + if (analysis_bytes > budget - input_bytes) return .{ .analysis = analysis, .owned = true }; + const owned_path = try gpa.dupe(u8, path); + errdefer gpa.free(owned_path); + const owned_source = try gpa.dupe(u8, source); + errdefer gpa.free(owned_source); + const next: Entry = .{ + .path = owned_path, + .source = owned_source, + .analysis = analysis, + .declarations_ready = declarations, + .colors_ready = colors, + }; + // Commit only after every allocation succeeds. A failed refresh leaves + // the previous cached snapshot usable by the next request. + if (previous) |index| cache.remove(gpa, index); + while (cache.len == max_entries or cache.bytes > budget - next.size()) cache.remove(gpa, 0); + cache.entries[cache.len] = next; + cache.len += 1; + cache.bytes += next.size(); + return .{ .analysis = next.analysis }; + } + + fn remove(cache: *Cache, gpa: std.mem.Allocator, index: usize) void { + cache.bytes -= cache.entries[index].size(); + cache.entries[index].deinit(gpa); + std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); + cache.len -= 1; + } +}; + +test "locations cache validates exact source and path and upgrades analysis" { + const gpa = std.testing.allocator; + syntax.start(gpa); + defer syntax.stop(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + const source = "const Thing = struct {\n value: u32,\n};\n"; + _ = try cache.get(gpa, "a.zig", source, true, false); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + _ = try cache.get(gpa, "a.zig", source, true, false); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + try std.testing.expectEqual(@as(usize, 1), cache.hits); + _ = try cache.get(gpa, "a.zig", source, false, true); + try std.testing.expectEqual(@as(usize, 2), cache.analyses); + try std.testing.expect(cache.entries[0].declarations_ready and cache.entries[0].colors_ready); + _ = try cache.get(gpa, "a.zig", source, true, true); + try std.testing.expectEqual(@as(usize, 2), cache.analyses); + _ = try cache.get(gpa, "a.zig", "const Thing = struct {\n other: u32,\n};\n", true, true); + try std.testing.expectEqual(@as(usize, 3), cache.analyses); + try std.testing.expectEqual(@as(usize, 1), cache.len); + _ = try cache.get(gpa, "a.txt", source, true, true); + try std.testing.expectEqual(@as(usize, 4), cache.analyses); + try std.testing.expectEqual(@as(usize, 2), cache.len); +} + +test "locations cache bounds memory and entries and evicts least recently used" { + const gpa = std.testing.allocator; + var cache: Cache = .{}; + defer cache.deinit(gpa); + for (0..Cache.max_entries) |index| { + var path: [32]u8 = undefined; + _ = try cache.get(gpa, try std.fmt.bufPrint(&path, "{d}.unknown", .{index}), "source", false, false); + } + _ = try cache.get(gpa, "0.unknown", "source", false, false); + _ = try cache.get(gpa, "new.unknown", "source", false, false); + try std.testing.expectEqual(Cache.max_entries, cache.len); + try std.testing.expectEqualStrings("2.unknown", cache.entries[0].path); + const count = cache.analyses; + _ = try cache.get(gpa, "0.unknown", "source", false, false); + try std.testing.expectEqual(count, cache.analyses); + cache.deinit(gpa); + _ = try cache.getBounded(gpa, "a.unknown", "source", false, false, 40); + _ = try cache.getBounded(gpa, "b.unknown", "source", false, false, 40); + _ = try cache.getBounded(gpa, "c.unknown", "source", false, false, 40); + try std.testing.expectEqual(@as(usize, 2), cache.len); + try std.testing.expect(cache.bytes <= 40); + try std.testing.expectEqualStrings("b.unknown", cache.entries[0].path); + try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.unknown", "x" ** 41, false, false, 40)); + try std.testing.expectEqual(@as(usize, 2), cache.len); + if (syntax.supportsPath("large.zig")) { + const before = cache.analyses; + try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.zig", "x" ** 25, true, true, 40)); + try std.testing.expectEqual(before, cache.analyses); + } + cache.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), cache.bytes); + try std.testing.expectEqual(@as(usize, 0), cache.len); +} + +test "locations cache keeps its previous snapshot after allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const gpa = failing.allocator(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); + const retained = cache.bytes; + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, cache.get(gpa, "a.unknown", "new bytes", false, false)); + try std.testing.expectEqual(@as(usize, 1), cache.len); + try std.testing.expectEqual(retained, cache.bytes); + try std.testing.expectEqualStrings("old bytes", cache.entries[0].source); + // This succeeds with allocations still disabled because the old entry + // remains valid and the hit path moves ownership records only. + _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); + try std.testing.expectEqual(@as(usize, 1), cache.hits); +} + +test "locations cache transfers oversized completed analysis without reparsing" { + if (!syntax.supportsPath("large.zig")) return error.SkipZigTest; + const gpa = std.testing.allocator; + syntax.start(gpa); + defer syntax.stop(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + const source = "const Thing = struct {\n value: u32,\n};\n"; + const budget = "large.zig".len + source.len; + var result = try cache.getBounded(gpa, "large.zig", source, true, false, budget); + defer result.deinit(gpa); + try std.testing.expect(result.owned); + try std.testing.expect(result.analysis.declarations.len > 0); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + try std.testing.expectEqual(@as(usize, 0), cache.len); + try std.testing.expectEqual(@as(usize, 0), cache.bytes); +} diff --git a/src/main.zig b/src/main.zig index 2ee295e1..40ec8667 100644 --- a/src/main.zig +++ b/src/main.zig @@ -369,6 +369,7 @@ test { _ = pardes.config.User; _ = @import("crash.zig"); _ = @import("memory.zig"); + _ = @import("locations_cache.zig"); _ = @import("fs.zig"); _ = @import("detached/wire.zig"); _ = @import("detached/server.zig"); diff --git a/src/pardes.zig b/src/pardes.zig index 2729ef93..c78d4702 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5969,6 +5969,7 @@ pub const Pardes = struct { next_serial: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, + locations_cache: @import("locations_cache.zig").Cache = .{}, tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, @@ -6212,6 +6213,7 @@ pub const Pardes = struct { if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); + p.locations_cache.deinit(p.tree_sitter_gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); diff --git a/src/syntax.zig b/src/syntax.zig index 9e31669f..e637bf67 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -376,28 +376,58 @@ pub fn supportsPath(path: []const u8) bool { 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 &.{}; +/// 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 { + declarations: []ContextDeclaration = &.{}, + colors: []u8 = &.{}, + + pub fn deinit(self: *SourceAnalysis, gpa: std.mem.Allocator) void { + gpa.free(self.declarations); + gpa.free(self.colors); + self.* = .{}; + } +}; + +pub fn analyzeSource(gpa: std.mem.Allocator, path: []const u8, content: []const u8, want_declarations: bool, want_colors: bool) !SourceAnalysis { + if (!enabled or content.len == 0 or (!want_declarations and !want_colors)) 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: SourceAnalysis = .{}; + errdefer result.deinit(gpa); + if (want_declarations) result.declarations = try declarationsFromTree(gpa, content, tree); + if (want_colors) { + result.colors = try gpa.alloc(u8, content.len); + @memset(result.colors, 0); + paintTree(result.colors, content, selected, tree); + } + return result; +} + +pub fn contextDeclarations(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]ContextDeclaration { + const analysis = try analyzeSource(gpa, path, content, true, false); + return analysis.declarations; +} + +fn declarationsFromTree(gpa: std.mem.Allocator, content: []const u8, tree: *ts.Tree) ![]ContextDeclaration { 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(); + // The cursor retains its ancestor stack: sibling visits do not repeatedly + // reconstruct parents in broad syntax trees. Preorder preserves source order. + var cursor = tree.rootNode().walk(); + defer cursor.destroy(); while (true) { - if (contextSpan(node, content)) |span| { + const node = cursor.node(); + if (node.isNamed()) 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 (cursor.gotoFirstChild()) continue; + while (!cursor.gotoNextSibling()) { + if (!cursor.gotoParent()) return result.toOwnedSlice(gpa); } - if (node.namedChild(0)) |child| { - node = child; - continue; - } - while (node.nextNamedSibling() == null) node = node.parent() orelse return result.toOwnedSlice(gpa); - node = node.nextNamedSibling().?; } } @@ -492,6 +522,10 @@ fn contextSpan(node: ts.Node, source: []const u8) ?ContextDeclaration { fn paint(styles: []u8, source: []const u8, selected: Selected) void { const tree = selected.parser.parseString(source, null) orelse return; defer tree.destroy(); + paintTree(styles, source, selected, tree); +} + +fn paintTree(styles: []u8, source: []const u8, selected: Selected, tree: *ts.Tree) void { runQuery(styles, selected, tree, 0); if (std.mem.eql(u8, selected.name, "markdown")) { inject(styles, source, tree.rootNode(), true); @@ -1279,3 +1313,64 @@ test "syntax stacked addresses do not split multiline source groups" { for (styles[at..][0..label.len]) |style| try std.testing.expectEqual(@intFromEnum(Syn.none), style); } } + +test "syntax source analysis preserves declarations and injected colors" { + if (!enabled) return; + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const Fixture = struct { path: []const u8, source: []const u8 }; + for ([_]Fixture{ + .{ .path = "a.zig", .source = "const Outer = struct {\n // comment\n const Inner = struct {\n pub fn run(\n x: u32,\n ) u32 { return x; }\n };\n};\n" }, + .{ .path = "a.cpp", .source = "namespace Outer {\nstruct Inner {\nint run(\n int x\n) { return x; }\n};\n}\n" }, + .{ .path = "a.rs", .source = "mod outer {\nstruct Inner {}\nimpl Inner {\nfn run(\n &self\n) {}\n}\n}\n" }, + .{ .path = "a.js", .source = "function outer() {\nclass Inner {\nrun(\n x\n) { return x; }\n}\n}\n" }, + .{ .path = "a.py", .source = "class Outer:\n class Inner:\n def run(\n self, x\n ):\n return x\n" }, + .{ .path = "a.md", .source = "# Heading\n\n```zig\npub fn run() void {}\n```\n" }, + .{ .path = "a.typst", .source = "= Heading\n\n```zig\npub fn run() void {}\n```\n" }, + }) |fixture| { + if (!supportsPath(fixture.path)) continue; + var analysis = try analyzeSource(gpa, fixture.path, fixture.source, true, true); + defer analysis.deinit(gpa); + const colors = try highlightFileRange(gpa, fixture.path, fixture.source, 0, fixture.source.len); + defer gpa.free(colors); + try std.testing.expectEqualSlices(u8, colors, analysis.colors); + + // Compare the previous named-node walk to the cursor walk, including + // source ordering, wrapper deduplication and inclusive declaration ends. + const selected = (try forExt(std.fs.path.extension(fixture.path))).?; + const tree = selected.parser.parseString(fixture.source, null).?; + defer tree.destroy(); + var reference: std.ArrayList(ContextDeclaration) = .empty; + defer reference.deinit(gpa); + var node = tree.rootNode(); + walk: while (true) { + if (contextSpan(node, fixture.source)) |span| { + if (reference.items.len == 0 or reference.items[reference.items.len - 1].start_line != span.start_line or + reference.items[reference.items.len - 1].end_line != span.end_line) + try reference.append(gpa, span); + } + if (node.namedChild(0)) |child| { + node = child; + continue; + } + while (node.nextNamedSibling() == null) node = node.parent() orelse break :walk; + node = node.nextNamedSibling().?; + } + try std.testing.expectEqual(reference.items.len, analysis.declarations.len); + for (reference.items, analysis.declarations) |old, new| try std.testing.expect(std.meta.eql(old, new)); + + var declarations_only = try analyzeSource(gpa, fixture.path, fixture.source, true, false); + defer declarations_only.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), declarations_only.colors.len); + try std.testing.expectEqual(analysis.declarations.len, declarations_only.declarations.len); + var colors_only = try analyzeSource(gpa, fixture.path, fixture.source, false, true); + defer colors_only.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), colors_only.declarations.len); + try std.testing.expectEqualSlices(u8, analysis.colors, colors_only.colors); + } + var unsupported = try analyzeSource(gpa, "a.unknown", "text", true, true); + unsupported.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), unsupported.declarations.len); + try std.testing.expectEqual(@as(usize, 0), unsupported.colors.len); +} diff --git a/test/locations_bench.zig b/test/locations_bench.zig new file mode 100644 index 00000000..54cebb59 --- /dev/null +++ b/test/locations_bench.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const pds = @import("pardes"); +const loc = pds.locations_config; +fn now() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); +} +// Run: zig build locations-bench -Doptimize=ReleaseFast +// Timed work is the real Output.fillResults pipeline. Fixtures are open unsaved +// sources; source generation, input duplication, rendering and subprocesses are +// excluded. Each layout/config starts with an empty source-analysis cache. +// Two repeats retain that cache and existing output. No latency threshold applies. +test "locations benchmark cold and warm formatting" { + if (!pds.syntax.supportsPath("large.zig")) return error.ZigGrammarRequired; + std.debug.print("layout,context,matches,source_bytes,phase,repeat,elapsed_ns\n", .{}); + const gpa = std.heap.smp_allocator; + for ([_]usize{ 100, 500, 1500 }) |functions| { + var source: std.Io.Writer.Allocating = .init(gpa); + defer source.deinit(); + var input: std.Io.Writer.Allocating = .init(gpa); + defer input.deinit(); + try source.writer.writeAll("pub const Module = struct {\n"); + var row: usize = 2; + for (0..functions) |i| { + try source.writer.print(" pub fn f{d}(x: u32) u32 {{\n", .{i}); + row += 1; + for (0..20) |j| { + try source.writer.print(" const v{d} = x + {d}; // explanatory source comment\n", .{ j, j }); + row += 1; + } + const line = " return x; // BENCH_MATCH"; + try source.writer.print("{s}\n }}\n", .{line}); + try input.writer.print("large.zig:{d}:1 {s}\n", .{ row, line }); + row += 2; + } + try source.writer.writeAll("};\n"); + const p = try pds.Pardes.init(gpa, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + const pane = try p.setTestFile(source.written()); + gpa.free(pane.file.?.path); + pane.file.?.path = try gpa.dupe(u8, "/bench/large.zig"); + for ([_]loc.Config.Layout{ .@"inline", .stacked }) |layout| { + for ([_]loc.Config{ .{ .context = 0, .tscontext = false }, .{ .context = 2, .tscontext = false }, .{ .context = 0, .tscontext = true }, .{ .context = 2, .tscontext = true } }, 0..) |config, mode| { + p.locations_config = config; + p.locations_cache.deinit(p.tree_sitter_gpa); + p.locations_cache = .{}; + p.locations_config.layout = layout; + for (0..3) |rep| { + _ = p.scratch.reset(.retain_capacity); + const data = try gpa.dupe(u8, input.written()); + const t = now(); + try pds.panes.Output.fillResults(p, 0, "/bench", .{ .cmd = .Grep }, "BENCH_MATCH", data, null); + const elapsed = now() - t; + const modes = [_][]const u8{ "off", "ordinary", "tree_sitter", "both" }; + std.debug.print("{s},{s},{d},{d},{s},{d},{d}\n", .{ @tagName(layout), modes[mode], functions, source.written().len, if (rep == 0) "cold" else "warm", rep, elapsed }); + } + } + } + } +} diff --git a/test/output.zig b/test/output.zig index c738a661..21e9f04c 100644 --- a/test/output.zig +++ b/test/output.zig @@ -1585,3 +1585,142 @@ test "stacked search and LSP results retain source tint navigation and dump owne } } } + +test "location analysis cache refreshes unsaved ancestry and query context" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + const before = "const Outer = struct {\n" ++ + " fn first() void {\n" ++ + " marker();\n" ++ + " }\n" ++ + " fn second() void {\n" ++ + " marker();\n" ++ + " }\n" ++ + "};\n"; + const after = "pub fn revised() void {\n // new neighbor\n marker();\n}\n"; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile(before); + p.gpa.free(source.file.?.path); + source.file.?.path = try p.gpa.dupe(u8, "/analysis-cache.zig"); + p.locations_config = .{ .context = 0, .tscontext = true, .tslocations = false }; + const Runner = struct { + fn fill(editor: *Pardes, line: usize) !void { + const path = editor.panes[0].?.file.?.path; + const input = try std.fmt.allocPrint(editor.gpa, "{s}:{d}:9-14 marker();\n", .{ path, line }); + try panes.Output.fillResults(editor, 0, "/", .search, "marker", input, 0); + } + }; + try Runner.fill(p, 3); + const id = source.search_pane.?; + const file = &p.panes[id].?.file.?; + try std.testing.expect(std.mem.indexOf(u8, file.content, "const Outer") != null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn first") != null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn second") == null); + const original = file.content.ptr; + const revision = file.revision; + const analyses = p.locations_cache.analyses; + const hits = p.locations_cache.hits; + try Runner.fill(p, 3); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(original, file.content.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(analyses, p.locations_cache.analyses); + try std.testing.expect(p.locations_cache.hits > hits); + + // Reusing analysis must still choose the ancestry of the current match. + try Runner.fill(p, 6); + try std.testing.expectEqual(analyses, p.locations_cache.analyses); + try std.testing.expect(std.mem.indexOf(u8, file.content, "const Outer") != null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn first") == null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn second") != null); + const match = file.location_rows[source.search_row.?]; + try std.testing.expectEqual(.match, match.kind); + try std.testing.expectEqual(@as(usize, 6), match.at.line); + + // The path stays identical, but an unsaved replacement changes its tree. + panes.File.setContent(p, &source.file.?, try p.gpa.dupe(u8, after)); + try Runner.fill(p, 3); + try std.testing.expect(p.locations_cache.analyses > analyses); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn revised") != null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "const Outer") == null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn second") == null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "new neighbor") == null); + + p.locations_config.context = 1; + try Runner.fill(p, 3); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn revised") != null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "new neighbor") != null); + const expanded = try p.gpa.dupe(u8, file.content); + defer p.gpa.free(expanded); + const expanded_analyses = p.locations_cache.analyses; + p.locations_config.context = 0; + try Runner.fill(p, 3); + try std.testing.expect(std.mem.indexOf(u8, file.content, "new neighbor") == null); + p.locations_config.context = 1; + try Runner.fill(p, 3); + try std.testing.expectEqualStrings(expanded, file.content); + try std.testing.expectEqual(expanded_analyses, p.locations_cache.analyses); + p.locations_config.tscontext = false; + try Runner.fill(p, 3); + try std.testing.expect(std.mem.indexOf(u8, file.content, "fn revised") == null); + try std.testing.expect(std.mem.indexOf(u8, file.content, "new neighbor") != null); + try std.testing.expectEqualStrings(after, source.file.?.content); +} + +test "location cache eviction retains early colors and honors disk and unsaved replacements" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + const comment = "/* open\nconst int value = 42;\n*/\nneedle();\n"; + const code = "// open\nconst int value = 42;\n//\nneedle();\n"; + try std.testing.expectEqual(comment.len, code.len); + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + p.locations_config = .{ .context = 3, .tscontext = false }; + var input: std.Io.Writer.Allocating = .init(p.gpa); + defer input.deinit(); + for (0..65) |index| { + var name_buf: [32]u8 = undefined; + const name = try std.fmt.bufPrint(&name_buf, "f{d:0>2}.c", .{index}); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = name, .data = if (index == 0) comment else code }); + try input.writer.print("{s}:4:1-6 needle();\n", .{name}); + } + try panes.Output.fillResults(p, 0, dir, .search, "eviction", try p.gpa.dupe(u8, input.written()), null); + const id = source.search_pane.?; + const file = &p.panes[id].?.file.?; + try std.testing.expectEqual(@as(usize, 64), p.locations_cache.len); + try std.testing.expectEqual(@as(usize, 65), p.locations_cache.analyses); + const Color = struct { + fn valueColor(result: *const panes.File.State) !u8 { + for (result.location_rows, 0..) |row, index| { + if (row.kind != .context or row.at.line != 2 or !std.mem.endsWith(u8, row.path, "f00.c")) continue; + const line = modal.lineSlice(result.content, index); + const number = std.mem.indexOf(u8, line[row.code_start..], "42").?; + try std.testing.expectEqualStrings("const int value = 42;", line[row.code_start..]); + try std.testing.expectEqual(line.len - row.code_start, row.colors.len); + return row.colors[number]; + } + return error.MissingEarlyContext; + } + }; + try std.testing.expectEqual(@intFromEnum(pardes.syntax.Syn.comment), try Color.valueColor(file)); + const single = "f00.c:4:1-6 needle();\n"; + // Put the evicted file back before a same-size edit tests exact-byte keys. + try panes.Output.fillResults(p, 0, dir, .search, "eviction", try p.gpa.dupe(u8, single), null); + const analyses = p.locations_cache.analyses; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "f00.c", .data = code }); + try panes.Output.fillResults(p, 0, dir, .search, "eviction", try p.gpa.dupe(u8, single), null); + try std.testing.expect(p.locations_cache.analyses > analyses); + try std.testing.expectEqual(@intFromEnum(pardes.syntax.Syn.number), try Color.valueColor(file)); + + // An open buffer has priority over that disk revision, even with equal size. + p.gpa.free(source.file.?.path); + source.file.?.path = try std.fs.path.join(p.gpa, &.{ dir, "f00.c" }); + panes.File.setContent(p, &source.file.?, try p.gpa.dupe(u8, comment)); + try panes.Output.fillResults(p, 0, dir, .search, "eviction", try p.gpa.dupe(u8, single), null); + try std.testing.expectEqual(@intFromEnum(pardes.syntax.Syn.comment), try Color.valueColor(file)); + try std.testing.expectEqualStrings(comment, source.file.?.content); +} |
