From 4d022bcc80221409c5307885df8e98c6af64e8d0 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 1 Sep 2026 13:31:17 -0700 Subject: [PATCH] first working version (still zig 0.15) --- .gitignore | 2 + .mise.toml | 5 + .pre-commit-config.yaml | 35 ++ LICENSE | 21 ++ README.md | 116 +++++++ build.zig | 42 +++ build.zig.zon | 17 + src/Server.zig | 722 ++++++++++++++++++++++++++++++++++++++++ src/analysis.zig | 494 +++++++++++++++++++++++++++ src/main.zig | 84 +++++ src/rpc.zig | 159 +++++++++ 11 files changed, 1697 insertions(+) create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/Server.zig create mode 100644 src/analysis.zig create mode 100644 src/main.zig create mode 100644 src/rpc.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3389c86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.zig-cache/ +zig-out/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..9927c37 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,5 @@ +[tools] +prek = "0.3.1" +"ubi:DonIsaac/zlint" = "0.7.9" +zig = "0.15.2" +zls = "0.15.1" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9fbc568 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/batmac/pre-commit-zig + rev: v0.3.0 + hooks: + - id: zig-fmt + - repo: local + hooks: + - id: zlint + name: Run zlint + entry: zlint + args: ["--deny-warnings", "--fix"] + language: system + types: [zig] + - repo: https://github.com/batmac/pre-commit-zig + rev: v0.3.0 + hooks: + - id: zig-build + - repo: local + hooks: + - id: test + name: Run zig build test + entry: zig + args: ["build", "test"] + language: system + types: [file] + pass_filenames: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2252f67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emil Lerch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a5ac22 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# srf-lsp + +Language Server Protocol implementation for [SRF (Simple Record Format)](https://git.lerch.org/lobo/srf), written in Zig. + +Provides real-time parse error diagnostics using the SRF library's parser directly. + +## Features + +- Parse error diagnostics on open and edit, using the SRF library's parser + directly: missing or duplicate `#!srfv1`, bad type hints, unparseable values, + data after `#!eof`, and so on. +- All errors from a parse are reported, not just the first. +- Position encoding is negotiated during `initialize` (`utf-8`, `utf-16` or + `utf-32`), so columns line up even on lines containing multi-byte characters. +- Full-document sync, which matches SRF's single-pass parser. +- stdio transport. + +Not implemented: hover, completion, document symbols, formatting, goto +definition, semantic tokens, and pull diagnostics +(`textDocument/diagnostic`). Requests for them are answered with +`MethodNotFound` rather than left unanswered, and none of them are advertised as +server capabilities. + +## Setup + +```sh +mise install +``` + +## Build & Test + +```sh +zig build +zig build test +``` + +The binary is at `zig-out/bin/srf-lsp`. + +## Install + +The Neovim config below expects `srf-lsp` on `PATH`: + +```sh +zig build && install -m755 zig-out/bin/srf-lsp ~/.local/bin/srf-lsp +``` + +## Neovim Integration + +Neovim 0.11 or newer. Add to your config: + +```lua +vim.filetype.add({ + extension = { + srf = "srf", + }, +}) + +vim.lsp.config("srf_lsp", { + cmd = { "srf-lsp" }, + filetypes = { "srf" }, + root_markers = { ".git", "/" }, -- single file server +}) + +vim.lsp.enable("srf_lsp") +``` + +Check it attached with `:checkhealth vim.lsp` on a `.srf` buffer. Server logs go +to stderr, which Neovim captures in `:LspLog`. + +## Combined Setup (with srf-tree-sitter) + +For both syntax highlighting and error detection: + +```lua +vim.filetype.add({ + extension = { + srf = "srf", + }, +}) + +-- Diagnostics +vim.lsp.config("srf_lsp", { + cmd = { "srf-lsp" }, + filetypes = { "srf" }, + root_markers = { ".git", "/" }, +}) +vim.lsp.enable("srf_lsp") + +-- Syntax highlighting +vim.api.nvim_create_autocmd("User", { + pattern = "TSUpdate", + callback = function() + require("nvim-treesitter.parsers").srf = { + install_info = { + url = "https://github.com/elerch/srf-tree-sitter", + branch = "master", + queries = "queries", + }, + } + end, +}) + +vim.api.nvim_create_autocmd("FileType", { + pattern = "srf", + callback = function(args) + pcall(vim.treesitter.start, args.buf) + end, +}) +``` + +Then install the tree-sitter parser (`:TSInstall srf` no-ops if it is already +present, so use `:TSUpdate srf` to pick up a newer grammar): + +```vim +:TSUpdate srf +``` diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..daedd8b --- /dev/null +++ b/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const srf_dep = b.dependency("srf", .{ + .target = target, + }); + + const exe = b.addExecutable(.{ + .name = "srf-lsp", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "srf", .module = srf_dep.module("srf") }, + }, + }), + }); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the LSP server"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..e6b61dc --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .srf_lsp, + .version = "0.0.0", + .fingerprint = 0x96c04d564e5a0f32, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .srf = .{ + .url = "git+https://git.lerch.org/lobo/srf.git#1a42735f11c15ce2e125aaad99699159925b3fdc", + .hash = "srf-0.0.0-qZj578XCAQDWNP6V8NxyyDCkrBbY7NWR8tP6wAigl8it", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/src/Server.zig b/src/Server.zig new file mode 100644 index 0000000..25b2569 --- /dev/null +++ b/src/Server.zig @@ -0,0 +1,722 @@ +//! The LSP server: message dispatch, document store, and the diagnostics we +//! push back to the client. +//! +//! Two rules drive the shape of this file: +//! +//! * Every *request* (a message with an `id`) gets exactly one response, even +//! if we do not implement the method. A client that asked and never heard +//! back either hangs or waits out a timeout, which is the difference between +//! "no hover support" and "the server is broken". +//! * A malformed message is answered or ignored, never fatal. We are talking +//! to someone else's editor over a pipe; a bad field is not our excuse to +//! take the session down. + +const std = @import("std"); +const analysis = @import("analysis.zig"); +const rpc = @import("rpc.zig"); + +const log = std.log.scoped(.srf_lsp); + +const Server = @This(); + +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#errorCodes +const ErrorCode = enum(i32) { + parse_error = -32700, + invalid_request = -32600, + method_not_found = -32601, + invalid_params = -32602, + internal_error = -32603, + server_not_initialized = -32002, + request_failed = -32803, +}; + +const Phase = enum { + /// Before `initialize`. Only `initialize` and `exit` are allowed. + uninitialized, + running, + /// After `shutdown`. Only `exit` is allowed. + shutting_down, +}; + +allocator: std.mem.Allocator, +out: std.fs.File, +documents: std.StringHashMapUnmanaged([]const u8) = .empty, +phase: Phase = .uninitialized, +position_encoding: analysis.PositionEncoding = .@"utf-16", +/// Set by the `exit` notification. `main` polls it so that it can tear down +/// cleanly instead of calling `std.process.exit` from deep in a handler. +exit_requested: bool = false, +/// Exit code to use once `exit_requested` is set. The spec asks for 0 after a +/// `shutdown`, 1 without one. +exit_code: u8 = 1, + +pub fn init(allocator: std.mem.Allocator, out: std.fs.File) Server { + return .{ .allocator = allocator, .out = out }; +} + +pub fn deinit(self: *Server) void { + var it = self.documents.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.*); + } + self.documents.deinit(self.allocator); +} + +// +// JSON helpers. std.json.Value is a union, so every field access needs a tag +// check first; an unchecked `.object` on a client's typo is a crash. +// + +fn asObject(value: ?std.json.Value) ?std.json.ObjectMap { + const v = value orelse return null; + return switch (v) { + .object => |o| o, + else => null, + }; +} + +fn asString(value: ?std.json.Value) ?[]const u8 { + const v = value orelse return null; + return switch (v) { + .string, .number_string => |s| s, + else => null, + }; +} + +fn asArray(value: ?std.json.Value) ?std.json.Array { + const v = value orelse return null; + return switch (v) { + .array => |a| a, + else => null, + }; +} + +fn field(object: std.json.ObjectMap, name: []const u8) ?std.json.Value { + return object.get(name); +} + +// +// Sending +// + +fn send(self: *Server, value: anytype) !void { + const body = try std.json.Stringify.valueAlloc(self.allocator, value, .{}); + defer self.allocator.free(body); + try rpc.writeMessage(self.out, body); +} + +/// `id` is echoed back exactly as it arrived: the spec allows an integer or a +/// string, and a client is entitled to correlate on either. +fn sendResult(self: *Server, id: std.json.Value, result: anytype) !void { + try self.send(.{ .jsonrpc = "2.0", .id = id, .result = result }); +} + +fn sendError(self: *Server, id: std.json.Value, code: ErrorCode, message: []const u8) !void { + try self.send(.{ + .jsonrpc = "2.0", + .id = id, + .@"error" = .{ .code = @intFromEnum(code), .message = message }, + }); +} + +fn sendNotification(self: *Server, method: []const u8, params: anytype) !void { + try self.send(.{ .jsonrpc = "2.0", .method = method, .params = params }); +} + +// +// Dispatch +// + +/// Handles one raw message body. Returns an error only for genuine local +/// failures (out of memory, a broken output pipe); anything the client got wrong +/// is answered or logged and the session continues. +pub fn handleMessage(self: *Server, body: []const u8) !void { + const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, body, .{}) catch |err| { + // The client's mistake, not ours, so this is a warning about their + // message rather than a server error. The JSON error name is the only + // clue about what was wrong with it, so it goes in the message. + log.warn("ignoring unparseable message: {t}", .{err}); + // A notification we cannot read has no id to answer on, and the spec + // says not to reply to a request whose id we could not recover either. + return; + }; + defer parsed.deinit(); + + const root = asObject(parsed.value) orelse { + log.warn("ignoring message whose top level is not a JSON object", .{}); + return; + }; + + const id = field(root, "id"); + const method = asString(field(root, "method")) orelse { + // No method: either a response to a request we never sent, or junk. + if (id) |i| try self.sendError(i, .invalid_request, "message has no method"); + return; + }; + + self.dispatch(method, root, id) catch |err| switch (err) { + // Anything the handler could not do locally still owes the client an + // answer if it was a request. + error.OutOfMemory => { + if (id) |i| self.sendError(i, .internal_error, "out of memory") catch |notify_err| { + // Nothing left to try, but say which failure hid the first one. + log.warn("could not report the failure to the client: {t}", .{notify_err}); + }; + return err; + }, + else => return err, + }; +} + +fn dispatch( + self: *Server, + method: []const u8, + root: std.json.ObjectMap, + id: ?std.json.Value, +) !void { + // `exit` is valid in every phase and must never be refused. + if (std.mem.eql(u8, method, "exit")) { + self.exit_requested = true; + self.exit_code = if (self.phase == .shutting_down) 0 else 1; + return; + } + + if (self.phase == .uninitialized and !std.mem.eql(u8, method, "initialize")) { + if (id) |i| try self.sendError(i, .server_not_initialized, "server not initialized"); + return; + } + + if (self.phase == .shutting_down) { + if (id) |i| try self.sendError(i, .invalid_request, "server is shutting down"); + return; + } + + if (std.mem.eql(u8, method, "initialize")) { + return self.handleInitialize(root, id); + } else if (std.mem.eql(u8, method, "shutdown")) { + self.phase = .shutting_down; + if (id) |i| try self.sendResult(i, @as(?u8, null)); + return; + } else if (std.mem.eql(u8, method, "initialized")) { + return; // notification, nothing to do + } else if (std.mem.eql(u8, method, "textDocument/didOpen")) { + return self.handleDidOpen(root); + } else if (std.mem.eql(u8, method, "textDocument/didChange")) { + return self.handleDidChange(root); + } else if (std.mem.eql(u8, method, "textDocument/didClose")) { + return self.handleDidClose(root); + } else if (std.mem.eql(u8, method, "textDocument/didSave")) { + return; // full sync means didChange already told us everything + } + + // Unknown method. A request must be told we cannot serve it; a notification + // is dropped, which the spec explicitly allows. + if (id) |i| { + log.debug("unimplemented request: {s}", .{method}); + try self.sendError(i, .method_not_found, "method not supported by srf-lsp"); + } else if (!std.mem.startsWith(u8, method, "$/")) { + // `$/` notifications are optional by design and not worth logging. + log.debug("ignoring notification: {s}", .{method}); + } +} + +// +// Lifecycle +// + +fn handleInitialize(self: *Server, root: std.json.ObjectMap, id: ?std.json.Value) !void { + self.position_encoding = negotiateEncoding(root); + self.phase = .running; + log.info("initialized, position encoding {s}", .{self.position_encoding.wireName()}); + + const request_id = id orelse return; // `initialize` without an id is nonsense, but harmless + try self.sendResult(request_id, .{ + .capabilities = .{ + .positionEncoding = self.position_encoding.wireName(), + .textDocumentSync = .{ + .openClose = true, + // 1 = Full. We reparse whole documents, which is honest: srf's + // parser is single-pass over the entire text. + .change = 1, + }, + // Deliberately no `diagnosticProvider`: that advertises *pull* + // diagnostics (`textDocument/diagnostic`), which we do not + // implement. We push via `textDocument/publishDiagnostics` instead, + // and a client that took us up on pull would just collect + // MethodNotFound. + }, + .serverInfo = .{ .name = "srf-lsp", .version = "0.0.1" }, + }); +} + +/// Picks the first encoding the client offers that we support. Falls back to +/// utf-16, which the spec mandates as the default every client must accept. +fn negotiateEncoding(root: std.json.ObjectMap) analysis.PositionEncoding { + const params = asObject(field(root, "params")) orelse return .@"utf-16"; + const capabilities = asObject(field(params, "capabilities")) orelse return .@"utf-16"; + const general = asObject(field(capabilities, "general")) orelse return .@"utf-16"; + const offered = asArray(field(general, "positionEncodings")) orelse return .@"utf-16"; + + for (offered.items) |item| { + const name = asString(item) orelse continue; + if (analysis.PositionEncoding.fromWireName(name)) |encoding| return encoding; + } + return .@"utf-16"; +} + +// +// Document synchronisation +// + +/// Stores `text` for `uri`, taking ownership of neither: both are copied. +fn putDocument(self: *Server, uri: []const u8, text: []const u8) !void { + const text_owned = try self.allocator.dupe(u8, text); + errdefer self.allocator.free(text_owned); + + if (self.documents.getEntry(uri)) |entry| { + // Key already ours; swap the value and free the old one. + self.allocator.free(entry.value_ptr.*); + entry.value_ptr.* = text_owned; + return; + } + + const uri_owned = try self.allocator.dupe(u8, uri); + errdefer self.allocator.free(uri_owned); + try self.documents.put(self.allocator, uri_owned, text_owned); +} + +fn handleDidOpen(self: *Server, root: std.json.ObjectMap) !void { + const params = asObject(field(root, "params")) orelse return; + const doc = asObject(field(params, "textDocument")) orelse return; + const uri = asString(field(doc, "uri")) orelse return; + const text = asString(field(doc, "text")) orelse return; + + try self.putDocument(uri, text); + try self.publishDiagnostics(uri, text); +} + +fn handleDidChange(self: *Server, root: std.json.ObjectMap) !void { + const params = asObject(field(root, "params")) orelse return; + const doc = asObject(field(params, "textDocument")) orelse return; + const uri = asString(field(doc, "uri")) orelse return; + const changes = asArray(field(params, "contentChanges")) orelse return; + + // We advertised full sync, so the last change carries the whole document. + // A client that ignores that and sends ranges would need us to apply them; + // we would rather notice than silently corrupt our copy. + if (changes.items.len == 0) return; + const last = asObject(changes.items[changes.items.len - 1]) orelse return; + if (field(last, "range") != null) { + log.warn("ignoring incremental change: server advertised full sync", .{}); + return; + } + const text = asString(field(last, "text")) orelse return; + + try self.putDocument(uri, text); + try self.publishDiagnostics(uri, text); +} + +fn handleDidClose(self: *Server, root: std.json.ObjectMap) !void { + const params = asObject(field(root, "params")) orelse return; + const doc = asObject(field(params, "textDocument")) orelse return; + const uri = asString(field(doc, "uri")) orelse return; + + if (self.documents.fetchRemove(uri)) |entry| { + self.allocator.free(entry.key); + self.allocator.free(entry.value); + } + + // Clear the client's diagnostics for a file we no longer track, otherwise + // they linger in the editor after the buffer is gone. + try self.sendNotification("textDocument/publishDiagnostics", .{ + .uri = uri, + .diagnostics = @as([]const analysis.Diagnostic, &.{}), + }); +} + +fn publishDiagnostics(self: *Server, uri: []const u8, text: []const u8) !void { + const diagnostics = try analysis.analyze(self.allocator, text, self.position_encoding); + defer analysis.freeDiagnostics(self.allocator, diagnostics); + + try self.sendNotification("textDocument/publishDiagnostics", .{ + .uri = uri, + .diagnostics = diagnostics, + }); +} + +const testing = std.testing; + +/// Drives a server against a scratch file so tests can read back exactly what +/// went over the wire. +const Harness = struct { + dir: std.testing.TmpDir, + file: std.fs.File, + server: Server, + + fn init(allocator: std.mem.Allocator) !Harness { + var dir = std.testing.tmpDir(.{}); + const file = try dir.dir.createFile("out.jsonl", .{ .read = true }); + return .{ .dir = dir, .file = file, .server = Server.init(allocator, file) }; + } + + fn deinit(self: *Harness) void { + self.server.deinit(); + self.file.close(); + self.dir.cleanup(); + } + + fn sent(self: *Harness, allocator: std.mem.Allocator) ![]u8 { + const size = try self.file.getEndPos(); + const buf = try allocator.alloc(u8, size); + errdefer allocator.free(buf); + _ = try self.file.preadAll(buf, 0); + return buf; + } + + /// Asserts the transcript contains `needle`, which keeps the tests readable + /// without reimplementing a JSON-RPC client here. + fn expectSent(self: *Harness, needle: []const u8) !void { + const transcript = try self.sent(testing.allocator); + defer testing.allocator.free(transcript); + if (std.mem.indexOf(u8, transcript, needle) == null) { + std.debug.print("\nexpected to find: {s}\nin transcript:\n{s}\n", .{ needle, transcript }); + return error.NotSent; + } + } + + fn expectNotSent(self: *Harness, needle: []const u8) !void { + const transcript = try self.sent(testing.allocator); + defer testing.allocator.free(transcript); + if (std.mem.indexOf(u8, transcript, needle) != null) { + std.debug.print("\nexpected NOT to find: {s}\nin transcript:\n{s}\n", .{ needle, transcript }); + return error.UnexpectedlySent; + } + } + + fn initialize(self: *Harness) !void { + try self.server.handleMessage( + \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}} + ); + } +}; + +test "initialize advertises capabilities and answers the request" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.initialize(); + try h.expectSent("\"id\":1"); + try h.expectSent("\"textDocumentSync\""); + try h.expectSent("\"serverInfo\""); + try testing.expectEqual(Phase.running, h.server.phase); +} + +test "requests before initialize are refused, not ignored" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":9,"method":"textDocument/hover","params":{}} + ); + try h.expectSent("-32002"); +} + +test "an unimplemented request gets method_not_found rather than silence" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":42,"method":"textDocument/documentSymbol","params":{}} + ); + try h.expectSent("-32601"); + try h.expectSent("\"id\":42"); +} + +test "an unimplemented notification is silently dropped" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + const before = try h.sent(testing.allocator); + defer testing.allocator.free(before); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"$/setTrace","params":{"value":"verbose"}} + ); + + const after = try h.sent(testing.allocator); + defer testing.allocator.free(after); + try testing.expectEqual(before.len, after.len); +} + +test "string request ids are echoed back verbatim" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":"abc-123","method":"shutdown"} + ); + try h.expectSent("\"id\":\"abc-123\""); +} + +test "didOpen on a broken document publishes diagnostics" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"name::alice\n"}}} + ); + try h.expectSent("publishDiagnostics"); + try h.expectSent("Magic header"); +} + +test "didOpen on a valid document publishes an empty diagnostic list" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nname::alice\n"}}} + ); + try h.expectSent("\"diagnostics\":[]"); +} + +test "didChange replaces the document and republishes" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nname::alice\n"}}} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///x.srf","version":2},"contentChanges":[{"text":"name::alice\n"}]}} + ); + try h.expectSent("Magic header"); + try testing.expectEqual(@as(usize, 1), h.server.documents.count()); + try testing.expectEqualStrings("name::alice\n", h.server.documents.get("file:///x.srf").?); +} + +test "didChange for an unopened document still tracks it" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///ghost.srf","version":2},"contentChanges":[{"text":"#!srfv1\nk::v\n"}]}} + ); + try testing.expectEqual(@as(usize, 1), h.server.documents.count()); +} + +test "an incremental change is refused instead of corrupting the document" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nk::v\n"}}} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///x.srf","version":2},"contentChanges":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"text":"X"}]}} + ); + try testing.expectEqualStrings("#!srfv1\nk::v\n", h.server.documents.get("file:///x.srf").?); +} + +test "didClose forgets the document and clears its diagnostics" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"name::alice\n"}}} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didClose","params":{"textDocument":{"uri":"file:///x.srf"}}} + ); + try testing.expectEqual(@as(usize, 0), h.server.documents.count()); + try h.expectSent("\"diagnostics\":[]"); +} + +test "reopening a document does not leak the previous text" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + for (0..3) |_| { + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf","languageId":"srf","version":1,"text":"#!srfv1\nk::v\n"}}} + ); + } + try testing.expectEqual(@as(usize, 1), h.server.documents.count()); +} + +test "shutdown then exit gives exit code 0" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":2,"method":"shutdown"} + ); + try testing.expectEqual(Phase.shutting_down, h.server.phase); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"exit"} + ); + try testing.expect(h.server.exit_requested); + try testing.expectEqual(@as(u8, 0), h.server.exit_code); +} + +test "exit without shutdown gives exit code 1" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"exit"} + ); + try testing.expect(h.server.exit_requested); + try testing.expectEqual(@as(u8, 1), h.server.exit_code); +} + +test "requests after shutdown are refused" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":2,"method":"shutdown"} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":3,"method":"textDocument/didOpen","params":{}} + ); + try h.expectSent("-32600"); +} + +test "malformed json does not take the server down" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage("{not json at all"); + try h.server.handleMessage(""); + try h.server.handleMessage("[]"); + try h.server.handleMessage("null"); + try h.server.handleMessage("42"); + try testing.expectEqual(Phase.running, h.server.phase); +} + +test "wrongly typed fields are survivable" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + // method as a number, params as a string, uri as an object, text missing + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":1,"method":7} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":"nope"} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":{},"text":"x"}}} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///x.srf"}}} + ); + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///x.srf"},"contentChanges":[]}} + ); + try testing.expectEqual(Phase.running, h.server.phase); + try testing.expectEqual(@as(usize, 0), h.server.documents.count()); +} + +test "a message with no method but an id is answered" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":5,"result":{}} + ); + try h.expectSent("-32600"); +} + +test "exit is honoured before initialize" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"exit"} + ); + try testing.expect(h.server.exit_requested); +} + +test "client offering utf-8 gets utf-8 and byte columns" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"general":{"positionEncodings":["utf-8","utf-16"]}}}} + ); + try testing.expectEqual(analysis.PositionEncoding.@"utf-8", h.server.position_encoding); + try h.expectSent("\"positionEncoding\":\"utf-8\""); +} + +test "client offering only utf-16 gets utf-16" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"general":{"positionEncodings":["utf-16"]}}}} + ); + try testing.expectEqual(analysis.PositionEncoding.@"utf-16", h.server.position_encoding); +} + +test "client offering nothing falls back to utf-16" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + try testing.expectEqual(analysis.PositionEncoding.@"utf-16", h.server.position_encoding); +} + +test "an unknown encoding is skipped in favour of one we support" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"general":{"positionEncodings":["utf-7",7,"utf-32"]}}}} + ); + try testing.expectEqual(analysis.PositionEncoding.@"utf-32", h.server.position_encoding); +} + +test "didSave is accepted without complaint" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + try h.server.handleMessage( + \\{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///x.srf"}}} + ); + try h.expectNotSent("-32601"); +} + +test "we do not advertise capabilities we cannot serve" { + var h = try Harness.init(testing.allocator); + defer h.deinit(); + try h.initialize(); + + // Every capability we announce must have a handler in `dispatch`. Pull + // diagnostics in particular: announcing it makes conforming clients send + // textDocument/diagnostic, which we would only answer with -32601. + try h.expectNotSent("diagnosticProvider"); + try h.expectNotSent("hoverProvider"); + try h.expectNotSent("completionProvider"); + try h.expectNotSent("documentSymbolProvider"); + try h.expectNotSent("documentFormattingProvider"); + try h.expectNotSent("definitionProvider"); + try h.expectNotSent("semanticTokensProvider"); +} diff --git a/src/analysis.zig b/src/analysis.zig new file mode 100644 index 0000000..d426f00 --- /dev/null +++ b/src/analysis.zig @@ -0,0 +1,494 @@ +//! Turns SRF text into LSP diagnostics. +//! +//! The srf library reports parse errors through its `Diagnostics` interface, so +//! we implement that interface directly instead of using the library's +//! `BoundedDiagnostics` helper. Three reasons: +//! +//! 1. `BoundedDiagnostics.errors()` takes `self` by value and returns a slice +//! into that copy, so the slice dangles the moment the call returns. +//! Reading it segfaults (see the `errors() is unusable` test below). +//! 2. It caps out at a comptime error count, and hitting the cap turns into +//! `error.ParseFailed`, losing the errors we already had. +//! 3. It would mean allocating each message twice: once by the parser, once +//! when we copy it into a diagnostic. We take ownership instead. + +const std = @import("std"); +const srf = @import("srf"); + +/// How LSP positions are counted. The client picks this during `initialize`. +pub const PositionEncoding = enum { + /// Byte offsets. What srf already reports, so no conversion. + @"utf-8", + /// UTF-16 code units. The protocol default, so it is the fallback. + @"utf-16", + /// Codepoints. + @"utf-32", + + pub fn fromWireName(name: []const u8) ?PositionEncoding { + return std.meta.stringToEnum(PositionEncoding, name); + } + + pub fn wireName(self: PositionEncoding) []const u8 { + return @tagName(self); + } +}; + +pub const Position = struct { + line: u32, + character: u32, +}; + +pub const Range = struct { + start: Position, + end: Position, +}; + +/// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity +pub const Severity = enum(u8) { + err = 1, + warning = 2, + information = 3, + hint = 4, + + /// The protocol wants the number, not the tag name, and Zig's default enum + /// serialization emits the name. `anytype` here is the signature + /// `std.json.Stringify` requires; it is not a choice. + pub fn jsonStringify(self: Severity, jw: anytype) !void { + try jw.write(@intFromEnum(self)); + } +}; + +pub const Diagnostic = struct { + range: Range, + severity: Severity, + source: []const u8 = "srf", + /// Owned by the `Diagnostic`. + message: []const u8, + + pub fn deinit(self: Diagnostic, allocator: std.mem.Allocator) void { + allocator.free(self.message); + } +}; + +/// Frees a slice of diagnostics and the slice itself. +pub fn freeDiagnostics(allocator: std.mem.Allocator, diagnostics: []const Diagnostic) void { + for (diagnostics) |d| d.deinit(allocator); + allocator.free(diagnostics); +} + +/// Implements `srf.Diagnostics`, converting each parse error into an LSP +/// diagnostic as the parser produces it. +const Sink = struct { + allocator: std.mem.Allocator, + /// The document being parsed, needed to convert columns and to size ranges. + text: []const u8, + encoding: PositionEncoding, + items: std.ArrayList(Diagnostic), + /// Set if we had to drop an error because we could not allocate. Reported + /// rather than swallowed, so the user is not left with a silently short + /// list of problems. + dropped: bool = false, + + fn interface(self: *Sink) srf.Diagnostics { + return .{ .ptr = self, .addErrorFn = addError }; + } + + /// `err.message` was allocated by the parser with `allocator` and ownership + /// passes to us, so on any failure path we have to free it ourselves. + fn addError( + ptr: *anyopaque, + allocator: std.mem.Allocator, + err: srf.ParseLineError, + ) srf.ParseError!void { + const self: *Sink = @ptrCast(@alignCast(ptr)); + + const diagnostic: Diagnostic = .{ + .range = self.rangeFor(err), + .severity = severityFor(err.level), + .message = err.message, + }; + + self.items.append(self.allocator, diagnostic) catch { + allocator.free(err.message); + self.dropped = true; + // Swallowed on purpose: returning an error here aborts the parse + // and costs us the diagnostics we already collected, which is worse + // than reporting a short list plus the note added by `analyze`. + return; + }; + } + + /// srf reports a 1-based line and a 1-based *byte* column. LSP wants 0-based + /// and the negotiated encoding. Columns are clamped to the line, because a + /// length-prefixed multi-line value leaves srf's column past the end of the + /// line it names. + fn rangeFor(self: *const Sink, err: srf.ParseLineError) Range { + const line_index: u32 = if (err.line > 0) + @intCast(@min(err.line - 1, std.math.maxInt(u32))) + else + 0; + + const line_text = lineAt(self.text, line_index); + const byte_col_raw = if (err.column > 0) err.column - 1 else 0; + const byte_col = @min(byte_col_raw, line_text.len); + + const line_end = convertColumn(line_text, line_text.len, self.encoding); + const start = convertColumn(line_text, byte_col, self.encoding); + + // srf's column usually points just past the text it consumed, which for + // a whole-line problem lands at end of line. Underlining from there to + // end of line is a zero-width caret the user cannot see, so fall back to + // underlining the whole line: the error is about that line either way. + if (start >= line_end) { + return .{ + .start = .{ .line = line_index, .character = 0 }, + .end = .{ .line = line_index, .character = line_end }, + }; + } + + return .{ + .start = .{ .line = line_index, .character = start }, + .end = .{ .line = line_index, .character = line_end }, + }; + } +}; + +fn severityFor(level: std.log.Level) Severity { + return switch (level) { + .err => .err, + .warn => .warning, + .info => .information, + .debug => .hint, + }; +} + +/// Returns the 0-based `index`th line of `text`, excluding its newline. Empty if +/// the line does not exist. +fn lineAt(text: []const u8, index: u32) []const u8 { + var remaining = text; + var i: u32 = 0; + while (true) { + const nl = std.mem.indexOfScalar(u8, remaining, '\n'); + if (i == index) { + const line = if (nl) |n| remaining[0..n] else remaining; + return std.mem.trimEnd(u8, line, "\r"); + } + if (nl) |n| { + remaining = remaining[n + 1 ..]; + i += 1; + } else { + return ""; + } + } +} + +/// Converts a byte offset within a line to a character offset in `encoding`. +/// Invalid UTF-8 is counted a byte at a time rather than rejected, so a +/// half-typed multi-byte character still produces a usable position. +fn convertColumn(line: []const u8, byte_offset: usize, encoding: PositionEncoding) u32 { + const limit = @min(byte_offset, line.len); + if (encoding == .@"utf-8") return @intCast(limit); + + var count: u32 = 0; + var i: usize = 0; + while (i < limit) { + const len = std.unicode.utf8ByteSequenceLength(line[i]) catch { + i += 1; + count += 1; + continue; + }; + if (i + len > line.len) { + i += 1; + count += 1; + continue; + } + const codepoint = std.unicode.utf8Decode(line[i..][0..len]) catch { + i += 1; + count += 1; + continue; + }; + count += switch (encoding) { + // Anything outside the BMP takes two UTF-16 code units. + .@"utf-16" => if (codepoint > 0xFFFF) 2 else 1, + .@"utf-32" => 1, + .@"utf-8" => unreachable, + }; + i += len; + } + return count; +} + +/// Parses `text` and returns its diagnostics. Caller owns the result and should +/// release it with `freeDiagnostics`. +pub fn analyze( + allocator: std.mem.Allocator, + text: []const u8, + encoding: PositionEncoding, +) error{OutOfMemory}![]Diagnostic { + var sink: Sink = .{ + .allocator = allocator, + .text = text, + .encoding = encoding, + .items = .empty, + }; + errdefer { + for (sink.items.items) |d| d.deinit(allocator); + sink.items.deinit(allocator); + } + + var diagnostics = sink.interface(); + run(allocator, text, &diagnostics) catch |err| { + // srf can fail without saying why: `iterator` returns `ParseFailed` with + // no diagnostic at all for an empty document. Swallowing that leaves the + // editor showing nothing on a file that will not parse, so synthesise a + // diagnostic rather than drop the reason on the floor. + if (sink.items.items.len == 0) try appendUnlocatedError(allocator, &sink, text, err); + }; + + if (sink.dropped) { + // Best effort: if we could not allocate a diagnostic we probably cannot + // allocate this note either, and the list we have is still worth + // sending. + const note = allocator.dupe(u8, "too many errors to report; some were dropped") catch + return sink.items.toOwnedSlice(allocator); + sink.items.append(allocator, .{ + .range = .{ .start = .{ .line = 0, .character = 0 }, .end = .{ .line = 0, .character = 0 } }, + .severity = .information, + .message = note, + }) catch allocator.free(note); + } + + return sink.items.toOwnedSlice(allocator); +} + +/// Records a parse failure that the parser gave us no location for. Keeps the +/// underlying error name in the message: it is the only information we have +/// about why the parse stopped, so hiding it would leave the user guessing. +fn appendUnlocatedError( + allocator: std.mem.Allocator, + sink: *Sink, + text: []const u8, + err: anyerror, +) error{OutOfMemory}!void { + const blank = std.mem.trim(u8, text, &std.ascii.whitespace).len == 0; + const message = if (blank) + try allocator.dupe(u8, "SRF document is empty: expected a #!srfv1 header on the first line") + else + try std.fmt.allocPrint( + allocator, + "parsing stopped ({t}) but the parser reported no location", + .{err}, + ); + errdefer allocator.free(message); + + const first_line = lineAt(text, 0); + try sink.items.append(allocator, .{ + .range = .{ + .start = .{ .line = 0, .character = 0 }, + .end = .{ .line = 0, .character = convertColumn(first_line, first_line.len, sink.encoding) }, + }, + .severity = .err, + .message = message, + }); +} + +/// Drives the parser to completion, discarding values: we only want the errors +/// the diagnostics sink collects along the way. +/// +/// `error.ParseFailed` normally means the parser gave up *after* reporting why +/// through the sink, so the caller checks whether anything was collected before +/// treating the error itself as the story. +fn run(allocator: std.mem.Allocator, text: []const u8, diagnostics: *srf.Diagnostics) !void { + var reader = std.Io.Reader.fixed(text); + var it = try srf.iterator(&reader, allocator, .{ .diagnostics = diagnostics }); + defer it.deinit(); + + while (try it.next()) |fields| { + while (try fields.next()) |_| {} + } +} + +const testing = std.testing; + +fn analyzeForTest(text: []const u8) ![]Diagnostic { + return analyze(testing.allocator, text, .@"utf-8"); +} + +test "valid document has no diagnostics" { + const diags = try analyzeForTest("#!srfv1\nname::alice\nage:num:30\n"); + defer freeDiagnostics(testing.allocator, diags); + try testing.expectEqual(@as(usize, 0), diags.len); +} + +test "missing magic header is reported on line 0" { + const diags = try analyzeForTest("name::alice\n"); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 0); + try testing.expectEqual(@as(u32, 0), diags[0].range.start.line); + try testing.expectEqual(Severity.err, diags[0].severity); + try testing.expect(std.mem.indexOf(u8, diags[0].message, "Magic header") != null); +} + +test "duplicate magic header is reported" { + const diags = try analyzeForTest("#!srfv1\n#!srfv1\nname::alice\n"); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 0); + try testing.expect(std.mem.indexOf(u8, diags[0].message, "duplicate magic") != null); +} + +test "empty document reports the missing header" { + // srf's `iterator` returns ParseFailed with no diagnostic here, so this is + // really a test that we surface the failure instead of showing nothing. + const diags = try analyzeForTest(""); + defer freeDiagnostics(testing.allocator, diags); + try testing.expectEqual(@as(usize, 1), diags.len); + try testing.expect(std.mem.indexOf(u8, diags[0].message, "empty") != null); + try testing.expect(std.mem.indexOf(u8, diags[0].message, "#!srfv1") != null); +} + +test "whitespace-only document is treated as empty" { + const diags = try analyzeForTest(" \n\t\n"); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 0); +} + +test "diagnostic range stays inside the line it names" { + const text = "#!srfv1\n#!srfv1\n"; + const diags = try analyzeForTest(text); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 0); + for (diags) |d| { + const line = lineAt(text, d.range.start.line); + try testing.expect(d.range.start.character <= line.len); + try testing.expect(d.range.end.character <= line.len); + try testing.expect(d.range.end.character >= d.range.start.character); + } +} + +test "analyze is repeatable and leaks nothing across runs" { + for (0..3) |_| { + const bad = try analyzeForTest("name::alice\n"); + freeDiagnostics(testing.allocator, bad); + const good = try analyzeForTest("#!srfv1\nname::alice\n"); + freeDiagnostics(testing.allocator, good); + } +} + +test "more errors than the old bounded buffer held are all reported" { + // The previous implementation capped at 10 and turned the 11th into a hard + // parse failure, throwing away everything collected. + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + try buf.appendSlice(testing.allocator, "#!srfv1\n"); + for (0..40) |_| try buf.appendSlice(testing.allocator, "#!srfv1\n"); + + const diags = try analyze(testing.allocator, buf.items, .@"utf-8"); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 10); +} + +test "utf-8 columns are byte offsets" { + // "¥" is 2 bytes, 1 UTF-16 unit, 1 codepoint. + const line = "a¥b"; + try testing.expectEqual(@as(u32, 4), convertColumn(line, line.len, .@"utf-8")); +} + +test "utf-16 columns count code units" { + const line = "a¥b"; + try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-16")); +} + +test "utf-16 counts astral characters as surrogate pairs" { + const line = "a\u{1F600}b"; // emoji: 4 bytes, 2 UTF-16 units, 1 codepoint + try testing.expectEqual(@as(u32, 4), convertColumn(line, line.len, .@"utf-16")); + try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-32")); + try testing.expectEqual(@as(u32, 6), convertColumn(line, line.len, .@"utf-8")); +} + +test "column conversion tolerates invalid utf-8" { + const line = "a\xffb"; + try testing.expectEqual(@as(u32, 3), convertColumn(line, line.len, .@"utf-16")); +} + +test "column conversion tolerates a truncated multi-byte sequence" { + const line = "a\xc2"; // leading byte of a 2-byte sequence, cut off + try testing.expectEqual(@as(u32, 2), convertColumn(line, line.len, .@"utf-16")); +} + +test "column conversion clamps past the end of the line" { + const line = "abc"; + try testing.expectEqual(@as(u32, 3), convertColumn(line, 99, .@"utf-16")); +} + +test "lineAt splits on newlines and strips carriage returns" { + const text = "one\r\ntwo\nthree"; + try testing.expectEqualStrings("one", lineAt(text, 0)); + try testing.expectEqualStrings("two", lineAt(text, 1)); + try testing.expectEqualStrings("three", lineAt(text, 2)); + try testing.expectEqualStrings("", lineAt(text, 3)); + try testing.expectEqualStrings("", lineAt(text, 99)); +} + +test "position encoding round trips through its wire name" { + try testing.expectEqual(PositionEncoding.@"utf-8", PositionEncoding.fromWireName("utf-8").?); + try testing.expectEqual(PositionEncoding.@"utf-16", PositionEncoding.fromWireName("utf-16").?); + try testing.expectEqual(PositionEncoding.@"utf-32", PositionEncoding.fromWireName("utf-32").?); + try testing.expectEqual(@as(?PositionEncoding, null), PositionEncoding.fromWireName("utf-7")); + try testing.expectEqualStrings("utf-16", PositionEncoding.@"utf-16".wireName()); +} + +test "srf BoundedDiagnostics.errors() is unusable, which is why Sink exists" { + // Regression guard for the bug that made the server segfault. `errors()` + // takes `self` by value, so the returned slice points into a copy that dies + // with the call. If this ever starts passing, upstream fixed it and this + // test should be deleted along with the comment at the top of the file. + var bounded = srf.BoundedDiagnostics(4).empty; + var diagnostics = bounded.diagnostics(); + var reader = std.Io.Reader.fixed("name::alice\n"); + if (srf.iterator(&reader, testing.allocator, .{ .diagnostics = &diagnostics })) |it| { + var mut = it; + mut.deinit(); + } else |_| {} + defer bounded.deinit(testing.allocator); + + try testing.expect(bounded.error_count > 0); + const from_fn = bounded.errors(); + const from_field = bounded.buffer[0..bounded.error_count]; + try testing.expect(from_fn.ptr != from_field.ptr); +} + +test "severity serializes as the protocol's integer, not its tag name" { + const out = try std.json.Stringify.valueAlloc(testing.allocator, Severity.err, .{}); + defer testing.allocator.free(out); + try testing.expectEqualStrings("1", out); + + const warn = try std.json.Stringify.valueAlloc(testing.allocator, Severity.warning, .{}); + defer testing.allocator.free(warn); + try testing.expectEqualStrings("2", warn); +} + +test "a diagnostic serializes to the shape the protocol expects" { + const diagnostic: Diagnostic = .{ + .range = .{ .start = .{ .line = 1, .character = 2 }, .end = .{ .line = 1, .character = 5 } }, + .severity = .err, + .message = "boom", + }; + const out = try std.json.Stringify.valueAlloc(testing.allocator, diagnostic, .{}); + defer testing.allocator.free(out); + try testing.expectEqualStrings( + \\{"range":{"start":{"line":1,"character":2},"end":{"line":1,"character":5}},"severity":1,"source":"srf","message":"boom"} + , out); +} + +test "an error reported at end of line underlines the whole line" { + // srf points at the end of what it consumed, which for a line it could not + // make sense of is the end of the line. A caret there is invisible. + const text = "#!srfv1\nbroken line here\n"; + const diags = try analyzeForTest(text); + defer freeDiagnostics(testing.allocator, diags); + try testing.expect(diags.len > 0); + const range = diags[0].range; + try testing.expectEqual(@as(u32, 1), range.start.line); + try testing.expectEqual(@as(u32, 0), range.start.character); + try testing.expectEqual(@as(u32, "broken line here".len), range.end.character); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..6f4c9b4 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,84 @@ +//! srf-lsp: a Language Server Protocol server for SRF (Simple Record Format). +//! +//! Speaks the base protocol over stdio. stdout carries protocol traffic only, so +//! all logging goes to stderr; anything printed to stdout would corrupt the +//! stream and confuse the client. + +const std = @import("std"); +const rpc = @import("rpc.zig"); +const Server = @import("Server.zig"); + +pub const std_options: std.Options = .{ + .log_level = .info, + .logFn = logToStderr, +}; + +fn logToStderr( + comptime level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + const scope_prefix = if (scope != .default) "(" ++ @tagName(scope) ++ ") " else ""; + const prefix = "[" ++ comptime level.asText() ++ "] " ++ scope_prefix; + + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stderr().writer(&buf); + const stderr = &writer.interface; + + // Logging is best effort: a failed write to stderr must not break the + // protocol conversation on stdout. + nosuspend { + stderr.print(prefix ++ format ++ "\n", args) catch return; + stderr.flush() catch return; + } +} + +const log = std.log.scoped(.srf_lsp); + +pub fn main() !u8 { + var debug_allocator: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug_allocator.deinit(); + const allocator = debug_allocator.allocator(); + + log.info("srf-lsp starting", .{}); + + // Large enough that a header line always fits; message bodies are read into + // their own exact-sized allocations, so this is not a message size limit. + var stdin_buf: [64 * 1024]u8 = undefined; + var stdin_reader = std.fs.File.stdin().reader(&stdin_buf); + + var server = Server.init(allocator, std.fs.File.stdout()); + defer server.deinit(); + + while (true) { + const body = rpc.readMessage(allocator, &stdin_reader.interface) catch |err| { + // A framing failure means the stream is no longer trustworthy: we + // cannot know where the next message starts, so resynchronising + // would be guesswork. Report and stop. + log.err("cannot read message: {t}", .{err}); + return 1; + } orelse { + // Clean end of stream. Editors that are killed rather than shut + // down cleanly leave us here. + log.info("input closed, exiting", .{}); + return 0; + }; + defer allocator.free(body); + + try server.handleMessage(body); + + if (server.exit_requested) { + log.info("exit requested, code {d}", .{server.exit_code}); + return server.exit_code; + } + } +} + +test { + // Pull in the tests from every module we own, which `zig build test` would + // otherwise skip: only the root file is analysed by default. + _ = @import("rpc.zig"); + _ = @import("Server.zig"); + _ = @import("analysis.zig"); +} diff --git a/src/rpc.zig b/src/rpc.zig new file mode 100644 index 0000000..d373d9f --- /dev/null +++ b/src/rpc.zig @@ -0,0 +1,159 @@ +//! Base protocol framing for LSP over stdio: a `Content-Length` header block, +//! a blank line, then exactly that many bytes of JSON. +//! +//! Only `Content-Length` carries meaning for us. Any other header (notably the +//! deprecated `Content-Type`) is skipped, per the base protocol. + +const std = @import("std"); + +/// Refuse absurd bodies rather than letting a bad header make us allocate the +/// machine. Real LSP traffic is kilobytes; a whole-document sync of a huge file +/// is still well under this. +pub const max_content_length: usize = 64 * 1024 * 1024; + +pub const ReadError = error{ + /// The header block ended without a usable `Content-Length`. + MissingContentLength, + /// `Content-Length` was present but not a number we can use. + MalformedContentLength, + /// `Content-Length` exceeded `max_content_length`. + ContentTooLarge, + /// The stream ended part way through a message. + UnexpectedEndOfStream, + /// A single header line was longer than the reader's buffer. + StreamTooLong, + ReadFailed, + OutOfMemory, +}; + +const content_length_header = "content-length:"; + +/// Reads one message body, allocating it. Returns `null` on a clean end of +/// stream, which is how a session normally ends when the editor exits without +/// sending `exit` (it just closes the pipe). +/// +/// Caller owns the returned slice. +pub fn readMessage(allocator: std.mem.Allocator, reader: *std.Io.Reader) ReadError!?[]u8 { + var content_length: ?usize = null; + var in_headers = false; + + while (true) { + const line = (reader.takeDelimiter('\n') catch |err| switch (err) { + error.StreamTooLong => return error.StreamTooLong, + error.ReadFailed => return error.ReadFailed, + }) orelse { + // Nothing at all left. Clean shutdown if we were between messages, + // truncation if we were part way through one. + if (in_headers) return error.UnexpectedEndOfStream; + return null; + }; + + const trimmed = std.mem.trimEnd(u8, line, "\r"); + if (trimmed.len == 0) { + // Blank line ends the header block. A blank line with no preceding + // headers is stray whitespace between messages, so keep reading. + if (!in_headers) continue; + break; + } + in_headers = true; + + if (std.ascii.startsWithIgnoreCase(trimmed, content_length_header)) { + const raw = std.mem.trim(u8, trimmed[content_length_header.len..], " \t"); + content_length = std.fmt.parseInt(usize, raw, 10) catch + return error.MalformedContentLength; + } + } + + const length = content_length orelse return error.MissingContentLength; + if (length > max_content_length) return error.ContentTooLarge; + + const body = try allocator.alloc(u8, length); + errdefer allocator.free(body); + + reader.readSliceAll(body) catch |err| switch (err) { + error.EndOfStream => return error.UnexpectedEndOfStream, + error.ReadFailed => return error.ReadFailed, + }; + return body; +} + +/// Writes one framed message. Unbuffered on purpose: a client blocks waiting on +/// our replies, so every message has to hit the pipe before we return. +pub fn writeMessage(out: std.fs.File, body: []const u8) !void { + var header_buf: [64]u8 = undefined; + // Cannot overflow in practice (a 20-digit length still fits), but propagating + // costs nothing and avoids relying on `unreachable`. + const header = try std.fmt.bufPrint(&header_buf, "Content-Length: {d}\r\n\r\n", .{body.len}); + try out.writeAll(header); + try out.writeAll(body); +} + +const testing = std.testing; + +fn expectMessages(input: []const u8, want: []const []const u8) !void { + var reader = std.Io.Reader.fixed(input); + for (want) |expected| { + const got = (try readMessage(testing.allocator, &reader)) orelse + return error.UnexpectedNull; + defer testing.allocator.free(got); + try testing.expectEqualStrings(expected, got); + } + try testing.expectEqual(@as(?[]u8, null), try readMessage(testing.allocator, &reader)); +} + +test "reads a single message" { + try expectMessages("Content-Length: 2\r\n\r\n{}", &.{"{}"}); +} + +test "reads back to back messages" { + try expectMessages( + "Content-Length: 2\r\n\r\n{}" ++ "Content-Length: 5\r\n\r\n[1,2]", + &.{ "{}", "[1,2]" }, + ); +} + +test "skips other headers and is case insensitive" { + try expectMessages( + "content-type: application/vscode-jsonrpc; charset=utf-8\r\n" ++ + "CONTENT-LENGTH: 2\r\n\r\n{}", + &.{"{}"}, + ); +} + +test "tolerates bare newlines instead of CRLF" { + try expectMessages("Content-Length: 2\n\n{}", &.{"{}"}); +} + +test "clean end of stream yields null" { + var reader = std.Io.Reader.fixed(""); + try testing.expectEqual(@as(?[]u8, null), try readMessage(testing.allocator, &reader)); +} + +test "body shorter than Content-Length is truncation, not EOF" { + var reader = std.Io.Reader.fixed("Content-Length: 10\r\n\r\n{}"); + try testing.expectError(error.UnexpectedEndOfStream, readMessage(testing.allocator, &reader)); +} + +test "header block with no Content-Length is an error" { + var reader = std.Io.Reader.fixed("Content-Type: text/plain\r\n\r\n{}"); + try testing.expectError(error.MissingContentLength, readMessage(testing.allocator, &reader)); +} + +test "non-numeric Content-Length is an error" { + var reader = std.Io.Reader.fixed("Content-Length: abc\r\n\r\n{}"); + try testing.expectError(error.MalformedContentLength, readMessage(testing.allocator, &reader)); +} + +test "oversized Content-Length is refused without allocating" { + var reader = std.Io.Reader.fixed("Content-Length: 999999999999\r\n\r\n"); + try testing.expectError(error.ContentTooLarge, readMessage(testing.allocator, &reader)); +} + +test "end of stream mid header block is truncation" { + var reader = std.Io.Reader.fixed("Content-Length: 2\r\n"); + try testing.expectError(error.UnexpectedEndOfStream, readMessage(testing.allocator, &reader)); +} + +test "zero length body is a valid message" { + try expectMessages("Content-Length: 0\r\n\r\n", &.{""}); +}