61ac06ab5f
Introduce fat function pointers (BuxFn {code, env}) so capturing closures
are heap-allocated per value in both bootstrap and selfhost. Expand
Array/Map/Set/String/Test/Result APIs, add proper tuple codegen and
error snippets with multi-char underlines, golden diagnostic tests, and
LSP diagnostics via buxc check.
539 lines
16 KiB
Nim
539 lines
16 KiB
Nim
# 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, osproc]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 — run `buxc check` when available and parse Rust-style errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
type
|
|
LspDiag = object
|
|
line: int ## 0-based for LSP
|
|
col: int ## 0-based
|
|
endCol: int ## 0-based exclusive
|
|
severity: int ## 1=error, 2=warning
|
|
message: string
|
|
|
|
proc findBuxc(): string =
|
|
## Prefer buxc next to the LSP binary, then PATH.
|
|
let beside = getAppDir() / "buxc"
|
|
if fileExists(beside): return beside
|
|
let beside2 = getCurrentDir() / "buxc"
|
|
if fileExists(beside2): return beside2
|
|
result = findExe("buxc")
|
|
|
|
proc parseBuxcDiagnostics(output, sourcePath: string): seq[LspDiag] =
|
|
## Parse lines like:
|
|
## error: cannot assign String to int
|
|
## --> /path/Main.bux:4:18
|
|
result = @[]
|
|
let lines = output.splitLines()
|
|
var i = 0
|
|
while i < lines.len:
|
|
let line = lines[i]
|
|
var sev = 0
|
|
var msg = ""
|
|
if line.startsWith("error: "):
|
|
sev = 1
|
|
msg = line[7..^1]
|
|
elif line.startsWith("warning: "):
|
|
sev = 2
|
|
msg = line[9..^1]
|
|
else:
|
|
inc i
|
|
continue
|
|
|
|
# Skip aggregate headers like "type errors in project"
|
|
if msg.startsWith("type errors") or msg.startsWith("parse errors") or
|
|
msg.startsWith("lex errors"):
|
|
inc i
|
|
continue
|
|
|
|
var fileLine = 1
|
|
var fileCol = 1
|
|
if i + 1 < lines.len and lines[i + 1].strip().startsWith("-->"):
|
|
let locPart = lines[i + 1].strip()[3..^1].strip()
|
|
# path:line:col
|
|
let parts = locPart.rsplit(':', maxsplit = 2)
|
|
if parts.len >= 3:
|
|
try:
|
|
fileLine = parseInt(parts[^2])
|
|
fileCol = parseInt(parts[^1])
|
|
except: discard
|
|
# Optionally filter to the open document
|
|
let pathPart = if parts.len >= 3: parts[0] else: ""
|
|
if sourcePath.len > 0 and pathPart.len > 0:
|
|
if not pathPart.endsWith(sourcePath.extractFilename) and
|
|
pathPart != sourcePath:
|
|
i += 1
|
|
continue
|
|
# Estimate end column from message quote or single caret width
|
|
var endCol = fileCol
|
|
let q = msg.find('\'')
|
|
if q >= 0:
|
|
let q2 = msg.find('\'', q + 1)
|
|
if q2 > q + 1:
|
|
endCol = fileCol + (q2 - q - 1)
|
|
if endCol <= fileCol:
|
|
endCol = fileCol + 1
|
|
|
|
result.add(LspDiag(
|
|
line: max(0, fileLine - 1),
|
|
col: max(0, fileCol - 1),
|
|
endCol: max(0, endCol - 1),
|
|
severity: sev,
|
|
message: msg
|
|
))
|
|
inc i
|
|
|
|
proc runBuxcDiagnostics(sourcePath, content: string): seq[LspDiag] =
|
|
result = @[]
|
|
let buxc = findBuxc()
|
|
if buxc.len == 0:
|
|
return
|
|
|
|
# Prefer package root if this file lives under src/
|
|
var projectDir = sourcePath.parentDir
|
|
if projectDir.endsWith("src"):
|
|
projectDir = projectDir.parentDir
|
|
let toml = projectDir / "bux.toml"
|
|
|
|
var cmd: string
|
|
var workDir: string
|
|
if fileExists(toml):
|
|
workDir = projectDir
|
|
cmd = buxc & " check --color off"
|
|
else:
|
|
# Temp package for free-standing buffers
|
|
let tmp = getTempDir() / "bux-lsp-" & $getCurrentProcessId()
|
|
createDir(tmp / "src")
|
|
writeFile(tmp / "bux.toml", """[Package]
|
|
Name = "lsp_tmp"
|
|
Version = "0.0.0"
|
|
Type = "bin"
|
|
[Build]
|
|
Output = "Bin"
|
|
""")
|
|
writeFile(tmp / "src" / "Main.bux", content)
|
|
workDir = tmp
|
|
cmd = buxc & " check --color off"
|
|
|
|
try:
|
|
let (output, _) = execCmdEx(cmd, workingDir = workDir)
|
|
result = parseBuxcDiagnostics(output, sourcePath)
|
|
except CatchableError:
|
|
discard
|
|
|
|
proc publishDiagnostics(stream: FileStream, uri: string, diags: seq[LspDiag] = @[]) =
|
|
var arr = newJArray()
|
|
for d in diags:
|
|
arr.add(%*{
|
|
"range": {
|
|
"start": {"line": d.line, "character": d.col},
|
|
"end": {"line": d.line, "character": d.endCol}
|
|
},
|
|
"severity": d.severity,
|
|
"source": "buxc",
|
|
"message": d.message
|
|
})
|
|
sendNotification(stream, "textDocument/publishDiagnostics", %*{
|
|
"uri": uri,
|
|
"diagnostics": arr
|
|
})
|
|
|
|
proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) =
|
|
let path = uriToPath(doc.uri)
|
|
let updated = analyzeFile(path, doc.content)
|
|
doc.symbols = updated.symbols
|
|
let diags = runBuxcDiagnostics(path, doc.content)
|
|
publishDiagnostics(stream, doc.uri, diags)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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()
|