feat(lsp): Gate 9 语义高亮保真

define 宏函数与调用点使用 macro token;常量保持 variable+readonly;多行字符串按行拆分;修正符号/出现点合并顺序导致整行 token 丢失;大文档诊断 scratch 按源大小扩容。
This commit is contained in:
TermiNexus committed 2026-10-26 21:04:10 +08:00
1 parent e837306159
commit 95a8da86e4
5 files changed
+231 -22

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
define MAX_VALUE = 100
define ADD(a, b): return a + b
def use(x: int) -> int:
s: str = """alpha
beta"""
n: int = 42
# gate9 comment
return ADD(x, MAX_VALUE) + n
class Box:
value: int
def get(self) -> int:
return self.value
+123
View File
@@ -1235,6 +1235,126 @@ def gate8(exe: str) -> int:
return 0
def gate9(exe: str) -> int:
root = Path(__file__).resolve().parents[2]
source = root / "Tools" / "lsp" / "fixtures" / "gate9.vp"
uri = source.as_uri()
text = source.read_text(encoding="utf-8")
lines = text.split("\n")
client = Client(exe)
response = client.request(
"initialize",
{"processId": os.getpid(), "rootUri": root.as_uri(), "capabilities": {}},
)
capabilities = response.get("result", {}).get("capabilities", {})
sem = capabilities.get("semanticTokensProvider")
if not isinstance(sem, dict):
raise AssertionError(f"missing semanticTokensProvider: {response}")
legend = sem.get("legend", {})
types = legend.get("tokenTypes", [])
mods = legend.get("tokenModifiers", [])
for required in ("macro", "variable", "keyword", "string", "number", "comment"):
if required not in types:
raise AssertionError(f"semantic legend misses {required}: {legend}")
if "readonly" not in mods:
raise AssertionError(f"semantic legend misses readonly: {legend}")
macro_idx = types.index("macro")
variable_idx = types.index("variable")
keyword_idx = types.index("keyword")
comment_idx = types.index("comment")
string_idx = types.index("string")
number_idx = types.index("number")
readonly_bit = 1 << mods.index("readonly")
mod_mask = (1 << len(mods)) - 1
client.notify("initialized", {})
client.notify(
"textDocument/didOpen",
{"textDocument": {"uri": uri, "languageId": "viper", "version": 1, "text": text}},
)
client.wait_notification("textDocument/publishDiagnostics")
semantic = client.request(
"textDocument/semanticTokens/full", {"textDocument": {"uri": uri}}
).get("result")
if not isinstance(semantic, dict):
raise AssertionError(f"semanticTokens/full failed: {semantic}")
tokens = _decode_tokens(semantic.get("data", []))
if not tokens:
raise AssertionError("semantic tokens are empty")
last_line = -1
last_end = -1
for token in tokens:
if token["length"] <= 0:
raise AssertionError(f"zero-length token: {token}")
if token["line"] < 0 or token["line"] >= len(lines):
raise AssertionError(f"token line is out of range: {token}")
line_len = len(lines[token["line"]])
if token["character"] < 0 or token["character"] + token["length"] > line_len:
raise AssertionError(f"token range is out of the line: {token}")
if token["type"] < 0 or token["type"] >= len(types):
raise AssertionError(f"token type is outside the legend: {token}")
if (token["modifiers"] & ~mod_mask) != 0:
raise AssertionError(f"token modifiers are outside the legend: {token}")
if token["type"] == keyword_idx:
raise AssertionError(f"keywords must be left to TextMate: {token}")
if token["line"] == last_line:
if token["character"] < last_end:
raise AssertionError(f"overlapping tokens on line {last_line}: {token}")
last_end = token["character"] + token["length"]
else:
last_line = token["line"]
last_end = token["character"] + token["length"]
add_decl = _position(text, "define ADD")
add_decl["character"] += len("define ")
if not _find_token(tokens, add_decl["line"], add_decl["character"], macro_idx):
raise AssertionError(f"define macro declaration is not a macro token: {add_decl}")
add_call = _position(text, "ADD(x")
if not _find_token(tokens, add_call["line"], add_call["character"], macro_idx):
raise AssertionError(f"define macro call is not a macro token: {add_call}")
max_decl = _position(text, "MAX_VALUE")
if not _find_token_mods(tokens, max_decl["line"], max_decl["character"],
variable_idx, readonly_bit):
raise AssertionError(f"define constant is not readonly: {max_decl}")
max_call = _position(text, "MAX_VALUE", 1)
if not _find_token_mods(tokens, max_call["line"], max_call["character"],
variable_idx, readonly_bit):
raise AssertionError(f"define constant use is not readonly: {max_call}")
for keyword in ("define", "def", "class", "return"):
pos = _position(text, keyword)
if _find_token(tokens, pos["line"], pos["character"], keyword_idx):
raise AssertionError(f"keyword {keyword} must not be a semantic token")
comment_pos = _position(text, "# gate9 comment")
if not _find_token(tokens, comment_pos["line"], comment_pos["character"], comment_idx):
raise AssertionError(f"missing comment token: {comment_pos}")
first_string = _position(text, '"""alpha')
if not _find_token(tokens, first_string["line"], first_string["character"], string_idx):
raise AssertionError(f"missing first line of the multi-line string: {first_string}")
second_string = _position(text, "beta")
if not _find_token(tokens, second_string["line"], second_string["character"], string_idx):
raise AssertionError(f"missing second line of the multi-line string: {second_string}")
number_pos = _position(text, "42")
if not _find_token(tokens, number_pos["line"], number_pos["character"], number_idx):
raise AssertionError(f"missing number token: {number_pos}")
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")
print("semantic-legend: OK")
print("semantic-audit: OK")
print("semantic-macro: 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")
@@ -1245,6 +1365,7 @@ def main() -> int:
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("--gate8", action="store_true", help="run cross-file references/rename checks")
parser.add_argument("--gate9", action="store_true", help="run semantic highlighting audits")
parser.add_argument("--exe", help="path to viperc.exe")
args = parser.parse_args()
@@ -1256,6 +1377,8 @@ def main() -> int:
if not os.path.isfile(exe):
print(f"probe: executable not found: {exe}", file=sys.stderr)
return 2
if args.gate9:
return gate9(exe)
if args.gate8:
return gate8(exe)
if args.gate7: