first working version (still zig 0.15)

This commit is contained in:
Emil Lerch 2026-09-01 13:31:17 -07:00
commit 4d022bcc80
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 1697 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.zig-cache/
zig-out/

5
.mise.toml Normal file
View file

@ -0,0 +1,5 @@
[tools]
prek = "0.3.1"
"ubi:DonIsaac/zlint" = "0.7.9"
zig = "0.15.2"
zls = "0.15.1"

35
.pre-commit-config.yaml Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

116
README.md Normal file
View file

@ -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
```

42
build.zig Normal file
View file

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

17
build.zig.zon Normal file
View file

@ -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",
},
}

722
src/Server.zig Normal file
View file

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

494
src/analysis.zig Normal file
View file

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

84
src/main.zig Normal file
View file

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

159
src/rpc.zig Normal file
View file

@ -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", &.{""});
}