Skip to content

Commit 8d99e54

Browse files
LSP: integration test suite
Python harness driving the server over stdio (make test_lsp): lifecycle, diagnostics, definition, hover, goals, symbols, completion, debouncing. Five cases assert behavior from #1366 and #1444 and are marked expectedFailure until those PRs merge.
1 parent 18a2bdf commit 8d99e54

27 files changed

Lines changed: 1991 additions & 0 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ libraries/zenon_modulo
2121
.DS_Store
2222
*.map
2323
*.log
24+
__pycache__/
25+
*.pyc

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ test_all: test
3636
test: lambdapi
3737
@dune runtest
3838
@tests/dtrees.sh
39+
@$(MAKE) test_lsp
40+
41+
.PHONY: test_lsp
42+
test_lsp: lambdapi
43+
@python3 -m tests.lsp
3944

4045
.PHONY: test_load
4146
test_load: lambdapi

tests/lsp/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# LSP integration tests
2+
3+
End-to-end tests for the lambdapi LSP server. Each test spawns a
4+
`lambdapi lsp` subprocess, speaks JSON-RPC over stdio, and asserts on the
5+
responses.
6+
7+
## Running
8+
9+
From the repository root:
10+
11+
```
12+
make test_lsp # runs as part of `make test` too
13+
python3 -m tests.lsp # equivalent, more verbose
14+
python3 -m unittest tests.lsp.test_hover -v # single file
15+
```
16+
17+
The harness prefers a locally-built binary (`_build/install/default/bin/lambdapi`)
18+
over whatever is on `PATH`, so you always test the current tree.
19+
20+
## Environment variables
21+
22+
| Name | Purpose |
23+
|---|---|
24+
| `LAMBDAPI_LIB_ROOT` | override stdlib location (defaults to the opam install) |
25+
| `LSP_TEST_LOG` | when set, pass `--log-file=$LSP_TEST_LOG` to the server |
26+
27+
## Layout
28+
29+
- `client.py` — JSON-RPC / stdio client (`LSPServer` class)
30+
- `source.py` — pattern-based position finder for fixtures
31+
- `base.py``LSPTestCase` with per-test server + fixture helpers
32+
- `fixtures/*.lp` — small focused test documents
33+
- `test_*.py` — one file per LSP concern (lifecycle, diagnostics, …)
34+
35+
## Requirements
36+
37+
Python ≥ 3.8, stdlib only. No pip packages.

tests/lsp/__init__.py

Whitespace-only changes.

