fix(lsp): 拉取诊断客户端不再重复收到 push 诊断

initialize 记录 textDocument.diagnostic 能力;客户端支持 pull 时不再发送 publishDiagnostics,避免 Problems 面板重复。gate2 增加溢出诊断断言:仅 1 条、range 覆盖完整字面量、错误恢复后函数语义 token 仍在,并验证 pull 客户端不会同时收到 push。
This commit is contained in:
TermiNexus committed 2026-10-28 15:07:15 +08:00
1 parent 9d298b5ae9
commit 86cced94dc
2 files changed
+87

No files matched your search

+27
View File
@@ -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)
+60
View File
@@ -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