lsp: whole-program diagnostics from the real compiler on save

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.
This commit is contained in:
agra
2026-05-31 15:10:59 +03:00
parent b497b74acb
commit 8775ffa778
2 changed files with 134 additions and 3 deletions

View File

@@ -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);
}

View File

@@ -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\":{{" ++