From 1f20c683a1e55ec3be0f3468a76f3d8270dc7215 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Mon, 20 Jul 2026 00:24:38 +0300 Subject: [PATCH] feat(lsp): type hierarchy for interfaces and implementors (0.15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepareTypeHierarchy plus supertypes/subtypes via extend Type for Iface. Smoke covers Drawable ↔ Circle/Square. --- Makefile | 3 + docs/QUALITY_PLAN.md | 22 +++- tools/lsp_server.nim | 212 +++++++++++++++++++++++++++++- tools/smoke_lsp_type_hierarchy.sh | 124 +++++++++++++++++ 4 files changed, 354 insertions(+), 7 deletions(-) create mode 100755 tools/smoke_lsp_type_hierarchy.sh diff --git a/Makefile b/Makefile index ca53b56..64986b0 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,9 @@ test-lsp: lsp @echo "==> LSP workspace import index smoke" @chmod +x tools/smoke_lsp_workspace_imports.sh @tools/smoke_lsp_workspace_imports.sh + @echo "==> LSP type hierarchy smoke" + @chmod +x tools/smoke_lsp_type_hierarchy.sh + @tools/smoke_lsp_type_hierarchy.sh .PHONY: test-registry test-registry: build diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 62f8fb6..83e750f 100644 --- a/docs/QUALITY_PLAN.md +++ b/docs/QUALITY_PLAN.md @@ -1,7 +1,7 @@ # Bux — План към „добър“ език (v0.5 → v1.0) > **Дата:** 2026-07-19 -> **Текущо:** v0.5.x — **CI `make test`**, selfhost fixed-point, LSP 0.14 +> **Текущо:** v0.5.x — **LSP 0.15 type hierarchy**, CI `make test`, fixed-point > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. --- @@ -838,9 +838,21 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 54 (LSP 0.15 type hierarchy) + +1. **`typeHierarchyProvider`** + prepare / supertypes / subtypes +2. **prepare** on `struct` / `enum` / `interface` / `type` → TypeHierarchyItem +3. **subtypes** of interface → types with `extend T for I` (Circle, Square) +4. **supertypes** of type → interfaces it implements +5. Uses open-doc `impls` + `workspaceImpls` + symbol resolve +6. Smoke: `tools/smoke_lsp_type_hierarchy.sh` +7. Version **bux-lsp 0.15.0**; `make test-lsp` + +--- + ## Следващи стъпки -1. LSP type hierarchy / prepareTypeHierarchy (optional) -2. Macro / quote hygiene using Expr.sourceFile grafts -3. Parenthesize binary ops in CBE for full C precedence safety -4. CI matrix (macOS) or split jobs for faster PR feedback +1. Macro / quote hygiene using Expr.sourceFile grafts +2. Parenthesize binary ops in CBE for full C precedence safety +3. CI matrix (macOS) or split jobs for faster PR feedback +4. Type hierarchy for multi-file closed docs without open (workspace type index) diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index f84fddf..4922bd0 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -16,6 +16,7 @@ # v0.12.0: module-path segment rename (import Std::Io / Std::Io::{…}). # v0.13.0: textDocument/implementation (interface → types / methods). # v0.14.0: workspace-wide import path index (no open-doc required). +# v0.15.0: type hierarchy (prepare / supertypes / subtypes via extend for). import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -2911,6 +2912,203 @@ proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) }) sendResponse(stream, id, arr) +# --------------------------------------------------------------------------- +# Type hierarchy (v0.15) — interface ↔ implementors via `extend T for I` +# --------------------------------------------------------------------------- + +proc isTypeHierarchyKind(kind: string): bool = + kind in ["struct", "enum", "union", "interface", "type"] + +proc typeHierarchySymbolKind(kind: string): int = + ## LSP SymbolKind + case kind + of "interface": 11 + of "struct", "union": 23 # Struct + of "enum": 10 + of "class": 5 + else: 5 # Class / type alias + +proc resolveTypeSymbol(name: string, preferUri: string = ""): tuple[ok: bool, uri: string, info: SymbolInfo] = + result.ok = false + if preferUri.len > 0 and documents.hasKey(preferUri): + let doc = documents[preferUri] + ensureAnalyzed(doc) + if doc.symbols.hasKey(name) and isTypeHierarchyKind(doc.symbols[name].kind): + result.ok = true + result.uri = preferUri + result.info = doc.symbols[name] + return + if workspaceSymbols.hasKey(name) and isTypeHierarchyKind(workspaceSymbols[name].info.kind): + result.ok = true + result.uri = workspaceSymbols[name].uri + result.info = workspaceSymbols[name].info + return + for uri, doc in documents.pairs: + if uri == preferUri: continue + ensureAnalyzed(doc) + if doc.symbols.hasKey(name) and isTypeHierarchyKind(doc.symbols[name].kind): + result.ok = true + result.uri = uri + result.info = doc.symbols[name] + return + +proc typeHierarchyItem(uri: string, name: string, info: SymbolInfo): JsonNode = + let nlen = name.len + %*{ + "name": name, + "kind": typeHierarchySymbolKind(info.kind), + "detail": info.detail, + "uri": uri, + "range": { + "start": {"line": info.line, "character": 0}, + "end": {"line": info.line, "character": info.col + nlen} + }, + "selectionRange": { + "start": {"line": info.line, "character": info.col}, + "end": {"line": info.line, "character": info.col + nlen} + }, + "data": name + } + +proc typeHierarchyItemSynthetic(uri: string, name: string, kind: string, line: int): JsonNode = + ## When we only know the type from `extend` relation, not a full SymbolInfo. + %*{ + "name": name, + "kind": typeHierarchySymbolKind(kind), + "detail": kind & " " & name, + "uri": uri, + "range": { + "start": {"line": line, "character": 0}, + "end": {"line": line, "character": max(1, name.len)} + }, + "selectionRange": { + "start": {"line": line, "character": 0}, + "end": {"line": line, "character": max(1, name.len)} + }, + "data": name + } + +proc collectSubtypeItems(iface: string): seq[JsonNode] = + ## 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) + let (ok, u, info) = resolveTypeSymbol(impl.typeName, uri) + if ok: + result.add(typeHierarchyItem(u, impl.typeName, info)) + else: + result.add(typeHierarchyItemSynthetic(uri, impl.typeName, "struct", impl.line)) + # workspaceImpls: "Iface.Method" → (uri, typeName, meth) + 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) + let (ok, u, info) = resolveTypeSymbol(impl.typeName, impl.uri) + if ok: + result.add(typeHierarchyItem(u, impl.typeName, info)) + else: + result.add(typeHierarchyItemSynthetic(impl.uri, impl.typeName, "struct", 0)) + +proc collectSupertypeItems(typeName: string): seq[JsonNode] = + ## Interfaces that `typeName` implements via `extend typeName for I`. + result = @[] + var seen = initHashSet[string]() + for uri, doc in documents.pairs: + ensureAnalyzed(doc) + for impl in doc.impls: + if impl.typeName != typeName: continue + if seen.contains(impl.iface): continue + seen.incl(impl.iface) + let (ok, u, info) = resolveTypeSymbol(impl.iface, uri) + if ok: + result.add(typeHierarchyItem(u, impl.iface, info)) + else: + result.add(typeHierarchyItemSynthetic(uri, impl.iface, "interface", impl.line)) + for wkey, impls in workspaceImpls.pairs: + for impl in impls: + if impl.typeName != typeName: continue + # keys are "Iface.Method" + let dot = wkey.find('.') + if dot < 0: continue + let iface = wkey[0 ..< dot] + if seen.contains(iface): continue + seen.incl(iface) + let (ok, u, info) = resolveTypeSymbol(iface, impl.uri) + if ok: + result.add(typeHierarchyItem(u, iface, info)) + else: + result.add(typeHierarchyItemSynthetic(impl.uri, iface, "interface", 0)) + +proc handlePrepareTypeHierarchy(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 symbol under cursor that is a type / interface + if doc.symbols.hasKey(word) and isTypeHierarchyKind(doc.symbols[word].kind): + let info = doc.symbols[word] + # If cursor is on decl name, good; also allow any occurrence of type name + sendResponse(stream, id, %*[typeHierarchyItem(uri, word, info)]) + return + let (ok, u, info) = resolveTypeSymbol(word, uri) + if ok: + sendResponse(stream, id, %*[typeHierarchyItem(u, word, info)]) + return + sendResponse(stream, id, %*[]) + +proc typeHierarchyItemName(item: JsonNode): string = + if item.hasKey("data") and item["data"].kind == JString: + let d = item["data"].getStr() + if d.len > 0: return d + if item.hasKey("name"): + return item["name"].getStr() + "" + +proc handleTypeHierarchySupertypes(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + ## Interfaces implemented by this type (`extend Type for Iface`). + if not paramsNode.hasKey("item"): + sendResponse(stream, id, %*[]) + return + let name = typeHierarchyItemName(paramsNode["item"]) + if name.len == 0: + sendResponse(stream, id, %*[]) + return + var arr = newJArray() + for it in collectSupertypeItems(name): + arr.add(it) + sendResponse(stream, id, arr) + +proc handleTypeHierarchySubtypes(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + ## Implementors of an interface (`extend Type for Iface`). + if not paramsNode.hasKey("item"): + sendResponse(stream, id, %*[]) + return + let name = typeHierarchyItemName(paramsNode["item"]) + if name.len == 0: + sendResponse(stream, id, %*[]) + return + var arr = newJArray() + for it in collectSubtypeItems(name): + arr.add(it) + sendResponse(stream, id, arr) + # --------------------------------------------------------------------------- # Main message loop # --------------------------------------------------------------------------- @@ -2936,9 +3134,10 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "renameProvider": {"prepareProvider": true}, "workspaceSymbolProvider": true, "callHierarchyProvider": true, - "implementationProvider": true + "implementationProvider": true, + "typeHierarchyProvider": true }, - "serverInfo": {"name": "bux-lsp", "version": "0.14.0"} + "serverInfo": {"name": "bux-lsp", "version": "0.15.0"} }) if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: rootPath = paramsNode["rootPath"].getStr() @@ -3041,6 +3240,15 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = of "textDocument/implementation": handleImplementation(stream, id, paramsNode) + of "textDocument/prepareTypeHierarchy": + handlePrepareTypeHierarchy(stream, id, paramsNode) + + of "typeHierarchy/supertypes": + handleTypeHierarchySupertypes(stream, id, paramsNode) + + of "typeHierarchy/subtypes": + handleTypeHierarchySubtypes(stream, id, paramsNode) + else: if id != nil: sendError(stream, id, -32601, "method not found: " & methodName) diff --git a/tools/smoke_lsp_type_hierarchy.sh b/tools/smoke_lsp_type_hierarchy.sh new file mode 100755 index 0000000..67dceea --- /dev/null +++ b/tools/smoke_lsp_type_hierarchy.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Smoke: type hierarchy prepare / subtypes / supertypes (bux-lsp 0.15) +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' +interface Drawable { + func Draw(self: &Self); +} +struct Circle { + radius: int; +} +struct Square { + side: int; +} +extend Circle for Drawable { + func Draw(self: &Circle) { + let r: int = self.radius; + } +} +extend Square for Drawable { + func Draw(self: &Square) { + let s: int = self.side; + } +} +func Main() -> int { + return 0; +} +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" + +# Drawable ~ line 0 col 10; Circle ~ line 3 col 7 +{ + 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"'}}}' + # prepare on interface Drawable + rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareTypeHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":10}}}' + # subtypes of Drawable + rpc '{"jsonrpc":"2.0","id":3,"method":"typeHierarchy/subtypes","params":{"item":{"name":"Drawable","kind":11,"uri":"'"$URI"'","data":"Drawable","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":18}},"selectionRange":{"start":{"line":0,"character":10},"end":{"line":0,"character":18}}}}}' + # prepare on Circle + rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/prepareTypeHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":3,"character":7}}}' + # supertypes of Circle → Drawable + rpc '{"jsonrpc":"2.0","id":5,"method":"typeHierarchy/supertypes","params":{"item":{"name":"Circle","kind":23,"uri":"'"$URI"'","data":"Circle","range":{"start":{"line":3,"character":0},"end":{"line":3,"character":13}},"selectionRange":{"start":{"line":3,"character":7},"end":{"line":3,"character":13}}}}}' + rpc '{"jsonrpc":"2.0","id":6,"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 '0.15.0' "$TMP/out.txt"; then + echo "WARN: version not 0.15.0" +fi + +if ! grep -q 'typeHierarchyProvider' "$TMP/out.txt"; then + echo "FAIL: missing typeHierarchyProvider capability" + cat "$TMP/out.txt" + exit 1 +fi + +python3 - <<'PY' "$TMP/out.txt" +import json, sys, re +raw = open(sys.argv[1]).read() +parts = re.split(r'Content-Length:\s*\d+\s*', raw) +got = {} +for p in parts: + p = p.strip() + if not p.startswith('{'): + continue + try: + j = json.loads(p) + except Exception: + continue + if 'id' in j and 'result' in j: + got[j['id']] = j['result'] + +# id 2: prepare Drawable +r2 = got.get(2) or [] +if not any(isinstance(x, dict) and x.get('name') == 'Drawable' for x in r2): + print('FAIL: prepare on Drawable missing item') + print(got.get(2)) + sys.exit(1) +print(' prepare Drawable: OK') + +# id 3: subtypes ≥2 (Circle, Square) +r3 = got.get(3) or [] +names = {x.get('name') for x in r3 if isinstance(x, dict)} +if 'Circle' not in names or 'Square' not in names: + print(f'FAIL: subtypes of Drawable expected Circle+Square, got {names}') + print(json.dumps(r3, indent=2)[:600]) + sys.exit(1) +print(f' subtypes Drawable → {sorted(names)}') + +# id 4: prepare Circle +r4 = got.get(4) or [] +if not any(isinstance(x, dict) and x.get('name') == 'Circle' for x in r4): + print('FAIL: prepare on Circle missing item') + sys.exit(1) +print(' prepare Circle: OK') + +# id 5: supertypes → Drawable +r5 = got.get(5) or [] +snames = {x.get('name') for x in r5 if isinstance(x, dict)} +if 'Drawable' not in snames: + print(f'FAIL: supertypes of Circle expected Drawable, got {snames}') + sys.exit(1) +print(f' supertypes Circle → {sorted(snames)}') + +print('PASS: LSP type hierarchy (0.15)') +PY