fig 2.0.0

Parse, edit, and convert config files while preserving comments. Supports JSON, YAML, TOML, and more.
//! fig-lsp — a minimal Language Server for the fig authoring dialect.
//!
//! This is a thin shell around the Zig parser (`src/languages/fig/parser.zig`). It speaks
//! LSP JSON-RPC over stdio and, for now, does exactly one job well: on every
//! open/change it re-parses the document and publishes the parser's teaching
//! diagnostics (DESIGN.md's "every diagnostic names the fix") as squiggles.
//!
//! The parser stays the single source of truth — the Tree-sitter grammar under
//! editors/ colors the text, this server is what makes the editor understand it.
//! Formatting (`fig fmt`), hover, and completion are deliberately out of scope
//! for this first cut; the didChange→parse→publish loop is the spine everything
//! else hangs off of.

const std = @import("std");
const fig = @import("fig");

const Io = std.Io;
const Parser = fig.Language.FIG.Parser;

const max_body = 64 * 1024 * 1024; // hard cap on one JSON-RPC message

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;

    var stdin_buf: [64 * 1024]u8 = undefined;
    var stdin = Io.File.stdin().reader(io, &stdin_buf);
    const r = &stdin.interface;

    var stdout_buf: [64 * 1024]u8 = undefined;
    var stdout = Io.File.stdout().writer(io, &stdout_buf);
    const w = &stdout.interface;

    var server = Server{ .gpa = gpa, .w = w };
    defer server.deinit();

    // One arena reused per message: parse JSON + build the reply in it, then
    // reset. Anything that must outlive the message (the doc store) is duped
    // into `gpa` instead.
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();

    while (true) {
        _ = arena.reset(.retain_capacity);
        const a = arena.allocator();

        // Any read error (EOF, broken pipe, …) means the client is gone — exit.
        const body = (readMessage(r, a) catch break) orelse break;

        const parsed = std.json.parseFromSlice(std.json.Value, a, body, .{}) catch continue;
        server.handle(a, parsed.value) catch |err| switch (err) {
            error.Exit => break,
            else => return err,
        };
    }
}

/// Read one `Content-Length`-framed JSON-RPC message. Returns the raw body
/// bytes (owned by `a`), or null on a clean end-of-headers EOF.
fn readMessage(r: *Io.Reader, a: std.mem.Allocator) !?[]u8 {
    var content_length: ?usize = null;
    while (true) {
        const line = r.takeDelimiterInclusive('\n') catch |err| switch (err) {
            error.EndOfStream => if (content_length == null) return null else return error.EndOfStream,
            else => return err,
        };
        const trimmed = std.mem.trimEnd(u8, line, "\r\n");
        if (trimmed.len == 0) break; // blank line ends the header block
        if (std.ascii.startsWithIgnoreCase(trimmed, "content-length:")) {
            const num = std.mem.trim(u8, trimmed["content-length:".len..], " \t");
            content_length = std.fmt.parseInt(usize, num, 10) catch null;
        }
        // Other headers (Content-Type, …) are ignored.
    }
    const len = content_length orelse return error.EndOfStream;
    if (len > max_body) return error.EndOfStream;
    const body = try a.alloc(u8, len);
    try r.readSliceAll(body);
    return body;
}

const Server = struct {
    gpa: std.mem.Allocator,
    w: *Io.Writer,
    /// uri -> latest full text. Both key and value owned by `gpa`.
    docs: std.StringHashMapUnmanaged([]u8) = .empty,

    fn deinit(self: *Server) void {
        var it = self.docs.iterator();
        while (it.next()) |e| {
            self.gpa.free(e.key_ptr.*);
            self.gpa.free(e.value_ptr.*);
        }
        self.docs.deinit(self.gpa);
    }

    fn handle(self: *Server, a: std.mem.Allocator, msg: std.json.Value) !void {
        if (msg != .object) return;
        const obj = msg.object;
        const method = (obj.get("method") orelse return).string;
        const id = obj.get("id");
        const params = obj.get("params");

        if (std.mem.eql(u8, method, "initialize")) {
            try self.respondInitialize(id.?);
        } else if (std.mem.eql(u8, method, "shutdown")) {
            try self.respondNull(id.?);
        } else if (std.mem.eql(u8, method, "exit")) {
            return error.Exit;
        } else if (std.mem.eql(u8, method, "textDocument/didOpen")) {
            try self.didOpen(a, params);
        } else if (std.mem.eql(u8, method, "textDocument/didChange")) {
            try self.didChange(a, params);
        } else if (std.mem.eql(u8, method, "textDocument/didClose")) {
            try self.didClose(params);
        } else if (id) |req_id| {
            // Any other REQUEST (has an id) must get a reply so the client
            // isn't left hanging; notifications we don't know are ignored.
            try self.respondNull(req_id);
        }
    }

    // ─── lifecycle ───

    fn respondInitialize(self: *Server, id: std.json.Value) !void {
        // textDocumentSync = 1 (Full): each didChange carries the whole document.
        var buf = std.Io.Writer.Allocating.init(self.gpa);
        defer buf.deinit();
        const b = &buf.writer;
        try b.writeAll("{\"jsonrpc\":\"2.0\",\"id\":");
        try writeId(b, id);
        try b.writeAll(",\"result\":{\"capabilities\":{\"textDocumentSync\":1}," ++
            "\"serverInfo\":{\"name\":\"fig-lsp\",\"version\":\"0.0.1\"}}}");
        try self.send(buf.written());
    }

    fn respondNull(self: *Server, id: std.json.Value) !void {
        var buf = std.Io.Writer.Allocating.init(self.gpa);
        defer buf.deinit();
        const b = &buf.writer;
        try b.writeAll("{\"jsonrpc\":\"2.0\",\"id\":");
        try writeId(b, id);
        try b.writeAll(",\"result\":null}");
        try self.send(buf.written());
    }

    // ─── text sync ───

    fn didOpen(self: *Server, a: std.mem.Allocator, params: ?std.json.Value) !void {
        const p = (params orelse return).object;
        const td = (p.get("textDocument") orelse return).object;
        const uri = (td.get("uri") orelse return).string;
        const text = (td.get("text") orelse return).string;
        try self.store(uri, text);
        try self.publish(a, uri, text);
    }

    fn didChange(self: *Server, a: std.mem.Allocator, params: ?std.json.Value) !void {
        const p = (params orelse return).object;
        const td = (p.get("textDocument") orelse return).object;
        const uri = (td.get("uri") orelse return).string;
        const changes = (p.get("contentChanges") orelse return).array;
        if (changes.items.len == 0) return;
        // Full sync: the last change holds the entire new document.
        const text = changes.items[changes.items.len - 1].object.get("text").?.string;
        try self.store(uri, text);
        try self.publish(a, uri, text);
    }

    fn didClose(self: *Server, params: ?std.json.Value) !void {
        const p = (params orelse return).object;
        const td = (p.get("textDocument") orelse return).object;
        const uri = (td.get("uri") orelse return).string;
        if (self.docs.fetchRemove(uri)) |kv| {
            self.gpa.free(kv.key);
            self.gpa.free(kv.value);
        }
        // Clear any squiggles the client is still showing for this file.
        try self.publishEmpty(uri);
    }

    /// Replace the stored text for `uri` (keys/values owned by `gpa`).
    fn store(self: *Server, uri: []const u8, text: []const u8) !void {
        const gop = try self.docs.getOrPut(self.gpa, uri);
        if (gop.found_existing) {
            self.gpa.free(gop.value_ptr.*);
        } else {
            gop.key_ptr.* = try self.gpa.dupe(u8, uri);
        }
        gop.value_ptr.* = try self.gpa.dupe(u8, text);
    }

    // ─── diagnostics ───

    /// Parse `text`; publish the parser's report — the hard error (if any) as
    /// severity 1 (Error) plus the authoring warnings as severity 2 (Warning),
    /// or clear everything on a clean, lint-free parse. Warnings collected
    /// before a failure still publish alongside the error.
    fn publish(self: *Server, a: std.mem.Allocator, uri: []const u8, text: []const u8) !void {
        const report = diagnose(a, text);

        var buf = std.Io.Writer.Allocating.init(self.gpa);
        defer buf.deinit();
        const b = &buf.writer;

        try b.writeAll("{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{\"uri\":");
        try writeJsonString(b, uri);
        try b.writeAll(",\"diagnostics\":[");
        var first = true;
        // `parseCollecting` fills `errors` with every failure (source order);
        // publish each as its own Error squiggle. `diag` is the fallback for a
        // non-recovering report (mirrors `errors[0]` when both are present).
        if (report.errors.len > 0) {
            for (report.errors) |d| {
                if (!first) try b.writeAll(",");
                first = false;
                try writeDiagnostic(b, text, d.offset, d.end, 1, @errorName(d.code), Parser.describe(d.code));
            }
        } else if (report.diag) |d| {
            try writeDiagnostic(b, text, d.offset, d.end, 1, @errorName(d.code), Parser.describe(d.code));
            first = false;
        }
        for (report.warnings) |wn| {
            if (!first) try b.writeAll(",");
            first = false;
            try writeDiagnostic(b, text, wn.offset, wn.end, 2, @tagName(wn.code), Parser.Warning.describeWarning(wn.code));
        }
        try b.writeAll("]}}");
        try self.send(buf.written());
    }

    fn publishEmpty(self: *Server, uri: []const u8) !void {
        var buf = std.Io.Writer.Allocating.init(self.gpa);
        defer buf.deinit();
        const b = &buf.writer;
        try b.writeAll("{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{\"uri\":");
        try writeJsonString(b, uri);
        try b.writeAll(",\"diagnostics\":[]}}");
        try self.send(buf.written());
    }

    // ─── framing ───

    fn send(self: *Server, body: []const u8) !void {
        try self.w.print("Content-Length: {d}\r\n\r\n", .{body.len});
        try self.w.writeAll(body);
        try self.w.flush();
    }
};

/// Run the parser purely for its report (error diagnostic + warnings). The AST
/// is thrown away (this is a validity check), so everything — including the
/// warning list — lands in the caller's per-message arena `a`.
fn diagnose(a: std.mem.Allocator, text: []const u8) Parser.Report {
    var report: Parser.Report = .{};
    // Recover past each error so the server squiggles the WHOLE file at once,
    // not just the first mistake (`report.errors`, source order).
    _ = Parser.parseCollecting(a, text, fig.Language.FIG.default_type, &report) catch {};
    return report;
}

// ─── position math (LSP positions are 0-based, UTF-16 code units) ───

const Position = struct { line: usize, character: usize };
const Range = struct { start: Position, end: Position };

fn utf16Len(bytes: []const u8) usize {
    var i: usize = 0;
    var n: usize = 0;
    while (i < bytes.len) {
        const seq = std.unicode.utf8ByteSequenceLength(bytes[i]) catch {
            i += 1;
            n += 1;
            continue;
        };
        if (i + seq > bytes.len) break;
        const cp = std.unicode.utf8Decode(bytes[i .. i + seq]) catch {
            i += 1;
            n += 1;
            continue;
        };
        n += if (cp >= 0x10000) @as(usize, 2) else 1;
        i += seq;
    }
    return n;
}

fn positionAt(source: []const u8, at: usize) Position {
    var line: usize = 0;
    var line_start: usize = 0;
    for (source[0..at], 0..) |c, i| {
        if (c == '\n') {
            line += 1;
            line_start = i + 1;
        }
    }
    return .{ .line = line, .character = utf16Len(source[line_start..at]) };
}

/// A range covering the offending token. When the diagnostic carries an exact
/// end (`end_off`, e.g. a `: type` value's span) the squiggle stops there — so
/// a trailing comment stays un-squiggled; otherwise it runs to end-of-line.
/// Mirrors `Diagnostic.locate`'s "resting past a newline means the previous
/// line" backtrack so the squiggle lands on the line the author sees.
fn errorRange(source: []const u8, offset: usize, end_off: ?usize) Range {
    var at = @min(offset, source.len);
    if (at > 0 and source[at - 1] == '\n') at -= 1;
    const line_end = std.mem.indexOfScalarPos(u8, source, at, '\n') orelse source.len;
    // Clamp a known token-end to this line and never before the caret.
    const stop = if (end_off) |e| @max(at, @min(e, line_end)) else line_end;
    var start = positionAt(source, at);
    const end = positionAt(source, stop);
    // Guarantee a non-empty range so every client renders something.
    if (start.line == end.line and start.character == end.character) {
        if (start.character > 0) start.character -= 1;
    }
    return .{ .start = start, .end = end };
}

// ─── tiny JSON writers (output only; input uses std.json) ───

/// One LSP Diagnostic object: range (via `errorRange`), severity (1 Error /
/// 2 Warning), code, and the parser's teaching message.
fn writeDiagnostic(w: *Io.Writer, source: []const u8, offset: usize, end_off: ?usize, severity: u8, code: []const u8, message: []const u8) !void {
    try w.writeAll("{\"range\":");
    try writeRange(w, errorRange(source, offset, end_off));
    try w.print(",\"severity\":{d},\"source\":\"fig\",\"code\":", .{severity});
    try writeJsonString(w, code);
    try w.writeAll(",\"message\":");
    try writeJsonString(w, message);
    try w.writeAll("}");
}

fn writeRange(w: *Io.Writer, rng: Range) !void {
    try w.print(
        "{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}",
        .{ rng.start.line, rng.start.character, rng.end.line, rng.end.character },
    );
}

fn writeId(w: *Io.Writer, id: std.json.Value) !void {
    switch (id) {
        .integer => |n| try w.print("{d}", .{n}),
        .string => |s| try writeJsonString(w, s),
        else => try w.writeAll("null"),
    }
}

fn writeJsonString(w: *Io.Writer, s: []const u8) !void {
    try w.writeByte('"');
    for (s) |c| switch (c) {
        '"' => try w.writeAll("\\\""),
        '\\' => try w.writeAll("\\\\"),
        '\n' => try w.writeAll("\\n"),
        '\r' => try w.writeAll("\\r"),
        '\t' => try w.writeAll("\\t"),
        0x08 => try w.writeAll("\\b"),
        0x0C => try w.writeAll("\\f"),
        else => if (c < 0x20) try w.print("\\u{x:0>4}", .{c}) else try w.writeByte(c),
    };
    try w.writeByte('"');
}