lean-lsp-mcp 0.18.0__tar.gz → 0.19.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.
Files changed (34) hide show
  1. {lean_lsp_mcp-0.18.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.19.0}/PKG-INFO +23 -2
  2. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/README.md +21 -0
  3. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/pyproject.toml +2 -2
  4. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/instructions.py +1 -0
  5. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/models.py +21 -0
  6. lean_lsp_mcp-0.19.0/src/lean_lsp_mcp/profile_utils.py +232 -0
  7. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/server.py +51 -0
  8. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +23 -2
  9. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  10. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
  11. lean_lsp_mcp-0.19.0/tests/test_profile.py +103 -0
  12. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/LICENSE +0 -0
  13. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/setup.cfg +0 -0
  14. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__init__.py +0 -0
  15. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__main__.py +0 -0
  16. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  17. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  18. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/loogle.py +0 -0
  19. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
  20. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  21. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/utils.py +0 -0
  22. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  23. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  24. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  25. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_diagnostic_line_range.py +0 -0
  26. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_editor_tools.py +0 -0
  27. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_error_handling.py +0 -0
  28. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_file_caching.py +0 -0
  29. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_logging.py +0 -0
  30. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_misc_tools.py +0 -0
  31. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_outline.py +0 -0
  32. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_project_tools.py +0 -0
  33. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_search_tools.py +0 -0
  34. {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_structured_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,7 +8,7 @@ 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.9.0
11
+ Requires-Dist: leanclient==0.9.1
12
12
  Requires-Dist: mcp[cli]==1.25.0
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Provides-Extra: lint
@@ -299,6 +299,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
299
299
  ```
300
300
  </details>
301
301
 
302
+ #### lean_profile_proof
303
+
304
+ Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
305
+
306
+ <details>
307
+ <summary>Example output (profiling a theorem using simp)</summary>
308
+
309
+ ```json
310
+ {
311
+ "ms": 42.5,
312
+ "lines": [
313
+ {"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
314
+ ],
315
+ "categories": {
316
+ "simp": 35.1,
317
+ "typeclass inference": 4.2
318
+ }
319
+ }
320
+ ```
321
+ </details>
322
+
302
323
  ### Local Search Tools
303
324
 
304
325
  #### lean_local_search
@@ -276,6 +276,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
276
276
  ```
277
277
  </details>
278
278
 
279
+ #### lean_profile_proof
280
+
281
+ Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
282
+
283
+ <details>
284
+ <summary>Example output (profiling a theorem using simp)</summary>
285
+
286
+ ```json
287
+ {
288
+ "ms": 42.5,
289
+ "lines": [
290
+ {"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
291
+ ],
292
+ "categories": {
293
+ "simp": 35.1,
294
+ "typeclass inference": 4.2
295
+ }
296
+ }
297
+ ```
298
+ </details>
299
+
279
300
  ### Local Search Tools
280
301
 
281
302
  #### lean_local_search
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.18.0"
3
+ version = "0.19.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
- dependencies = ["leanclient==0.9.0", "mcp[cli]==1.25.0", "orjson>=3.11.1"]
9
+ dependencies = ["leanclient==0.9.1", "mcp[cli]==1.25.0", "orjson>=3.11.1"]
10
10
 
11
11
  [project.urls]
12
12
  Repository = "https://github.com/oOo0oOo/lean-lsp-mcp"
@@ -13,6 +13,7 @@ INSTRUCTIONS = """## General Rules
13
13
  - **lean_declaration_file**: Get declaration source. Use sparingly (large output).
14
14
  - **lean_run_code**: Run standalone snippet. Use rarely.
15
15
  - **lean_build**: Rebuild + restart LSP. Only if needed (new imports). SLOW!
16
+ - **lean_profile_proof**: Profile a theorem for performance. Shows tactic hotspots. SLOW!
16
17
 
17
18
  ## Search Tools (rate limited)
18
19
  - **lean_leansearch** (3/30s): Natural language → mathlib
@@ -1,6 +1,7 @@
1
1
  """Pydantic models for MCP tool structured outputs."""
2
2
 
3
3
  from typing import List, Optional
4
+
4
5
  from pydantic import BaseModel, Field
5
6
 
6
7
 
@@ -210,3 +211,23 @@ class PremiseResults(BaseModel):
210
211
  items: List[PremiseResult] = Field(
211
212
  default_factory=list, description="List of premise results"
212
213
  )
214
+
215
+
216
+ class LineProfile(BaseModel):
217
+ """Timing for a single source line."""
218
+
219
+ line: int = Field(description="Source line number (1-indexed)")
220
+ ms: float = Field(description="Time in milliseconds")
221
+ text: str = Field(description="Source line content (truncated)")
222
+
223
+
224
+ class ProofProfileResult(BaseModel):
225
+ """Profiling result for a theorem."""
226
+
227
+ ms: float = Field(description="Total elaboration time in ms")
228
+ lines: List[LineProfile] = Field(
229
+ default_factory=list, description="Time per source line (>1% of total)"
230
+ )
231
+ categories: dict[str, float] = Field(
232
+ default_factory=dict, description="Cumulative time by category in ms"
233
+ )
@@ -0,0 +1,232 @@
1
+ """Lean proof profiling via CLI trace output."""
2
+
3
+ import asyncio
4
+ import os
5
+ import re
6
+ import tempfile
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ from lean_lsp_mcp.models import LineProfile, ProofProfileResult
11
+
12
+ _TRACE_RE = re.compile(r"^(\s*)\[([^\]]+)\]\s+\[([\d.]+)\]\s+(.+)$")
13
+ _CUMULATIVE_RE = re.compile(r"^\s+(\S+(?:\s+\S+)*)\s+([\d.]+)(ms|s)$")
14
+ _DECL_RE = re.compile(r"^\s*(?:private\s+)?(theorem|lemma|def)\s+(\S+)")
15
+ _HEADER_RE = re.compile(r"^(import|open|set_option|universe|variable)\s")
16
+ _SKIP_CATEGORIES = {"import", "initialization", "parsing", "interpretation", "linting"}
17
+
18
+
19
+ def _find_header_end(lines: list[str]) -> int:
20
+ """Find where imports/header ends and declarations begin."""
21
+ header_end, in_block = 0, False
22
+ for i, line in enumerate(lines):
23
+ s = line.strip()
24
+ if "/-" in line:
25
+ in_block = True
26
+ if "-/" in line:
27
+ in_block = False
28
+ if in_block or not s or s.startswith("--") or _HEADER_RE.match(line):
29
+ header_end = i + 1
30
+ elif s.startswith(("namespace", "section")):
31
+ header_end = i + 1
32
+ elif _DECL_RE.match(line) or s.startswith(("@[", "private ", "protected ")):
33
+ break
34
+ else:
35
+ header_end = i + 1
36
+ return header_end
37
+
38
+
39
+ def _find_theorem_end(lines: list[str], start: int) -> int:
40
+ """Find where theorem ends (next declaration or EOF)."""
41
+ for i in range(start + 1, len(lines)):
42
+ if _DECL_RE.match(lines[i]):
43
+ return i
44
+ return len(lines)
45
+
46
+
47
+ def _extract_theorem_source(lines: list[str], target_line: int) -> tuple[str, str, int]:
48
+ """Extract imports/header + single theorem. Returns (source, name, theorem_start_in_source)."""
49
+ m = _DECL_RE.match(lines[target_line - 1])
50
+ if not m:
51
+ raise ValueError(f"No theorem/lemma/def at line {target_line}")
52
+
53
+ header_end = _find_header_end(lines)
54
+ theorem_end = _find_theorem_end(lines, target_line - 1)
55
+
56
+ header = "\n".join(lines[:header_end])
57
+ theorem = "\n".join(lines[target_line - 1 : theorem_end])
58
+ return f"{header}\n\n{theorem}\n", m.group(2), header_end + 2
59
+
60
+
61
+ def _parse_output(
62
+ output: str,
63
+ ) -> tuple[list[tuple[int, str, float, str]], dict[str, float]]:
64
+ """Parse trace output into (traces, cumulative). Traces are (depth, cls, ms, msg)."""
65
+ traces, cumulative, in_cumulative = [], {}, False
66
+
67
+ for line in output.splitlines():
68
+ if "cumulative profiling times:" in line:
69
+ in_cumulative = True
70
+ elif in_cumulative and (m := _CUMULATIVE_RE.match(line)):
71
+ cat, val, unit = m.groups()
72
+ cumulative[cat] = float(val) * (1000 if unit == "s" else 1)
73
+ elif not in_cumulative and (m := _TRACE_RE.match(line)):
74
+ indent, cls, time_s, msg = m.groups()
75
+ traces.append((len(indent) // 2, cls, float(time_s) * 1000, msg))
76
+
77
+ return traces, cumulative
78
+
79
+
80
+ def _build_proof_items(
81
+ source_lines: list[str], proof_start: int
82
+ ) -> list[tuple[int, str, bool]]:
83
+ """Build list of (line_no, content, is_bullet) for proof lines."""
84
+ items = []
85
+ for i in range(proof_start, len(source_lines)):
86
+ s = source_lines[i].strip()
87
+ if s and not s.startswith("--"):
88
+ is_bullet = s[0] in "·*-"
89
+ items.append((i + 1, s.lstrip("·*- \t"), is_bullet))
90
+ return items
91
+
92
+
93
+ def _match_line(
94
+ tactic: str, is_bullet: bool, items: list[tuple[int, str, bool]], used: set[int]
95
+ ) -> int | None:
96
+ """Find matching source line for a tactic trace. Returns line number or None."""
97
+ for ln, content, src_bullet in items:
98
+ if ln in used:
99
+ continue
100
+ if is_bullet and src_bullet:
101
+ return ln
102
+ if (
103
+ not is_bullet
104
+ and content
105
+ and (tactic.startswith(content[:25]) or content.startswith(tactic[:25]))
106
+ ):
107
+ return ln
108
+ return None
109
+
110
+
111
+ def _extract_line_times(
112
+ traces: list[tuple[int, str, float, str]],
113
+ name: str,
114
+ proof_items: list[tuple[int, str, bool]],
115
+ ) -> tuple[dict[int, float], float]:
116
+ """Extract per-line timing from traces."""
117
+ line_times: dict[int, float] = defaultdict(float)
118
+ total, value_depth, in_value, tactic_depth = 0.0, 0, False, None
119
+ name_re = re.compile(rf"\b{re.escape(name)}\b")
120
+ used: set[int] = set()
121
+
122
+ for depth, cls, ms, msg in traces:
123
+ if cls == "Elab.definition.value" and name_re.search(msg):
124
+ in_value, value_depth, total = True, depth, ms
125
+ elif cls == "Elab.async" and f"proof of {name}" in msg:
126
+ total = max(total, ms)
127
+ elif in_value:
128
+ if depth <= value_depth:
129
+ break
130
+ if cls == "Elab.step" and not msg.startswith("expected type:"):
131
+ tactic_depth = tactic_depth or depth
132
+ if depth == tactic_depth:
133
+ tactic = msg.split("\n")[0].strip().lstrip("·*- \t")
134
+ if ln := _match_line(tactic, not tactic, proof_items, used):
135
+ line_times[ln] += ms
136
+ used.add(ln)
137
+
138
+ return dict(line_times), total
139
+
140
+
141
+ def _filter_categories(cumulative: dict[str, float]) -> dict[str, float]:
142
+ """Filter to relevant categories >= 1ms."""
143
+ return {
144
+ k: round(v, 1)
145
+ for k, v in sorted(cumulative.items(), key=lambda x: -x[1])
146
+ if k not in _SKIP_CATEGORIES and v >= 1.0
147
+ }
148
+
149
+
150
+ async def _run_lean_profile(file_path: Path, project_path: Path, timeout: float) -> str:
151
+ """Run lean --profile, return output."""
152
+ proc = await asyncio.create_subprocess_exec(
153
+ "lake",
154
+ "env",
155
+ "lean",
156
+ "--profile",
157
+ "-Dtrace.profiler=true",
158
+ "-Dtrace.profiler.threshold=0",
159
+ str(file_path.resolve()),
160
+ stdout=asyncio.subprocess.PIPE,
161
+ stderr=asyncio.subprocess.STDOUT,
162
+ cwd=project_path.resolve(),
163
+ env=os.environ.copy(),
164
+ )
165
+ try:
166
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
167
+ return stdout.decode("utf-8", errors="replace")
168
+ except asyncio.TimeoutError:
169
+ proc.kill()
170
+ await proc.wait()
171
+ raise TimeoutError(f"Profiling timed out after {timeout}s")
172
+
173
+
174
+ def _find_proof_start(source_lines: list[str]) -> int:
175
+ """Find line after ':= by' in source."""
176
+ for i, line in enumerate(source_lines):
177
+ if ":= by" in line or line.rstrip().endswith(" by"):
178
+ return i + 1
179
+ raise ValueError("No 'by' proof found in theorem")
180
+
181
+
182
+ async def profile_theorem(
183
+ file_path: Path,
184
+ theorem_line: int,
185
+ project_path: Path,
186
+ timeout: float = 60.0,
187
+ top_n: int = 5,
188
+ ) -> ProofProfileResult:
189
+ """Profile a theorem via `lean --profile`. Returns per-line timing data."""
190
+ lines = file_path.read_text().splitlines()
191
+ if not (0 < theorem_line <= len(lines)):
192
+ raise ValueError(f"Line {theorem_line} out of range")
193
+
194
+ source, name, src_start = _extract_theorem_source(lines, theorem_line)
195
+ source_lines = source.splitlines()
196
+ line_offset = theorem_line - src_start
197
+ proof_start = _find_proof_start(source_lines)
198
+ proof_items = _build_proof_items(source_lines, proof_start)
199
+
200
+ with tempfile.NamedTemporaryFile(
201
+ mode="w", suffix=".lean", dir=project_path, delete=False
202
+ ) as f:
203
+ f.write(source)
204
+ temp_path = Path(f.name)
205
+
206
+ try:
207
+ output = await _run_lean_profile(temp_path, project_path, timeout)
208
+ finally:
209
+ temp_path.unlink(missing_ok=True)
210
+
211
+ traces, cumulative = _parse_output(output)
212
+ line_times, total = _extract_line_times(traces, name, proof_items)
213
+
214
+ top_lines = sorted(
215
+ [(ln, ms) for ln, ms in line_times.items() if ms >= total * 0.01],
216
+ key=lambda x: -x[1],
217
+ )[:top_n]
218
+
219
+ return ProofProfileResult(
220
+ ms=round(total, 1),
221
+ lines=[
222
+ LineProfile(
223
+ line=ln + line_offset,
224
+ ms=round(ms, 1),
225
+ text=source_lines[ln - 1].strip()[:60]
226
+ if ln <= len(source_lines)
227
+ else "",
228
+ )
229
+ for ln, ms in top_lines
230
+ ],
231
+ categories=_filter_categories(cumulative),
232
+ )
@@ -50,6 +50,7 @@ from lean_lsp_mcp.models import (
50
50
  MultiAttemptResult,
51
51
  PremiseResult,
52
52
  PremiseResults,
53
+ ProofProfileResult,
53
54
  RunResult,
54
55
  StateSearchResult,
55
56
  StateSearchResults,
@@ -1300,5 +1301,55 @@ async def hammer_premise(
1300
1301
  return PremiseResults(items=items)
1301
1302
 
1302
1303
 
1304
+ @mcp.tool(
1305
+ "lean_profile_proof",
1306
+ annotations=ToolAnnotations(
1307
+ title="Profile Proof",
1308
+ readOnlyHint=True,
1309
+ idempotentHint=True,
1310
+ openWorldHint=False,
1311
+ ),
1312
+ )
1313
+ async def profile_proof(
1314
+ ctx: Context,
1315
+ file_path: Annotated[str, Field(description="Absolute path to Lean file")],
1316
+ line: Annotated[
1317
+ int, Field(description="Line where theorem starts (1-indexed)", ge=1)
1318
+ ],
1319
+ top_n: Annotated[
1320
+ int, Field(description="Number of slowest lines to return", ge=1)
1321
+ ] = 5,
1322
+ timeout: Annotated[float, Field(description="Max seconds to wait", ge=1)] = 60.0,
1323
+ ) -> ProofProfileResult:
1324
+ """Run `lean --profile` on a theorem. Returns per-line timing and categories."""
1325
+ from lean_lsp_mcp.profile_utils import profile_theorem
1326
+
1327
+ # Get project path
1328
+ lifespan = ctx.request_context.lifespan_context
1329
+ project_path = lifespan.lean_project_path
1330
+
1331
+ if not project_path:
1332
+ infer_project_path(ctx, file_path)
1333
+ project_path = lifespan.lean_project_path
1334
+
1335
+ if not project_path:
1336
+ raise LeanToolError("Lean project not found")
1337
+
1338
+ file_path_obj = Path(file_path)
1339
+ if not file_path_obj.exists():
1340
+ raise LeanToolError(f"File not found: {file_path}")
1341
+
1342
+ try:
1343
+ return await profile_theorem(
1344
+ file_path=file_path_obj,
1345
+ theorem_line=line,
1346
+ project_path=project_path,
1347
+ timeout=timeout,
1348
+ top_n=top_n,
1349
+ )
1350
+ except (ValueError, TimeoutError) as e:
1351
+ raise LeanToolError(str(e)) from e
1352
+
1353
+
1303
1354
  if __name__ == "__main__":
1304
1355
  mcp.run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -8,7 +8,7 @@ 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.9.0
11
+ Requires-Dist: leanclient==0.9.1
12
12
  Requires-Dist: mcp[cli]==1.25.0
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Provides-Extra: lint
@@ -299,6 +299,27 @@ h_neq : ¬P.card = 2 ^ (Fintype.card S - 1)
299
299
  ```
300
300
  </details>
301
301
 
302
+ #### lean_profile_proof
303
+
304
+ Profile a theorem to identify slow tactics. Runs `lean --profile` on an isolated copy of the theorem and returns per-line timing data.
305
+
306
+ <details>
307
+ <summary>Example output (profiling a theorem using simp)</summary>
308
+
309
+ ```json
310
+ {
311
+ "ms": 42.5,
312
+ "lines": [
313
+ {"line": 7, "ms": 38.2, "text": "simp [add_comm, add_assoc]"}
314
+ ],
315
+ "categories": {
316
+ "simp": 35.1,
317
+ "typeclass inference": 4.2
318
+ }
319
+ }
320
+ ```
321
+ </details>
322
+
302
323
  ### Local Search Tools
303
324
 
304
325
  #### lean_local_search
@@ -9,6 +9,7 @@ src/lean_lsp_mcp/instructions.py
9
9
  src/lean_lsp_mcp/loogle.py
10
10
  src/lean_lsp_mcp/models.py
11
11
  src/lean_lsp_mcp/outline_utils.py
12
+ src/lean_lsp_mcp/profile_utils.py
12
13
  src/lean_lsp_mcp/search_utils.py
13
14
  src/lean_lsp_mcp/server.py
14
15
  src/lean_lsp_mcp/utils.py
@@ -25,6 +26,7 @@ tests/test_file_caching.py
25
26
  tests/test_logging.py
26
27
  tests/test_misc_tools.py
27
28
  tests/test_outline.py
29
+ tests/test_profile.py
28
30
  tests/test_project_tools.py
29
31
  tests/test_search_tools.py
30
32
  tests/test_structured_output.py
@@ -1,4 +1,4 @@
1
- leanclient==0.9.0
1
+ leanclient==0.9.1
2
2
  mcp[cli]==1.25.0
3
3
  orjson>=3.11.1
4
4
 
@@ -0,0 +1,103 @@
1
+ """Integration tests for lean_profile_proof tool.
2
+
3
+ These tests run actual Lean profiling and verify the output structure.
4
+ If Lean's profiler format changes, these tests should fail.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from lean_lsp_mcp.profile_utils import profile_theorem
12
+ from tests.helpers.mcp_client import MCPToolError, result_json
13
+
14
+
15
+ class TestProfileTheorem:
16
+ """Direct API tests - run real Lean profiling."""
17
+
18
+ @pytest.fixture
19
+ def profile_file(self, test_project_path: Path) -> Path:
20
+ return test_project_path / "ProfileTest.lean"
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_profiles_rw_theorem(self, profile_file: Path):
24
+ """Line 3: theorem simple_by using rw tactic."""
25
+ profile = await profile_theorem(
26
+ profile_file, theorem_line=3, project_path=profile_file.parent
27
+ )
28
+ assert profile.ms > 0
29
+ assert len(profile.categories) > 0
30
+ # Should extract timing for line 4 (rw)
31
+ if profile.lines:
32
+ ln = profile.lines[0]
33
+ assert ln.line == 4 # rw is on line 4
34
+ assert "rw" in ln.text
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_profiles_simp_theorem(self, profile_file: Path):
38
+ """Line 6: theorem simp_test using simp tactic."""
39
+ profile = await profile_theorem(
40
+ profile_file, theorem_line=6, project_path=profile_file.parent
41
+ )
42
+ assert profile.ms > 0
43
+ assert "simp" in profile.categories
44
+ if profile.lines:
45
+ assert "simp" in profile.lines[0].text
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_profiles_omega_theorem(self, profile_file: Path):
49
+ """Line 9: theorem omega_test using omega tactic."""
50
+ profile = await profile_theorem(
51
+ profile_file, theorem_line=9, project_path=profile_file.parent
52
+ )
53
+ assert profile.ms > 0
54
+ if profile.lines:
55
+ ln = profile.lines[0]
56
+ assert ln.line == 10 # omega is on line 10
57
+ assert "omega" in ln.text
58
+ assert ln.ms > 0
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_invalid_line_raises(self, profile_file: Path):
62
+ with pytest.raises(ValueError):
63
+ await profile_theorem(
64
+ profile_file, theorem_line=999, project_path=profile_file.parent
65
+ )
66
+
67
+
68
+ class TestProfileProofTool:
69
+ """MCP tool tests."""
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_returns_structured_profile(
73
+ self, mcp_client_factory, test_project_path: Path
74
+ ):
75
+ async with mcp_client_factory() as client:
76
+ result = await client.call_tool(
77
+ "lean_profile_proof",
78
+ {
79
+ "file_path": str(test_project_path / "ProfileTest.lean"),
80
+ "line": 6,
81
+ },
82
+ )
83
+ data = result_json(result)
84
+ assert data["ms"] > 0
85
+ assert "simp" in data["categories"]
86
+ # Verify line structure
87
+ if data["lines"]:
88
+ ln = data["lines"][0]
89
+ assert "text" in ln and "ms" in ln and "line" in ln
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_error_on_invalid_line(
93
+ self, mcp_client_factory, test_project_path: Path
94
+ ):
95
+ async with mcp_client_factory() as client:
96
+ with pytest.raises(MCPToolError):
97
+ await client.call_tool(
98
+ "lean_profile_proof",
99
+ {
100
+ "file_path": str(test_project_path / "ProfileTest.lean"),
101
+ "line": 999,
102
+ },
103
+ )
File without changes
File without changes