//! RinneScript language server (JSON-RPC 2.0 over stdio). //! //! Reuses the real lexer/parser/compiler pipeline for diagnostics — no //! duplicated parsing. Supports full-text sync, hover docs and completion //! for the standard builtin vocabulary. POSIX stdio only (v1). const std = @import("std"); // LSP speaks raw framed JSON-RPC over stdio; the process owns both pipes for // its lifetime, so libc's read/write are the simplest portable transport // (the binary already links libc for the FFI builtins). extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize; const PollFd = extern struct { fd: c_int, events: i16, revents: i16 }; extern "c" fn poll(fds: [*]PollFd, nfds: c_ulong, timeout: c_int) c_int; const POLLIN: i16 = 0x001; /// Returns 1 when stdin has data within `timeout_ms`, 0 on timeout. fn waitReadable(timeout_ms: c_int) c_int { var fds = [_]PollFd{.{ .fd = 0, .events = POLLIN, .revents = 0 }}; return poll(&fds, 1, timeout_ms); } extern "c" fn read(fd: c_int, buf: [*]u8, count: usize) isize; const rinne = @import("rinne"); const parser_mod = rinne.parser; const compiler_mod = rinne.compiler; const fmt_mod = rinne.fmt; const Doc = struct { name: []const u8, sig: []const u8, doc: []const u8 }; const docs = [_]Doc{ .{ .name = "print", .sig = "@print(fmt: str, args: tuple)", .doc = "Print formatted text to stdout. `{}` placeholders; `{{`/`}}` escape braces." }, .{ .name = "stdout", .sig = "@stdout(fmt: str, args: tuple)", .doc = "Formatted output to stdout (alias of print)." }, .{ .name = "stderr", .sig = "@stderr(fmt: str, args: tuple)", .doc = "Formatted output to stderr." }, .{ .name = "log", .sig = "@log(fmt: str, args: tuple)", .doc = "Formatted output to stderr (alias of stderr)." }, .{ .name = "len", .sig = "len(x) -> int", .doc = "Length of a str, array or map." }, .{ .name = "int", .sig = "int(v) -> int", .doc = "Coerce to integer." }, .{ .name = "float", .sig = "float(v) -> float", .doc = "Coerce to float." }, .{ .name = "str", .sig = "str(v) -> str", .doc = "Render any value as a string." }, .{ .name = "as", .sig = "@as(type, v)", .doc = "Cast with a type name: @as(float, 7). Aliases i32/u64/f32/... map to the core casts." }, .{ .name = "env", .sig = "env(name: str) -> str", .doc = "Read an environment variable (empty string when unset)." }, .{ .name = "clock", .sig = "clock() -> float", .doc = "Seconds since VM start." }, .{ .name = "read_file", .sig = "read_file(path: str) -> str", .doc = "Read a whole file." }, .{ .name = "write_file", .sig = "write_file(path: str, data: str)", .doc = "Write text to a file." }, .{ .name = "file_exists", .sig = "file_exists(path: str) -> bool", .doc = "True when the path exists." }, .{ .name = "args", .sig = "args() -> [str]", .doc = "CLI arguments passed to the script." }, .{ .name = "push", .sig = "push(arr, v) -> array", .doc = "Append a value (returns a new array)." }, .{ .name = "slice", .sig = "slice(x, start, end) -> same", .doc = "Substring/subarray; end exclusive; negatives count from the end." }, .{ .name = "split", .sig = "split(s: str, sep: str) -> [str]", .doc = "Split a string by a separator." }, .{ .name = "join", .sig = "join(parts: [str], sep: str) -> str", .doc = "Join strings with a separator." }, .{ .name = "trim", .sig = "trim(s: str) -> str", .doc = "Trim surrounding whitespace." }, .{ .name = "replace", .sig = "replace(s: str, old: str, new: str) -> str", .doc = "Replace all occurrences." }, .{ .name = "contains", .sig = "contains(s: str, sub: str) -> bool", .doc = "Substring test." }, .{ .name = "starts_with", .sig = "starts_with(s: str, p: str) -> bool", .doc = "Prefix test." }, .{ .name = "ends_with", .sig = "ends_with(s: str, suf: str) -> bool", .doc = "Suffix test." }, .{ .name = "to_lower", .sig = "to_lower(s: str) -> str", .doc = "ASCII lowercase." }, .{ .name = "to_upper", .sig = "to_upper(s: str) -> str", .doc = "ASCII uppercase." }, .{ .name = "map_new", .sig = "map_new() -> map", .doc = "Fresh string-keyed map (.{k=v} literals build these too)." }, .{ .name = "keys", .sig = "m.keys() -> [str]", .doc = "Map keys as an array." }, .{ .name = "values", .sig = "m.values() -> [v]", .doc = "Map values as an array." }, .{ .name = "has", .sig = "m.has(k: str) -> bool", .doc = "Key membership test." }, .{ .name = "remove", .sig = "m.remove(k: str)", .doc = "Delete a key." }, .{ .name = "json_parse", .sig = "json_parse(text: str) -> any", .doc = "Parse JSON into values/maps." }, .{ .name = "json_stringify", .sig = "json_stringify(v) -> str", .doc = "Serialize values to JSON." }, .{ .name = "csv_parse", .sig = "csv_parse(text: str) -> [[str]]", .doc = "Parse CSV (quoted fields, CRLF)." }, .{ .name = "csv_format", .sig = "csv_format(rows) -> str", .doc = "Serialize rows to CSV." }, .{ .name = "sha256", .sig = "sha256(s: str) -> str", .doc = "Hex SHA-256 digest." }, .{ .name = "base64_encode", .sig = "base64_encode(s: str) -> str", .doc = "Standard base64 encode." }, .{ .name = "base64_decode", .sig = "base64_decode(s: str) -> str", .doc = "Base64 decode (errors on invalid input)." }, .{ .name = "abs", .sig = "abs(x: num) -> int|float", .doc = "Absolute value; preserves the input's type." }, .{ .name = "floor", .sig = "floor(x: num) -> float", .doc = "Largest integer not greater than x." }, .{ .name = "ceil", .sig = "ceil(x: num) -> float", .doc = "Smallest integer not less than x." }, .{ .name = "round", .sig = "round(x: num) -> float", .doc = "Nearest integer (half away from zero)." }, .{ .name = "sqrt", .sig = "sqrt(x: num) -> float", .doc = "Principal square root." }, .{ .name = "pow", .sig = "pow(base: num, exp: num) -> float", .doc = "Raise base to the exp power." }, .{ .name = "min", .sig = "min(a: num, b: num) -> num", .doc = "Smaller of two numeric values (preserves int when both int)." }, .{ .name = "max", .sig = "max(a: num, b: num) -> num", .doc = "Larger of two numeric values (preserves int when both int)." }, .{ .name = "clamp", .sig = "clamp(v: num, lo: num, hi: num) -> num", .doc = "Constrain v into [lo, hi]." }, .{ .name = "repeat", .sig = "repeat(s: str, count: int) -> str", .doc = "Concatenate s count times." }, .{ .name = "chars", .sig = "chars(s: str) -> [str]", .doc = "Split a string into an array of one-character strings." }, .{ .name = "reverse", .sig = "reverse(s: str|array) -> same", .doc = "Reverse a string's bytes or an array's elements." }, .{ .name = "contains_val", .sig = "contains_val(arr, v) -> bool", .doc = "True when the array holds an equal value." }, .{ .name = "make_dir", .sig = "make_dir(path: str) -> bool", .doc = "Create a single directory." }, .{ .name = "make_dir_all", .sig = "make_dir_all(path: str) -> bool", .doc = "Create a directory and any missing parents." }, .{ .name = "remove_file", .sig = "remove_file(path: str) -> bool", .doc = "Delete a file (false when absent)." }, .{ .name = "remove_dir", .sig = "remove_dir(path: str) -> bool", .doc = "Delete an empty directory (false when absent)." }, .{ .name = "rename_file", .sig = "rename_file(from: str, to: str) -> bool", .doc = "Rename or move a file/directory." }, .{ .name = "append_file", .sig = "append_file(path: str, data: str) -> bool", .doc = "Append text to a file, creating it if needed." }, .{ .name = "list_dir", .sig = "list_dir(path: str) -> [str]", .doc = "Names of the entries (files+dirs) in a directory." }, .{ .name = "json_pretty", .sig = "json_pretty(v) -> str", .doc = "Serialize values to indented, multi-line JSON." }, .{ .name = "assert", .sig = "assert(cond)", .doc = "Runtime failure when the condition is falsy." }, .{ .name = "assert_eq", .sig = "assert_eq(a, b)", .doc = "Deep equality check with both values in the message." }, .{ .name = "panic", .sig = "panic(msg: str)", .doc = "Raise a runtime error carrying msg (catchable via try())." }, .{ .name = "try", .sig = "try(fn_call()) -> [ok, result_or_msg]", .doc = "Run a call guarded; runtime errors become [false, message]." }, .{ .name = "log_write", .sig = "log_write(level, msg: str)", .doc = "Structured stderr line: [INFO] msg. Level int 0-4 or custom tag." }, .{ .name = "vector", .sig = "vector(len, \"type\") -> []T", .doc = "Zero-filled typed vector: std.vec.vector(4, f32)." }, .{ .name = "splat", .sig = "splat(len, v)", .doc = "Vector with every element v: std.vec.splat(4, 2)." }, .{ .name = "reduce", .sig = "reduce(op, arr)", .doc = "Reduce an array with an op fn name (add/mul/min/max)." }, .{ .name = "select", .sig = "select(mask, a, b)", .doc = "Element-wise b[mask] ? a : b over equal-length vectors." }, .{ .name = "tcp_listen", .sig = "tcp_listen(port: int)", .doc = "Bind 0.0.0.0:port (0 = ephemeral); requires `@import = .{ net };`." }, .{ .name = "tcp_connect", .sig = "tcp_connect(host: str, port: int)", .doc = "Connect to a TCP server; requires the net module." }, .{ .name = "load_lib", .sig = "load_lib(path: str)", .doc = "dlopen a shared library (sonames resolve); requires the c module. fabs lives in libm." }, .{ .name = "llm_load", .sig = "llm_load(path: str)", .doc = "Load a GGUF transformer (rinne.llm.* convention); requires llm module." }, .{ .name = "load_safetensors", .sig = "load_safetensors(path: str)", .doc = "Zero-copy SafeTensors views; requires ai module." }, .{ .name = "load_gguf", .sig = "load_gguf(path: str)", .doc = "Zero-copy GGUF tensor views (F32/F64/I32/I64); requires ai module." }, }; fn docFor(name: []const u8) ?Doc { for (docs) |d| { if (std.mem.eql(u8, d.name, name)) return d; } return null; } // --------------------------------------------------------------------------- // stdio framing (POSIX blocking I/O) // --------------------------------------------------------------------------- var g_in_buf: [1]u8 = undefined; fn readByte() !u8 { const b = &g_in_buf; const n = std.posix.read(0, b[0..1]) catch return error.ReadFailed; if (n == 0) return error.EndOfStream; return b[0]; } fn readLine(buf: []u8) ![]u8 { var i: usize = 0; while (i < buf.len) { const b = try readByte(); if (b == '\n') return buf[0..i]; if (b != '\r') { buf[i] = b; i += 1; } } return error.HeaderTooLong; } /// Reads one Content-Length framed JSON-RPC message. Caller owns the slice. fn readMessage(alloc: std.mem.Allocator) ![]u8 { var line_buf: [256]u8 = undefined; var content_length: ?usize = null; while (true) { const line = try readLine(&line_buf); if (line.len == 0) break; // blank line = end of headers const prefix = "Content-Length:"; if (line.len > prefix.len and std.ascii.startsWithIgnoreCase(line, prefix)) { const val = std.mem.trim(u8, line[prefix.len..], " \t"); content_length = std.fmt.parseInt(usize, val, 10) catch return error.BadHeader; } } const len = content_length orelse return error.BadHeader; if (len > 16 * 1024 * 1024) return error.MessageTooLarge; const body = alloc.alloc(u8, len) catch return error.OutOfMemory; errdefer alloc.free(body); var got: usize = 0; while (got < len) { const n = read(0, body[got..].ptr, body[got..].len); if (n <= 0) return error.ReadFailed; got += @intCast(n); } return body; } fn writeAllOut(bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = write(1, bytes[off..].ptr, bytes[off..].len); if (n <= 0) return; off += @intCast(n); } } fn jsonEscape(alloc: std.mem.Allocator, s: []const u8) []u8 { var out: std.ArrayList(u8) = .empty; for (s) |c| { switch (c) { '"' => out.appendSlice(alloc, "\\\"") catch {}, '\\' => out.appendSlice(alloc, "\\\\") catch {}, '\n' => out.appendSlice(alloc, "\\n") catch {}, '\r' => out.appendSlice(alloc, "\\r") catch {}, '\t' => out.appendSlice(alloc, "\\t") catch {}, else => { if (c < 0x20) { var b: [8]u8 = undefined; const hex = std.fmt.bufPrint(&b, "\\u{x:0>4}", .{c}) catch continue; out.appendSlice(alloc, hex) catch {}; } else { out.append(alloc, c) catch {}; } }, } } return out.toOwnedSlice(alloc) catch alloc.dupe(u8, "") catch @panic("oom"); } fn sendMessage(alloc: std.mem.Allocator, payload: []const u8) void { const msg = std.fmt.allocPrint(alloc, "Content-Length: {d}\r\n\r\n{s}", .{ payload.len, payload }) catch return; defer alloc.free(msg); writeAllOut(msg); } // --------------------------------------------------------------------------- // Diagnostics via the real pipeline // --------------------------------------------------------------------------- /// A structured diagnostic in 0-based LSP coordinates. `message` is owned by /// the receiver and must be freed with the same allocator. const Diag = struct { start_line: u32, start_col: u32, end_line: u32, end_col: u32, message: []u8, }; /// Returns the 0-based-indexed line `want` from `text`, clamped to the last /// existing line when past EOF. Never returns null. fn lineSpan(text: []const u8, want: usize) struct { idx: usize, row: []const u8 } { var ln: usize = 0; var last_idx: usize = 0; var last_row: []const u8 = ""; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |l| : (ln += 1) { if (ln == want) return .{ .idx = ln, .row = l }; last_idx = ln; last_row = l; } return .{ .idx = last_idx, .row = last_row }; } const ident_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.@"; /// Converts a 1-based pipeline position (parser/compiler err_line/err_col) /// into a precise 0-based LSP range on `text`. The end column extends through /// the word starting at the position (or to end-of-line for punctuation). /// Unknown positions (0,0) start the range at the document head; positions /// past EOF clamp to the last line. fn diagRange(text: []const u8, line1: usize, col1: usize) struct { sl: u32, sc: u32, el: u32, ec: u32 } { const want: usize = if (line1 > 0) line1 - 1 else 0; const span = lineSpan(text, want); if (span.row.len == 0) return .{ .sl = @intCast(span.idx), .sc = 0, .el = @intCast(span.idx), .ec = 0 }; const col0: usize = if (col1 > 0) col1 - 1 else 0; const start = @min(col0, span.row.len - 1); var end: usize = start; if (std.mem.indexOfScalar(u8, ident_chars, span.row[end]) != null) { while (end < span.row.len and std.mem.indexOfScalar(u8, ident_chars, span.row[end]) != null) end += 1; } else { end = span.row.len; } return .{ .sl = @intCast(span.idx), .sc = @intCast(start), .el = @intCast(span.idx), .ec = @intCast(end), }; } /// Parses + compiles `text`; returns the first stage's error as a structured /// diagnostic or null when the document is clean. Positions come straight /// from the parser's / compiler's err_line/err_col fields — no message /// parsing. fn diagnose(alloc: std.mem.Allocator, text: []const u8) ?Diag { // Scratch lives on page_allocator, not the caller's (resetting) message // arena: nested arenas would pin their nodes in it until reset, which F2 // bisection tied to linear RSS growth. deinit() returns pages directly. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); const msg_from = struct { fn make(al: std.mem.Allocator, t: []const u8, l: usize, c: usize, m: []const u8) ?Diag { const copy = al.dupe(u8, m) catch return null; const r = diagRange(t, l, c); return .{ .start_line = r.sl, .start_col = r.sc, .end_line = r.el, .end_col = r.ec, .message = copy }; } }.make; var parser = parser_mod.Parser.init(a, text); var program = parser.parseProgram() catch { if (parser.err_msg) |m| return msg_from(alloc, text, parser.err_line, parser.err_col, m); return msg_from(alloc, text, 0, 0, "parse error"); }; defer program.deinit(a); var compiler = compiler_mod.Compiler.init(a); defer compiler.deinit(); _ = compiler.compileProgram(&program) catch { if (compiler.err_msg) |m| return msg_from(alloc, text, compiler.err_line, compiler.err_col, m); return null; }; return null; } fn publishDiagnostics(alloc: std.mem.Allocator, uri: []const u8, text: []const u8) void { const diag = diagnose(alloc, text); defer if (diag) |d| alloc.free(d.message); var body: std.ArrayList(u8) = .empty; body.appendSlice(alloc, "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{\"uri\":\"") catch return; const e_uri = jsonEscape(alloc, uri); body.appendSlice(alloc, e_uri) catch return; body.appendSlice(alloc, "\",\"diagnostics\":[") catch return; if (diag) |d| { const e_msg = jsonEscape(alloc, d.message); const piece = allocPrint( alloc, "{{\"range\":{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}},\"severity\":1,\"source\":\"rinne\",\"message\":\"{s}\"}}", .{ d.start_line, d.start_col, d.end_line, d.end_col, e_msg }, ) catch return; body.appendSlice(alloc, piece) catch return; } body.appendSlice(alloc, "]}}") catch return; sendMessage(alloc, body.items); } /// Builds the JSON-RPC `result` for textDocument/formatting: a single /// whole-document TextEdit when `rinne fmt` would change the text, else /// `null`. Pure aside from allocation — trivially testable without stdio. fn formattingReply(alloc: std.mem.Allocator, text: []const u8) (fmt_mod.Error || error{OutOfMemory})![]u8 { const formatted = try fmt_mod.formatSource(alloc, text); if (std.mem.eql(u8, formatted, text)) { alloc.free(formatted); return alloc.dupe(u8, "null"); } // End range covers the whole original document: last line index is the // number of '\n's; last line length is whatever follows the final one. var last_line: usize = 0; var last_len: usize = text.len; for (text, 0..) |c, i| { if (c == '\n') { last_line += 1; last_len = text.len - i - 1; } } const e_new = jsonEscape(alloc, formatted); defer alloc.free(e_new); alloc.free(formatted); // LSP result type for textDocument/formatting is TextEdit[] — a single // whole-document edit, wrapped. return allocPrint( alloc, "[{{\"range\":{{\"start\":{{\"line\":0,\"character\":0}},\"end\":{{\"line\":{d},\"character\":{d}}}}},\"newText\":\"{s}\"}}]", .{ last_line, last_len, e_new }, ); } // --------------------------------------------------------------------------- // Document store + JSON-RPC handlers // --------------------------------------------------------------------------- const max_docs = 64; const StoredDoc = struct { uri: [256]u8 = undefined, uri_len: usize = 0, text: ?[]u8 = null }; var g_docs: [max_docs]StoredDoc = [_]StoredDoc{.{ .uri_len = 0, .text = null }} ** max_docs; fn storeSet(alloc: std.mem.Allocator, uri: []const u8, text: []const u8) void { var slot: ?*StoredDoc = null; for (&g_docs) |*d| { if (d.uri_len == 0) { if (slot == null) slot = d; continue; } if (std.mem.eql(u8, d.uri[0..d.uri_len], uri)) { slot = d; break; } } const d = slot orelse return; // table full: ignore silently @memcpy(d.uri[0..uri.len], uri); d.uri_len = uri.len; if (d.text) |old| alloc.free(old); d.text = alloc.dupe(u8, text) catch null; } fn storeGet(uri: []const u8) ?[]u8 { for (&g_docs) |*d| { if (d.uri_len != 0 and std.mem.eql(u8, d.uri[0..d.uri_len], uri)) return d.text; } return null; } /// Returns the full text of the 0-based `line` (without trailing newline), /// or null when the line is out of range. fn lineAt(text: []const u8, line: usize) ?[]const u8 { var it = std.mem.splitScalar(u8, text, '\n'); var ln: usize = 0; while (it.next()) |l| : (ln += 1) { if (ln == line) return l; } return null; } /// Extracts the identifier under a 0-based position from stored text. fn wordAt(text: []const u8, line: usize, col: usize) ?[]const u8 { var it = std.mem.splitScalar(u8, text, '\n'); var ln: usize = 0; var row: []const u8 = ""; while (it.next()) |l| { if (ln == line) { row = l; break; } ln += 1; } var c = col; if (c >= row.len) c = if (row.len > 0) row.len - 1 else return null; var start = c; while (start > 0 and std.mem.indexOfScalar(u8, ident_chars, row[start - 1]) != null) start -= 1; var end: usize = c; while (end < row.len and std.mem.indexOfScalar(u8, ident_chars, row[end]) != null) end += 1; if (start == end) return null; return row[start..end]; } fn respond(alloc: std.mem.Allocator, id_json: []const u8, result: []const u8) void { const payload = allocPrint(alloc, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"result\":{s}}}", .{ id_json, result }) catch return; sendMessage(alloc, payload); } const allocPrint = std.fmt.allocPrint; /// Builds a snippet-format insertText from a doc signature: /// `len(x) -> int` becomes `len(${1:x})$0`; zero-param fns get `clock()$0`. /// Placeholder names use the first token of each parameter (before spaces). fn snippetFor(alloc: std.mem.Allocator, name: []const u8, sig: []const u8) []const u8 { var out: std.ArrayList(u8) = .empty; out.appendSlice(alloc, name) catch return name; const open = std.mem.indexOfScalar(u8, sig, '(') orelse { out.appendSlice(alloc, "()$0") catch {}; return out.toOwnedSlice(alloc) catch name; }; const close = std.mem.indexOfScalarPos(u8, sig, open, ')') orelse sig.len; const params = if (open + 1 <= close and close <= sig.len) sig[open + 1 .. @min(close, sig.len)] else ""; out.append(alloc, '(') catch return name; if (params.len == 0) { out.appendSlice(alloc, ")$0") catch return name; return out.toOwnedSlice(alloc) catch name; } var it = std.mem.splitScalar(u8, params, ','); var n: usize = 1; while (it.next()) |raw| { const p = std.mem.trim(u8, raw, " "); if (p.len == 0) continue; // First token of the param is its name. var end: usize = 0; while (end < p.len and p[end] != ' ' and p[end] != ':') end += 1; if (n > 1) out.appendSlice(alloc, ", ") catch {}; // LSP snippet placeholder: `${n:name}` (braces hold the default text). // Built explicitly since `$` and `{}` collide with fmt specifiers. var buf: [48]u8 = undefined; if (end > buf.len - 6) { out.appendSlice(alloc, p[0..end]) catch {}; n += 1; continue; } var idx: usize = 0; buf[idx] = '$'; idx += 1; buf[idx] = '{'; idx += 1; const nd = std.fmt.bufPrint(buf[idx..], "{d}", .{n}) catch break; idx += nd.len; buf[idx] = ':'; idx += 1; @memcpy(buf[idx .. idx + end], p[0..end]); idx += end; buf[idx] = '}'; idx += 1; out.appendSlice(alloc, buf[0..idx]) catch {}; n += 1; } out.appendSlice(alloc, ")$0") catch {}; return out.toOwnedSlice(alloc) catch name; } /// Extracts the parenthesized parameter list from a user declaration's source /// line: `pub fn add(a: int, b: int) int {` -> `(a: int, b: int)` (the form /// `snippetFor` expects the bare `( ... )` group). Returns a slice borrowed /// from `line`, or null when there is no `( ... )` pair (const/var /// declarations, malformed source). fn sigFromDeclLine(line: []const u8) ?[]const u8 { const open = std.mem.indexOfScalar(u8, line, '(') orelse return null; const close = std.mem.indexOfScalarPos(u8, line, open, ')') orelse return null; return line[open..close + 1]; } /// Builds the completion item list: keywords, builtins (with detail), and — /// when `prefix_ns` is set — that namespace's members only. /// Known module namespaces and their callable members. const ns_names = [_][]const u8{ "std", "ai", "llm", "net", "c" }; fn nsIndex(name: []const u8) ?usize { for (ns_names, 0..) |n, i| { if (std.mem.eql(u8, n, name)) return i; } return null; } /// The leading module of a namespace path: "std.io" -> "std", "c" -> "c". fn nsRoot(prefix: []const u8) []const u8 { const dot = std.mem.indexOfScalar(u8, prefix, '.') orelse return prefix; return prefix[0..dot]; } /// True when `builtin`'s canonical path lives under the `ns` prefix (which may /// be a dotted sub-namespace like "std.io" or a single module like "c"). /// Derived from the compiler's canonical builtin_paths table — the single /// source of truth — so the LSP never drifts from the language. fn nsHas(ns: []const u8, builtin: []const u8) bool { const p = compiler_mod.builtinPath(builtin) orelse return false; if (p.len == 0 or p[0] == '@') return false; // directive-only (@int, @as...) if (ns.len == 0) return true; if (p.len <= ns.len or !std.mem.startsWith(u8, p, ns)) return false; // The namespace boundary must align at a dot: "std.io" matches std.io.read // but must NOT match std.ioo.read (prefix-only, no dot). return p[ns.len] == '.'; } /// True when `ns` appears inside an @import declaration in `text`. fn nsImported(text: []const u8, ns: []const u8) bool { var i: usize = 0; while (i < text.len) { const at = std.mem.indexOfPos(u8, text, i, "@import") orelse return false; const close = std.mem.indexOfScalarPos(u8, text, at, '}') orelse return false; const seg = text[at..close]; var j: usize = 0; while (j < seg.len) : (j += 1) { const c = seg[j]; if (!(c == '_' or std.ascii.isAlphabetic(c))) continue; var k = j + 1; while (k < seg.len and (std.ascii.isAlphanumeric(seg[k]) or seg[k] == '_')) k += 1; if (std.mem.eql(u8, seg[j..k], ns)) return true; j = k; } i = close + 1; } return false; } /// Returns the dotted namespace prefix when the cursor sits right after a dot /// at the end of a dotted identifier chain that starts with a module namespace /// (e.g. `std.io.` -> "std.io", `std.` -> "std", `c.` -> "c"). Returns null for /// any other dot (object method like `m.keys()`, plain locals, etc.). The /// result is allocated in `alloc`. fn dotNamespace(alloc: std.mem.Allocator, text: []const u8, line_idx: usize, col: usize) ?[]const u8 { var row_start: usize = 0; var li: usize = 0; while (li < line_idx and row_start < text.len) { if (text[row_start] == '\n') li += 1; row_start += 1; } var row_end = row_start; while (row_end < text.len and text[row_end] != '\n') row_end += 1; var c = @min(col, row_end - row_start); const row = text[row_start..row_end]; // Skip whitespace directly before the cursor. while (c > 0 and (row[c - 1] == ' ' or row[c - 1] == '\t')) c -= 1; if (c == 0 or row[c - 1] != '.') return null; // Walk left across `id . id . ...` and require the first id to be a // module namespace (std/ai/llm/net/c). var seg_begin = c - 1; while (true) { const end = seg_begin; var begin = end; while (begin > 0 and (std.ascii.isAlphanumeric(row[begin - 1]) or row[begin - 1] == '_')) begin -= 1; if (begin == end) return null; // no identifier segment seg_begin = begin; if (begin == 0 or row[begin - 1] != '.') { // Reached the chain root: it must be a module namespace. const root = row[begin..end]; if (nsIndex(root) == null) return null; break; } seg_begin = begin - 1; // include the separating dot } // seg_begin points at the start of the root module identifier; the dotted // chain runs up to (but not including) the trailing dot at `c - 1`. if (seg_begin + 1 >= c) return null; if (seg_begin > 0 and row[seg_begin - 1] == '.') return null; return alloc.dupe(u8, row[seg_begin .. c - 1]) catch return null; } // --------------------------------------------------------------------------- // Document-local declarations (lightweight lexical scan) // --------------------------------------------------------------------------- /// Kinds of top-level declarations the scanner recognizes. Mirrors the /// language surface: `fn`/`pub fn`, `const`, `var` — the parser has no /// user-defined type declarations. const DeclKind = enum { fn_decl, const_decl, var_decl }; const Decl = struct { /// Borrowed from the scanned document text. name: []const u8, kind: DeclKind, /// 0-based line of the declaration keyword. line: u32, /// 0-based column of the declaration NAME within its line. col: u32, }; /// SymbolKind for a scanned declaration (documentSymbol). fn declSymbolKind(k: DeclKind) u8 { return switch (k) { .fn_decl => 12, // Function .var_decl => 13, // Variable .const_decl => 14, // Constant }; } /// CompletionItemKind for a scanned declaration. fn declCompletionKind(k: DeclKind) u8 { return switch (k) { .fn_decl => 3, // Function .var_decl => 6, // Variable .const_decl => 21, // Constant }; } fn isIdentChar(c: u8) bool { return std.ascii.isAlphanumeric(c) or c == '_'; } /// Lexical scan for TOP-LEVEL declarations (`fn`/`pub fn`, `const`, `var`). /// Tracks brace depth outside line comments and string literals so locals /// inside function bodies are excluded. Names borrow from `text`. fn scanDeclarations(alloc: std.mem.Allocator, text: []const u8) error{OutOfMemory}![]Decl { var out: std.ArrayList(Decl) = .empty; errdefer out.deinit(alloc); var depth: usize = 0; var line: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |row| : (line += 1) { // Cut the row at `//` when it sits outside a string literal // (strings cannot span lines in RinneScript). var code_end: usize = row.len; var in_str = false; var i: usize = 0; while (i < row.len) : (i += 1) { const c = row[i]; if (c == '"') { in_str = !in_str; } else if (!in_str and c == '/' and i + 1 < row.len and row[i + 1] == '/') { code_end = i; break; } } // Match a declaration only at top level (before this row's braces // are counted, so `fn f() ... {` headers still register). if (depth == 0) { // Manual left-trim keeps the offset so the NAME column stays // exact for definition ranges. var s: usize = 0; while (s < code_end and (row[s] == ' ' or row[s] == '\t' or row[s] == '\r')) s += 1; const t = row[s..code_end]; var kind: ?DeclKind = null; var name_start: usize = 0; if (std.mem.startsWith(u8, t, "pub fn ")) { kind = .fn_decl; name_start = "pub fn ".len; } else if (std.mem.startsWith(u8, t, "fn ")) { kind = .fn_decl; name_start = "fn ".len; } else if (std.mem.startsWith(u8, t, "const ")) { kind = .const_decl; name_start = "const ".len; } else if (std.mem.startsWith(u8, t, "var ")) { kind = .var_decl; name_start = "var ".len; } if (kind) |k| { var e = name_start; while (e < t.len and isIdentChar(t[e])) e += 1; if (e > name_start) { try out.append(alloc, .{ .name = t[name_start..e], .kind = k, .line = @intCast(line), .col = @intCast(s + name_start), }); } } } // Brace-depth update over the code portion only (string-aware): // format placeholders like "{}" live inside literals and must not // corrupt the nesting count. var j: usize = 0; in_str = false; while (j < code_end) : (j += 1) { const c = row[j]; if (c == '"') { in_str = !in_str; } else if (!in_str) { if (c == '{') { depth += 1; } else if (c == '}') { depth = if (depth > 0) depth - 1 else 0; } } } } return out.toOwnedSlice(alloc); } fn completionItems(parent_alloc: std.mem.Allocator, text: []const u8, prefix_ns: ?[]const u8) error{OutOfMemory}![]u8 { // All intermediate strings (escaped labels, snippets, the item JSON // itself) live in a scratch arena; only the joined list escapes to the // caller's allocator. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, "["); var first = true; if (prefix_ns == null) { const kws = [_][]const u8{ "pub", "fn", "const", "var", "if", "else", "while", "for", "return", "break", "continue", "defer", "and", "or", "not", "true", "false", "null" }; for (kws) |kw| { if (!first) out.append(alloc, ',') catch {}; first = false; const e_kw = jsonEscape(alloc, kw); out.appendSlice(alloc, allocPrint( alloc, "{{\"label\":\"{s}\",\"kind\":14,\"insertText\":\"{s}\",\"insertTextFormat\":1}}", .{ e_kw, e_kw }, ) catch continue) catch {}; } // The module namespaces themselves: typing `std.` opens its members. for (compiler_mod.modules) |m| { if (!first) out.append(alloc, ',') catch {}; first = false; const e_m = jsonEscape(alloc, m); const insert = allocPrint(alloc, "{s}.", .{m}) catch continue; const e_insert = jsonEscape(alloc, insert); out.appendSlice(alloc, allocPrint( alloc, "{{\"label\":\"{s}\",\"kind\":9,\"insertText\":\"{s}\",\"insertTextFormat\":1}}", .{ e_m, e_insert }, ) catch continue) catch {}; } } for (docs) |d| { if (prefix_ns) |ns| { if (!nsHas(ns, d.name)) continue; } else { // Bare names are no longer valid after the namespacing redesign: // builtins are reached through a namespace, so in plain-word // context (no `std.`) we do not offer them. Complete std., c., etc. continue; } if (!first) out.append(alloc, ',') catch {}; first = false; const e_name = jsonEscape(alloc, d.name); const e_sig = jsonEscape(alloc, d.sig); const e_doc = jsonEscape(alloc, d.doc); // Snippet-format insert: `push(${1:arr}, ${2:v})$0` — editors with // snippet support (nvim cmp, VS Code) expand tabstops directly. const snip_raw = snippetFor(alloc, d.name, d.sig); const e_snip = jsonEscape(alloc, snip_raw); out.appendSlice(alloc, allocPrint( alloc, "{{\"label\":\"{s}\",\"kind\":3,\"detail\":\"{s}\",\"documentation\":\"{s}\",\"insertText\":\"{s}\",\"insertTextFormat\":2}}", .{ e_name, e_sig, e_doc, e_snip }, ) catch continue) catch {}; } // Document-local symbols (plain-word context only): top-level fns and // variables declared in the current buffer. if (prefix_ns == null) { const locals = try scanDeclarations(alloc, text); defer alloc.free(locals); for (locals) |d| { if (!first) out.append(alloc, ',') catch {}; first = false; const e_name = jsonEscape(alloc, d.name); // User-defined functions use snippet-format insert so completing // a local fn places the cursor in its parameter list (like ZLS); // const/var locals insert plain text. if (d.kind == .fn_decl) { const line_src = lineAt(text, d.line) orelse ""; const sig = sigFromDeclLine(line_src); const snip = if (sig) |s| snippetFor(alloc, d.name, s) else alloc.dupe(u8, d.name) catch d.name; const e_snip = jsonEscape(alloc, snip); out.appendSlice(alloc, allocPrint( alloc, "{{\"label\":\"{s}\",\"kind\":{d},\"insertText\":\"{s}\",\"insertTextFormat\":2}}", .{ e_name, declCompletionKind(d.kind), e_snip }, ) catch continue) catch {}; } else { out.appendSlice(alloc, allocPrint( alloc, "{{\"label\":\"{s}\",\"kind\":{d},\"insertText\":\"{s}\",\"insertTextFormat\":1}}", .{ e_name, declCompletionKind(d.kind), e_name }, ) catch continue) catch {}; } } } try out.appendSlice(alloc, "]"); const joined = try out.toOwnedSlice(alloc); return parent_alloc.dupe(u8, joined); } fn handleHover(alloc: std.mem.Allocator, id: []const u8, params_json: std.json.Value) void { const obj = switch (params_json) { .object => |o| o, else => return respond(alloc, id, "null"), }; const pos = obj.get("position") orelse return respond(alloc, id, "null"); const td = obj.get("textDocument") orelse return respond(alloc, id, "null"); const uri_v = td.object.get("uri") orelse return respond(alloc, id, "null"); const line_v = pos.object.get("line").?.integer; const col_v = pos.object.get("character").?.integer; const text = storeGet(uri_v.string) orelse return respond(alloc, id, "null"); const word = wordAt(text, @intCast(line_v), @intCast(col_v)) orelse return respond(alloc, id, "null"); // Namespace member hover: std.print -> print doc. var lookup = word; if (std.mem.indexOfScalar(u8, word, '.')) |dot| lookup = word[dot + 1 ..]; // Strip trailing '(' from call-position words. if (std.mem.endsWith(u8, lookup, "(")) lookup = lookup[0 .. lookup.len - 1]; // Builtin docs first; fall through to user declarations when unknown. if (docFor(lookup)) |d| { const md = allocPrint(alloc, "```rinne\n{s}\n```\n{s}", .{ d.sig, d.doc }) catch return; const e_md = jsonEscape(alloc, md); respond(alloc, id, allocPrint(alloc, "{{\"contents\":{{\"kind\":\"markdown\",\"value\":\"{s}\"}}}}", .{e_md}) catch "null"); return; } // User-defined top-level declaration: hover shows its kind and the full // declaration source line so `gd`-less editors still surface the shape. const decls = scanDeclarations(alloc, text) catch return respond(alloc, id, "null"); defer alloc.free(decls); for (decls) |dc| { if (std.mem.eql(u8, dc.name, lookup)) { const kind_label = switch (dc.kind) { .fn_decl => "function", .const_decl => "const", .var_decl => "var", }; const line_src = lineAt(text, dc.line) orelse ""; const md2 = allocPrint( alloc, "```rinne\n{s}\n```\n**{s} ({s})**", .{ line_src, kind_label, dc.name }, ) catch return respond(alloc, id, "null"); const e_md2 = jsonEscape(alloc, md2); respond(alloc, id, allocPrint(alloc, "{{\"contents\":{{\"kind\":\"markdown\",\"value\":\"{s}\"}}}}", .{e_md2}) catch "null"); return; } } respond(alloc, id, "null"); } fn respondList(alloc: std.mem.Allocator, id: []const u8, items: []u8) void { const list = allocPrint( alloc, "{{\"isIncomplete\":false,\"items\":{s}}}", .{items}, ) catch { respond(alloc, id, "{\"isIncomplete\":false,\"items\":[]}"); return; }; respond(alloc, id, list); } /// Builds the `textDocument/documentSymbol` result: a flat list mirroring /// the document's top-level declarations. Scratch work stays in an arena; /// only the joined JSON escapes to the caller's allocator. fn symbolsReply(alloc: std.mem.Allocator, text: []const u8) error{OutOfMemory}![]u8 { // Scratch lives on page_allocator, not the caller's (resetting) message // arena: nested arenas would pin their nodes in it until reset, which F2 // bisection tied to linear RSS growth. deinit() returns pages directly. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); var out: std.ArrayList(u8) = .empty; try out.appendSlice(a, "["); const decls = try scanDeclarations(a, text); var first = true; for (decls) |d| { if (!first) out.append(a, ',') catch {}; first = false; const e_name = jsonEscape(a, d.name); const name_len = d.name.len; // range == selectionRange == the declaration NAME itself. out.appendSlice(a, allocPrint( a, "{{\"name\":\"{s}\",\"kind\":{d},\"range\":{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}},\"selectionRange\":{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}}}", .{ e_name, declSymbolKind(d.kind), d.line, d.col, d.line, d.col + name_len, d.line, d.col, d.line, d.col + name_len, }, ) catch continue) catch {}; } try out.appendSlice(a, "]"); return alloc.dupe(u8, out.items); } /// Builds the `textDocument/definition` result for the word under a 0-based /// position: a Location on the same-document declaration, or null. fn definitionReply( alloc: std.mem.Allocator, uri: []const u8, text: []const u8, line: usize, col: usize, ) error{OutOfMemory}![]u8 { const word_raw = wordAt(text, line, col) orelse return alloc.dupe(u8, "null") catch error.OutOfMemory; var word = word_raw; // Namespace member / call-position words resolve to their bare name. if (std.mem.indexOfScalar(u8, word, '.')) |dot| word = word[dot + 1 ..]; if (std.mem.endsWith(u8, word, "(")) word = word[0 .. word.len - 1]; const decls = try scanDeclarations(alloc, text); defer alloc.free(decls); for (decls) |d| { if (std.mem.eql(u8, d.name, word)) { const e_uri = jsonEscape(alloc, uri); defer alloc.free(e_uri); return allocPrint( alloc, "{{\"uri\":\"{s}\",\"range\":{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}}}", .{ e_uri, d.line, d.col, d.line, d.col + d.name.len }, ); } } return alloc.dupe(u8, "null") catch error.OutOfMemory; } fn handleDocumentSymbol(alloc: std.mem.Allocator, id: []const u8, params_json: std.json.Value) void { if (params_json != .object) return respond(alloc, id, "null"); const td = params_json.object.get("textDocument") orelse return respond(alloc, id, "null"); if (td != .object) return respond(alloc, id, "null"); const uri_v = td.object.get("uri") orelse return respond(alloc, id, "null"); if (uri_v != .string) return respond(alloc, id, "null"); const text = storeGet(uri_v.string) orelse return respond(alloc, id, "[]"); const rep = symbolsReply(alloc, text) catch return respond(alloc, id, "null"); respond(alloc, id, rep); } fn handleDefinition(alloc: std.mem.Allocator, id: []const u8, params_json: std.json.Value) void { if (params_json != .object) return respond(alloc, id, "null"); const obj = params_json.object; const td = obj.get("textDocument") orelse return respond(alloc, id, "null"); if (td != .object) return respond(alloc, id, "null"); const uri_v = td.object.get("uri") orelse return respond(alloc, id, "null"); if (uri_v != .string) return respond(alloc, id, "null"); const pos = obj.get("position") orelse return respond(alloc, id, "null"); if (pos != .object) return respond(alloc, id, "null"); const line_v = pos.object.get("line") orelse return respond(alloc, id, "null"); const col_v = pos.object.get("character") orelse return respond(alloc, id, "null"); if (line_v != .integer or col_v != .integer) return respond(alloc, id, "null"); const text = storeGet(uri_v.string) orelse return respond(alloc, id, "null"); const rep = definitionReply( alloc, uri_v.string, text, @intCast(line_v.integer), @intCast(col_v.integer), ) catch return respond(alloc, id, "null"); respond(alloc, id, rep); } fn handleCompletion(alloc: std.mem.Allocator, id: []const u8, params_json: std.json.Value) void { const empty_id = "{\"isIncomplete\":false,\"items\":[]}"; // Resolve the open document (when the client names one): needed for both // dot-context detection and local-symbol completions. var text: []const u8 = ""; if (params_json == .object) { if (params_json.object.get("textDocument")) |td| { if (td == .object) { if (td.object.get("uri")) |u| { if (u == .string) text = storeGet(u.string) orelse ""; } } } } // Dot-context: `X.` where X is a module namespace -> that module's // members (only when imported). Any other `x.` -> nothing: plain local // variables have no methods in v1. if (params_json == .object) blk: { const obj = params_json.object; const pos = obj.get("position") orelse break :blk; const line_v = pos.object.get("line") orelse break :blk; const col_v = pos.object.get("character") orelse break :blk; if (line_v != .integer or col_v != .integer) break :blk; if (dotNamespace(alloc, text, @intCast(line_v.integer), @intCast(col_v.integer))) |prefix| { // The leading module must be imported (std is implicit; c/ai/llm/net // require @import). std.io / std.vec etc. reduce to std. const root = nsRoot(prefix); if (!std.mem.eql(u8, root, "std") and !nsImported(text, root)) { respond(alloc, id, empty_id); return; } const items = completionItems(alloc, text, prefix) catch { respond(alloc, id, empty_id); return; }; respondList(alloc, id, items); return; } // No dot before the cursor -> fall through to the full list. } // Plain-word context (or missing params): keywords + builtins + this // document's top-level declarations; editors filter client-side. const items = completionItems(alloc, text, null) catch { respond(alloc, id, empty_id); return; }; respondList(alloc, id, items); } // --------------------------------------------------------------------------- // Main loop // --------------------------------------------------------------------------- pub fn run(alloc: std.mem.Allocator) !void { var shutdown_requested = false; // Debounce state: hold diagnostics until the editor pauses typing, then // publish once per burst. Rapid didChange storms coalesce into a single // parse+compile per settle window. var pending_uri: ?[]u8 = null; var last_published: ?[]u8 = null; // Per-message scratch: the framed body, JSON parse tree, id encodings and // every handler's response strings live here and are reclaimed wholesale // at the top of each iteration. Only the document store, the debounce URI // and the last-published snapshot outlive a message (long-lived `alloc`). var msg_arena = std.heap.ArenaAllocator.init(alloc); defer msg_arena.deinit(); while (true) { // retain_capacity: with handler scratch arenas isolated on // page_allocator (see diagnose/completionItems/symbolsReply), this // arena only sees same-shaped message data, so one reused node keeps // RSS flat without GPA churn. _ = msg_arena.reset(.retain_capacity); const ma = msg_arena.allocator(); if (pending_uri) |pu| { if (waitReadable(250) == 0) { // Settle window elapsed with no new input: publish once. if (storeGet(pu)) |txt| { const unchanged = last_published != null and std.mem.eql(u8, last_published.?, txt); if (!unchanged) { publishDiagnostics(ma, pu, txt); if (last_published) |old| alloc.free(old); last_published = alloc.dupe(u8, txt) catch null; } } alloc.free(pu); pending_uri = null; } // ready > 0: more input queued — drain it before settling. } const body = readMessage(ma) catch |e| { if (e == error.EndOfStream) { // Client went away: flush any held diagnostics first. if (pending_uri) |pu| { if (storeGet(pu)) |txt| { const unchanged2 = last_published != null and std.mem.eql(u8, last_published.?, txt); if (!unchanged2) publishDiagnostics(ma, pu, txt); } } std.process.exit(0); } continue; }; defer ma.free(body); var parsed = std.json.parseFromSlice(std.json.Value, ma, body, .{}) catch continue; defer parsed.deinit(); const obj = switch (parsed.value) { .object => |o| o, else => continue, }; const method = if (obj.get("method")) |m| switch (m) { .string => |s| s, else => "", } else ""; // Responses carry an id; notifications do not. var id_json: []const u8 = "null"; var has_id = false; if (obj.get("id")) |idv| { has_id = true; id_json = switch (idv) { .integer => |n| allocPrint(ma, "{d}", .{n}) catch "null", .string => |s| jsonEscape(ma, s), else => "null", }; } const params_v = obj.get("params"); if (std.mem.eql(u8, method, "initialize")) { respond(ma, id_json, \\{"capabilities":{"textDocumentSync":2,"hoverProvider":true,"completionProvider":{"triggerCharacters":["."]},"documentFormattingProvider":true,"definitionProvider":true,"documentSymbolProvider":true},"serverInfo":{"name":"rinne-lsp","version":"0.1"}} ); } else if (std.mem.eql(u8, method, "initialized")) { // no-op } else if (std.mem.eql(u8, method, "shutdown")) { shutdown_requested = true; respond(ma, id_json, "null"); } else if (std.mem.eql(u8, method, "exit")) { std.process.exit(if (shutdown_requested) 0 else 1); } else if (std.mem.eql(u8, method, "textDocument/didOpen") or std.mem.eql(u8, method, "textDocument/didChange")) { const p = params_v orelse continue; const po = switch (p) { .object => |o| o, else => continue, }; var uri: []const u8 = ""; var text: []const u8 = ""; if (std.mem.eql(u8, method, "textDocument/didChange")) { // params.textDocument.uri + params.contentChanges[LAST].text // (full sync: each change carries the complete document). if (po.get("textDocument")) |td| { if (td == .object) { if (td.object.get("uri")) |u| uri = u.string; } } if (po.get("contentChanges")) |cc| { if (cc == .array and cc.array.items.len > 0) { const last = cc.array.items[cc.array.items.len - 1]; if (last == .object) { if (last.object.get("text")) |t| text = t.string; } } } } else { // didOpen: params.textDocument.{uri,text} const td = po.get("textDocument") orelse continue; if (td == .object) { if (td.object.get("uri")) |u| uri = u.string; if (td.object.get("text")) |t| text = t.string; } } if (uri.len == 0) continue; storeSet(alloc, uri, text); // Debounced: diagnostics publish after the settle window, not on // every keystroke. if (pending_uri) |old| alloc.free(old); pending_uri = alloc.dupe(u8, uri) catch null; } else if (std.mem.eql(u8, method, "textDocument/hover")) { const p = params_v orelse continue; handleHover(ma, id_json, p); } else if (std.mem.eql(u8, method, "textDocument/formatting")) { // In-process `rinne fmt`: whole-document TextEdit or null. var fmt_text: ?[]const u8 = null; if (params_v) |p| { if (p == .object) { if (p.object.get("textDocument")) |td| { if (td == .object) { if (td.object.get("uri")) |u| fmt_text = storeGet(u.string); } } } } const reply = if (fmt_text) |t| formattingReply(ma, t) catch "null" else "null"; respond(ma, id_json, reply); } else if (std.mem.eql(u8, method, "textDocument/completion")) { handleCompletion(alloc, id_json, if (params_v) |p| p else .null); } else if (std.mem.eql(u8, method, "textDocument/documentSymbol")) { const p = params_v orelse continue; handleDocumentSymbol(ma, id_json, p); } else if (std.mem.eql(u8, method, "textDocument/definition")) { const p = params_v orelse continue; handleDefinition(ma, id_json, p); } else if (has_id) { // Unknown request: answer null so clients never hang. respond(ma, id_json, "null"); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const arity_fixture = \\fn add(a: int, b: int) int { \\ return a + b \\} \\ \\pub fn main() int { \\ return add(1) \\} ; test "diagRange converts structured positions into precise LSP ranges" { // Compiler reports statement start line 6, col 5 (1-based). const r = diagRange(arity_fixture, 6, 5); try std.testing.expectEqual(@as(u32, 5), r.sl); try std.testing.expectEqual(@as(u32, 4), r.sc); try std.testing.expectEqual(@as(u32, 5), r.el); try std.testing.expectEqual(@as(u32, 10), r.ec); // end of `return` } test "diagRange handles unknown, past-EOF, and empty positions safely" { // Unknown position (0,0): starts at document head. const head = diagRange(arity_fixture, 0, 0); try std.testing.expectEqual(@as(u32, 0), head.sl); try std.testing.expectEqual(@as(u32, 0), head.sc); // Past EOF clamps to the last line (`}`). const tail = diagRange(arity_fixture, 99, 99); try std.testing.expectEqual(@as(u32, 6), tail.sl); try std.testing.expectEqual(@as(u32, 1), tail.ec); // Empty document: zero range, no panic. const empty = diagRange("", 1, 1); try std.testing.expectEqual(@as(u32, 0), empty.ec); } test "diagnose surfaces structured compiler diagnostics" { const d = diagnose(std.testing.allocator, arity_fixture) orelse return error.TestUnexpectedResult; defer std.testing.allocator.free(d.message); // Error lives on source line 6 -> 0-based line 5, never clamped to 0. try std.testing.expectEqual(@as(u32, 5), d.start_line); try std.testing.expect(d.start_col > 0 or d.end_col > d.start_col); try std.testing.expect(std.mem.indexOf(u8, d.message, "add") != null); } test "diagnose surfaces parse errors on their real lines" { const bad = "var = 5"; const d = diagnose(std.testing.allocator, bad) orelse return error.TestUnexpectedResult; defer std.testing.allocator.free(d.message); try std.testing.expectEqual(@as(u32, 0), d.start_line); try std.testing.expect(d.message.len > 0); } test "formattingReply emits a whole-document TextEdit for dirty source" { const dirty = "fn f( ) int {\nreturn 1\n}"; const reply = try formattingReply(std.testing.allocator, dirty); defer std.testing.allocator.free(reply); try std.testing.expect(std.mem.indexOf(u8, reply, "\"newText\"") != null); try std.testing.expect(std.mem.indexOf(u8, reply, "\"start\":{\"line\":0,\"character\":0}") != null); } test "formattingReply returns null when the text is already formatted" { const dirty = "fn f( ) int {\nreturn 1\n}"; const once = try fmt_mod.formatSource(std.testing.allocator, dirty); defer std.testing.allocator.free(once); const reply = try formattingReply(std.testing.allocator, once); defer std.testing.allocator.free(reply); try std.testing.expectEqualStrings("null", reply); } test "scanDeclarations finds top-level fns and variables only" { const src = \\@import = .{ std } \\const pi: float = 3.14 \\ \\fn add(a: int, b: int) int { \\ return a + b \\} \\ \\pub fn main() int { \\ var n = add(pi, 2) \\ const hidden = 9 \\ return n \\} \\// const commented = 0 ; const decls = try scanDeclarations(std.testing.allocator, src); defer std.testing.allocator.free(decls); try std.testing.expectEqual(@as(usize, 3), decls.len); try std.testing.expectEqualStrings("pi", decls[0].name); try std.testing.expectEqual(DeclKind.const_decl, decls[0].kind); try std.testing.expectEqual(@as(u32, 1), decls[0].line); try std.testing.expectEqualStrings("add", decls[1].name); try std.testing.expectEqual(@as(u32, 3), decls[1].line); try std.testing.expectEqualStrings("main", decls[2].name); try std.testing.expectEqual(DeclKind.fn_decl, decls[2].kind); try std.testing.expectEqual(@as(u32, 7), decls[2].line); } test "scanDeclarations survives braces inside strings and comments" { const src = \\const s = "brace } inside" \\ \\fn f() int { \\ return 1 \\} \\ \\fn g() int { \\ var x = 1 // trailing { comment \\ return x \\} ; const decls = try scanDeclarations(std.testing.allocator, src); defer std.testing.allocator.free(decls); // Without string/comment awareness the "}" literal would close f's body // early and g would be scanned at depth 0 too. `const s` is a real // top-level declaration and must also appear. try std.testing.expectEqual(@as(usize, 3), decls.len); try std.testing.expectEqualStrings("s", decls[0].name); try std.testing.expectEqualStrings("f", decls[1].name); try std.testing.expectEqualStrings("g", decls[2].name); } test "completionItems merges document-local symbols in plain context" { const src = "const alpha = 1\nfn beta() int {\n return alpha\n}\n"; const items = try completionItems(std.testing.allocator, src, null); defer std.testing.allocator.free(items); // Locals present with their completion kinds (const→21, fn→3). try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"alpha\",\"kind\":21") != null); try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"beta\",\"kind\":3") != null); // Keywords and the module namespaces (std.) present; bare builtins are not // offered in plain context (they must be reached through a namespace). try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"while\"") != null); try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"std\",\"kind\":9") != null); try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"print\"") == null); } test "completionItems omits locals in namespace context" { const src = "const alpha = 1\n"; const items = try completionItems(std.testing.allocator, src, "std"); defer std.testing.allocator.free(items); try std.testing.expect(std.mem.indexOf(u8, items, "alpha") == null); // But the namespace member is there. try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"print\"") != null); } test "completionItems lists members of a dotted sub-namespace" { const src = ""; const items = try completionItems(std.testing.allocator, src, "std.io"); defer std.testing.allocator.free(items); // read_file belongs to std.io; print (std.print) does not. try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"read_file\"") != null); try std.testing.expect(std.mem.indexOf(u8, items, "\"label\":\"print\"") == null); } test "completionItems gives local user functions snippet-format insert" { const src = \\fn add(a: int, b: int) int { \\ return a + b \\} \\fn zero() int { \\ return 0 \\} \\var counter = 0 \\ \\pub fn main() int { \\ return 0 \\} ; const items = try completionItems(std.testing.allocator, src, null); defer std.testing.allocator.free(items); // User fn with params -> snippet tabstops and format 2. const add_item = "\"label\":\"add\",\"kind\":3,\"insertText\":\"add(${1:a}, ${2:b})$0\",\"insertTextFormat\":2"; try std.testing.expect(std.mem.indexOf(u8, items, add_item) != null); // Zero-param user fn -> bare call + final tabstop, still format 2. const zero_item = "\"label\":\"zero\",\"kind\":3,\"insertText\":\"zero()$0\",\"insertTextFormat\":2"; try std.testing.expect(std.mem.indexOf(u8, items, zero_item) != null); // var local stays plain-insert format 1. const var_item = "\"label\":\"counter\",\"kind\":6,\"insertText\":\"counter\",\"insertTextFormat\":1"; try std.testing.expect(std.mem.indexOf(u8, items, var_item) != null); } test "sigFromDeclLine extracts the parameter list" { try std.testing.expectEqualStrings("(a: int, b: int)", sigFromDeclLine("fn add(a: int, b: int) int {").?); try std.testing.expectEqualStrings("()", sigFromDeclLine("pub fn zero() int {").?); // No parens (const/var) -> null. try std.testing.expect(sigFromDeclLine("const pi = 3") == null); } test "declSymbolKind maps declaration kinds to LSP SymbolKind" { try std.testing.expectEqual(@as(u8, 12), declSymbolKind(.fn_decl)); try std.testing.expectEqual(@as(u8, 13), declSymbolKind(.var_decl)); try std.testing.expectEqual(@as(u8, 14), declSymbolKind(.const_decl)); } test "scanDeclarations records the name column" { // `const pi = 3` -> name starts at col 6; `fn add(...)` -> col 3. const src = "const pi = 3\nfn add(a: int, b: int) int {\n return a + b\n}\n"; const decls = try scanDeclarations(std.testing.allocator, src); // Names borrow from `src`; only the array is caller-owned. defer std.testing.allocator.free(decls); try std.testing.expectEqual(@as(usize, 2), decls.len); try std.testing.expectEqualStrings("pi", decls[0].name); try std.testing.expectEqual(@as(u32, 6), decls[0].col); try std.testing.expectEqualStrings("add", decls[1].name); try std.testing.expectEqual(@as(u32, 3), decls[1].col); } test "symbolsReply lists top-level symbols with LSP kinds" { const src = \\@import = .{ std }; \\const pi = 3 \\ \\fn add(a: int, b: int) int { \\ return a + b \\} \\ \\pub fn main() int { \\ return add(1, pi) \\} ; const result = try symbolsReply(std.testing.allocator, src); defer std.testing.allocator.free(result); try std.testing.expect(std.mem.indexOf(u8, result, "\"name\":\"pi\",\"kind\":14") != null); try std.testing.expect(std.mem.indexOf(u8, result, "\"name\":\"add\",\"kind\":12") != null); try std.testing.expect(std.mem.indexOf(u8, result, "\"name\":\"main\",\"kind\":12") != null); // Nested declarations are excluded by design. try std.testing.expect(std.mem.indexOf(u8, result, "\"name\":\"n\"") == null); } test "definitionReply resolves same-file symbol or answers null" { const src = \\const pi = 3 \\ \\fn add(a: int, b: int) int { \\ return a + b \\} \\ \\pub fn main() int { \\ return add(1, pi) \\} ; // Cursor inside `add` usage on line 7 (0-based): ` return add(1, pi)` — col 13. const hit = try definitionReply(std.testing.allocator, "file:///t.rg", src, 7, 13); defer std.testing.allocator.free(hit); try std.testing.expect(std.mem.indexOf(u8, hit, "\"uri\":\"file:///t.rg\"") != null); // Declaration sits on 0-based line 2. try std.testing.expect(std.mem.indexOf(u8, hit, "\"line\":2") != null); // Unknown word answers plain null (JSON literal). const miss = try definitionReply(std.testing.allocator, "file:///t.rg", src, 0, 0); defer std.testing.allocator.free(miss); try std.testing.expectEqualStrings("null", miss); } test "lineAt returns the exact source line at a 0-based index" { const src = "fn a() { }\nfn b() { }\n"; try std.testing.expectEqualStrings("fn a() { }", lineAt(src, 0).?); try std.testing.expectEqualStrings("fn b() { }", lineAt(src, 1).?); // Past EOF answers null; an empty document is a single empty line. try std.testing.expect(lineAt(src, 5) == null); try std.testing.expectEqualStrings("", lineAt("", 0).?); try std.testing.expect(lineAt("", 1) == null); }