# 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. # # Hover uses real bootstrap sema types when possible (globals + stdlib). # Locals are position-sensitive (scoped) and include inferred `let` types (v0.4.0). # 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). # v0.9.0: method call hierarchy (extend Type / .Method() sites). # v0.10.0: method rename + qualified path / extend Type rename edges. # v0.11.0: interface dispatch in call hierarchy (extend Type for Trait). # v0.12.0: module-path segment rename (import Std::Io / Std::Io::{…}). # v0.13.0: textDocument/implementation (interface → types / methods). import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location # --------------------------------------------------------------------------- # 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) = ## 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" stdout.write(header) stdout.write(body) stdout.flushFile() 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 = 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) fromSema: bool ## detail came from real type checker ## Struct field / enum variant (type member) MemberInfo = object name: string parent: string ## owning type name kind: string ## field | variant line: int ## 0-based decl line col: int ## 0-based decl col of member name ## One segment of an `import A::B::C` / `import A::B::{…}` path (v0.12) PathSegInfo = object name: string line: int ## 0-based col: int ## 0-based start of segment name path: seq[string] ## full path including this segment (prefix…name) index: int ## index of this segment in path ## Scoped local binding for position-sensitive hover / go-to-def LocalBinding = object name: string detail: string ## e.g. "let x: int" (inferred or annotated) kind: string ## variable | parameter declLine: int ## 0-based declaration line declCol: int ## 0-based start of name scopeStartLine: int ## first line where name is visible scopeEndLine: int ## last line where name is visible (inclusive) container: string ## enclosing function name inferred: bool ## type came from initializer, not annotation DocumentState = ref object uri: string content: string version: int symbols: Table[string, SymbolInfo] ordered: seq[string] ## declaration order for outline ## Full-project type index for hover (includes stdlib after sema enrich) typeIndex: Table[string, string] ## name → type / signature string kindIndex: Table[string, string] ## name → kind label ## Position-sensitive locals (filled by enrichWithSema) locals: seq[LocalBinding] ## Type members for field/variant rename (v0.7) members: seq[MemberInfo] ## Interface methods (v0.11): name is method, parent is interface ifaceMethods: seq[MemberInfo] ## Type implements Interface (from `extend Type for Interface`) impls: seq[tuple[typeName, iface: string, line: int]] ## Import path segments for module-path rename (v0.12) importPaths: seq[PathSegInfo] var documents = initTable[string, DocumentState]() rootPath = "" rootUri = "" workspaceSymbols = initTable[string, tuple[uri: string, info: SymbolInfo]]() ## Cross-file: "Iface.Method" → list of implementors workspaceImpls = initTable[string, seq[tuple[uri, typeName, meth: string]]]() cachedStdlibDir = "" cachedStdlibDecls: seq[Decl] = @[] stdlibLoaded = false 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] # 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 # --------------------------------------------------------------------------- # Symbol analysis — lightweight scan (not full sema; good enough for hover/def) # --------------------------------------------------------------------------- proc isIdentChar(c: char): bool = c in {'a'..'z', 'A'..'Z', '0'..'9', '_'} 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 var i = 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 addMember(doc: var DocumentState, m: MemberInfo) = if m.name.len == 0 or m.parent.len == 0: return doc.members.add(m) proc scanTypeBodyMembers(content: string, openBrace: int, parent, bodyKind: string, doc: var DocumentState) = ## Scan `{ ... }` after struct/enum for field (`name:`) or variant (`Name` / `Name(`) decls. if openBrace < 0 or openBrace >= content.len or content[openBrace] != '{': return var i = openBrace + 1 var depth = 1 var inLineComment = false var inBlockComment = false var inString = false var stringDelim = '\0' var escape = false while i < content.len and depth > 0: let c = content[i] if inLineComment: if c == '\n': inLineComment = false inc i continue 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 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 {'"', '`'}: inString = true stringDelim = c inc i continue if c == '{': inc depth inc i continue if c == '}': dec depth inc i continue # Only collect at depth 1 (direct body of the type) if depth == 1 and isIdentStart(c): let nameStart = i let name = readIdent(content, i) if name.len == 0: inc i continue # Skip keywords / visibility if name in ["pub", "own", "const", "static", "func", "let", "var"]: continue skipWs(content, i) if bodyKind == "struct" or bodyKind == "union" or bodyKind == "interface": # Field: Name: Type (or Name,) if i < content.len and content[i] == ':': let (line, col) = lineColAt(content, nameStart) addMember(doc, MemberInfo( name: name, parent: parent, kind: "field", line: line, col: col)) elif bodyKind == "enum": # Variant: Name | Name(...) | Name { ... } — not Name: if i >= content.len or content[i] != ':': let (line, col) = lineColAt(content, nameStart) addMember(doc, MemberInfo( name: name, parent: parent, kind: "variant", line: line, col: col)) continue inc i proc analyzeFile(path: string, content: string): DocumentState = result = DocumentState(uri: pathToUri(path), content: content) result.symbols = initTable[string, SymbolInfo]() result.ordered = @[] result.members = @[] var i = 0 var inLineComment = false var inBlockComment = false var inString = false var stringDelim = '\0' var escape = false ## extend/impl Type { ... } tracking for method indexing var braceDepth = 0 var activeExtend = "" ## Type name of open extend/impl block var activeIface = "" ## Interface when `extend Type for Iface` var extendBodyDepth = 0 ## braceDepth of the extend/impl opening `{` var pendingExtend = "" ## Type name after `extend Name` before `{` var pendingIface = "" ## Interface name after `for` ## interface Iface { func M... } — abstract methods var activeInterface = "" var interfaceBodyDepth = 0 var pendingInterface = "" result.ifaceMethods = @[] result.impls = @[] result.importPaths = @[] while i < content.len: let c = content[i] # Comments / strings (best-effort skip so "func" in strings is ignored) if inLineComment: if c == '\n': inLineComment = false inc i continue 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 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 # Brace tracking for extend/impl/interface bodies (outside strings/comments) if c == '{': inc braceDepth if pendingExtend.len > 0 and extendBodyDepth == 0: activeExtend = pendingExtend activeIface = pendingIface extendBodyDepth = braceDepth if pendingIface.len > 0: result.impls.add((activeExtend, pendingIface, lineColAt(content, i).line)) pendingExtend = "" pendingIface = "" if pendingInterface.len > 0 and interfaceBodyDepth == 0: activeInterface = pendingInterface interfaceBodyDepth = braceDepth pendingInterface = "" inc i continue if c == '}': if extendBodyDepth > 0 and braceDepth == extendBodyDepth: activeExtend = "" activeIface = "" extendBodyDepth = 0 if interfaceBodyDepth > 0 and braceDepth == interfaceBodyDepth: activeInterface = "" interfaceBodyDepth = 0 if braceDepth > 0: dec braceDepth inc i continue # 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]))) # interface Name — abstract methods in the following { block } if atWord("interface"): i += 9 skipWs(content, i) let iname = readIdent(content, i) if iname.len > 0: let (line, col) = lineColAt(content, i - iname.len) addSymbol(result, iname, SymbolInfo( line: line, col: col, kind: "interface", detail: "interface " & iname, container: "")) pendingInterface = iname continue # import A::B::C | import A::B::{X, Y} | import A::B::* if atWord("import"): i += 6 skipWs(content, i) var path: seq[string] = @[] while i < content.len and isIdentStart(content[i]): let nameStart = i let name = readIdent(content, i) if name.len == 0: break let (line, col) = lineColAt(content, nameStart) path.add(name) result.importPaths.add(PathSegInfo( name: name, line: line, col: col, path: path, index: path.len - 1)) skipWs(content, i) if i + 1 < content.len and content[i] == ':' and content[i + 1] == ':': i += 2 skipWs(content, i) # stop before multi-import `{` or glob `*` if i < content.len and (content[i] == '{' or content[i] == '*'): break continue break continue # extend Type / impl Type [for Interface] — methods in following { block } if atWord("extend") or atWord("impl"): let kwLen = if content[i] == 'e': 6 else: 4 i += kwLen skipWs(content, i) let tname = readIdent(content, i) if tname.len > 0: pendingExtend = tname pendingIface = "" skipWs(content, i) # `for Interface` if atWord("for"): i += 3 skipWs(content, i) let iface = readIdent(content, i) if iface.len > 0: pendingIface = iface continue 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 == ';' or (ch == '\n' and depth == 0 and j > nameStart + name.len): # interface methods often end with `;` (no body) if sigEnd <= nameStart: sigEnd = i if ch == ';': sigEnd = j break inc j var sig = content[kwPos ..< min(sigEnd, content.len)].strip() # collapse whitespace sig = sig.replace("\n", " ").multiReplace([(" ", " "), (" ", " "), (" ", " ")]) if activeInterface.len > 0: # Abstract interface method sig = activeInterface & "." & sig addSymbol(result, name, SymbolInfo( line: line, col: col, kind: "method", detail: sig, container: activeInterface)) result.ifaceMethods.add(MemberInfo( name: name, parent: activeInterface, kind: "iface_method", line: line, col: col)) elif activeExtend.len > 0: var detail = activeExtend & "." & sig if activeIface.len > 0: detail = detail & " [" & activeIface & "]" addSymbol(result, name, SymbolInfo( line: line, col: col, kind: "method", detail: detail, container: activeExtend)) # Record as implementor of interface method if activeIface.len > 0: let key = activeIface & "." & name if not workspaceImpls.hasKey(key): workspaceImpls[key] = @[] workspaceImpls[key].add((result.uri, activeExtend, name)) else: addSymbol(result, name, SymbolInfo( line: line, col: col, kind: "function", detail: sig, container: "")) continue 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: "")) # Index members inside { ... } if kind in ["struct", "enum", "union", "interface"]: var j = i skipWs(content, j) # skip generic params if j < content.len and content[j] == '<': var gd = 1 inc j while j < content.len and gd > 0: if content[j] == '<': inc gd elif content[j] == '>': dec gd inc j skipWs(content, j) if j < content.len and content[j] == '{': scanTypeBodyMembers(content, j, name, kind, result) break if not matchedTypeKw: inc i # --------------------------------------------------------------------------- # Real sema types for hover # --------------------------------------------------------------------------- proc typeExprToStr(te: TypeExpr): string = if te == nil: return "?" case te.kind of tekNamed: result = te.typeName if te.typeArgs.len > 0: result &= "<" & te.typeArgs.mapIt(typeExprToStr(it)).join(", ") & ">" of tekPath: result = te.pathSegments.join("::") of tekPointer: result = "*" & typeExprToStr(te.pointerPointee) of tekOwn: result = "own " & typeExprToStr(te.pointerPointee) of tekRef: if te.refLifetime.len > 0: result = "&" & te.refLifetime & " " & typeExprToStr(te.pointerPointee) else: result = "&" & typeExprToStr(te.pointerPointee) of tekMutRef: if te.refLifetime.len > 0: result = "&" & te.refLifetime & " mut " & typeExprToStr(te.pointerPointee) else: result = "&mut " & typeExprToStr(te.pointerPointee) of tekSlice: result = typeExprToStr(te.sliceElement) & "[]" of tekTuple: result = "(" & te.tupleElements.mapIt(typeExprToStr(it)).join(", ") & ")" of tekFunc: let ps = te.funcParams.mapIt(typeExprToStr(it)).join(", ") let ret = if te.funcRet != nil: typeExprToStr(te.funcRet) else: "void" result = "func(" & ps & ") -> " & ret of tekSelf: result = "self" of tekDynRef: result = "&dyn " & te.dynInterface proc formatFuncDetail(name: string, decl: Decl): string = if decl == nil or decl.kind != dkFunc: return "func " & name var parts: seq[string] = @[] for p in decl.declFuncParams: var s = p.name if p.ptype != nil: s &= ": " & typeExprToStr(p.ptype) parts.add(s) result = "func " & name & "(" & parts.join(", ") & ")" if decl.declFuncTypeParams.len > 0: let tps = decl.declFuncTypeParams.mapIt(it.name).join(", ") result = "func " & name & "<" & tps & ">(" & parts.join(", ") & ")" if decl.declFuncReturnType != nil: result &= " -> " & typeExprToStr(decl.declFuncReturnType) proc symbolKindFromSema(sk: SymbolKind): string = case sk of skFunc: "function" of skVar: "variable" of skConst: "constant" of skType: "type" of skModule: "module" proc findStdlibDirLocal(root: string): string = if root.len == 0: return "" let candidates = @[ root / "lib", root / ".." / "lib", getAppDir() / "lib", getAppDir() / ".." / "lib", getCurrentDir() / "lib" ] for c in candidates: if dirExists(c): return c.absolutePath # Walk up from root looking for lib/ var cur = root.absolutePath for _ in 0 .. 6: let lib = cur / "lib" if dirExists(lib): return lib let parent = cur.parentDir if parent == cur: break cur = parent return "" proc loadStdlibDecls(stdlibDir: string): seq[Decl] = result = @[] if stdlibDir.len == 0 or not dirExists(stdlibDir): return for path in walkDirRec(stdlibDir): if not path.endsWith(".bux"): continue try: let source = readFile(path) let lexRes = tokenize(source, path) if lexRes.hasErrors: continue let parseRes = parse(lexRes.tokens, path) if parseRes.diagnostics.len > 0: continue for item in parseRes.module.items: if item.kind == dkModule: for sub in item.declModuleItems: result.add(sub) else: result.add(item) except: discard proc ensureStdlibCached() = if stdlibLoaded: return stdlibLoaded = true cachedStdlibDir = findStdlibDirLocal(rootPath) if cachedStdlibDir.len == 0: # try from open document path later return cachedStdlibDecls = loadStdlibDecls(cachedStdlibDir) proc enrichWithSema(doc: DocumentState) = ## Run real bootstrap sema (file + stdlib) and fill typeIndex + upgrade symbols. if doc.content.len == 0: return let path = uriToPath(doc.uri) ensureStdlibCached() if cachedStdlibDecls.len == 0 and rootPath.len == 0: # Try stdlib relative to the file let tryRoot = path.parentDir.parentDir # …/src/Main.bux → package cachedStdlibDir = findStdlibDirLocal(tryRoot) if cachedStdlibDir.len > 0: cachedStdlibDecls = loadStdlibDecls(cachedStdlibDir) try: let lexRes = tokenize(doc.content, path) if lexRes.hasErrors: return let parseRes = parse(lexRes.tokens, path) # Build unified module: stdlib first, then this file var unified = newModule("lsp") for d in cachedStdlibDecls: unified.items.add(d) for d in parseRes.module.items: if d.kind == dkModule: for sub in d.declModuleItems: unified.items.add(sub) else: unified.items.add(d) let (semaRes, semaCtx) = analyzeFull(unified) discard semaRes # diagnostics already published via buxc doc.typeIndex = initTable[string, string]() doc.kindIndex = initTable[string, string]() # Index entire global scope for hover (includes stdlib) if semaCtx.globalScope != nil: for name, sym in semaCtx.globalScope.table.pairs: if name.len == 0: continue var detail = "" var kind = symbolKindFromSema(sym.kind) if sym.kind == skFunc and sym.decl != nil and sym.decl.kind == dkFunc: detail = formatFuncDetail(name, sym.decl) elif sym.typ != nil and not sym.typ.isUnknown: detail = name & ": " & sym.typ.toString if sym.kind == skFunc: detail = sym.typ.toString # already "func(...) -> T" if not detail.startsWith("func"): detail = "func " & name & " — " & detail else: # inject name: func name(...) detail = detail.replace("func(", "func " & name & "(") elif sym.kind == skConst: detail = "const " & name & ": " & sym.typ.toString elif sym.kind == skType: detail = "type " & name kind = "type" else: detail = (if sym.isMutable: "var " else: "let ") & name & ": " & sym.typ.toString elif sym.decl != nil: case sym.decl.kind of dkStruct: detail = "struct " & name kind = "struct" of dkEnum: detail = "enum " & name kind = "enum" of dkUnion: detail = "union " & name kind = "struct" of dkInterface: detail = "interface " & name kind = "interface" of dkTypeAlias: detail = "type " & name if sym.decl.declAliasType != nil: detail &= " = " & typeExprToStr(sym.decl.declAliasType) kind = "type" of dkFunc: detail = formatFuncDetail(name, sym.decl) kind = "function" else: detail = name else: detail = name doc.typeIndex[name] = detail doc.kindIndex[name] = kind # Upgrade file-local symbols (already found by lightweight scan) if doc.symbols.hasKey(name): var info = doc.symbols[name] info.detail = detail info.kind = kind info.fromSema = true doc.symbols[name] = info # Prefer THIS file's declarations over stdlib when names collide # (e.g. user `Max` vs lib/Math `Max(int64,int64)`). for d in parseRes.module.items: proc indexDecl(dd: Decl) = case dd.kind of dkFunc: let n = dd.declFuncName if n.len == 0: return let detail = formatFuncDetail(n, dd) doc.typeIndex[n] = detail doc.kindIndex[n] = "function" if doc.symbols.hasKey(n): var info = doc.symbols[n] info.detail = detail info.kind = "function" info.fromSema = true doc.symbols[n] = info else: let line = max(0, int(dd.loc.line) - 1) let col = max(0, int(dd.loc.column) - 1) doc.symbols[n] = SymbolInfo( line: line, col: col, kind: "function", detail: detail, container: "", fromSema: true) if n notin doc.ordered: doc.ordered.add(n) of dkStruct: let n = dd.declStructName doc.typeIndex[n] = "struct " & n doc.kindIndex[n] = "struct" of dkEnum: let n = dd.declEnumName doc.typeIndex[n] = "enum " & n doc.kindIndex[n] = "enum" of dkTypeAlias: let n = dd.declAliasName var detail = "type " & n if dd.declAliasType != nil: detail &= " = " & typeExprToStr(dd.declAliasType) doc.typeIndex[n] = detail doc.kindIndex[n] = "type" else: discard if d.kind == dkModule: for sub in d.declModuleItems: indexDecl(sub) else: indexDecl(d) # --- Position-sensitive locals + inferred let types --- doc.locals = @[] proc blockEndLine(blk: Block): int = ## Last 0-based line covered by statements in `blk` (best-effort). if blk == nil: return 0 result = max(0, int(blk.loc.line) - 1) for stmt in blk.stmts: result = max(result, max(0, int(stmt.loc.line) - 1)) case stmt.kind of skIf: result = max(result, blockEndLine(stmt.stmtIfThen)) result = max(result, blockEndLine(stmt.stmtIfElse)) for br in stmt.stmtIfElseIfs: result = max(result, blockEndLine(br.blk)) of skWhile: result = max(result, blockEndLine(stmt.stmtWhileBody)) of skDoWhile: result = max(result, blockEndLine(stmt.stmtDoWhileBody)) of skLoop: result = max(result, blockEndLine(stmt.stmtLoopBody)) of skFor: result = max(result, blockEndLine(stmt.stmtForBody)) of skMatch: for arm in stmt.stmtMatchArms: if arm.body != nil and arm.body.kind == ekBlock: result = max(result, blockEndLine(arm.body.exprBlock)) elif arm.body != nil: result = max(result, max(0, int(arm.body.loc.line) - 1)) of skExpr: if stmt.stmtExpr != nil and stmt.stmtExpr.kind == ekBlock: result = max(result, blockEndLine(stmt.stmtExpr.exprBlock)) else: discard proc collectLocals(sema: var Sema, blk: Block, sc: Scope, scopeEnd: int, container: string) = if blk == nil: return let endLine = max(scopeEnd, blockEndLine(blk)) for stmt in blk.stmts: case stmt.kind of skLet: let n = stmt.stmtLetName if n.len == 0: continue var typ: Type = makeUnknown() var inferred = false if stmt.stmtLetType != nil: typ = sema.resolveType(stmt.stmtLetType) if (typ == nil or typ.isUnknown) and stmt.stmtLetInit != nil: typ = sema.checkExprForLsp(stmt.stmtLetInit, sc) inferred = true elif stmt.stmtLetType == nil and stmt.stmtLetInit != nil: # Explicit absence of annotation — still type the initializer typ = sema.checkExprForLsp(stmt.stmtLetInit, sc) inferred = true let kw = if stmt.stmtLetMut: "var" else: "let" let typStr = if typ != nil and not typ.isUnknown: typ.toString else: "" let detail = if typStr.len > 0: kw & " " & n & ": " & typStr else: kw & " " & n let line = max(0, int(stmt.loc.line) - 1) let col = max(0, int(stmt.loc.column) - 1) doc.locals.add(LocalBinding( name: n, detail: detail, kind: "variable", declLine: line, declCol: col, scopeStartLine: line, scopeEndLine: endLine, container: container, inferred: inferred and typStr.len > 0)) # Also keep latest flat entry for outline (position lookup prefers locals) doc.symbols[n] = SymbolInfo( line: line, col: col, kind: "variable", detail: detail, container: container, fromSema: typStr.len > 0) if n notin doc.ordered: doc.ordered.add(n) # Define in scope for subsequent inference let sym = Symbol(kind: skVar, name: n, typ: typ, isMutable: stmt.stmtLetMut, isOwn: false) discard sc.define(sym) of skExpr: if stmt.stmtExpr != nil and stmt.stmtExpr.kind == ekBlock: var child = newScope(sc) collectLocals(sema, stmt.stmtExpr.exprBlock, child, blockEndLine(stmt.stmtExpr.exprBlock), container) of skIf: var thenSc = newScope(sc) collectLocals(sema, stmt.stmtIfThen, thenSc, blockEndLine(stmt.stmtIfThen), container) for br in stmt.stmtIfElseIfs: var elifSc = newScope(sc) collectLocals(sema, br.blk, elifSc, blockEndLine(br.blk), container) if stmt.stmtIfElse != nil: var elseSc = newScope(sc) collectLocals(sema, stmt.stmtIfElse, elseSc, blockEndLine(stmt.stmtIfElse), container) of skWhile: var wSc = newScope(sc) collectLocals(sema, stmt.stmtWhileBody, wSc, blockEndLine(stmt.stmtWhileBody), container) of skDoWhile: var dSc = newScope(sc) collectLocals(sema, stmt.stmtDoWhileBody, dSc, blockEndLine(stmt.stmtDoWhileBody), container) of skLoop: var lSc = newScope(sc) collectLocals(sema, stmt.stmtLoopBody, lSc, blockEndLine(stmt.stmtLoopBody), container) of skFor: var fSc = newScope(sc) if stmt.stmtForVar.len > 0: let fline = max(0, int(stmt.loc.line) - 1) let fcol = max(0, int(stmt.loc.column) - 1) let fend = blockEndLine(stmt.stmtForBody) # Best-effort: element type unknown without iterator typing let detail = "for " & stmt.stmtForVar doc.locals.add(LocalBinding( name: stmt.stmtForVar, detail: detail, kind: "variable", declLine: fline, declCol: fcol, scopeStartLine: fline, scopeEndLine: fend, container: container, inferred: false)) discard fSc.define(Symbol(kind: skVar, name: stmt.stmtForVar, typ: makeUnknown(), isMutable: false)) collectLocals(sema, stmt.stmtForBody, fSc, blockEndLine(stmt.stmtForBody), container) of skMatch: for arm in stmt.stmtMatchArms: if arm.body != nil and arm.body.kind == ekBlock: var mSc = newScope(sc) collectLocals(sema, arm.body.exprBlock, mSc, blockEndLine(arm.body.exprBlock), container) else: discard proc collectFuncLocals(sema: var Sema, d: Decl) = if d == nil or d.kind != dkFunc or d.declFuncBody == nil: return let fname = d.declFuncName let bodyEnd = blockEndLine(d.declFuncBody) var funcScope = newScope(sema.globalScope) # Parameters — visible for entire function body let funcStart = max(0, int(d.loc.line) - 1) for p in d.declFuncParams: if p.name.len == 0: continue var pType = makeUnknown() if p.ptype != nil: pType = sema.resolveType(p.ptype) let typStr = if pType != nil and not pType.isUnknown: pType.toString else: "" let detail = if typStr.len > 0: "param " & p.name & ": " & typStr else: "param " & p.name let pline = max(0, int(p.loc.line) - 1) let pcol = max(0, int(p.loc.column) - 1) doc.locals.add(LocalBinding( name: p.name, detail: detail, kind: "parameter", declLine: pline, declCol: pcol, scopeStartLine: funcStart, scopeEndLine: bodyEnd, container: fname, inferred: false)) discard funcScope.define(Symbol(kind: skVar, name: p.name, typ: pType, isMutable: false)) collectLocals(sema, d.declFuncBody, funcScope, bodyEnd, fname) var semaMut = semaCtx for d in parseRes.module.items: if d.kind == dkFunc: collectFuncLocals(semaMut, d) elif d.kind == dkModule: for sub in d.declModuleItems: if sub.kind == dkFunc: collectFuncLocals(semaMut, sub) except: discard # sema failures must not crash the LSP # --------------------------------------------------------------------------- # 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 doc.ordered = updated.ordered doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls doc.importPaths = updated.importPaths # Keep / refresh real types for hover (does not replace lightweight outline) enrichWithSema(doc) 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 # --------------------------------------------------------------------------- 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 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 doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls doc.importPaths = updated.importPaths proc completionKind(kind: string): int = case kind of "function": 3 of "method": 2 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"] let lineNum = position["line"].getInt() let col = position["character"].getInt() let doc = getDoc(uri) if doc.content == "": sendResponse(stream, id, %*{"isIncomplete": false, "items": []}) return ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) let prefix = findWordAt(doc.content, lineNum, col) var items = newJArray() var offered = initHashSet[string]() # Position-sensitive locals / params first (highest priority) for b in doc.locals: if lineNum < b.scopeStartLine or lineNum > b.scopeEndLine: continue if prefix != "" and not b.name.toLowerAscii().startsWith(prefix.toLowerAscii()): continue # Prefer later/narrower binding for same name if offered.contains(b.name): continue offered.incl(b.name) let k = if b.kind == "parameter": 6 else: completionKind("variable") items.add(%*{ "label": b.name, "kind": k, "detail": b.detail, "sortText": "0_" & b.name, "documentation": {"kind": "markdown", "value": "```bux\n" & b.detail & "\n```\n\n_" & b.kind & (if b.inferred: " · inferred" else: "") & "_"} }) for name, info in doc.symbols.pairs: if offered.contains(name): continue if prefix == "" or name.toLowerAscii().startsWith(prefix.toLowerAscii()): offered.incl(name) items.add(%*{ "label": name, "kind": completionKind(info.kind), "detail": info.detail, "sortText": "1_" & name, "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 offered.contains(name): continue if prefix == "" or name.toLowerAscii().startsWith(prefix.toLowerAscii()): offered.incl(name) items.add(%*{ "label": name, "kind": completionKind(ws.info.kind), "detail": ws.info.detail & " (workspace)", "sortText": "2_" & name, "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", "defer", "switch", "case", "default", "pub", "own"] for kw in keywords: if prefix == "" or kw.startsWith(prefix): items.add(%*{ "label": kw, "kind": 14, "detail": "keyword", "sortText": "3_" & kw }) sendResponse(stream, id, %*{"isIncomplete": false, "items": items}) # --------------------------------------------------------------------------- # Position-sensitive local lookup # --------------------------------------------------------------------------- proc lookupLocalAt*(doc: DocumentState, name: string, line: int): tuple[ok: bool, b: LocalBinding] = ## Innermost local/parameter binding for `name` visible at `line` (0-based). result.ok = false var bestSpan = high(int) var bestStart = -1 for b in doc.locals: if b.name != name: continue if line < b.scopeStartLine or line > b.scopeEndLine: continue let span = b.scopeEndLine - b.scopeStartLine # Prefer narrower scope; on ties prefer later declaration (shadowing) if span < bestSpan or (span == bestSpan and b.scopeStartLine >= bestStart): bestSpan = span bestStart = b.scopeStartLine result.b = b result.ok = true # --------------------------------------------------------------------------- # 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 ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) let word = findWordAt(doc.content, lineNum, col) if word.len == 0: sendResponse(stream, id, %*[]) return var locs = newJArray() # Position-sensitive local first let (lok, lb) = lookupLocalAt(doc, word, lineNum) if lok: locs.add(%*{ "uri": uri, "range": { "start": {"line": lb.declLine, "character": lb.declCol}, "end": {"line": lb.declLine, "character": lb.declCol + word.len} } }) elif doc.symbols.hasKey(word): let info = doc.symbols[word] locs.add(%*{ "uri": uri, "range": { "start": {"line": info.line, "character": info.col}, "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) # --------------------------------------------------------------------------- # Hover # --------------------------------------------------------------------------- proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = ## Hover with accurate range; prefer real sema types when available. ## Locals are resolved by position (shadowing / nested scopes). 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, newJNull()) return ensureAnalyzed(doc) # Lazy sema enrich on first hover if not yet run (e.g. only didChange so far) if (doc.typeIndex.len == 0 or doc.locals.len == 0) and doc.content.len > 0: enrichWithSema(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 detail = "" var kind = "" var found = false var fromSema = false var inferred = false var scopeNote = "" # 1) Position-sensitive local / parameter let (lok, lb) = lookupLocalAt(doc, word, lineNum) if lok: detail = lb.detail kind = lb.kind found = true fromSema = true inferred = lb.inferred if lb.container.len > 0: scopeNote = " in `" & lb.container & "`" # 2) File-level / global symbols (functions, types, …) if not found and doc.symbols.hasKey(word): let info = doc.symbols[word] detail = info.detail kind = info.kind found = true fromSema = info.fromSema if doc.typeIndex.hasKey(word) and doc.typeIndex[word].len >= detail.len: detail = doc.typeIndex[word] if doc.kindIndex.hasKey(word): kind = doc.kindIndex[word] fromSema = true elif not found and doc.typeIndex.hasKey(word): detail = doc.typeIndex[word] kind = if doc.kindIndex.hasKey(word): doc.kindIndex[word] else: "symbol" found = true fromSema = true elif not found and workspaceSymbols.hasKey(word): let info = workspaceSymbols[word].info detail = info.detail kind = info.kind found = true if not found: sendResponse(stream, id, newJNull()) return var md = "```bux\n" & detail & "\n```\n\n_" & kind & "_" if scopeNote.len > 0: md &= scopeNote if fromSema: md &= " · sema" if inferred: md &= " · inferred" sendResponse(stream, id, %*{ "contents": {"kind": "markdown", "value": md}, "range": { "start": {"line": lineNum, "character": start}, "end": {"line": lineNum, "character": endC} } }) # --------------------------------------------------------------------------- # Identifier occurrences (references / rename) — sessions 34 + 39 (deeper) # --------------------------------------------------------------------------- type IdentAccess = enum iaBare ## plain ident iaDot ## .member iaColonColon ## Type::Variant or path::name iaFieldInit ## Name: inside struct literal / pattern (preceded by `{` or `,`) IdentHit = object line: int ## 0-based col: int ## 0-based start of name len: int access: IdentAccess RenameTargetKind = enum rtkLocal rtkGlobal ## free func / type / const (not method) rtkMethod ## extend/impl method — decl + .Name( + bare Name( rtkMember ## struct field / enum variant rtkPathSeg ## module path segment in import / A::B path (v0.12) rtkUnknown RenameTarget = object kind: RenameTargetKind name: string ## For rtkMember / rtkMethod parent: string memberKind: string declLine: int declCol: int ## For rtkLocal local: LocalBinding ## For rtkGlobal type symbols isType: bool ## For rtkPathSeg: segments before this name (e.g. ["Std"] for Io in Std::Io) pathPrefix: seq[string] proc peekAccessBefore(content: string, start: int): IdentAccess = ## Classify how the identifier at `start` is written. var k = start - 1 while k >= 0 and content[k] in {' ', '\t'}: dec k if k < 0: return iaBare if content[k] == '.': return iaDot if content[k] == ':' and k > 0 and content[k - 1] == ':': return iaColonColon if content[k] == ':' and (k == 0 or content[k - 1] != ':'): # This is after the name for `name: type` — check from start side instead discard # Field init: after `{` or `,` optional whitespace then name then `:` var j = start + 1 while j < content.len and isIdentChar(content[j]): inc j var t = j while t < content.len and content[t] in {' ', '\t'}: inc t if t < content.len and content[t] == ':': # name: — could be field decl/init if preceded by { or , if k >= 0 and content[k] in {'{', ',', '\n', ';'}: return iaFieldInit return iaBare proc collectIdentHits(content, name: string): seq[IdentHit] = ## Textual identifier occurrences of `name`, skipping strings/comments. result = @[] if name.len == 0 or content.len == 0: return 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] if inLineComment: if c == '\n': inLineComment = false inc i continue 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 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 if isIdentStart(c): let start = i var j = i + 1 while j < content.len and isIdentChar(content[j]): inc j let ident = content[start ..< j] if ident == name: let (line, col) = lineColAt(content, start) result.add(IdentHit( line: line, col: col, len: name.len, access: peekAccessBefore(content, start))) i = j continue inc i proc sameLocal*(a, b: LocalBinding): bool = a.name == b.name and a.declLine == b.declLine and a.declCol == b.declCol and a.container == b.container proc locationJson(uri: string, line, col, nameLen: int): JsonNode = %*{ "uri": uri, "range": { "start": {"line": line, "character": col}, "end": {"line": line, "character": col + nameLen} } } proc lookupMemberAt(doc: DocumentState, name: string, line, col: int): tuple[ok: bool, m: MemberInfo] = ## Prefer member decl under cursor; else member if access is .name / ::name on that line. result.ok = false for m in doc.members: if m.name != name: continue if m.line == line and col >= m.col and col <= m.col + name.len: result.ok = true result.m = m return # Dot / :: access: any member with this name (ambiguous if multiple parents — # pick first; refine if line has Parent. or Parent::) let lines = doc.content.split("\n") if line < 0 or line >= lines.len: return let l = lines[line] var wordStart = min(col, l.len) while wordStart > 0 and l[wordStart - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: dec wordStart # Find parent type name before `.` or `::` var parentHint = "" var k = wordStart - 1 while k >= 0 and l[k] in {' ', '\t'}: dec k if k >= 0 and l[k] == '.': var p = k - 1 while p >= 0 and l[p] in {' ', '\t'}: dec p var pe = p while p >= 0 and l[p] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: dec p if pe > p: parentHint = l[p + 1 .. pe] elif k >= 1 and l[k] == ':' and l[k - 1] == ':': var p = k - 2 while p >= 0 and l[p] in {' ', '\t'}: dec p var pe = p while p >= 0 and l[p] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: dec p if pe > p: parentHint = l[p + 1 .. pe] for m in doc.members: if m.name != name: continue if parentHint.len > 0 and m.parent != parentHint: continue result.ok = true result.m = m if parentHint.len > 0: return # If no parent hint and only one member with this name, use it var count = 0 var last: MemberInfo for m in doc.members: if m.name == name: inc count last = m if count == 1: result.ok = true result.m = last proc absOffset(content: string, line, col: int): int = let lines = content.split("\n") result = 0 for li in 0 ..< min(line, lines.len): result += lines[li].len + 1 result += col 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 isCallableKind(kind: string): bool = kind == "function" or kind == "method" proc followedByColonColon(content: string, start, nameLen: int): bool = ## True if ident is followed by optional space then `::`. var j = start + nameLen while j < content.len and content[j] in {' ', '\t'}: inc j result = j + 1 < content.len and content[j] == ':' and content[j + 1] == ':' proc pathPrefixBefore(content: string, start: int): seq[string] = ## Segments immediately before `start` via `A::B::` (left of the current ident). result = @[] var pos = start - 1 while pos >= 0 and content[pos] in {' ', '\t'}: dec pos while pos >= 1 and content[pos] == ':' and content[pos - 1] == ':': pos -= 2 while pos >= 0 and content[pos] in {' ', '\t'}: dec pos if pos < 0 or not isIdentChar(content[pos]): break var e = pos while pos >= 0 and isIdentChar(content[pos]): dec pos result.insert(content[pos + 1 .. e], 0) proc pathStartsWith(path, prefix: seq[string]): bool = if path.len < prefix.len: return false for i, s in prefix: if path[i] != s: return false true proc isKnownImportPathPrefix(prefix: seq[string], name: string): bool = ## True if some open document's import path starts with prefix ++ name. let full = prefix & name for _, d in documents.pairs: if d.importPaths.len == 0 and d.content.len > 0: # lazy: may not have been copied yet discard for seg in d.importPaths: if pathStartsWith(seg.path, full): return true false proc hitMatchesPathSeg(content: string, h: IdentHit, prefix: seq[string], name: string): bool = ## Match path segment with exact left-prefix (avoids enum Color::X / bare locals). if h.access in {iaDot, iaFieldInit}: return false let off = absOffset(content, h.line, h.col) let pfx = pathPrefixBefore(content, off) if pfx != prefix: return false if prefix.len == 0: # Module path head: must introduce a path (`Std::…`), not bare ident return followedByColonColon(content, off, name.len) # Middle / tail: written as `…::Name` return h.access == iaColonColon proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): RenameTarget = result.kind = rtkUnknown result.name = word result.isType = false result.pathPrefix = @[] ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) let lines = doc.content.split("\n") var wordStart = col if line >= 0 and line < lines.len: wordStart = min(col, lines[line].len) while wordStart > 0 and lines[line][wordStart - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: dec wordStart let absStart = absOffset(doc.content, line, wordStart) let acc = peekAccessBefore(doc.content, absStart) # 1) Type member (field/variant) when cursor is on decl or qualified access let (mok, mem) = lookupMemberAt(doc, word, line, col) if mok: var isMemberCtx = acc in {iaDot, iaColonColon, iaFieldInit} or (mem.line == line and mem.col == wordStart) # Don't treat method calls as field members let isMethodSym = doc.symbols.hasKey(word) and doc.symbols[word].kind == "method" if isMethodSym and acc == iaDot and isCallSiteAt(doc.content, absStart, word.len): isMemberCtx = false if isMemberCtx or (not lookupLocalAt(doc, word, line).ok and not isMethodSym): if not isMethodSym: result.kind = rtkMember result.parent = mem.parent result.memberKind = mem.kind result.declLine = mem.line result.declCol = mem.col return # 2) Method (extend/impl) — decl or .Method( / bare Method( if doc.symbols.hasKey(word) and doc.symbols[word].kind == "method": let info = doc.symbols[word] result.kind = rtkMethod result.parent = info.container result.declLine = info.line result.declCol = info.col return if workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "method": let info = workspaceSymbols[word].info result.kind = rtkMethod result.parent = info.container result.declLine = info.line result.declCol = info.col return # Call site on method: .Area( or Area( if isCallSiteAt(doc.content, absStart, word.len): if doc.symbols.hasKey(word) and isCallableKind(doc.symbols[word].kind): if doc.symbols[word].kind == "method" or acc == iaDot: # Prefer method if registered as method; .Name( may be method even if only function table if doc.symbols.hasKey(word): let info = doc.symbols[word] if info.kind == "method": result.kind = rtkMethod result.parent = info.container result.declLine = info.line result.declCol = info.col return # 2b) Module-path segment (import Std::Io / Std::Io::{…} / matching A::B uses) # Prefer path rename when cursor sits on an indexed import segment (except last # segment that is also a known symbol — leave that to global/symbol rename). for seg in doc.importPaths: if seg.name != word: continue if seg.line != line: continue if col < seg.col or col > seg.col + word.len: continue let isLast = seg.index == seg.path.len - 1 let isKnownSym = doc.symbols.hasKey(word) or workspaceSymbols.hasKey(word) or doc.typeIndex.hasKey(word) if isLast and isKnownSym: break # fall through result.kind = rtkPathSeg result.name = word if seg.index > 0: result.pathPrefix = seg.path[0 ..< seg.index] else: result.pathPrefix = @[] return # Path use site matching a known import prefix (not enum Type::Variant alone) let pfx = pathPrefixBefore(doc.content, absStart) let inPathCtx = (pfx.len == 0 and followedByColonColon(doc.content, absStart, word.len)) or (pfx.len > 0 and acc == iaColonColon) if inPathCtx and isKnownImportPathPrefix(pfx, word): result.kind = rtkPathSeg result.name = word result.pathPrefix = pfx return # 3) Local / param (scoped) — including `self` receiver let (lok, lb) = lookupLocalAt(doc, word, line) if lok: result.kind = rtkLocal result.local = lb return # 3b) Synthetic `self` when inside a method (sema may omit method params) if word == "self": let lines = doc.content.split("\n") var ms = 0 var me = lines.len - 1 var li = min(line, lines.len - 1) while li >= 0: let t = lines[li].strip() if t.startsWith("func ") or t.startsWith("pub func "): ms = li break dec li li = ms + 1 while li < lines.len: let t = lines[li].strip() if t.startsWith("func ") or t.startsWith("pub func "): me = li - 1 break inc li # Find col of "self" on the func line if present var sc = col if ms >= 0 and ms < lines.len: let idx = lines[ms].find("self") if idx >= 0: sc = idx result.kind = rtkLocal result.local = LocalBinding( name: "self", detail: "param self", kind: "parameter", declLine: ms, declCol: sc, scopeStartLine: ms, scopeEndLine: me, container: "", inferred: false) return # 4) Global / type / free function if doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) or workspaceSymbols.hasKey(word): result.kind = rtkGlobal if doc.symbols.hasKey(word): let k = doc.symbols[word].kind result.isType = k in ["struct", "enum", "union", "interface", "type"] elif workspaceSymbols.hasKey(word): let k = workspaceSymbols[word].info.kind result.isType = k in ["struct", "enum", "union", "interface", "type"] return result.kind = rtkUnknown proc hitMatchesMember(h: IdentHit, m: MemberInfo): bool = ## Member rename: decl, .name, ::name, and field init `name:` — not bare locals. if h.line == m.line and h.col == m.col: return true case h.access of iaDot, iaColonColon, iaFieldInit: return true of iaBare: return false proc hitMatchesMethod(content: string, h: IdentHit, name: string, declLine, declCol: int): bool = ## Method rename: decl, .Name(, bare Name( — not bare non-call idents. if h.line == declLine and h.col == declCol: return true let off = absOffset(content, h.line, h.col) if not isCallSiteAt(content, off, name.len): return false # .Method( or free Method( return h.access in {iaDot, iaBare} proc hitMatchesType(content: string, h: IdentHit, name: string, declLine, declCol: int): bool = ## Type rename: decl, bare Type, Type::Variant, extend Type, self: Type — not .field if h.line == declLine and h.col == declCol: return true if h.access == iaDot: return false # obj.Type would be weird; skip field-like # bare or Type:: (iaColonColon is the *second* segment; first segment is bare) if h.access in {iaBare, iaColonColon, iaFieldInit}: return true return false proc collectReferences(doc: DocumentState, word: string, lineNum: int, includeDecl: bool, col: int = 0): seq[JsonNode] = ## Collect LSP Location nodes for references at `word` on `lineNum`. result = @[] if word.len == 0: return ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) let target = classifyRenameTarget(doc, word, lineNum, col) let hits = collectIdentHits(doc.content, word) case target.kind of rtkLocal: # Always clip to the textual function/method body containing the cursor. # Find the previous line that starts a `func` and the next such line. let lines = doc.content.split("\n") var methodStart = 0 var methodEnd = lines.len - 1 var li = min(lineNum, lines.len - 1) while li >= 0: let t = lines[li].strip() if t.startsWith("func ") or t.startsWith("pub func "): methodStart = li break li -= 1 li = methodStart + 1 while li < lines.len: let t = lines[li].strip() if t.startsWith("func ") or t.startsWith("pub func "): methodEnd = li - 1 break li += 1 for h in hits: if h.line < methodStart or h.line > methodEnd: continue if not includeDecl and h.line == target.local.declLine and h.col == target.local.declCol: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) return of rtkMethod: for h in hits: if not hitMatchesMethod(doc.content, h, word, target.declLine, target.declCol): continue if not includeDecl and h.line == target.declLine and h.col == target.declCol: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) # Workspace other files var seenUri = initHashSet[string]() seenUri.incl(doc.uri) for u, d in documents.pairs: if d.content.len == 0 or seenUri.contains(u): continue seenUri.incl(u) ensureAnalyzed(d) for h in collectIdentHits(d.content, word): if hitMatchesMethod(d.content, h, word, -1, -1): result.add(locationJson(u, h.line, h.col, h.len)) return of rtkMember: let m = MemberInfo( name: target.name, parent: target.parent, kind: target.memberKind, line: target.declLine, col: target.declCol) for h in hits: if not hitMatchesMember(h, m): continue # Don't rename method calls that share a field name let off = absOffset(doc.content, h.line, h.col) if h.access == iaDot and isCallSiteAt(doc.content, off, word.len): if doc.symbols.hasKey(word) and doc.symbols[word].kind == "method": continue if not includeDecl and h.line == m.line and h.col == m.col: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) var seenUri = initHashSet[string]() seenUri.incl(doc.uri) for u, d in documents.pairs: if d.content.len == 0 or seenUri.contains(u): continue seenUri.incl(u) ensureAnalyzed(d) for h in collectIdentHits(d.content, word): if hitMatchesMember(h, m): result.add(locationJson(u, h.line, h.col, h.len)) return of rtkPathSeg: # Rename only path segments with the same left-prefix (Std::Io not Foo::Io). let pfx = target.pathPrefix for h in hits: if hitMatchesPathSeg(doc.content, h, pfx, word): result.add(locationJson(doc.uri, h.line, h.col, h.len)) var seenUri = initHashSet[string]() seenUri.incl(doc.uri) for u, d in documents.pairs: if d.content.len == 0 or seenUri.contains(u): continue seenUri.incl(u) ensureAnalyzed(d) for h in collectIdentHits(d.content, word): if hitMatchesPathSeg(d.content, h, pfx, word): result.add(locationJson(u, h.line, h.col, h.len)) # On-disk workspace .bux files not yet opened 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) for h in collectIdentHits(text, word): if hitMatchesPathSeg(text, h, pfx, word): result.add(locationJson(u, h.line, h.col, h.len)) except CatchableError: discard except CatchableError: discard return of rtkGlobal, rtkUnknown: let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) let isWsSym = workspaceSymbols.hasKey(word) if not isFileSym and not isWsSym and target.kind == rtkUnknown: for h in hits: if h.access == iaBare: result.add(locationJson(doc.uri, h.line, h.col, h.len)) return let declLine = if doc.symbols.hasKey(word): doc.symbols[word].line else: -1 let declCol = if doc.symbols.hasKey(word): doc.symbols[word].col else: -1 for h in hits: if not includeDecl and h.line == declLine and h.col == declCol: continue if target.isType: if not hitMatchesType(doc.content, h, word, declLine, declCol): continue else: # Free function: bare + call; skip .member (fields/methods) if h.access == iaDot: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) if isWsSym or isFileSym: var seenUri = initHashSet[string]() seenUri.incl(doc.uri) for u, d in documents.pairs: if d.content.len == 0 or seenUri.contains(u): continue seenUri.incl(u) for h in collectIdentHits(d.content, word): if target.isType: if h.access == iaDot: continue else: if h.access == iaDot: continue result.add(locationJson(u, h.line, h.col, h.len)) 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) for h in collectIdentHits(text, word): if h.access == iaDot: continue result.add(locationJson(u, h.line, h.col, h.len)) except CatchableError: discard except CatchableError: discard proc handleReferences(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() var includeDecl = true if paramsNode.hasKey("context") and paramsNode["context"].hasKey("includeDeclaration"): includeDecl = paramsNode["context"]["includeDeclaration"].getBool() let doc = getDoc(uri) if doc.content == "": sendResponse(stream, id, %*[]) return let word = findWordAt(doc.content, lineNum, col) if word.len == 0: sendResponse(stream, id, %*[]) return var arr = newJArray() for loc in collectReferences(doc, word, lineNum, includeDecl, col): arr.add(loc) sendResponse(stream, id, arr) proc isValidIdentName(s: string): bool = if s.len == 0: return false if not isIdentStart(s[0]): return false for i in 1 ..< s.len: if not isIdentChar(s[i]): return false true proc handlePrepareRename(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, newJNull()) return 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] # Reject keywords const kws = ["func", "var", "let", "if", "else", "while", "for", "return", "struct", "enum", "true", "false", "null", "self", "match", "import", "module", "type", "const", "pub", "own"] if word in kws: sendResponse(stream, id, newJNull()) return sendResponse(stream, id, %*{ "range": { "start": {"line": lineNum, "character": start}, "end": {"line": lineNum, "character": endC} }, "placeholder": word }) proc handleRename(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 newName = if paramsNode.hasKey("newName"): paramsNode["newName"].getStr() else: "" if not isValidIdentName(newName): sendError(stream, id, -32602, "invalid identifier: '" & newName & "'") return let doc = getDoc(uri) if doc.content == "": sendResponse(stream, id, %*{"changes": newJObject()}) return let word = findWordAt(doc.content, lineNum, col) if word.len == 0: sendResponse(stream, id, %*{"changes": newJObject()}) return if word == newName: sendResponse(stream, id, %*{"changes": newJObject()}) return let refs = collectReferences(doc, word, lineNum, includeDecl = true, col = col) # Group TextEdits by URI var byUri = initTable[string, JsonNode]() for loc in refs: let u = loc["uri"].getStr() if not byUri.hasKey(u): byUri[u] = newJArray() let r = loc["range"] byUri[u].add(%*{ "range": r, "newText": newName }) var changes = newJObject() for u, edits in byUri.pairs: changes[u] = edits sendResponse(stream, id, %*{"changes": changes}) # --------------------------------------------------------------------------- # Document symbols (outline) # --------------------------------------------------------------------------- proc symbolKindLsp(kind: string): int = case kind of "function": 12 of "method": 6 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) proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = ## workspace/symbol — fuzzy-ish substring filter over workspace + open docs. let query = if paramsNode.hasKey("query"): paramsNode["query"].getStr().toLowerAscii() else: "" var arr = newJArray() var seen = initHashSet[string]() # name@uri proc maybeAdd(name, uri: string, info: SymbolInfo) = if query.len > 0 and query notin name.toLowerAscii() and query notin info.detail.toLowerAscii() and query notin info.kind.toLowerAscii(): return let key = name & "@" & uri if seen.contains(key): return seen.incl(key) var item = %*{ "name": name, "kind": symbolKindLsp(info.kind), "location": { "uri": uri, "range": { "start": {"line": info.line, "character": info.col}, "end": {"line": info.line, "character": info.col + name.len} } } } if info.detail.len > 0: item["containerName"] = %info.kind arr.add(item) # Open documents first (freshest) for uri, doc in documents.pairs: ensureAnalyzed(doc) for name, info in doc.symbols.pairs: maybeAdd(name, uri, info) # Workspace index from scan for name, ws in workspaceSymbols.pairs: maybeAdd(name, ws.uri, ws.info) # Cap result size for IDE responsiveness if arr.len > 200: var capped = newJArray() var i = 0 while i < 200: capped.add(arr[i]) inc i arr = capped sendResponse(stream, id, arr) # --------------------------------------------------------------------------- # Call hierarchy (v0.8 + v0.9 methods) — textual call graph # --------------------------------------------------------------------------- type FuncSym = object name: string uri: string info: SymbolInfo CallSite = object callee: string line: int col: int ## Enclosing function/method name ("" if top-level / unknown) caller: string callerUri: string isMethodCall: bool ## true if site was `.Name(` (receiver call) proc listFunctionSymbols(doc: DocumentState): seq[tuple[name: string, info: SymbolInfo]] = result = @[] ensureAnalyzed(doc) for name, info in doc.symbols.pairs: if isCallableKind(info.kind): result.add((name, info)) proc allKnownFuncs(): Table[string, FuncSym] = ## name → first seen FuncSym (workspace + open docs); methods included 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 isCallableKind(ws.info.kind) 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/method whose decl line ≤ line (same file). result = "" var best = -1 for name, info in doc.symbols.pairs: if not isCallableKind(info.kind): continue if info.line <= line and info.line >= best: best = info.line result = name 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/method 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 let caller = enclosingFuncName(doc, h.line) result.add(CallSite( callee: name, line: h.line, col: h.col, caller: caller, callerUri: doc.uri, isMethodCall: h.access == iaDot)) 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 # SymbolKind: Method=6, Function=12 let sk = if fs.info.kind == "method": 6 else: 12 var name = fs.name if fs.info.kind == "method" and fs.info.container.len > 0: name = fs.info.container & "." & fs.name %*{ "name": name, "kind": sk, "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} }, "data": fs.name # bare name for graph matching } proc lookupFuncSym(name, uri: string): FuncSym = ## Resolve by bare name (Area) or qualified display (Rectangle.Area). var bare = name let dot = name.rfind('.') if dot >= 0: bare = name[dot + 1 .. ^1] result = FuncSym(name: bare, uri: uri) if documents.hasKey(uri): let doc = documents[uri] ensureAnalyzed(doc) if doc.symbols.hasKey(bare) and isCallableKind(doc.symbols[bare].kind): result.info = doc.symbols[bare] return if workspaceSymbols.hasKey(bare) and isCallableKind(workspaceSymbols[bare].info.kind): result.uri = workspaceSymbols[bare].uri result.info = workspaceSymbols[bare].info return let all = allKnownFuncs() if all.hasKey(bare): return all[bare] proc findIfaceMethodAt(doc: DocumentState, word: string, line, col: int): tuple[ok: bool, m: MemberInfo] = result.ok = false for m in doc.ifaceMethods: if m.name != word: continue if m.line == line and col >= m.col and col <= m.col + word.len: result.ok = true result.m = m return # Only one iface method with this name var n = 0 var last: MemberInfo for m in doc.ifaceMethods: if m.name == word: inc n last = m if n == 1: result.ok = true result.m = last proc ifaceMethodItem(uri: string, m: MemberInfo): JsonNode = %*{ "name": m.parent & "." & m.name, "kind": 11, # SymbolKind.Interface "detail": "interface " & m.parent & "." & m.name, "uri": uri, "range": { "start": {"line": m.line, "character": 0}, "end": {"line": m.line, "character": m.col + m.name.len} }, "selectionRange": { "start": {"line": m.line, "character": m.col}, "end": {"line": m.line, "character": m.col + m.name.len} }, "data": m.parent & "#" & m.name # iface#method for dispatch } proc collectImplementorFuncs(iface, meth: string): seq[FuncSym] = ## Find concrete methods Type.Method where Type implements Interface. result = @[] var seen = initHashSet[string]() for uri, doc in documents.pairs: ensureAnalyzed(doc) for impl in doc.impls: if impl.iface != iface: continue if doc.symbols.hasKey(meth): let info = doc.symbols[meth] if info.kind == "method" and (info.container == impl.typeName or info.container == iface): let key = uri & "#" & impl.typeName & "." & meth if seen.contains(key): continue seen.incl(key) var useInfo = info if info.container == iface: # Symbol table kept iface method; use impl type from relation useInfo = SymbolInfo( line: info.line, col: info.col, kind: "method", detail: impl.typeName & ".func " & meth & " [" & iface & "]", container: impl.typeName) # Prefer implementor container over iface when both present if info.container == impl.typeName: useInfo = info result.add(FuncSym(name: meth, uri: uri, info: useInfo)) for name, info in doc.symbols.pairs: if name != meth: continue if info.kind != "method": continue if info.container == impl.typeName: let key = uri & "#" & impl.typeName & "." & meth if seen.contains(key): continue seen.incl(key) result.add(FuncSym(name: meth, uri: uri, info: info)) # Closed-file index from workspace scan let wkey = iface & "." & meth if workspaceImpls.hasKey(wkey): for impl in workspaceImpls[wkey]: let key = impl.uri & "#" & impl.typeName & "." & meth if seen.contains(key): continue seen.incl(key) if documents.hasKey(impl.uri): let doc = documents[impl.uri] ensureAnalyzed(doc) if doc.symbols.hasKey(meth) and doc.symbols[meth].kind == "method" and doc.symbols[meth].container == impl.typeName: result.add(FuncSym(name: meth, uri: impl.uri, info: doc.symbols[meth])) continue if workspaceSymbols.hasKey(meth) and workspaceSymbols[meth].uri == impl.uri: var info = workspaceSymbols[meth].info info.container = impl.typeName result.add(FuncSym(name: meth, uri: impl.uri, info: info)) else: result.add(FuncSym( name: meth, uri: impl.uri, info: SymbolInfo(line: 0, col: 0, kind: "method", detail: impl.typeName & "." & meth, container: impl.typeName))) proc collectTypeImplementorLocs(iface: string): seq[JsonNode] = ## Locations of types that `extend Type for iface`. result = @[] var seen = initHashSet[string]() for uri, doc in documents.pairs: ensureAnalyzed(doc) for impl in doc.impls: if impl.iface != iface: continue let key = uri & "#" & impl.typeName if seen.contains(key): continue seen.incl(key) if doc.symbols.hasKey(impl.typeName): let info = doc.symbols[impl.typeName] result.add(locationJson(uri, info.line, info.col, impl.typeName.len)) else: # Fall back to the `extend` line result.add(locationJson(uri, impl.line, 0, max(1, impl.typeName.len))) # Derive types from workspaceImpls (Iface.Method → typeName) for wkey, impls in workspaceImpls.pairs: if not wkey.startsWith(iface & "."): continue for impl in impls: let key = impl.uri & "#" & impl.typeName if seen.contains(key): continue seen.incl(key) if documents.hasKey(impl.uri): let doc = documents[impl.uri] ensureAnalyzed(doc) if doc.symbols.hasKey(impl.typeName): let info = doc.symbols[impl.typeName] result.add(locationJson(impl.uri, info.line, info.col, impl.typeName.len)) continue if workspaceSymbols.hasKey(impl.typeName): let ws = workspaceSymbols[impl.typeName] result.add(locationJson(ws.uri, ws.info.line, ws.info.col, impl.typeName.len)) else: result.add(locationJson(impl.uri, 0, 0, max(1, impl.typeName.len))) proc handleImplementation(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = ## textDocument/implementation — go to implementors of interface / iface method. 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 var arr = newJArray() # 1) Interface method under cursor → implementor methods let (iok, im) = findIfaceMethodAt(doc, word, lineNum, col) if iok: for fs in collectImplementorFuncs(im.parent, im.name): arr.add(locationJson(fs.uri, fs.info.line, fs.info.col, fs.name.len)) sendResponse(stream, id, arr) return # 2) Interface type name → implementing types (extend Type for Iface) var isIface = false if doc.symbols.hasKey(word) and doc.symbols[word].kind == "interface": isIface = true elif workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "interface": isIface = true if isIface: for loc in collectTypeImplementorLocs(word): arr.add(loc) sendResponse(stream, id, arr) return # 3) Call site / method name that matches a known iface method # (e.g. cursor on Draw in c.Draw() or on implementor name shared with iface) for m in doc.ifaceMethods: if m.name != word: continue for fs in collectImplementorFuncs(m.parent, m.name): arr.add(locationJson(fs.uri, fs.info.line, fs.info.col, fs.name.len)) if arr.len > 0: sendResponse(stream, id, arr) return # 4) Method registered as implementor of some interface — still list siblings? # Prefer: if word is method on a type that implements I, and I has that method, # return all implementors of I.Method (including self). if doc.symbols.hasKey(word) and doc.symbols[word].kind == "method": let container = doc.symbols[word].container for impl in doc.impls: if impl.typeName != container: continue # This type implements impl.iface; if method is an iface method, list all for m in doc.ifaceMethods: if m.parent == impl.iface and m.name == word: for fs in collectImplementorFuncs(impl.iface, word): arr.add(locationJson(fs.uri, fs.info.line, fs.info.col, fs.name.len)) if arr.len > 0: sendResponse(stream, id, arr) return # workspaceImpls reverse lookup for wkey, impls in workspaceImpls.pairs: if not wkey.endsWith("." & word): continue let iface = wkey[0 ..< wkey.len - word.len - 1] var onType = false for impl in impls: if impl.typeName == container or impl.uri == uri: onType = true break if onType: for fs in collectImplementorFuncs(iface, word): arr.add(locationJson(fs.uri, fs.info.line, fs.info.col, fs.name.len)) if arr.len > 0: sendResponse(stream, id, arr) return sendResponse(stream, id, arr) 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 # Interface abstract method under cursor let (iok, im) = findIfaceMethodAt(doc, word, lineNum, col) if iok: sendResponse(stream, id, %*[ifaceMethodItem(uri, im)]) return # Prefer function/method symbol under cursor if doc.symbols.hasKey(word) and isCallableKind(doc.symbols[word].kind): let fs = FuncSym(name: word, uri: uri, info: doc.symbols[word]) sendResponse(stream, id, %*[callHierarchyItem(fs)]) return if workspaceSymbols.hasKey(word) and isCallableKind(workspaceSymbols[word].info.kind): let ws = workspaceSymbols[word] let fs = FuncSym(name: word, uri: ws.uri, info: ws.info) sendResponse(stream, id, %*[callHierarchyItem(fs)]) return # Call site: Foo( or .Method( 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): # Prefer interface method if this name is an iface method for m in doc.ifaceMethods: if m.name == word: sendResponse(stream, id, %*[ifaceMethodItem(uri, m)]) return let all = allKnownFuncs() if all.hasKey(word): sendResponse(stream, id, %*[callHierarchyItem(all[word])]) return sendResponse(stream, id, %*[]) proc bareCallName(itemName: string): string = ## "Rectangle.Area" → "Area"; "Add" → "Add" let dot = itemName.rfind('.') if dot >= 0: return itemName[dot + 1 .. ^1] # Prefer data field if present (from our CallHierarchyItem) result = itemName proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = ## Who calls this function/method? ## Interface methods: callers of .Method( (dynamic dispatch sites). if not paramsNode.hasKey("item"): sendResponse(stream, id, %*[]) return let item = paramsNode["item"] var name = item["name"].getStr() var data = "" if item.hasKey("data") and item["data"].kind == JString: data = item["data"].getStr() if data.contains("#"): name = data.split('#')[^1] # method name else: name = data else: name = bareCallName(name) 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/method call? ## For interface methods: "outgoing" lists implementor methods (dispatch targets). if not paramsNode.hasKey("item"): sendResponse(stream, id, %*[]) return let item = paramsNode["item"] var name = item["name"].getStr() var data = "" if item.hasKey("data") and item["data"].kind == JString: data = item["data"].getStr() name = data else: name = bareCallName(name) # Interface dispatch: data = "Iface#Method" if data.contains("#"): let parts = data.split('#') if parts.len == 2: let iface = parts[0] let meth = parts[1] let impls = collectImplementorFuncs(iface, meth) var arr = newJArray() for fs in impls: arr.add(%*{ "to": callHierarchyItem(fs), "fromRanges": [%*{ "start": {"line": fs.info.line, "character": fs.info.col}, "end": {"line": fs.info.line, "character": fs.info.col + meth.len} }] }) sendResponse(stream, id, arr) return let bare = bareCallName(name) let uri = if item.hasKey("uri"): item["uri"].getStr() else: "" let sites = collectAllCallSites() # Group by callee among sites whose caller is bare name var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]() for s in sites: if s.caller != bare: continue if uri.len > 0 and s.callerUri != uri: continue let key = s.callee if not groups.hasKey(key): let all = allKnownFuncs() let fs2 = if all.hasKey(s.callee): all[s.callee] else: lookupFuncSym(s.callee, s.callerUri) 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 # --------------------------------------------------------------------------- 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, "documentSymbolProvider": true, "referencesProvider": true, "renameProvider": {"prepareProvider": true}, "workspaceSymbolProvider": true, "callHierarchyProvider": true, "implementationProvider": true }, "serverInfo": {"name": "bux-lsp", "version": "0.13.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) # Preload stdlib for hover types if rootPath.len > 0: ensureStdlibCached() of "initialized": if rootPath.len > 0: scanWorkspace(rootPath) ensureStdlibCached() 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() # Lightweight scan + sema enrich + buxc diagnostics 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() # Fast path: lightweight symbols only; keep previous typeIndex until save/hover refresh let updated = analyzeFile(uriToPath(uri), doc.content) doc.symbols = updated.symbols doc.ordered = updated.ordered doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls doc.importPaths = updated.importPaths # Re-apply typeIndex details onto matching names (don't drop sema types mid-edit) for name, detail in doc.typeIndex.pairs: if doc.symbols.hasKey(name): var info = doc.symbols[name] info.detail = detail info.fromSema = true if doc.kindIndex.hasKey(name): info.kind = doc.kindIndex[name] doc.symbols[name] = info of "textDocument/didSave": let td = paramsNode["textDocument"] let uri = td["uri"].getStr() discard getDoc(uri) analyzeAndPublishDiagnostics(stream, getDoc(uri)) of "textDocument/completion": handleCompletion(stream, id, paramsNode) of "textDocument/definition": handleDefinition(stream, id, paramsNode) of "textDocument/hover": handleHover(stream, id, paramsNode) of "textDocument/documentSymbol": handleDocumentSymbol(stream, id, paramsNode) of "textDocument/references": handleReferences(stream, id, paramsNode) of "textDocument/prepareRename": handlePrepareRename(stream, id, paramsNode) of "textDocument/rename": handleRename(stream, id, paramsNode) 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) of "textDocument/implementation": handleImplementation(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()