lean-lsp-mcp 0.11.3__tar.gz → 0.12.1__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.
Files changed (27) hide show
  1. {lean_lsp_mcp-0.11.3/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.12.1}/PKG-INFO +3 -3
  2. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/pyproject.toml +3 -3
  3. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/client_utils.py +0 -1
  4. lean_lsp_mcp-0.12.1/src/lean_lsp_mcp/file_utils.py +49 -0
  5. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/server.py +43 -19
  6. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -3
  7. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  8. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp.egg-info/requires.txt +2 -2
  9. lean_lsp_mcp-0.12.1/tests/test_diagnostic_line_range.py +154 -0
  10. lean_lsp_mcp-0.12.1/tests/test_file_caching.py +80 -0
  11. lean_lsp_mcp-0.11.3/src/lean_lsp_mcp/file_utils.py +0 -100
  12. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/LICENSE +0 -0
  13. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/README.md +0 -0
  14. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/setup.cfg +0 -0
  15. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/__init__.py +0 -0
  16. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/__main__.py +0 -0
  17. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/instructions.py +0 -0
  18. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/search_utils.py +0 -0
  19. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp/utils.py +0 -0
  20. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  21. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  22. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  23. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/tests/test_editor_tools.py +0 -0
  24. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/tests/test_logging.py +0 -0
  25. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/tests/test_misc_tools.py +0 -0
  26. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/tests/test_project_tools.py +0 -0
  27. {lean_lsp_mcp-0.11.3 → lean_lsp_mcp-0.12.1}/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.11.3
3
+ Version: 0.12.1
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.4.0
12
- Requires-Dist: mcp[cli]==1.19.0
11
+ Requires-Dist: leanclient==0.5.1
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.11.3"
3
+ version = "0.12.1"
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.4.0",
11
- "mcp[cli]==1.19.0",
10
+ "leanclient==0.5.1",
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, update_file
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(ctx: Context, file_path: str) -> List[str] | str:
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). Filters from this line.
284
+ end_line (int, optional): End line (1-indexed). Filters to this line.
281
285
 
282
286
  Returns:
283
287
  List[str] | str: Diagnostic msgs or error msg
@@ -286,10 +290,19 @@ 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
- diagnostics = client.get_diagnostics(rel_path)
294
+
295
+ # Convert 1-indexed to 0-indexed for leanclient
296
+ start_line_0 = (start_line - 1) if start_line is not None else None
297
+ end_line_0 = (end_line - 1) if end_line is not None else None
298
+
299
+ diagnostics = client.get_diagnostics(
300
+ rel_path,
301
+ start_line=start_line_0,
302
+ end_line=end_line_0,
303
+ inactivity_timeout=8.0,
304
+ )
305
+
293
306
  return format_diagnostics(diagnostics)
294
307
 
295
308
 
@@ -314,8 +327,9 @@ def goal(ctx: Context, file_path: str, line: int, column: Optional[int] = None)
314
327
  if not rel_path:
315
328
  return "Invalid Lean file path: Unable to start LSP server or load file"
316
329
 
317
- content = update_file(ctx, rel_path)
318
330
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
331
+ client.open_file(rel_path)
332
+ content = client.get_file_content(rel_path)
319
333
 
320
334
  if column is None:
321
335
  lines = content.splitlines()
