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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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\":{{" ++
|
||||
|
||||
Reference in New Issue
Block a user