ajhahn.de
← Flash
Flash 461 lines
// server — the LSP server core: JSON-RPC dispatch, the initialize
// lifecycle, and the document store.
//
// This module is pure protocol logic: `handle` takes one decoded message
// body and returns what to send back (if anything) and whether to exit —
// no file descriptors, so the tests drive it with byte buffers end to
// end. The driver in main.flash owns the actual stdio loop.
//
// Lifecycle per the spec: before `initialize`, every other request is
// answered with ServerNotInitialized and every notification except
// `exit` is dropped. `shutdown` answers with a null result and arms the
// exit handshake; `exit` then ends the process — code 0 after a
// shutdown, code 1 without one, so a client that dies mid-session is
// distinguishable from a clean stop. Unknown requests get MethodNotFound;
// unknown notifications (including `$/`-prefixed ones) are ignored.
//
// Memory: each message is handled inside the caller's per-message arena —
// the response and all JSON nodes die with it. Only the document store
// lives longer: opened texts are duplicated into the server's own
// allocator and freed on replace and close.

use std
use core
use "check"

pub const Error = error{OutOfMemory}

// One open document: the uri and the full current text, both owned by
// the server's allocator.
pub const Doc = struct {
    uri []u8,
    text []u8,
}

// What `handle` decided: a message body to frame and write — a request's
// response, or a server-initiated notification such as
// publishDiagnostics; null when there is nothing to send — and an exit
// code when the client asked the process to end.
pub const Outcome = struct {
    response ?[]u8,
    exit ?u8,
}

pub const Server = struct {
    // Long-lived allocator — the document store only.
    alloc std.mem.Allocator,
    // Reported as serverInfo.version in the initialize answer.
    version []u8,
    docs core.list.List(Doc),
    initialized bool,
    shutdown_requested bool,
    // True when the client offered utf-8 position encoding and we
    // declared it; diagnostics columns depend on this.
    utf8_positions bool,

    pub fn init(alloc std.mem.Allocator, version []u8) Server {
        return .{ .alloc = alloc, .version = version, .docs = .empty, .initialized = false, .shutdown_requested = false, .utf8_positions = false }
    }

    pub fn deinit(self *mut Server) void {
        for d in self.docs.items {
            self.alloc.free(d.uri)
            self.alloc.free(d.text)
        }
        self.docs.deinit(self.alloc)
    }

    // Handle one message body. `arena` is the per-message arena: the
    // returned response and every intermediate JSON node come from it.
    pub fn handle(self *mut Server, arena std.mem.Allocator, body []u8) Error!Outcome {
        v := core.json.parse(arena, body) catch |err| switch err {
            error.Malformed => return respond(try errorResponse(arena, "null", -32700, "parse error")),
            error.OutOfMemory => return error.OutOfMemory,
        }
        method_v := v.get("method") orelse return respond(try errorResponse(arena, try idJson(arena, v), -32600, "invalid request"))
        var method []u8 = ""
        switch method_v {
            .string => |s| {
                method = s
            },
            else => return respond(try errorResponse(arena, try idJson(arena, v), -32600, "invalid request")),
        }
        is_request := v.get("id") != null

        // The pre-initialize gate: only `initialize` and `exit` pass.
        if !self.initialized && !core.mem.eql(u8, method, "initialize") && !core.mem.eql(u8, method, "exit") {
            if is_request {
                return respond(try errorResponse(arena, try idJson(arena, v), -32002, "server not initialized"))
            }
            return none()
        }

        if core.mem.eql(u8, method, "initialize") {
            self.initialized = true
            self.utf8_positions = wantsUtf8(v.get("params"))
            return respond(try self.initializeResponse(arena, try idJson(arena, v)))
        }
        if core.mem.eql(u8, method, "initialized") {
            return none()
        }
        if core.mem.eql(u8, method, "shutdown") {
            self.shutdown_requested = true
            return respond(try concat3(arena, "{\"jsonrpc\":\"2.0\",\"id\":", try idJson(arena, v), ",\"result\":null}"))
        }
        if core.mem.eql(u8, method, "exit") {
            var code u8 = 1
            if self.shutdown_requested {
                code = 0
            }
            return Outcome{ .response = null, .exit = code }
        }
        if core.mem.eql(u8, method, "textDocument/didOpen") {
            if textDocument(v) |td| {
                if stringField(td, "uri") |uri| {
                    if stringField(td, "text") |txt| {
                        try self.upsert(uri, txt)
                        return respond(try check.publishJson(arena, uri, self.text(uri).?, false))
                    }
                }
            }
            return none()
        }
        if core.mem.eql(u8, method, "textDocument/didChange") {
            changed := try self.applyChange(v)
            if changed |uri| {
                return respond(try check.publishJson(arena, uri, self.text(uri).?, false))
            }
            return none()
        }
        if core.mem.eql(u8, method, "textDocument/didSave") {
            // The on-save pass adds sema on top of the parse.
            if textDocument(v) |td| {
                if stringField(td, "uri") |uri| {
                    if self.text(uri) |src| {
                        return respond(try check.publishJson(arena, uri, src, true))
                    }
                }
            }
            return none()
        }
        if core.mem.eql(u8, method, "textDocument/didClose") {
            if textDocument(v) |td| {
                if stringField(td, "uri") |uri| {
                    self.close(uri)
                    // An empty publish clears the file's stale squiggles.
                    return respond(try check.publishJson(arena, uri, "", false))
                }
            }
            return none()
        }

        if is_request {
            return respond(try errorResponse(arena, try idJson(arena, v), -32601, "method not found"))
        }
        return none()
    }

    // The current text of `uri`, or null when it is not open.
    pub fn text(self *Server, uri []u8) ?[]u8 {
        for d in self.docs.items {
            if core.mem.eql(u8, d.uri, uri) {
                return d.text
            }
        }
        return null
    }

    fn initializeResponse(self *Server, arena std.mem.Allocator, id []u8) Error![]u8 {
        var out core.list.List(u8) = .empty
        try out.appendSlice(arena, "{\"jsonrpc\":\"2.0\",\"id\":")
        try out.appendSlice(arena, id)
        try out.appendSlice(arena, ",\"result\":{\"capabilities\":{\"textDocumentSync\":1")
        if self.utf8_positions {
            try out.appendSlice(arena, ",\"positionEncoding\":\"utf-8\"")
        }
        try out.appendSlice(arena, "},\"serverInfo\":{\"name\":\"flashd\",\"version\":\"")
        try out.appendSlice(arena, self.version)
        try out.appendSlice(arena, "\"}}}")
        return out.toOwnedSlice(arena)
    }

    // Full-document sync: replace the stored text with the last entry of
    // contentChanges and report the uri, or null when the message does
    // not carry one — we declared Full sync, so a range-only change
    // cannot be applied.
    fn applyChange(self *mut Server, v core.json.Value) Error!?[]u8 {
        td := textDocument(v) orelse return null
        uri := stringField(td, "uri") orelse return null
        params := v.get("params") orelse return null
        changes_v := params.get("contentChanges") orelse return null
        switch changes_v {
            .array => |xs| {
                if xs.len == 0 {
                    return null
                }
                new_text := stringField(xs[xs.len - 1], "text") orelse return null
                try self.upsert(uri, new_text)
                return uri
            },
            else => return null,
        }
    }

    fn upsert(self *mut Server, uri []u8, new_text []u8) Error!void {
        for d, i in self.docs.items {
            if core.mem.eql(u8, d.uri, uri) {
                dup := try self.alloc.dupe(u8, new_text)
                self.alloc.free(self.docs.items[i].text)
                self.docs.items[i].text = dup
                return
            }
        }
        u := try self.alloc.dupe(u8, uri)
        errdefer self.alloc.free(u)
        t := try self.alloc.dupe(u8, new_text)
        errdefer self.alloc.free(t)
        try self.docs.append(self.alloc, .{ .uri = u, .text = t })
    }

    fn close(self *mut Server, uri []u8) void {
        for d, i in self.docs.items {
            if core.mem.eql(u8, d.uri, uri) {
                self.alloc.free(d.uri)
                self.alloc.free(d.text)
                _ = self.docs.swapRemove(i)
                return
            }
        }
    }
}

fn respond(body []u8) Outcome {
    return .{ .response = body, .exit = null }
}

fn none() Outcome {
    return .{ .response = null, .exit = null }
}

// The request id re-serialized exactly as it arrived (number or string;
// "null" when absent) — safe to splice into a response.
fn idJson(arena std.mem.Allocator, v core.json.Value) Error![]u8 {
    idv := v.get("id") orelse return "null"
    return core.json.stringify(arena, idv) catch |err| switch err {
        error.OutOfMemory => return error.OutOfMemory,
        error.Malformed => return "null",
    }
}

fn errorResponse(arena std.mem.Allocator, id []u8, code i64, msg []u8) Error![]u8 {
    var out core.list.List(u8) = .empty
    try out.appendSlice(arena, "{\"jsonrpc\":\"2.0\",\"id\":")
    try out.appendSlice(arena, id)
    try out.appendSlice(arena, ",\"error\":{\"code\":")
    var buf [32]u8 = undefined
    try out.appendSlice(arena, core.fmt.bufPrint(buf[0..], "{d}", .{code}) catch unreachable)
    try out.appendSlice(arena, ",\"message\":\"")
    try out.appendSlice(arena, msg)
    try out.appendSlice(arena, "\"}}")
    return out.toOwnedSlice(arena)
}

fn concat3(arena std.mem.Allocator, a []u8, b []u8, c []u8) Error![]u8 {
    var out core.list.List(u8) = .empty
    try out.appendSlice(arena, a)
    try out.appendSlice(arena, b)
    try out.appendSlice(arena, c)
    return out.toOwnedSlice(arena)
}

// params.textDocument of `v`, when present.
fn textDocument(v core.json.Value) ?core.json.Value {
    params := v.get("params") orelse return null
    return params.get("textDocument")
}

fn stringField(v core.json.Value, key []u8) ?[]u8 {
    f := v.get(key) orelse return null
    switch f {
        .string => |s| return s,
        else => return null,
    }
}

// Did the client offer utf-8 position encoding (LSP 3.17,
// capabilities.general.positionEncodings)?
fn wantsUtf8(params ?core.json.Value) bool {
    p := params orelse return false
    caps := p.get("capabilities") orelse return false
    g := caps.get("general") orelse return false
    encs := g.get("positionEncodings") orelse return false
    switch encs {
        .array => |xs| {
            for x in xs {
                switch x {
                    .string => |s| {
                        if core.mem.eql(u8, s, "utf-8") {
                            return true
                        }
                    },
                    else => {},
                }
            }
        },
        else => {},
    }
    return false
}

fn contains(haystack []u8, needle []u8) bool {
    return core.mem.indexOf(u8, haystack, needle) != null
}

test "initialize answers capabilities and arms the session" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0.0.0-test")
    defer srv.deinit()

    o := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"capabilities\":{\"general\":{\"positionEncodings\":[\"utf-16\",\"utf-8\"]}}}}")
    r := o.response.?
    try std.testing.expect(contains(r, "\"id\":1"))
    try std.testing.expect(contains(r, "\"textDocumentSync\":1"))
    try std.testing.expect(contains(r, "\"positionEncoding\":\"utf-8\""))
    try std.testing.expect(contains(r, "\"name\":\"flashd\""))
    try std.testing.expect(contains(r, "\"version\":\"0.0.0-test\""))
    try std.testing.expect(srv.initialized)
    try std.testing.expect(srv.utf8_positions)
    try std.testing.expect(o.exit == null)
}

test "without a utf-8 offer the encoding stays at the protocol default" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    o := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"capabilities\":{}}}")
    try std.testing.expect(!contains(o.response.?, "positionEncoding"))
    try std.testing.expect(!srv.utf8_positions)
}

test "requests before initialize are refused, notifications dropped" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    o := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"shutdown\"}")
    try std.testing.expect(contains(o.response.?, "-32002"))
    n := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{}}")
    try std.testing.expect(n.response == null)
    try std.testing.expect(n.exit == null)
}

test "the shutdown-exit handshake reports a clean zero" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
    s := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"shutdown\"}")
    try std.testing.expect(contains(s.response.?, "\"result\":null"))
    e := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}")
    try std.testing.expectEqual(0, e.exit.?)
}

test "exit without shutdown reports failure" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    e := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"exit\"}")
    try std.testing.expectEqual(1, e.exit.?)
}

test "unknown requests get MethodNotFound, unknown notifications nothing" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
    u := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"method\":\"textDocument/hover\"}")
    try std.testing.expect(contains(u.response.?, "-32601"))
    try std.testing.expect(contains(u.response.?, "\"id\":\"abc\""))
    n := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"$/cancelRequest\",\"params\":{\"id\":1}}")
    try std.testing.expect(n.response == null)
}

test "unparseable bodies answer ParseError with a null id" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    o := try srv.handle(a.allocator(), "{nope")
    try std.testing.expect(contains(o.response.?, "-32700"))
    try std.testing.expect(contains(o.response.?, "\"id\":null"))
}

test "didOpen, didChange, and didClose drive the document store" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()

    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{\"textDocument\":{\"uri\":\"file:///a.flash\",\"text\":\"fn one\"}}}")
    try std.testing.expect(core.mem.eql(u8, srv.text("file:///a.flash").?, "fn one"))

    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{\"uri\":\"file:///a.flash\"},\"contentChanges\":[{\"text\":\"fn two\"}]}}")
    try std.testing.expect(core.mem.eql(u8, srv.text("file:///a.flash").?, "fn two"))

    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didClose\",\"params\":{\"textDocument\":{\"uri\":\"file:///a.flash\"}}}")
    try std.testing.expect(srv.text("file:///a.flash") == null)
    try std.testing.expectEqual(0, srv.docs.items.len)
}

test "the open-edit-save cycle publishes and clears diagnostics" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    var srv = Server.init(std.testing.allocator, "0")
    defer srv.deinit()
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")

    // Open with a parse error: the publish carries a severity-1 entry.
    o := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{\"textDocument\":{\"uri\":\"file:///d.flash\",\"text\":\"fn broken(\"}}}")
    try std.testing.expect(contains(o.response.?, "publishDiagnostics"))
    try std.testing.expect(contains(o.response.?, "\"severity\":1"))

    // Edit to clean source: the publish is empty — squiggles clear.
    c := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{\"uri\":\"file:///d.flash\"},\"contentChanges\":[{\"text\":\"fn ok() void {\\n}\\n\"}]}}")
    try std.testing.expect(contains(c.response.?, "\"diagnostics\":[]"))

    // A parseable sema error stays quiet on change, loud on save.
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{\"uri\":\"file:///d.flash\"},\"contentChanges\":[{\"text\":\"fn f() usize {\\n    return 1 / 0\\n}\\n\"}]}}")
    s := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didSave\",\"params\":{\"textDocument\":{\"uri\":\"file:///d.flash\"}}}")
    try std.testing.expect(contains(s.response.?, "\"severity\":1"))

    // Close: one final empty publish.
    x := try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didClose\",\"params\":{\"textDocument\":{\"uri\":\"file:///d.flash\"}}}")
    try std.testing.expect(contains(x.response.?, "\"diagnostics\":[]"))
}

fn handleSweep(alloc std.mem.Allocator) !void {
    var a = core.arena.ArenaAllocator.init(alloc)
    defer a.deinit()
    var srv = Server.init(alloc, "0")
    defer srv.deinit()
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{\"textDocument\":{\"uri\":\"file:///s.flash\",\"text\":\"x\"}}}")
    _ = try srv.handle(a.allocator(), "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{\"textDocument\":{\"uri\":\"file:///s.flash\"},\"contentChanges\":[{\"text\":\"y\"}]}}")
}

test "handle survives failure at every allocation point" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, handleSweep, .{})
}