feat(lsp): 自动识别项目根并为常量加 readonly
工作区根无 project.vpj 时从首个打开文件向上采纳项目并重建索引;全大写/define 常量输出 variable+readonly,配合插件的 semanticTokenScopes 使用常量色。
This commit is contained in:
1 parent
1183f92344
commit
bc98d61b64
4 files changed
+211
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user