feat: 自研本地 LSP 服务器 (jedi 语义 + py_detect 诊断),替代 pylsp
- xcli_core/local_lsp_server.py: LSP over stdio (Content-Length 分帧),兼容现有 lsp_client - 语义: jedi 0.20 API (goto/infer/get_references/complete/help) — 补全/跳转/引用/悬停 - 诊断: 复用 plugins/py_detect 规则引擎,中文诊断 + 修复建议,didOpen/didChange 自动推送 - 零网络、零外部安装 (无需 pip install pylsp),服务器无响应时客户端可超时跳过 - config.json(.example): python 语言默认切到本地服务器; 换回 pylsp 改 command 即可 - lsp_client.py: command 为裸 python 且 PATH 找不到时回退 sys.executable (防环境坑) - 16 项测试 (分帧/路径/协议/语义/真实 subprocess 完整握手); 相关套件 277 passed 零回归
This commit is contained in:
1 parent
9a133dcf88
commit
56d5ed27e0
4 files changed
+675
-3
No files matched your search
@@ -0,0 +1,229 @@
|
||||
"""自研本地 LSP 服务器测试:单元(分帧/协议/引擎)+ 真实 subprocess 集成"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from xcli_core.local_lsp_server import (
|
||||
LocalLspServer,
|
||||
path_to_uri,
|
||||
read_message,
|
||||
uri_to_path,
|
||||
write_message,
|
||||
)
|
||||
|
||||
SRC = "import math\ndef add(a, b):\n return a + b\n\nresult = add(1, 2)\nprint(result)\n"
|
||||
URI = "file:///C:/Users/Administrator/Desktop/xiaoli-cli/_test_tmp.py"
|
||||
|
||||
|
||||
# ── 分帧传输层 ──
|
||||
class TestFraming:
|
||||
def test_roundtrip(self):
|
||||
buf = io.BytesIO()
|
||||
write_message(buf, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
||||
buf.seek(0)
|
||||
msg = read_message(buf)
|
||||
assert msg == {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
|
||||
|
||||
def test_cjk_bytes(self):
|
||||
# 中文消息体: Content-Length 按字节计
|
||||
obj = {"result": "未定义变量 — 中文诊断"}
|
||||
buf = io.BytesIO()
|
||||
write_message(buf, obj)
|
||||
buf.seek(0)
|
||||
assert read_message(buf) == obj
|
||||
|
||||
def test_empty_stream(self):
|
||||
assert read_message(io.BytesIO(b"")) is None
|
||||
|
||||
|
||||
# ── 路径工具 ──
|
||||
class TestPath:
|
||||
def test_uri_to_path_windows(self):
|
||||
p = uri_to_path("file:///C:/Users/a/b.py")
|
||||
assert os.path.abspath(p) == os.path.abspath("C:/Users/a/b.py")
|
||||
|
||||
def test_path_to_uri(self):
|
||||
assert path_to_uri(r"C:\Users\a\b.py").startswith("file:///C:/Users/a/b.py")
|
||||
|
||||
|
||||
# ── 协议层(进程内直接调 handle) ──
|
||||
class TestProtocol:
|
||||
def make_server(self):
|
||||
srv = LocalLspServer()
|
||||
srv.handle({"jsonrpc": "2.0", "method": "initialize", "id": 1,
|
||||
"params": {"rootUri": "file:///" + PROJECT_ROOT.replace("\\", "/")}})
|
||||
return srv
|
||||
|
||||
def test_initialize_capabilities(self):
|
||||
srv = LocalLspServer()
|
||||
outs = srv.handle({"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}})
|
||||
assert outs[0]["id"] == 1
|
||||
caps = outs[0]["result"]["capabilities"]
|
||||
assert caps["definitionProvider"] is True
|
||||
assert "completionProvider" in caps
|
||||
assert caps["textDocumentSync"]["change"] == 1
|
||||
|
||||
def test_unknown_method_error(self):
|
||||
srv = LocalLspServer()
|
||||
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/xxx", "id": 9, "params": {}})
|
||||
assert outs[0]["error"]["code"] == -32601
|
||||
|
||||
def test_shutdown_and_unknown_notification_no_reply(self):
|
||||
srv = LocalLspServer()
|
||||
assert srv.handle({"jsonrpc": "2.0", "method": "initialized", "params": {}}) == []
|
||||
assert srv.handle({"jsonrpc": "2.0", "method": "shutdown", "id": 2,
|
||||
"params": {}})[0]["result"] is None
|
||||
|
||||
def test_did_open_pushes_diagnostics(self):
|
||||
srv = self.make_server()
|
||||
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
|
||||
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": SRC}}})
|
||||
push = [o for o in outs if o.get("method") == "textDocument/publishDiagnostics"]
|
||||
assert push, "didOpen 应推送诊断"
|
||||
diags = push[0]["params"]["diagnostics"]
|
||||
assert diags, "应检测到问题 (import math 未使用)"
|
||||
first = diags[0]
|
||||
assert first["source"] == "py_detect"
|
||||
assert "range" in first and "severity" in first and "message" in first
|
||||
|
||||
def test_diagnostics_clean_file_no_push(self):
|
||||
srv = self.make_server()
|
||||
clean = "def add(a, b):\n return a + b\nprint(add(1, 2))\n"
|
||||
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
|
||||
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": clean}}})
|
||||
assert not [o for o in outs if o.get("method") == "textDocument/publishDiagnostics"]
|
||||
|
||||
|
||||
# ── jedi 语义(进程内,无需真实文件) ──
|
||||
class TestSemantic:
|
||||
def open(self):
|
||||
srv = LocalLspServer()
|
||||
srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
|
||||
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": SRC}}})
|
||||
return srv
|
||||
|
||||
def req(self, srv, method, line, character):
|
||||
outs = srv.handle({"jsonrpc": "2.0", "method": method, "id": 1, "params": {
|
||||
"textDocument": {"uri": URI}, "position": {"line": line, "character": character}}})
|
||||
return outs[0]["result"]
|
||||
|
||||
def test_completion(self):
|
||||
srv = self.open()
|
||||
res = self.req(srv, "textDocument/completion", 6, 10) # result.
|
||||
items = res.get("items", []) if isinstance(res, dict) else res
|
||||
labels = [i["label"] for i in items]
|
||||
assert "result" in labels, f"应在 result 后补全: {labels}"
|
||||
|
||||
def test_definition(self):
|
||||
srv = self.open()
|
||||
res = self.req(srv, "textDocument/definition", 5, 10) # add 调用处
|
||||
assert res, "应找到 add 定义"
|
||||
loc = res[0]
|
||||
assert loc["uri"].startswith("file:///")
|
||||
assert loc["range"]["start"]["line"] == 2 # 定义在第 3 行 (0-based)
|
||||
|
||||
def test_references(self):
|
||||
srv = self.open()
|
||||
res = self.req(srv, "textDocument/references", 5, 10) # add
|
||||
assert len(res) >= 2, "应至少含定义处+调用处"
|
||||
|
||||
def test_hover(self):
|
||||
srv = self.open()
|
||||
res = self.req(srv, "textDocument/hover", 5, 10) # add
|
||||
assert res is not None
|
||||
contents = res.get("contents", {})
|
||||
value = contents.get("value", "") if isinstance(contents, dict) else str(contents)
|
||||
assert value, "hover 应有内容"
|
||||
|
||||
def test_hover_builtin(self):
|
||||
srv = self.open()
|
||||
# math 已 import; 悬停 print 是内置, 不崩溃即可
|
||||
res = self.req(srv, "textDocument/hover", 6, 1)
|
||||
assert res is None or isinstance(res, dict)
|
||||
|
||||
|
||||
# ── 真实 subprocess 集成(完整握手) ──
|
||||
class TestSubprocessIntegration:
|
||||
def test_full_handshake(self):
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "xcli_core.local_lsp_server"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, cwd=PROJECT_ROOT, bufsize=0)
|
||||
try:
|
||||
q = queue.Queue()
|
||||
|
||||
def _reader():
|
||||
try:
|
||||
while True:
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = proc.stdout.read(1)
|
||||
if not chunk:
|
||||
return
|
||||
header += chunk
|
||||
m = re.search(rb"Content-Length:\s*(\d+)", header)
|
||||
body = proc.stdout.read(int(m.group(1))) if m else b""
|
||||
if body:
|
||||
q.put(json.loads(body.decode("utf-8")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_reader, daemon=True).start()
|
||||
|
||||
def send(obj):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
proc.stdin.write(f"Content-Length: {len(data)}\r\n\r\n".encode() + data)
|
||||
proc.stdin.flush()
|
||||
|
||||
def recv(timeout=25):
|
||||
return q.get(timeout=timeout)
|
||||
|
||||
root_uri = "file:///" + PROJECT_ROOT.replace("\\", "/")
|
||||
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"processId": os.getpid(), "rootUri": root_uri,
|
||||
"capabilities": {"textDocument": {}}}})
|
||||
r = recv()
|
||||
assert "capabilities" in r.get("result", {}), r
|
||||
|
||||
send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
send({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
|
||||
"textDocument": {"uri": URI, "languageId": "python", "version": 1,
|
||||
"text": SRC}}})
|
||||
diag = recv()
|
||||
assert diag.get("method") == "textDocument/publishDiagnostics", diag
|
||||
assert diag["params"]["diagnostics"], "应推送诊断"
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 2, "method": "textDocument/completion", "params": {
|
||||
"textDocument": {"uri": URI}, "position": {"line": 6, "character": 10}}})
|
||||
c = recv()["result"]
|
||||
items = c.get("items", []) if isinstance(c, dict) else c
|
||||
assert any(i["label"] == "result" for i in items)
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 3, "method": "textDocument/definition", "params": {
|
||||
"textDocument": {"uri": URI}, "position": {"line": 5, "character": 10}}})
|
||||
d = recv()["result"]
|
||||
assert d and d[0]["range"]["start"]["line"] == 2
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 4, "method": "textDocument/hover", "params": {
|
||||
"textDocument": {"uri": URI}, "position": {"line": 5, "character": 10}}})
|
||||
h = recv()["result"]
|
||||
assert h is not None
|
||||
|
||||
send({"jsonrpc": "2.0", "id": 5, "method": "shutdown", "params": {}})
|
||||
assert recv()["result"] is None
|
||||
send({"jsonrpc": "2.0", "method": "exit", "params": {}})
|
||||
proc.stdin.close()
|
||||
proc.wait(timeout=5)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
Reference in new issue
Block a user