// lsp.v — a minimal Language Server Protocol server for VuurRaaf. // // vr lsp // // Speaks JSON-RPC 2.0 over stdio with Content-Length framing. The editor // (see extension/ for a VS Code client) sends the open document, and the // server replies with: // - diagnostics: the compiler's first error, positioned via its line/col // - go-to-definition: jumps to function/struct/enum/const/let declarations // in the current file (and stdlib module functions) // - hover: the kind of the symbol under the cursor // // Text sync is "full document" (version 1), which keeps the client simple. module main import os import json2 import compiler // stdio bindings used for the JSON-RPC transport (Content-Length framing) fn C.fgetc(stream voidptr) int fn C.fread(dest voidptr, size usize, count usize, stream voidptr) usize fn C.fwrite(src voidptr, size usize, count usize, stream voidptr) usize fn C.fflush(stream voidptr) int // --------------------------------------------------------------------------- // JSON-RPC framing struct LspParams { mut: text_document TextDocParam @[json: 'textDocument'] position LspPosition content_changes []TextChangeParam @[json: 'contentChanges'] } struct TextDocParam { mut: uri string text string version int } struct LspPosition { mut: line int character int } struct TextChangeParam { mut: text string } struct LspMsg { mut: jsonrpc string id int method string params LspParams } fn toolchain_lsp() { run_lsp() } fn run_lsp() { mut docs := map[string]string{} // uri -> latest text (full sync) for { raw := read_rpc() or { break } if raw.len == 0 { continue } handle_rpc(raw, mut docs) } } fn handle_rpc(raw string, mut docs map[string]string) { msg := json2.decode[LspMsg](raw) or { return } match msg.method { 'initialize' { // textDocumentSync 1 = full document sync send_response(msg.id, '{"capabilities":{"textDocumentSync":1,"definitionProvider":true,"hoverProvider":true}}') } 'initialized' { // notification — nothing to do } 'shutdown' { send_response(msg.id, 'null') } 'exit' { exit(0) } 'textDocument/didOpen' { docs[msg.params.text_document.uri] = msg.params.text_document.text publish_diagnostics(msg.params.text_document.uri, msg.params.text_document.text) } 'textDocument/didChange' { // full sync: the last content change carries the whole document if msg.params.content_changes.len > 0 { docs[msg.params.text_document.uri] = msg.params.content_changes[msg.params.content_changes.len - 1].text } text := docs[msg.params.text_document.uri] or { '' } publish_diagnostics(msg.params.text_document.uri, text) } 'textDocument/didSave' { text := docs[msg.params.text_document.uri] or { '' } publish_diagnostics(msg.params.text_document.uri, text) } 'textDocument/definition' { loc := definition_at(msg.params.text_document.uri, docs, msg.params.position) send_response(msg.id, loc) } 'textDocument/hover' { h := hover_at(msg.params.text_document.uri, docs, msg.params.position) send_response(msg.id, h) } else { // respond null to unknown requests so editors don't hang if msg.id > 0 { send_response(msg.id, 'null') } } } } // read_rpc reads one Content-Length framed JSON-RPC message from stdin. fn read_rpc() !string { mut content_len := 0 for { line := read_stdin_line() or { return err } if line.len == 0 { break // blank line ends the header block } if line.starts_with('Content-Length:') { content_len = line.all_after(':').trim_space().int() } } if content_len <= 0 { return '' } return read_stdin_bytes(content_len).bytestr() } fn read_stdin_line() !string { mut line := []u8{} for { b := read_stdin_byte() or { return err } if b == `\n` { break } if b != `\r` { line << b } } return line.bytestr() } fn read_stdin_byte() !u8 { c := unsafe { C.fgetc(C.stdin) } if c == -1 { return error('stdin closed') } return u8(c) } fn read_stdin_bytes(n int) []u8 { mut buf := []u8{len: n} if n > 0 { unsafe { C.fread(buf.data, 1, usize(n), C.stdin) } } return buf } fn send_response(id int, result string) { send_raw('{"jsonrpc":"2.0","id":${id},"result":${result}}') } fn send_raw(body string) { header := 'Content-Length: ${body.len}\r\n\r\n' unsafe { C.fwrite(header.str, 1, usize(header.len), C.stdout) C.fwrite(body.str, 1, usize(body.len), C.stdout) C.fflush(C.stdout) } } // --------------------------------------------------------------------------- // diagnostics fn publish_diagnostics(uri string, text string) { diags := collect_diagnostics(text) send_raw('{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"${json_escape(uri)}","diagnostics":[${diags}]}}') } // collect_diagnostics runs the compiler over `text` and renders the first // error as an LSP diagnostic. Returns the diagnostics array body (or empty). fn collect_diagnostics(text string) string { _ = compiler.compile(text) or { mut line, mut col := extract_pos(err.msg()) if line < 1 { line = 1 } start_line := line - 1 msg := json_escape(err.msg()) return '{"range":{"start":{"line":${start_line},"character":${col}},"end":{"line":${start_line},"character":${col + 1}}},"severity":1,"source":"vr","message":"${msg}"}' } return '' } // extract_pos pulls "line N[, col M]" out of a compiler error message. // The toolchain's errors are formatted as `... at line 12, col 5`, // `... at line 12` or `... (line 12)`. fn extract_pos(msg string) (int, int) { mut line := 0 mut col := 0 if start := msg.index('line ') { mut i := start + 5 mut num := '' for i < msg.len && msg[i] >= `0` && msg[i] <= `9` { num += msg[i].ascii_str() i++ } if num.len > 0 { line = num.int() } if c := msg.index('col ') { mut j := c + 4 mut cnum := '' for j < msg.len && msg[j] >= `0` && msg[j] <= `9` { cnum += msg[j].ascii_str() j++ } if cnum.len > 0 { col = cnum.int() } } } return line, col } // --------------------------------------------------------------------------- // symbols (definition + hover) // symbol_at resolves the identifier under the cursor and returns its kind and // definition line, looking in the current document and then in imported // stdlib modules. fn symbol_at(uri string, docs map[string]string, pos LspPosition) (string, int, string) { text := docs[uri] or { return '', 0, '' } word := word_at(text, pos) if word.len == 0 { return '', 0, '' } line, kind, ok := find_symbol(text, word) if ok { return uri, line, kind } // module call: the cursor may sit on the module part ("os" in "os.exists") // or the function part ("exists"). Look up imported modules either way. mut mod_name := '' mut fn_name := word if word.contains('.') { parts := word.split('.') if parts.len == 2 { mod_name = parts[0] fn_name = parts[1] } } else { // cursor on the module part: extend to the dotted name if pos.character > 0 && pos.character < text.len { if e := word_at_ext(text, pos) { parts := e.split('.') if parts.len == 2 && parts[0] == word { mod_name = parts[0] fn_name = parts[1] } } } } if mod_name.len > 0 { if path := compiler.resolve_import(mod_name) { src := os.read_file(path) or { return '', 0, '' } l2, k2, ok2 := find_symbol(src, fn_name) if ok2 { return 'file://${path}', l2, k2 } } } // bare function name: search every module this file imports for m in imported_modules(text) { if path := compiler.resolve_import(m) { src := os.read_file(path) or { continue } l2, k2, ok2 := find_symbol(src, word) if ok2 { return 'file://${path}', l2, k2 } } } return '', 0, '' } // word_at_ext returns the dotted identifier starting at `pos` when the cursor // is on the module part of a call like os.exists (word_at alone would stop // at the dot). fn word_at_ext(text string, pos LspPosition) ?string { mut line_i := 0 for l in text.split('\n') { if line_i == pos.line { if pos.character < 0 || pos.character > l.len { return none } // scan back to the start of the dotted identifier mut start := pos.character for start > 0 && (is_ident_char(l[start - 1]) || l[start - 1] == `.`) { start-- } mut end := pos.character for end < l.len && (is_ident_char(l[end]) || l[end] == `.`) { end++ } if start == end { return none } return l[start..end] } line_i++ } return none } // imported_modules returns the bare module names (`import os`) in a file. fn imported_modules(text string) []string { toks := compiler.tokenize(text) or { return []string{} } prog := compiler.parse(toks) or { return []string{} } mut out := []string{} for imp in prog.imports { if imp.name.len > 0 { out << imp.name } } return out } fn definition_at(uri string, docs map[string]string, pos LspPosition) string { loc_uri, line, _ := symbol_at(uri, docs, pos) if loc_uri.len == 0 { return 'null' } return location_json(loc_uri, line) } fn hover_at(uri string, docs map[string]string, pos LspPosition) string { _, line, kind := symbol_at(uri, docs, pos) if line == 0 { return 'null' } return '{"contents":{"kind":"markdown","value":"**${kind}** at line ${line}"}}' } fn location_json(uri string, line int) string { // 1-based source line → 0-based LSP position; character 0 (we only track lines) return '{"uri":"${json_escape(uri)}","range":{"start":{"line":${line - 1},"character":0},"end":{"line":${line - 1},"character":0}}}' } // word_at returns the identifier covering the given position in `text`. fn word_at(text string, pos LspPosition) string { mut line_i := 0 for l in text.split('\n') { if line_i == pos.line { if pos.character < 0 || pos.character > l.len { return '' } mut start := pos.character mut end := pos.character for start > 0 && is_ident_char(l[start - 1]) { start-- } for end < l.len && is_ident_char(l[end]) { end++ } if start == end { return '' } return l[start..end] } line_i++ } return '' } fn is_ident_char(c u8) bool { lo := c >= `a` && c <= `z` hi := c >= `A` && c <= `Z` dig := c >= `0` && c <= `9` return lo || hi || dig || c == `_` } // find_symbol parses VuurRaaf source and locates the definition line and kind // of a named symbol (functions, structs, enums, constants, locals, params). fn find_symbol(src string, name string) (int, string, bool) { toks := compiler.tokenize(src) or { return 0, '', false } prog := compiler.parse(toks) or { return 0, '', false } mut line := 0 mut kind := '' for fd in prog.fns { if fd.name == name { return fd.line, 'function', true } for p in fd.params { if p == name { line = fd.line kind = 'parameter' } } } for sd in prog.structs { if sd.name == name { return sd.line, 'struct', true } } for ed in prog.enums { if ed.name == name { return ed.line, 'enum', true } } for cd in prog.consts { if cd.name == name { return cd.line, 'constant', true } } for fd in prog.fns { for st in fd.body { l, k, ok := find_symbol_stmt(st, name) if ok { line = l kind = k } } } if line > 0 { return line, kind, true } return 0, '', false } fn find_symbol_stmt(st compiler.Stmt, name string) (int, string, bool) { match st.kind { .let_stmt { if st.target == name { return st.line, 'variable', true } } .destruct_stmt { for t in st.destruct_targets { if t == name { return st.line, 'variable', true } } } .for_range_stmt, .for_in_stmt { if st.target == name { return st.line, 'loop variable', true } if st.idx_target == name { return st.line, 'loop variable', true } } .try_stmt { if st.target == name { return st.line, 'catch variable', true } } else {} } // recurse into nested statements so inner blocks are covered too for s in st.body { l, k, ok := find_symbol_stmt(s, name) if ok { return l, k, true } } for s in st.els { l, k, ok := find_symbol_stmt(s, name) if ok { return l, k, true } } for s in st.els_body { l, k, ok := find_symbol_stmt(s, name) if ok { return l, k, true } } for arm in st.arms { for s in arm.body { l, k, ok := find_symbol_stmt(s, name) if ok { return l, k, true } } } return 0, '', false } // --------------------------------------------------------------------------- // helpers fn json_escape(s string) string { mut out := '' for c in s { match c { `"` { out += '\\"' } `\\` { out += '\\\\' } `\n` { out += '\\n' } `\r` { out += '\\r' } `\t` { out += '\\t' } else { out += c.ascii_str() } } } return out }