first working version (still zig 0.15)
This commit is contained in:
commit
4d022bcc80
11 changed files with 1697 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.zig-cache/
|
||||
zig-out/
|
||||
5
.mise.toml
Normal file
5
.mise.toml
Normal 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
35
.pre-commit-config.yaml
Normal 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
21
LICENSE
Normal 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
116
README.md
Normal 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
42
build.zig
Normal 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
17
build.zig.zon
Normal 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
722
src/Server.zig
Normal 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
494
src/analysis.zig
Normal 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
84
src/main.zig
Normal 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
159
src/rpc.zig
Normal 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", &.{""});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue