ajhahn.de
← Flash
Flash 176 lines
// check — in-process diagnostics: the selfhost frontend over one open
// document, rendered as a `textDocument/publishDiagnostics` notification.
//
// The compiler is a library here: the lexer and parser (and, on save,
// the semantic checker) run directly over the stored document text — no
// flashc process is spawned and no error text is parsed back. The
// parser's diagnostic carries a line but no column, so its range is the
// whole offending line; sema anchors recover line and column through
// `locate`, so those get a one-character point range, and a diagnostic's
// optional note becomes a second, information-severity entry at its own
// location. The parser stops at the first error — one parse diagnostic
// per run is the accepted limit until error recovery lands.
//
// Every run renders a complete notification, empty `diagnostics` array
// included: publishing the empty list is what clears stale squiggles
// after a fix. Positions are 0-based byte offsets within their line
// (the utf-8 position encoding; on the default utf-16 encoding,
// non-ASCII lines may render slightly off — accepted for Phase A).

use std
use core
use "parser"
use "sema"

pub const Error = error{OutOfMemory}

// Render the full publishDiagnostics notification body for `uri`: parse
// always, sema only when `run_sema` (the on-save pass).
pub fn publishJson(arena std.mem.Allocator, uri []u8, src []u8, run_sema bool) Error![]u8 {
    var out core.list.List(u8) = .empty
    try out.appendSlice(arena, "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{\"uri\":")
    try out.appendSlice(arena, try jsonStr(arena, uri))
    try out.appendSlice(arena, ",\"diagnostics\":[")
    try appendDiagnostics(arena, &out, src, run_sema)
    try out.appendSlice(arena, "]}}")
    return out.toOwnedSlice(arena)
}

fn appendDiagnostics(arena std.mem.Allocator, out *mut core.list.List(u8), src []u8, run_sema bool) Error!void {
    var p = parser.Parser.init(arena, src)
    program := p.parseProgram() catch |err| switch err {
        error.UnexpectedToken => {
            if p.diag |d| {
                span := lineSpan(src, d.line)
                try appendOne(arena, out, d.line - 1, span.start_col, d.line - 1, span.end_col, 1, d.msg)
            } else {
                try appendOne(arena, out, 0, 0, 0, 1, 1, "parse error")
            }
            return
        },
        error.OutOfMemory => return error.OutOfMemory,
    }
    if !run_sema {
        return
    }
    diags := try sema.check(arena, program)
    for d in diags {
        loc := sema.locate(src, d.anchor)
        try appendOne(arena, out, loc.line - 1, loc.col - 1, loc.line - 1, loc.col, 1, d.msg)
        if d.note_anchor |na| {
            nloc := sema.locate(src, na)
            nmsg := d.note_msg orelse "note"
            try appendOne(arena, out, nloc.line - 1, nloc.col - 1, nloc.line - 1, nloc.col, 3, nmsg)
        }
    }
}

// Append one LSP diagnostic object, comma-separated from a predecessor.
fn appendOne(arena std.mem.Allocator, out *mut core.list.List(u8), l0 u32, c0 u32, l1 u32, c1 u32, severity u8, msg []u8) Error!void {
    if out.items.len > 0 && out.items[out.items.len - 1] == '}' {
        try out.append(arena, ',')
    }
    try out.appendSlice(arena, "{\"range\":{\"start\":{\"line\":")
    try appendNum(arena, out, l0)
    try out.appendSlice(arena, ",\"character\":")
    try appendNum(arena, out, c0)
    try out.appendSlice(arena, "},\"end\":{\"line\":")
    try appendNum(arena, out, l1)
    try out.appendSlice(arena, ",\"character\":")
    try appendNum(arena, out, c1)
    try out.appendSlice(arena, "}},\"severity\":")
    try appendNum(arena, out, severity)
    try out.appendSlice(arena, ",\"source\":\"flashc\",\"message\":")
    try out.appendSlice(arena, try jsonStr(arena, msg))
    try out.append(arena, '}')
}

fn appendNum(arena std.mem.Allocator, out *mut core.list.List(u8), n u32) Error!void {
    var buf [16]u8 = undefined
    s := core.fmt.bufPrint(buf[0..], "{d}", .{n}) catch unreachable
    try out.appendSlice(arena, s)
}

// The 0-based character span of 1-based line `line1`: 0 to the line's
// byte length, so a column-less parser diagnostic underlines the whole
// line.
const Span = struct {
    start_col u32,
    end_col u32,
}

fn lineSpan(src []u8, line1 u32) Span {
    var line u32 = 1
    var start usize = 0
    var i usize = 0
    while i < src.len && line < line1 {
        if src[i] == '\n' {
            line += 1
            start = i + 1
        }
        i += 1
    }
    var end usize = start
    while end < src.len && src[end] != '\n' {
        end += 1
    }
    var width u32 = #as(u32, #intCast(end - start))
    if width == 0 {
        width = 1
    }
    return .{ .start_col = 0, .end_col = width }
}

// A JSON string literal (quoted, escaped) for splicing into a response.
fn jsonStr(arena std.mem.Allocator, s []u8) Error![]u8 {
    v := core.json.Value{ .string = s }
    return core.json.stringify(arena, v) catch return error.OutOfMemory
}

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

test "a clean document publishes an empty diagnostics array" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    body := try publishJson(a.allocator(), "file:///ok.flash", "fn id(x u32) u32 {\n    return x\n}\n", true)
    try std.testing.expect(contains(body, "\"method\":\"textDocument/publishDiagnostics\""))
    try std.testing.expect(contains(body, "\"uri\":\"file:///ok.flash\""))
    try std.testing.expect(contains(body, "\"diagnostics\":[]"))
}

test "a parse error publishes one whole-line diagnostic" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    // `const` without a name errors on the `=` token: line 1, and the
    // column-less parser diagnostic underlines the whole 9-byte line.
    body := try publishJson(a.allocator(), "file:///bad.flash", "const = 1\n", false)
    try std.testing.expect(contains(body, "\"severity\":1"))
    try std.testing.expect(contains(body, "\"source\":\"flashc\""))
    try std.testing.expect(contains(body, "\"start\":{\"line\":0,\"character\":0}"))
    try std.testing.expect(contains(body, "\"end\":{\"line\":0,\"character\":9}"))
    try std.testing.expect(!contains(body, "\"diagnostics\":[]"))
}

test "sema runs only when asked" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    // Parses fine; the evaluator rejects the definite division by zero.
    src := "fn f() usize {\n    return 1 / 0\n}\n"
    quiet := try publishJson(a.allocator(), "file:///s.flash", src, false)
    try std.testing.expect(contains(quiet, "\"diagnostics\":[]"))
    loud := try publishJson(a.allocator(), "file:///s.flash", src, true)
    try std.testing.expect(contains(loud, "\"severity\":1"))
    try std.testing.expect(contains(loud, "\"line\":1"))
}

test "diagnostic messages arrive JSON-escaped" {
    var a = core.arena.ArenaAllocator.init(std.testing.allocator)
    defer a.deinit()
    body := try publishJson(a.allocator(), "file:///q.flash", "\"unterminated\n", false)
    // Whatever the message text, the body must stay parseable JSON.
    v := try core.json.parse(a.allocator(), body)
    try std.testing.expect(v.get("params") != null)
}