feat(lsp): hover signatures, go-to-def, document outline
Upgrade bux-lsp to 0.2.0 with a richer lightweight symbol index: func signatures, typed let/var/const, struct/enum/interface/type/module. Skip comments and strings while scanning. Hover shows markdown Bux code fences; definition resolves in-file and across workspace .bux files; documentSymbol provides outline. Fix responses writing to stdout (stdin was a broken pipe). didChange refreshes symbols; diagnostics still run via buxc on open/save.
This commit is contained in:
+23
-12
@@ -17,7 +17,7 @@
|
||||
| Gradual ownership | `@[Checked]`, `&`/`&mut`, move, Drop | ★★★☆☆ (basic) |
|
||||
| Concurrency | M:N tasks + channels + async | ★★★★☆ |
|
||||
| Stdlib | Array/Map/Set/String/Iter HOF разширени | ★★★★☆ |
|
||||
| Tooling | `test-errors`, LSP diagnostics via `buxc check` | ★★★☆☆ |
|
||||
| Tooling | `test-errors`, LSP diagnostics + hover/def/outline | ★★★★☆ |
|
||||
| Ecosystem / registry | path+git deps; няма централен registry | ★☆☆☆☆ |
|
||||
| Документация | README + QUALITY_PLAN синхронизирани (2026-07-15) | ★★★★☆ |
|
||||
|
||||
@@ -75,13 +75,13 @@
|
||||
|
||||
### D — Tooling (P1)
|
||||
|
||||
| # | Задача | Защо |
|
||||
|---|--------|------|
|
||||
| D.1 | LSP: hover, go-to-def, diagnostics (wire към sema) | IDE = adoption |
|
||||
| D.2 | `bux fmt` стабилен + CI check | Единен style |
|
||||
| D.3 | `bux test` с `--filter`, exit codes, summary table | CI-friendly |
|
||||
| D.4 | `bux doc` от `///` comments | Самодокументиращ се stdlib |
|
||||
| D.5 | Golden tests за stdlib modules | Регресии без изненади |
|
||||
| # | Задача | Защо | Статус |
|
||||
|---|--------|------|--------|
|
||||
| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ hover/def/outline + `buxc` diags (lightweight index; full sema later) |
|
||||
| D.2 | `bux fmt` стабилен + CI check | Единен style | ⏳ |
|
||||
| D.3 | `bux test` с `--filter`, exit codes, summary table | CI-friendly | ⏳ partial (`bux test` exists) |
|
||||
| D.4 | `bux doc` от `///` comments | Самодокументиращ се stdlib | ⏳ |
|
||||
| D.5 | Golden tests за stdlib modules | Регресии без изненади | ⏳ |
|
||||
|
||||
### E — Ecosystem & v1.0 (P2)
|
||||
|
||||
@@ -233,12 +233,23 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth)
|
||||
6. Example: `examples/string_interp.bux` (name/int/bool/plain/escaped braces)
|
||||
7. Verified: bootstrap + **buxc2** + all 41 examples + error goldens + **selfhost-loop IDENTICAL ✓**
|
||||
|
||||
## Сесия 13 (LSP hover / go-to-def / outline — D.1)
|
||||
|
||||
1. **Richer symbol index:** `func` signatures (`params` + `-> Ret`), `let`/`var`/`const` with types, `struct`/`enum`/`union`/`interface`/`type`/`module`
|
||||
2. **Skip comments/strings** during scan (no false `func` hits)
|
||||
3. **Hover:** markdown ```bux signature``` + kind; accurate word range
|
||||
4. **Go-to-definition:** current file + workspace index (scan `.bux` under rootUri)
|
||||
5. **Document symbols** (outline) via `textDocument/documentSymbol`
|
||||
6. **didChange** refreshes symbols immediately; **didSave/didOpen** still run `buxc check` diagnostics
|
||||
7. **Fix:** responses write to **stdout** (was writing to stdin stream → broken pipe)
|
||||
8. Version `bux-lsp` **0.2.0**; smoke-tested via JSON-RPC
|
||||
|
||||
---
|
||||
|
||||
## Следващи стъпки
|
||||
|
||||
1. **LSP hover / go-to-def** (над текущите diagnostics)
|
||||
2. **Generic Iter map** (не само int), ако monomorphization с `func` params е стабилна
|
||||
3. Struct/tuple patterns (`Point { x, y }`, `(a, b)`) + nested bindings
|
||||
4. Match arm multi-stmt bodies (beyond single expr)
|
||||
1. **Generic Iter map** (не само int), ако monomorphization с `func` params е стабилна
|
||||
2. Struct/tuple patterns (`Point { x, y }`, `(a, b)`) + nested bindings
|
||||
3. Match arm multi-stmt bodies (beyond single expr)
|
||||
4. LSP: wire hover types from real sema (replace lightweight index where possible)
|
||||
|
||||
|
||||
+375
-89
@@ -43,11 +43,13 @@ proc readMessage(stream: FileStream): JsonNode =
|
||||
return nil
|
||||
|
||||
proc sendMessage(stream: FileStream, msg: JsonNode) =
|
||||
## Always write responses on stdout (stream arg kept for call-site compatibility).
|
||||
discard stream
|
||||
let body = $msg
|
||||
let header = "Content-Length: " & $body.len & "\r\n\r\n"
|
||||
stream.write(header)
|
||||
stream.write(body)
|
||||
stream.flush()
|
||||
stdout.write(header)
|
||||
stdout.write(body)
|
||||
stdout.flushFile()
|
||||
|
||||
proc sendResponse(stream: FileStream, id: JsonNode, resultNode: JsonNode) =
|
||||
sendMessage(stream, %*{
|
||||
@@ -75,17 +77,24 @@ proc sendNotification(stream: FileStream, methodName: string, paramsNode: JsonNo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
type
|
||||
SymbolInfo = tuple[line: int, col: int, kind: string, typeName: string]
|
||||
SymbolInfo = object
|
||||
line: int ## 0-based
|
||||
col: int ## 0-based start of name
|
||||
kind: string ## function | variable | struct | enum | …
|
||||
detail: string ## signature / type annotation
|
||||
container: string ## optional parent (module / type)
|
||||
DocumentState = ref object
|
||||
uri: string
|
||||
content: string
|
||||
version: int
|
||||
symbols: Table[string, SymbolInfo]
|
||||
ordered: seq[string] ## declaration order for outline
|
||||
|
||||
var
|
||||
documents = initTable[string, DocumentState]()
|
||||
rootPath = ""
|
||||
rootUri = ""
|
||||
workspaceSymbols = initTable[string, tuple[uri: string, info: SymbolInfo]]()
|
||||
|
||||
proc getDoc(uri: string): DocumentState =
|
||||
if not documents.hasKey(uri):
|
||||
@@ -99,80 +108,223 @@ proc getDoc(uri: string): DocumentState =
|
||||
proc uriToPath(uri: string): string =
|
||||
if uri.startsWith("file://"):
|
||||
result = uri[7..^1]
|
||||
# Decode minimal %XX (space)
|
||||
result = result.replace("%20", " ")
|
||||
else:
|
||||
result = uri
|
||||
|
||||
proc pathToUri(path: string): string =
|
||||
if path.startsWith("file://"):
|
||||
return path
|
||||
result = "file://" & path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple analysis: extract symbols via regex (no full compiler integration yet)
|
||||
# Symbol analysis — lightweight scan (not full sema; good enough for hover/def)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc analyzeFile(path: string, content: string): DocumentState =
|
||||
result = DocumentState(uri: "file://" & path, content: content)
|
||||
proc isIdentChar(c: char): bool =
|
||||
c in {'a'..'z', 'A'..'Z', '0'..'9', '_'}
|
||||
|
||||
# Simple symbol extraction: find func/var/let/struct/enum declarations
|
||||
var idx = 0
|
||||
proc isIdentStart(c: char): bool =
|
||||
c in {'a'..'z', 'A'..'Z', '_'}
|
||||
|
||||
proc skipWs(s: string, i: var int) =
|
||||
while i < s.len and s[i] in {' ', '\t', '\r'}:
|
||||
inc i
|
||||
|
||||
proc readIdent(s: string, i: var int): string =
|
||||
result = ""
|
||||
if i >= s.len or not isIdentStart(s[i]):
|
||||
return
|
||||
while i < s.len and isIdentChar(s[i]):
|
||||
result.add(s[i])
|
||||
inc i
|
||||
|
||||
proc lineColAt(content: string, pos: int): tuple[line, col: int] =
|
||||
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 < pos and i < content.len:
|
||||
if content[i] == '\n':
|
||||
inc line
|
||||
col = 0
|
||||
else:
|
||||
inc col
|
||||
inc i
|
||||
(line, col)
|
||||
|
||||
proc readTypeish(s: string, i: var int): string =
|
||||
## Read a rough type expression: Name, *Name, []Name, func(...)->T, generics
|
||||
skipWs(s, i)
|
||||
if i >= s.len:
|
||||
return ""
|
||||
result = ""
|
||||
var depth = 0
|
||||
while i < s.len:
|
||||
let c = s[i]
|
||||
if c in {'\n', ';', '{', '=', ','} and depth == 0:
|
||||
break
|
||||
if c == '(' or c == '[' or c == '<':
|
||||
inc depth
|
||||
elif c == ')' or c == ']' or c == '>':
|
||||
if depth > 0: dec depth
|
||||
result.add(c)
|
||||
inc i
|
||||
result = result.strip()
|
||||
|
||||
proc addSymbol(doc: var DocumentState, name: string, info: SymbolInfo) =
|
||||
if name.len == 0:
|
||||
return
|
||||
# Keep first declaration (don't overwrite outer with locals later — last wins for locals is OK for single-file)
|
||||
doc.symbols[name] = info
|
||||
if name notin doc.ordered:
|
||||
doc.ordered.add(name)
|
||||
workspaceSymbols[name] = (uri: doc.uri, info: info)
|
||||
|
||||
proc analyzeFile(path: string, content: string): DocumentState =
|
||||
result = DocumentState(uri: pathToUri(path), content: content)
|
||||
result.symbols = initTable[string, SymbolInfo]()
|
||||
result.ordered = @[]
|
||||
|
||||
var i = 0
|
||||
var inLineComment = false
|
||||
var inBlockComment = false
|
||||
var inString = false
|
||||
var stringDelim = '\0'
|
||||
var escape = false
|
||||
|
||||
while i < content.len:
|
||||
let c = content[i]
|
||||
|
||||
# Comments / strings (best-effort skip so "func" in strings is ignored)
|
||||
if inLineComment:
|
||||
if c == '\n':
|
||||
currLine += 1
|
||||
currCol = 0
|
||||
i += 1
|
||||
inLineComment = false
|
||||
inc i
|
||||
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
|
||||
if inBlockComment:
|
||||
if c == '*' and i + 1 < content.len and content[i + 1] == '/':
|
||||
inBlockComment = false
|
||||
i += 2
|
||||
continue
|
||||
inc i
|
||||
continue
|
||||
if inString:
|
||||
if escape:
|
||||
escape = false
|
||||
elif c == '\\':
|
||||
escape = true
|
||||
elif c == stringDelim:
|
||||
inString = false
|
||||
inc i
|
||||
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
|
||||
if c == '/' and i + 1 < content.len and content[i + 1] == '/':
|
||||
inLineComment = true
|
||||
i += 2
|
||||
continue
|
||||
if c == '/' and i + 1 < content.len and content[i + 1] == '*':
|
||||
inBlockComment = true
|
||||
i += 2
|
||||
continue
|
||||
if c in {'"', '`'} or (c == 'f' and i + 1 < content.len and content[i + 1] == '"'):
|
||||
inString = true
|
||||
if c == 'f':
|
||||
stringDelim = '"'
|
||||
i += 2
|
||||
else:
|
||||
stringDelim = c
|
||||
inc i
|
||||
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
|
||||
# Keyword must be at token boundary
|
||||
template atWord(kw: string): bool =
|
||||
(i + kw.len <= content.len and content[i ..< i + kw.len] == kw and
|
||||
(i == 0 or not isIdentChar(content[i - 1])) and
|
||||
(i + kw.len >= content.len or not isIdentChar(content[i + kw.len])))
|
||||
|
||||
if atWord("func"):
|
||||
let kwPos = i
|
||||
i += 4
|
||||
skipWs(content, i)
|
||||
let nameStart = i
|
||||
let name = readIdent(content, i)
|
||||
if name.len > 0:
|
||||
let (line, col) = lineColAt(content, nameStart)
|
||||
# signature: from "func" through params + optional return type, stop at '{'
|
||||
var j = nameStart
|
||||
var depth = 0
|
||||
var sigEnd = j
|
||||
while j < content.len:
|
||||
let ch = content[j]
|
||||
if ch == '(' : inc depth
|
||||
elif ch == ')' :
|
||||
if depth > 0: dec depth
|
||||
if depth == 0:
|
||||
sigEnd = j + 1
|
||||
var k = j + 1
|
||||
skipWs(content, k)
|
||||
if k + 1 < content.len and content[k] == '-' and content[k + 1] == '>':
|
||||
k += 2
|
||||
discard readTypeish(content, k)
|
||||
sigEnd = k
|
||||
break
|
||||
elif ch == '{' or ch == '\n' and depth == 0 and j > nameStart + name.len:
|
||||
# no-param or broken — still capture name
|
||||
if sigEnd <= nameStart:
|
||||
sigEnd = i
|
||||
break
|
||||
inc j
|
||||
var sig = content[kwPos ..< min(sigEnd, content.len)].strip()
|
||||
# collapse whitespace
|
||||
sig = sig.replace("\n", " ").multiReplace([(" ", " "), (" ", " "), (" ", " ")])
|
||||
addSymbol(result, name, SymbolInfo(
|
||||
line: line, col: col, kind: "function", detail: sig, container: ""))
|
||||
continue
|
||||
|
||||
i += 1
|
||||
if atWord("let") or atWord("var") or atWord("const"):
|
||||
let kw = if content[i] == 'l': "let" elif content[i] == 'c': "const" else: "var"
|
||||
let kind = if kw == "const": "constant" else: "variable"
|
||||
i += kw.len
|
||||
skipWs(content, i)
|
||||
let nameStart = i
|
||||
let name = readIdent(content, i)
|
||||
if name.len > 0:
|
||||
let (line, col) = lineColAt(content, nameStart)
|
||||
skipWs(content, i)
|
||||
var typ = ""
|
||||
if i < content.len and content[i] == ':':
|
||||
inc i
|
||||
typ = readTypeish(content, i)
|
||||
let detail = if typ.len > 0: kw & " " & name & ": " & typ else: kw & " " & name
|
||||
addSymbol(result, name, SymbolInfo(
|
||||
line: line, col: col, kind: kind, detail: detail, container: ""))
|
||||
continue
|
||||
|
||||
var matchedTypeKw = false
|
||||
for (kw, kind) in [("struct", "struct"), ("enum", "enum"), ("union", "struct"),
|
||||
("interface", "interface"), ("type", "type"), ("module", "module")]:
|
||||
if atWord(kw):
|
||||
matchedTypeKw = true
|
||||
i += kw.len
|
||||
skipWs(content, i)
|
||||
let nameStart = i
|
||||
let name = readIdent(content, i)
|
||||
if name.len > 0:
|
||||
let (line, col) = lineColAt(content, nameStart)
|
||||
var detail = kw & " " & name
|
||||
if kind == "type":
|
||||
skipWs(content, i)
|
||||
if i < content.len and content[i] == '=':
|
||||
inc i
|
||||
let rhs = readTypeish(content, i)
|
||||
if rhs.len > 0:
|
||||
detail = "type " & name & " = " & rhs
|
||||
addSymbol(result, name, SymbolInfo(
|
||||
line: line, col: col, kind: kind, detail: detail, container: ""))
|
||||
break
|
||||
if not matchedTypeKw:
|
||||
inc i
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics — run `buxc check` when available and parse Rust-style errors
|
||||
@@ -317,9 +469,30 @@ proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) =
|
||||
let path = uriToPath(doc.uri)
|
||||
let updated = analyzeFile(path, doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
doc.ordered = updated.ordered
|
||||
let diags = runBuxcDiagnostics(path, doc.content)
|
||||
publishDiagnostics(stream, doc.uri, diags)
|
||||
|
||||
proc scanWorkspace(dir: string, depth = 0) =
|
||||
## Index .bux files under the workspace for cross-file go-to-def / hover.
|
||||
if depth > 4 or dir.len == 0 or not dirExists(dir):
|
||||
return
|
||||
let base = dir.extractFilename
|
||||
if base in [".git", "build", "examples_pkg", "node_modules", "vendor"]:
|
||||
return
|
||||
try:
|
||||
for kind, path in walkDir(dir):
|
||||
if kind == pcDir:
|
||||
scanWorkspace(path, depth + 1)
|
||||
elif kind == pcFile and path.endsWith(".bux"):
|
||||
try:
|
||||
let content = readFile(path)
|
||||
discard analyzeFile(path, content)
|
||||
except CatchableError:
|
||||
discard
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Completion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -337,6 +510,26 @@ proc findWordAt(content: string, lineNum: int, col: int): string =
|
||||
if start < endC:
|
||||
result = l[start..endC-1]
|
||||
|
||||
proc ensureAnalyzed(doc: DocumentState) =
|
||||
if doc.content.len == 0:
|
||||
return
|
||||
if doc.symbols.len == 0:
|
||||
let updated = analyzeFile(uriToPath(doc.uri), doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
doc.ordered = updated.ordered
|
||||
|
||||
proc completionKind(kind: string): int =
|
||||
case kind
|
||||
of "function": 3
|
||||
of "variable": 6
|
||||
of "constant": 14
|
||||
of "struct": 22
|
||||
of "enum": 13
|
||||
of "interface": 8
|
||||
of "type": 25
|
||||
of "module": 9
|
||||
else: 6
|
||||
|
||||
proc handleCompletion(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
let uri = paramsNode["textDocument"]["uri"].getStr()
|
||||
let position = paramsNode["position"]
|
||||
@@ -348,10 +541,7 @@ proc handleCompletion(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
sendResponse(stream, id, %*{"isIncomplete": false, "items": []})
|
||||
return
|
||||
|
||||
if doc.symbols.len == 0:
|
||||
let updated = analyzeFile(uriToPath(uri), doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
|
||||
ensureAnalyzed(doc)
|
||||
let prefix = findWordAt(doc.content, lineNum, col)
|
||||
|
||||
var items = newJArray()
|
||||
@@ -359,15 +549,28 @@ proc handleCompletion(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
if prefix == "" or name.toLowerAscii().startsWith(prefix.toLowerAscii()):
|
||||
items.add(%*{
|
||||
"label": name,
|
||||
"kind": 6,
|
||||
"detail": info.typeName,
|
||||
"documentation": info.kind & " [" & info.typeName & "]"
|
||||
"kind": completionKind(info.kind),
|
||||
"detail": info.detail,
|
||||
"documentation": {"kind": "markdown", "value": "```bux\n" & info.detail & "\n```\n\n_" & info.kind & "_"}
|
||||
})
|
||||
|
||||
# Also offer workspace symbols (other open / scanned files)
|
||||
for name, ws in workspaceSymbols.pairs:
|
||||
if doc.symbols.hasKey(name):
|
||||
continue
|
||||
if prefix == "" or name.toLowerAscii().startsWith(prefix.toLowerAscii()):
|
||||
items.add(%*{
|
||||
"label": name,
|
||||
"kind": completionKind(ws.info.kind),
|
||||
"detail": ws.info.detail & " (workspace)",
|
||||
"documentation": {"kind": "markdown", "value": "```bux\n" & ws.info.detail & "\n```"}
|
||||
})
|
||||
|
||||
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"]
|
||||
"continue", "async", "await", "spawn", "const", "type",
|
||||
"defer", "switch", "case", "default", "pub", "own"]
|
||||
for kw in keywords:
|
||||
if prefix == "" or kw.startsWith(prefix):
|
||||
items.add(%*{
|
||||
@@ -393,15 +596,15 @@ proc handleDefinition(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
|
||||
if doc.symbols.len == 0:
|
||||
let updated = analyzeFile(uriToPath(uri), doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
|
||||
ensureAnalyzed(doc)
|
||||
let word = findWordAt(doc.content, lineNum, col)
|
||||
if word.len == 0:
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
|
||||
var locs = newJArray()
|
||||
if doc.symbols.hasKey(word):
|
||||
let info = doc.symbols[word]
|
||||
var locs = newJArray()
|
||||
locs.add(%*{
|
||||
"uri": uri,
|
||||
"range": {
|
||||
@@ -409,15 +612,23 @@ proc handleDefinition(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
"end": {"line": info.line, "character": info.col + word.len}
|
||||
}
|
||||
})
|
||||
elif workspaceSymbols.hasKey(word):
|
||||
let ws = workspaceSymbols[word]
|
||||
locs.add(%*{
|
||||
"uri": ws.uri,
|
||||
"range": {
|
||||
"start": {"line": ws.info.line, "character": ws.info.col},
|
||||
"end": {"line": ws.info.line, "character": ws.info.col + word.len}
|
||||
}
|
||||
})
|
||||
sendResponse(stream, id, locs)
|
||||
else:
|
||||
sendResponse(stream, id, %*[])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hover
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
## Hover with accurate range for the word under the cursor.
|
||||
let uri = paramsNode["textDocument"]["uri"].getStr()
|
||||
let position = paramsNode["position"]
|
||||
let lineNum = position["line"].getInt()
|
||||
@@ -425,25 +636,89 @@ proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
|
||||
let doc = getDoc(uri)
|
||||
if doc.content == "":
|
||||
sendResponse(stream, id, %*{})
|
||||
sendResponse(stream, id, newJNull())
|
||||
return
|
||||
|
||||
if doc.symbols.len == 0:
|
||||
let updated = analyzeFile(uriToPath(uri), doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
|
||||
let word = findWordAt(doc.content, lineNum, col)
|
||||
ensureAnalyzed(doc)
|
||||
let lines = doc.content.split("\n")
|
||||
if lineNum >= lines.len:
|
||||
sendResponse(stream, id, newJNull())
|
||||
return
|
||||
let l = lines[lineNum]
|
||||
var start = min(col, l.len)
|
||||
var endC = start
|
||||
while start > 0 and l[start - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||
dec start
|
||||
while endC < l.len and l[endC] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||
inc endC
|
||||
if start >= endC:
|
||||
sendResponse(stream, id, newJNull())
|
||||
return
|
||||
let word = l[start ..< endC]
|
||||
|
||||
var info: SymbolInfo
|
||||
var found = false
|
||||
if doc.symbols.hasKey(word):
|
||||
let info = doc.symbols[word]
|
||||
info = doc.symbols[word]
|
||||
found = true
|
||||
elif workspaceSymbols.hasKey(word):
|
||||
info = workspaceSymbols[word].info
|
||||
found = true
|
||||
if not found:
|
||||
sendResponse(stream, id, newJNull())
|
||||
return
|
||||
|
||||
let md = "```bux\n" & info.detail & "\n```\n\n_" & info.kind & "_"
|
||||
sendResponse(stream, id, %*{
|
||||
"contents": {
|
||||
"kind": "markdown",
|
||||
"value": "**" & word & "**: " & info.typeName & "\n\n" & info.kind
|
||||
"contents": {"kind": "markdown", "value": md},
|
||||
"range": {
|
||||
"start": {"line": lineNum, "character": start},
|
||||
"end": {"line": lineNum, "character": endC}
|
||||
}
|
||||
})
|
||||
else:
|
||||
sendResponse(stream, id, %*{})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document symbols (outline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc symbolKindLsp(kind: string): int =
|
||||
case kind
|
||||
of "function": 12
|
||||
of "variable": 13
|
||||
of "constant": 14
|
||||
of "struct": 23
|
||||
of "enum": 10
|
||||
of "interface": 11
|
||||
of "type": 5
|
||||
of "module": 2
|
||||
else: 13
|
||||
|
||||
proc handleDocumentSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
let uri = paramsNode["textDocument"]["uri"].getStr()
|
||||
let doc = getDoc(uri)
|
||||
if doc.content == "":
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
ensureAnalyzed(doc)
|
||||
var arr = newJArray()
|
||||
for name in doc.ordered:
|
||||
if not doc.symbols.hasKey(name):
|
||||
continue
|
||||
let info = doc.symbols[name]
|
||||
arr.add(%*{
|
||||
"name": name,
|
||||
"detail": info.detail,
|
||||
"kind": symbolKindLsp(info.kind),
|
||||
"range": {
|
||||
"start": {"line": info.line, "character": 0},
|
||||
"end": {"line": info.line, "character": info.col + name.len}
|
||||
},
|
||||
"selectionRange": {
|
||||
"start": {"line": info.line, "character": info.col},
|
||||
"end": {"line": info.line, "character": info.col + name.len}
|
||||
}
|
||||
})
|
||||
sendResponse(stream, id, arr)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main message loop
|
||||
@@ -462,19 +737,23 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
||||
sendResponse(stream, id, %*{
|
||||
"capabilities": {
|
||||
"textDocumentSync": 1,
|
||||
"completionProvider": {"triggerCharacters": ["."]},
|
||||
"completionProvider": {"triggerCharacters": [".", ":"]},
|
||||
"definitionProvider": true,
|
||||
"hoverProvider": true
|
||||
"hoverProvider": true,
|
||||
"documentSymbolProvider": true
|
||||
},
|
||||
"serverInfo": {"name": "bux-lsp", "version": "0.1.0"}
|
||||
"serverInfo": {"name": "bux-lsp", "version": "0.2.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()
|
||||
if rootPath.len == 0:
|
||||
rootPath = uriToPath(rootUri)
|
||||
|
||||
of "initialized":
|
||||
discard
|
||||
if rootPath.len > 0:
|
||||
scanWorkspace(rootPath)
|
||||
|
||||
of "shutdown":
|
||||
sendResponse(stream, id, %*{})
|
||||
@@ -501,6 +780,10 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
||||
doc.content = changes[changes.len - 1]["text"].getStr()
|
||||
if td.hasKey("version"):
|
||||
doc.version = td["version"].getInt()
|
||||
# Refresh symbols immediately (no buxc — diagnostics on save)
|
||||
let updated = analyzeFile(uriToPath(uri), doc.content)
|
||||
doc.symbols = updated.symbols
|
||||
doc.ordered = updated.ordered
|
||||
|
||||
of "textDocument/didSave":
|
||||
let td = paramsNode["textDocument"]
|
||||
@@ -517,6 +800,9 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
||||
of "textDocument/hover":
|
||||
handleHover(stream, id, paramsNode)
|
||||
|
||||
of "textDocument/documentSymbol":
|
||||
handleDocumentSymbol(stream, id, paramsNode)
|
||||
|
||||
else:
|
||||
if id != nil:
|
||||
sendError(stream, id, -32601, "method not found: " & methodName)
|
||||
|
||||
Reference in New Issue
Block a user