feat(lsp): 自动识别项目根并为常量加 readonly

工作区根无 project.vpj 时从首个打开文件向上采纳项目并重建索引;全大写/define 常量输出 variable+readonly,配合插件的 semanticTokenScopes 使用常量色。
This commit is contained in:
TermiNexus committed 2026-10-26 19:14:33 +08:00
1 parent 1183f92344
commit bc98d61b64
4 files changed
+211 -30

No files matched your search

+22 -3
View File
@@ -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
+7 -2
View File
@@ -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)
+55 -25
View File
@@ -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. <repo>/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
+127
View File
@@ -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: