diff --git a/App/lib/lsp/LspServer.vp b/App/lib/lsp/LspServer.vp index 5137ae2..82fba53 100644 --- a/App/lib/lsp/LspServer.vp +++ b/App/lib/lsp/LspServer.vp @@ -33,6 +33,10 @@ define LSP_CANCEL_CAP = 64 _cancel_int: list[int]* = None _cancel_str: list[str]* = None +# 1 when the client advertises pull diagnostics (`textDocument.diagnostic`). Pushing +# publishDiagnostics as well would show every problem twice in the Problems panel. +_pull_diagnostics: int = 0 + def _CancelAdd(pool: memhub.MemManager*, id_node: json.JsonValue*): if id_node is None: @@ -146,9 +150,24 @@ def _BuildInitializeResult(pool: memhub.MemManager*) -> json.JsonValue*: return result +def _ClientSupportsPull(params: json.JsonValue*) -> int: + """Whether initialize params advertise textDocument/diagnostic (pull diagnostics).""" + if params is None: + return 0 + caps: json.JsonValue* = params["capabilities"] + if caps is None: + return 0 + td: json.JsonValue* = caps["textDocument"] + if td is None: + return 0 + return 1 if td["diagnostic"] is not None else 0 + + def _HandleInitialize(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, ws: LspWorkspace.LspWorkspace*, id_node: json.JsonValue*, params: json.JsonValue*) -> int: + global _pull_diagnostics + _pull_diagnostics = _ClientSupportsPull(params) root_uri: str = None if params is not None: root_uri = LspRpc.GetStringField(params, "rootUri") @@ -188,6 +207,8 @@ def _HandleDidOpen(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, if docs.Open(uri, text, version) is None: LspTransport.StderrWrite("viperc-lsp: didOpen failed to store document\n") return -1 + if _pull_diagnostics != 0: + return 0 return LspDiagnostics.Publish(pool, tr, docs, uri) @@ -227,6 +248,8 @@ def _HandleDidChange(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, LspTransport.StderrWrite("viperc-lsp: didChange failed to apply a content change\n") return -1 i += 1 + if _pull_diagnostics != 0: + return 0 return LspDiagnostics.Publish(pool, tr, docs, uri) @@ -239,6 +262,8 @@ def _HandleDidClose(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, if uri is None: return -1 docs.Close(uri) + if _pull_diagnostics != 0: + return 0 return LspDiagnostics.PublishEmpty(pool, tr, uri) @@ -250,6 +275,8 @@ def _HandleDidSave(pool: memhub.MemManager*, tr: LspTransport.LspTransport*, uri: str = LspRpc.GetStringField(td, "uri") if uri is None: return -1 + if _pull_diagnostics != 0: + return 0 return LspDiagnostics.Publish(pool, tr, docs, uri) diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index e218c55..63165cd 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -395,12 +395,72 @@ def gate2(exe: str) -> int: if not isinstance(diagnostics, list) or len(diagnostics) != 0: raise AssertionError(f"didClose did not clear diagnostics: {publish}") + # Lexer overflow: exactly one diagnostic, the range covers the whole literal, and the rest of + # the file keeps its symbols/semantic tokens (recovery parsing). + overflow = "def a(l: int):\n pass\n\ndef main():\n a(1024000000000000000000)\n" + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": uri, "languageId": "viper", "version": 3, "text": overflow}}, + ) + publish = client.wait_notification("textDocument/publishDiagnostics") + diagnostics = publish.get("params", {}).get("diagnostics") + if not isinstance(diagnostics, list) or len(diagnostics) != 1: + raise AssertionError(f"expected exactly one overflow diagnostic: {publish}") + overflow_diag = diagnostics[0] + if overflow_diag["range"]["start"] != {"line": 4, "character": 6} or \ + overflow_diag["range"]["end"] != {"line": 4, "character": 28}: + raise AssertionError(f"overflow range does not cover the literal: {overflow_diag}") + if "1024000000000000000000" not in overflow_diag["message"]: + raise AssertionError(f"overflow diagnostic lost the literal text: {overflow_diag}") + sem = client.request( + "textDocument/semanticTokens/full", {"textDocument": {"uri": uri}}, timeout=120.0 + ).get("result") + sem_tokens = _decode_tokens(sem.get("data", [])) if isinstance(sem, dict) else [] + legend = response.get("result", {}).get("capabilities", {}) \ + .get("semanticTokensProvider", {}).get("legend", {}).get("tokenTypes", []) + function_type = legend.index("function") + functions = {(t["line"], t["character"]) for t in sem_tokens if t["type"] == function_type} + if (0, 4) not in functions or (3, 4) not in functions: + raise AssertionError(f"semantic tokens lost after a lexer error: {sem_tokens}") + 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") + + # A pull-diagnostics client must not also receive publishDiagnostics (that shows every + # problem twice in the Problems panel). + pull_client = Client(exe) + pull_client.request( + "initialize", + {"processId": os.getpid(), "rootUri": root.as_uri(), + "capabilities": {"textDocument": {"diagnostic": {}}}}, + ) + pull_client.notify("initialized", {}) + pull_client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": uri, "languageId": "viper", "version": 4, "text": overflow}}, + ) + time.sleep(0.4) + pushed: list[dict[str, Any]] = [] + while not pull_client.reader.messages.empty(): + pushed.append(pull_client.reader.messages.get()) + if any(m.get("method") == "textDocument/publishDiagnostics" for m in pushed): + raise AssertionError(f"pull client received duplicate push diagnostics: {pushed}") + pulled = pull_client.request( + "textDocument/diagnostic", {"textDocument": {"uri": uri}}, timeout=60.0 + ).get("result") + items = pulled.get("items", []) if isinstance(pulled, dict) else [] + if len(items) != 1: + raise AssertionError(f"pull diagnostics returned {len(items)} items: {pulled}") + pull_client.request("shutdown") + pull_client.close() + print("diagnostics: OK") print("recovery: OK") + print("overflow-diagnostics: OK") return 0