diff options
Diffstat (limited to 'src/locations.zig')
| -rw-r--r-- | src/locations.zig | 292 |
1 files changed, 290 insertions, 2 deletions
diff --git a/src/locations.zig b/src/locations.zig index b34f7a6f..6a604907 100644 --- a/src/locations.zig +++ b/src/locations.zig @@ -4,7 +4,6 @@ const look = @import("look.zig"); const syntax = @import("syntax.zig"); const filesystem = @import("fs.zig"); -pub const Config = @import("locations_config.zig").Config; const neighbor_context: u8 = 1; const declaration_context: u8 = 2; const declaration_start: u8 = 4; @@ -206,7 +205,7 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us // 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 .{} }; + Cache.Hit{ .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; @@ -393,3 +392,292 @@ test "locations native results retain the first source tab" { try std.testing.expectEqualStrings("some directory/a.zig", row.path); try std.testing.expectEqualStrings("\t value", text[row.code_start..]); } + +// ---- the Locations setting ---- + +pub const Config = struct { + pub const Layout = enum { @"inline", stacked }; + context: u16 = 0, + tscontext: bool = false, + tslocations: bool = true, + layout: Layout = .stacked, + + /// Apply only supplied fields. A malformed token rejects the whole update. + /// Repeating a field uses its last supplied value. + pub fn parse(current: Config, text: []const u8) !Config { + var result = current; + var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n"); + while (tokens.next()) |token| { + const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue; + const key = token[0..colon]; + const value = token[colon + 1 ..]; + var found = false; + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (std.mem.eql(u8, key, field.name)) { + @field(result, field.name) = switch (@typeInfo(field.type)) { + .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff, + .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext, + .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout, + else => @compileError("unsupported location setting type"), + }; + found = true; + } + } + if (!found) return error.UnknownLocationSetting; + } + return result; + } + + pub fn write(config: Config, writer: *std.Io.Writer) !void { + inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| { + if (index > 0) try writer.writeByte(' '); + try writer.writeAll(field.name ++ ":"); + const value = @field(config, field.name); + switch (@typeInfo(field.type)) { + .bool => try writer.writeAll(if (value) "on" else "off"), + .int => try writer.print("{d}", .{value}), + .@"enum" => try writer.writeAll(@tagName(value)), + else => @compileError("unsupported location setting type"), + } + } + } +}; + +test "LocationsConfig reflection round trip and partial updates" { + var config: Config = .{}; + config = try config.parse("context:5 tscontext:on"); + config = try config.parse("tslocations:off"); + try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config); + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try config.write(&out.writer); + try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written()); + try std.testing.expectEqual(config, try (Config{}).parse(out.written())); + try std.testing.expectEqual(config, try config.parse(" \n\t")); + try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context); + try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout); +} + +test "LocationsConfig rejects invalid updates atomically" { + const config: Config = .{ .context = 3, .tscontext = true }; + try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on")); + try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true")); + try std.testing.expectError(error.ExpectedKeyValue, config.parse("context")); + try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways")); + for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input| + try std.testing.expectError(error.InvalidContext, config.parse(input)); + try std.testing.expectEqual(@as(u16, 3), config.context); +} + +// ---- the analysis cache ---- + +/// 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 { + /// A cache hit borrows its analysis; an entry too large to retain transfers + /// ownership instead. Call deinit after consuming either result. + pub const Hit = struct { + analysis: syntax.SourceAnalysis, + owned: bool = false, + + pub fn deinit(result: *Hit, gpa: std.mem.Allocator) void { + if (result.owned) result.analysis.deinit(gpa); + result.* = undefined; + } + }; + + 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 Hit.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) !Hit { + 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) !Hit { + 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); +} |
