Files
sx/src/lsp/server.zig
agra 9bf3dc75e6 lang F0.3: multi-message diagnostic bundling + help-blocks
Feature 0 complete. addNote/addHelp bundle notes and help-blocks under a
primary diagnostic (handle from new addId/addFmtId); help blocks carry an
optional fix-it line that substitutes the suggested source. renderExtended
now renders primary -> notes -> helps with blank-line separators.

Wire the CLI to the extended renderer (renderErrors -> renderStderr) and
flip render_style default to .extended; the previous renderErrors ->
renderDebug path bypassed render() entirely, so flipping the field alone
was a no-op. 13 diagnostic snapshots re-rendered to the extended format.
2026-05-29 09:36:53 +03:00

3125 lines
130 KiB
Zig

const std = @import("std");
const sx = struct {
pub const ast = @import("../ast.zig");
pub const parser = @import("../parser.zig");
pub const lexer = @import("../lexer.zig");
pub const token = @import("../token.zig");
pub const types = @import("../types.zig");
pub const sema = @import("../sema.zig");
pub const errors = @import("../errors.zig");
pub const imports = @import("../imports.zig");
pub const c_import = @import("../c_import.zig");
};
const lsp = @import("types.zig");
const doc_mod = @import("document.zig");
const DocumentStore = doc_mod.DocumentStore;
const Document = doc_mod.Document;
const Transport = @import("transport.zig").Transport;
const SemaResult = sx.sema.SemaResult;
pub const Server = struct {
allocator: std.mem.Allocator,
documents: DocumentStore,
transport: *Transport,
io: std.Io,
shutdown_requested: bool = false,
root_path: []const u8 = "",
stdlib_paths: []const []const u8 = &.{},
pub fn init(allocator: std.mem.Allocator, transport: *Transport, io: std.Io, stdlib_paths: []const []const u8) Server {
return .{
.allocator = allocator,
.documents = DocumentStore.init(allocator, io, stdlib_paths),
.transport = transport,
.io = io,
.stdlib_paths = stdlib_paths,
};
}
pub fn handleMessage(self: *Server, raw: []const u8) bool {
const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, raw, .{}) catch {
return true;
};
const root = parsed.value;
const method = jsonStr(jsonGet(root, "method") orelse return true) orelse return true;
const id = jsonGet(root, "id");
const params = jsonGet(root, "params");
if (std.mem.eql(u8, method, "initialize")) {
self.handleInitialize(id, params) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "initialized")) {
// Nothing to do
} else if (std.mem.eql(u8, method, "shutdown")) {
self.shutdown_requested = true;
if (id) |req_id| {
const id_json = lsp.valueToJson(self.allocator, req_id) catch return true;
const resp = lsp.jsonRpcResponse(self.allocator, id_json, "null") catch return true;
self.transport.writeMessage(resp) catch {};
}
} else if (std.mem.eql(u8, method, "exit")) {
return false;
} else if (std.mem.eql(u8, method, "textDocument/didOpen")) {
if (params) |p| self.handleDidOpen(p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/didChange")) {
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/definition")) {
if (params) |p| self.handleDefinition(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/hover")) {
if (params) |p| self.handleHover(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/documentSymbol")) {
if (params) |p| self.handleDocumentSymbol(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/completion")) {
if (params) |p| self.handleCompletion(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/signatureHelp")) {
if (params) |p| self.handleSignatureHelp(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/semanticTokens/full")) {
if (params) |p| self.handleSemanticTokens(id, p) catch |e| self.logError(method, e);
} else if (std.mem.eql(u8, method, "textDocument/inlayHint")) {
if (params) |p| self.handleInlayHint(id, p) catch |e| self.logError(method, e);
}
return true;
}
fn logError(self: *Server, method: []const u8, err: anyerror) void {
self.logMessage("lsp: {s} failed: {s}", .{ method, @errorName(err) });
}
fn logMessage(self: *Server, comptime fmt: []const u8, args: anytype) void {
const stderr = std.Io.File.stderr();
var buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch return;
stderr.writeStreamingAll(self.io, msg) catch {};
}
const RequestContext = struct {
id_json: []const u8,
uri: []const u8,
};
fn extractRequest(self: *Server, id: ?std.json.Value, params: std.json.Value) !?RequestContext {
const req_id = id orelse return null;
const id_json = try lsp.valueToJson(self.allocator, req_id);
const td = jsonGet(params, "textDocument") orelse return null;
const uri = jsonStr(jsonGet(td, "uri") orelse return null) orelse return null;
return .{ .id_json = id_json, .uri = uri };
}
fn extractPosition(params: std.json.Value) ?struct { line: u32, character: u32 } {
const position = jsonGet(params, "position") orelse return null;
const line = std.math.cast(u32, jsonInt(jsonGet(position, "line") orelse return null) orelse return null) orelse return null;
const character = std.math.cast(u32, jsonInt(jsonGet(position, "character") orelse return null) orelse return null) orelse return null;
return .{ .line = line, .character = character };
}
fn sendResponse(self: *Server, id_json: []const u8, result_json: []const u8) !void {
const resp = try lsp.jsonRpcResponse(self.allocator, id_json, result_json);
try self.transport.writeMessage(resp);
}
fn handleInitialize(self: *Server, id: ?std.json.Value, params: ?std.json.Value) !void {
// chdir to workspace root so relative paths in #import c work
chdir: {
const p = params orelse break :chdir;
const root_uri_val = jsonGet(p, "rootUri") orelse break :chdir;
const root_uri = jsonStr(root_uri_val) orelse break :chdir;
const prefix = "file://";
if (!std.mem.startsWith(u8, root_uri, prefix)) break :chdir;
const root_path = root_uri[prefix.len..];
self.root_path = self.allocator.dupe(u8, root_path) catch break :chdir;
self.documents.root_path = self.root_path;
const path_z = self.allocator.dupeZ(u8, root_path) catch break :chdir;
_ = std.c.chdir(path_z.ptr);
}
const req_id = id orelse return;
const id_json = try lsp.valueToJson(self.allocator, req_id);
const result_json = try lsp.initializeResultJson(self.allocator);
try self.sendResponse(id_json, result_json);
}
// ---- Document lifecycle ----
fn handleDidOpen(self: *Server, params: std.json.Value) !void {
const td = jsonGet(params, "textDocument") orelse return;
const uri = jsonStr(jsonGet(td, "uri") orelse return) orelse return;
const text = jsonStr(jsonGet(td, "text") orelse return) orelse return;
const version = jsonInt(jsonGet(td, "version") orelse return) orelse return;
try self.analyzeAndPublish(uri, text, version);
}
fn handleDidChange(self: *Server, params: std.json.Value) !void {
const td = jsonGet(params, "textDocument") orelse return;
const uri = jsonStr(jsonGet(td, "uri") orelse return) orelse return;
const version = jsonInt(jsonGet(td, "version") orelse return) orelse return;
const changes_arr = jsonArr(jsonGet(params, "contentChanges") orelse return) orelse return;
if (changes_arr.len == 0) return;
const last = changes_arr[changes_arr.len - 1];
const text = jsonStr(jsonGet(last, "text") orelse return) orelse return;
try self.analyzeAndPublish(uri, text, version);
}
fn handleDidClose(_: *Server, params: std.json.Value) void {
_ = params;
// Documents stay in the store (imports may reference them).
}
// ---- Go to definition ----
fn handleDefinition(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const pos = extractPosition(params) orelse return;
const id_json = ctx.id_json;
const uri = ctx.uri;
const file_path = uriToFilePath(uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "null");
};
const sema = doc.sema orelse doc.last_good_sema orelse {
return try self.sendResponse(id_json, "null");
};
const offset = positionToOffset(doc.source, pos.line, pos.character) orelse {
return try self.sendResponse(id_json, "null");
};
// 1. Qualified name (e.g. "std.print" or UFCS "list.append")
if (extractQualifiedName(doc.source, offset)) |qn| {
const qn_origin = sx.ast.Span{ .start = qn.full_start, .end = qn.full_end };
// Namespace import member
if (self.findImportByNs(doc, qn.ns)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
// C import source location: jump to the C header
if (imp_doc.c_source_locations.get(qn.member)) |cloc| {
if (try self.sendCSourceLocation(id_json, cloc, qn_origin, doc.source)) return;
}
// Single-file import
if (imp_doc.sema) |imp_sema| {
if (findSymbolByName(imp_sema.symbols, qn.member)) |si| {
const sym = imp_sema.symbols[si];
if (try self.sendSymbolLocationWithOrigin(id_json, imp_doc, sym, qn_origin)) return;
}
}
} else {
// Directory import: search all documents whose path starts with the import directory
const dir_prefix = try std.fmt.allocPrint(self.allocator, "{s}/", .{imp.path});
var it = self.documents.by_path.iterator();
while (it.next()) |entry| {
if (!std.mem.startsWith(u8, entry.key_ptr.*, dir_prefix)) continue;
const dir_doc = entry.value_ptr.*;
if (dir_doc.sema) |dir_sema| {
if (findSymbolByName(dir_sema.symbols, qn.member)) |si| {
const sym = dir_sema.symbols[si];
if (try self.sendSymbolLocationWithOrigin(id_json, dir_doc, sym, qn_origin)) return;
}
}
}
}
}
// UFCS: obj.method → find free function "method"
if (findSymbolByName(sema.symbols, qn.member)) |si| {
const sym = sema.symbols[si];
if (sym.kind == .function) {
if (try self.sendSymbolLocationWithOrigin(id_json, doc, sym, qn_origin)) return;
}
}
// Struct method/field: obj.method or Type.method or obj.field
if (try self.resolveStructMemberDef(id_json, sema, doc, qn.ns, qn.member, qn_origin)) return;
}
// 1b. Dot-shorthand: .method(args) — identifier preceded by dot with no qualifier
if (extractIdentAtOffset(doc.source, offset)) |name| {
const name_start = @as(u32, @intCast(@intFromPtr(name.ptr) - @intFromPtr(doc.source.ptr)));
if (name_start > 0 and doc.source[name_start - 1] == '.' and
(name_start < 2 or !isIdentChar(doc.source[name_start - 2])))
{
const name_end = name_start + @as(u32, @intCast(name.len));
const origin = sx.ast.Span{ .start = name_start, .end = name_end };
// Search all type declarations for a matching method/variant/field
for (sema.symbols) |sym| {
if (sym.kind != .struct_type and sym.kind != .enum_type) continue;
const lookup = self.findTypeDeclNode(sema, doc, sym.name) orelse continue;
if (try self.sendMemberLocation(id_json, lookup, name, doc, origin)) return;
}
}
}
// 2. Reference at offset → jump to definition
if (sx.sema.findReferenceAtOffset(sema.references, offset)) |ref_idx| {
const ref = sema.references[ref_idx];
if (ref.symbol_index < sema.symbols.len) {
const sym = sema.symbols[ref.symbol_index];
if (try self.sendSymbolLocationWithOrigin(id_json, doc, sym, ref.span)) return;
}
}
// 3. Symbol definition name at offset
if (findSymbolNameAtOffset(sema.symbols, doc.source, offset)) |sym_idx| {
const sym = sema.symbols[sym_idx];
if (try self.sendSymbolLocationWithOrigin(id_json, doc, sym, sym.def_span)) return;
}
// 4. #import "path" string → open the file (or directory)
if (findImportPathAtOffset(doc.source, offset)) |import_path| {
const base_dir = sx.imports.dirName(file_path);
const rp: ?[]const u8 = if (self.root_path.len > 0) self.root_path else null;
const resolved = try sx.imports.resolveImportPath(self.allocator, self.io, base_dir, import_path, rp, self.stdlib_paths);
// For directory imports, try to read as file first
if (std.Io.Dir.readFileAlloc(.cwd(), self.io, resolved, self.allocator, .limited(10 * 1024 * 1024))) |_| {
// It's a file — navigate to it
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{resolved});
const range = lsp.Range{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = 0, .character = 0 },
};
const loc_json = try lsp.locationJson(self.allocator, target_uri, range);
try self.sendResponse(id_json, loc_json);
return;
} else |_| {
// Might be a directory — no single file to navigate to
}
}
// 5. Fallback: identifier in string interpolation
if (extractIdentAtOffset(doc.source, offset)) |name| {
const name_start = @as(u32, @intCast(@intFromPtr(name.ptr) - @intFromPtr(doc.source.ptr)));
const is_qualified = name_start > 0 and doc.source[name_start - 1] == '.';
if (!is_qualified) {
if (findSymbolByName(sema.symbols, name)) |si| {
const sym = sema.symbols[si];
const name_end = name_start + @as(u32, @intCast(name.len));
const origin = sx.ast.Span{ .start = name_start, .end = name_end };
if (try self.sendSymbolLocationWithOrigin(id_json, doc, sym, origin)) return;
}
}
}
try self.sendResponse(id_json, "null");
}
// ---- Hover ----
fn handleHover(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const pos = extractPosition(params) orelse return;
const id_json = ctx.id_json;
const file_path = uriToFilePath(ctx.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "null");
};
const sema = doc.sema orelse {
return try self.sendResponse(id_json, "null");
};
const root = doc.root orelse {
return try self.sendResponse(id_json, "null");
};
const offset = positionToOffset(doc.source, pos.line, pos.character) orelse {
return try self.sendResponse(id_json, "null");
};
// Qualified name (e.g. std.print)
if (extractQualifiedName(doc.source, offset)) |qn| {
// Namespace member hover
if (self.findImportByNs(doc, qn.ns)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
if (imp_doc.root) |imp_root| {
if (findDeclByName(imp_root, qn.member)) |decl| {
const hover_text = try formatDeclHover(self.allocator, decl, imp_doc.source);
const hover_json = try lsp.hoverJson(self.allocator, hover_text);
return try self.sendResponse(id_json, hover_json);
}
}
}
}
// Struct field hover (e.g. point.x)
if (try self.formatStructFieldHover(doc, qn.ns, qn.member)) |hover_text| {
const hover_json = try lsp.hoverJson(self.allocator, hover_text);
return try self.sendResponse(id_json, hover_json);
}
}
// Enum variant hover
if (sx.sema.findNodeAtOffset(root, offset)) |node| {
if (node.data == .enum_literal) {
if (try self.formatEnumVariantHover(doc, node.data.enum_literal.name)) |hover_text| {
const hover_json = try lsp.hoverJson(self.allocator, hover_text);
return try self.sendResponse(id_json, hover_json);
}
}
}
// Find symbol via reference or definition name
var sym_idx: ?usize = null;
if (sx.sema.findReferenceAtOffset(sema.references, offset)) |ref_idx| {
const si = sema.references[ref_idx].symbol_index;
if (si < sema.symbols.len) sym_idx = si;
} else {
sym_idx = findSymbolNameAtOffset(sema.symbols, doc.source, offset);
}
// Fallback: identifier in string interpolation
if (sym_idx == null) {
if (extractIdentAtOffset(doc.source, offset)) |name| {
const name_start = @as(u32, @intCast(@intFromPtr(name.ptr) - @intFromPtr(doc.source.ptr)));
const is_qualified = name_start > 0 and doc.source[name_start - 1] == '.';
if (!is_qualified) {
sym_idx = findSymbolByName(sema.symbols, name);
}
}
}
if (sym_idx) |si| {
const sym = sema.symbols[si];
// Resolve the right source and root for hover formatting
const hover_doc = self.resolveSymbolDoc(doc, sym);
const hover_root = hover_doc.root orelse root;
const hover_text = try formatSymbolHover(self.allocator, sym, hover_root, hover_doc.source);
const hover_json = try lsp.hoverJson(self.allocator, hover_text);
return try self.sendResponse(id_json, hover_json);
}
try self.sendResponse(id_json, "null");
}
// ---- Document symbols ----
fn handleDocumentSymbol(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const id_json = ctx.id_json;
const file_path = uriToFilePath(ctx.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "[]");
};
const sema = doc.sema orelse {
return try self.sendResponse(id_json, "[]");
};
var doc_symbols = std.ArrayList(lsp.DocumentSymbol).empty;
for (sema.symbols) |sym| {
if (sym.scope_depth != 0) continue;
// Only show symbols defined in this file
if (sym.origin != null) continue;
const kind: u32 = switch (sym.kind) {
.function => @intFromEnum(lsp.SymbolKindLsp.Function),
.variable => @intFromEnum(lsp.SymbolKindLsp.Variable),
.constant => @intFromEnum(lsp.SymbolKindLsp.Constant),
.enum_type => @intFromEnum(lsp.SymbolKindLsp.Enum),
.struct_type => @intFromEnum(lsp.SymbolKindLsp.Struct),
.protocol_type => @intFromEnum(lsp.SymbolKindLsp.Interface),
.type_alias => @intFromEnum(lsp.SymbolKindLsp.Class),
.param => @intFromEnum(lsp.SymbolKindLsp.Variable),
.namespace => @intFromEnum(lsp.SymbolKindLsp.Namespace),
};
const range = spanToRange(doc.source, sym.def_span);
try doc_symbols.append(self.allocator, .{
.name = sym.name,
.kind = kind,
.range = range,
.selection_range = range,
});
}
const symbols_json = try lsp.documentSymbolsJson(self.allocator, doc_symbols.items);
try self.sendResponse(id_json, symbols_json);
}
// ---- Completion ----
fn handleCompletion(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const pos = extractPosition(params) orelse return;
const id_json = ctx.id_json;
const file_path = uriToFilePath(ctx.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "[]");
};
// Check if cursor is after a dot
if (positionToOffset(doc.source, pos.line, pos.character)) |off| {
var scan = off;
while (scan > 0 and isIdentChar(doc.source[scan - 1])) scan -= 1;
if (scan > 0 and doc.source[scan - 1] == '.') {
try self.handleDotCompletion(id_json, doc, scan);
return;
}
}
// Regular completion: all in-scope symbols + keywords
// Fall back to last successful analysis when current parse/analysis fails
// (common while user is mid-typing)
const sema = doc.sema orelse doc.last_good_sema orelse {
return try self.sendResponse(id_json, "[]");
};
var items = std.ArrayList(lsp.CompletionItem).empty;
for (sema.symbols) |sym| {
const kind: u32 = switch (sym.kind) {
.function => @intFromEnum(lsp.CompletionItemKind.Function),
.variable => @intFromEnum(lsp.CompletionItemKind.Variable),
.constant => @intFromEnum(lsp.CompletionItemKind.Constant),
.enum_type => @intFromEnum(lsp.CompletionItemKind.Enum),
.struct_type => @intFromEnum(lsp.CompletionItemKind.Struct),
.protocol_type => @intFromEnum(lsp.CompletionItemKind.Interface),
.type_alias => @intFromEnum(lsp.CompletionItemKind.Class),
.param => @intFromEnum(lsp.CompletionItemKind.Variable),
.namespace => @intFromEnum(lsp.CompletionItemKind.Module),
};
const detail = if (sym.ty) |ty| try ty.displayName(self.allocator) else null;
try items.append(self.allocator, .{
.label = sym.name,
.kind = kind,
.detail = detail,
});
}
const keywords = [_][]const u8{
"if", "else", "then", "return", "defer",
"case", "break", "enum", "struct", "true",
"false", "xx", "while", "continue",
"and", "or", "union",
};
const builtins = [_]struct { label: []const u8, detail: []const u8 }{
.{ .label = "type_of", .detail = "(val: $T) -> Type" },
.{ .label = "type_name", .detail = "($T: Type) -> string" },
.{ .label = "field_count", .detail = "($T: Type) -> s32" },
.{ .label = "field_name", .detail = "($T: Type, idx: s32) -> string" },
.{ .label = "field_value", .detail = "(s: $T, idx: s32) -> Any" },
.{ .label = "size_of", .detail = "($T: Type) -> s64" },
.{ .label = "align_of", .detail = "($T: Type) -> s64" },
.{ .label = "cast", .detail = "(Type) expr — prefix type cast" },
.{ .label = "malloc", .detail = "(size: s64) -> *void" },
.{ .label = "free", .detail = "(ptr: *void) -> void" },
.{ .label = "memcpy", .detail = "(dst: *void, src: *void, size: s64) -> *void" },
.{ .label = "memset", .detail = "(dst: *void, val: s64, size: s64) -> void" },
.{ .label = "sqrt", .detail = "(x: $T) -> T" },
};
for (&keywords) |kw| {
try items.append(self.allocator, .{
.label = kw,
.kind = @intFromEnum(lsp.CompletionItemKind.Keyword),
});
}
for (&builtins) |b| {
try items.append(self.allocator, .{
.label = b.label,
.kind = @intFromEnum(lsp.CompletionItemKind.Function),
.detail = b.detail,
});
}
const items_json = try lsp.completionItemsJson(self.allocator, items.items);
try self.sendResponse(id_json, items_json);
}
fn handleDotCompletion(self: *Server, id_json: []const u8, doc: *const Document, cursor_offset: u32) !void {
var items = std.ArrayList(lsp.CompletionItem).empty;
if (extractDotPrefix(doc.source, cursor_offset)) |prefix| {
if (doc.sema orelse doc.last_good_sema) |sema| {
// Check if prefix is a namespace — offer imported doc's declarations
if (self.findImportByNs(doc, prefix)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
if (imp_doc.root) |imp_root| {
if (imp_root.data == .root) {
try collectDeclCompletions(self.allocator, &items, imp_root.data.root.decls);
}
}
}
} else if (doc.root) |root| {
// Try as type name directly (e.g. Vec2., Color.)
try self.collectMemberCompletions(&items, sema, doc, prefix);
// Try as variable name — resolve to type and offer fields + UFCS methods
if (items.items.len == 0) {
if (resolveVariableType(sema, prefix)) |type_name| {
try self.collectMemberCompletions(&items, sema, doc, type_name);
try self.collectUfcsCompletions(&items, root, type_name);
}
}
}
}
// Fallback: try resolving as a match arm capture variable (works without current parse)
if (items.items.len == 0) {
try self.collectCaptureCompletions(&items, doc, cursor_offset, prefix);
}
} else {
// Bare dot (no prefix) — check if we're inside a match expression
// and offer the subject's enum variants (e.g. case .quit)
try self.collectMatchEnumCompletions(&items, doc, cursor_offset);
}
const items_json = try lsp.completionListJson(self.allocator, items.items);
try self.sendResponse(id_json, items_json);
}
fn collectMatchEnumCompletions(self: *Server, items: *std.ArrayList(lsp.CompletionItem), doc: *const Document, cursor_offset: u32) !void {
// Text-based approach: scan backward from cursor to find enclosing "SUBJECT == {"
// This works even when the file has parse errors (user is mid-typing)
const subject_name = findMatchSubjectText(doc.source, cursor_offset) orelse return;
// Use sema (current or last successful) to resolve the subject's type
const sema = doc.sema orelse doc.last_good_sema orelse return;
// Resolve subject name to an enum type
const enum_name = self.resolveExprEnumType(sema, subject_name) orelse return;
// Use enum variant names directly from sema symbols (works even without doc.root)
for (sema.symbols) |sym| {
if (!std.mem.eql(u8, sym.name, enum_name)) continue;
if (sym.kind != .enum_type) continue;
// Find the enum_decl node in the origin doc (or main doc)
const lookup_doc = if (sym.origin) |origin_path|
self.documents.get(origin_path)
else
null;
const lookup_root = if (lookup_doc) |ld| ld.root orelse doc.root else doc.root;
const root = lookup_root orelse continue;
if (sx.sema.findNodeAtOffset(root, sym.def_span.start)) |node| {
if (node.data == .enum_decl) {
for (node.data.enum_decl.variant_names) |variant| {
try items.append(self.allocator, .{
.label = variant,
.kind = @intFromEnum(lsp.CompletionItemKind.EnumMember),
});
}
}
}
break;
}
}
/// Scan backward through source text to find the subject of an enclosing match expression.
/// Looks for the pattern: SUBJECT == { ... case .
/// Returns the subject text (e.g. "event", "e.key") or null.
fn findMatchSubjectText(source: []const u8, cursor_offset: u32) ?[]const u8 {
var pos: u32 = cursor_offset;
var brace_depth: u32 = 0;
// Walk backward, tracking brace depth, looking for "== {"
while (pos > 2) {
pos -= 1;
const ch = source[pos];
if (ch == '}') {
brace_depth += 1;
} else if (ch == '{') {
if (brace_depth > 0) {
brace_depth -= 1;
} else {
// Found an unmatched '{' — check if preceded by "=="
var scan = pos;
while (scan > 0 and std.ascii.isWhitespace(source[scan - 1])) scan -= 1;
if (scan >= 2 and source[scan - 1] == '=' and source[scan - 2] == '=') {
// Found "== {" — extract the subject before "=="
var end = scan - 2;
while (end > 0 and std.ascii.isWhitespace(source[end - 1])) end -= 1;
if (end == 0) return null;
var start = end;
while (start > 0 and (isIdentChar(source[start - 1]) or source[start - 1] == '.')) {
start -= 1;
}
if (start < end) return source[start..end];
}
// Not a match expr — but keep scanning (might be a nested block)
}
}
}
return null;
}
/// Resolve a capture variable's struct type by scanning backward for `case .VARIANT: (name)`
/// and looking up the variant's payload type in the enum declaration.
fn collectCaptureCompletions(self: *Server, items: *std.ArrayList(lsp.CompletionItem), doc: *const Document, cursor_offset: u32, var_name: []const u8) !void {
const sema = doc.sema orelse doc.last_good_sema orelse return;
// Scan backward from cursor to find: case .VARIANT: (var_name)
const variant_name = findCaptureVariant(doc.source, cursor_offset, var_name) orelse return;
// Find the enclosing match subject
const subject_name = findMatchSubjectText(doc.source, cursor_offset) orelse return;
// Resolve subject to an enum type
const enum_name = self.resolveExprEnumType(sema, subject_name) orelse return;
// Find the enum_decl and look up the variant's payload type
for (sema.symbols) |sym| {
if (!std.mem.eql(u8, sym.name, enum_name)) continue;
if (sym.kind != .enum_type) continue;
const lookup_doc = if (sym.origin) |origin_path|
self.documents.get(origin_path)
else
null;
const lookup_root = if (lookup_doc) |ld| ld.root orelse doc.root else doc.root;
const root = lookup_root orelse continue;
if (sx.sema.findNodeAtOffset(root, sym.def_span.start)) |node| {
if (node.data == .enum_decl) {
const ed = node.data.enum_decl;
// Find the matching variant and its payload type
for (ed.variant_names, 0..) |vn, vi| {
if (!std.mem.eql(u8, vn, variant_name)) continue;
if (vi >= ed.variant_types.len) break;
const vt = ed.variant_types[vi] orelse break;
// The payload type should be a struct — get its name
const payload_type_name = if (vt.data == .type_expr) vt.data.type_expr.name else break;
// Now offer that struct's fields
const payload_sema = if (lookup_doc) |ld| ld.sema orelse sema else sema;
const payload_doc = if (lookup_doc) |ld| ld else doc;
try self.collectMemberCompletions(items, payload_sema, payload_doc, payload_type_name);
return;
}
}
}
break;
}
}
/// Scan backward from cursor to find `case .VARIANT: (var_name)` and return VARIANT.
fn findCaptureVariant(source: []const u8, cursor_offset: u32, var_name: []const u8) ?[]const u8 {
// Look backward for pattern: case .VARIANT: (var_name)
// We search for "(var_name)" first, then look for "case .VARIANT:" before it
var pos: u32 = cursor_offset;
while (pos > var_name.len + 10) { // need room for "case .X: (name)"
pos -= 1;
// Look for the closing ) of a capture
if (source[pos] != ')') continue;
// Check if the capture name matches
if (pos < var_name.len + 1) continue;
const name_end = pos;
const name_start = pos - @as(u32, @intCast(var_name.len));
if (!std.mem.eql(u8, source[name_start..name_end], var_name)) continue;
// Check for ( before the name
if (name_start < 1 or source[name_start - 1] != '(') continue;
// Now scan backward from '(' to find ": " then ".VARIANT" then "case "
var scan = name_start - 1;
// Skip whitespace
while (scan > 0 and std.ascii.isWhitespace(source[scan - 1])) scan -= 1;
// Expect ':'
if (scan < 1 or source[scan - 1] != ':') continue;
scan -= 1;
// Now extract the variant name: scan backward for ".VARIANT"
// Skip whitespace before ':'
// The variant is an enum literal like .key_down
// Actually the ':' comes right after the variant value (explicit) or variant name
// Pattern: case .VARIANT: (name) OR case .VARIANT :: 0x300: PayloadType;... nah
// In the match arm: case .key_down: (e) {
// So before ':' we have the enum literal .key_down
while (scan > 0 and std.ascii.isWhitespace(source[scan - 1])) scan -= 1;
// Extract identifier (variant name)
const vend = scan;
while (scan > 0 and (isIdentChar(source[scan - 1]))) scan -= 1;
if (scan >= vend) continue;
const variant = source[scan..vend];
// Check for '.' before variant name
if (scan < 1 or source[scan - 1] != '.') continue;
return variant;
}
return null;
}
/// Resolve a text expression like "event" or "e.key" to an enum/union type name.
fn resolveExprEnumType(self: *Server, sema: SemaResult, expr: []const u8) ?[]const u8 {
// Simple identifier: look up variable type
if (std.mem.indexOfScalar(u8, expr, '.') == null) {
for (sema.symbols) |sym| {
if (!std.mem.eql(u8, sym.name, expr)) continue;
if (sym.kind != .variable and sym.kind != .param) continue;
const ty = sym.ty orelse return null;
return switch (ty) {
.enum_type => |n| n,
.union_type => |n| n,
else => null,
};
}
return null;
}
// Field access: "var.field" — resolve var's struct type, then look up field
if (std.mem.indexOfScalar(u8, expr, '.')) |dot| {
const var_name = expr[0..dot];
const field_name = expr[dot + 1 ..];
for (sema.symbols) |sym| {
if (!std.mem.eql(u8, sym.name, var_name)) continue;
const ty = sym.ty orelse return null;
const struct_name = switch (ty) {
.struct_type => |n| n,
else => return null,
};
if (sema.struct_types.get(struct_name)) |info| {
for (info.field_names, 0..) |fname, fi| {
if (std.mem.eql(u8, fname, field_name) and fi < info.field_types.len) {
return switch (info.field_types[fi]) {
.enum_type => |n| n,
.union_type => |n| n,
else => null,
};
}
}
}
// Also check imported docs for struct info
if (sym.origin) |origin_path| {
if (self.documents.get(origin_path)) |od| {
if (od.sema) |imp_sema| {
if (imp_sema.struct_types.get(struct_name)) |info| {
for (info.field_names, 0..) |fname, fi| {
if (std.mem.eql(u8, fname, field_name) and fi < info.field_types.len) {
return switch (info.field_types[fi]) {
.enum_type => |n| n,
.union_type => |n| n,
else => null,
};
}
}
}
}
}
}
return null;
}
}
return null;
}
fn collectDeclCompletions(allocator: std.mem.Allocator, items: *std.ArrayList(lsp.CompletionItem), decls: []const *sx.ast.Node) !void {
for (decls) |decl| {
switch (decl.data) {
.fn_decl => |fd| {
var detail_buf = std.ArrayList(u8).empty;
try detail_buf.append(allocator, '(');
for (fd.params, 0..) |param, pi| {
if (pi > 0) try detail_buf.appendSlice(allocator, ", ");
try detail_buf.appendSlice(allocator, param.name);
if (param.type_expr.data != .inferred_type) {
try detail_buf.appendSlice(allocator, ": ");
if (param.type_expr.data == .type_expr) {
try detail_buf.appendSlice(allocator, param.type_expr.data.type_expr.name);
} else {
try detail_buf.appendSlice(allocator, "?");
}
}
}
try detail_buf.append(allocator, ')');
if (fd.return_type) |rt| {
try detail_buf.appendSlice(allocator, " -> ");
if (rt.data == .type_expr) {
try detail_buf.appendSlice(allocator, rt.data.type_expr.name);
}
}
try items.append(allocator, .{
.label = fd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Function),
.detail = detail_buf.items,
});
},
.const_decl => |cd| {
const kind: u32 = if (cd.value.data == .lambda)
@intFromEnum(lsp.CompletionItemKind.Function)
else
@intFromEnum(lsp.CompletionItemKind.Constant);
try items.append(allocator, .{
.label = cd.name,
.kind = kind,
});
},
.enum_decl => |ed| {
try items.append(allocator, .{
.label = ed.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Enum),
});
},
.struct_decl => |sd| {
try items.append(allocator, .{
.label = sd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Struct),
});
},
.union_decl => |ud| {
try items.append(allocator, .{
.label = ud.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Struct),
});
},
.protocol_decl => |pd| {
try items.append(allocator, .{
.label = pd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Interface),
});
},
.var_decl => |vd| {
try items.append(allocator, .{
.label = vd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Variable),
});
},
else => {},
}
}
}
fn collectMemberCompletions(self: *Server, items: *std.ArrayList(lsp.CompletionItem), sema: SemaResult, doc: *const Document, name: []const u8) !void {
const lookup = self.findTypeDeclNode(sema, doc, name) orelse return;
const node = lookup.node;
if (node.data == .struct_decl) {
const sd = node.data.struct_decl;
for (sd.field_names, 0..) |field_name, fi| {
const detail: ?[]const u8 = if (fi < sd.field_types.len) blk: {
const ft = sd.field_types[fi];
break :blk if (ft.data == .type_expr) ft.data.type_expr.name else null;
} else null;
try items.append(self.allocator, .{
.label = field_name,
.kind = @intFromEnum(lsp.CompletionItemKind.Field),
.detail = detail,
});
}
for (sd.methods) |method_node| {
if (method_node.data == .fn_decl) {
try items.append(self.allocator, .{
.label = method_node.data.fn_decl.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Method),
});
}
}
} else if (node.data == .enum_decl) {
const ed = node.data.enum_decl;
for (ed.variant_names) |variant| {
try items.append(self.allocator, .{
.label = variant,
.kind = @intFromEnum(lsp.CompletionItemKind.EnumMember),
});
}
} else if (node.data == .protocol_decl) {
const pd = node.data.protocol_decl;
for (pd.methods) |method| {
// Build detail string: (params) -> ret
var detail_buf = std.ArrayList(u8).empty;
try detail_buf.append(self.allocator, '(');
for (method.param_names, 0..) |pname, pi| {
if (pi > 0) try detail_buf.appendSlice(self.allocator, ", ");
try detail_buf.appendSlice(self.allocator, pname);
if (pi < method.params.len) {
try detail_buf.appendSlice(self.allocator, ": ");
if (method.params[pi].data == .type_expr) {
try detail_buf.appendSlice(self.allocator, method.params[pi].data.type_expr.name);
}
}
}
try detail_buf.append(self.allocator, ')');
if (method.return_type) |rt| {
try detail_buf.appendSlice(self.allocator, " -> ");
if (rt.data == .type_expr) {
try detail_buf.appendSlice(self.allocator, rt.data.type_expr.name);
}
}
try items.append(self.allocator, .{
.label = method.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Method),
.detail = detail_buf.items,
});
}
}
}
// ---- Signature help ----
fn handleSignatureHelp(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const req = try self.extractRequest(id, params) orelse return;
const pos = extractPosition(params) orelse return;
const id_json = req.id_json;
const file_path = uriToFilePath(req.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "null");
};
const offset = positionToOffset(doc.source, pos.line, pos.character) orelse {
return try self.sendResponse(id_json, "null");
};
const call_ctx = findCallContext(doc.source, offset) orelse {
return try self.sendResponse(id_json, "null");
};
// Built-in function signatures
const builtin_sigs = [_]struct { name: []const u8, label: []const u8, params: []const []const u8 }{
.{ .name = "type_of", .label = "type_of(val: $T) -> Type", .params = &.{"val: $T"} },
.{ .name = "type_name", .label = "type_name($T: Type) -> string", .params = &.{"$T: Type"} },
.{ .name = "field_count", .label = "field_count($T: Type) -> s32", .params = &.{"$T: Type"} },
.{ .name = "field_name", .label = "field_name($T: Type, idx: s32) -> string", .params = &.{ "$T: Type", "idx: s32" } },
.{ .name = "field_value", .label = "field_value(s: $T, idx: s32) -> Any", .params = &.{ "s: $T", "idx: s32" } },
.{ .name = "size_of", .label = "size_of($T: Type) -> s64", .params = &.{"$T: Type"} },
.{ .name = "align_of", .label = "align_of($T: Type) -> s64", .params = &.{"$T: Type"} },
.{ .name = "cast", .label = "cast(Type) expr", .params = &.{"Type"} },
.{ .name = "malloc", .label = "malloc(size: s64) -> *void", .params = &.{"size: s64"} },
.{ .name = "free", .label = "free(ptr: *void) -> void", .params = &.{"ptr: *void"} },
.{ .name = "memcpy", .label = "memcpy(dst: *void, src: *void, size: s64) -> *void", .params = &.{ "dst: *void", "src: *void", "size: s64" } },
.{ .name = "memset", .label = "memset(dst: *void, val: s64, size: s64) -> void", .params = &.{ "dst: *void", "val: s64", "size: s64" } },
.{ .name = "sqrt", .label = "sqrt(x: $T) -> T", .params = &.{"x: $T"} },
.{ .name = "print", .label = "print(fmt: string, args: ..Any)", .params = &.{ "fmt: string", "args: ..Any" } },
.{ .name = "out", .label = "out(str: string) -> void", .params = &.{"str: string"} },
};
for (&builtin_sigs) |b| {
const matches = std.mem.eql(u8, call_ctx.name, b.name) or
(std.mem.startsWith(u8, call_ctx.name, "std.") and std.mem.eql(u8, call_ctx.name[4..], b.name));
if (matches) {
const sig_json = try lsp.signatureHelpJson(self.allocator, b.label, b.params, call_ctx.active_param);
return try self.sendResponse(id_json, sig_json);
}
}
// Look up function declaration
const fn_node = self.findFnDeclByName(doc, call_ctx.name);
if (fn_node) |fd| {
var label_buf = std.ArrayList(u8).empty;
try label_buf.appendSlice(self.allocator, fd.name);
try label_buf.append(self.allocator, '(');
var param_labels = std.ArrayList([]const u8).empty;
for (fd.params, 0..) |param, pi| {
if (pi > 0) try label_buf.appendSlice(self.allocator, ", ");
const param_start = label_buf.items.len;
try label_buf.appendSlice(self.allocator, param.name);
if (param.type_expr.data != .inferred_type) {
try label_buf.appendSlice(self.allocator, ": ");
if (param.type_expr.data == .type_expr) {
try label_buf.appendSlice(self.allocator, param.type_expr.data.type_expr.name);
} else {
try label_buf.appendSlice(self.allocator, "?");
}
}
const param_label = try self.allocator.dupe(u8, label_buf.items[param_start..]);
try param_labels.append(self.allocator, param_label);
}
try label_buf.append(self.allocator, ')');
if (fd.return_type) |rt| {
try label_buf.appendSlice(self.allocator, " -> ");
if (rt.data == .type_expr) {
try label_buf.appendSlice(self.allocator, rt.data.type_expr.name);
}
}
const sig_json = try lsp.signatureHelpJson(self.allocator, label_buf.items, param_labels.items, call_ctx.active_param);
return try self.sendResponse(id_json, sig_json);
}
try self.sendResponse(id_json, "null");
}
// ---- Semantic tokens ----
fn handleSemanticTokens(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const id_json = ctx.id_json;
const file_path = uriToFilePath(ctx.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "{\"data\":[]}");
};
const sema = doc.sema orelse {
return try self.sendResponse(id_json, "{\"data\":[]}");
};
var data = std.ArrayList(u32).empty;
var prev_line: u32 = 0;
var prev_char: u32 = 0;
var lexer = sx.lexer.Lexer.init(doc.source);
while (true) {
const tok = lexer.next();
if (tok.tag == .eof) break;
if (tok.tag == .string_literal or tok.tag == .raw_string_literal) {
try emitStringParts(&data, self.allocator, doc.source, tok.loc.start, tok.loc.end, &prev_line, &prev_char);
continue;
}
const token_type = classifyToken(tok, sema, doc.source) orelse continue;
try emitToken(&data, self.allocator, doc.source, tok.loc.start, tok.loc.end, token_type, &prev_line, &prev_char);
}
const result_json = try lsp.semanticTokensJson(self.allocator, data.items);
try self.sendResponse(id_json, result_json);
}
// ---- Inlay hints ----
fn handleInlayHint(self: *Server, id: ?std.json.Value, params: std.json.Value) !void {
const ctx = try self.extractRequest(id, params) orelse return;
const id_json = ctx.id_json;
const file_path = uriToFilePath(ctx.uri) orelse "";
const doc = self.documents.get(file_path) orelse {
return try self.sendResponse(id_json, "[]");
};
const sema = doc.sema orelse doc.last_good_sema orelse {
return try self.sendResponse(id_json, "[]");
};
const root = doc.root orelse {
return try self.sendResponse(id_json, "[]");
};
var hints = std.ArrayList(lsp.InlayHint).empty;
collectInlayHints(self.allocator, root, sema.symbols, sema.fn_signatures, doc.source, &hints);
self.collectCallHints(doc, root, &hints);
const result_json = try lsp.inlayHintsJson(self.allocator, hints.items);
try self.sendResponse(id_json, result_json);
}
fn collectInlayHints(
allocator: std.mem.Allocator,
node: *const sx.ast.Node,
symbols: []const sx.sema.Symbol,
fn_signatures: std.StringHashMap(sx.sema.FnSignature),
source: [:0]const u8,
hints: *std.ArrayList(lsp.InlayHint),
) void {
switch (node.data) {
.root => |r| {
for (r.decls) |decl| collectInlayHints(allocator, decl, symbols, fn_signatures, source, hints);
},
.block => |b| {
for (b.stmts) |stmt| collectInlayHints(allocator, stmt, symbols, fn_signatures, source, hints);
},
.fn_decl => |fd| {
collectInlayHints(allocator, fd.body, symbols, fn_signatures, source, hints);
// Return type hint for arrow functions without explicit return type
if (fd.return_type == null and fd.is_arrow) {
if (fn_signatures.get(fd.name)) |sig| {
if (sig.return_type != .void_type) {
addReturnTypeHint(allocator, node.span, source, sig.return_type, hints);
}
}
}
},
.lambda => |lm| {
collectInlayHints(allocator, lm.body, symbols, fn_signatures, source, hints);
},
.if_expr => |ie| {
if (ie.binding_name) |bname| {
addBindingHint(allocator, bname, node.span, symbols, source, hints);
}
collectInlayHints(allocator, ie.then_branch, symbols, fn_signatures, source, hints);
if (ie.else_branch) |eb| collectInlayHints(allocator, eb, symbols, fn_signatures, source, hints);
},
.while_expr => |we| {
if (we.binding_name) |bname| {
addBindingHint(allocator, bname, node.span, symbols, source, hints);
}
collectInlayHints(allocator, we.body, symbols, fn_signatures, source, hints);
},
.for_expr => |fe| {
collectInlayHints(allocator, fe.body, symbols, fn_signatures, source, hints);
},
.var_decl => |vd| {
// Only show hint when type is inferred (:= syntax)
if (vd.type_annotation != null) return;
if (vd.value == null) return;
addHintForDecl(allocator, vd.name, node.span, symbols, source, hints, true);
},
.const_decl => |cd| {
// Skip if explicit type annotation
if (cd.type_annotation != null) return;
// Handle lambda with return type hint
if (cd.value.data == .lambda) {
const lam = cd.value.data.lambda;
collectInlayHints(allocator, lam.body, symbols, fn_signatures, source, hints);
if (lam.return_type == null) {
if (fn_signatures.get(cd.name)) |sig| {
if (sig.return_type != .void_type) {
addReturnTypeHint(allocator, cd.value.span, source, sig.return_type, hints);
}
}
}
return;
}
// Skip functions, types, structs, enums, unions, comptime, foreign, library
switch (cd.value.data) {
.fn_decl, .type_expr, .struct_decl, .enum_decl, .union_decl,
.comptime_expr, .foreign_expr, .library_decl,
=> return,
else => {},
}
addHintForDecl(allocator, cd.name, node.span, symbols, source, hints, false);
},
else => {},
}
}
fn addHintForDecl(
allocator: std.mem.Allocator,
name: []const u8,
span: sx.ast.Span,
symbols: []const sx.sema.Symbol,
source: [:0]const u8,
hints: *std.ArrayList(lsp.InlayHint),
is_colon_equal: bool,
) void {
// Find symbol by matching span start
const sym = findSymbolAtSpan(symbols, span.start, name) orelse return;
const ty = sym.ty orelse return;
// Skip void types — not useful to display
if (ty == .void_type) return;
const type_name = ty.displayName(allocator) catch return;
if (is_colon_equal) {
// For `:=` declarations: place hint between `:` and `=`
// Scan from after the name to find `:=`
var pos = span.start + @as(u32, @intCast(name.len));
while (pos + 1 < source.len) : (pos += 1) {
if (source[pos] == ':' and source[pos + 1] == '=') {
// Place hint at the `=` position (between `:` and `=`)
const eq_offset = pos + 1;
const loc = sx.errors.SourceLoc.compute(source, eq_offset);
if (loc.line == 0 or loc.col == 0) return;
hints.append(allocator, .{
.line = loc.line - 1,
.character = loc.col - 1,
.label = type_name,
.padding_left = true,
.padding_right = true,
}) catch {};
return;
}
}
} else {
// For `::` declarations: place hint between first `:` and second `:`
var pos = span.start + @as(u32, @intCast(name.len));
while (pos + 1 < source.len) : (pos += 1) {
if (source[pos] == ':' and source[pos + 1] == ':') {
const second_colon = pos + 1;
const loc = sx.errors.SourceLoc.compute(source, second_colon);
if (loc.line == 0 or loc.col == 0) return;
hints.append(allocator, .{
.line = loc.line - 1,
.character = loc.col - 1,
.label = type_name,
.padding_left = true,
.padding_right = true,
}) catch {};
return;
}
}
}
}
fn addBindingHint(
allocator: std.mem.Allocator,
name: []const u8,
span: sx.ast.Span,
symbols: []const sx.sema.Symbol,
source: [:0]const u8,
hints: *std.ArrayList(lsp.InlayHint),
) void {
// Look up symbol by name + span (sema stores binding with if/while node span)
const sym = findSymbolAtSpan(symbols, span.start, name) orelse return;
const ty = sym.ty orelse return;
if (ty == .void_type) return;
const type_name = ty.displayName(allocator) catch return;
// Scan from span start to find the `:=` used in the binding
var pos = span.start;
while (pos + 1 < source.len) : (pos += 1) {
if (source[pos] == ':' and source[pos + 1] == '=') {
const eq_offset = pos + 1;
const loc = sx.errors.SourceLoc.compute(source, eq_offset);
if (loc.line == 0 or loc.col == 0) return;
hints.append(allocator, .{
.line = loc.line - 1,
.character = loc.col - 1,
.label = type_name,
.padding_left = true,
.padding_right = true,
}) catch {};
return;
}
}
}
fn addReturnTypeHint(
allocator: std.mem.Allocator,
span: sx.ast.Span,
source: [:0]const u8,
return_type: sx.types.Type,
hints: *std.ArrayList(lsp.InlayHint),
) void {
// Find '(' from span start
var pos: u32 = span.start;
while (pos < source.len and source[pos] != '(') : (pos += 1) {}
if (pos >= source.len) return;
// Match nested parens to find closing ')'
var depth: u32 = 0;
while (pos < source.len) : (pos += 1) {
if (source[pos] == '(') {
depth += 1;
} else if (source[pos] == ')') {
depth -= 1;
if (depth == 0) break;
}
}
if (pos >= source.len or depth != 0) return;
// Place hint right after ')'
const loc = sx.errors.SourceLoc.compute(source, pos + 1);
if (loc.line == 0 or loc.col == 0) return;
const type_name = return_type.displayName(allocator) catch return;
const label = std.fmt.allocPrint(allocator, "-> {s}", .{type_name}) catch return;
hints.append(allocator, .{
.line = loc.line - 1,
.character = loc.col - 1,
.label = label,
.kind = 1,
.padding_left = true,
.padding_right = true,
}) catch {};
}
fn findSymbolAtSpan(symbols: []const sx.sema.Symbol, span_start: u32, name: []const u8) ?sx.sema.Symbol {
for (symbols) |sym| {
if (sym.def_span.start == span_start and std.mem.eql(u8, sym.name, name)) {
return sym;
}
}
return null;
}
// ---- Parameter name hints at call sites ----
fn collectCallHints(self: *Server, doc: *const Document, node: *const sx.ast.Node, hints: *std.ArrayList(lsp.InlayHint)) void {
switch (node.data) {
.root => |r| {
for (r.decls) |decl| self.collectCallHints(doc, decl, hints);
},
.block => |b| {
for (b.stmts) |stmt| self.collectCallHints(doc, stmt, hints);
},
.fn_decl => |fd| {
self.collectCallHints(doc, fd.body, hints);
},
.lambda => |lm| {
self.collectCallHints(doc, lm.body, hints);
},
.if_expr => |ie| {
self.collectCallHints(doc, ie.condition, hints);
self.collectCallHints(doc, ie.then_branch, hints);
if (ie.else_branch) |eb| self.collectCallHints(doc, eb, hints);
},
.while_expr => |we| {
self.collectCallHints(doc, we.condition, hints);
self.collectCallHints(doc, we.body, hints);
},
.for_expr => |fe| {
self.collectCallHints(doc, fe.iterable, hints);
self.collectCallHints(doc, fe.body, hints);
},
.var_decl => |vd| {
if (vd.value) |val| self.collectCallHints(doc, val, hints);
},
.const_decl => |cd| {
self.collectCallHints(doc, cd.value, hints);
},
.return_stmt => |rs| {
if (rs.value) |val| self.collectCallHints(doc, val, hints);
},
.assignment => |a| {
self.collectCallHints(doc, a.value, hints);
},
.binary_op => |bop| {
self.collectCallHints(doc, bop.lhs, hints);
self.collectCallHints(doc, bop.rhs, hints);
},
.unary_op => |uop| {
self.collectCallHints(doc, uop.operand, hints);
},
.call => |c| {
// Recurse into arguments (they may contain nested calls)
for (c.args) |arg| self.collectCallHints(doc, arg, hints);
// Emit parameter name hints for this call
self.emitCallParamHints(doc, c, hints);
},
.push_stmt => |ps| {
self.collectCallHints(doc, ps.context_expr, hints);
self.collectCallHints(doc, ps.body, hints);
},
.defer_stmt => |ds| {
self.collectCallHints(doc, ds.expr, hints);
},
else => {},
}
}
fn emitCallParamHints(self: *Server, doc: *const Document, call: sx.ast.Call, hints: *std.ArrayList(lsp.InlayHint)) void {
if (call.args.len == 0) return;
// Resolve callee name and find function declaration
var param_offset: usize = 0;
const fd = self.resolveCallTarget(doc, call, &param_offset) orelse return;
// Emit hints for each argument
for (call.args, 0..) |arg, i| {
const param_idx = i + param_offset;
if (param_idx >= fd.params.len) break;
const param = fd.params[param_idx];
// Skip variadic params
if (param.is_variadic) break;
// Skip if arg is an identifier matching the param name
if (arg.data == .identifier) {
if (std.mem.eql(u8, arg.data.identifier.name, param.name)) continue;
}
// Skip _ params
if (std.mem.eql(u8, param.name, "_")) continue;
const loc = sx.errors.SourceLoc.compute(doc.source, arg.span.start);
if (loc.line == 0 or loc.col == 0) continue;
const label = std.fmt.allocPrint(self.allocator, "{s}:", .{param.name}) catch continue;
hints.append(self.allocator, .{
.line = loc.line - 1,
.character = loc.col - 1,
.label = label,
.padding_left = false,
}) catch {};
}
}
fn resolveCallTarget(self: *Server, doc: *const Document, call: sx.ast.Call, param_offset: *usize) ?sx.ast.FnDecl {
param_offset.* = 0;
if (call.callee.data == .identifier) {
const name = call.callee.data.identifier.name;
return self.findFnDeclByName(doc, name);
}
if (call.callee.data == .field_access) {
const fa = call.callee.data.field_access;
// Try namespaced: "ns.func"
if (fa.object.data == .identifier) {
const ns_name = fa.object.data.identifier.name;
const qualified = std.fmt.allocPrint(self.allocator, "{s}.{s}", .{ ns_name, fa.field }) catch return null;
if (self.findFnDeclByName(doc, qualified)) |fd| {
return fd;
}
}
// Try UFCS: bare function name, skip first param (receiver)
if (self.findFnDeclByName(doc, fa.field)) |fd| {
if (fd.params.len == call.args.len + 1) {
param_offset.* = 1;
}
return fd;
}
}
return null;
}
fn classifyToken(tok: sx.token.Token, sema: SemaResult, source: [:0]const u8) ?u32 {
const ST = lsp.SemanticTokenType;
return switch (tok.tag) {
.kw_if,
.kw_else,
.kw_then,
.kw_true,
.kw_false,
.kw_enum,
.kw_case,
.kw_break,
.kw_continue,
.kw_while,
.kw_for,
.kw_return,
.kw_defer,
.kw_struct,
.kw_union,
.kw_xx,
.kw_and,
.kw_or,
.kw_null,
.kw_push,
.kw_ufcs,
.kw_in,
.kw_closure,
.kw_protocol,
.kw_impl,
.kw_inline,
.kw_callconv,
.hash_run,
.hash_import,
.hash_insert,
.hash_builtin,
.hash_compiler,
.hash_foreign,
.hash_library,
.hash_framework,
.hash_using,
.hash_include,
.hash_source,
.hash_define,
.hash_flags,
.hash_inline,
.hash_objc_call,
.hash_jni_call,
.hash_jni_static_call,
.hash_jni_class,
.hash_jni_interface,
.hash_objc_class,
.hash_objc_protocol,
.hash_swift_class,
.hash_swift_struct,
.hash_swift_protocol,
.hash_extends,
.hash_implements,
.hash_jni_method_descriptor,
.hash_jni_env,
.hash_jni_main,
.hash_selector,
.hash_property,
=> ST.keyword,
.kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_,
.int_literal, .float_literal => ST.number,
.string_literal, .raw_string_literal => null,
.plus,
.minus,
.star,
.slash,
.equal,
.equal_equal,
.bang,
.bang_equal,
.less,
.less_equal,
.greater,
.greater_equal,
.plus_equal,
.minus_equal,
.star_equal,
.slash_equal,
.percent,
.percent_equal,
.ampersand,
.ampersand_equal,
.at,
.pipe,
.pipe_equal,
.pipe_arrow,
.caret,
.caret_equal,
.question,
.question_question,
.question_dot,
.tilde,
.less_less,
.less_less_equal,
.greater_greater,
.greater_greater_equal,
.arrow,
.fat_arrow,
.colon_colon,
.colon_equal,
.triple_minus,
=> ST.operator_,
.identifier => classifyIdentifier(tok, sema, source),
.colon,
.semicolon,
.comma,
.dot,
.dot_dot,
.dollar,
.l_paren,
.r_paren,
.l_brace,
.r_brace,
.l_bracket,
.r_bracket,
.eof,
.invalid,
=> null,
};
}
fn classifyIdentifier(tok: sx.token.Token, sema: SemaResult, source: [:0]const u8) ?u32 {
const ST = lsp.SemanticTokenType;
const offset = tok.loc.start;
if (tok.loc.start >= source.len or tok.loc.end > source.len or tok.loc.start >= tok.loc.end) return null;
const name = source[tok.loc.start..tok.loc.end];
if (sx.sema.findReferenceAtOffset(sema.references, offset)) |ref_idx| {
const si = sema.references[ref_idx].symbol_index;
if (si >= sema.symbols.len) return null;
const sym = sema.symbols[si];
return symbolKindToTokenType(sym.kind);
}
for (sema.symbols) |sym| {
if (sym.def_span.start == offset and std.mem.eql(u8, sym.name, name)) {
return symbolKindToTokenType(sym.kind);
}
}
if (sx.types.Type.fromName(name) != null) {
return ST.type_;
}
// Uppercase identifiers are conventionally types
if (name.len > 0 and name[0] >= 'A' and name[0] <= 'Z') {
return ST.type_;
}
return null;
}
fn symbolKindToTokenType(kind: sx.sema.SymbolKind) u32 {
const ST = lsp.SemanticTokenType;
return switch (kind) {
.function => ST.function,
.variable => ST.variable,
.constant => ST.variable,
.param => ST.parameter,
.enum_type => ST.enum_,
.struct_type => ST.struct_,
.protocol_type => ST.interface,
.type_alias => ST.type_,
.namespace => ST.namespace,
};
}
fn emitToken(
data: *std.ArrayList(u32),
allocator: std.mem.Allocator,
source: [:0]const u8,
start: u32,
end: u32,
token_type: u32,
prev_line: *u32,
prev_char: *u32,
) !void {
if (start >= source.len or end > source.len or start >= end) return;
const loc = sx.errors.SourceLoc.compute(source, start);
if (loc.line == 0 or loc.col == 0) return;
const line = loc.line - 1;
const col = loc.col - 1;
const length = end - start;
if (line < prev_line.*) return;
const delta_line = line - prev_line.*;
const delta_char = if (delta_line == 0) (if (col >= prev_char.*) col - prev_char.* else return) else col;
try data.append(allocator, delta_line);
try data.append(allocator, delta_char);
try data.append(allocator, length);
try data.append(allocator, token_type);
try data.append(allocator, 0);
prev_line.* = line;
prev_char.* = col;
}
fn emitStringSegment(
data: *std.ArrayList(u32),
allocator: std.mem.Allocator,
source: [:0]const u8,
seg_start: u32,
seg_end: u32,
token_type: u32,
prev_line: *u32,
prev_char: *u32,
) !void {
var line_start = seg_start;
var pos = seg_start;
while (pos < seg_end) : (pos += 1) {
if (source[pos] == '\n') {
if (pos + 1 > line_start) {
try emitToken(data, allocator, source, line_start, pos + 1, token_type, prev_line, prev_char);
}
line_start = pos + 1;
}
}
if (line_start < seg_end) {
try emitToken(data, allocator, source, line_start, seg_end, token_type, prev_line, prev_char);
}
}
fn emitStringParts(
data: *std.ArrayList(u32),
allocator: std.mem.Allocator,
source: [:0]const u8,
tok_start: u32,
tok_end: u32,
prev_line: *u32,
prev_char: *u32,
) !void {
const ST = lsp.SemanticTokenType;
var pos = tok_start;
var seg_start = tok_start;
var in_interp = false;
while (pos < tok_end) : (pos += 1) {
if (in_interp) {
if (source[pos] == '}') {
in_interp = false;
seg_start = pos + 1;
}
} else {
if (source[pos] == '\\' and pos + 1 < tok_end) {
pos += 1;
} else if (source[pos] == '{') {
if (pos > seg_start) {
try emitStringSegment(data, allocator, source, seg_start, pos, ST.string_, prev_line, prev_char);
}
in_interp = true;
}
}
}
if (!in_interp and seg_start < tok_end) {
try emitStringSegment(data, allocator, source, seg_start, tok_end, ST.string_, prev_line, prev_char);
}
}
// ---- Core analysis pipeline ----
fn analyzeAndPublish(self: *Server, uri: []const u8, text: []const u8, version: i64) !void {
const file_path = uriToFilePath(uri) orelse "";
const source = try self.allocator.dupeZ(u8, text);
const doc = try self.documents.openOrUpdate(file_path, source, version);
self.documents.analyzeDocument(doc) catch {};
// Publish diagnostics from sema
if (doc.sema) |sema| {
try self.sendDiagnostics(uri, semaToLspDiags(self.allocator, doc.source, sema.diagnostics));
} else {
try self.sendDiagnostics(uri, &.{});
}
}
fn semaToLspDiags(allocator: std.mem.Allocator, source: [:0]const u8, diags: []const sx.errors.Diagnostic) []const lsp.Diagnostic {
var result = std.ArrayList(lsp.Diagnostic).empty;
for (diags) |d| {
const range = if (d.span) |span| spanToRange(source, span) else lsp.Range{
.start = .{ .line = 0, .character = 0 },
.end = .{ .line = 0, .character = 1 },
};
const severity: u32 = switch (d.level) {
.err => 1,
.warn => 2,
.note => 3,
.help => 4,
};
result.append(allocator, .{
.range = range,
.severity = severity,
.message = d.message,
}) catch continue;
}
return result.items;
}
fn sendDiagnostics(self: *Server, uri: []const u8, diagnostics: []const lsp.Diagnostic) !void {
const params_json = try lsp.publishDiagnosticsJson(self.allocator, uri, diagnostics);
const body = try lsp.jsonRpcNotification(self.allocator, "textDocument/publishDiagnostics", params_json);
try self.transport.writeMessage(body);
}
// ---- Symbol resolution helpers ----
/// Send a Location response for a symbol, resolving to the correct file via origin.
fn sendSymbolLocation(self: *Server, id_json: []const u8, doc: *const Document, sym: sx.sema.Symbol) !bool {
return self.sendSymbolLocationWithOrigin(id_json, doc, sym, null);
}
fn sendSymbolLocationWithOrigin(self: *Server, id_json: []const u8, doc: *const Document, sym: sx.sema.Symbol, origin_span: ?sx.ast.Span) !bool {
if (sym.origin) |origin_path| {
const origin_doc = self.documents.get(origin_path) orelse return false;
const target_range = spanToRange(origin_doc.source, sym.def_span);
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{origin_path});
if (origin_span) |os| {
const src_range = spanToRange(doc.source, os);
const loc_json = try lsp.locationLinkJson(self.allocator, target_uri, target_range, src_range);
try self.sendResponse(id_json, loc_json);
} else {
const loc_json = try lsp.locationJson(self.allocator, target_uri, target_range);
try self.sendResponse(id_json, loc_json);
}
return true;
} else {
const target_range = spanToRange(doc.source, sym.def_span);
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{doc.path});
if (origin_span) |os| {
const src_range = spanToRange(doc.source, os);
const loc_json = try lsp.locationLinkJson(self.allocator, target_uri, target_range, src_range);
try self.sendResponse(id_json, loc_json);
} else {
const loc_json = try lsp.locationJson(self.allocator, target_uri, target_range);
try self.sendResponse(id_json, loc_json);
}
return true;
}
}
/// Send a go-to-definition response pointing to a C header source location.
fn sendCSourceLocation(self: *Server, id_json: []const u8, cloc: sx.c_import.CSourceLocation, origin_span: ?sx.ast.Span, origin_source: [:0]const u8) !bool {
// Resolve to absolute path if relative
const abs_path = if (cloc.file.len > 0 and cloc.file[0] != '/')
try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.root_path, cloc.file })
else
cloc.file;
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{abs_path});
const line: u32 = if (cloc.line > 0) cloc.line - 1 else 0; // LSP lines are 0-based
const target_range = lsp.Range{
.start = .{ .line = line, .character = 0 },
.end = .{ .line = line, .character = 0 },
};
if (origin_span) |os| {
const src_range = spanToRange(origin_source, os);
const loc_json = try lsp.locationLinkJson(self.allocator, target_uri, target_range, src_range);
try self.sendResponse(id_json, loc_json);
} else {
const loc_json = try lsp.locationJson(self.allocator, target_uri, target_range);
try self.sendResponse(id_json, loc_json);
}
return true;
}
/// Resolve which document a symbol belongs to (for hover/source lookup).
fn resolveSymbolDoc(self: *Server, doc: *const Document, sym: sx.sema.Symbol) *const Document {
if (sym.origin) |origin_path| {
return self.documents.get(origin_path) orelse doc;
}
return doc;
}
const TypeDeclLookup = struct { node: *sx.ast.Node, doc: *const Document };
/// Given a type name, find its AST declaration node and the document it lives in.
/// Works for struct_decl, enum_decl, union_decl, and protocol_decl across files.
fn findTypeDeclNode(self: *Server, sema: SemaResult, doc: *const Document, type_name: []const u8) ?TypeDeclLookup {
for (sema.symbols) |sym| {
if (!std.mem.eql(u8, sym.name, type_name)) continue;
if (sym.kind != .struct_type and sym.kind != .enum_type and sym.kind != .protocol_type) continue;
const lookup_doc = self.resolveSymbolDoc(doc, sym);
const lookup_root = lookup_doc.root orelse return null;
if (sx.sema.findNodeAtOffset(lookup_root, sym.def_span.start)) |node| {
return .{ .node = node, .doc = lookup_doc };
}
return null;
}
return null;
}
/// Send a go-to-definition response for a struct method, field, or enum variant.
fn sendMemberLocation(self: *Server, id_json: []const u8, lookup: TypeDeclLookup, member_name: []const u8, origin_doc: *const Document, origin: sx.ast.Span) !bool {
if (lookup.node.data == .struct_decl) {
const sd = lookup.node.data.struct_decl;
// Check methods
for (sd.methods) |method_node| {
if (method_node.data == .fn_decl) {
if (std.mem.eql(u8, method_node.data.fn_decl.name, member_name)) {
return self.sendSpanLocation(id_json, lookup.doc, method_node.span, origin_doc, origin);
}
}
}
// Check fields — use field type span as approximate location
for (sd.field_names, 0..) |fname, fi| {
if (std.mem.eql(u8, fname, member_name)) {
if (fi < sd.field_types.len) {
return self.sendSpanLocation(id_json, lookup.doc, sd.field_types[fi].span, origin_doc, origin);
}
}
}
} else if (lookup.node.data == .enum_decl) {
const ed = lookup.node.data.enum_decl;
for (ed.variant_names) |v| {
if (std.mem.eql(u8, v, member_name)) {
return self.sendSpanLocation(id_json, lookup.doc, lookup.node.span, origin_doc, origin);
}
}
} else if (lookup.node.data == .union_decl) {
const ud = lookup.node.data.union_decl;
for (ud.field_names) |fname| {
if (std.mem.eql(u8, fname, member_name)) {
return self.sendSpanLocation(id_json, lookup.doc, lookup.node.span, origin_doc, origin);
}
}
}
return false;
}
/// Resolve qualified name as struct/union method or field, send definition location.
fn resolveStructMemberDef(self: *Server, id_json: []const u8, sema: SemaResult, doc: *const Document, ns: []const u8, member: []const u8, origin: sx.ast.Span) !bool {
// Try ns as a type name directly
if (self.findTypeDeclNode(sema, doc, ns)) |lookup| {
if (try self.sendMemberLocation(id_json, lookup, member, doc, origin)) return true;
}
// Try ns as a variable name → resolve to struct type
if (resolveStructTypeName(sema, doc, ns)) |type_name| {
if (self.findTypeDeclNode(sema, doc, type_name)) |lookup| {
if (try self.sendMemberLocation(id_json, lookup, member, doc, origin)) return true;
}
}
return false;
}
/// Send a go-to-definition response pointing to a span in a document.
fn sendSpanLocation(self: *Server, id_json: []const u8, target_doc: *const Document, target_span: sx.ast.Span, origin_doc: *const Document, origin_span: sx.ast.Span) !bool {
const target_range = spanToRange(target_doc.source, target_span);
const target_uri = try std.fmt.allocPrint(self.allocator, "file://{s}", .{target_doc.path});
const src_range = spanToRange(origin_doc.source, origin_span);
const loc_json = try lsp.locationLinkJson(self.allocator, target_uri, target_range, src_range);
try self.sendResponse(id_json, loc_json);
return true;
}
/// Find an import by namespace name (falls back to last good imports).
fn findImportByNs(_: *Server, doc: *const Document, ns_name: []const u8) ?doc_mod.Import {
const imports_lists = [_][]const doc_mod.Import{ doc.imports, doc.last_good_imports };
for (&imports_lists) |imports| {
for (imports) |imp| {
if (imp.ns) |ns| {
if (std.mem.eql(u8, ns, ns_name)) return imp;
}
}
}
return null;
}
pub fn searchFnDeclInDoc(target_doc: *const Document, fn_name: []const u8) ?sx.ast.FnDecl {
const root = target_doc.root orelse return null;
if (root.data != .root) return null;
for (root.data.root.decls) |decl| {
if (decl.data == .fn_decl and std.mem.eql(u8, decl.data.fn_decl.name, fn_name)) {
return decl.data.fn_decl;
}
}
return null;
}
/// Find a fn_decl by name. Supports dotted names like "ns.func".
fn findFnDeclByName(self: *Server, doc: *const Document, name: []const u8) ?sx.ast.FnDecl {
// Check for dotted name (e.g. "std.print")
if (std.mem.indexOfScalar(u8, name, '.')) |dot_idx| {
const ns_name = name[0..dot_idx];
const fn_name = name[dot_idx + 1 ..];
if (self.findImportByNs(doc, ns_name)) |imp| {
if (self.documents.get(imp.path)) |imp_doc| {
if (searchFnDeclInDoc(imp_doc, fn_name)) |fd| return fd;
} else {
// Directory import: search all documents in the directory
const dir_prefix = std.fmt.allocPrint(self.allocator, "{s}/", .{imp.path}) catch null;
if (dir_prefix) |prefix| {
var it = self.documents.by_path.iterator();
while (it.next()) |entry| {
if (!std.mem.startsWith(u8, entry.key_ptr.*, prefix)) continue;
if (searchFnDeclInDoc(entry.value_ptr.*, fn_name)) |fd| return fd;
}
}
}
}
}
// Top-level lookup in current doc
const root = doc.root orelse return null;
const func_name = extractLastSegment(name);
if (root.data == .root) {
for (root.data.root.decls) |decl| {
if (decl.data == .fn_decl and std.mem.eql(u8, decl.data.fn_decl.name, func_name)) {
return decl.data.fn_decl;
}
}
}
// Also check imported docs for flat-imported functions
for (doc.imports) |imp| {
if (imp.ns != null) continue; // skip namespaced
if (self.documents.get(imp.path)) |imp_doc| {
if (imp_doc.root) |imp_root| {
if (imp_root.data == .root) {
for (imp_root.data.root.decls) |decl| {
if (decl.data == .fn_decl and std.mem.eql(u8, decl.data.fn_decl.name, func_name)) {
return decl.data.fn_decl;
}
}
}
}
}
}
return null;
}
/// UFCS completions: free functions whose first param matches the given type.
fn collectUfcsCompletions(self: *Server, items: *std.ArrayList(lsp.CompletionItem), root: *sx.ast.Node, type_name: []const u8) !void {
if (root.data != .root) return;
for (root.data.root.decls) |decl| {
try self.collectUfcsFromDecl(items, decl, type_name);
}
}
fn collectUfcsFromDecl(self: *Server, items: *std.ArrayList(lsp.CompletionItem), decl: *sx.ast.Node, type_name: []const u8) !void {
switch (decl.data) {
.fn_decl => |fd| {
if (fd.params.len > 0) {
const base = extractBaseTypeName(fd.params[0].type_expr) orelse return;
if (!std.mem.eql(u8, base, type_name)) return;
const detail = try self.formatUfcsDetail(fd.params[1..], fd.return_type);
try items.append(self.allocator, .{
.label = fd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Method),
.detail = detail,
});
}
},
.const_decl => |cd| {
if (cd.value.data == .lambda) {
const lam = cd.value.data.lambda;
if (lam.params.len > 0) {
const base = extractBaseTypeName(lam.params[0].type_expr) orelse return;
if (!std.mem.eql(u8, base, type_name)) return;
const detail = try self.formatUfcsDetail(lam.params[1..], lam.return_type);
try items.append(self.allocator, .{
.label = cd.name,
.kind = @intFromEnum(lsp.CompletionItemKind.Method),
.detail = detail,
});
}
}
},
else => {},
}
}
fn formatUfcsDetail(self: *Server, params: []const sx.ast.Param, return_type: ?*sx.ast.Node) ![]const u8 {
var buf = std.ArrayList(u8).empty;
try buf.append(self.allocator, '(');
for (params, 0..) |param, pi| {
if (pi > 0) try buf.appendSlice(self.allocator, ", ");
try buf.appendSlice(self.allocator, param.name);
if (param.type_expr.data == .inferred_type) {
// Inferred type — show name only
} else if (param.type_expr.data == .type_expr) {
try buf.appendSlice(self.allocator, ": ");
try buf.appendSlice(self.allocator, param.type_expr.data.type_expr.name);
} else {
try buf.appendSlice(self.allocator, "?");
}
}
try buf.append(self.allocator, ')');
if (return_type) |rt| {
try buf.appendSlice(self.allocator, " -> ");
if (rt.data == .type_expr) {
try buf.appendSlice(self.allocator, rt.data.type_expr.name);
}
}
return buf.items;
}
// ---- Hover helpers ----
fn formatStructFieldHover(self: *Server, doc: *const Document, obj_name: []const u8, field_name: []const u8) !?[]const u8 {
const sema = doc.sema orelse return null;
const struct_name = resolveStructTypeName(sema, doc, obj_name) orelse return null;
const lookup = self.findTypeDeclNode(sema, doc, struct_name) orelse return null;
if (lookup.node.data != .struct_decl) return null;
const sd = lookup.node.data.struct_decl;
const lookup_doc = lookup.doc;
for (sd.field_names, 0..) |fn_, fi| {
if (!std.mem.eql(u8, fn_, field_name)) continue;
var buf = std.ArrayList(u8).empty;
// Doc comment above field
const fn_addr = @intFromPtr(fn_.ptr);
const src_addr = @intFromPtr(lookup_doc.source.ptr);
const src_end = src_addr + lookup_doc.source.len;
if (fn_addr >= src_addr and fn_addr < src_end) {
const field_offset = @as(u32, @intCast(fn_addr - src_addr));
if (extractDocComment(lookup_doc.source, field_offset)) |comment| {
try buf.appendSlice(self.allocator, comment);
try buf.appendSlice(self.allocator, "\n\n");
}
}
try buf.appendSlice(self.allocator, "```sx\n");
try buf.appendSlice(self.allocator, struct_name);
try buf.append(self.allocator, '.');
try buf.appendSlice(self.allocator, field_name);
if (fi < sd.field_types.len) {
if (sd.field_types[fi].data == .type_expr) {
try buf.appendSlice(self.allocator, " : ");
try buf.appendSlice(self.allocator, sd.field_types[fi].data.type_expr.name);
}
}
try buf.appendSlice(self.allocator, "\n```");
return buf.items;
}
return null;
}
fn formatEnumVariantHover(self: *Server, doc: *const Document, variant_name: []const u8) !?[]const u8 {
const sema = doc.sema orelse return null;
// Search all enum types for the variant
for (sema.symbols) |sym| {
if (sym.kind != .enum_type) continue;
const lookup = self.findTypeDeclNode(sema, doc, sym.name) orelse continue;
if (lookup.node.data != .enum_decl) continue;
const ed = lookup.node.data.enum_decl;
const lookup_doc = lookup.doc;
for (ed.variant_names) |v| {
if (!std.mem.eql(u8, v, variant_name)) continue;
var buf = std.ArrayList(u8).empty;
const v_addr = @intFromPtr(v.ptr);
const src_addr2 = @intFromPtr(lookup_doc.source.ptr);
const src_end2 = src_addr2 + lookup_doc.source.len;
if (v_addr >= src_addr2 and v_addr < src_end2) {
const variant_offset = @as(u32, @intCast(v_addr - src_addr2));
if (extractDocComment(lookup_doc.source, variant_offset)) |comment| {
try buf.appendSlice(self.allocator, comment);
try buf.appendSlice(self.allocator, "\n\n");
}
}
try buf.appendSlice(self.allocator, "```sx\n");
try buf.appendSlice(self.allocator, sym.name);
try buf.append(self.allocator, '.');
try buf.appendSlice(self.allocator, variant_name);
try buf.appendSlice(self.allocator, "\n```");
return buf.items;
}
}
return null;
}
fn resolveVariableType(sema: SemaResult, var_name: []const u8) ?[]const u8 {
var i = sema.symbols.len;
while (i > 0) {
i -= 1;
const sym = sema.symbols[i];
if (!std.mem.eql(u8, sym.name, var_name)) continue;
if (sym.kind != .variable and sym.kind != .param) continue;
const ty = sym.ty orelse return null;
return switch (ty) {
.struct_type => |name| name,
else => null,
};
}
return null;
}
fn resolveStructTypeName(sema: SemaResult, doc: *const Document, var_name: []const u8) ?[]const u8 {
var i = sema.symbols.len;
while (i > 0) {
i -= 1;
const sym = sema.symbols[i];
if (!std.mem.eql(u8, sym.name, var_name)) continue;
const ty = sym.ty orelse return null;
if (ty != .struct_type) return null;
if (sym.kind == .param) {
if (sym.def_span.start < doc.source.len and sym.def_span.end <= doc.source.len) {
return doc.source[sym.def_span.start..sym.def_span.end];
}
}
if (sym.kind == .variable) {
const root = doc.root orelse return null;
if (sym.def_span.start < doc.source.len) {
if (sx.sema.findNodeAtOffset(root, sym.def_span.start)) |node| {
if (node.data == .var_decl) {
if (node.data.var_decl.type_annotation) |ta| {
if (ta.data == .type_expr) return ta.data.type_expr.name;
}
}
}
}
}
return null;
}
return null;
}
// ---- JSON helpers ----
fn jsonGet(val: std.json.Value, key: []const u8) ?std.json.Value {
return switch (val) {
.object => |obj| obj.get(key),
else => null,
};
}
fn jsonStr(val: std.json.Value) ?[]const u8 {
return switch (val) {
.string => |s| s,
else => null,
};
}
fn jsonInt(val: std.json.Value) ?i64 {
return switch (val) {
.integer => |i| i,
else => null,
};
}
fn jsonArr(val: std.json.Value) ?[]std.json.Value {
return switch (val) {
.array => |a| a.items,
else => null,
};
}
// ---- Text helpers ----
fn findSymbolNameAtOffset(symbols: []const sx.sema.Symbol, source: [:0]const u8, offset: u32) ?usize {
for (symbols, 0..) |sym, i| {
if (sym.origin != null) continue; // skip imported symbols
const name_start = sym.def_span.start;
const name_end = name_start + @as(u32, @intCast(sym.name.len));
if (offset >= name_start and offset < name_end and name_end <= source.len) {
if (std.mem.eql(u8, source[name_start..name_end], sym.name)) {
return i;
}
}
}
return null;
}
pub fn positionToOffset(source: []const u8, line: u32, character: u32) ?u32 {
var cur_line: u32 = 0;
var cur_col: u32 = 0;
for (source, 0..) |ch, i| {
if (cur_line == line and cur_col == character) {
return @intCast(i);
}
if (ch == '\n') {
if (cur_line == line) return @intCast(i);
cur_line += 1;
cur_col = 0;
} else {
cur_col += 1;
}
}
if (cur_line == line and cur_col == character) {
return @intCast(source.len);
}
return null;
}
fn extractDotPrefix(source: []const u8, cursor_offset: u32) ?[]const u8 {
if (cursor_offset < 2) return null;
const dot_pos = cursor_offset - 1;
if (source[dot_pos] != '.') return null;
var start: u32 = dot_pos;
while (start > 0) {
const ch = source[start - 1];
if (std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '.') {
start -= 1;
} else {
break;
}
}
if (start == dot_pos) return null;
const prefix = source[start..dot_pos];
var trimmed_start: usize = 0;
while (trimmed_start < prefix.len and prefix[trimmed_start] == '.') {
trimmed_start += 1;
}
if (trimmed_start == prefix.len) return null;
return prefix[trimmed_start..];
}
fn findCallContext(source: []const u8, cursor_offset: u32) ?struct { name: []const u8, active_param: u32 } {
if (cursor_offset == 0) return null;
var depth: i32 = 0;
var comma_count: u32 = 0;
var pos: u32 = cursor_offset;
while (pos > 0) {
pos -= 1;
const ch = source[pos];
if (ch == ')') {
depth += 1;
} else if (ch == '(') {
if (depth == 0) {
if (pos == 0) return null;
const name_end: u32 = pos;
var name_start: u32 = pos;
while (name_start > 0) {
const nc = source[name_start - 1];
if (std.ascii.isAlphanumeric(nc) or nc == '_' or nc == '.') {
name_start -= 1;
} else {
break;
}
}
if (name_start == name_end) return null;
var trimmed = name_start;
while (trimmed < name_end and source[trimmed] == '.') {
trimmed += 1;
}
if (trimmed == name_end) return null;
return .{
.name = source[trimmed..name_end],
.active_param = comma_count,
};
}
depth -= 1;
} else if (ch == ',' and depth == 0) {
comma_count += 1;
}
}
return null;
}
fn extractLastSegment(name: []const u8) []const u8 {
var i = name.len;
while (i > 0) {
i -= 1;
if (name[i] == '.') {
return name[i + 1 ..];
}
}
return name;
}
pub fn extractIdentAtOffset(source: []const u8, offset: u32) ?[]const u8 {
if (offset >= source.len) return null;
var start: u32 = offset;
while (start > 0 and isIdentChar(source[start - 1])) {
start -= 1;
}
var end: u32 = offset;
while (end < source.len and isIdentChar(source[end])) {
end += 1;
}
if (start == end) return null;
return source[start..end];
}
fn isIdentChar(c: u8) bool {
return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '_';
}
pub fn findImportPathAtOffset(source: []const u8, offset: u32) ?[]const u8 {
if (offset >= source.len) return null;
var qstart: u32 = offset;
while (qstart > 0 and source[qstart] != '"') : (qstart -= 1) {}
if (source[qstart] != '"') return null;
const qend: u32 = if (offset < source.len and source[offset] == '"')
offset
else blk: {
var e = offset;
while (e < source.len and source[e] != '"' and source[e] != '\n') : (e += 1) {}
if (e >= source.len or source[e] != '"') return null;
break :blk e;
};
if (qstart == qend) return null;
var scan = qstart;
while (scan > 0 and (source[scan - 1] == ' ' or source[scan - 1] == '\t')) : (scan -= 1) {}
const kw = "#import";
if (scan < kw.len) return null;
if (!std.mem.eql(u8, source[scan - kw.len .. scan], kw)) return null;
return source[qstart + 1 .. qend];
}
pub fn extractQualifiedName(source: []const u8, offset: u32) ?struct { ns: []const u8, member: []const u8, full_start: u32, full_end: u32 } {
if (offset >= source.len) return null;
var end: u32 = offset;
while (end < source.len and isIdentChar(source[end])) end += 1;
var start: u32 = offset;
while (start > 0 and isIdentChar(source[start - 1])) start -= 1;
if (start == end) return null;
if (start >= 2 and source[start - 1] == '.') {
var ns_start: u32 = start - 1;
while (ns_start > 0 and isIdentChar(source[ns_start - 1])) ns_start -= 1;
if (ns_start < start - 1) {
return .{
.ns = source[ns_start .. start - 1],
.member = source[start..end],
.full_start = ns_start,
.full_end = end,
};
}
}
if (end < source.len and source[end] == '.') {
var member_end: u32 = end + 1;
while (member_end < source.len and isIdentChar(source[member_end])) member_end += 1;
if (member_end > end + 1) {
return .{
.ns = source[start..end],
.member = source[end + 1 .. member_end],
.full_start = start,
.full_end = member_end,
};
}
}
return null;
}
pub fn findSymbolByName(symbols: []const sx.sema.Symbol, name: []const u8) ?usize {
var i = symbols.len;
while (i > 0) {
i -= 1;
if (std.mem.eql(u8, symbols[i].name, name)) {
return i;
}
}
return null;
}
fn uriToFilePath(uri: []const u8) ?[]const u8 {
if (std.mem.startsWith(u8, uri, "file://")) {
return uri[7..];
}
return null;
}
fn spanToRange(source: [:0]const u8, span: sx.ast.Span) lsp.Range {
const clamped_start = @min(span.start, @as(u32, @intCast(source.len)));
const clamped_end = @min(span.end, @as(u32, @intCast(source.len)));
const start = sx.errors.SourceLoc.compute(source, clamped_start);
const end = sx.errors.SourceLoc.compute(source, clamped_end);
return .{
.start = .{ .line = start.line - 1, .character = start.col - 1 },
.end = .{ .line = end.line - 1, .character = end.col - 1 },
};
}
fn extractDocComment(source: []const u8, def_start: u32) ?[]const u8 {
if (def_start == 0 or def_start > source.len) return null;
var pos: u32 = def_start;
while (pos > 0 and source[pos - 1] != '\n') : (pos -= 1) {}
if (pos == 0) return null;
const block_end = pos;
var block_start = pos;
while (block_start > 0) {
var scan = block_start - 1;
while (scan > 0 and source[scan - 1] != '\n') : (scan -= 1) {}
const line = std.mem.trimEnd(u8, source[scan..block_start], "\r\n");
const trimmed = std.mem.trimStart(u8, line, " \t");
if (trimmed.len >= 2 and trimmed[0] == '/' and trimmed[1] == '/') {
block_start = scan;
} else {
break;
}
}
if (block_start >= block_end) return null;
var end_pos = block_end;
while (end_pos > block_start and (source[end_pos - 1] == '\n' or source[end_pos - 1] == '\r')) : (end_pos -= 1) {}
if (end_pos <= block_start) return null;
return source[block_start..end_pos];
}
fn extractBaseTypeName(type_node: *sx.ast.Node) ?[]const u8 {
return switch (type_node.data) {
.type_expr => |te| te.name,
.pointer_type_expr => |pte| extractBaseTypeName(pte.pointee_type),
.parameterized_type_expr => |pte| pte.name,
else => null,
};
}
fn findDeclByName(root: *sx.ast.Node, name: []const u8) ?*sx.ast.Node {
if (root.data != .root) return null;
for (root.data.root.decls) |decl| {
if (decl.data.declName()) |dn| {
if (std.mem.eql(u8, dn, name)) return decl;
}
}
return null;
}
fn formatDeclHover(allocator: std.mem.Allocator, decl: *sx.ast.Node, source: []const u8) ![]const u8 {
var buf = std.ArrayList(u8).empty;
if (extractDocComment(source, decl.span.start)) |comment| {
try buf.appendSlice(allocator, comment);
try buf.appendSlice(allocator, "\n\n");
}
try buf.appendSlice(allocator, "```sx\n");
switch (decl.data) {
.fn_decl => |fd| {
try buf.appendSlice(allocator, fd.name);
try buf.appendSlice(allocator, " :: (");
for (fd.params, 0..) |param, pi| {
if (pi > 0) try buf.appendSlice(allocator, ", ");
try buf.appendSlice(allocator, param.name);
if (param.type_expr.data != .inferred_type) {
try buf.appendSlice(allocator, ": ");
if (param.type_expr.data == .type_expr) {
try buf.appendSlice(allocator, param.type_expr.data.type_expr.name);
} else {
try buf.appendSlice(allocator, "?");
}
}
}
try buf.append(allocator, ')');
if (fd.return_type) |rt| {
try buf.appendSlice(allocator, " -> ");
if (rt.data == .type_expr) {
try buf.appendSlice(allocator, rt.data.type_expr.name);
}
}
},
.enum_decl => |ed| {
try buf.appendSlice(allocator, ed.name);
if (ed.is_flags) {
try buf.appendSlice(allocator, " :: enum flags ");
} else {
try buf.appendSlice(allocator, " :: enum ");
}
if (ed.backing_type) |bt| {
if (bt.data == .type_expr) {
try buf.appendSlice(allocator, bt.data.type_expr.name);
try buf.appendSlice(allocator, " ");
} else if (bt.data == .struct_decl) {
const sd = bt.data.struct_decl;
try buf.appendSlice(allocator, "struct { ");
for (sd.field_names, 0..) |fn_, fi| {
if (fi > 0) try buf.appendSlice(allocator, "; ");
try buf.appendSlice(allocator, fn_);
try buf.appendSlice(allocator, ": ");
if (fi < sd.field_types.len) {
if (sd.field_types[fi].data == .type_expr) {
try buf.appendSlice(allocator, sd.field_types[fi].data.type_expr.name);
} else if (sd.field_types[fi].data == .array_type_expr) {
const ate = sd.field_types[fi].data.array_type_expr;
try buf.append(allocator, '[');
if (ate.length.data == .int_literal) {
const val = ate.length.data.int_literal.value;
var num_buf: [20]u8 = undefined;
const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{val}) catch "?";
try buf.appendSlice(allocator, num_str);
}
try buf.append(allocator, ']');
if (ate.element_type.data == .type_expr) {
try buf.appendSlice(allocator, ate.element_type.data.type_expr.name);
}
}
}
}
try buf.appendSlice(allocator, " } ");
}
}
try buf.appendSlice(allocator, "{ ");
for (ed.variant_names, 0..) |v, i| {
if (i > 0) try buf.appendSlice(allocator, ", ");
try buf.append(allocator, '.');
try buf.appendSlice(allocator, v);
}
try buf.appendSlice(allocator, " }");
},
.struct_decl => |sd| {
try buf.appendSlice(allocator, sd.name);
try buf.appendSlice(allocator, " :: struct { ");
for (sd.field_names, 0..) |fn_, fi| {
if (fi > 0) try buf.appendSlice(allocator, ", ");
try buf.appendSlice(allocator, fn_);
if (fi < sd.field_types.len) {
if (sd.field_types[fi].data == .type_expr) {
try buf.appendSlice(allocator, ": ");
try buf.appendSlice(allocator, sd.field_types[fi].data.type_expr.name);
}
}
}
try buf.appendSlice(allocator, " }");
},
.union_decl => |ud| {
try buf.appendSlice(allocator, ud.name);
try buf.appendSlice(allocator, " :: union { ");
for (ud.field_names, 0..) |fn_name, i| {
if (i > 0) try buf.appendSlice(allocator, ", ");
// Anonymous struct fields: show as "struct { ... }"
if (std.mem.startsWith(u8, fn_name, "__anon_")) {
if (i < ud.field_types.len and ud.field_types[i].data == .type_expr) {
try buf.appendSlice(allocator, ud.field_types[i].data.type_expr.name);
} else {
try buf.appendSlice(allocator, "struct { ... }");
}
} else {
try buf.appendSlice(allocator, fn_name);
if (i < ud.field_types.len) {
try buf.appendSlice(allocator, ": ");
if (ud.field_types[i].data == .type_expr) {
try buf.appendSlice(allocator, ud.field_types[i].data.type_expr.name);
}
}
}
}
try buf.appendSlice(allocator, " }");
},
.protocol_decl => |pd| {
try buf.appendSlice(allocator, pd.name);
try buf.appendSlice(allocator, " :: protocol");
if (pd.is_inline) try buf.appendSlice(allocator, " #inline");
try buf.appendSlice(allocator, " { ");
for (pd.methods, 0..) |method, mi| {
if (mi > 0) try buf.appendSlice(allocator, " ");
try buf.appendSlice(allocator, method.name);
try buf.appendSlice(allocator, " :: (");
for (method.param_names, 0..) |pname, pi| {
if (pi > 0) try buf.appendSlice(allocator, ", ");
try buf.appendSlice(allocator, pname);
if (pi < method.params.len) {
try buf.appendSlice(allocator, ": ");
if (method.params[pi].data == .type_expr) {
try buf.appendSlice(allocator, method.params[pi].data.type_expr.name);
} else {
try buf.appendSlice(allocator, "?");
}
}
}
try buf.append(allocator, ')');
if (method.return_type) |rt| {
try buf.appendSlice(allocator, " -> ");
if (rt.data == .type_expr) {
try buf.appendSlice(allocator, rt.data.type_expr.name);
}
}
try buf.appendSlice(allocator, ";");
}
try buf.appendSlice(allocator, " }");
},
.impl_block => |ib| {
try buf.appendSlice(allocator, "impl ");
try buf.appendSlice(allocator, ib.protocol_name);
try buf.appendSlice(allocator, " for ");
try buf.appendSlice(allocator, ib.target_type);
},
.const_decl => |cd| {
try buf.appendSlice(allocator, cd.name);
try buf.appendSlice(allocator, " :: ");
if (cd.type_annotation) |ta| {
if (ta.data == .type_expr) {
try buf.appendSlice(allocator, ta.data.type_expr.name);
}
}
},
.var_decl => |vd| {
try buf.appendSlice(allocator, vd.name);
if (vd.type_annotation) |ta| {
if (ta.data == .type_expr) {
try buf.appendSlice(allocator, " : ");
try buf.appendSlice(allocator, ta.data.type_expr.name);
}
}
},
else => {
try buf.appendSlice(allocator, "(declaration)");
},
}
try buf.appendSlice(allocator, "\n```");
return buf.items;
}
fn formatSymbolHover(allocator: std.mem.Allocator, sym: sx.sema.Symbol, root: *sx.ast.Node, source: [:0]const u8) ![]const u8 {
// Try offset-based AST lookup
if (sym.def_span.start < source.len) {
if (sx.sema.findNodeAtOffset(root, sym.def_span.start)) |node| {
if (node.data.declName()) |dn| {
if (std.mem.eql(u8, dn, sym.name)) {
return try formatDeclHover(allocator, node, source);
}
}
}
}
// Fallback: name-based lookup
if (findDeclByName(root, sym.name)) |decl| {
return try formatDeclHover(allocator, decl, source);
}
// Last resort: simple format
var buf = std.ArrayList(u8).empty;
if (sym.def_span.start < source.len) {
if (extractDocComment(source, sym.def_span.start)) |comment| {
try buf.appendSlice(allocator, comment);
try buf.appendSlice(allocator, "\n\n");
}
}
try buf.appendSlice(allocator, "```sx\n");
switch (sym.kind) {
.function => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: (...)");
if (sym.ty) |ty| {
try buf.appendSlice(allocator, " -> ");
const type_name = try ty.displayName(allocator);
try buf.appendSlice(allocator, type_name);
}
},
.variable => {
try buf.appendSlice(allocator, sym.name);
if (sym.ty) |ty| {
try buf.appendSlice(allocator, " : ");
const type_name = try ty.displayName(allocator);
try buf.appendSlice(allocator, type_name);
}
},
.constant => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: ");
if (sym.ty) |ty| {
const type_name = try ty.displayName(allocator);
try buf.appendSlice(allocator, type_name);
} else {
try buf.appendSlice(allocator, "(constant)");
}
},
.param => {
try buf.appendSlice(allocator, sym.name);
if (sym.ty) |ty| {
try buf.appendSlice(allocator, " : ");
const type_name = try ty.displayName(allocator);
try buf.appendSlice(allocator, type_name);
}
},
.enum_type => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: enum { ... }");
},
.struct_type => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: struct { ... }");
},
.protocol_type => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: protocol { ... }");
},
.type_alias => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: (type)");
},
.namespace => {
try buf.appendSlice(allocator, sym.name);
try buf.appendSlice(allocator, " :: (namespace)");
},
}
try buf.appendSlice(allocator, "\n```");
return buf.items;
}
};
test "findMatchSubjectText: simple identifier" {
const source = "if event == {\n case .";
const result = Server.findMatchSubjectText(source, @intCast(source.len));
try std.testing.expectEqualStrings("event", result.?);
}
test "findMatchSubjectText: field access" {
const source = "if e.key == {\n case .";
const result = Server.findMatchSubjectText(source, @intCast(source.len));
try std.testing.expectEqualStrings("e.key", result.?);
}
test "findMatchSubjectText: nested braces" {
const source = "if event == {\n case .quit: { do_something(); }\n case .";
const result = Server.findMatchSubjectText(source, @intCast(source.len));
try std.testing.expectEqualStrings("event", result.?);
}
test "findMatchSubjectText: no match context" {
const source = "while true {\n case .";
const result = Server.findMatchSubjectText(source, @intCast(source.len));
try std.testing.expect(result == null);
}
test "findCaptureVariant: simple capture" {
const source = "case .key_down: (e) {\n e.";
const result = Server.findCaptureVariant(source, @intCast(source.len), "e");
try std.testing.expectEqualStrings("key_down", result.?);
}
test "findCaptureVariant: different name" {
const source = "case .mouse_motion: (evt) {\n evt.";
const result = Server.findCaptureVariant(source, @intCast(source.len), "evt");
try std.testing.expectEqualStrings("mouse_motion", result.?);
}
test "findCaptureVariant: no capture" {
const source = "case .quit: running = false;\n e.";
const result = Server.findCaptureVariant(source, @intCast(source.len), "e");
try std.testing.expect(result == null);
}
// ---- Helper function tests ----
test "extractQualifiedName: namespace.member at member offset" {
const source = "pkg.mul(3, 4)";
// offset inside "mul" (index 4)
const result = Server.extractQualifiedName(source, 4);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("pkg", result.?.ns);
try std.testing.expectEqualStrings("mul", result.?.member);
}
test "extractQualifiedName: namespace.member at namespace offset" {
const source = "pkg.mul(3, 4)";
// offset inside "pkg" (index 1)
const result = Server.extractQualifiedName(source, 1);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("pkg", result.?.ns);
try std.testing.expectEqualStrings("mul", result.?.member);
}
test "extractQualifiedName: bare identifier returns null" {
const source = "hello(42)";
const result = Server.extractQualifiedName(source, 2);
try std.testing.expect(result == null);
}
test "extractQualifiedName: chained a.b.c at c offset" {
const source = "a.b.c(1)";
// offset inside "c" (index 4)
const result = Server.extractQualifiedName(source, 4);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("b", result.?.ns);
try std.testing.expectEqualStrings("c", result.?.member);
}
test "positionToOffset: line 0 char 0" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 0, 0);
try std.testing.expectEqual(@as(u32, 0), result.?);
}
test "positionToOffset: line 1 char 0" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 1, 0);
try std.testing.expectEqual(@as(u32, 6), result.?);
}
test "positionToOffset: line 0 char 3" {
const source = "hello\nworld\n";
const result = Server.positionToOffset(source, 0, 3);
try std.testing.expectEqual(@as(u32, 3), result.?);
}
test "extractIdentAtOffset: middle of identifier" {
const source = "foo bar baz";
const result = Server.extractIdentAtOffset(source, 5);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("bar", result.?);
}
test "extractIdentAtOffset: at space returns null" {
const source = "foo bar";
const result = Server.extractIdentAtOffset(source, 3);
try std.testing.expect(result == null);
}
test "findSymbolByName: finds existing symbol" {
const symbols = [_]sx.sema.Symbol{
.{ .name = "add", .kind = .function, .ty = null, .def_span = .{ .start = 0, .end = 3 }, .scope_depth = 0 },
.{ .name = "mul", .kind = .function, .ty = null, .def_span = .{ .start = 10, .end = 13 }, .scope_depth = 0 },
};
const result = Server.findSymbolByName(&symbols, "mul");
try std.testing.expectEqual(@as(usize, 1), result.?);
}
test "findSymbolByName: returns null for missing" {
const symbols = [_]sx.sema.Symbol{
.{ .name = "add", .kind = .function, .ty = null, .def_span = .{ .start = 0, .end = 3 }, .scope_depth = 0 },
};
const result = Server.findSymbolByName(&symbols, "sub");
try std.testing.expect(result == null);
}
test "findImportPathAtOffset: inside import string" {
const source = "#import \"modules/std.sx\";";
// offset inside the string (index 10)
const result = Server.findImportPathAtOffset(source, 10);
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("modules/std.sx", result.?);
}
test "findImportPathAtOffset: outside import returns null" {
const source = "x := 42;";
const result = Server.findImportPathAtOffset(source, 2);
try std.testing.expect(result == null);
}
// ---- Document analysis pipeline tests ----
test "analyzeDocument: parse and sema basic function" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }";
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// Should have "add" as a function symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "add") != null);
const idx = Server.findSymbolByName(sema.symbols, "add").?;
try std.testing.expectEqual(sx.sema.SymbolKind.function, sema.symbols[idx].kind);
}
test "analyzeDocument: flat import pre-registers symbols" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
// Pre-load the imported module
const lib_src: [:0]const u8 = "mul :: (a: s32, b: s32) -> s32 { a * b; }";
const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1);
try store.analyzeDocument(lib_doc);
// Load main file with flat import
const main_src: [:0]const u8 =
\\#import "lib.sx";
\\main :: () -> s32 { mul(3, 4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// "mul" should be pre-registered from flat import
try std.testing.expect(Server.findSymbolByName(sema.symbols, "mul") != null);
// "main" should be defined locally
try std.testing.expect(Server.findSymbolByName(sema.symbols, "main") != null);
}
test "analyzeDocument: namespaced import registers namespace symbol" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
// Pre-load the imported module
const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }";
const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1);
try store.analyzeDocument(lib_doc);
// Load main file with namespaced import
const main_src: [:0]const u8 =
\\pkg :: #import "lib.sx";
\\main :: () -> s32 { pkg.add(3, 4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// "pkg" should be a namespace symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "pkg") != null);
const idx = Server.findSymbolByName(sema.symbols, "pkg").?;
try std.testing.expectEqual(sx.sema.SymbolKind.namespace, sema.symbols[idx].kind);
}
test "analyzeDocument: namespaced import fn_signatures have prefix" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }";
const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1);
try store.analyzeDocument(lib_doc);
const main_src: [:0]const u8 =
\\pkg :: #import "lib.sx";
\\main :: () -> s32 { pkg.add(1, 2); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// fn_signatures should contain "pkg.add"
try std.testing.expect(sema.fn_signatures.contains("pkg.add"));
}
test "analyzeDocument: pipe operator desugars to call" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const lib_src: [:0]const u8 = "add :: (a: s32, b: s32) -> s32 { a + b; }";
const lib_doc = try store.openOrUpdate("/test/lib.sx", lib_src, 1);
try store.analyzeDocument(lib_doc);
// Pipe operator should parse and analyze without errors
const main_src: [:0]const u8 =
\\pkg :: #import "lib.sx";
\\main :: () -> s32 { 3 |> pkg.add(4); }
;
const main_doc = try store.openOrUpdate("/test/main.sx", main_src, 1);
try store.analyzeDocument(main_doc);
const sema = main_doc.sema orelse return error.SkipZigTest;
// Should parse successfully with no sema errors for "add"
try std.testing.expect(Server.findSymbolByName(sema.symbols, "main") != null);
}
test "analyzeDocument: for-loop capture variables are registered" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (it, ix) {
\\ x := it + ix;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// Capture variables should be registered — "it" and "ix" should not produce unresolved errors
try std.testing.expect(Server.findSymbolByName(sema.symbols, "it") != null);
try std.testing.expect(Server.findSymbolByName(sema.symbols, "ix") != null);
// "x" should also be registered
try std.testing.expect(Server.findSymbolByName(sema.symbols, "x") != null);
}
test "analyzeDocument: for-loop with underscore capture" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (_, ix) {
\\ x := ix;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// "_" should NOT be registered as a symbol
try std.testing.expect(Server.findSymbolByName(sema.symbols, "_") == null);
// "ix" should be registered
try std.testing.expect(Server.findSymbolByName(sema.symbols, "ix") != null);
}
test "analyzeDocument: for-loop value-only capture" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var store = doc_mod.DocumentStore.init(alloc, undefined_io());
const src: [:0]const u8 =
\\main :: () {
\\ arr : [3]s32 = ---;
\\ for arr: (val) {
\\ x := val;
\\ }
\\}
;
const doc = try store.openOrUpdate("/test/main.sx", src, 1);
try store.analyzeDocument(doc);
const sema = doc.sema orelse return error.SkipZigTest;
// "val" should be registered
try std.testing.expect(Server.findSymbolByName(sema.symbols, "val") != null);
}
/// Helper: create a dummy std.Io that won't be called (used when all docs are pre-loaded).
fn undefined_io() std.Io {
return .{ .userdata = null, .vtable = @ptrFromInt(@intFromPtr(@as(?*const std.Io.VTable, null)) | 1) };
}