lean-lsp-mcp 0.16.1__tar.gz → 0.16.2__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 (32) hide show
  1. {lean_lsp_mcp-0.16.1/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.16.2}/PKG-INFO +3 -4
  2. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/pyproject.toml +3 -3
  3. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/models.py +77 -0
  4. lean_lsp_mcp-0.16.2/src/lean_lsp_mcp/search_utils.py +236 -0
  5. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/server.py +37 -34
  6. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2/src/lean_lsp_mcp.egg-info}/PKG-INFO +3 -4
  7. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -1
  8. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/requires.txt +2 -3
  9. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_diagnostic_line_range.py +55 -41
  10. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_search_tools.py +20 -10
  11. lean_lsp_mcp-0.16.2/tests/test_structured_output.py +124 -0
  12. lean_lsp_mcp-0.16.1/src/lean_lsp_mcp/search_utils.py +0 -142
  13. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/LICENSE +0 -0
  14. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/README.md +0 -0
  15. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/setup.cfg +0 -0
  16. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__init__.py +0 -0
  17. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/__main__.py +0 -0
  18. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/client_utils.py +0 -0
  19. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/file_utils.py +0 -0
  20. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/instructions.py +0 -0
  21. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/loogle.py +0 -0
  22. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/outline_utils.py +0 -0
  23. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp/utils.py +0 -0
  24. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  25. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  26. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  27. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_editor_tools.py +0 -0
  28. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_file_caching.py +0 -0
  29. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_logging.py +0 -0
  30. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_misc_tools.py +0 -0
  31. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_outline.py +0 -0
  32. {lean_lsp_mcp-0.16.1 → lean_lsp_mcp-0.16.2}/tests/test_project_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.16.1
3
+ Version: 0.16.2
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,9 +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.6.1
12
- Requires-Dist: mcp[cli]==1.23.1
13
- Requires-Dist: mcp[cli]>=1.22.0
11
+ Requires-Dist: leanclient==0.6.2
12
+ Requires-Dist: mcp[cli]==1.24.0
14
13
  Requires-Dist: orjson>=3.11.1
15
14
  Provides-Extra: lint
16
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.16.1"
3
+ version = "0.16.2"
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.6.1",
11
- "mcp[cli]==1.23.1", "mcp[cli]>=1.22.0",
10
+ "leanclient==0.6.2",
11
+ "mcp[cli]==1.24.0",
12
12
  "orjson>=3.11.1",
13
13
  ]
14
14
 
@@ -118,3 +118,80 @@ class RunResult(BaseModel):
118
118
  class DeclarationInfo(BaseModel):
119
119
  file_path: str = Field(description="Path to declaration file")
120
120
  content: str = Field(description="File content")
121
+
122
+
123
+ # Wrapper models for list-returning tools
124
+ # FastMCP flattens bare lists into separate TextContent blocks, causing serialization issues.
125
+ # Wrapping in a model ensures proper JSON serialization.
126
+
127
+
128
+ class DiagnosticsResult(BaseModel):
129
+ """Wrapper for diagnostic messages list."""
130
+
131
+ items: List[DiagnosticMessage] = Field(
132
+ default_factory=list, description="List of diagnostic messages"
133
+ )
134
+
135
+
136
+ class CompletionsResult(BaseModel):
137
+ """Wrapper for completions list."""
138
+
139
+ items: List[CompletionItem] = Field(
140
+ default_factory=list, description="List of completion items"
141
+ )
142
+
143
+
144
+ class MultiAttemptResult(BaseModel):
145
+ """Wrapper for multi-attempt results list."""
146
+
147
+ items: List[AttemptResult] = Field(
148
+ default_factory=list, description="List of attempt results"
149
+ )
150
+
151
+
152
+ class LocalSearchResults(BaseModel):
153
+ """Wrapper for local search results list."""
154
+
155
+ items: List[LocalSearchResult] = Field(
156
+ default_factory=list, description="List of local search results"
157
+ )
158
+
159
+
160
+ class LeanSearchResults(BaseModel):
161
+ """Wrapper for LeanSearch results list."""
162
+
163
+ items: List[LeanSearchResult] = Field(
164
+ default_factory=list, description="List of LeanSearch results"
165
+ )
166
+
167
+
168
+ class LoogleResults(BaseModel):
169
+ """Wrapper for Loogle results list."""
170
+
171
+ items: List[LoogleResult] = Field(
172
+ default_factory=list, description="List of Loogle results"
173
+ )
174
+
175
+
176
+ class LeanFinderResults(BaseModel):
177
+ """Wrapper for Lean Finder results list."""
178
+
179
+ items: List[LeanFinderResult] = Field(
180
+ default_factory=list, description="List of Lean Finder results"
181
+ )
182
+
183
+
184
+ class StateSearchResults(BaseModel):
185
+ """Wrapper for state search results list."""
186
+
187
+ items: List[StateSearchResult] = Field(
188
+ default_factory=list, description="List of state search results"
189
+ )
190
+
191
+
192
+ class PremiseResults(BaseModel):
193
+ """Wrapper for premise results list."""
194
+
195
+ items: List[PremiseResult] = Field(
196
+ default_factory=list, description="List of premise results"
197
+ )
@@ -0,0 +1,236 @@
1
+ """Utilities for Lean search tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from functools import lru_cache
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import threading
12
+ from orjson import loads as _json_loads
13
+ from pathlib import Path
14
+
15
+
16
+ INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
17
+
18
+ _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
19
+ "Windows": (
20
+ "winget install BurntSushi.ripgrep.MSVC",
21
+ "choco install ripgrep",
22
+ ),
23
+ "Darwin": ("brew install ripgrep",),
24
+ "Linux": (
25
+ "sudo apt-get install ripgrep",
26
+ "sudo dnf install ripgrep",
27
+ ),
28
+ }
29
+
30
+
31
+ def _create_ripgrep_process(command: list[str], *, cwd: str) -> subprocess.Popen[str]:
32
+ """Spawn ripgrep and return a process with line-streaming stdout.
33
+
34
+ Separated for test monkeypatching and to allow early termination once we
35
+ have enough matches.
36
+ """
37
+ return subprocess.Popen(
38
+ command,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.PIPE,
41
+ text=True,
42
+ cwd=cwd,
43
+ )
44
+
45
+
46
+ def check_ripgrep_status() -> tuple[bool, str]:
47
+ """Check whether ``rg`` is available on PATH and return status + message."""
48
+
49
+ if shutil.which("rg"):
50
+ return True, ""
51
+
52
+ system = platform.system()
53
+ platform_instructions = _PLATFORM_INSTRUCTIONS.get(
54
+ system, ("Check alternative installation methods.",)
55
+ )
56
+
57
+ lines = [
58
+ "ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
59
+ "",
60
+ "Installation options:",
61
+ *(f" - {item}" for item in platform_instructions),
62
+ f"More installation options: {INSTALL_URL}",
63
+ ]
64
+
65
+ return False, "\n".join(lines)
66
+
67
+
68
+ def lean_local_search(
69
+ query: str,
70
+ limit: int = 32,
71
+ project_root: Path | None = None,
72
+ ) -> list[dict[str, str]]:
73
+ """Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
74
+ root = (project_root or Path.cwd()).resolve()
75
+
76
+ pattern = (
77
+ rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
78
+ rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
79
+ )
80
+
81
+ command = [
82
+ "rg",
83
+ "--json",
84
+ "--no-ignore",
85
+ "--smart-case",
86
+ "--hidden",
87
+ "--color",
88
+ "never",
89
+ "--no-messages",
90
+ "-g",
91
+ "*.lean",
92
+ "-g",
93
+ "!.git/**",
94
+ "-g",
95
+ "!.lake/build/**",
96
+ pattern,
97
+ str(root),
98
+ ]
99
+
100
+ if lean_src := _get_lean_src_search_path():
101
+ command.append(lean_src)
102
+
103
+ process = _create_ripgrep_process(command, cwd=str(root))
104
+
105
+ matches: list[dict[str, str]] = []
106
+ stderr_text = ""
107
+ terminated_early = False
108
+ stderr_chunks: list[str] = []
109
+ stderr_chars = 0
110
+ stderr_truncated = False
111
+ max_stderr_chars = 100_000
112
+
113
+ def _drain_stderr(pipe) -> None:
114
+ nonlocal stderr_chars, stderr_truncated
115
+ try:
116
+ for err_line in pipe:
117
+ if stderr_chars < max_stderr_chars:
118
+ stderr_chunks.append(err_line)
119
+ stderr_chars += len(err_line)
120
+ else:
121
+ stderr_truncated = True
122
+ except Exception:
123
+ return
124
+
125
+ stderr_thread: threading.Thread | None = None
126
+ if process.stderr is not None:
127
+ stderr_thread = threading.Thread(
128
+ target=_drain_stderr,
129
+ args=(process.stderr,),
130
+ name="lean-local-search-rg-stderr",
131
+ daemon=True,
132
+ )
133
+ stderr_thread.start()
134
+
135
+ try:
136
+ stdout = process.stdout
137
+ if stdout is None:
138
+ raise RuntimeError("ripgrep did not provide stdout pipe")
139
+
140
+ for line in stdout:
141
+ if not line or (event := _json_loads(line)).get("type") != "match":
142
+ continue
143
+
144
+ data = event["data"]
145
+ parts = data["lines"]["text"].lstrip().split(maxsplit=2)
146
+ if len(parts) < 2:
147
+ continue
148
+
149
+ decl_kind, decl_name = parts[0], parts[1].rstrip(":")
150
+ file_path = Path(data["path"]["text"])
151
+ abs_path = (
152
+ file_path if file_path.is_absolute() else (root / file_path).resolve()
153
+ )
154
+
155
+ try:
156
+ display_path = str(abs_path.relative_to(root))
157
+ except ValueError:
158
+ display_path = str(file_path)
159
+
160
+ matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
161
+
162
+ if len(matches) >= limit:
163
+ terminated_early = True
164
+ try:
165
+ process.terminate()
166
+ except Exception:
167
+ pass
168
+ break
169
+
170
+ try:
171
+ if terminated_early:
172
+ process.wait(timeout=5)
173
+ else:
174
+ process.wait()
175
+ except subprocess.TimeoutExpired:
176
+ process.kill()
177
+ process.wait()
178
+ finally:
179
+ if process.returncode is None:
180
+ try:
181
+ process.terminate()
182
+ except Exception:
183
+ pass
184
+ try:
185
+ process.wait(timeout=5)
186
+ except Exception:
187
+ try:
188
+ process.kill()
189
+ except Exception:
190
+ pass
191
+ try:
192
+ process.wait(timeout=5)
193
+ except Exception:
194
+ pass
195
+ if stderr_thread is not None:
196
+ stderr_thread.join(timeout=1)
197
+ if process.stdout is not None:
198
+ process.stdout.close()
199
+ if process.stderr is not None:
200
+ process.stderr.close()
201
+
202
+ if stderr_chunks:
203
+ stderr_text = "".join(stderr_chunks)
204
+ if stderr_truncated:
205
+ stderr_text += "\n[stderr truncated]"
206
+
207
+ returncode = process.returncode if process.returncode is not None else 0
208
+
209
+ if returncode not in (0, 1) and not matches:
210
+ error_msg = f"ripgrep exited with code {returncode}"
211
+ if stderr_text:
212
+ error_msg += f"\n{stderr_text}"
213
+ raise RuntimeError(error_msg)
214
+
215
+ return matches
216
+
217
+
218
+ @lru_cache(maxsize=1)
219
+ def _get_lean_src_search_path() -> str | None:
220
+ """Return the Lean stdlib directory, if available (cache once)."""
221
+ try:
222
+ completed = subprocess.run(
223
+ ["lean", "--print-prefix"], capture_output=True, text=True
224
+ )
225
+ except (FileNotFoundError, subprocess.CalledProcessError):
226
+ return None
227
+
228
+ prefix = completed.stdout.strip()
229
+ if not prefix:
230
+ return None
231
+
232
+ candidate = Path(prefix).expanduser().resolve() / "src"
233
+ if candidate.exists():
234
+ return str(candidate)
235
+
236
+ return None
@@ -12,7 +12,7 @@ import functools
12
12
  import uuid