tests/lsp/__main__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Entry point: `python3 -m tests.lsp` runs the whole suite."""
2+
3+
import os
4+
import sys
5+
import unittest
6+
7+
8+
if __name__ == "__main__":
9+
# Ensure the tests can find each other as a package.
10+
here = os.path.dirname(__file__)
11+
repo_root = os.path.abspath(os.path.join(here, "..", ".."))
12+
if repo_root not in sys.path:
13+
sys.path.insert(0, repo_root)
14+
15+
loader = unittest.TestLoader()
16+
suite = loader.discover(start_dir=here, pattern="test_*.py",
17+
top_level_dir=repo_root)
18+
runner = unittest.TextTestRunner(verbosity=2)
19+
result = runner.run(suite)
20+
sys.exit(0 if result.wasSuccessful() else 1)

tests/lsp/base.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Shared test base class and fixture helpers."""
2+
3+
import os
4+
import pathlib
5+
import shutil
6+
import tempfile
7+
import unittest
8+
9+
from .client import LSPServer
10+
from .source import SourceFile
11+
12+
13+
HERE = pathlib.Path(__file__).resolve().parent
14+
FIXTURES = HERE / "fixtures"
15+
REPO_ROOT = HERE.parent.parent
16+
17+
18+
def _lambdapi_binary():
19+
"""Prefer a freshly-built local binary over whatever's on PATH."""
20+
local = REPO_ROOT / "_build" / "install" / "default" / "bin" / "lambdapi"
21+
if local.exists():
22+
return str(local)
23+
return shutil.which("lambdapi")
24+
25+
26+
def _opam_stdlib():
27+
"""Locate the installed lambdapi Stdlib (for --map-dir)."""
28+
for base in [
29+
os.environ.get("LAMBDAPI_LIB_ROOT"),
30+
os.path.expanduser("~/.opam/default/lib/lambdapi/lib_root"),
31+
]:
32+
if base and os.path.isdir(os.path.join(base, "Stdlib")):
33+
return os.path.join(base, "Stdlib")
34+
return None
35+
36+
37+
def has_stdlib():
38+
"""True when the opam Stdlib is reachable (for conditional tests)."""
39+
return _opam_stdlib() is not None
40+
41+
42+
def stdlib_map_dir():
43+
"""[--map-dir] value for Stdlib, or None if not installed."""
44+
d = _opam_stdlib()
45+
return f"Stdlib:{d}" if d else None
46+
47+
48+
def requires_stdlib(test):
49+
"""Decorator: skip a test method if Stdlib is not available."""
50+
return unittest.skipUnless(
51+
has_stdlib(),
52+
"lambdapi Stdlib not installed; set LAMBDAPI_LIB_ROOT")(test)
53+
54+
55+
class LSPTestCase(unittest.TestCase):
56+
"""Base class: starts one server per test, exposes helpers."""
57+
58+
standard_lsp = True
59+
advertise_snippet_support = False
60+
advertise_hierarchical_symbols = False
61+
extra_map_dirs = ()
62+
63+
@classmethod
64+
def setUpClass(cls):
65+
cls._tmpdir = tempfile.TemporaryDirectory(prefix="lptest-")
66+
cls.lib_root = cls._tmpdir.name
67+
# Copy fixtures into the lib root so relative references resolve.
68+
for fixture in FIXTURES.glob("*.lp"):
69+
dest = pathlib.Path(cls.lib_root) / fixture.name
70+
dest.write_text(fixture.read_text())
71+
# A lambdapi.pkg is required for the server to treat the dir as a
72+
# package root.
73+
pkg = pathlib.Path(cls.lib_root) / "lambdapi.pkg"
74+
pkg.write_text("package_name = test\nroot_path = test\n")
75+
76+
@classmethod
77+
def tearDownClass(cls):
78+
cls._tmpdir.cleanup()
79+
80+
def setUp(self):
81+
map_dirs = list(self.extra_map_dirs)
82+
md = stdlib_map_dir()
83+
if md:
84+
map_dirs.append(md)
85+
self.server = LSPServer(
86+
lib_root=self.lib_root,
87+
map_dirs=map_dirs,
88+
standard_lsp=self.standard_lsp,
89+
log_file=os.environ.get("LSP_TEST_LOG"),
90+
binary=_lambdapi_binary(),
91+
)
92+
self.server.start()
93+
self.addCleanup(self.server.stop)
94+
td = {}
95+
if self.advertise_snippet_support:
96+
td["completion"] = {"completionItem": {"snippetSupport": True}}
97+
if self.advertise_hierarchical_symbols:
98+
td["documentSymbol"] = {
99+
"hierarchicalDocumentSymbolSupport": True}
100+
caps = {"textDocument": td} if td else {}
101+
self.server.initialize(
102+
root_uri=f"file://{self.lib_root}", capabilities=caps)
103+
104+
# --- Fixture helpers --------------------------------------------------
105+
106+
def fixture_path(self, name):
107+
return os.path.join(self.lib_root, name)
108+
109+
def fixture_uri(self, name):
110+
return "file://" + self.fixture_path(name)
111+
112+
def open_fixture(self, name, wait=True):
113+
"""Open a fixture and return (uri, text, SourceFile, diagnostics)."""
114+
path = self.fixture_path(name)
115+
with open(path) as f:
116+
text = f.read()
117+
uri = "file://" + path
118+
self.server.did_open(uri, text)
119+
diags = []
120+
if wait:
121+
notifs = self.server.drain_notifications(timeout=5.0)
122+
diags = self.server.extract_diagnostics(notifs, uri=uri)
123+
return uri, text, SourceFile(text), diags
124+
125+
def open_text(self, name, text, wait=True):
126+
"""Open an inline text as a fresh doc under [name]."""
127+
path = os.path.join(self.lib_root, name)
128+
with open(path, "w") as f:
129+
f.write(text)
130+
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
131+
uri = "file://" + path
132+
self.server.did_open(uri, text)
133+
diags = []
134+
if wait:
135+
notifs = self.server.drain_notifications(timeout=5.0)
136+
diags = self.server.extract_diagnostics(notifs, uri=uri)
137+
return uri, SourceFile(text), diags
138+
139+
# --- Diagnostic helpers ----------------------------------------------
140+
141+
@staticmethod
142+
def errors(diagnostics):
143+
return [d for d in diagnostics if d.get("severity") == 1]
144+
145+
@staticmethod
146+
def hints(diagnostics):
147+
return [d for d in diagnostics if d.get("severity") == 4]
148+
149+
def assertNoErrors(self, diagnostics, msg=""):
150+
errs = self.errors(diagnostics)
151+
if errs:
152+
self.fail(
153+
f"{msg}: {len(errs)} error(s), first: {errs[0].get('message')}"
154+
if msg else
155+
f"{len(errs)} error(s), first: {errs[0].get('message')}")
156+
157+
def assert_server_alive(self):
158+
"""Prove the server can still handle a request (via a real open doc)."""
159+
uri, _, _, _ = self.open_fixture("simple.lp")
160+
syms = self.server.document_symbol(uri)
161+
self.assertIsNotNone(syms)

0 commit comments

Comments
 (0)