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.
- {lean_lsp_mcp-0.18.0/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.19.0}/PKG-INFO +23 -2
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/README.md +21 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/pyproject.toml +2 -2
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/instructions.py +1 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/models.py +21 -0
- lean_lsp_mcp-0.19.0/src/lean_lsp_mcp/profile_utils.py +232 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/server.py +51 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +23 -2
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/requires.txt +1 -1
- lean_lsp_mcp-0.19.0/tests/test_profile.py +103 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/LICENSE +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/setup.cfg +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/client_utils.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/loogle.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp/utils.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_diagnostic_line_range.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_error_handling.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_file_caching.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_outline.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_project_tools.py +0 -0
- {lean_lsp_mcp-0.18.0 → lean_lsp_mcp-0.19.0}/tests/test_search_tools.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|