diff --git a/App/lib/lsp/LspDiagnostics.vp b/App/lib/lsp/LspDiagnostics.vp new file mode 100644 index 0000000..fb839b3 --- /dev/null +++ b/App/lib/lsp/LspDiagnostics.vp @@ -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) diff --git a/App/lib/lsp/LspDocuments.vp b/App/lib/lsp/LspDocuments.vp index 3f03bb8..2401c43 100644 --- a/App/lib/lsp/LspDocuments.vp +++ b/App/lib/lsp/LspDocuments.vp @@ -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) diff --git a/App/lib/lsp/LspRpc.vp b/App/lib/lsp/LspRpc.vp index 674ba1d..86792a2 100644 --- a/App/lib/lsp/LspRpc.vp +++ b/App/lib/lsp/LspRpc.vp @@ -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) diff --git a/App/lib/lsp/LspServer.vp b/App/lib/lsp/LspServer.vp index 7b76504..9178a87 100644 --- a/App/lib/lsp/LspServer.vp +++ b/App/lib/lsp/LspServer.vp @@ -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: diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index 3d112c3..cfb8b8b 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -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)