13
13
  from pathlib import Path
14
14
 
15
- from pydantic import BaseModel, Field
15
+ from pydantic import Field
16
16
  from mcp.server.fastmcp import Context, FastMCP
17
17
  from mcp.server.fastmcp.utilities.logging import get_logger, configure_logging
18
18
  from mcp.server.auth.settings import AuthSettings
@@ -46,6 +46,16 @@ from lean_lsp_mcp.models import (
46
46
  BuildResult,
47
47
  RunResult,
48
48
  DeclarationInfo,
49
+ # Wrapper models for list-returning tools
50
+ DiagnosticsResult,
51
+ CompletionsResult,
52
+ MultiAttemptResult,
53
+ LocalSearchResults,
54
+ LeanSearchResults,
55
+ LoogleResults,
56
+ LeanFinderResults,
57
+ StateSearchResults,
58
+ PremiseResults,
49
59
  )
50
60
  from lean_lsp_mcp.utils import (
51
61
  COMPLETION_KIND,
@@ -67,13 +77,6 @@ class LeanToolError(Exception):
67
77
  pass
68
78
 
69
79
 
70
- def _to_json_array(items: List[BaseModel]) -> str:
71
- """Serialize list of models as JSON array (avoids FastMCP list flattening)."""
72
- return orjson.dumps(
73
- [item.model_dump() for item in items], option=orjson.OPT_INDENT_2
74
- ).decode()
75
-
76
-
77
80
  _LOG_LEVEL = os.environ.get("LEAN_LOG_LEVEL", "INFO")
78
81
  configure_logging("CRITICAL" if _LOG_LEVEL == "NONE" else _LOG_LEVEL)
79
82
  logger = get_logger(__name__)
@@ -414,7 +417,7 @@ def diagnostic_messages(
414
417
  declaration_name: Annotated[
415
418
  Optional[str], Field(description="Filter to declaration (slow)")
416
419
  ] = None,
417
- ) -> str:
420
+ ) -> DiagnosticsResult:
418
421
  """Get compiler diagnostics (errors, warnings, infos) for a Lean file."""
419
422
  rel_path = setup_client_for_file(ctx, file_path)
420
423
  if not rel_path:
@@ -443,7 +446,7 @@ def diagnostic_messages(
443
446
  inactivity_timeout=15.0,
444
447
  )
445
448
 
446
- return _to_json_array(_to_diagnostic_messages(diagnostics))
449
+ return DiagnosticsResult(items=_to_diagnostic_messages(diagnostics))
447
450
 
448
451
 
449
452
  @mcp.tool(
@@ -610,7 +613,7 @@ def completions(
610
613
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
611
614
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
612
615
  max_completions: Annotated[int, Field(description="Max completions", ge=1)] = 32,
613
- ) -> str:
616
+ ) -> CompletionsResult:
614
617
  """Get IDE autocompletions. Use on INCOMPLETE code (after `.` or partial name)."""
615
618
  rel_path = setup_client_for_file(ctx, file_path)
616
619
  if not rel_path:
@@ -639,7 +642,7 @@ def completions(
639
642
  )
640
643
 
641
644
  if not items:
642
- return "[]"
645
+ return CompletionsResult(items=[])
643
646
 
644
647
  # Find the sort term: The last word/identifier before the cursor
645
648
  lines = content.splitlines()
@@ -666,7 +669,7 @@ def completions(
666
669
  items.sort(key=lambda x: x.label.lower())
667
670
 
668
671
  # Truncate if too many results
669
- return _to_json_array(items[:max_completions])
672
+ return CompletionsResult(items=items[:max_completions])
670
673
 
671
674
 
672
675
  @mcp.tool(
@@ -741,7 +744,7 @@ def multi_attempt(
741
744
  snippets: Annotated[
742
745
  List[str], Field(description="Tactics to try (3+ recommended)")
743
746
  ],
744
- ) -> str:
747
+ ) -> MultiAttemptResult:
745
748
  """Try multiple tactics without modifying file. Returns goal state for each."""
746
749
  rel_path = setup_client_for_file(ctx, file_path)
747
750
  if not rel_path:
@@ -753,8 +756,6 @@ def multi_attempt(
753
756
  client.open_file(rel_path)
754
757
 
755
758
  try:
756
- client.open_file(rel_path)
757
-
758
759
  results: List[AttemptResult] = []
759
760
  # Avoid mutating caller-provided snippets; normalize locally per attempt
760
761
  for snippet in snippets:
@@ -781,7 +782,7 @@ def multi_attempt(
781
782
  )
782
783
  )
783
784
 
784
- return _to_json_array(results)
785
+ return MultiAttemptResult(items=results)
785
786
  finally:
786
787
  try:
787
788
  client.close_files([rel_path])
@@ -878,7 +879,7 @@ def local_search(
878
879
  project_root: Annotated[
879
880
  Optional[str], Field(description="Project root (inferred if omitted)")
880
881
  ] = None,
881
- ) -> str:
882
+ ) -> LocalSearchResults:
882
883
  """Fast local search to verify declarations exist. Use BEFORE trying a lemma name."""
883
884
  if not _RG_AVAILABLE:
884
885
  raise LocalSearchError(_RG_MESSAGE)
@@ -910,7 +911,7 @@ def local_search(
910
911
  LocalSearchResult(name=r["name"], kind=r["kind"], file=r["file"])
911
912
  for r in raw_results
912
913
  ]
913
- return _to_json_array(results)
914
+ return LocalSearchResults(items=results)
914
915
  except RuntimeError as exc:
915
916
  raise LocalSearchError(f"Search failed: {exc}")
916
917
 
@@ -929,7 +930,7 @@ def leansearch(
929
930
  ctx: Context,
930
931
  query: Annotated[str, Field(description="Natural language or Lean term query")],
931
932
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
932
- ) -> str:
933
+ ) -> LeanSearchResults:
933
934
  """Search Mathlib via leansearch.net using natural language.
934
935
 
935
936
  Examples: "sum of two even numbers is even", "Cauchy-Schwarz inequality",
@@ -949,7 +950,7 @@ def leansearch(
949
950
  results = orjson.loads(response.read())
950
951
 
951
952
  if not results or not results[0]:
952
- return "[]"
953
+ return LeanSearchResults(items=[])
953
954
 
954
955
  raw_results = [r["result"] for r in results[0][:num_results]]
955
956
  items = [
@@ -961,7 +962,7 @@ def leansearch(
961
962
  )
962
963
  for r in raw_results
963
964
  ]
964
- return _to_json_array(items)
965
+ return LeanSearchResults(items=items)
965
966
 
966
967
 
967
968
  @mcp.tool(
@@ -979,7 +980,7 @@ async def loogle(
979
980
  str, Field(description="Type pattern, constant, or name substring")
980
981
  ],
981
982
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 8,
982
- ) -> str:
983
+ ) -> LoogleResults:
983
984
  """Search Mathlib by type signature via loogle.lean-lang.org.
984
985
 
985
986
  Examples: `Real.sin`, `"comm"`, `(?a → ?b) → List ?a → List ?b`,