@@ -360,14 +374,15 @@ def term_goal(
360
374
  if not rel_path:
361
375
  return "Invalid Lean file path: Unable to start LSP server or load file"
362
376
 
363
- content = update_file(ctx, rel_path)
377
+ client: LeanLSPClient = ctx.request_context.lifespan_context.client
378
+ client.open_file(rel_path)
379
+ content = client.get_file_content(rel_path)
364
380
  if column is None:
365
381
  lines = content.splitlines()
366
382
  if line < 1 or line > len(lines):
367
383
  return "Line number out of range. Try elsewhere?"
368
384
  column = len(content.splitlines()[line - 1])
369
385
 
370
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
371
386
  term_goal = client.get_term_goal(rel_path, line - 1, column - 1)
372
387
  f_line = format_line(content, line, column)
373
388
  if term_goal is None:
@@ -394,8 +409,9 @@ def hover(ctx: Context, file_path: str, line: int, column: int) -> str:
394
409
  if not rel_path:
395
410
  return "Invalid Lean file path: Unable to start LSP server or load file"
396
411
 
397
- file_content = update_file(ctx, rel_path)
398
412
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
413
+ client.open_file(rel_path)
414
+ file_content = client.get_file_content(rel_path)
399
415
  hover_info = client.get_hover(rel_path, line - 1, column - 1)
400
416
  if hover_info is None:
401
417
  f_line = format_line(file_content, line, column)
@@ -440,9 +456,10 @@ def completions(
440
456
  rel_path = setup_client_for_file(ctx, file_path)
441
457
  if not rel_path:
442
458
  return "Invalid Lean file path: Unable to start LSP server or load file"
443
- content = update_file(ctx, rel_path)
444
459
 
445
460
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
461
+ client.open_file(rel_path)
462
+ content = client.get_file_content(rel_path)
446
463
  completions = client.get_completions(rel_path, line - 1, column - 1)
447
464
  formatted = [c["label"] for c in completions if "label" in c]
448
465
  f_line = format_line(content, line, column)
@@ -502,14 +519,16 @@ def declaration_file(ctx: Context, file_path: str, symbol: str) -> str:
502
519
  rel_path = setup_client_for_file(ctx, file_path)
503
520
  if not rel_path:
504
521
  return "Invalid Lean file path: Unable to start LSP server or load file"
505
- orig_file_content = update_file(ctx, rel_path)
522
+
523
+ client: LeanLSPClient = ctx.request_context.lifespan_context.client
524
+ client.open_file(rel_path)
525
+ orig_file_content = client.get_file_content(rel_path)
506
526
 
507
527
  # Find the first occurence of the symbol (line and column) in the file,
508
528
  position = find_start_position(orig_file_content, symbol)
509
529
  if not position:
510
530
  return f"Symbol `{symbol}` (case sensitive) not found in file `{rel_path}`. Add it first, then try again."
511
531
 
512
- client: LeanLSPClient = ctx.request_context.lifespan_context.client
513
532
  declaration = client.get_declarations(
514
533
  rel_path, position["line"], position["column"]
515
534
  )
@@ -557,8 +576,9 @@ def multi_attempt(
557
576
  rel_path = setup_client_for_file(ctx, file_path)
558
577
  if not rel_path:
559
578
  return "Invalid Lean file path: Unable to start LSP server or load file"
560
- update_file(ctx, rel_path)
579
+
561
580
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
581
+ client.open_file(rel_path)
562
582
 
563
583
  try:
564
584
  client.open_file(rel_path)
@@ -637,7 +657,9 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
637
657
  assert client is not None # startup_client guarantees an initialized client
638
658
  client.open_file(rel_path)
639
659
  opened_file = True
640
- diagnostics = format_diagnostics(client.get_diagnostics(rel_path))
660
+ diagnostics = format_diagnostics(
661
+ client.get_diagnostics(rel_path, inactivity_timeout=8.0)
662
+ )
641
663
  finally:
642
664
  if opened_file:
643
665
  try:
@@ -858,8 +880,9 @@ def state_search(
858
880
  if not rel_path:
859
881
  return "Invalid Lean file path: Unable to start LSP server or load file"
860
882
 
861
- file_contents = update_file(ctx, rel_path)
862
883
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
884
+ client.open_file(rel_path)
885
+ file_contents = client.get_file_content(rel_path)
863
886
  goal = client.get_goal(rel_path, line - 1, column - 1)
864
887
 
865
888
  f_line = format_line(file_contents, line, column)
@@ -908,8 +931,9 @@ def hammer_premise(
908
931
  if not rel_path:
909
932
  return "Invalid Lean file path: Unable to start LSP server or load file"
910
933
 
911
- file_contents = update_file(ctx, rel_path)
912
934
  client: LeanLSPClient = ctx.request_context.lifespan_context.client
935
+ client.open_file(rel_path)
936
+ file_contents = client.get_file_content(rel_path)
913
937
  goal = client.get_goal(rel_path, line - 1, column - 1)
914
938
 
915
939
  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.11.3
3
+ Version: 0.12.1
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.4.0
12
- Requires-Dist: mcp[cli]==1.19.0
11
+ Requires-Dist: leanclient==0.5.1
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
@@ -1,5 +1,5 @@
1
- leanclient==0.4.0
2
- mcp[cli]==1.19.0
1
+ leanclient==0.5.1
2
+ mcp[cli]==1.21.0
3
3
  orjson>=3.11.1
4
4
 
5
5
  [dev]
@@ -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