summaryrefslogtreecommitdiff
path: root/src/lsp/lsp_zls.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 10:41:33 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (patch)
tree91fce278abc85e4f7491de8b5f02184c8a2d5a56 /src/lsp/lsp_zls.zig
parent2b1fac6fecbf7d7cc08f9dad24687b05e9968c9a (diff)
downloadpardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.tar.gz
pardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.zip
replace ArrayLists with bounded storage
Diffstat (limited to 'src/lsp/lsp_zls.zig')
-rw-r--r--src/lsp/lsp_zls.zig227
1 files changed, 171 insertions, 56 deletions
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index e1624df0..76c5594c 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -60,6 +60,9 @@ const enc: offsets.Encoding = .@"utf-8";
/// unbounded tree walk because someone opened a file in `/`.
const max_files = 512;
const max_rows = 2000;
+const max_output_bytes = max_rows * 4096;
+const max_trace_bytes = 32 * 1024;
+const max_code_action_bytes = max_rows * 1024;
pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{
.definition,
@@ -171,12 +174,18 @@ fn nowUs() u64 {
/// find out what actually happened.
const Trace = struct {
on: bool = false,
- arena: std.mem.Allocator,
- buf: std.ArrayList(u8) = .empty,
+ buf: [max_trace_bytes]u8 = undefined,
+ len: usize = 0,
fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
- if (!t.on) return;
- t.buf.print(t.arena, fmt ++ "\n", args) catch {};
+ if (!t.on or t.len == t.buf.len) return;
+ var w: std.Io.Writer = .fixed(t.buf[t.len..]);
+ w.print(fmt ++ "\n", args) catch {};
+ t.len += w.buffered().len;
+ }
+
+ fn written(t: *const Trace) []const u8 {
+ return t.buf[0..t.len];
}
};
@@ -189,33 +198,34 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out
// 24-entry ring every time you looked at it.
if (req.kind == .status) return status(arena, req, out) catch {};
- // Rows land in a scratch buffer first, for two reasons a plain Writer
- // cannot serve: the log wants an exact row count, and `explain` throws the
- // rows away and prints the narration in their place — neither a count nor
- // a rewind exists on a Writer you do not own.
- var scratch: std.Io.Writer.Allocating = .init(gpa);
- defer scratch.deinit();
+ // Rows land in bounded scratch storage first, for two reasons a plain
+ // Writer cannot serve: the log wants an exact row count, and `explain`
+ // throws the rows away and prints the narration in their place. A query
+ // that exceeds the row budget's byte allowance keeps its ordered prefix.
+ const scratch_buf = gpa.alloc(u8, max_output_bytes) catch return;
+ defer gpa.free(scratch_buf);
+ var scratch: std.Io.Writer = .fixed(scratch_buf);
- var tr: Trace = .{ .arena = arena, .on = req.kind == .explain };
+ var tr: Trace = .{ .on = req.kind == .explain };
const t0 = nowUs();
var err_name: []const u8 = "";
- run(gpa, arena, req, &scratch.writer, &tr) catch |e| {
+ run(gpa, arena, req, &scratch, &tr) catch |e| {
err_name = @errorName(e);
tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name});
};
const us = nowUs() -| t0;
- const rows = std.mem.count(u8, scratch.written(), "\n");
+ const rows = std.mem.count(u8, scratch.buffered(), "\n");
record(req, us, rows, err_name);
if (req.kind != .explain) {
- out.writeAll(scratch.written()) catch {};
+ out.writeAll(scratch.buffered()) catch {};
return;
}
// the question was never "where is it", it was "what did you do"
out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
}) catch {};
- out.writeAll(tr.buf.items) catch {};
+ out.writeAll(tr.written()) catch {};
if (hideTime())
out.print("\n{d} row(s)\n", .{rows}) catch {}
else
@@ -470,10 +480,16 @@ const Lines = struct {
src: []const u8,
fn build(arena: std.mem.Allocator, src: []const u8) !Lines {
- var starts: std.ArrayList(u32) = .empty;
- try starts.append(arena, 0);
- for (src, 0..) |c, i| if (c == '\n') try starts.append(arena, @intCast(i + 1));
- return .{ .starts = starts.items, .src = src };
+ const count = std.mem.countScalar(u8, src, '\n') + 1;
+ const starts = try arena.alloc(u32, count);
+ starts[0] = 0;
+ var n: usize = 1;
+ for (src, 0..) |c, i| {
+ if (c != '\n') continue;
+ starts[n] = @intCast(i + 1);
+ n += 1;
+ }
+ return .{ .starts = starts, .src = src };
}
fn line(l: Lines, n: usize) []const u8 {
@@ -529,7 +545,8 @@ fn goto(
const ctx = try Analyser.getPositionContext(arena, tree, off, true);
tr.note("position context: .{s}", .{@tagName(ctx)});
- var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
+ const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows);
+ var decl_count: usize = 0;
switch (ctx) {
.var_access, .test_doctest_name => {
const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
@@ -550,7 +567,8 @@ fn goto(
tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name});
return;
};
- try decls.append(arena, d);
+ decls[decl_count] = d;
+ decl_count += 1;
},
.field_access => |loc| {
const name_loc = offsets.identifierLocFromIndex(tree, off) orelse {
@@ -576,19 +594,22 @@ fn goto(
explainFieldMiss(tr, offsets.locToSlice(tree.source, loc));
return;
}
- try decls.appendSlice(arena, found);
+ decl_count = @min(found.len, decls.len);
+ @memcpy(decls[0..decl_count], found[0..decl_count]);
},
.label_access, .label_decl => {
const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
const name = offsets.locToSlice(tree.source, name_loc);
const d = try Analyser.lookupLabel(handle, name, off) orelse return;
- try decls.append(arena, d);
+ decls[decl_count] = d;
+ decl_count += 1;
},
.enum_literal => {
const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return;
const name = offsets.locToSlice(tree.source, name_loc);
const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return;
- try decls.append(arena, d);
+ decls[decl_count] = d;
+ decl_count += 1;
},
// `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports
// resolve — a package name needs the build graph we do not run.
@@ -626,10 +647,10 @@ fn goto(
},
}
- tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) });
+ tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decl_count, @tagName(kind) });
// One decl that fails to resolve must not swallow the others: a field
// access can name several and a partial answer beats none.
- for (decls.items) |d| {
+ for (decls[0..decl_count]) |d| {
switch (kind) {
.declaration => try rowForToken(arena, base, d.definitionToken(analyser, false) catch continue, out),
.definition => try rowForToken(arena, base, d.definitionToken(analyser, true) catch continue, out),
@@ -730,6 +751,86 @@ fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]cons
return best;
}
+/// ZLS's collector takes a growable list. This equivalent writes directly into
+/// the query's row-bounded slice and stops in declaration order when it fills.
+fn collectContainerDecls(
+ analyser: *Analyser,
+ container_type: Analyser.Type,
+ original_handle: *DocumentStore.Handle,
+ instance_access: bool,
+ out: []Analyser.DeclWithHandle,
+ len: *usize,
+) !void {
+ const info = switch (container_type.data) {
+ .container => |info| info,
+ .either => |entries| {
+ for (entries) |entry| {
+ if (len.* == out.len) return;
+ const ty: Analyser.Type = .{
+ .data = entry.type_data,
+ .is_type_val = container_type.is_type_val,
+ };
+ try collectContainerDecls(analyser, ty, original_handle, instance_access, out, len);
+ }
+ return;
+ },
+ else => return,
+ };
+ const container_scope = info.scope_handle;
+ const handle = container_scope.handle;
+ const tree = &handle.tree;
+ const document_scope = try handle.getDocumentScope();
+ const main_token = tree.nodeMainToken(container_scope.toNode());
+ const is_enum = tree.tokenTag(main_token) == .keyword_enum;
+
+ for (document_scope.getScopeDeclarationsConst(container_scope.scope)) |decl_index| {
+ if (len.* == out.len) return;
+ const decl = document_scope.declarations.get(@intFromEnum(decl_index));
+ const candidate: Analyser.DeclWithHandle = .{
+ .decl = decl,
+ .handle = handle,
+ .container_type = container_type,
+ };
+ if (handle != original_handle and !candidate.isPublic()) continue;
+
+ switch (decl) {
+ .ast_node => |node| switch (tree.nodeTag(node)) {
+ .container_field_init, .container_field_align, .container_field => {
+ if (is_enum) {
+ if (instance_access) continue;
+ const field_name = offsets.tokenToSlice(tree, tree.nodeMainToken(node));
+ if (std.mem.eql(u8, field_name, "_")) continue;
+ } else if (!instance_access) continue;
+ },
+ .fn_proto,
+ .fn_proto_multi,
+ .fn_proto_one,
+ .fn_proto_simple,
+ .fn_decl,
+ .global_var_decl,
+ .local_var_decl,
+ .simple_var_decl,
+ .aligned_var_decl,
+ => {
+ if (instance_access) {
+ const alias_type = try candidate.resolveType(analyser) orelse continue;
+ const func_ty = try analyser.resolveFuncProtoOfCallable(alias_type) orelse continue;
+ if (!analyser.firstParamIs(func_ty, .{
+ .data = .{ .container = info },
+ .is_type_val = true,
+ })) continue;
+ }
+ },
+ else => unreachable,
+ },
+ .label => continue,
+ else => {},
+ }
+ out[len.*] = candidate;
+ len.* += 1;
+ }
+}
+
/// Tab after a `.`. NOT an autocomplete popup: the seam answers with
/// locations, so the question it asks is "what could go here, and where is
/// each of those DEFINED" — one `+Search` row per candidate, pointing at its
@@ -796,7 +897,7 @@ fn completion(
try analyser.resolveDerefType(ty) orelse
try analyser.resolveOptionalUnwrap(ty) orelse break;
}
- // `instance_access` is collectDeclarationsOfContainer's one knob and it
+ // `instance_access` is the collector's one knob and it
// means different things per container kind: it is what hides an enum's
// members and what reveals a struct's fields. So: a field access asks
// whichever side the left-hand expression already was; a field named
@@ -806,12 +907,13 @@ fn completion(
// whose fields are also its tags, so it wants the instance side wherever
// it appears.
const value_pos = ctx == .enum_literal and !in_init;
- var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty;
- try analyser.collectDeclarationsOfContainer(ty, handle, switch (ctx) {
+ const decls = try arena.alloc(Analyser.DeclWithHandle, max_rows);
+ var decl_count: usize = 0;
+ try collectContainerDecls(analyser, ty, handle, switch (ctx) {
.field_access => !ty.is_type_val,
else => in_init or ty.isUnionType(),
- }, &decls);
- tr.note("{d} candidate(s) in scope", .{decls.items.len});
+ }, decls, &decl_count);
+ tr.note("{d} candidate(s) in scope", .{decl_count});
// What completionSource spliced in at `off`, which the ROWS have to take
// back out. The tree being read is the REPAIRED copy, so for a candidate
// declared in this same file two things are wrong with the naive row: its
@@ -824,7 +926,7 @@ fn completion(
const pad = tree.source.len - orig.len;
const dot = lsp.lineCol(orig, off);
var n: usize = 0;
- for (decls.items) |d| {
+ for (decls[0..decl_count]) |d| {
if (n >= max_rows) return;
// In a value position the answer is the container's MEMBERS plus its
// DECL LITERALS (`.empty`, `.init`) — never a method, an unrelated
@@ -959,10 +1061,9 @@ fn workspaceSymbols(
out: *std.Io.Writer,
) !void {
if (req.arg.len == 0) return;
- var files: std.ArrayList([]const u8) = .empty;
- try collectZigFiles(arena, io, req.root, &files);
+ const files = try collectZigFiles(arena, io, req.root);
var n: usize = 0;
- for (files.items) |path| {
+ for (files) |path| {
if (n >= max_rows) return;
const src = readFileZ(arena, io, path) catch continue;
var tree: Ast = Ast.parse(arena, src, .zig) catch continue;
@@ -1147,14 +1248,18 @@ fn treeDiagnostics(
const lines: Lines = try .build(arena, tree.source);
for (tree.errors) |e| {
if (n >= max_rows) return n;
- var buf: std.ArrayList(u8) = .empty;
- var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf);
- tree.renderError(e, &w.writer) catch continue;
+ var count_buf: [256]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&count_buf);
+ tree.renderError(e, &counting.writer) catch continue;
+ const size = std.math.cast(usize, counting.fullCount()) orelse continue;
+ const rendered = try arena.alloc(u8, size);
+ var w: std.Io.Writer = .fixed(rendered);
+ tree.renderError(e, &w) catch continue;
const at = tree.tokenStart(e.token);
const lc = lsp.lineCol(tree.source, at);
n += 1;
lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{
- w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"),
+ w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"),
}));
}
return n;
@@ -1187,17 +1292,16 @@ fn workspaceDiagnostics(
req: lsp.Req,
out: *std.Io.Writer,
) !void {
- var files: std.ArrayList([]const u8) = .empty;
- try collectZigFiles(arena, io, req.root, &files);
+ const files = try collectZigFiles(arena, io, req.root);
var total: usize = 0;
- for (files.items) |path| {
+ for (files) |path| {
const src = readFileZ(arena, io, path) catch continue;
var tree: Ast = Ast.parse(arena, src, .zig) catch continue;
defer tree.deinit(arena);
total += treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out) catch continue;
if (total >= max_rows) return;
}
- if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len}));
+ if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len}));
}
// ----------------------------------------------------------- code actions
@@ -1225,8 +1329,12 @@ fn codeActions(
var bundle = try astCheck(gpa, "", tree);
defer bundle.deinit(gpa);
+ // The external builder owns growable collections internally. Confine all
+ // of them and their payloads to one preallocated query-local region.
+ const action_storage = try arena.alloc(u8, max_code_action_bytes);
+ var action_fba: std.heap.FixedBufferAllocator = .init(action_storage);
var builder: zls.code_actions.Builder = .{
- .arena = arena,
+ .arena = action_fba.allocator(),
.analyser = analyser,
.handle = handle,
.offset_encoding = enc,
@@ -1236,7 +1344,7 @@ fn codeActions(
const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc);
builder.generateCodeActionsInRange(at) catch {};
- for (builder.actions.items) |a| {
+ for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| {
out.print("{s}\n", .{a.title}) catch return;
}
if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {};
@@ -1257,10 +1365,14 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi
lsp.row(out, path, 0, 0, "cannot format: file does not parse");
return;
}
- var buf: std.ArrayList(u8) = .empty;
- var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf);
- tree.render(arena, &w.writer, .{}) catch return;
- const formatted = w.written();
+ var count_buf: [4096]u8 = undefined;
+ var counting: std.Io.Writer.Discarding = .init(&count_buf);
+ tree.render(arena, &counting.writer, .{}) catch return;
+ const size = std.math.cast(usize, counting.fullCount()) orelse return;
+ const render_buf = try arena.alloc(u8, size);
+ var w: std.Io.Writer = .fixed(render_buf);
+ tree.render(arena, &w, .{}) catch return;
+ const formatted = w.buffered();
if (std.mem.eql(u8, formatted, req.source)) {
lsp.row(out, path, 0, 0, "already formatted");
return;
@@ -1287,13 +1399,15 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi
/// Every `.zig` under `root`, skipping the directories a source walk has no
/// business in. Capped, because this runs on a keypress.
-fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out: *std.ArrayList([]const u8)) !void {
- if (root.len == 0) return;
- var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return;
+fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]const []const u8 {
+ const files = try arena.alloc([]const u8, max_files);
+ var len: usize = 0;
+ if (root.len == 0) return files[0..0];
+ var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return files[0..0];
defer dir.close(io);
- var w = dir.walkSelectively(arena) catch return;
+ var w = dir.walkSelectively(arena) catch return files[0..0];
defer w.deinit();
- while (out.items.len < max_files) {
+ while (len < files.len) {
const e = (w.next(io) catch break) orelse break;
switch (e.kind) {
.directory => {
@@ -1304,12 +1418,13 @@ fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out:
},
.file => {
if (!std.mem.endsWith(u8, e.basename, ".zig")) continue;
- const p = std.fs.path.join(arena, &.{ root, e.path }) catch continue;
- try out.append(arena, p);
+ files[len] = std.fs.path.join(arena, &.{ root, e.path }) catch continue;
+ len += 1;
},
else => {},
}
}
+ return files[0..len];
}
fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 {