@@ -992,7 +993,7 @@ async def loogle(
992
993
  try:
993
994
  results = await app_ctx.loogle_manager.query(query, num_results)
994
995
  if not results:
995
- return "No results found."
996
+ return LoogleResults(items=[])
996
997
  items = [
997
998
  LoogleResult(
998
999
  name=r.get("name", ""),
@@ -1001,7 +1002,7 @@ async def loogle(
1001
1002
  )
1002
1003
  for r in results
1003
1004
  ]
1004
- return _to_json_array(items)
1005
+ return LoogleResults(items=items)
1005
1006
  except Exception as e:
1006
1007
  logger.warning(f"Local loogle failed: {e}, falling back to remote")
1007
1008
 
@@ -1010,13 +1011,15 @@ async def loogle(
1010
1011
  now = int(time.time())
1011
1012
  rate_limit[:] = [t for t in rate_limit if now - t < 30]
1012
1013
  if len(rate_limit) >= 3:
1013
- return "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
1014
+ raise LeanToolError(
1015
+ "Rate limit exceeded: 3 requests per 30s. Use --loogle-local to avoid limits."
1016
+ )
1014
1017
  rate_limit.append(now)
1015
1018
 
1016
1019
  result = loogle_remote(query, num_results)
1017
1020
  if isinstance(result, str):
1018
- return result # Error message
1019
- return _to_json_array(result)
1021
+ raise LeanToolError(result) # Error message from remote
1022
+ return LoogleResults(items=result)
1020
1023
 
1021
1024
 
1022
1025
  @mcp.tool(
@@ -1033,7 +1036,7 @@ def leanfinder(
1033
1036
  ctx: Context,
1034
1037
  query: Annotated[str, Field(description="Mathematical concept or proof state")],
1035
1038
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1036
- ) -> str:
1039
+ ) -> LeanFinderResults:
1037
1040
  """Semantic search by mathematical meaning via Lean Finder.
1038
1041
 
1039
1042
  Examples: "commutativity of addition on natural numbers",
@@ -1065,7 +1068,7 @@ def leanfinder(
1065
1068
  )
1066
1069
  )
1067
1070
 
1068
- return _to_json_array(results)
1071
+ return LeanFinderResults(items=results)
1069
1072
 
1070
1073
 
1071
1074
  @mcp.tool(
@@ -1084,7 +1087,7 @@ def state_search(
1084
1087
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1085
1088
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1086
1089
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 5,
1087
- ) -> str:
1090
+ ) -> StateSearchResults:
1088
1091
  """Find lemmas to close the goal at a position. Searches premise-search.com."""
1089
1092
  rel_path = setup_client_for_file(ctx, file_path)
1090
1093
  if not rel_path:
@@ -1114,7 +1117,7 @@ def state_search(
1114
1117
  results = orjson.loads(response.read())
1115
1118
 
1116
1119
  items = [StateSearchResult(name=r["name"]) for r in results]
1117
- return _to_json_array(items)
1120
+ return StateSearchResults(items=items)
1118
1121
 
1119
1122
 
1120
1123
  @mcp.tool(
@@ -1133,7 +1136,7 @@ def hammer_premise(
1133
1136
  line: Annotated[int, Field(description="Line number (1-indexed)", ge=1)],
1134
1137
  column: Annotated[int, Field(description="Column number (1-indexed)", ge=1)],
1135
1138
  num_results: Annotated[int, Field(description="Max results", ge=1)] = 32,
1136
- ) -> str:
1139
+ ) -> PremiseResults:
1137
1140
  """Get premise suggestions for automation tactics at a goal position.
1138
1141
 
1139
1142
  Returns lemma names to try with `simp only [...]`, `aesop`, or as hints.
@@ -1174,7 +1177,7 @@ def hammer_premise(
1174
1177
  results = orjson.loads(response.read())
1175
1178
 
1176
1179
  items = [PremiseResult(name=r["name"]) for r in results]
1177
- return _to_json_array(items)
1180
+ return PremiseResults(items=items)
1178
1181
 
1179
1182
 
1180
1183
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.16.1
3
+ Version: 0.16.2
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,9 +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.6.1
12
- Requires-Dist: mcp[cli]==1.23.1
13
- Requires-Dist: mcp[cli]>=1.22.0
11
+ Requires-Dist: leanclient==0.6.2
12
+ Requires-Dist: mcp[cli]==1.24.0
14
13
  Requires-Dist: orjson>=3.11.1
15
14
  Provides-Extra: lint
16
15
  Requires-Dist: ruff>=0.2.0; extra == "lint"
@@ -25,4 +25,5 @@ tests/test_logging.py
25
25
  tests/test_misc_tools.py
26
26
  tests/test_outline.py
27
27
  tests/test_project_tools.py
28
- tests/test_search_tools.py
28
+ tests/test_search_tools.py
29
+ tests/test_structured_output.py
@@ -1,6 +1,5 @@
1
- leanclient==0.6.1
2
- mcp[cli]==1.23.1
3
- mcp[cli]>=1.22.0
1
+ leanclient==0.6.2
2
+ mcp[cli]==1.24.0
4
3
  orjson>=3.11.1
5
4
 
6
5
  [dev]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import textwrap
4
5
  from collections.abc import Callable
5
6
  from pathlib import Path
@@ -10,6 +11,21 @@ import pytest
10
11
  from tests.helpers.mcp_client import MCPClient, MCPToolError, result_text
11
12
 
12
13
 
14
+ def parse_diagnostics_result(result) -> list[dict]:
15
+ """Parse diagnostics result, handling both structured and text formats."""
16
+ if result.structuredContent is not None:
17
+ return result.structuredContent.get("items", [])
18
+ # Fallback to parsing text output
19
+ text = result_text(result).strip()
20
+ if not text or text == "[]":
21
+ return []
22
+ try:
23
+ parsed = json.loads(text)
24
+ return parsed.get("items", parsed) if isinstance(parsed, dict) else parsed
25
+ except json.JSONDecodeError:
26
+ return []
27
+
28
+
13
29
  @pytest.fixture(scope="module")
14
30
  def diagnostic_file(test_project_path: Path) -> Path:
15
31
  path = test_project_path / "DiagnosticTest.lean"
@@ -51,12 +67,16 @@ async def test_diagnostic_messages_line_filtering(
51
67
  "lean_diagnostic_messages",
52
68
  {"file_path": str(diagnostic_file)},
53
69
  )
54
- diag_text = result_text(diagnostics)
55
- # Should contain both errors - now returns JSON with "severity" field
56
- assert "string" in diag_text.lower() or "error" in diag_text.lower()
57
- # Count occurrences of "severity" in JSON output (appears as field name)
58
- assert diag_text.count('"severity"') >= 2
59
- all_diag_text = diag_text
70
+ all_items = parse_diagnostics_result(diagnostics)
71
+ # Should contain at least 2 errors
72
+ assert len(all_items) >= 2, (
73
+ f"Expected at least 2 diagnostics, got {len(all_items)}"
74
+ )
75
+ # Verify items have expected structure
76
+ for item in all_items:
77
+ assert "severity" in item
78
+ assert "message" in item
79
+ assert "line" in item
60
80
 
61
81
  # Test 2: Get diagnostics starting from line 10
62
82
  diagnostics = await client.call_tool(
@@ -66,18 +86,13 @@ async def test_diagnostic_messages_line_filtering(
66
86
  "start_line": 10,
67
87
  },
68
88
  )
69
- diag_text = result_text(diagnostics)
70
- # Should contain the second error (line 13: anotherError)
71
- assert "123" in diag_text or "error" in diag_text.lower()
72
- assert len(diag_text) < len(all_diag_text)
89
+ filtered_items = parse_diagnostics_result(diagnostics)
90
+ # Should have fewer diagnostics than unfiltered
91
+ assert len(filtered_items) < len(all_items)
73
92
 
74
93
  # Test 3: Get diagnostics for specific line range
75
- import re
76
-
77
- # Extract start_line from JSON format (e.g., "start_line": 7)
78
- line_matches = re.findall(r'"start_line":\s*(\d+)', all_diag_text)
79
- if line_matches:
80
- first_error_line = int(line_matches[0])
94
+ if all_items:
95
+ first_error_line = all_items[0]["line"]
81
96
  diagnostics = await client.call_tool(
82
97
  "lean_diagnostic_messages",
83
98
  {
@@ -86,9 +101,9 @@ async def test_diagnostic_messages_line_filtering(
86
101
  "end_line": first_error_line,
87
102
  },
88
103
  )
89
- diag_text = result_text(diagnostics)
90
- assert "string" in diag_text.lower() or len(diag_text) > 0
91
- assert len(diag_text) < len(all_diag_text)
104
+ range_items = parse_diagnostics_result(diagnostics)
105
+ assert len(range_items) >= 1
106
+ assert len(range_items) < len(all_items)
92
107
 
93
108
  # Test 4: Get diagnostics for range with no errors (lines 14-17)
94
109
  diagnostics = await client.call_tool(
@@ -99,9 +114,9 @@ async def test_diagnostic_messages_line_filtering(
99
114
  "end_line": 17,
100
115
  },
101
116
  )
102
- diag_text = result_text(diagnostics)
103
- # Empty array or no diagnostics
104
- assert diag_text.strip() == "[]" or len(diag_text.strip()) == 0
117
+ empty_items = parse_diagnostics_result(diagnostics)
118
+ # Should be empty
119
+ assert len(empty_items) == 0, f"Expected no diagnostics, got {len(empty_items)}"
105
120
 
106
121
 
107
122
  @pytest.fixture(scope="module")
@@ -142,9 +157,8 @@ async def test_diagnostic_messages_declaration_filtering(
142
157
  "lean_diagnostic_messages",
143
158
  {"file_path": str(declaration_diagnostic_file)},
144
159
  )
145
- all_diag_text = result_text(all_diagnostics)
146
- assert len(all_diag_text) > 0
147
- assert "string" in all_diag_text.lower() or "type" in all_diag_text.lower()
160
+ all_items = parse_diagnostics_result(all_diagnostics)
161
+ assert len(all_items) > 0, "Expected diagnostics in file with errors"
148
162
 
149
163
  # Test 2: Get diagnostics for firstTheorem only
150
164
  diagnostics = await client.call_tool(
@@ -154,9 +168,9 @@ async def test_diagnostic_messages_declaration_filtering(
154
168
  "declaration_name": "firstTheorem",
155
169
  },
156
170
  )
157
- diag_text = result_text(diagnostics)
158
- assert len(diag_text) > 0
159
- assert len(diag_text) <= len(all_diag_text)
171
+ first_items = parse_diagnostics_result(diagnostics)
172
+ assert len(first_items) > 0
173
+ assert len(first_items) <= len(all_items)
160
174
 
161
175
  # Test 3: Get diagnostics for secondTheorem (has type error in statement)
162
176
  diagnostics = await client.call_tool(
@@ -166,9 +180,8 @@ async def test_diagnostic_messages_declaration_filtering(
166
180
  "declaration_name": "secondTheorem",
167
181
  },
168
182
  )
169
- diag_text = result_text(diagnostics)
170
- assert len(diag_text) > 0
171
- assert isinstance(diag_text, str)
183
+ second_items = parse_diagnostics_result(diagnostics)
184
+ assert len(second_items) > 0
172
185
 
173
186
  # Test 4: Get diagnostics for validFunction (no errors)
174
187
  diagnostics = await client.call_tool(
@@ -178,11 +191,9 @@ async def test_diagnostic_messages_declaration_filtering(
178
191
  "declaration_name": "validFunction",
179
192
  },
180
193
  )
181
- diag_text = result_text(diagnostics)
182
- assert (
183
- "no" in diag_text.lower()
184
- or len(diag_text.strip()) == 0
185
- or diag_text == "[]"
194
+ valid_items = parse_diagnostics_result(diagnostics)
195
+ assert len(valid_items) == 0, (
196
+ f"Expected no diagnostics for valid function, got {len(valid_items)}"
186
197
  )
187
198
 
188
199
 
@@ -250,9 +261,12 @@ async def test_diagnostic_messages_detects_kernel_errors(
250
261
  "lean_diagnostic_messages",
251
262
  {"file_path": str(kernel_error_file)},
252
263
  )
253
- diag_text = result_text(diagnostics)
264
+ items = parse_diagnostics_result(diagnostics)
265
+
266
+ # Should have at least 2 diagnostics
267
+ assert len(items) >= 2, f"Expected at least 2 diagnostics, got {len(items)}"
254
268
 
255
- # Detect kernel error and regular error
256
- assert "kernel" in diag_text.lower() or "unsafe" in diag_text.lower()
257
- assert "rfl" in diag_text.lower() or "failed" in diag_text.lower()
258
- assert diag_text.count("severity") >= 2
269
+ # Check for kernel error and regular error in message content
270
+ all_messages = " ".join(item.get("message", "").lower() for item in items)
271
+ assert "kernel" in all_messages or "unsafe" in all_messages
272
+ assert "rfl" in all_messages or "failed" in all_messages
@@ -10,18 +10,28 @@ import pytest
10
10
  from tests.helpers.mcp_client import MCPClient, result_text
11
11
 
12
12
 
13
- def _first_json_block(result) -> dict[str, str] | None:
14
- """Extract the first JSON object from result content.
13
+ def _first_result_item(result) -> dict[str, str] | None:
14
+ """Extract the first item from a result that returns a list wrapped in {"items": [...]}.
15
15
 
16
- Handles both single JSON objects and JSON arrays (extracts first element).
16
+ Handles both structured content and text content formats.
17
17
  """
18
+ # Try structured content first (new format)
19
+ if result.structuredContent is not None:
20
+ items = result.structuredContent.get("items", [])
21
+ return items[0] if items else None
22
+
23
+ # Fall back to parsing text content
18
24
  for block in result.content:
19
25
  text = getattr(block, "text", "").strip()
20
26
  if not text:
21
27
  continue
22
28
  try:
23
29
  parsed = orjson.loads(text)
24
- # If it's a list, return the first element
30
+ # Handle {"items": [...]} wrapper format
31
+ if isinstance(parsed, dict) and "items" in parsed:
32
+ items = parsed["items"]
33
+ return items[0] if items else None
34
+ # Handle bare list format (legacy)
25
35
  if isinstance(parsed, list):
26
36
  return parsed[0] if parsed else None
27
37
  return parsed
@@ -54,7 +64,7 @@ async def test_search_tools(
54
64
  "lean_loogle",
55
65
  {"query": "Nat"},
56
66
  )
57
- loogle_entry = _first_json_block(loogle)
67
+ loogle_entry = _first_result_item(loogle)
58
68
  if loogle_entry is None:
59
69
  pytest.skip("lean_loogle did not return JSON content")
60
70
  assert {"module", "name", "type"} <= set(loogle_entry.keys())
@@ -78,7 +88,7 @@ async def test_search_tools(
78
88
  },
79
89
  )
80
90
  # Now returns JSON array of StateSearchResult models
81
- state_entry = _first_json_block(state_search)
91
+ state_entry = _first_result_item(state_search)
82
92
  if state_entry is None:
83
93
  pytest.skip("lean_state_search did not return JSON content")
84
94
  assert "name" in state_entry
@@ -92,7 +102,7 @@ async def test_search_tools(
92
102
  },
93
103
  )
94
104
  # Now returns JSON array of PremiseResult models
95
- hammer_entry = _first_json_block(hammer)
105
+ hammer_entry = _first_result_item(hammer)
96
106
  if hammer_entry is None:
97
107
  pytest.skip("lean_hammer_premise did not return JSON content")
98
108
  assert "name" in hammer_entry
@@ -104,7 +114,7 @@ async def test_search_tools(
104
114
  "project_root": str(goal_file.parent),
105
115
  },
106
116
  )
107
- local_entry = _first_json_block(local_search)
117
+ local_entry = _first_result_item(local_search)
108
118
  if local_entry is None:
109
119
  message = result_text(local_search).strip()
110
120
  if "ripgrep" in message.lower():
@@ -120,7 +130,7 @@ async def test_search_tools(
120
130
  "lean_leansearch",
121
131
  {"query": "Nat.succ"},
122
132
  )
123
- entry = _first_json_block(leansearch)
133
+ entry = _first_result_item(leansearch)
124
134
  if entry is None:
125
135
  pytest.skip("lean_leansearch did not return JSON content")
126
136
  assert {"module_name", "name", "type"} <= set(entry.keys())
@@ -133,7 +143,7 @@ async def test_search_tools(
133
143
  "num_results": 3,
134
144
  },
135
145
  )
136
- finder_results = _first_json_block(finder_informal)
146
+ finder_results = _first_result_item(finder_informal)
137
147
  if finder_results:
138
148
  assert isinstance(finder_results, dict) and len(finder_results.keys()) == 3
139
149
  assert {"full_name", "formal_statement", "informal_statement"} <= set(
@@ -0,0 +1,124 @@
1
+ """Test that tool outputs are properly structured JSON, not double-encoded strings.
2
+
3
+ This test addresses an issue where tools returning lists would produce
4
+ double-encoded JSON in the structuredContent field. For example, instead of:
5
+
6
+ {"items": [{"severity": "error", ...}]}
7
+
8
+ The output would be:
9
+
10
+ {"result": "[\\n {\\\"severity\\\": \\\"error\\\", ...}]"}
11
+
12
+ Where the list is encoded as an escaped string instead of a proper JSON array.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import textwrap
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import AsyncContextManager
22
+
23
+ import pytest
24
+
25
+ from tests.helpers.mcp_client import MCPClient
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def error_file(test_project_path: Path) -> Path:
30
+ """Create a Lean file with a type error for testing diagnostics."""
31
+ path = test_project_path / "StructuredOutputTest.lean"
32
+ content = textwrap.dedent(
33
+ """
34
+ import Mathlib
35
+
36
+ -- This line has a type error: assigning String to Nat
37
+ def badDef : Nat := "not a number"
38
+ """
39
+ ).strip()
40
+ if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
41
+ path.write_text(content + "\n", encoding="utf-8")
42
+ return path
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_diagnostics_structured_output_not_double_encoded(
47
+ mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
48
+ error_file: Path,
49
+ ) -> None:
50
+ """Verify diagnostics are returned as structured data, not double-encoded JSON.
51
+
52
+ The structuredContent should contain an 'items' field with a list of
53
+ diagnostic objects, not a 'result' field with an escaped JSON string.
54
+ """
55
+ async with mcp_client_factory() as client:
56
+ result = await client.call_tool(
57
+ "lean_diagnostic_messages",
58
+ {"file_path": str(error_file)},
59
+ )
60
+
61
+ # structuredContent should be present
62
+ assert result.structuredContent is not None, (
63
+ "Tool should return structured content"
64
+ )
65
+
66
+ structured = result.structuredContent
67
+
68
+ # The result should have an 'items' field, not a 'result' field
69
+ # If it has 'result' with a string value, that indicates double-encoding
70
+ if "result" in structured:
71
+ result_value = structured["result"]
72
+ if isinstance(result_value, str):
73
+ # Try to parse it as JSON to confirm it's double-encoded
74
+ try:
75
+ parsed = json.loads(result_value)
76
+ pytest.fail(
77
+ f"Diagnostics are double-encoded! "
78
+ f"structuredContent['result'] is a JSON string that parses to: {type(parsed).__name__}. "
79
+ f"Expected structuredContent to contain 'items' with a list directly."
80
+ )
81
+ except json.JSONDecodeError:
82
+ pass # Not JSON, different issue
83
+
84
+ # Should have 'items' field with a list
85
+ assert "items" in structured, (
86
+ f"Expected 'items' field in structuredContent, got keys: {list(structured.keys())}"
87
+ )
88
+ items = structured["items"]
89
+ assert isinstance(items, list), (
90
+ f"Expected 'items' to be a list, got {type(items).__name__}"
91
+ )
92
+
93
+ # Each item should be a dict with proper fields, not strings
94
+ for i, item in enumerate(items):
95
+ assert isinstance(item, dict), (
96
+ f"Item {i} should be a dict, got {type(item).__name__}. "
97
+ f"This suggests the list items are double-encoded as strings."
98
+ )
99
+
100
+ # Verify the diagnostic has the expected fields as proper types
101
+ assert "severity" in item, f"Item {i} missing 'severity' field"
102
+ assert isinstance(item["severity"], str), (
103
+ f"Item {i} 'severity' should be a string, got {type(item['severity']).__name__}"
104
+ )
105
+
106
+ assert "message" in item, f"Item {i} missing 'message' field"
107
+ assert isinstance(item["message"], str), (
108
+ f"Item {i} 'message' should be a string, got {type(item['message']).__name__}"
109
+ )
110
+
111
+ assert "line" in item, f"Item {i} missing 'line' field"
112
+ assert isinstance(item["line"], int), (
113
+ f"Item {i} 'line' should be an int, got {type(item['line']).__name__}"
114
+ )
115
+
116
+ assert "column" in item, f"Item {i} missing 'column' field"
117
+ assert isinstance(item["column"], int), (
118
+ f"Item {i} 'column' should be an int, got {type(item['column']).__name__}"
119
+ )
120
+
121
+ # We should have at least one diagnostic (the type error)
122
+ assert len(items) >= 1, (
123
+ "Expected at least one diagnostic for the type error in the test file"
124
+ )
@@ -1,142 +0,0 @@
1
- """Utilities for Lean search tools."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Iterable
6
- from functools import lru_cache
7
- import platform
8
- import re
9
- import shutil
10
- import subprocess
11
- from orjson import loads as _json_loads
12
- from pathlib import Path
13
-
14
-
15
- INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"
16
-
17
- _PLATFORM_INSTRUCTIONS: dict[str, Iterable[str]] = {
18
- "Windows": (
19
- "winget install BurntSushi.ripgrep.MSVC",
20
- "choco install ripgrep",
21
- ),
22
- "Darwin": ("brew install ripgrep",),
23
- "Linux": (
24
- "sudo apt-get install ripgrep",
25
- "sudo dnf install ripgrep",
26
- ),
27
- }
28
-
29
-
30
- def check_ripgrep_status() -> tuple[bool, str]:
31
- """Check whether ``rg`` is available on PATH and return status + message."""
32
-
33
- if shutil.which("rg"):
34
- return True, ""
35
-
36
- system = platform.system()
37
- platform_instructions = _PLATFORM_INSTRUCTIONS.get(
38
- system, ("Check alternative installation methods.",)
39
- )
40
-
41
- lines = [
42
- "ripgrep (rg) was not found on your PATH. The lean_local_search tool uses ripgrep for fast declaration search.",
43
- "",
44
- "Installation options:",
45
- *(f" - {item}" for item in platform_instructions),
46
- f"More installation options: {INSTALL_URL}",
47
- ]
48
-
49
- return False, "\n".join(lines)
50
-
51
-
52
- def lean_local_search(
53
- query: str,
54
- limit: int = 32,
55
- project_root: Path | None = None,
56
- ) -> list[dict[str, str]]:
57
- """Search Lean declarations matching ``query`` using ripgrep; results include theorems, lemmas, defs, classes, instances, structures, inductives, abbrevs, and opaque decls."""
58
- root = (project_root or Path.cwd()).resolve()
59
-
60
- pattern = (
61
- rf"^\s*(?:theorem|lemma|def|axiom|class|instance|structure|inductive|abbrev|opaque)\s+"
62
- rf"(?:[A-Za-z0-9_'.]+\.)*{re.escape(query)}[A-Za-z0-9_'.]*(?:\s|:)"
63
- )
64
-
65
- command = [
66
- "rg",
67
- "--json",
68
- "--no-ignore",
69
- "--smart-case",
70
- "--hidden",
71
- "--color",
72
- "never",
73
- "--no-messages",
74
- "-g",
75
- "*.lean",
76
- "-g",
77
- "!.git/**",
78
- "-g",
79
- "!.lake/build/**",
80
- pattern,
81
- str(root),
82
- ]
83
-
84
- if lean_src := _get_lean_src_search_path():
85
- command.append(lean_src)
86
-
87
- result = subprocess.run(command, capture_output=True, text=True, cwd=str(root))
88
-
89
- matches = []
90
- for line in result.stdout.splitlines():
91
- if not line or (event := _json_loads(line)).get("type") != "match":
92
- continue
93
-
94
- data = event["data"]
95
- parts = data["lines"]["text"].lstrip().split(maxsplit=2)
96
- if len(parts) < 2:
97
- continue
98
-
99
- decl_kind, decl_name = parts[0], parts[1].rstrip(":")
100
- file_path = Path(data["path"]["text"])
101
- abs_path = (
102
- file_path if file_path.is_absolute() else (root / file_path).resolve()
103
- )
104
-
105
- try:
106
- display_path = str(abs_path.relative_to(root))
107
- except ValueError:
108
- display_path = str(file_path)
109
-
110
- matches.append({"name": decl_name, "kind": decl_kind, "file": display_path})
111
-
112
- if len(matches) >= limit:
113
- break
114
-
115
- if result.returncode not in (0, 1) and not matches:
116
- error_msg = f"ripgrep exited with code {result.returncode}"
117
- if result.stderr:
118
- error_msg += f"\n{result.stderr}"
119
- raise RuntimeError(error_msg)
120
-
121
- return matches
122
-
123
-
124
- @lru_cache(maxsize=1)
125
- def _get_lean_src_search_path() -> str | None:
126
- """Return the Lean stdlib directory, if available (cache once)."""
127
- try:
128
- completed = subprocess.run(
129
- ["lean", "--print-prefix"], capture_output=True, text=True
130
- )
131
- except (FileNotFoundError, subprocess.CalledProcessError):
132
- return None
133
-
134
- prefix = completed.stdout.strip()
135
- if not prefix:
136
- return None
137
-
138
- candidate = Path(prefix).expanduser().resolve() / "src"
139
- if candidate.exists():
140
- return str(candidate)
141
-
142
- return None
File without changes
File without changes
File without changes