feat(lsp): Gate 12 协议与诊断增强
新增 textDocument/diagnostic pull(full 报告);响应超出上限返回 -32603 而非关闭连接;记录 \$/cancelRequest 并在派发边界返回 -32800;补充 gate12 探针。
This commit is contained in:
1 parent
3c8bd49292
commit
bb72ed0971
6 files changed
+271
-21
No files matched your search
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Gate 12 fixture: pull diagnostics and protocol checks."""
|
||||
|
||||
|
||||
def answer() -> int:
|
||||
return 42
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user