feat(lsp): call hierarchy prepare / incoming / outgoing (0.8)
Expose LSP call hierarchy over a lightweight textual call graph of known functions and Name( call sites across the workspace. - prepareCallHierarchy on func decl or call site - incomingCalls / outgoingCalls with fromRanges - smoke_lsp_call_hierarchy + test-lsp wiring
This commit is contained in:
@@ -199,6 +199,9 @@ test-lsp: lsp
|
||||
@echo "=== LSP deeper rename smoke ==="
|
||||
@chmod +x tools/smoke_lsp_rename_deep.sh
|
||||
@tools/smoke_lsp_rename_deep.sh
|
||||
@echo "=== LSP call hierarchy smoke ==="
|
||||
@chmod +x tools/smoke_lsp_call_hierarchy.sh
|
||||
@tools/smoke_lsp_call_hierarchy.sh
|
||||
|
||||
.PHONY: test-registry
|
||||
test-registry: build
|
||||
|
||||
+22
-6
@@ -1,7 +1,7 @@
|
||||
# Bux — План към „добър“ език (v0.5 → v1.0)
|
||||
|
||||
> **Дата:** 2026-07-19
|
||||
> **Текущо:** v0.5.x — field-move Drop, selfhost #line, Nexus KA, **LSP 0.7 deeper rename**
|
||||
> **Текущо:** v0.5.x — field-move, selfhost #line, Nexus KA, **LSP 0.8 call hierarchy**
|
||||
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
|
||||
|
||||
---
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
| # | Задача | Защо | Статус |
|
||||
|---|--------|------|--------|
|
||||
| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.6.0: refs/rename + **workspace/symbol** |
|
||||
| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.7.0: + **field/variant rename** + workspace/symbol |
|
||||
| D.2 | `bux fmt` стабилен + CI check | Единен style | ✅ full-tree format + `make fmt-check` enforce |
|
||||
| D.3 | `bux test` с `--filter`, exit codes, summary table | CI-friendly | ✅ `--filter` / summary / exit 0\|1 |
|
||||
| D.4 | `bux doc` от `///` comments | Самодокументиращ се stdlib | ✅ bootstrap+selfhost + `make docs` |
|
||||
@@ -626,9 +626,25 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth)
|
||||
|
||||
---
|
||||
|
||||
## Сесия 39 (LSP 0.7 deeper rename)
|
||||
|
||||
1. **Member index** in `analyzeFile`:
|
||||
- struct/union/interface fields (`name: Type`)
|
||||
- enum variants (`Name` / `Name(...)`)
|
||||
2. **Access classification** for each hit:
|
||||
- bare / `.member` / `::Variant` / `name:` field-init
|
||||
3. **Rename targets**:
|
||||
- **local** — same binding only (shadowing-safe)
|
||||
- **member** — decl + `.x` + `::Red` + `{ x: }` — **not** bare locals named `x`
|
||||
- **global** — bare + `::` paths; skips `.member` false positives
|
||||
4. Smoke: `tools/smoke_lsp_rename_deep.sh` (x→px ≥3, Red→Crimson ≥2, local x→xx =2)
|
||||
5. Version **bux-lsp 0.7.0**; wired into `make test-lsp`
|
||||
|
||||
---
|
||||
|
||||
## Следващи стъпки
|
||||
|
||||
1. Deeper rename (type members / qualified paths)
|
||||
2. LSP call hierarchy (optional)
|
||||
3. Selfhost multi-file `#line` paths without BUX_DEBUG_FILE
|
||||
4. Wire selfhost smoke for move_field into CI
|
||||
1. LSP call hierarchy (optional)
|
||||
2. Selfhost multi-file `#line` paths without BUX_DEBUG_FILE
|
||||
3. Wire selfhost smoke for move_field into CI
|
||||
4. Rename of method receivers / qualified module paths (edge cases)
|
||||
|
||||
+280
-2
@@ -9,6 +9,7 @@
|
||||
# v0.5.0: textDocument/references + rename (scoped locals + workspace globals).
|
||||
# v0.6.0: workspace/symbol search.
|
||||
# v0.7.0: deeper rename — struct fields, enum variants, .member / ::Variant.
|
||||
# v0.8.0: call hierarchy (prepare / incoming / outgoing).
|
||||
|
||||
import std/[json, os, strutils, streams, tables, osproc, sequtils, sets]
|
||||
import lexer, parser, ast, sema, types, scope, source_location
|
||||
@@ -1907,6 +1908,273 @@ proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNod
|
||||
|
||||
sendResponse(stream, id, arr)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Call hierarchy (v0.8) — lightweight textual call graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
type
|
||||
FuncSym = object
|
||||
name: string
|
||||
uri: string
|
||||
info: SymbolInfo
|
||||
CallSite = object
|
||||
callee: string
|
||||
line: int
|
||||
col: int
|
||||
## Enclosing function name ("" if top-level / unknown)
|
||||
caller: string
|
||||
callerUri: string
|
||||
|
||||
proc listFunctionSymbols(doc: DocumentState): seq[tuple[name: string, info: SymbolInfo]] =
|
||||
result = @[]
|
||||
ensureAnalyzed(doc)
|
||||
for name, info in doc.symbols.pairs:
|
||||
if info.kind == "function":
|
||||
result.add((name, info))
|
||||
|
||||
proc allKnownFuncs(): Table[string, FuncSym] =
|
||||
## name → first seen FuncSym (workspace + open docs)
|
||||
result = initTable[string, FuncSym]()
|
||||
for uri, doc in documents.pairs:
|
||||
for (name, info) in listFunctionSymbols(doc):
|
||||
if not result.hasKey(name):
|
||||
result[name] = FuncSym(name: name, uri: uri, info: info)
|
||||
for name, ws in workspaceSymbols.pairs:
|
||||
if ws.info.kind == "function" and not result.hasKey(name):
|
||||
result[name] = FuncSym(name: name, uri: ws.uri, info: ws.info)
|
||||
|
||||
proc enclosingFuncName(doc: DocumentState, line: int): string =
|
||||
## Nearest function whose decl line ≤ line (same file).
|
||||
result = ""
|
||||
var best = -1
|
||||
for name, info in doc.symbols.pairs:
|
||||
if info.kind != "function": continue
|
||||
if info.line <= line and info.line >= best:
|
||||
best = info.line
|
||||
result = name
|
||||
|
||||
proc isCallSiteAt(content: string, start, nameLen: int): bool =
|
||||
## True if `name` is followed by optional space then `(`.
|
||||
var j = start + nameLen
|
||||
while j < content.len and content[j] in {' ', '\t'}:
|
||||
inc j
|
||||
result = j < content.len and content[j] == '('
|
||||
|
||||
proc collectCallSitesInDoc(doc: DocumentState, known: HashSet[string]): seq[CallSite] =
|
||||
result = @[]
|
||||
ensureAnalyzed(doc)
|
||||
if doc.content.len == 0: return
|
||||
for name in known:
|
||||
for h in collectIdentHits(doc.content, name):
|
||||
# absolute offset for call-site check
|
||||
let lines = doc.content.split("\n")
|
||||
if h.line < 0 or h.line >= lines.len: continue
|
||||
var off = 0
|
||||
for li in 0 ..< h.line:
|
||||
off += lines[li].len + 1
|
||||
off += h.col
|
||||
if not isCallSiteAt(doc.content, off, h.len):
|
||||
continue
|
||||
# Skip the function declaration itself (func Name() — also has () )
|
||||
if doc.symbols.hasKey(name):
|
||||
let info = doc.symbols[name]
|
||||
if info.line == h.line and info.col == h.col:
|
||||
continue
|
||||
# Skip .method-style if access is dot (method calls still useful — keep)
|
||||
let caller = enclosingFuncName(doc, h.line)
|
||||
result.add(CallSite(
|
||||
callee: name, line: h.line, col: h.col,
|
||||
caller: caller, callerUri: doc.uri))
|
||||
|
||||
proc collectAllCallSites(): seq[CallSite] =
|
||||
result = @[]
|
||||
let funcs = allKnownFuncs()
|
||||
var known = initHashSet[string]()
|
||||
for k in funcs.keys:
|
||||
known.incl(k)
|
||||
if known.len == 0: return
|
||||
|
||||
var seenUri = initHashSet[string]()
|
||||
for uri, doc in documents.pairs:
|
||||
seenUri.incl(uri)
|
||||
result.add(collectCallSitesInDoc(doc, known))
|
||||
|
||||
# Disk scan for other workspace files
|
||||
if rootPath.len > 0 and dirExists(rootPath):
|
||||
var stack: seq[tuple[dir: string, depth: int]] = @[(rootPath, 0)]
|
||||
while stack.len > 0:
|
||||
let (dir, depth) = stack.pop()
|
||||
if depth > 4: continue
|
||||
let base = dir.extractFilename
|
||||
if base in [".git", "build", "examples_pkg", "node_modules", "vendor", "nimcache"]:
|
||||
continue
|
||||
try:
|
||||
for kind, path in walkDir(dir):
|
||||
if kind == pcDir:
|
||||
stack.add((path, depth + 1))
|
||||
elif kind == pcFile and path.endsWith(".bux"):
|
||||
let u = pathToUri(path.absolutePath)
|
||||
if seenUri.contains(u): continue
|
||||
seenUri.incl(u)
|
||||
try:
|
||||
let text = readFile(path)
|
||||
var d = DocumentState(uri: u, content: text)
|
||||
let updated = analyzeFile(path, text)
|
||||
d.symbols = updated.symbols
|
||||
d.ordered = updated.ordered
|
||||
d.members = updated.members
|
||||
result.add(collectCallSitesInDoc(d, known))
|
||||
except CatchableError:
|
||||
discard
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
proc callHierarchyItem(fs: FuncSym): JsonNode =
|
||||
let nameLen = fs.name.len
|
||||
%*{
|
||||
"name": fs.name,
|
||||
"kind": 12, # SymbolKind.Function
|
||||
"detail": fs.info.detail,
|
||||
"uri": fs.uri,
|
||||
"range": {
|
||||
"start": {"line": fs.info.line, "character": 0},
|
||||
"end": {"line": fs.info.line, "character": fs.info.col + nameLen}
|
||||
},
|
||||
"selectionRange": {
|
||||
"start": {"line": fs.info.line, "character": fs.info.col},
|
||||
"end": {"line": fs.info.line, "character": fs.info.col + nameLen}
|
||||
}
|
||||
}
|
||||
|
||||
proc lookupFuncSym(name, uri: string): FuncSym =
|
||||
result = FuncSym(name: name, uri: uri)
|
||||
if documents.hasKey(uri):
|
||||
let doc = documents[uri]
|
||||
ensureAnalyzed(doc)
|
||||
if doc.symbols.hasKey(name) and doc.symbols[name].kind == "function":
|
||||
result.info = doc.symbols[name]
|
||||
return
|
||||
if workspaceSymbols.hasKey(name) and workspaceSymbols[name].info.kind == "function":
|
||||
result.uri = workspaceSymbols[name].uri
|
||||
result.info = workspaceSymbols[name].info
|
||||
return
|
||||
# Fallback from allKnownFuncs
|
||||
let all = allKnownFuncs()
|
||||
if all.hasKey(name):
|
||||
return all[name]
|
||||
|
||||
proc handlePrepareCallHierarchy(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
|
||||
ensureAnalyzed(doc)
|
||||
let word = findWordAt(doc.content, lineNum, col)
|
||||
if word.len == 0:
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
# Prefer function symbol under cursor
|
||||
if doc.symbols.hasKey(word) and doc.symbols[word].kind == "function":
|
||||
let fs = FuncSym(name: word, uri: uri, info: doc.symbols[word])
|
||||
sendResponse(stream, id, %*[callHierarchyItem(fs)])
|
||||
return
|
||||
if workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "function":
|
||||
let ws = workspaceSymbols[word]
|
||||
let fs = FuncSym(name: word, uri: ws.uri, info: ws.info)
|
||||
sendResponse(stream, id, %*[callHierarchyItem(fs)])
|
||||
return
|
||||
# Allow prepare on a call site: Foo( → hierarchy for Foo
|
||||
let lines = doc.content.split("\n")
|
||||
if lineNum < lines.len:
|
||||
let l = lines[lineNum]
|
||||
var ws = min(col, l.len)
|
||||
while ws > 0 and l[ws - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||
dec ws
|
||||
var off = 0
|
||||
for li in 0 ..< lineNum:
|
||||
off += lines[li].len + 1
|
||||
off += ws
|
||||
if isCallSiteAt(doc.content, off, word.len):
|
||||
let all = allKnownFuncs()
|
||||
if all.hasKey(word):
|
||||
sendResponse(stream, id, %*[callHierarchyItem(all[word])])
|
||||
return
|
||||
sendResponse(stream, id, %*[])
|
||||
|
||||
proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
## Who calls this function?
|
||||
if not paramsNode.hasKey("item"):
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
let item = paramsNode["item"]
|
||||
let name = item["name"].getStr()
|
||||
let sites = collectAllCallSites()
|
||||
# Group by caller
|
||||
var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]()
|
||||
for s in sites:
|
||||
if s.callee != name: continue
|
||||
if s.caller.len == 0: continue
|
||||
let key = s.callerUri & "#" & s.caller
|
||||
if not groups.hasKey(key):
|
||||
let fs = lookupFuncSym(s.caller, s.callerUri)
|
||||
groups[key] = (fs, @[])
|
||||
groups[key].ranges.add((s.line, s.col, name.len))
|
||||
|
||||
var arr = newJArray()
|
||||
for _, g in groups.pairs:
|
||||
var fromRanges = newJArray()
|
||||
for r in g.ranges:
|
||||
fromRanges.add(%*{
|
||||
"start": {"line": r.line, "character": r.col},
|
||||
"end": {"line": r.line, "character": r.col + r.len}
|
||||
})
|
||||
arr.add(%*{
|
||||
"from": callHierarchyItem(g.fs),
|
||||
"fromRanges": fromRanges
|
||||
})
|
||||
sendResponse(stream, id, arr)
|
||||
|
||||
proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||
## What does this function call?
|
||||
if not paramsNode.hasKey("item"):
|
||||
sendResponse(stream, id, %*[])
|
||||
return
|
||||
let item = paramsNode["item"]
|
||||
let name = item["name"].getStr()
|
||||
let uri = if item.hasKey("uri"): item["uri"].getStr() else: ""
|
||||
let sites = collectAllCallSites()
|
||||
# Group by callee among sites whose caller is `name`
|
||||
var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]()
|
||||
for s in sites:
|
||||
if s.caller != name: continue
|
||||
if uri.len > 0 and s.callerUri != uri: continue
|
||||
let key = s.callee
|
||||
if not groups.hasKey(key):
|
||||
let fs = lookupFuncSym(s.callee, s.callerUri)
|
||||
# Prefer known func uri
|
||||
let all = allKnownFuncs()
|
||||
let fs2 = if all.hasKey(s.callee): all[s.callee] else: fs
|
||||
groups[key] = (fs2, @[])
|
||||
groups[key].ranges.add((s.line, s.col, s.callee.len))
|
||||
|
||||
var arr = newJArray()
|
||||
for _, g in groups.pairs:
|
||||
var fromRanges = newJArray()
|
||||
for r in g.ranges:
|
||||
fromRanges.add(%*{
|
||||
"start": {"line": r.line, "character": r.col},
|
||||
"end": {"line": r.line, "character": r.col + r.len}
|
||||
})
|
||||
arr.add(%*{
|
||||
"to": callHierarchyItem(g.fs),
|
||||
"fromRanges": fromRanges
|
||||
})
|
||||
sendResponse(stream, id, arr)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main message loop
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1930,9 +2198,10 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
||||
"documentSymbolProvider": true,
|
||||
"referencesProvider": true,
|
||||
"renameProvider": {"prepareProvider": true},
|
||||
"workspaceSymbolProvider": true
|
||||
"workspaceSymbolProvider": true,
|
||||
"callHierarchyProvider": true
|
||||
},
|
||||
"serverInfo": {"name": "bux-lsp", "version": "0.7.0"}
|
||||
"serverInfo": {"name": "bux-lsp", "version": "0.8.0"}
|
||||
})
|
||||
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
|
||||
rootPath = paramsNode["rootPath"].getStr()
|
||||
@@ -2020,6 +2289,15 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
||||
of "workspace/symbol":
|
||||
handleWorkspaceSymbol(stream, id, paramsNode)
|
||||
|
||||
of "textDocument/prepareCallHierarchy":
|
||||
handlePrepareCallHierarchy(stream, id, paramsNode)
|
||||
|
||||
of "callHierarchy/incomingCalls":
|
||||
handleIncomingCalls(stream, id, paramsNode)
|
||||
|
||||
of "callHierarchy/outgoingCalls":
|
||||
handleOutgoingCalls(stream, id, paramsNode)
|
||||
|
||||
else:
|
||||
if id != nil:
|
||||
sendError(stream, id, -32601, "method not found: " & methodName)
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke: call hierarchy prepare / incoming / outgoing (bux-lsp 0.8)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
LSP="$ROOT/tools/bux-lsp"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
if [[ ! -x "$LSP" ]]; then
|
||||
(cd "$ROOT" && make lsp >/dev/null)
|
||||
fi
|
||||
|
||||
cat > "$TMP/Main.bux" <<'EOF'
|
||||
func Add(a: int, b: int) -> int {
|
||||
return a + b;
|
||||
}
|
||||
func Mul(a: int, b: int) -> int {
|
||||
return a * b;
|
||||
}
|
||||
func Compute(n: int) -> int {
|
||||
let s: int = Add(n, 1);
|
||||
let p: int = Mul(s, 2);
|
||||
return Add(p, s);
|
||||
}
|
||||
func Main() -> int {
|
||||
return Compute(3);
|
||||
}
|
||||
EOF
|
||||
|
||||
rpc() {
|
||||
local body="$1"
|
||||
local len
|
||||
len=$(printf '%s' "$body" | wc -c)
|
||||
printf 'Content-Length: %s\r\n\r\n%s' "$len" "$body"
|
||||
}
|
||||
|
||||
CONTENT_JSON=$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$TMP/Main.bux")
|
||||
URI="file://$TMP/Main.bux"
|
||||
|
||||
# Prepare on Add (line 0, character 5)
|
||||
# Incoming for Add — expect Compute
|
||||
# Outgoing for Compute — expect Add and Mul
|
||||
{
|
||||
rpc '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"rootUri":"file://'"$TMP"'"}}'
|
||||
rpc '{"jsonrpc":"2.0","method":"initialized","params":{}}'
|
||||
rpc '{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"'"$URI"'","languageId":"bux","version":1,"text":'"$CONTENT_JSON"'}}}'
|
||||
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareCallHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":5}}}'
|
||||
# incomingCalls — item for Add from prepare (we construct manually)
|
||||
rpc '{"jsonrpc":"2.0","id":3,"method":"callHierarchy/incomingCalls","params":{"item":{"name":"Add","kind":12,"uri":"'"$URI"'","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":8}},"selectionRange":{"start":{"line":0,"character":5},"end":{"line":0,"character":8}}}}}'
|
||||
rpc '{"jsonrpc":"2.0","id":4,"method":"callHierarchy/outgoingCalls","params":{"item":{"name":"Compute","kind":12,"uri":"'"$URI"'","range":{"start":{"line":6,"character":0},"end":{"line":6,"character":12}},"selectionRange":{"start":{"line":6,"character":5},"end":{"line":6,"character":12}}}}}'
|
||||
rpc '{"jsonrpc":"2.0","id":5,"method":"shutdown","params":null}'
|
||||
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
|
||||
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
|
||||
|
||||
if ! grep -q 'callHierarchyProvider' "$TMP/out.txt"; then
|
||||
echo "FAIL: missing callHierarchyProvider"
|
||||
cat "$TMP/out.txt"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '0.8.0' "$TMP/out.txt"; then
|
||||
echo "WARN: version not 0.8.0"
|
||||
fi
|
||||
|
||||
# prepare should mention Add
|
||||
if ! grep -q '"name":"Add"' "$TMP/out.txt"; then
|
||||
echo "FAIL: prepareCallHierarchy did not return Add"
|
||||
cat "$TMP/out.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# incoming: Compute calls Add
|
||||
if ! grep -q '"name":"Compute"' "$TMP/out.txt"; then
|
||||
echo "FAIL: incomingCalls missing Compute"
|
||||
cat "$TMP/out.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# outgoing from Compute: Add and Mul
|
||||
if ! grep -q '"name":"Mul"' "$TMP/out.txt"; then
|
||||
echo "FAIL: outgoingCalls missing Mul"
|
||||
cat "$TMP/out.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# fromRanges present
|
||||
if ! grep -q 'fromRanges' "$TMP/out.txt"; then
|
||||
echo "FAIL: missing fromRanges"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: LSP call hierarchy (prepare + incoming + outgoing)"
|
||||
@@ -59,7 +59,7 @@ if ! grep -q 'renameProvider' "$TMP/out.txt"; then
|
||||
echo "FAIL: initialize missing renameProvider"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE '0\.[567]\.0' "$TMP/out.txt"; then
|
||||
if ! grep -qE '0\.[5678]\.0' "$TMP/out.txt"; then
|
||||
echo "WARN: unexpected bux-lsp version in initialize"
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user