lean-lsp-mcp 0.11.3__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lean_lsp_mcp-0.11.3/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.12.0}/PKG-INFO +3 -3
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/pyproject.toml +3 -3
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/client_utils.py +0 -1
- lean_lsp_mcp-0.12.0/src/lean_lsp_mcp/file_utils.py +49 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/server.py +43 -18
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -3
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -2
- lean_lsp_mcp-0.12.0/tests/test_diagnostic_line_range.py +154 -0
- lean_lsp_mcp-0.12.0/tests/test_file_caching.py +80 -0
- lean_lsp_mcp-0.11.3/src/lean_lsp_mcp/file_utils.py +0 -100
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/LICENSE +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/README.md +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/setup.cfg +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/instructions.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp/utils.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/tests/test_project_tools.py +0 -0
- {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.0}/tests/test_search_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: leanclient==0.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.
|
|
11
|
+
Requires-Dist: leanclient==0.5.0
|
|
12
|
+
Requires-Dist: mcp[cli]==1.21.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
15
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "Lean Theorem Prover MCP"
|
|
5
5
|
authors = [{name="Oliver Dressler", email="hey@oli.show"}]
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"leanclient==0.
|
|
11
|
-
"mcp[cli]==1.
|
|
10
|
+
"leanclient==0.5.0",
|
|
11
|
+
"mcp[cli]==1.21.0",
|
|
12
12
|
"orjson>=3.11.1",
|
|
13
13
|
]
|
|
14
14
|
|
|
@@ -34,7 +34,6 @@ def startup_client(ctx: Context):
|
|
|
34
34
|
return # Client already set up correctly - reuse it!
|
|
35
35
|
# Different project path - close old client
|
|
36
36
|
client.close()
|
|
37
|
-
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
38
37
|
|
|
39
38
|
# Need to create a new client
|
|
40
39
|
with OutputCapture() as output:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_relative_file_path(lean_project_path: Path, file_path: str) -> Optional[str]:
|
|
6
|
+
"""Convert path relative to project path.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
lean_project_path (Path): Path to the Lean project root.
|
|
10
|
+
file_path (str): File path.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
str: Relative file path.
|
|
14
|
+
"""
|
|
15
|
+
file_path_obj = Path(file_path)
|
|
16
|
+
|
|
17
|
+
# Absolute path under project
|
|
18
|
+
if file_path_obj.is_absolute() and file_path_obj.exists():
|
|
19
|
+
try:
|
|
20
|
+
return str(file_path_obj.relative_to(lean_project_path))
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
# Relative to project path
|
|
25
|
+
path = lean_project_path / file_path
|
|
26
|
+
if path.exists():
|
|
27
|
+
return str(path.relative_to(lean_project_path))
|
|
28
|
+
|
|
29
|
+
# Relative to CWD, but only if inside project root
|
|
30
|
+
cwd = Path.cwd()
|
|
31
|
+
path = cwd / file_path
|
|
32
|
+
if path.exists():
|
|
33
|
+
try:
|
|
34
|
+
return str(path.resolve().relative_to(lean_project_path))
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_file_contents(abs_path: str) -> str:
|
|
42
|
+
for enc in ("utf-8", "latin-1"):
|
|
43
|
+
try:
|
|
44
|
+
with open(abs_path, "r", encoding=enc) as f:
|
|
45
|
+
return f.read()
|
|
46
|
+
except UnicodeDecodeError:
|
|
47
|
+
continue
|
|
48
|
+
with open(abs_path, "r", encoding=None) as f:
|
|
49
|
+
return f.read()
|
|
@@ -23,7 +23,7 @@ from lean_lsp_mcp.client_utils import (
|
|
|
23
23
|
startup_client,
|
|
24
24
|
infer_project_path,
|
|
25
25
|
)
|
|
26
|
-
from lean_lsp_mcp.file_utils import get_file_contents
|
|
26
|
+
from lean_lsp_mcp.file_utils import get_file_contents
|
|
27
27
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
28
28
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
29
29
|
from lean_lsp_mcp.utils import (
|
|
@@ -51,7 +51,6 @@ _RG_AVAILABLE, _RG_MESSAGE = check_ripgrep_status()
|
|
|
51
51
|
class AppContext:
|
|
52
52
|
lean_project_path: Path | None
|
|
53
53
|
client: LeanLSPClient | None
|
|
54
|
-
file_content_hashes: Dict[str, str]
|
|
55
54
|
rate_limit: Dict[str, List[int]]
|
|
56
55
|
lean_search_available: bool
|
|
57
56
|
|
|
@@ -68,7 +67,6 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
|
|
68
67
|
context = AppContext(
|
|
69
68
|
lean_project_path=lean_project_path,
|
|
70
69
|
client=None,
|
|
71
|
-
file_content_hashes={},
|
|
72
70
|
rate_limit={
|
|
73
71
|
"leansearch": [],
|
|
74
72
|
"loogle": [],
|
|
@@ -166,7 +164,6 @@ async def lsp_build(
|
|
|
166
164
|
if client:
|
|
167
165
|
ctx.request_context.lifespan_context.client = None
|
|
168
166
|
client.close()
|
|
169
|
-
ctx.request_context.lifespan_context.file_content_hashes.clear()
|
|
170
167
|
|
|
171
168
|
if clean:
|
|
172
169
|
subprocess.run(["lake", "clean"], cwd=lean_project_path_obj, check=False)
|
|
@@ -271,13 +268,20 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
271
268
|
|
|
272
269
|
|
|
273
270
|
@mcp.tool("lean_diagnostic_messages")
|
|
274
|
-
def diagnostic_messages(
|
|
271
|
+
def diagnostic_messages(
|
|
272
|
+
ctx: Context,
|
|
273
|
+
file_path: str,
|
|
274
|
+
start_line: Optional[int] = None,
|
|
275
|
+
end_line: Optional[int] = None,
|
|
276
|
+
) -> List[str] | str:
|
|
275
277
|
"""Get all diagnostic msgs (errors, warnings, infos) for a Lean file.
|
|
276
278
|
|
|
277
279
|
"no goals to be solved" means code may need removal.
|
|
278
280
|
|
|
279
281
|
Args:
|
|
280
282
|
file_path (str): Abs path to Lean file
|
|
283
|
+
start_line (int, optional): Start line (1-indexed) for filtering diagnostics. If provided, only diagnostics from this line onwards are returned.
|
|
284
|
+
end_line (int, optional): End line (1-indexed) for filtering diagnostics. If provided with start_line, only diagnostics in this range are returned.
|
|
281
285
|
|
|
282
286
|
Returns:
|
|
283
287
|
List[str] | str: Diagnostic msgs or error msg
|
|
@@ -286,10 +290,22 @@ def diagnostic_messages(ctx: Context, file_path: str) -> List[str] | str:
|
|
|
286
290
|
if not rel_path:
|
|
287
291
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
288
292
|
|
|
289
|
-
update_file(ctx, rel_path)
|
|
290
|
-
|
|
291
293
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
292
|
-
|
|
294
|
+
|
|
295
|
+
# leanclient requires both start_line and end_line to filter
|
|
296
|
+
# Convert 1-indexed to 0-indexed for leanclient
|
|
297
|
+
if start_line is not None or end_line is not None:
|
|
298
|
+
start_line_0 = (start_line - 1) if start_line is not None else 0
|
|
299
|
+
end_line_0 = (end_line - 1) if end_line is not None else 999999
|
|
300
|
+
diagnostics = client.get_diagnostics(
|
|
301
|
+
rel_path,
|
|
302
|
+
start_line=start_line_0,
|
|
303
|
+
end_line=end_line_0,
|
|
304
|
+
inactivity_timeout=10.0,
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
diagnostics = client.get_diagnostics(rel_path, inactivity_timeout=10.0)
|
|
308
|
+
|
|
293
309
|
return format_diagnostics(diagnostics)
|
|
294
310
|
|
|
295
311
|
|
|
@@ -314,8 +330,9 @@ def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None)
|
|
|
314
330
|
if not rel_path:
|
|
315
331
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
316
332
|
|
|
317
|
-
content = update_file(ctx, rel_path)
|
|
318
333
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
334
|
+
client.open_file(rel_path)
|
|
335
|
+
content = client.get_file_content(rel_path)
|
|
319
336
|
|
|
320
337
|
if column is None:
|
|
321
338
|
lines = content.splitlines()
|
|
@@ -360,14 +377,15 @@ def term_goal(
|
|
|
360
377
|
if not rel_path:
|
|
361
378
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
362
379
|
|
|
363
|
-
|
|
380
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
381
|
+
client.open_file(rel_path)
|
|
382
|
+
content = client.get_file_content(rel_path)
|
|
364
383
|
if column is None:
|
|
365
384
|
lines = content.splitlines()
|
|
366
385
|
if line < 1 or line > len(lines):
|
|
367
386
|
return "Line number out of range. Try elsewhere?"
|
|
368
387
|
column = len(content.splitlines()[line - 1])
|
|
369
388
|
|
|
370
|
-
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
371
389
|
term_goal = client.get_term_goal(rel_path, line - 1, column - 1)
|
|
372
390
|
f_line = format_line(content, line, column)
|
|
373
391
|
if term_goal is None:
|
|
@@ -394,8 +412,9 @@ def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
|
|
|
394
412
|
if not rel_path:
|
|
395
413
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
396
414
|
|
|
397
|
-
file_content = update_file(ctx, rel_path)
|
|
398
415
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
416
|
+
client.open_file(rel_path)
|
|
417
|
+
file_content = client.get_file_content(rel_path)
|
|
399
418
|
hover_info = client.get_hover(rel_path, line - 1, column - 1)
|
|
400
419
|
if hover_info is None:
|
|
401
420
|
f_line = format_line(file_content, line, column)
|
|
@@ -440,9 +459,10 @@ def completions(
|
|
|
440
459
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
441
460
|
if not rel_path:
|
|
442
461
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
443
|
-
content = update_file(ctx, rel_path)
|
|
444
462
|
|
|
445
463
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
464
|
+
client.open_file(rel_path)
|
|
465
|
+
content = client.get_file_content(rel_path)
|
|
446
466
|
completions = client.get_completions(rel_path, line - 1, column - 1)
|
|
447
467
|
formatted = [c["label"] for c in completions if "label" in c]
|
|
448
468
|
f_line = format_line(content, line, column)
|
|
@@ -502,14 +522,16 @@ def declaration_file(ctx: Context, file_path: str, symbol: str) -> str:
|
|
|
502
522
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
503
523
|
if not rel_path:
|
|
504
524
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
505
|
-
|
|
525
|
+
|
|
526
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
527
|
+
client.open_file(rel_path)
|
|
528
|
+
orig_file_content = client.get_file_content(rel_path)
|
|
506
529
|
|
|
507
530
|
# Find the first occurence of the symbol (line and column) in the file,
|
|
508
531
|
position = find_start_position(orig_file_content, symbol)
|
|
509
532
|
if not position:
|
|
510
533
|
return f"Symbol `{symbol}` (case sensitive) not found in file `{rel_path}`. Add it first, then try again."
|
|
511
534
|
|
|
512
|
-
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
513
535
|
declaration = client.get_declarations(
|
|
514
536
|
rel_path, position["line"], position["column"]
|
|
515
537
|
)
|
|
@@ -557,8 +579,9 @@ def multi_attempt(
|
|
|
557
579
|
rel_path = setup_client_for_file(ctx, file_path)
|
|
558
580
|
if not rel_path:
|
|
559
581
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
560
|
-
|
|
582
|
+
|
|
561
583
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
584
|
+
client.open_file(rel_path)
|
|
562
585
|
|
|
563
586
|
try:
|
|
564
587
|
client.open_file(rel_path)
|
|
@@ -858,8 +881,9 @@ def state_search(
|
|
|
858
881
|
if not rel_path:
|
|
859
882
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
860
883
|
|
|
861
|
-
file_contents = update_file(ctx, rel_path)
|
|
862
884
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
885
|
+
client.open_file(rel_path)
|
|
886
|
+
file_contents = client.get_file_content(rel_path)
|
|
863
887
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
864
888
|
|
|
865
889
|
f_line = format_line(file_contents, line, column)
|
|
@@ -908,8 +932,9 @@ def hammer_premise(
|
|
|
908
932
|
if not rel_path:
|
|
909
933
|
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
910
934
|
|
|
911
|
-
file_contents = update_file(ctx, rel_path)
|
|
912
935
|
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
936
|
+
client.open_file(rel_path)
|
|
937
|
+
file_contents = client.get_file_content(rel_path)
|
|
913
938
|
goal = client.get_goal(rel_path, line - 1, column - 1)
|
|
914
939
|
|
|
915
940
|
f_line = format_line(file_contents, line, column)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: leanclient==0.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.
|
|
11
|
+
Requires-Dist: leanclient==0.5.0
|
|
12
|
+
Requires-Dist: mcp[cli]==1.21.0
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
15
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -15,7 +15,9 @@ src/lean_lsp_mcp.egg-info/dependency_links.txt
|
|
|
15
15
|
src/lean_lsp_mcp.egg-info/entry_points.txt
|
|
16
16
|
src/lean_lsp_mcp.egg-info/requires.txt
|
|
17
17
|
src/lean_lsp_mcp.egg-info/top_level.txt
|
|
18
|
+
tests/test_diagnostic_line_range.py
|
|
18
19
|
tests/test_editor_tools.py
|
|
20
|
+
tests/test_file_caching.py
|
|
19
21
|
tests/test_logging.py
|
|
20
22
|
tests/test_misc_tools.py
|
|
21
23
|
tests/test_project_tools.py
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import AsyncContextManager
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tests.helpers.mcp_client import MCPClient, result_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def diagnostic_file(test_project_path: Path) -> Path:
|
|
15
|
+
path = test_project_path / "DiagnosticTest.lean"
|
|
16
|
+
content = textwrap.dedent(
|
|
17
|
+
"""
|
|
18
|
+
import Mathlib
|
|
19
|
+
|
|
20
|
+
-- Line 3: Valid definition
|
|
21
|
+
def validDef : Nat := 42
|
|
22
|
+
|
|
23
|
+
-- Line 6: Error on this line
|
|
24
|
+
def errorDef : Nat := "string"
|
|
25
|
+
|
|
26
|
+
-- Line 9: Another valid definition
|
|
27
|
+
def anotherValidDef : Nat := 100
|
|
28
|
+
|
|
29
|
+
-- Line 12: Another error
|
|
30
|
+
def anotherError : String := 123
|
|
31
|
+
|
|
32
|
+
-- Line 15: Valid theorem
|
|
33
|
+
theorem validTheorem : True := by
|
|
34
|
+
trivial
|
|
35
|
+
"""
|
|
36
|
+
).strip()
|
|
37
|
+
path.write_text(content + "\n", encoding="utf-8")
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_diagnostic_messages_without_line_range(
|
|
43
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
44
|
+
diagnostic_file: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Test getting all diagnostic messages without line range filtering."""
|
|
47
|
+
async with mcp_client_factory() as client:
|
|
48
|
+
diagnostics = await client.call_tool(
|
|
49
|
+
"lean_diagnostic_messages",
|
|
50
|
+
{"file_path": str(diagnostic_file)},
|
|
51
|
+
)
|
|
52
|
+
diag_text = result_text(diagnostics)
|
|
53
|
+
|
|
54
|
+
# Should contain both errors
|
|
55
|
+
assert "string" in diag_text.lower() or "error" in diag_text.lower()
|
|
56
|
+
# Check that multiple diagnostics are returned (at least the two errors we created)
|
|
57
|
+
assert diag_text.count("severity") >= 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_diagnostic_messages_with_start_line(
|
|
62
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
63
|
+
diagnostic_file: Path,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Test getting diagnostic messages starting from a specific line."""
|
|
66
|
+
async with mcp_client_factory() as client:
|
|
67
|
+
# First get all diagnostics to see what we have
|
|
68
|
+
all_diagnostics = await client.call_tool(
|
|
69
|
+
"lean_diagnostic_messages",
|
|
70
|
+
{
|
|
71
|
+
"file_path": str(diagnostic_file),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
all_diag_text = result_text(all_diagnostics)
|
|
75
|
+
|
|
76
|
+
# Get diagnostics starting from line 10 (should only include the second error)
|
|
77
|
+
diagnostics = await client.call_tool(
|
|
78
|
+
"lean_diagnostic_messages",
|
|
79
|
+
{
|
|
80
|
+
"file_path": str(diagnostic_file),
|
|
81
|
+
"start_line": 10,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
diag_text = result_text(diagnostics)
|
|
85
|
+
|
|
86
|
+
# Should contain the second error (line 13: anotherError)
|
|
87
|
+
assert "123" in diag_text or "error" in diag_text.lower()
|
|
88
|
+
# Should have fewer diagnostics than all_diagnostics
|
|
89
|
+
assert len(diag_text) < len(all_diag_text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_diagnostic_messages_with_line_range(
|
|
94
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
95
|
+
diagnostic_file: Path,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Test getting diagnostic messages for a specific line range."""
|
|
98
|
+
async with mcp_client_factory() as client:
|
|
99
|
+
# First, get all diagnostics to see what lines they're actually on
|
|
100
|
+
all_diagnostics = await client.call_tool(
|
|
101
|
+
"lean_diagnostic_messages",
|
|
102
|
+
{"file_path": str(diagnostic_file)},
|
|
103
|
+
)
|
|
104
|
+
all_diag_text = result_text(all_diagnostics)
|
|
105
|
+
|
|
106
|
+
# Extract line numbers from the diagnostics (format: "l7c23-l7c31")
|
|
107
|
+
import re
|
|
108
|
+
|
|
109
|
+
line_matches = re.findall(r"l(\d+)c", all_diag_text)
|
|
110
|
+
if line_matches:
|
|
111
|
+
first_error_line = int(line_matches[0])
|
|
112
|
+
|
|
113
|
+
# Get diagnostics only up to that error line
|
|
114
|
+
diagnostics = await client.call_tool(
|
|
115
|
+
"lean_diagnostic_messages",
|
|
116
|
+
{
|
|
117
|
+
"file_path": str(diagnostic_file),
|
|
118
|
+
"start_line": 1,
|
|
119
|
+
"end_line": first_error_line,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
diag_text = result_text(diagnostics)
|
|
123
|
+
|
|
124
|
+
# Should contain the first error
|
|
125
|
+
assert "string" in diag_text.lower() or len(diag_text) > 0
|
|
126
|
+
# Should be fewer diagnostics than all
|
|
127
|
+
assert len(diag_text) < len(all_diag_text)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_diagnostic_messages_with_no_errors_in_range(
|
|
132
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
133
|
+
diagnostic_file: Path,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Test getting diagnostic messages for a range with no errors."""
|
|
136
|
+
async with mcp_client_factory() as client:
|
|
137
|
+
# Get diagnostics only for lines 14-17 (valid theorem, should have no errors)
|
|
138
|
+
diagnostics = await client.call_tool(
|
|
139
|
+
"lean_diagnostic_messages",
|
|
140
|
+
{
|
|
141
|
+
"file_path": str(diagnostic_file),
|
|
142
|
+
"start_line": 14,
|
|
143
|
+
"end_line": 17,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
diag_text = result_text(diagnostics)
|
|
147
|
+
|
|
148
|
+
# Should indicate no errors or be empty
|
|
149
|
+
# The exact format depends on how the tool formats an empty result
|
|
150
|
+
assert (
|
|
151
|
+
"no" in diag_text.lower()
|
|
152
|
+
or len(diag_text.strip()) == 0
|
|
153
|
+
or diag_text == "[]"
|
|
154
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Test file caching optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import AsyncContextManager
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from tests.helpers.mcp_client import MCPClient, result_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture()
|
|
15
|
+
def cache_test_file(test_project_path: Path) -> Path:
|
|
16
|
+
path = test_project_path / "CacheTest.lean"
|
|
17
|
+
content = """import Mathlib
|
|
18
|
+
|
|
19
|
+
def cachedValue : Nat := 42
|
|
20
|
+
|
|
21
|
+
theorem cachedTheorem : cachedValue = 42 := by rfl
|
|
22
|
+
"""
|
|
23
|
+
path.write_text(content, encoding="utf-8")
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_disk_changes_detected(
|
|
29
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
30
|
+
cache_test_file: Path,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Disk changes must be detected and reprocessed correctly."""
|
|
33
|
+
|
|
34
|
+
async with mcp_client_factory() as client:
|
|
35
|
+
goal1 = await client.call_tool(
|
|
36
|
+
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
37
|
+
)
|
|
38
|
+
result1 = result_text(goal1)
|
|
39
|
+
assert "no goals" in result1.lower()
|
|
40
|
+
|
|
41
|
+
cache_test_file.write_text(
|
|
42
|
+
"""import Mathlib
|
|
43
|
+
|
|
44
|
+
def cachedValue : Nat := 42
|
|
45
|
+
|
|
46
|
+
theorem cachedTheorem : cachedValue = 42 := by sorry
|
|
47
|
+
""",
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
goal2 = await client.call_tool(
|
|
52
|
+
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
53
|
+
)
|
|
54
|
+
result2 = result_text(goal2)
|
|
55
|
+
|
|
56
|
+
assert "cachedValue = 42" in result2, (
|
|
57
|
+
f"Should show goal at sorry, got: {result2}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_multiple_tools_share_file(
|
|
63
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
64
|
+
cache_test_file: Path,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Different tools must reuse cached file state correctly."""
|
|
67
|
+
|
|
68
|
+
async with mcp_client_factory() as client:
|
|
69
|
+
await client.call_tool(
|
|
70
|
+
"lean_diagnostic_messages", {"file_path": str(cache_test_file)}
|
|
71
|
+
)
|
|
72
|
+
await client.call_tool(
|
|
73
|
+
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
74
|
+
)
|
|
75
|
+
hover = await client.call_tool(
|
|
76
|
+
"lean_hover_info",
|
|
77
|
+
{"file_path": str(cache_test_file), "line": 3, "column": 5},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert "cachedValue" in result_text(hover)
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
from typing import Optional, Dict
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from mcp.server.fastmcp import Context
|
|
5
|
-
from mcp.server.fastmcp.utilities.logging import get_logger
|
|
6
|
-
from leanclient import LeanLSPClient
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
logger = get_logger(__name__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def get_relative_file_path(lean_project_path: Path, file_path: str) -> Optional[str]:
|
|
13
|
-
"""Convert path relative to project path.
|
|
14
|
-
|
|
15
|
-
Args:
|
|
16
|
-
lean_project_path (Path): Path to the Lean project root.
|
|
17
|
-
file_path (str): File path.
|
|
18
|
-
|
|
19
|
-
Returns:
|
|
20
|
-
str: Relative file path.
|
|
21
|
-
"""
|
|
22
|
-
file_path_obj = Path(file_path)
|
|
23
|
-
|
|
24
|
-
# Check if absolute path
|
|
25
|
-
if file_path_obj.is_absolute() and file_path_obj.exists():
|
|
26
|
-
try:
|
|
27
|
-
return str(file_path_obj.relative_to(lean_project_path))
|
|
28
|
-
except ValueError:
|
|
29
|
-
# File is not in this project
|
|
30
|
-
return None
|
|
31
|
-
|
|
32
|
-
# Check if relative to project path
|
|
33
|
-
path = lean_project_path / file_path
|
|
34
|
-
if path.exists():
|
|
35
|
-
return str(path.relative_to(lean_project_path))
|
|
36
|
-
|
|
37
|
-
# Check if relative to CWD
|
|
38
|
-
cwd = Path.cwd()
|
|
39
|
-
path = cwd / file_path
|
|
40
|
-
if path.exists():
|
|
41
|
-
try:
|
|
42
|
-
return str(path.relative_to(lean_project_path))
|
|
43
|
-
except ValueError:
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def get_file_contents(abs_path: str) -> str:
|
|
50
|
-
for enc in ("utf-8", "latin-1"):
|
|
51
|
-
try:
|
|
52
|
-
with open(abs_path, "r", encoding=enc) as f:
|
|
53
|
-
return f.read()
|
|
54
|
-
except UnicodeDecodeError:
|
|
55
|
-
continue
|
|
56
|
-
with open(abs_path, "r", encoding=None) as f:
|
|
57
|
-
return f.read()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def update_file(ctx: Context, rel_path: str) -> str:
|
|
61
|
-
"""Update the file contents in the context.
|
|
62
|
-
Args:
|
|
63
|
-
ctx (Context): Context object.
|
|
64
|
-
rel_path (str): Relative file path.
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
str: Updated file contents.
|
|
68
|
-
"""
|
|
69
|
-
# Get file contents and hash
|
|
70
|
-
abs_path = ctx.request_context.lifespan_context.lean_project_path / rel_path
|
|
71
|
-
file_content = get_file_contents(str(abs_path))
|
|
72
|
-
hashed_file = hash(file_content)
|
|
73
|
-
|
|
74
|
-
# Check if file_contents have changed
|
|
75
|
-
file_content_hashes: Dict[str, str] = (
|
|
76
|
-
ctx.request_context.lifespan_context.file_content_hashes
|
|
77
|
-
)
|
|
78
|
-
if rel_path not in file_content_hashes:
|
|
79
|
-
file_content_hashes[rel_path] = hashed_file
|
|
80
|
-
return file_content
|
|
81
|
-
|
|
82
|
-
elif hashed_file == file_content_hashes[rel_path]:
|
|
83
|
-
return file_content
|
|
84
|
-
|
|
85
|
-
# Update file_contents
|
|
86
|
-
file_content_hashes[rel_path] = hashed_file
|
|
87
|
-
|
|
88
|
-
# Reload file in LSP
|
|
89
|
-
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
90
|
-
try:
|
|
91
|
-
client.close_files([rel_path])
|
|
92
|
-
except FileNotFoundError as e:
|
|
93
|
-
logger.warning(
|
|
94
|
-
f"Attempted to close file {rel_path} that wasn't open in LSP client: {e}"
|
|
95
|
-
)
|
|
96
|
-
except Exception as e:
|
|
97
|
-
logger.error(
|
|
98
|
-
f"Unexpected error closing file {rel_path}: {type(e).__name__}: {e}"
|
|
99
|
-
)
|
|
100
|
-
return file_content
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|