feat(lsp): Gate 12 协议与诊断增强

新增 textDocument/diagnostic pull(full 报告);响应超出上限返回 -32603 而非关闭连接;记录 \$/cancelRequest 并在派发边界返回 -32800;补充 gate12 探针。
This commit is contained in:
TermiNexus committed 2026-10-26 23:15:10 +08:00
1 parent 3c8bd49292
commit bb72ed0971
6 files changed
+271 -21

No files matched your search

+56 -18
View File
@@ -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)
+1
View File
@@ -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"
+9 -3
View File
@@ -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)
+79
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
"""Gate 12 fixture: pull diagnostics and protocol checks."""
def answer() -> int:
return 42
+121
View File
@@ -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: