From bb72ed0971e35b6a76398d49188a35f13f8ef799 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Mon, 26 Oct 2026 23:15:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(lsp):=20Gate=2012=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E4=B8=8E=E8=AF=8A=E6=96=AD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 textDocument/diagnostic pull(full 报告);响应超出上限返回 -32603 而非关闭连接;记录 \$/cancelRequest 并在派发边界返回 -32800;补充 gate12 探针。 --- App/lib/lsp/LspDiagnostics.vp | 74 ++++++++++++++++----- App/lib/lsp/LspProtocol.vp | 1 + App/lib/lsp/LspRpc.vp | 12 +++- App/lib/lsp/LspServer.vp | 79 ++++++++++++++++++++++ Tools/lsp/fixtures/gate12.vp | 5 ++ Tools/lsp/probe.py | 121 ++++++++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 21 deletions(-) create mode 100644 Tools/lsp/fixtures/gate12.vp diff --git a/App/lib/lsp/LspDiagnostics.vp b/App/lib/lsp/LspDiagnostics.vp index 9800462..4a14ba9 100644 --- a/App/lib/lsp/LspDiagnostics.vp +++ b/App/lib/lsp/LspDiagnostics.vp @@ -71,6 +71,36 @@ def _AddDiagnostic(pool: memhub.MemManager*, arr: json.JsonValue*, json.array_append(pool, arr, diag) +def _BuildItems(pool: memhub.MemManager*, + doc: LspDocuments.LspDocument*, + errors: list[ast.ParseError*]*) -> json.JsonValue*: + diags: json.JsonValue* = json.array(pool) + if diags is None: + return None + 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 + return diags + + +def _Parse(docs: LspDocuments.LspDocuments*, + doc: LspDocuments.LspDocument*) -> list[ast.ParseError*]*: + scratch: LspPools.LspPool* = docs.ScratchFor(sizet(string.strlen(doc.text))) + if scratch is None: + LspTransport.StderrWrite("viperc-lsp: diagnostics scratch pool is not available\n") + return None + if scratch.Reset() < 0: + LspTransport.StderrWrite("viperc-lsp: diagnostics scratch pool reset failed\n") + return None + # 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) + return ast.get_parse_errors() + + def _Send(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, uri: str, doc: LspDocuments.LspDocument*, errors: list[ast.ParseError*]*) -> int: @@ -78,15 +108,9 @@ def _Send(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, if params is None: return -1 json.object_set(pool, params, "uri", json.string_val(pool, uri)) - diags: json.JsonValue* = json.array(pool) + diags: json.JsonValue* = _BuildItems(pool, doc, errors) 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) @@ -96,20 +120,34 @@ def Publish(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, doc: LspDocuments.LspDocument* = docs.Find(uri) if doc is None: return 0 - scratch: LspPools.LspPool* = docs.ScratchFor(sizet(string.strlen(doc.text))) - 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() + errors: list[ast.ParseError*]* = _Parse(docs, doc) return _Send(pool, tr, uri, doc, errors) +def Pull(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, + docs: LspDocuments.LspDocuments*, id_node: json.JsonValue*, + params: json.JsonValue*) -> int: + """Pull diagnostics: always a full report for the requested document.""" + uri: str = None + if params is not None: + uri = LspRpc.GetStringField(params["textDocument"], "uri") + doc: LspDocuments.LspDocument* = docs.Find(uri) + items: json.JsonValue* = json.array(pool) + if items is None: + return -1 + if doc is not None: + errors: list[ast.ParseError*]* = _Parse(docs, doc) + built: json.JsonValue* = _BuildItems(pool, doc, errors) + if built is not None: + items = built + result: json.JsonValue* = json.object(pool) + if result is None: + return -1 + json.object_set(pool, result, "kind", json.string_val(pool, "full")) + json.object_set(pool, result, "items", items) + return LspRpc.WriteResponse(pool, tr, id_node, result) + + def PublishEmpty(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, uri: str) -> int: return _Send(pool, tr, uri, None, None) diff --git a/App/lib/lsp/LspProtocol.vp b/App/lib/lsp/LspProtocol.vp index 1692574..cf37645 100644 --- a/App/lib/lsp/LspProtocol.vp +++ b/App/lib/lsp/LspProtocol.vp @@ -7,6 +7,7 @@ define LSP_ERROR_INVALID_REQUEST = -32600 define LSP_ERROR_METHOD_NOT_FOUND = -32601 define LSP_ERROR_INVALID_PARAMS = -32602 define LSP_ERROR_INTERNAL = -32603 +define LSP_ERROR_REQUEST_CANCELLED = -32800 define LSP_METHOD_INITIALIZE = "initialize" define LSP_METHOD_INITIALIZED = "initialized" diff --git a/App/lib/lsp/LspRpc.vp b/App/lib/lsp/LspRpc.vp index b0e515b..4a2c31e 100644 --- a/App/lib/lsp/LspRpc.vp +++ b/App/lib/lsp/LspRpc.vp @@ -73,7 +73,9 @@ def WriteResponse(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, json.object_set(pool, resp, "result", result) body: str = json.write(pool, resp, 0, sizet(LSP_RESPONSE_CAP)) if body is None: - return -1 + # Never drop the connection because a result does not fit; report it as a JSON-RPC error. + return WriteError(pool, tr, id_node, LspProtocol.LSP_ERROR_INTERNAL, + "response exceeds the server limit") return tr.WriteMessage(body) @@ -90,7 +92,11 @@ def WriteNotification(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, json.object_set(pool, msg, "params", params) body: str = json.write(pool, msg, 0, sizet(LSP_RESPONSE_CAP)) if body is None: - return -1 + # A notification has no response channel; log and drop it instead of closing the session. + LspTransport.StderrWrite("viperc-lsp: dropped oversized notification '") + LspTransport.StderrWrite(method) + LspTransport.StderrWrite("'\n") + return 0 return tr.WriteMessage(body) @@ -107,5 +113,5 @@ def WriteError(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, json.object_set(pool, resp, "error", err) body: str = json.write(pool, resp, 0, sizet(LSP_RESPONSE_CAP)) if body is None: - return -1 + return tr.WriteMessage("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"response exceeds the server limit\"}}") return tr.WriteMessage(body) diff --git a/App/lib/lsp/LspServer.vp b/App/lib/lsp/LspServer.vp index 18431ba..37fb185 100644 --- a/App/lib/lsp/LspServer.vp +++ b/App/lib/lsp/LspServer.vp @@ -9,6 +9,7 @@ import os.path import lib.lsp.LspProtocol as LspProtocol import lib.lsp.LspTransport as LspTransport import lib.lsp.LspRpc as LspRpc +import lib.lsp.LspPools as LspPools import lib.lsp.LspDocuments as LspDocuments import lib.lsp.LspWorkspace as LspWorkspace import lib.lsp.LspDiagnostics as LspDiagnostics @@ -24,6 +25,61 @@ import lib.lsp.LspCodeAction as LspCodeAction define LSP_SERVER_NAME = "viperc-lsp" define LSP_SERVER_VERSION = "0.1.0-gate1" +define LSP_CANCEL_CAP = 64 + +# Request ids the client cancelled before they reached a handler. The server processes one +# request at a time, so cancellation applies at dispatch boundaries. +_cancel_int: list[int]* = None +_cancel_str: list[str]* = None + + +def _CancelAdd(pool: memhub.MemManager*, id_node: json.JsonValue*): + if id_node is None: + return + if id_node.is_int(): + if _cancel_int is not None and int(len(_cancel_int)) < LSP_CANCEL_CAP: + _cancel_int.append(int(id_node.as_int())) + return + if id_node.is_string(): + s: str = id_node.as_string() + if s is not None and _cancel_str is not None and int(len(_cancel_str)) < LSP_CANCEL_CAP: + _cancel_str.append(LspPools.DupStr(pool, s)) + + +def _CancelTake(id_node: json.JsonValue*) -> int: + if id_node is None: + return 0 + if id_node.is_int() and _cancel_int is not None: + v: int = int(id_node.as_int()) + i: int = 0 + while i < int(len(_cancel_int)): + if _cancel_int.get(sizet(i)) == v: + last: int = int(len(_cancel_int)) - 1 + _cancel_int.set(sizet(i), _cancel_int.get(sizet(last))) + _cancel_int.pop() + return 1 + i += 1 + return 0 + if id_node.is_string() and _cancel_str is not None: + s: str = id_node.as_string() + if s is None: + return 0 + i: int = 0 + while i < int(len(_cancel_str)): + if string.strcmp(_cancel_str.get(sizet(i)), s) == 0: + last: int = int(len(_cancel_str)) - 1 + _cancel_str.set(sizet(i), _cancel_str.get(sizet(last))) + _cancel_str.pop() + return 1 + i += 1 + return 0 + + +def _HandleCancel(pool: memhub.MemManager*, params: json.JsonValue*): + if params is None: + return + _CancelAdd(pool, params["id"]) + def _StringOrNull(pool: memhub.MemManager*, s: str) -> json.JsonValue*: if s is None: @@ -76,6 +132,11 @@ def _BuildInitializeResult(pool: memhub.MemManager*) -> json.JsonValue*: sem: json.JsonValue* = LspSemantic.Provider(pool) if sem is not None: json.object_set(pool, caps, "semanticTokensProvider", sem) + diag: json.JsonValue* = json.object(pool) + if diag is not None: + json.object_set(pool, diag, "interFileDependencies", json.bool_val(pool, False)) + json.object_set(pool, diag, "workspaceDiagnostics", json.bool_val(pool, False)) + json.object_set(pool, caps, "diagnosticProvider", diag) json.object_set(pool, caps, "codeActionProvider", json.bool_val(pool, True)) json.object_set(pool, result, "capabilities", caps) json.object_set(pool, info, "name", name) @@ -337,6 +398,7 @@ def _WritePingResult(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: + global _cancel_int, _cancel_str os._mbuddy = perm_pool os._win32._mbuddy = perm_pool os._posix._mbuddy = perm_pool @@ -357,6 +419,11 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: if LspIndex.Init(perm_pool) != 0: LspTransport.StderrWrite("viperc-lsp: failed to allocate the workspace symbol index\n") return 1 + _cancel_int = list[int](perm_pool, 16) + _cancel_str = list[str](perm_pool, 8) + if _cancel_int is None or _cancel_str is None: + LspTransport.StderrWrite("viperc-lsp: failed to allocate the cancellation table\n") + return 1 shutdown_requested: int = 0 while True: @@ -390,6 +457,14 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: params: json.JsonValue* = msg["params"] rc: int = 0 + if id_node is not None and _CancelTake(id_node) != 0: + rc = LspRpc.WriteError(pool, tr, id_node, + LspProtocol.LSP_ERROR_REQUEST_CANCELLED, + "Request cancelled") + if rc != 0: + return 1 + pool.reset_to(mark) + continue if _IsPingMethod(method) != 0: rc = _WritePingResult(pool, tr, id_node) elif string.strcmp(method, LspProtocol.LSP_METHOD_INITIALIZE) == 0: @@ -407,6 +482,8 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: elif string.strcmp(method, "textDocument/didSave") == 0: _HandleDidSave(pool, tr, docs, params) LspIndex.Invalidate() + elif string.strcmp(method, "textDocument/diagnostic") == 0: + rc = LspDiagnostics.Pull(pool, tr, docs, id_node, params) elif string.strcmp(method, "textDocument/documentSymbol") == 0: rc = LspNavigation.DocumentSymbol(pool, tr, docs, ws, id_node, params) elif string.strcmp(method, "workspace/symbol") == 0: @@ -452,6 +529,8 @@ def Run(pool: memhub.MemBuddy*, perm_pool: memhub.MemBuddy*) -> int: return 0 if shutdown_requested != 0 else 1 elif string.strcmp(method, "workspace/didChangeWatchedFiles") == 0: LspIndex.Invalidate() + elif string.strcmp(method, "$/cancelRequest") == 0: + _HandleCancel(perm_pool, params) elif _IsQuietNotification(method) != 0: pass elif id_node is not None: diff --git a/Tools/lsp/fixtures/gate12.vp b/Tools/lsp/fixtures/gate12.vp new file mode 100644 index 0000000..120ab91 --- /dev/null +++ b/Tools/lsp/fixtures/gate12.vp @@ -0,0 +1,5 @@ +"""Gate 12 fixture: pull diagnostics and protocol checks.""" + + +def answer() -> int: + return 42 diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index c1e759e..15de8ff 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -1735,6 +1735,124 @@ def gate11(exe: str) -> int: return 0 +def gate12(exe: str) -> int: + root = Path(__file__).resolve().parents[2] + fixtures = root / "Tools" / "lsp" / "fixtures" + workspace = Path(tempfile.mkdtemp(prefix="transpyc_lsp_gate12_")) + source = workspace / "gate12.vp" + shutil.copy(fixtures / "gate12.vp", source) + text = source.read_text(encoding="utf-8") + uri = source.as_uri() + + client = Client(exe) + response = client.request( + "initialize", + {"processId": os.getpid(), "rootUri": workspace.as_uri(), "capabilities": {}}, + ) + capabilities = response.get("result", {}).get("capabilities", {}) + provider = capabilities.get("diagnosticProvider") + if not isinstance(provider, dict): + raise AssertionError(f"missing diagnosticProvider: {response}") + if provider.get("interFileDependencies") is not False: + raise AssertionError(f"diagnosticProvider advertises inter-file dependencies: {provider}") + if provider.get("workspaceDiagnostics") is not False: + raise AssertionError(f"diagnosticProvider advertises workspace diagnostics: {provider}") + client.notify("initialized", {}) + + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": uri, "languageId": "viper", "version": 1, "text": text}}, + ) + pushed = client.wait_notification("textDocument/publishDiagnostics") + if pushed.get("params", {}).get("diagnostics"): + raise AssertionError(f"clean fixture has push diagnostics: {pushed}") + + pulled = client.request( + "textDocument/diagnostic", {"textDocument": {"uri": uri}} + ).get("result") + if not isinstance(pulled, dict) or pulled.get("kind") != "full": + raise AssertionError(f"pull diagnostics result is malformed: {pulled}") + if pulled.get("items") != []: + raise AssertionError(f"clean fixture has pull diagnostics: {pulled}") + + broken = 'def broken(:\n pass\n' + client.notify( + "textDocument/didChange", + {"textDocument": {"uri": uri, "version": 2}, "contentChanges": [{"text": broken}]}, + ) + pushed = client.wait_notification("textDocument/publishDiagnostics") + if not pushed.get("params", {}).get("diagnostics"): + raise AssertionError(f"syntax error did not produce push diagnostics: {pushed}") + pulled = client.request( + "textDocument/diagnostic", {"textDocument": {"uri": uri}} + ).get("result") + items = pulled.get("items", []) if isinstance(pulled, dict) else [] + if pulled.get("kind") != "full" or not items: + raise AssertionError(f"syntax error did not produce pull diagnostics: {pulled}") + first = items[0] + if not first.get("message") or not isinstance(first.get("range"), dict): + raise AssertionError(f"pull diagnostic item is incomplete: {first}") + + # A response that cannot fit the 1 MiB cap must be an error, not a closed connection. + big_lines = ["class Big:"] + for i in range(5000): + big_lines.append(f" field_{i:05d}_long_name: int") + big_text = "\n".join(big_lines) + "\n" + big_path = workspace / "gate12_big.vp" + big_path.write_text(big_text, encoding="utf-8") + big_uri = big_path.as_uri() + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": big_uri, "languageId": "viper", "version": 1, "text": big_text}}, + ) + client.wait_notification("textDocument/publishDiagnostics") + oversized = client.request( + "textDocument/documentSymbol", {"textDocument": {"uri": big_uri}}, timeout=120.0 + ) + error = oversized.get("error", {}) + if error.get("code") != -32603 or "limit" not in error.get("message", ""): + raise AssertionError(f"oversized response was not reported: {oversized}") + pong = client.request("$/ping", {}).get("result") + if not isinstance(pong, dict): + raise AssertionError(f"server closed after an oversized response: {pong}") + + # Cancelled ids are rejected with RequestCancelled at the dispatch boundary. + def raw_request(message: dict[str, Any]) -> dict[str, Any]: + client.proc.stdin.write(encode_message(message)) + client.proc.stdin.flush() + while True: + reply = client.reader.recv(timeout=120.0) + if reply.get("id") == message["id"]: + return reply + + hover_params = {"textDocument": {"uri": uri}, "position": {"line": 0, "character": 0}} + client.notify("$/cancelRequest", {"id": 4242}) + reply = raw_request({"jsonrpc": "2.0", "id": 4242, + "method": "textDocument/hover", "params": hover_params}) + if reply.get("error", {}).get("code") != -32800: + raise AssertionError(f"integer cancel was not honoured: {reply}") + + client.notify("$/cancelRequest", {"id": "cancel-me"}) + reply = raw_request({"jsonrpc": "2.0", "id": "cancel-me", + "method": "textDocument/hover", "params": hover_params}) + if reply.get("error", {}).get("code") != -32800: + raise AssertionError(f"string cancel was not honoured: {reply}") + + after = client.request("$/ping", {}).get("result") + if not isinstance(after, dict): + raise AssertionError(f"server stopped after cancellations: {after}") + + client.request("shutdown") + code = client.close() + if code != 0: + raise AssertionError(f"exit code is {code}, expected 0") + shutil.rmtree(workspace, ignore_errors=True) + print("pull-diagnostics: OK") + print("response-limit: OK") + print("cancel-request: 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") @@ -1748,6 +1866,7 @@ def main() -> int: parser.add_argument("--gate9", action="store_true", help="run semantic highlighting audits") parser.add_argument("--gate10", action="store_true", help="run member resolution checks") parser.add_argument("--gate11", action="store_true", help="run index freshness/multi-document checks") + parser.add_argument("--gate12", action="store_true", help="run protocol/diagnostic checks") parser.add_argument("--exe", help="path to viperc.exe") args = parser.parse_args() @@ -1759,6 +1878,8 @@ def main() -> int: if not os.path.isfile(exe): print(f"probe: executable not found: {exe}", file=sys.stderr) return 2 + if args.gate12: + return gate12(exe) if args.gate11: return gate11(exe) if args.gate10: