//! ZLS as a LIBRARY, not a language server. //! //! There is no subprocess, no JSON-RPC, no `initialize` handshake and no //! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a //! token; the whole "protocol" is a struct field. ZLS's build.zig already //! publishes its guts as an importable module (`b.modules.put("zls", ...)`), //! and everything under `features/` that is not welded to `*Server` is a plain //! function over (analyser, arena, handle, offset) — so the parts we want are //! callable and the parts we don't want cost nothing. //! //! What that buys and what it costs: //! //! - Cold IS warm. There is no server to start, no workspace to index, no //! handshake to wait for. The first `gd` after launch pays for parsing this //! file and whatever it imports, and nothing else. //! - It also means NOTHING IS CACHED between queries. Every call builds a //! DocumentStore, resolves imports, and throws it all away, because `query` //! is handed an arena that dies on return and a `req` that is a snapshot of //! a buffer the user is still typing into. A cache would have to be a global //! with its own mutex and its own invalidation story; that is a real design, //! not a line of code, and it is the obvious next step rather than something //! smuggled in here. //! - `zig_exe_path` is null on purpose: shelling out to the compiler is the //! thing this backend exists to avoid. `zig_lib_dir` is the build's own //! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable — //! `gd` on `std.mem.count` lands in the real `mem.zig`. The price is that //! `@import("builtin")`, `@import("")` and `@cImport` resolve to //! nothing, silently, because those need a build graph we refuse to run. const std = @import("std"); const zls = @import("zls"); const lsp = @import("lsp.zig"); const cfg = @import("pardes_config"); const Analyser = zls.Analyser; const DocumentStore = zls.DocumentStore; const InternPool = zls.analyser.InternPool; const offsets = zls.offsets; const Uri = zls.Uri; const types = zls.lsp.types; const Ast = std.zig.Ast; /// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol /// says; we are not on a wire, and `+Search` rows are byte columns. const enc: offsets.Encoding = .@"utf-8"; /// Guards on the workspace walks (workspace symbols / workspace diagnostics). /// A language query runs on every keypress of `SPC S`; it may not turn into an /// unbounded tree walk because someone opened a file in `/`. const max_files = 512; const max_rows = 2000; pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{ .definition, .declaration, .type_definition, .implementation, .references, .select_refs, .hover, .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .rename, .format, .code_action, }); /// The seam's contract: never fail, never panic, no rows is a legal answer. /// Every error path in here — OOM, a cancelled io, a file that vanished — /// collapses to "appended nothing", which the core already treats as "no /// result". There is deliberately no error rendering. pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) void { run(gpa, arena, req, out) catch {}; } fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void { // The blocking, single-threaded std.Io — the same one look.zig walks // directories with. ZLS wants an `Io` for every file read; a worker thread // that is allowed to block wants the one that just does the syscall. const io = std.Io.Threaded.global_single_threaded.io(); // The two workspace kinds are about the PROJECT, not about this buffer, so // they answer from any pane and never need the analyser, the store or the // intern pool — a walk and a parse each. switch (req.kind) { .workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out), .workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out), // Everything else is about THIS file, and this backend speaks Zig. The // core does not gate the keymap by file type (a pane is a pane), so // the gate is here: `gd` in a README must find nothing rather than // parse prose as Zig and confidently resolve a word out of it. else => if (!std.mem.endsWith(u8, req.path, ".zig")) return, } // Pure tree, no analyser. if (req.kind == .format) return formatQuery(gpa, arena, req, out); var env: std.process.Environ.Map = .init(arena); var lib_dir: ?std.Build.Cache.Directory = null; if (zigLibPath()) |p| { if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { lib_dir = .{ .handle = h, .path = p }; } else |_| {} } defer if (lib_dir) |d| d.handle.close(io); var ip: InternPool = try .init(io, gpa); defer ip.deinit(gpa); var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa }; defer diags.deinit(); var store: DocumentStore = .{ .io = io, .allocator = gpa, .config = .{ .environ_map = &env, // no compiler subprocess: that is the entire point of this backend .zig_exe_path = null, .zig_lib_dir = lib_dir, .build_runner_path = null, .builtin_path = null, .global_cache_dir = null, .wasi_preopens = {}, }, .diagnostics_collection = &diags, }; defer store.deinit(); // The buffer the user is looking at, not the file on disk: `source` is the // shell's snapshot, so unsaved edits are what gets analysed. const uri: Uri = try .fromPath(arena, req.path); try store.openLspSyncedDocument(uri, req.source); const handle = store.getHandle(uri) orelse return; if (handle.tree.mode == .zon) return; var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); defer analyser.deinit(); const off: usize = @min(req.offset, req.source.len); switch (req.kind) { .definition, .implementation => try goto(gpa, arena, &analyser, handle, off, .definition, out), .declaration => try goto(gpa, arena, &analyser, handle, off, .declaration, out), .type_definition => try goto(gpa, arena, &analyser, handle, off, .type_definition, out), .hover => try hover(gpa, arena, &analyser, handle, off, out), .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out), .references, .select_refs => try references(gpa, arena, &analyser, handle, off, null, out), .rename => try references(gpa, arena, &analyser, handle, off, req.arg, out), .diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out), .code_action => try codeActions(gpa, arena, &analyser, handle, off, out), else => {}, } } /// The zig lib directory, which is the one thing this backend cannot work out /// for itself: it is baked in at build time from `b.graph.zig_lib_directory`, /// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if /// the user moved it. Without this, `gd` on anything in `std` finds nothing. fn zigLibPath() ?[]const u8 { if (std.c.getenv("ZIG_LIB_DIR")) |p| { const s = std.mem.span(p); if (s.len != 0) return s; } if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir; return null; } // ---------------------------------------------------------------- rows /// Byte offset -> line, without rescanning the file per row. Built once per /// query over the file we emit many rows from (symbols, references, /// diagnostics); a goto that emits two rows just scans. const Lines = struct { starts: []const u32, 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 }; } fn line(l: Lines, n: usize) []const u8 { if (n >= l.starts.len) return ""; const a = l.starts[n]; const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len; return l.src[a..@min(b, l.src.len)]; } }; /// The source line at a 0-based line number, scanned. For the one-or-two-row /// answers (a goto lands in a file we opened only to read one line out of). fn lineAt(src: []const u8, want: usize) []const u8 { var it = std.mem.splitScalar(u8, src, '\n'); var i: usize = 0; while (it.next()) |l| : (i += 1) if (i == want) return l; return ""; } /// Emit one row for a token in whatever file it lives in — which for a goto /// into `std` is not the file the cursor was in, so the path comes off the /// handle's uri rather than off `req`. fn rowForToken(gpa: std.mem.Allocator, arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.ArrayList(u8)) !void { const tree = &th.handle.tree; if (th.token >= tree.tokens.len) return; const r = offsets.tokenToRange(tree, th.token, enc); const path = th.handle.uri.toFsPath(arena) catch return; lsp.row(gpa, out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line)); } // ---------------------------------------------------------------- goto const GotoKind = enum { declaration, definition, type_definition }; /// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the /// linkSupport branch: dispatch on the position context, resolve to a decl, /// resolve the decl to a token, print the token's line. The five helix gotos /// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's /// own implementation handler does too). fn goto( gpa: std.mem.Allocator, arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, kind: GotoKind, out: *std.ArrayList(u8), ) !void { const tree = &handle.tree; const ctx = try Analyser.getPositionContext(arena, tree, off, true); var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty; switch (ctx) { .var_access, .test_doctest_name => { const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; const name = offsets.locToSlice(tree.source, name_loc); if (std.mem.eql(u8, name, "_")) return; if (std.zig.isPrimitive(name)) return; const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse return; try decls.append(arena, d); }, .field_access => |loc| { const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; const name = offsets.locToSlice(tree.source, name_loc); const held = offsets.locMerge(loc, name_loc); const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse return; try decls.appendSlice(arena, found); }, .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); }, .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); }, // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports // resolve — a package name needs the build graph we do not run. .import_string_literal, .embedfile_string_literal => { const loc = ctx.stringLiteralContentLoc(tree.source); if (loc.start == loc.end) return; const str = offsets.locToSlice(tree.source, loc); const res = try analyser.store.uriFromImportStr(arena, handle, str); switch (res) { .none => return, .one => |u| lsp.row(gpa, out, u.toFsPath(arena) catch return, 0, 0, str), .many => |us| for (us) |u| lsp.row(gpa, out, u.toFsPath(arena) catch continue, 0, 0, str), } return; }, else => return, } // 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| { switch (kind) { .declaration => try rowForToken(gpa, arena, d.definitionToken(analyser, false) catch continue, out), .definition => try rowForToken(gpa, arena, d.definitionToken(analyser, true) catch continue, out), .type_definition => { // peel error unions / pointers / optionals the way ZLS does, // then take the container's own name token if (try d.resolveType(analyser)) |ty| { var r = ty; while (true) { r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse try analyser.resolveDerefType(r) orelse try analyser.resolveOptionalUnwrap(r) orelse break; } if (r.typeDefinitionToken()) |th| { try rowForToken(gpa, arena, th, out); continue; } } const nd = try d.typeDeclarationNode() orelse continue; const t2 = &nd.handle.tree; const rr = offsets.nodeToRange(t2, nd.node, enc); const path = nd.handle.uri.toFsPath(arena) catch continue; lsp.row(gpa, out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line)); }, } } } // ---------------------------------------------------------------- hover /// `hover.hover` is already Server-free, so this is a call and a reformat: the /// `+Hover` buffer holds prose, not locations, so the markdown comes through /// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped /// because a plain-text pane has nothing to do with them. fn hover( gpa: std.mem.Allocator, arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, out: *std.ArrayList(u8), ) !void { const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return; const text = switch (h.contents) { .markup_content => |m| m.value, else => return, }; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |ln| { if (std.mem.startsWith(u8, ln, "```")) continue; out.print(gpa, "{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; } } // ---------------------------------------------------------------- symbols /// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding /// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest /// thing in this file: a parse and a walk. fn documentSymbols( gpa: std.mem.Allocator, arena: std.mem.Allocator, path: []const u8, tree: *const Ast, out: *std.ArrayList(u8), ) !void { const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc); var n: usize = 0; try emitSymbols(gpa, arena, path, syms, "", &n, out); } /// Depth-first so the rows read in source order, with the parent's name as a /// qualifier — `Kind.jumpsWhenSingle` rather than a bare `jumpsWhenSingle`, /// because a flat list of method names is not navigable. fn emitSymbols( gpa: std.mem.Allocator, arena: std.mem.Allocator, path: []const u8, syms: []const types.DocumentSymbol, prefix: []const u8, n: *usize, out: *std.ArrayList(u8), ) !void { for (syms) |s| { if (n.* >= max_rows) return; n.* += 1; const name = if (prefix.len == 0) s.name else try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); const text = if (s.detail) |d| try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) else name; lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, text); if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); } } /// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered /// by substring. ZLS's own workspace symbols would only see files already in /// the DocumentStore (which is one), so this walks the tree itself — and /// because `getDocumentSymbols` needs nothing but a tree, it never has to open /// a store or resolve an import to do it. fn workspaceSymbols( gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, req: lsp.Req, out: *std.ArrayList(u8), ) !void { if (req.arg.len == 0) return; var files: std.ArrayList([]const u8) = .empty; try collectZigFiles(arena, io, req.root, &files); var n: usize = 0; for (files.items) |path| { if (n >= max_rows) return; const src = readFileZ(arena, io, path) catch continue; var tree: Ast = Ast.parse(arena, src, .zig) catch continue; defer tree.deinit(arena); const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue; try filterSymbols(gpa, arena, path, syms, "", req.arg, &n, out); } } fn filterSymbols( gpa: std.mem.Allocator, arena: std.mem.Allocator, path: []const u8, syms: []const types.DocumentSymbol, prefix: []const u8, needle: []const u8, n: *usize, out: *std.ArrayList(u8), ) !void { for (syms) |s| { if (n.* >= max_rows) return; const name = if (prefix.len == 0) s.name else try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); if (containsIgnoreCase(s.name, needle)) { n.* += 1; lsp.row(gpa, out, path, s.selectionRange.start.line, s.selectionRange.start.character, name); } if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); } } fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { if (needle.len == 0) return true; if (needle.len > hay.len) return false; var i: usize = 0; outer: while (i + needle.len <= hay.len) : (i += 1) { for (needle, 0..) |c, j| if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer; return true; } return false; } // ------------------------------------------------------------- references /// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its /// `referencesHandler` takes a `*Server`, so this is the same algorithm from /// the outside: find the decl under the cursor, then offer every identifier /// token in the file that spells the same name back to the analyser and keep /// the ones that resolve to the same decl. Resolving rather than matching is /// what makes a shadowed local not a reference to the outer one. /// /// LIMIT: this file only. A workspace-wide answer means loading every project /// file into the store and running the analyser over each — seconds, not /// milliseconds, on every keypress, and the store's own workspace iteration /// has the same restriction (it can only see handles that were loaded). /// /// `new_name` non-null makes it a rename PREVIEW: the same rows, annotated /// with the replacement. The seam returns rows, not edits, so `SPC r` shows /// what would change and changes nothing — an honest half of rename. fn references( gpa: std.mem.Allocator, arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, new_name: ?[]const u8, out: *std.ArrayList(u8), ) !void { const tree = &handle.tree; const target = try declAt(arena, analyser, handle, off) orelse return; // The decl's name token indexes ITS OWN file, which after an alias resolve // is not necessarily this one — and on a half-typed file it is not // necessarily an identifier either, which `identifierTokenToNameSlice` // asserts. Both of those are crashes on a keystroke, so both are checked. const decl_tree = &target.handle.tree; const name_tok = target.nameToken(); if (name_tok >= decl_tree.tokens.len) return; if (decl_tree.tokenTag(name_tok) != .identifier) return; const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok); if (want.len == 0) return; const lines: Lines = try .build(arena, tree.source); var n: usize = 0; for (0..tree.tokens.len) |i| { if (n >= max_rows) return; const tok: Ast.TokenIndex = @intCast(i); if (tree.tokenTag(tok) != .identifier) continue; if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; const at = tree.tokenStart(tok); const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; if (!d.eql(target)) continue; n += 1; const r = offsets.tokenToRange(tree, tok, enc); const text = if (new_name) |nn| try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") }) else lines.line(r.start.line); lsp.row(gpa, out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text); } } /// The decl under a byte offset, whatever the surrounding syntax is. Shared by /// references and by rename, which is references with a label on it. fn declAt( arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, ) !?Analyser.DeclWithHandle { const tree = &handle.tree; const ctx = try Analyser.getPositionContext(arena, tree, off, true); const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null; const name = offsets.locToSlice(tree.source, name_loc); const d = switch (ctx) { .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off), .field_access => |loc| blk: { const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null; break :blk if (found.len != 0) found[0] else null; }, .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off), .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name), else => null, } orelse return null; return try analyser.resolveVarDeclAlias(d) orelse d; } // ------------------------------------------------------------ diagnostics /// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers /// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this /// process, which is the branch a subprocess-free backend wants, so that is /// what is inlined here (it is ten lines and needs a `*Server` only for the /// config it reads). /// /// A clean file emits ONE row saying so. Silence already means "no backend" in /// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that /// checked and found nothing has to be able to say the difference. fn diagnostics( gpa: std.mem.Allocator, arena: std.mem.Allocator, path: []const u8, tree: *const Ast, out: *std.ArrayList(u8), ) !void { const n = try treeDiagnostics(gpa, arena, path, tree, out); if (n == 0) lsp.row(gpa, out, path, 0, 0, "no diagnostics"); } /// `zig ast-check`, in this process. ZLS spawns the compiler for this when it /// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback /// is this backend's only branch. Caller owns the bundle. fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle { if (tree.errors.len != 0) return .empty; var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty; defer zir.deinit(gpa); if (!zir.hasCompileErrors()) return .empty; var wip: std.zig.ErrorBundle.Wip = undefined; try wip.init(gpa); defer wip.deinit(); wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty; return wip.toOwnedBundle("") catch .empty; } /// Parse errors first (AstGen cannot run on a tree that did not parse), then /// the AstGen pass. Returns how many rows were emitted. fn treeDiagnostics( gpa: std.mem.Allocator, arena: std.mem.Allocator, path: []const u8, tree: *const Ast, out: *std.ArrayList(u8), ) !usize { var n: usize = 0; if (tree.errors.len != 0) { 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; const at = tree.tokenStart(e.token); const lc = lsp.lineCol(tree.source, at); n += 1; lsp.row(gpa, 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"), })); } return n; } var bundle = try astCheck(gpa, path, tree); defer bundle.deinit(gpa); if (bundle.errorMessageCount() == 0) return n; for (bundle.getMessages()) |m| { if (n >= max_rows) return n; const em = bundle.getErrorMessage(m); if (em.src_loc == .none) continue; const sl = bundle.getSourceLocation(em.src_loc); n += 1; lsp.row(gpa, out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ bundle.nullTerminatedString(em.msg), })); } return n; } /// `SPC D`: the same check over every `.zig` under the pane's directory. No /// analyser and no store — AstGen is a per-file pass — so this is bounded by /// how fast Zig can parse the tree, which for pardes's own `src/` is tens of /// milliseconds. fn workspaceDiagnostics( gpa: std.mem.Allocator, arena: std.mem.Allocator, io: std.Io, req: lsp.Req, out: *std.ArrayList(u8), ) !void { var files: std.ArrayList([]const u8) = .empty; try collectZigFiles(arena, io, req.root, &files); var total: usize = 0; for (files.items) |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, path, &tree, out) catch continue; if (total >= max_rows) return; } if (total == 0) lsp.row(gpa, out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len})); } // ----------------------------------------------------------- code actions /// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants /// an arena, an analyser, a handle and an ErrorBundle — so the only missing /// piece is the bundle, which `astCheck` above produces without a compiler. /// /// It lists what could be done, and does none of it: the seam hands back rows, /// not edits, so there is no channel through which a backend could apply a /// fix. `+Lsp` is prose, so these are lines rather than locations. fn codeActions( gpa: std.mem.Allocator, arena: std.mem.Allocator, analyser: *Analyser, handle: *DocumentStore.Handle, off: usize, out: *std.ArrayList(u8), ) !void { const tree = &handle.tree; if (tree.errors.len != 0) { out.print(gpa, "no code actions: file does not parse\n", .{}) catch {}; return; } var bundle = try astCheck(gpa, "", tree); defer bundle.deinit(gpa); var builder: zls.code_actions.Builder = .{ .arena = arena, .analyser = analyser, .handle = handle, .offset_encoding = enc, .only_kinds = null, }; builder.generateCodeAction(bundle) catch {}; const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc); builder.generateCodeActionsInRange(at) catch {}; for (builder.actions.items) |a| { out.print(gpa, "{s}\n", .{a.title}) catch return; } if (builder.actions.items.len == 0) out.print(gpa, "no code actions\n", .{}) catch {}; } // ---------------------------------------------------------------- format /// `=`. The seam returns ROWS, not edits — there is no channel through which a /// backend can rewrite the buffer — so format reports rather than formats: one /// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the /// honest shape of "format" behind a read-only seam, and it is still the /// answer to the question the keypress asks. fn formatQuery(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.ArrayList(u8)) !void { var tree: Ast = try .parse(arena, req.source, .zig); defer tree.deinit(arena); if (tree.errors.len != 0) { lsp.row(gpa, out, req.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(); if (std.mem.eql(u8, formatted, req.source)) { lsp.row(gpa, out, req.path, 0, 0, "already formatted"); return; } var a = std.mem.splitScalar(u8, req.source, '\n'); var b = std.mem.splitScalar(u8, formatted, '\n'); var ln: usize = 0; var n: usize = 0; while (n < max_rows) : (ln += 1) { const x = a.next(); const y = b.next(); if (x == null and y == null) break; const xs = x orelse ""; const ys = y orelse ""; if (std.mem.eql(u8, xs, ys)) continue; n += 1; lsp.row(gpa, out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{ std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"), })); } } // ------------------------------------------------------------------ files /// 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; defer dir.close(io); var w = dir.walkSelectively(arena) catch return; defer w.deinit(); while (out.items.len < max_files) { const e = (w.next(io) catch break) orelse break; switch (e.kind) { .directory => { if (e.basename.len != 0 and e.basename[0] == '.') continue; if (std.mem.eql(u8, e.basename, "zig-out")) continue; if (std.mem.eql(u8, e.basename, "zig-cache")) continue; w.enter(io, e) catch {}; }, .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); }, else => {}, } } } fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); }