From bc98d61b646fe1d9f0a497d57a31d86864aae4de Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Mon, 26 Oct 2026 19:14:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(lsp):=20=E8=87=AA=E5=8A=A8=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E9=A1=B9=E7=9B=AE=E6=A0=B9=E5=B9=B6=E4=B8=BA=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=8A=A0=20readonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作区根无 project.vpj 时从首个打开文件向上采纳项目并重建索引;全大写/define 常量输出 variable+readonly,配合插件的 semanticTokenScopes 使用常量色。 --- App/lib/lsp/LspSemantic.vp | 25 ++++++- App/lib/lsp/LspServer.vp | 9 ++- App/lib/lsp/LspWorkspace.vp | 80 ++++++++++++++++------- Tools/lsp/probe.py | 127 ++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 30 deletions(-) diff --git a/App/lib/lsp/LspSemantic.vp b/App/lib/lsp/LspSemantic.vp index 2ef4c3c..747a0a1 100644 --- a/App/lib/lsp/LspSemantic.vp +++ b/App/lib/lsp/LspSemantic.vp @@ -36,6 +36,7 @@ class LspSemToken: el: int ec: int typ: int + mods: int def __new__(self, pool: memhub.MemManager*) -> rawptr: p: LspSemToken* = (LspSemToken*)(pool.alloc(LspSemToken.__sizeof__())) @@ -54,6 +55,7 @@ def _NewTok(pool: memhub.MemManager*, sl: int, sc: int, el: int, ec: int, t.el = el t.ec = ec t.typ = typ + t.mods = 0 return t @@ -78,6 +80,7 @@ def Provider(pool: memhub.MemManager*) -> json.JsonValue*: json.array_append(pool, types, json.string_val(pool, "operator")) json.array_append(pool, types, json.string_val(pool, "decorator")) json.array_append(pool, mods, json.string_val(pool, "declaration")) + json.array_append(pool, mods, json.string_val(pool, "readonly")) json.object_set(pool, legend, "tokenTypes", types) json.object_set(pool, legend, "tokenModifiers", mods) json.object_set(pool, provider, "legend", legend) @@ -104,18 +107,34 @@ def _TypeFor(s: LspSymbols.LspSymbol*) -> int: return ST_VARIABLE +def _ModsFor(s: LspSymbols.LspSymbol*) -> int: + """readonly = bit 1 in the legend; constants use the theme's constant color (as Vipeye does).""" + if s is None: + return 0 + if s.kind == LspSymbols.LSP_KIND_CONSTANT: + return 2 + return 0 + + def _TokenForSymbol(pool: memhub.MemManager*, s: LspSymbols.LspSymbol*) -> LspSemToken*: if s is None or s.sel is None: return None - return _NewTok(pool, s.sel.sl, s.sel.sc, s.sel.el, s.sel.ec, _TypeFor(s)) + t: LspSemToken* = _NewTok(pool, s.sel.sl, s.sel.sc, s.sel.el, s.sel.ec, + _TypeFor(s)) + if t is not None: + t.mods = _ModsFor(s) + return t def _TokenForOcc(pool: memhub.MemManager*, o: LspSymbols.LspOccurrence*) -> LspSemToken*: if o is None or o.sym is None: return None - return _NewTok(pool, o.line, o.sc, o.line, o.ec, _TypeFor(o.sym)) + t: LspSemToken* = _NewTok(pool, o.line, o.sc, o.line, o.ec, _TypeFor(o.sym)) + if t is not None: + t.mods = _ModsFor(o.sym) + return t def _Before(a: LspSemToken*, b: LspSemToken*) -> int: @@ -332,7 +351,7 @@ def _Encode(pool: memhub.MemManager*, out: json.JsonValue*, json.array_append(pool, out, json.int_val(pool, int64(dc))) json.array_append(pool, out, json.int_val(pool, int64(length))) json.array_append(pool, out, json.int_val(pool, int64(t.typ))) - json.array_append(pool, out, json.int_val(pool, 0)) + json.array_append(pool, out, json.int_val(pool, int64(t.mods))) prev_line = t.sl prev_char = t.sc last = t diff --git a/App/lib/lsp/LspServer.vp b/App/lib/lsp/LspServer.vp index 2792fb1..4908621 100644 --- a/App/lib/lsp/LspServer.vp +++ b/App/lib/lsp/LspServer.vp @@ -107,7 +107,8 @@ def _HandleInitialize(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, def _HandleDidOpen(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, - docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int: + docs: LspDocuments.LspDocuments*, + ws: LspWorkspace.LspWorkspace*, params: json.JsonValue*) -> int: if params is None: return -1 td: json.JsonValue* = params["textDocument"] @@ -117,6 +118,10 @@ def _HandleDidOpen(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, if uri is None or text is None: LspTransport.StderrWrite("viperc-lsp: didOpen missing uri or text\n") return -1 + file: str = LspWorkspace.DecodeUri(pool, uri) + if file is not None and ws is not None: + if ws.AdoptFromFile(file) != 0: + LspIndex.Invalidate() if docs.Open(uri, text, version) is None: LspTransport.StderrWrite("viperc-lsp: didOpen failed to store document\n") return -1 @@ -392,7 +397,7 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: elif string.strcmp(method, LspProtocol.LSP_METHOD_INITIALIZED) == 0: pass elif string.strcmp(method, "textDocument/didOpen") == 0: - _HandleDidOpen(pool, tr, docs, params) + _HandleDidOpen(pool, tr, docs, ws, params) LspIndex.Invalidate() elif string.strcmp(method, "textDocument/didChange") == 0: _HandleDidChange(pool, tr, docs, params) diff --git a/App/lib/lsp/LspWorkspace.vp b/App/lib/lsp/LspWorkspace.vp index 0547313..d30f3a9 100644 --- a/App/lib/lsp/LspWorkspace.vp +++ b/App/lib/lsp/LspWorkspace.vp @@ -6,6 +6,7 @@ import os._posix import os.path import memhub import lib.lsp.LspPools as LspPools +import lib.lsp.LspTransport as LspTransport import lib.Projectrans.Config as Config @@ -137,6 +138,32 @@ class LspWorkspace: i += 1 return None + def _ApplyProject(self, project: str) -> int: + self.project = project + old_pool: memhub.MemBuddy* = Config._mbuddy + Config._mbuddy = self.pool + rc: int = Config.Load_project_config(project) + if rc == 0: + Config.resolve_paths(os.path.dirname(project)) + self.source_dir = Config.SourceDir + self.includes = Config.IncludesDirs + Config._mbuddy = old_pool + return rc + + def _FillFallbacks(self, base: str): + if self.source_dir is None or self.source_dir[0] == 0: + self.source_dir = base + if self.includes is None or self.includes[0] == 0: + sibling: str = os.path.join(base, "includes") + if os.isdir(sibling): + self.includes = sibling + else: + parent_sibling: str = os.path.join(base, "../includes") + if os.isdir(parent_sibling): + self.includes = parent_sibling + else: + self.includes = "" + def Init(self, root_uri: str) -> int: path: str = DecodeUri(self.pool, root_uri) if path is None or path[0] == 0: @@ -147,34 +174,37 @@ class LspWorkspace: if path is None: return -1 self.root = path - self.project = self.FindProject(path) - - if self.project is not None: - old_pool: memhub.MemBuddy* = Config._mbuddy - Config._mbuddy = self.pool - rc: int = Config.Load_project_config(self.project) - if rc == 0: - Config.resolve_paths(os.path.dirname(self.project)) - self.source_dir = Config.SourceDir - self.includes = Config.IncludesDirs - Config._mbuddy = old_pool - - if self.source_dir is None or self.source_dir[0] == 0: - self.source_dir = self.root - - if self.includes is None or self.includes[0] == 0: - sibling: str = os.path.join(self.root, "includes") - if os.isdir(sibling): - self.includes = sibling - else: - parent_sibling: str = os.path.join(self.root, "../includes") - if os.isdir(parent_sibling): - self.includes = parent_sibling - else: - self.includes = "" + project: str = self.FindProject(path) + if project is not None: + self._ApplyProject(project) + self._FillFallbacks(self.root) self.ready = 1 return 0 + def AdoptFromFile(self, file_path: str) -> int: + """Adopt the nearest project.vpj above an opened file. + + A client may open the repository root as the workspace while the project file lives in a + subdirectory (e.g. /ViperC/project.vpj). The first opened source file then provides + an unambiguous root; returns 1 when a project was adopted, 0 otherwise. + """ + if file_path is None or file_path[0] == 0: + return 0 + if self.project is not None: + return 0 + directory: str = os.path.dirname(file_path) + project: str = self.FindProject(directory) + if project is None: + return 0 + if self._ApplyProject(project) != 0: + self.project = None + return 0 + self._FillFallbacks(os.path.dirname(project)) + LspTransport.StderrWrite("viperc-lsp: adopted project ") + LspTransport.StderrWrite(project) + LspTransport.StderrWrite("\n") + return 1 + def Root(self) -> str: return self.root diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index a379a3f..b4e7d57 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -804,6 +804,17 @@ def _find_token(tokens: list[dict[str, int]], line: int, character: int, ) +def _find_token_mods(tokens: list[dict[str, int]], line: int, character: int, + token_type: int, mod_bit: int) -> bool: + return any( + t["line"] == line + and t["character"] == character + and t["type"] == token_type + and (t["modifiers"] & mod_bit) != 0 + for t in tokens + ) + + def gate5(exe: str) -> int: root = Path(__file__).resolve().parents[2] source = root / "Tools" / "lsp" / "fixtures" / "gate5.vp" @@ -827,6 +838,8 @@ def gate5(exe: str) -> int: for required in ("class", "function", "parameter", "keyword", "comment", "string", "number"): if required not in types: raise AssertionError(f"semantic legend misses {required}: {legend}") + if "readonly" not in legend.get("tokenModifiers", []): + raise AssertionError(f"semantic legend misses the readonly modifier: {legend}") if capabilities.get("codeActionProvider") is not True: raise AssertionError(f"missing codeAction capability: {response}") @@ -899,11 +912,17 @@ def gate5(exe: str) -> int: define_pos = _position(text, "APP_NAME") if not _find_token(tokens, define_pos["line"], define_pos["character"], 5): raise AssertionError(f"define name token is misplaced: {define_pos} {tokens}") + if not _find_token_mods(tokens, define_pos["line"], define_pos["character"], 5, 2): + raise AssertionError(f"define constant is not readonly: {tokens}") for token in tokens: if token["line"] == define_pos["line"] and token["character"] == 0: if token["length"] == len("APP_NAME"): raise AssertionError(f"define name token is at column 0: {token}") + value_pos = _position(text, "VALUE: int") + if not _find_token_mods(tokens, value_pos["line"], value_pos["character"], 5, 2): + raise AssertionError(f"uppercase constant is not readonly: {tokens}") + extern_pos = _position(text, "add(a: int, b: int)") if not _find_token(tokens, extern_pos["line"], extern_pos["character"], 2): raise AssertionError(f"extern name token is misplaced: {extern_pos} {tokens}") @@ -972,6 +991,111 @@ def gate5(exe: str) -> int: return 0 +def _tree_state(path: Path) -> tuple | None: + if not path.exists(): + return None + items = [] + for candidate in sorted(path.rglob("*")): + if candidate.is_file(): + stat = candidate.stat() + items.append( + ( + str(candidate.relative_to(path)).replace("\\", "/"), + stat.st_size, + stat.st_mtime_ns, + ) + ) + return tuple(items) + + +def gate7(exe: str) -> int: + root = Path(__file__).resolve().parents[2] + workspace = root.parent + source = root / "Tools" / "lsp" / "fixtures" / "gate3.vp" + uri = source.as_uri() + text = source.read_text(encoding="utf-8") + output_before = _tree_state(root / "output") + temp_before = _tree_state(root / "temp") + + client = Client(exe) + response = client.request( + "initialize", + {"processId": os.getpid(), "rootUri": workspace.as_uri(), "capabilities": {}}, + timeout=15.0, + ) + if not isinstance(response.get("result"), dict): + raise AssertionError(f"initialize failed from the repository root: {response}") + client.notify("initialized", {}) + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": uri, "languageId": "viper", "version": 1, "text": text}}, + ) + client.wait_notification("textDocument/publishDiagnostics") + + stats = client.request("viper/lspStats").get("result") + if not isinstance(stats, dict): + raise AssertionError(f"lspStats failed after project adoption: {stats}") + project = str(stats.get("project") or "").replace("\\", "/") + source_dir = str(stats.get("sourceDir") or "").replace("\\", "/") + includes = str(stats.get("includes") or "").replace("\\", "/") + if not project.endswith("ViperC/project.vpj"): + raise AssertionError(f"the subproject was not adopted: {stats}") + if not source_dir.endswith("ViperC/App"): + raise AssertionError(f"source_dir is wrong after adoption: {stats}") + if "includes" not in includes: + raise AssertionError(f"includes is wrong after adoption: {stats}") + + symbols = client.request( + "workspace/symbol", {"query": "LspDocuments"}, timeout=60.0 + ).get("result") + if not isinstance(symbols, list) or len(symbols) == 0: + raise AssertionError(f"workspace/symbol failed after adoption: {symbols}") + if not any( + item["location"]["uri"].endswith("LspDocuments.vp") and item["kind"] == 5 + for item in symbols + ): + raise AssertionError(f"workspace/symbol missed the LspDocuments class: {symbols}") + + pos = _position(text, "helper_func", 1) + definition = client.request( + "textDocument/definition", {"textDocument": {"uri": uri}, "position": pos} + ).get("result") + if not isinstance(definition, list) or not definition: + raise AssertionError(f"definition failed after adoption: {definition}") + if not definition[0]["uri"].endswith("gate3_helper.vp"): + raise AssertionError(f"definition target is wrong after adoption: {definition}") + + client.notify("textDocument/didClose", {"textDocument": {"uri": uri}}) + client.wait_notification("textDocument/publishDiagnostics") + client.request("shutdown") + code = client.close() + if code != 0: + raise AssertionError(f"exit code is {code}, expected 0") + + output_after = _tree_state(root / "output") + temp_after = _tree_state(root / "temp") + if output_before != output_after: + raise AssertionError("--lsp modified ViperC/output") + if temp_before != temp_after: + raise AssertionError("--lsp modified ViperC/temp") + + mixed = subprocess.run( + [exe, "--lsp", "--project", str(root / "project.vpj")], + capture_output=True, + timeout=30, + ) + if mixed.returncode == 0: + raise AssertionError("--lsp accepted --project") + stderr = mixed.stderr.decode("utf-8", "replace") + if "standalone" not in stderr and "--lsp" not in stderr: + raise AssertionError(f"--lsp rejection message is unclear: {stderr}") + + print("workspace-root-adoption: OK") + print("product-side-effects: OK") + print("standalone-mode: OK") + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description="ViperC LSP probe") parser.add_argument("--selfcheck", action="store_true", help="run pure-Python framing checks") @@ -980,6 +1104,7 @@ def main() -> int: parser.add_argument("--gate3", action="store_true", help="run symbol/navigation checks") parser.add_argument("--gate4", action="store_true", help="run completion/signature checks") parser.add_argument("--gate5", action="store_true", help="run rename/semantic/code-action checks") + parser.add_argument("--gate7", action="store_true", help="run the product gate checks") parser.add_argument("--exe", help="path to viperc.exe") args = parser.parse_args() @@ -991,6 +1116,8 @@ def main() -> int: if not os.path.isfile(exe): print(f"probe: executable not found: {exe}", file=sys.stderr) return 2 + if args.gate7: + return gate7(exe) if args.gate5: return gate5(exe) if args.gate4: