feat(lsp): 发布解析诊断

didOpen/didChange/didSave 推送 publishDiagnostics,didClose 清空;诊断解析使用按次重置的 scratch 池。
This commit is contained in:
TermiNexus committed 2026-10-26 14:55:23 +08:00
1 parent 93cd0f1ea5
commit dee324ed06
5 files changed
+240 -14

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
from stdint import *
import string
import json
import ast
import memhub
import lib.lsp.LspDocuments as LspDocuments
import lib.lsp.LspPools as LspPools
import lib.lsp.LspTransport as LspTransport
import lib.lsp.LspRpc as LspRpc
def _AddDiagnostic(pool: memhub.MemManager*, arr: json.JsonValue*,
doc: LspDocuments.LspDocument*, e: ast.ParseError*):
if e is None:
return
line: int = e.line
if line < 1:
line = 1
start_line: int = line - 1
col: int = e.col
if col < 0:
col = 0
start_char: int = 0
end_line: int = e.end_line
if end_line < line:
end_line = line
end_line0: int = end_line - 1
end_char: int = start_char + 1
count: int = 0
if doc.index is not None:
count = doc.index.Count()
if count <= 0:
# An empty document has no line table to map byte columns through; report line 0 only.
start_line = 0
end_line0 = 0
else:
if start_line >= count:
start_line = count - 1
start_char = doc.index.ByteColToUtf16(start_line, col)
if end_line0 >= count:
end_line0 = count - 1
end_char = doc.index.ByteColToUtf16(end_line0, e.end_col)
if end_line0 == start_line and end_char <= start_char:
end_char = start_char + 1
if end_line0 < start_line:
end_line0 = start_line
end_char = start_char + 1
rng: json.JsonValue* = json.object(pool)
start: json.JsonValue* = json.object(pool)
end: json.JsonValue* = json.object(pool)
if rng is None or start is None or end is None:
return
json.object_set(pool, start, "line", json.int_val(pool, int64(start_line)))
json.object_set(pool, start, "character", json.int_val(pool, int64(start_char)))
json.object_set(pool, end, "line", json.int_val(pool, int64(end_line0)))
json.object_set(pool, end, "character", json.int_val(pool, int64(end_char)))
json.object_set(pool, rng, "start", start)
json.object_set(pool, rng, "end", end)
diag: json.JsonValue* = json.object(pool)
if diag is None:
return
json.object_set(pool, diag, "range", rng)
json.object_set(pool, diag, "severity", json.int_val(pool, 1))
json.object_set(pool, diag, "source", json.string_val(pool, "viperc"))
msg: str = e.message
if msg is None:
msg = "syntax error"
json.object_set(pool, diag, "message", json.string_val(pool, msg))
json.array_append(pool, arr, diag)
def _Send(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
uri: str, doc: LspDocuments.LspDocument*,
errors: list[ast.ParseError*]*) -> int:
params: json.JsonValue* = json.object(pool)
if params is None:
return -1
json.object_set(pool, params, "uri", json.string_val(pool, uri))
diags: json.JsonValue* = json.array(pool)
if diags is None:
return -1
if errors is not None:
n: int = int(len(errors))
i: int = 0
while i < n:
_AddDiagnostic(pool, diags, doc, errors.get(sizet(i)))
i += 1
json.object_set(pool, params, "diagnostics", diags)
return LspRpc.WriteNotification(pool, tr, "textDocument/publishDiagnostics", params)
def Publish(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
docs: LspDocuments.LspDocuments*, uri: str) -> int:
doc: LspDocuments.LspDocument* = docs.Find(uri)
if doc is None:
return 0
scratch: LspPools.LspPool* = docs.Scratch()
if scratch is None:
LspTransport.StderrWrite("viperc-lsp: diagnostics scratch pool is not available\n")
return -1
if scratch.Reset() < 0:
LspTransport.StderrWrite("viperc-lsp: diagnostics scratch pool reset failed\n")
return -1
# The parse tables are process-lifetime global state, so they are created in the documents'
# permanent pool; the message pool passed to this handler is reset after every request.
ast.parse_ex(doc.text, scratch.mb, docs.pool)
errors: list[ast.ParseError*]* = ast.get_parse_errors()
return _Send(pool, tr, uri, doc, errors)
def PublishEmpty(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
uri: str) -> int:
return _Send(pool, tr, uri, None, None)
+9
View File
@@ -6,6 +6,10 @@ import lib.lsp.LspPositions as LspPositions
define LSP_DOC_POOL_SIZE = 4194304
# Parse scratch arena shared by diagnostics: reset before each publish so ASTs and token lists do
# not accumulate across edits. It lives in the per-documents object, whose owner is the permanent
# pool, because the message pool is reset after every request.
define LSP_SCRATCH_POOL_SIZE = 4194304
class LspDocument:
@@ -21,6 +25,7 @@ class LspDocuments:
pool: memhub.MemManager*
docs: list[LspDocument*]*
count: int
scratch: LspPools.LspPool*
def __new__(self, pool: memhub.MemManager*) -> rawptr:
p: LspDocuments* = (LspDocuments*)(pool.alloc(LspDocuments.__sizeof__()))
@@ -32,6 +37,10 @@ class LspDocuments:
self.pool = pool
self.docs = list[LspDocument*](pool, 8)
self.count = 0
self.scratch = LspPools.Create(pool, sizet(LSP_SCRATCH_POOL_SIZE))
def Scratch(self) -> LspPools.LspPool*:
return self.scratch
def Find(self, uri: str) -> LspDocument*:
return self._Find(uri)
+17
View File
@@ -58,6 +58,23 @@ def WriteResponse(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
return tr.WriteMessage(body)
def WriteNotification(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
method: str, params: json.JsonValue*) -> int:
msg: json.JsonValue* = json.object(pool)
if msg is None:
return -1
json.object_set(pool, msg, "jsonrpc",
json.string_val(pool, LspProtocol.LSP_JSONRPC_VERSION))
json.object_set(pool, msg, "method", json.string_val(pool, method))
if params is None:
params = json.null(pool)
json.object_set(pool, msg, "params", params)
body: str = json.write(pool, msg, 0, sizet(LSP_RESPONSE_CAP))
if body is None:
return -1
return tr.WriteMessage(body)
def WriteError(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
id_node: json.JsonValue*, code: int, msg: str) -> int:
resp: json.JsonValue* = _NewEnvelope(pool, id_node)
+28 -10
View File
@@ -11,6 +11,7 @@ import lib.lsp.LspTransport as LspTransport
import lib.lsp.LspRpc as LspRpc
import lib.lsp.LspDocuments as LspDocuments
import lib.lsp.LspWorkspace as LspWorkspace
import lib.lsp.LspDiagnostics as LspDiagnostics
define LSP_SERVER_NAME = "viperc-lsp"
@@ -65,7 +66,8 @@ def _HandleInitialize(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
return LspRpc.WriteResponse(pool, tr, id_node, result)
def _HandleDidOpen(docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
def _HandleDidOpen(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
if params is None:
return -1
td: json.JsonValue* = params["textDocument"]
@@ -78,10 +80,11 @@ def _HandleDidOpen(docs: LspDocuments.LspDocuments*, params: json.JsonValue*) ->
if docs.Open(uri, text, version) is None:
LspTransport.StderrWrite("viperc-lsp: didOpen failed to store document\n")
return -1
return 0
return LspDiagnostics.Publish(pool, tr, docs, uri)
def _HandleDidChange(docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
def _HandleDidChange(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
if params is None:
return -1
td: json.JsonValue* = params["textDocument"]
@@ -116,10 +119,11 @@ def _HandleDidChange(docs: LspDocuments.LspDocuments*, params: json.JsonValue*)
LspTransport.StderrWrite("viperc-lsp: didChange failed to apply a content change\n")
return -1
i += 1
return 0
return LspDiagnostics.Publish(pool, tr, docs, uri)
def _HandleDidClose(docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
def _HandleDidClose(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
if params is None:
return -1
td: json.JsonValue* = params["textDocument"]
@@ -127,7 +131,18 @@ def _HandleDidClose(docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -
if uri is None:
return -1
docs.Close(uri)
return 0
return LspDiagnostics.PublishEmpty(pool, tr, uri)
def _HandleDidSave(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
docs: LspDocuments.LspDocuments*, params: json.JsonValue*) -> int:
if params is None:
return -1
td: json.JsonValue* = params["textDocument"]
uri: str = LspRpc.GetStringField(td, "uri")
if uri is None:
return -1
return LspDiagnostics.Publish(pool, tr, docs, uri)
def _StatsResult(pool: memhub.MemBuddy*, docs: LspDocuments.LspDocuments*,
@@ -238,6 +253,9 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int:
if docs is None or ws is None:
LspTransport.StderrWrite("viperc-lsp: failed to allocate the document or workspace store\n")
return 1
if docs.Scratch() is None:
LspTransport.StderrWrite("viperc-lsp: failed to allocate the diagnostics scratch pool\n")
return 1
shutdown_requested: int = 0
while True:
@@ -276,13 +294,13 @@ 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(docs, params)
_HandleDidOpen(pool, tr, docs, params)
elif string.strcmp(method, "textDocument/didChange") == 0:
_HandleDidChange(docs, params)
_HandleDidChange(pool, tr, docs, params)
elif string.strcmp(method, "textDocument/didClose") == 0:
_HandleDidClose(docs, params)
_HandleDidClose(pool, tr, docs, params)
elif string.strcmp(method, "textDocument/didSave") == 0:
pass
_HandleDidSave(pool, tr, docs, params)
elif string.strcmp(method, "viper/lspStats") == 0:
rc = _HandleStats(pool, tr, docs, ws, id_node)
elif string.strcmp(method, "viper/lspDocument") == 0:
+71 -4
View File
@@ -99,10 +99,19 @@ class Client:
message["params"] = params
self.proc.stdin.write(encode_message(message))
self.proc.stdin.flush()
response = self.reader.recv()
if response.get("id") != self.next_id:
raise AssertionError(f"id mismatch: {response}")
return response
while True:
response = self.reader.recv()
if "id" not in response:
continue
if response.get("id") != self.next_id:
raise AssertionError(f"id mismatch: {response}")
return response
def wait_notification(self, method: str) -> dict[str, Any]:
while True:
message = self.reader.recv()
if message.get("method") == method:
return message
def close(self) -> int:
self.notify("exit")
@@ -306,10 +315,66 @@ def documents(exe: str) -> int:
return 0
def gate2(exe: str) -> int:
root = Path(__file__).resolve().parents[2]
uri = (root / "Tools" / "lsp" / "fixtures" / "gate2.vp").as_uri()
broken = "def a(:\n pass\n\ndef b(:\n pass\n\ndef c(:\n pass\n"
fixed = "def a() -> int:\n return 1\n"
client = Client(exe)
response = client.request(
"initialize",
{"processId": os.getpid(), "rootUri": root.as_uri(), "capabilities": {}},
)
if not isinstance(response.get("result"), dict):
raise AssertionError(f"initialize failed: {response}")
client.notify("initialized", {})
client.notify(
"textDocument/didOpen",
{"textDocument": {"uri": uri, "languageId": "viper", "version": 1, "text": broken}},
)
publish = client.wait_notification("textDocument/publishDiagnostics")
diagnostics = publish.get("params", {}).get("diagnostics")
if not isinstance(diagnostics, list) or len(diagnostics) < 3:
raise AssertionError(f"expected at least 3 syntax diagnostics: {publish}")
for diagnostic in diagnostics:
if diagnostic.get("severity") != 1 or not diagnostic.get("message"):
raise AssertionError(f"invalid diagnostic shape: {diagnostic}")
if diagnostic.get("range", {}).get("start") is None:
raise AssertionError(f"diagnostic has no range: {diagnostic}")
client.notify(
"textDocument/didChange",
{
"textDocument": {"uri": uri, "version": 2},
"contentChanges": [{"text": fixed}],
},
)
publish = client.wait_notification("textDocument/publishDiagnostics")
diagnostics = publish.get("params", {}).get("diagnostics")
if not isinstance(diagnostics, list) or len(diagnostics) != 0:
raise AssertionError(f"fixed document still has diagnostics: {publish}")
client.notify("textDocument/didClose", {"textDocument": {"uri": uri}})
publish = client.wait_notification("textDocument/publishDiagnostics")
diagnostics = publish.get("params", {}).get("diagnostics")
if not isinstance(diagnostics, list) or len(diagnostics) != 0:
raise AssertionError(f"didClose did not clear diagnostics: {publish}")
client.request("shutdown")
code = client.close()
if code != 0:
raise AssertionError(f"exit code is {code}, expected 0")
print("diagnostics: OK")
print("recovery: 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")
parser.add_argument("--gate1", action="store_true", help="run document/position checks")
parser.add_argument("--gate2", action="store_true", help="run syntax diagnostic checks")
parser.add_argument("--exe", help="path to viperc.exe")
args = parser.parse_args()
@@ -321,6 +386,8 @@ def main() -> int:
if not os.path.isfile(exe):
print(f"probe: executable not found: {exe}", file=sys.stderr)
return 2
if args.gate2:
return gate2(exe)
if args.gate1:
return documents(exe)
return lifecycle(exe)