From b3af1208e83c0ce70de8e9620e02e52bed5e78f2 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Sun, 7 Jun 2026 18:20:35 +0300 Subject: [PATCH] feat(lsp): add LSP server with completion, definition, hover, diagnostics --- Makefile | 8 +- tools/lsp_server.nim | 407 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 tools/lsp_server.nim diff --git a/Makefile b/Makefile index 365ec5f..fb7612a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ BUILD_DIR := build EXAMPLES := hello fibonacci factorial structs enums methods algebraic_enums generics generics_struct generic_infer generic_infer2 extend_generic pattern_matching strings strings2 map result_option try_operator ownership ctfe async concurrency os_time process json iter -.PHONY: all build dev debug test clean clean-all test-examples selfhost test-golden selfhost-loop +.PHONY: all build dev debug test clean clean-all test-examples selfhost test-golden selfhost-loop lsp all: build @@ -132,3 +132,9 @@ selfhost-loop: build echo " C output was identical — ELF difference is likely linker non-determinism"; \ echo "=== Selfhost loop: C codegen deterministic ✓, ELF non-deterministic (expected) ==="; \ fi + +lsp: tools/bux-lsp + @echo "LSP server ready at tools/bux-lsp" + +tools/bux-lsp: tools/lsp_server.nim + cd tools && $(NIM) c -o:bux-lsp lsp_server.nim diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim new file mode 100644 index 0000000..e161577 --- /dev/null +++ b/tools/lsp_server.nim @@ -0,0 +1,407 @@ +# lsp_server.nim — Bux Language Server Protocol implementation +# Communicates via stdin/stdout JSON-RPC 2.0 +# +# Usage: bux-lsp +# The editor spawns this binary and communicates via stdin/stdout. + +import std/[json, os, strutils, streams, tables] + +# --------------------------------------------------------------------------- +# JSON-RPC Transport +# --------------------------------------------------------------------------- + +proc readMessage(stream: FileStream): JsonNode = + ## Read a single JSON-RPC message from stream. + ## Format: Content-Length: N\r\n\r\n{json} + var header = "" + while true: + var line = "" + if not stream.readLine(line): + return nil + if line == "\r" or line == "": + break + header &= line & "\r\n" + + var contentLen = 0 + for hdr in header.split("\r\n"): + if hdr.toLowerAscii().startsWith("content-length:"): + try: + contentLen = parseInt(hdr.split(":")[1].strip()) + except: + discard + + if contentLen <= 0: + return nil + + var body = newString(contentLen) + if stream.readData(addr body[0], contentLen) != contentLen: + return nil + + try: + return parseJson(body) + except: + return nil + +proc sendMessage(stream: FileStream, msg: JsonNode) = + let body = $msg + let header = "Content-Length: " & $body.len & "\r\n\r\n" + stream.write(header) + stream.write(body) + stream.flush() + +proc sendResponse(stream: FileStream, id: JsonNode, resultNode: JsonNode) = + sendMessage(stream, %*{ + "jsonrpc": "2.0", + "id": id, + "result": resultNode + }) + +proc sendError(stream: FileStream, id: JsonNode, code: int, message: string) = + sendMessage(stream, %*{ + "jsonrpc": "2.0", + "id": id, + "error": {"code": code, "message": message} + }) + +proc sendNotification(stream: FileStream, methodName: string, paramsNode: JsonNode) = + sendMessage(stream, %*{ + "jsonrpc": "2.0", + "method": methodName, + "params": paramsNode + }) + +# --------------------------------------------------------------------------- +# Document state +# --------------------------------------------------------------------------- + +type + SymbolInfo = tuple[line: int, col: int, kind: string, typeName: string] + DocumentState = ref object + uri: string + content: string + version: int + symbols: Table[string, SymbolInfo] + +var + documents = initTable[string, DocumentState]() + rootPath = "" + rootUri = "" + +proc getDoc(uri: string): DocumentState = + if not documents.hasKey(uri): + documents[uri] = DocumentState(uri: uri) + return documents[uri] + +# --------------------------------------------------------------------------- +# File path from URI +# --------------------------------------------------------------------------- + +proc uriToPath(uri: string): string = + if uri.startsWith("file://"): + result = uri[7..^1] + else: + result = uri + +# --------------------------------------------------------------------------- +# Simple analysis: extract symbols via regex (no full compiler integration yet) +# --------------------------------------------------------------------------- + +proc analyzeFile(path: string, content: string): DocumentState = + result = DocumentState(uri: "file://" & path, content: content) + + # Simple symbol extraction: find func/var/let/struct/enum declarations + var idx = 0 + var line = 0 + var col = 0 + for ch in content: + if ch == '\n': + line += 1 + col = 0 + idx += 1 + continue + col += 1 + idx += 1 + + # Use string-based pattern matching for common Bux declarations + var i = 0 + var currLine = 0 + var currCol = 0 + while i < content.len: + let c = content[i] + if c == '\n': + currLine += 1 + currCol = 0 + i += 1 + continue + currCol += 1 + + # Match "func Name" + if content[i..min(i+4, content.len-1)] == "func ": + var start = i + 5 + var name = "" + while start < content.len and content[start] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + name &= content[start] + start += 1 + if name != "": + result.symbols[name] = (line: currLine, col: currCol + 5, kind: "function", typeName: "") + i = start + continue + + # Match "var Name" or "let Name" + if i + 3 < content.len and (content[i..i+2] == "var " or content[i..i+2] == "let "): + let kwEnd = if content[i] == 'v': i + 4 else: i + 4 + var name = "" + var start = kwEnd + while start < content.len and content[start] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + name &= content[start] + start += 1 + if name != "" and name != "": + result.symbols[name] = (line: currLine, col: currCol + kwEnd - i, kind: "variable", typeName: "") + i = start + continue + + # Match "struct Name" + if i + 6 < content.len and content[i..i+5] == "struct ": + var name = "" + var start = i + 7 + while start < content.len and content[start] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + name &= content[start] + start += 1 + if name != "": + result.symbols[name] = (line: currLine, col: currCol + 7, kind: "struct", typeName: "") + i = start + continue + + i += 1 + +# --------------------------------------------------------------------------- +# Diagnostics (placeholder — emits empty diagnostics) +# --------------------------------------------------------------------------- + +proc publishDiagnostics(stream: FileStream, uri: string) = + sendNotification(stream, "textDocument/publishDiagnostics", %*{ + "uri": uri, + "diagnostics": [] + }) + +proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) = + let updated = analyzeFile(uriToPath(doc.uri), doc.content) + doc.symbols = updated.symbols + publishDiagnostics(stream, doc.uri) + +# --------------------------------------------------------------------------- +# Completion +# --------------------------------------------------------------------------- + +proc findWordAt(content: string, lineNum: int, col: int): string = + var lines = content.split("\n") + if lineNum >= lines.len: return "" + let l = lines[lineNum] + var start = col + var endC = col + while start > 0 and l[start-1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + start -= 1 + while endC < l.len and l[endC] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + endC += 1 + if start < endC: + result = l[start..endC-1] + +proc handleCompletion(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + let uri = paramsNode["textDocument"]["uri"].getStr() + let position = paramsNode["position"] + let lineNum = position["line"].getInt() + let col = position["character"].getInt() + + let doc = getDoc(uri) + if doc.content == "": + sendResponse(stream, id, %*{"isIncomplete": false, "items": []}) + return + + if doc.symbols.len == 0: + let updated = analyzeFile(uriToPath(uri), doc.content) + doc.symbols = updated.symbols + + let prefix = findWordAt(doc.content, lineNum, col) + + var items = newJArray() + for name, info in doc.symbols.pairs: + if prefix == "" or name.toLowerAscii().startsWith(prefix.toLowerAscii()): + items.add(%*{ + "label": name, + "kind": 6, + "detail": info.typeName, + "documentation": info.kind & " [" & info.typeName & "]" + }) + + let keywords = ["func", "var", "let", "if", "else", "while", "for", "return", + "struct", "enum", "union", "interface", "extend", "module", + "import", "true", "false", "null", "self", "match", "break", + "continue", "async", "await", "spawn", "const", "type"] + for kw in keywords: + if prefix == "" or kw.startsWith(prefix): + items.add(%*{ + "label": kw, + "kind": 14, + "detail": "keyword" + }) + + sendResponse(stream, id, %*{"isIncomplete": false, "items": items}) + +# --------------------------------------------------------------------------- +# Go-to-definition +# --------------------------------------------------------------------------- + +proc handleDefinition(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + let uri = paramsNode["textDocument"]["uri"].getStr() + let position = paramsNode["position"] + let lineNum = position["line"].getInt() + let col = position["character"].getInt() + + let doc = getDoc(uri) + if doc.content == "": + sendResponse(stream, id, %*[]) + return + + if doc.symbols.len == 0: + let updated = analyzeFile(uriToPath(uri), doc.content) + doc.symbols = updated.symbols + + let word = findWordAt(doc.content, lineNum, col) + + if doc.symbols.hasKey(word): + let info = doc.symbols[word] + var locs = newJArray() + locs.add(%*{ + "uri": uri, + "range": { + "start": {"line": info.line, "character": info.col}, + "end": {"line": info.line, "character": info.col + word.len} + } + }) + sendResponse(stream, id, locs) + else: + sendResponse(stream, id, %*[]) + +# --------------------------------------------------------------------------- +# Hover +# --------------------------------------------------------------------------- + +proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + let uri = paramsNode["textDocument"]["uri"].getStr() + let position = paramsNode["position"] + let lineNum = position["line"].getInt() + let col = position["character"].getInt() + + let doc = getDoc(uri) + if doc.content == "": + sendResponse(stream, id, %*{}) + return + + if doc.symbols.len == 0: + let updated = analyzeFile(uriToPath(uri), doc.content) + doc.symbols = updated.symbols + + let word = findWordAt(doc.content, lineNum, col) + + if doc.symbols.hasKey(word): + let info = doc.symbols[word] + sendResponse(stream, id, %*{ + "contents": { + "kind": "markdown", + "value": "**" & word & "**: " & info.typeName & "\n\n" & info.kind + } + }) + else: + sendResponse(stream, id, %*{}) + +# --------------------------------------------------------------------------- +# Main message loop +# --------------------------------------------------------------------------- + +proc handleMessage(stream: FileStream, msg: JsonNode) = + if not msg.hasKey("method"): + return + + let methodName = msg["method"].getStr() + let id = if msg.hasKey("id"): msg["id"] else: nil + let paramsNode = if msg.hasKey("params"): msg["params"] else: %*{} + + case methodName: + of "initialize": + sendResponse(stream, id, %*{ + "capabilities": { + "textDocumentSync": 1, + "completionProvider": {"triggerCharacters": ["."]}, + "definitionProvider": true, + "hoverProvider": true + }, + "serverInfo": {"name": "bux-lsp", "version": "0.1.0"} + }) + if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: + rootPath = paramsNode["rootPath"].getStr() + if paramsNode.hasKey("rootUri") and paramsNode["rootUri"].kind != JNull: + rootUri = paramsNode["rootUri"].getStr() + + of "initialized": + discard + + of "shutdown": + sendResponse(stream, id, %*{}) + + of "exit": + quit(0) + + of "textDocument/didOpen": + let td = paramsNode["textDocument"] + let uri = td["uri"].getStr() + let content = td["text"].getStr() + var doc = getDoc(uri) + doc.content = content + if td.hasKey("version"): + doc.version = td["version"].getInt() + analyzeAndPublishDiagnostics(stream, doc) + + of "textDocument/didChange": + let td = paramsNode["textDocument"] + let uri = td["uri"].getStr() + var doc = getDoc(uri) + let changes = paramsNode["contentChanges"] + if changes.len > 0: + doc.content = changes[changes.len - 1]["text"].getStr() + if td.hasKey("version"): + doc.version = td["version"].getInt() + + of "textDocument/didSave": + let td = paramsNode["textDocument"] + let uri = td["uri"].getStr() + let doc = getDoc(uri) + analyzeAndPublishDiagnostics(stream, doc) + + of "textDocument/completion": + handleCompletion(stream, id, paramsNode) + + of "textDocument/definition": + handleDefinition(stream, id, paramsNode) + + of "textDocument/hover": + handleHover(stream, id, paramsNode) + + else: + if id != nil: + sendError(stream, id, -32601, "method not found: " & methodName) + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +proc main() = + let stream = newFileStream(stdin) + + while true: + let msg = readMessage(stream) + if msg == nil: + break + handleMessage(stream, msg) + +when isMainModule: + main()