From 8775ffa778a554fdec6b1cb0a50381c87539e523 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 31 May 2026 15:10:59 +0300 Subject: [PATCH] lsp: whole-program diagnostics from the real compiler on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the compiler's lowering pass instead of re-implementing its checks in sema. A module can't be lowered standalone — lowering only type-checks functions reachable from a root — so the open file alone misses errors like a *Move passed into a by-value method parameter. Drive the workspace entry (main.sx) through parse → resolveImports → lowerToIR, then attribute each diagnostic back to its file via source_file and publish per file (clearing files whose errors are gone). Runs on didOpen/didSave (disk-based); sema stays the live per-keystroke layer. Advertise textDocumentSync.save so the editor sends didSave. collectProjectDiagnostics is split out (transport-free) and covered by a hermetic temp-project test. --- src/lsp/server.zig | 135 ++++++++++++++++++++++++++++++++++++++++++++- src/lsp/types.zig | 2 +- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/lsp/server.zig b/src/lsp/server.zig index a80af1e..a969118 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -9,6 +9,7 @@ const sx = struct { pub const errors = @import("../errors.zig"); pub const imports = @import("../imports.zig"); pub const c_import = @import("../c_import.zig"); + pub const core = @import("../core.zig"); }; const lsp = @import("types.zig"); const doc_mod = @import("document.zig"); @@ -25,6 +26,9 @@ pub const Server = struct { shutdown_requested: bool = false, root_path: []const u8 = "", stdlib_paths: []const []const u8 = &.{}, + /// URIs the last whole-program check published diagnostics to, so the next + /// check can clear (publish empty for) files whose errors are now gone. + project_diag_uris: std.StringHashMap(void), pub fn init(allocator: std.mem.Allocator, transport: *Transport, io: std.Io, stdlib_paths: []const []const u8) Server { return .{ @@ -33,6 +37,7 @@ pub const Server = struct { .transport = transport, .io = io, .stdlib_paths = stdlib_paths, + .project_diag_uris = std.StringHashMap(void).init(allocator), }; } @@ -66,6 +71,8 @@ pub const Server = struct { if (params) |p| self.handleDidChange(p) catch |e| self.logError(method, e); } else if (std.mem.eql(u8, method, "textDocument/didClose")) { if (params) |p| self.handleDidClose(p); + } else if (std.mem.eql(u8, method, "textDocument/didSave")) { + self.runProjectCheck(); } else if (std.mem.eql(u8, method, "textDocument/definition")) { if (params) |p| self.handleDefinition(id, p) catch |e| self.logError(method, e); } else if (std.mem.eql(u8, method, "textDocument/references")) { @@ -152,6 +159,7 @@ pub const Server = struct { const version = jsonInt(jsonGet(td, "version") orelse return) orelse return; try self.analyzeAndPublish(uri, text, version); + self.runProjectCheck(); } fn handleDidChange(self: *Server, params: std.json.Value) !void { @@ -1948,6 +1956,87 @@ pub const Server = struct { try self.transport.writeMessage(body); } + const ProjectDiag = struct { + file_path: []const u8, + range: lsp.Range, + severity: u32, + message: []const u8, + }; + + /// Compile the whole program from `entry_path` and return its diagnostics, + /// each attributed (via `source_file`) to the file it belongs to. A single + /// module can't be lowered on its own — lowering only type-checks functions + /// reachable from a root — so we drive the entry and map results back. A + /// bundled help is folded into its primary's message. Pure of transport, so + /// it is unit-testable; empty on a parse failure or unreadable entry. + fn collectProjectDiagnostics(self: *Server, entry_path: []const u8) []const ProjectDiag { + const entry_doc = self.documents.getOrLoad(entry_path) catch return &.{}; + var comp = sx.core.Compilation.init(self.allocator, self.io, entry_path, entry_doc.source, .{}, self.stdlib_paths); + defer comp.deinit(); + comp.parse() catch return &.{}; + comp.resolveImports() catch {}; + _ = comp.lowerToIR() catch {}; + + var out = std.ArrayList(ProjectDiag).empty; + for (comp.diagnostics.items.items, 0..) |d, i| { + if (d.level == .note or d.level == .help) continue; + const span = d.span orelse continue; + const file_path = d.source_file orelse entry_path; + const src = comp.import_sources.get(file_path) orelse entry_doc.source; + var message = d.message; + for (comp.diagnostics.items.items) |h| { + if (h.level == .help and h.primary == i) { + message = std.fmt.allocPrint(self.allocator, "{s} — {s}", .{ message, h.message }) catch message; + break; + } + } + out.append(self.allocator, .{ + .file_path = file_path, + .range = spanToRange(src, span), + .severity = switch (d.level) { + .err => 1, + .warn => 2, + .note => 3, + .help => 4, + }, + .message = message, + }) catch {}; + } + return out.toOwnedSlice(self.allocator) catch &.{}; + } + + /// Drive the whole-program check from the workspace entry point and publish + /// the real compiler's diagnostics per file (runs on save; the sema layer + /// keeps live per-keystroke feedback). + fn runProjectCheck(self: *Server) void { + if (self.root_path.len == 0) return; + const entry_path = std.fmt.allocPrint(self.allocator, "{s}/main.sx", .{self.root_path}) catch return; + + var by_file = std.StringHashMap(std.ArrayList(lsp.Diagnostic)).init(self.allocator); + for (self.collectProjectDiagnostics(entry_path)) |pd| { + const gop = by_file.getOrPut(pd.file_path) catch continue; + if (!gop.found_existing) gop.value_ptr.* = std.ArrayList(lsp.Diagnostic).empty; + gop.value_ptr.append(self.allocator, .{ .range = pd.range, .severity = pd.severity, .message = pd.message }) catch {}; + } + + var new_uris = std.StringHashMap(void).init(self.allocator); + var it = by_file.iterator(); + while (it.next()) |entry| { + const uri = std.fmt.allocPrint(self.allocator, "file://{s}", .{entry.key_ptr.*}) catch continue; + self.sendDiagnostics(uri, entry.value_ptr.items) catch {}; + new_uris.put(uri, {}) catch {}; + } + // Clear any file that reported errors last time but doesn't now. + var pit = self.project_diag_uris.iterator(); + while (pit.next()) |entry| { + if (!new_uris.contains(entry.key_ptr.*)) { + self.sendDiagnostics(entry.key_ptr.*, &.{}) catch {}; + } + } + self.project_diag_uris.deinit(); + self.project_diag_uris = new_uris; + } + // ---- Symbol resolution helpers ---- /// Send a Location response for a symbol, resolving to the correct file via origin. @@ -3321,7 +3410,7 @@ test "lsp/references: a field's uses are found across documents" { const main_doc = try store.openOrUpdate("main.sx", main_src, 1); try store.analyzeDocument(main_doc); - var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io() }; + var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io(), .project_diag_uris = std.StringHashMap(void).init(alloc) }; // Cursor on the `flag` field definition in lib.sx. const flag_off: u32 = @intCast(std.mem.indexOf(u8, lib_src, "flag").?); @@ -3344,7 +3433,7 @@ test "lsp/references: excluding the declaration drops the definition" { const doc = try store.openOrUpdate("main.sx", src, 1); try store.analyzeDocument(doc); - var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io() }; + var server = Server{ .allocator = alloc, .documents = store, .transport = undefined, .io = test_io(), .project_diag_uris = std.StringHashMap(void).init(alloc) }; const flag_off: u32 = @intCast(std.mem.indexOf(u8, src, "flag").?); const with_decl = try server.referencesPayload(doc, &doc.sema.?, flag_off, true); @@ -3440,3 +3529,45 @@ test "lsp/workspace: loadWorkspaceFiles analyses .sx files that were never opene try std.testing.expect(a.sema != null); try std.testing.expect(b.sema != null); } + +test "lsp/project: whole-program check attributes a reachable error to its module" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = test_io(); + + const dir = ".sx-lsp-proj-test"; + std.Io.Dir.createDirPath(.cwd(), io, dir) catch {}; + defer { + std.Io.Dir.deleteFile(.cwd(), io, dir ++ "/main.sx") catch {}; + std.Io.Dir.deleteFile(.cwd(), io, dir ++ "/mod.sx") catch {}; + std.Io.Dir.deleteDir(.cwd(), io, dir) catch {}; + } + // `use` forwards a `*Move` into a by-value parameter — an error the compiler + // only sees because `main` calls `use` (reachability). Lowering mod.sx alone + // would miss it; the whole-program check catches it and pins it to mod.sx. + try std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = dir ++ "/mod.sx", .data = + "Move :: struct { flag: s64; }\n" ++ + "take :: (m: Move) -> s64 { return m.flag; }\n" ++ + "use :: (p: *Move) -> s64 { return take(p); }\n" }); + try std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = dir ++ "/main.sx", .data = + "#import \"mod.sx\";\n" ++ + "main :: () -> s32 { mv : Move = .{ flag = 1 }; return xx use(@mv); }\n" }); + + const store = doc_mod.DocumentStore.init(alloc, io, &.{}); + var server = Server{ + .allocator = alloc, + .documents = store, + .transport = undefined, + .io = io, + .project_diag_uris = std.StringHashMap(void).init(alloc), + }; + + var found = false; + for (server.collectProjectDiagnostics(dir ++ "/main.sx")) |d| { + if (std.mem.indexOf(u8, d.message, "expected here") != null and std.mem.endsWith(u8, d.file_path, "mod.sx")) { + found = true; + } + } + try std.testing.expect(found); +} diff --git a/src/lsp/types.zig b/src/lsp/types.zig index 112ce50..ebdf9f7 100644 --- a/src/lsp/types.zig +++ b/src/lsp/types.zig @@ -102,7 +102,7 @@ fn writeJsonValue(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, value: /// Build the initialize result JSON. pub fn initializeResultJson(allocator: std.mem.Allocator) ![]const u8 { return std.fmt.allocPrint(allocator, - "{{\"capabilities\":{{\"textDocumentSync\":1,\"definitionProvider\":true,\"referencesProvider\":true,\"hoverProvider\":true,\"documentSymbolProvider\":true," ++ + "{{\"capabilities\":{{\"textDocumentSync\":{{\"openClose\":true,\"change\":1,\"save\":true}},\"definitionProvider\":true,\"referencesProvider\":true,\"hoverProvider\":true,\"documentSymbolProvider\":true," ++ "\"completionProvider\":{{\"triggerCharacters\":[\".\"]}}," ++ "\"signatureHelpProvider\":{{\"triggerCharacters\":[\"(\",\",\"]}}," ++ "\"semanticTokensProvider\":{{\"legend\":{{" ++