Files
xiaoli-cli/tests/test_lsp_client.py
T
shuiyu 1054932921 feat: pylsp 作为工具之一 + AI 写/改 .py 自动触发诊断并注入上下文
- 补装 pylsp 及 lint 后端(pyflakes/pycodestyle/rope/mccabe),真实诊断可用
- code_editor / file_manager 写/改操作后自动调用 LSP 诊断(.py),结果经
  get_lsp_context() 静默注入下一轮模型上下文,仅在有问题时的可见提示
- config.json 中 rust 设为 disabled(本机未装,免 ERROR 噪声)
- requirements.txt / config.json.example 补充 pylsp 配置说明
- 新增 4 个 _auto_lsp_check 钩子单测
2026-08-05 21:23:16 +08:00

376 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LSP 客户端测试
==============
用 inline 的 fake LSP server(Content-Length 分帧 JSON-RPC)验证:
- LspClient 全链路(initialize / didOpen 触发诊断 / hover / definition / shutdown)
- LspManager 按扩展名路由、多 server
- UnifiedToolManager 桥接:5 个 lsp__* 工具注册、get_mcp_tools 可见、execute 返回诊断
- get_lsp_context 注入
- fc_tools 保真(lsp 工具进入模型视野)
全程不触真实引擎 / 网络。
"""
import os
import sys
import json
import time
import threading
import pytest
import lsp_client
from lsp_client import LspClient, LspManager, path_to_uri, ext_to_language
# ── Fake LSP server(Content-Length 分帧)──
FAKE_SERVER_SRC = r'''
import sys, json, threading, time
def send(obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
def read_msg():
header = b""
while b"\r\n\r\n" not in header:
c = sys.stdin.buffer.read(1)
if not c:
return None
header += c
m = __import__("re").search(rb"Content-Length:\s*(\d+)", header)
if not m:
return None
n = int(m.group(1))
body = b""
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body += c
return json.loads(body.decode("utf-8"))
def main():
initialized = threading.Event()
while True:
msg = read_msg()
if msg is None:
break
mid = msg.get("id")
method = msg.get("method")
if method == "initialize":
send({"jsonrpc": "2.0", "id": mid, "result": {
"capabilities": {"hoverProvider": True,
"definitionProvider": True,
"referencesProvider": True,
"completionProvider": {}}}})
elif method == "initialized":
initialized.set()
elif method == "textDocument/didOpen":
params = msg.get("params", {})
uri = params.get("textDocument", {}).get("uri", "")
# 立即推送一条诊断
send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": [
{"range": {"start": {"line": 2, "character": 4},
"end": {"line": 2, "character": 7}},
"severity": 1, "source": "fake-lsp",
"message": "Undefined variable 'foo'"}]}})
elif method == "textDocument/hover":
send({"jsonrpc": "2.0", "id": mid, "result": {
"contents": {"kind": "markdown", "value": "def foo(): ..."}}})
elif method == "textDocument/definition":
send({"jsonrpc": "2.0", "id": mid, "result": {
"uri": "file:///x.py", "range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3}}}})
elif method == "textDocument/references":
send({"jsonrpc": "2.0", "id": mid, "result": [
{"uri": "file:///x.py", "range": {
"start": {"line": 1, "character": 0},
"end": {"line": 1, "character": 3}}}]})
elif method == "textDocument/completion":
send({"jsonrpc": "2.0", "id": mid, "result": {
"items": [{"label": "bar"}, {"label": "baz"}]}})
elif method == "shutdown":
send({"jsonrpc": "2.0", "id": mid, "result": None})
elif method == "exit":
break
else:
if mid is not None:
send({"jsonrpc": "2.0", "id": mid, "result": {}})
if __name__ == "__main__":
main()
'''
@pytest.fixture
def fake_server(tmp_path):
p = tmp_path / "fake_lsp_server.py"
p.write_text(FAKE_SERVER_SRC)
return str(p)
def test_path_to_uri_windows():
uri = path_to_uri("C:\\Users\\me\\a.py")
assert uri.startswith("file:///")
assert uri.endswith("/C:/Users/me/a.py")
def test_ext_to_language():
assert ext_to_language("x.py") == "python"
assert ext_to_language("x.rs") == "rust"
assert ext_to_language("x.unknown") is None
def test_lsp_client_lifecycle(fake_server, tmp_path):
code_file = tmp_path / "sample.py"
code_file.write_text("print('hi')\nx = 1\nfoo = 2\n")
client = LspClient("python", sys.executable, [fake_server],
language_id="python", root_uri="file:///tmp")
try:
client.start()
init = client.initialize()
assert isinstance(init, dict)
diags = client.diagnostics(str(code_file), wait=3.0)
assert len(diags) == 1
assert "Undefined variable" in diags[0]["message"]
hover = client.hover(str(code_file), 0, 0)
assert hover and "foo" in json.dumps(hover)
definition = client.definition(str(code_file), 0, 0)
assert definition
references = client.references(str(code_file), 0, 0)
assert references
completion = client.completion(str(code_file), 0, 0)
assert completion
finally:
client.shutdown()
def test_lsp_manager_routing(fake_server, tmp_path):
# 用 .py 扩展名映射到 fake server(languageId=python)
servers = {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert "python" in mgr.get_servers()
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
out = mgr.diagnostics(str(code_file))
assert "Undefined variable" in out
# 诊断被缓存,供上下文注入
ctx = mgr.get_context()
assert "Undefined variable" in ctx
hover_out = mgr.hover(str(code_file), 0, 0)
assert "foo" in hover_out
def test_lsp_manager_unknown_ext_graceful(fake_server, tmp_path):
servers = {"python": {"command": sys.executable, "args": [fake_server]}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
# .rs 没有配置 server,应优雅提示而非崩溃
out = mgr.diagnostics(str(tmp_path / "x.rs"))
assert "没有为" in out or "LSP server" in out
def test_lsp_manager_connect_failure_graceful(tmp_path):
# 命令不存在 → 不应抛异常,仅记录
servers = {"python": {"command": "this_command_does_not_exist_xyz"}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert mgr.get_servers() == []
def test_unified_manager_bridges_lsp(fake_server, tmp_path, monkeypatch):
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
cfg = {
"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}},
}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
tools = mgr.get_mcp_tools()
names = [t["name"] for t in tools]
for expected in ("lsp__diagnostics", "lsp__hover", "lsp__definition",
"lsp__references", "lsp__completion"):
assert expected in names, f"缺少 LSP 工具: {expected}"
# inputSchema 保真(不压成 args)
diag_def = next(t for t in tools if t["name"] == "lsp__diagnostics")
assert "file" in diag_def["inputSchema"]["properties"]
# execute 返回真实诊断文本
res = mgr.execute("lsp__diagnostics", json.dumps({"file": str(code_file)}))
assert res.success
assert "Undefined variable" in (res.data or "")
# 上下文注入可用
ctx = mgr.get_lsp_context()
assert "Undefined variable" in ctx
finally:
mgr.shutdown()
def test_lsp_skip_when_no_servers_configured(tmp_path, monkeypatch):
# noch keinen LSP-Server -> keine lsp__ Tools
cfg = {"mcpServers": {}, "lspServers": {}}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
names = [t["name"] for t in mgr.get_mcp_tools()]
assert not any(n.startswith("lsp__") for n in names)
finally:
mgr.shutdown()
def test_fc_tools_includes_lsp(tmp_path, monkeypatch, fake_server):
"""验证 lsp__* 工具能经 get_mcp_tools 进入 fc_tools 保真输出"""
import xcli_core.config as xcfg
cfg = {"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server]}}}
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
import xcli_core.fc_tools as fc_tools
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
fc = fc_tools.mcp_to_openai_tools(mgr)
lsp_fc = [t for t in fc if t.get("function", {}).get("name", "").startswith("lsp__")]
assert len(lsp_fc) == 5, f"应进入 5 个 lsp 工具,实际 {len(lsp_fc)}"
# 保真:参数应是 file/line/character,而非单个 args
diag = next(t for t in lsp_fc if t["function"]["name"] == "lsp__diagnostics")
assert "file" in diag["function"]["parameters"]["properties"]
finally:
mgr.shutdown()
# ── code_editor / file_manager 的「写 .py 后自动触发 pylsp 诊断」钩子 ──
def _make_fake_lsp_manager(diag_text):
"""构造一个 fake LSP 管理器,记录被调用,并返回固定诊断文本。"""
calls = []
class FakeManager:
def __init__(self):
self.clients = {"python": object()} # 模拟已连接 python server
def diagnostics(self, filepath, wait=1.5):
calls.append(filepath)
return diag_text
fm = FakeManager()
fm._calls = calls
return fm
def _attach(cli, fm):
class UTM:
_lsp_manager = fm
cli.liugin_manager = UTM()
def test_auto_lsp_check_triggers_on_py_write(tmp_path):
"""写/改 .py 后钩子应调用 LSP 诊断,并在有问题时返回概要。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("🔍 x.py:2 条诊断\n 错误 L1:1 — undefined name 'foo'")
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
py_file = tmp_path / "mod.py"
py_file.write_text("foo = bar\n", encoding="utf-8")
out = ce._auto_lsp_check(str(py_file))
assert str(py_file) in fm._calls, "应调用 lsp_manager.diagnostics(该 py 文件)"
assert "LSP 诊断" in out and "2 条诊断" in out
def test_auto_lsp_check_silent_when_clean(tmp_path):
"""文件干净(无诊断)时钩子应静默返回空串。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("✅ mod.py:无诊断(未发现问题)")
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
py_file = tmp_path / "clean.py"
py_file.write_text("x = 1\n", encoding="utf-8")
assert ce._auto_lsp_check(str(py_file)) == ""
def test_auto_lsp_check_silent_without_lsp_manager(tmp_path):
"""未连接 LSP 管理器(或未配置 server)时钩子应静默跳过,绝不报错。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
cli = type("CLI", (), {})()
# liugin_manager 没有 _lsp_manager 属性
cli.liugin_manager = type("UTM", (), {})()
ce.set_cli(cli)
py_file = tmp_path / "any.py"
py_file.write_text("pass\n", encoding="utf-8")
assert ce._auto_lsp_check(str(py_file)) == ""
def test_auto_lsp_check_no_server_for_ext(tmp_path):
"""扩展名未配置 LSP server 时(如 .txt)应静默返回空串。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("⚠️ 没有为 x.txt 配置 LSP server")
# clients 里只有 python,没有 txt 对应的 server;这里用文本模拟「无 server」返回
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
txt_file = tmp_path / "note.txt"
txt_file.write_text("hello\n", encoding="utf-8")
assert ce._auto_lsp_check(str(txt_file)) == ""