lean-lsp-mcp 0.13.1__tar.gz → 0.14.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.13.1/src/lean_lsp_mcp.egg-info → lean_lsp_mcp-0.14.0}/PKG-INFO +11 -7
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/README.md +8 -4
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/pyproject.toml +3 -3
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/client_utils.py +6 -7
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/instructions.py +1 -0
- lean_lsp_mcp-0.14.0/src/lean_lsp_mcp/outline_utils.py +200 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/server.py +45 -4
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/utils.py +20 -1
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0/src/lean_lsp_mcp.egg-info}/PKG-INFO +11 -7
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/requires.txt +2 -2
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_diagnostic_line_range.py +26 -123
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_file_caching.py +18 -24
- lean_lsp_mcp-0.14.0/tests/test_outline.py +117 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/LICENSE +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/setup.cfg +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/__init__.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/__main__.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/file_utils.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp/search_utils.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_editor_tools.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_logging.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_misc_tools.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_project_tools.py +0 -0
- {lean_lsp_mcp-0.13.1 → lean_lsp_mcp-0.14.0}/tests/test_search_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: leanclient==0.5.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.21.
|
|
11
|
+
Requires-Dist: leanclient==0.5.5
|
|
12
|
+
Requires-Dist: mcp[cli]==1.21.2
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
15
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -66,7 +66,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
66
66
|
### 3. Configure your IDE/Setup
|
|
67
67
|
|
|
68
68
|
<details>
|
|
69
|
-
<summary><b>VSCode</b></summary>
|
|
69
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
70
70
|
One-click config setup:
|
|
71
71
|
|
|
72
72
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -118,7 +118,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
118
118
|
</details>
|
|
119
119
|
|
|
120
120
|
<details>
|
|
121
|
-
<summary><b>Cursor</b></summary>
|
|
121
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
122
122
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
123
123
|
|
|
124
124
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -138,7 +138,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
138
138
|
</details>
|
|
139
139
|
|
|
140
140
|
<details>
|
|
141
|
-
<summary><b>Claude Code</b></summary>
|
|
141
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
142
142
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
143
143
|
|
|
144
144
|
```bash
|
|
@@ -166,7 +166,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
166
166
|
|
|
167
167
|
### File interactions (LSP)
|
|
168
168
|
|
|
169
|
-
####
|
|
169
|
+
#### lean_file_outline
|
|
170
|
+
|
|
171
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
172
|
+
|
|
173
|
+
#### lean_file_contents (DEPRECATED)
|
|
170
174
|
|
|
171
175
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
172
176
|
|
|
@@ -44,7 +44,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
44
44
|
### 3. Configure your IDE/Setup
|
|
45
45
|
|
|
46
46
|
<details>
|
|
47
|
-
<summary><b>VSCode</b></summary>
|
|
47
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
48
48
|
One-click config setup:
|
|
49
49
|
|
|
50
50
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -96,7 +96,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
96
96
|
</details>
|
|
97
97
|
|
|
98
98
|
<details>
|
|
99
|
-
<summary><b>Cursor</b></summary>
|
|
99
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
100
100
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
101
101
|
|
|
102
102
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -116,7 +116,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
116
116
|
</details>
|
|
117
117
|
|
|
118
118
|
<details>
|
|
119
|
-
<summary><b>Claude Code</b></summary>
|
|
119
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
120
120
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
121
121
|
|
|
122
122
|
```bash
|
|
@@ -144,7 +144,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
144
144
|
|
|
145
145
|
### File interactions (LSP)
|
|
146
146
|
|
|
147
|
-
####
|
|
147
|
+
#### lean_file_outline
|
|
148
|
+
|
|
149
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
150
|
+
|
|
151
|
+
#### lean_file_contents (DEPRECATED)
|
|
148
152
|
|
|
149
153
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
150
154
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lean-lsp-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.14.0"
|
|
4
4
|
description = "Lean Theorem Prover MCP"
|
|
5
5
|
authors = [{name="Oliver Dressler", email="hey@oli.show"}]
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.10"
|
|
8
8
|
license = "MIT"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"leanclient==0.5.
|
|
11
|
-
"mcp[cli]==1.21.
|
|
10
|
+
"leanclient==0.5.5",
|
|
11
|
+
"mcp[cli]==1.21.2",
|
|
12
12
|
"orjson>=3.11.1",
|
|
13
13
|
]
|
|
14
14
|
|
|
@@ -36,14 +36,13 @@ def startup_client(ctx: Context):
|
|
|
36
36
|
client.close()
|
|
37
37
|
|
|
38
38
|
# Need to create a new client
|
|
39
|
+
# In test environments, prevent repeated cache downloads
|
|
40
|
+
prevent_cache = bool(os.environ.get("LEAN_LSP_TEST_MODE"))
|
|
39
41
|
with OutputCapture() as output:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
logger.warning(f"Initial connection failed, trying with build: {e}")
|
|
45
|
-
client = LeanLSPClient(lean_project_path, initial_build=True)
|
|
46
|
-
logger.info(f"Connected with initial build to {lean_project_path}")
|
|
42
|
+
client = LeanLSPClient(
|
|
43
|
+
lean_project_path, initial_build=False, prevent_cache_get=prevent_cache
|
|
44
|
+
)
|
|
45
|
+
logger.info(f"Connected to Lean language server at {lean_project_path}")
|
|
47
46
|
build_output = output.get_output()
|
|
48
47
|
if build_output:
|
|
49
48
|
logger.debug(f"Build output: {build_output}")
|
|
@@ -5,6 +5,7 @@ INSTRUCTIONS = """## General Rules
|
|
|
5
5
|
- Work iteratively: Small steps, intermediate sorries, frequent checks.
|
|
6
6
|
|
|
7
7
|
## Key Tools
|
|
8
|
+
- lean_file_outline: Concise skeleton of a file (imports, docstrings, declarations). Token efficient.
|
|
8
9
|
- lean_local_search: Confirm declarations (theorems/lemmas/defs/etc.) exist. VERY USEFUL AND FAST!
|
|
9
10
|
- lean_goal: Check proof state. USE OFTEN!
|
|
10
11
|
- lean_diagnostic_messages: Understand current proof situation.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Dict, List, Optional, Tuple
|
|
3
|
+
from leanclient import LeanLSPClient
|
|
4
|
+
from leanclient.utils import DocumentContentChange
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
METHOD_KIND = {6, "method"}
|
|
8
|
+
KIND_TAGS = {"namespace": "Ns"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_info_trees(client: LeanLSPClient, path: str, symbols: List[Dict]) -> Dict[str, str]:
|
|
12
|
+
"""Insert #info_trees commands, collect diagnostics, then revert changes."""
|
|
13
|
+
if not symbols:
|
|
14
|
+
return {}
|
|
15
|
+
|
|
16
|
+
symbol_by_line = {}
|
|
17
|
+
changes = []
|
|
18
|
+
for i, sym in enumerate(sorted(symbols, key=lambda s: s['range']['start']['line'])):
|
|
19
|
+
line = sym['range']['start']['line'] + i
|
|
20
|
+
symbol_by_line[line] = sym['name']
|
|
21
|
+
changes.append(DocumentContentChange("#info_trees in\n", [line, 0], [line, 0]))
|
|
22
|
+
|
|
23
|
+
client.update_file(path, changes)
|
|
24
|
+
diagnostics = client.get_diagnostics(path)
|
|
25
|
+
|
|
26
|
+
info_trees = {
|
|
27
|
+
symbol_by_line[diag['range']['start']['line']]: diag['message']
|
|
28
|
+
for diag in diagnostics
|
|
29
|
+
if diag['severity'] == 3 and diag['range']['start']['line'] in symbol_by_line
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Revert in reverse order
|
|
33
|
+
client.update_file(path, [
|
|
34
|
+
DocumentContentChange("", [line, 0], [line + 1, 0])
|
|
35
|
+
for line in sorted(symbol_by_line.keys(), reverse=True)
|
|
36
|
+
])
|
|
37
|
+
return info_trees
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_type(info: str, name: str) -> Optional[str]:
|
|
41
|
+
"""Extract type signature from info tree message."""
|
|
42
|
+
if m := re.search(rf' • \[Term\] {re.escape(name)} \(isBinder := true\) : ([^@]+) @', info):
|
|
43
|
+
return m.group(1).strip()
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_fields(info: str, name: str) -> List[Tuple[str, str]]:
|
|
48
|
+
"""Extract structure/class fields from info tree message."""
|
|
49
|
+
fields = []
|
|
50
|
+
for pattern in [rf'{re.escape(name)}\.(\w+)', rf'@{re.escape(name)}\.(\w+)']:
|
|
51
|
+
for m in re.finditer(rf' • \[Term\] {pattern} \(isBinder := true\) : (.+?) @', info):
|
|
52
|
+
field_name, full_type = m.groups()
|
|
53
|
+
# Clean up the type signature
|
|
54
|
+
if ']' in full_type:
|
|
55
|
+
field_type = full_type[full_type.rfind(']')+1:].lstrip('→ ').strip()
|
|
56
|
+
elif ' → ' in full_type:
|
|
57
|
+
field_type = full_type.split(' → ')[-1].strip()
|
|
58
|
+
else:
|
|
59
|
+
field_type = full_type.strip()
|
|
60
|
+
fields.append((field_name, field_type))
|
|
61
|
+
return fields
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_declarations(content: str, start: int, end: int) -> List[Dict]:
|
|
65
|
+
"""Extract theorem/lemma/def declarations from file content."""
|
|
66
|
+
lines = content.splitlines()
|
|
67
|
+
decls, i = [], start
|
|
68
|
+
|
|
69
|
+
while i < min(end, len(lines)):
|
|
70
|
+
line = lines[i].strip()
|
|
71
|
+
for keyword in ['theorem', 'lemma', 'def']:
|
|
72
|
+
if line.startswith(f"{keyword} "):
|
|
73
|
+
name = line[len(keyword):].strip().split()[0]
|
|
74
|
+
if name and not name.startswith('_'):
|
|
75
|
+
# Collect until :=
|
|
76
|
+
decl_lines = [line]
|
|
77
|
+
j = i + 1
|
|
78
|
+
while j < min(end, len(lines)) and ':=' not in ' '.join(decl_lines):
|
|
79
|
+
if (next_line := lines[j].strip()) and not next_line.startswith('--'):
|
|
80
|
+
decl_lines.append(next_line)
|
|
81
|
+
j += 1
|
|
82
|
+
|
|
83
|
+
# Extract signature (everything before :=, minus keyword and name)
|
|
84
|
+
full_decl = ' '.join(decl_lines)
|
|
85
|
+
type_sig = None
|
|
86
|
+
if ':=' in full_decl:
|
|
87
|
+
sig_part = full_decl.split(':=', 1)[0].strip()[len(keyword):].strip()
|
|
88
|
+
if sig_part.startswith(name):
|
|
89
|
+
type_sig = sig_part[len(name):].strip()
|
|
90
|
+
|
|
91
|
+
decls.append({
|
|
92
|
+
'name': name,
|
|
93
|
+
'kind': 'method',
|
|
94
|
+
'range': {'start': {'line': i, 'character': 0},
|
|
95
|
+
'end': {'line': i, 'character': len(lines[i])}},
|
|
96
|
+
'_keyword': keyword,
|
|
97
|
+
'_type': type_sig
|
|
98
|
+
})
|
|
99
|
+
break
|
|
100
|
+
i += 1
|
|
101
|
+
return decls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _flatten_symbols(symbols: List[Dict], indent: int = 0, content: str = "") -> List[Tuple[Dict, int]]:
|
|
105
|
+
"""Recursively flatten symbol hierarchy, extracting declarations from namespaces."""
|
|
106
|
+
result = []
|
|
107
|
+
for sym in symbols:
|
|
108
|
+
result.append((sym, indent))
|
|
109
|
+
children = sym.get('children', [])
|
|
110
|
+
|
|
111
|
+
# Extract theorem/lemma/def from namespace bodies
|
|
112
|
+
if content and sym.get('kind') == 'namespace':
|
|
113
|
+
ns_range = sym['range']
|
|
114
|
+
ns_start = ns_range['start']['line']
|
|
115
|
+
ns_end = ns_range['end']['line']
|
|
116
|
+
children = children + _extract_declarations(content, ns_start, ns_end)
|
|
117
|
+
|
|
118
|
+
if children:
|
|
119
|
+
result.extend(_flatten_symbols(children, indent + 1, content))
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _detect_tag(name: str, kind: str, type_sig: str, has_fields: bool, keyword: Optional[str]) -> str:
|
|
124
|
+
"""Determine the appropriate tag for a symbol."""
|
|
125
|
+
if has_fields:
|
|
126
|
+
return "Class" if '→' in type_sig else "Struct"
|
|
127
|
+
if name == "example":
|
|
128
|
+
return "Ex"
|
|
129
|
+
if keyword in {'theorem', 'lemma'}:
|
|
130
|
+
return "Thm"
|
|
131
|
+
if type_sig and any(marker in type_sig for marker in ['∀', '=']):
|
|
132
|
+
return "Thm"
|
|
133
|
+
if type_sig and '→' in type_sig.replace(' → ', '', 1): # More than one arrow
|
|
134
|
+
return "Thm"
|
|
135
|
+
return KIND_TAGS.get(kind, "Def")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_symbol(sym: Dict, type_sigs: Dict, fields_map: Dict, indent: int) -> str:
|
|
139
|
+
"""Format a single symbol with its type signature and fields."""
|
|
140
|
+
name = sym['name']
|
|
141
|
+
type_sig = sym.get('_type') or type_sigs.get(name, "")
|
|
142
|
+
fields = fields_map.get(name, [])
|
|
143
|
+
|
|
144
|
+
tag = _detect_tag(name, sym.get('kind', ''), type_sig, bool(fields), sym.get('_keyword'))
|
|
145
|
+
prefix = "\t" * indent
|
|
146
|
+
|
|
147
|
+
start = sym['range']['start']['line'] + 1
|
|
148
|
+
end = sym['range']['end']['line'] + 1
|
|
149
|
+
line_info = f"L{start}" if start == end else f"L{start}-{end}"
|
|
150
|
+
|
|
151
|
+
result = f"{prefix}[{tag}: {line_info}] {name}"
|
|
152
|
+
if type_sig:
|
|
153
|
+
result += f" : {type_sig}"
|
|
154
|
+
|
|
155
|
+
for fname, ftype in fields:
|
|
156
|
+
result += f"\n{prefix}\t{fname} : {ftype}"
|
|
157
|
+
|
|
158
|
+
return result + "\n"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def generate_outline(client: LeanLSPClient, path: str) -> str:
|
|
162
|
+
"""Generate a concise outline of a Lean file showing structure and signatures."""
|
|
163
|
+
client.open_file(path)
|
|
164
|
+
content = client.get_file_content(path)
|
|
165
|
+
|
|
166
|
+
# Extract imports
|
|
167
|
+
imports = [line.strip()[7:] for line in content.splitlines()
|
|
168
|
+
if line.strip().startswith("import ")]
|
|
169
|
+
|
|
170
|
+
symbols = client.get_document_symbols(path)
|
|
171
|
+
if not symbols and not imports:
|
|
172
|
+
return f"# {path}\n\n*No symbols or imports found*\n"
|
|
173
|
+
|
|
174
|
+
# Flatten symbol tree and extract namespace declarations
|
|
175
|
+
all_symbols = _flatten_symbols(symbols, content=content)
|
|
176
|
+
|
|
177
|
+
# Get info trees only for LSP symbols (not extracted declarations)
|
|
178
|
+
lsp_methods = [s for s, _ in all_symbols if s.get('kind') in METHOD_KIND and '_keyword' not in s]
|
|
179
|
+
info_trees = _get_info_trees(client, path, lsp_methods)
|
|
180
|
+
|
|
181
|
+
# Extract type signatures and fields from info trees
|
|
182
|
+
type_sigs = {name: sig for name, info in info_trees.items()
|
|
183
|
+
if (sig := _extract_type(info, name))}
|
|
184
|
+
fields_map = {name: fields for name, info in info_trees.items()
|
|
185
|
+
if (fields := _extract_fields(info, name))}
|
|
186
|
+
|
|
187
|
+
# Build output
|
|
188
|
+
parts = []
|
|
189
|
+
if imports:
|
|
190
|
+
parts.append("## Imports\n" + "\n".join(imports))
|
|
191
|
+
|
|
192
|
+
if symbols:
|
|
193
|
+
declarations = [
|
|
194
|
+
_format_symbol(sym, type_sigs, fields_map, indent)
|
|
195
|
+
for sym, indent in all_symbols
|
|
196
|
+
if sym.get('kind') in METHOD_KIND or sym.get('_keyword') or sym.get('kind') == 'namespace'
|
|
197
|
+
]
|
|
198
|
+
parts.append("## Declarations\n" + "".join(declarations).rstrip())
|
|
199
|
+
|
|
200
|
+
return "\n\n".join(parts) + "\n"
|
|
@@ -26,8 +26,10 @@ from lean_lsp_mcp.client_utils import (
|
|
|
26
26
|
from lean_lsp_mcp.file_utils import get_file_contents
|
|
27
27
|
from lean_lsp_mcp.instructions import INSTRUCTIONS
|
|
28
28
|
from lean_lsp_mcp.search_utils import check_ripgrep_status, lean_local_search
|
|
29
|
+
from lean_lsp_mcp.outline_utils import generate_outline
|
|
29
30
|
from lean_lsp_mcp.utils import (
|
|
30
31
|
OutputCapture,
|
|
32
|
+
deprecated,
|
|
31
33
|
extract_range,
|
|
32
34
|
filter_diagnostics_by_position,
|
|
33
35
|
find_start_position,
|
|
@@ -236,6 +238,7 @@ async def lsp_build(
|
|
|
236
238
|
|
|
237
239
|
# File level tools
|
|
238
240
|
@mcp.tool("lean_file_contents")
|
|
241
|
+
@deprecated
|
|
239
242
|
def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) -> str:
|
|
240
243
|
"""Get the text contents of a Lean file, optionally with line numbers.
|
|
241
244
|
|
|
@@ -270,6 +273,26 @@ def file_contents(ctx: Context, file_path: str, annotate_lines: bool = True) ->
|
|
|
270
273
|
return data
|
|
271
274
|
|
|
272
275
|
|
|
276
|
+
@mcp.tool("lean_file_outline")
|
|
277
|
+
def file_outline(ctx: Context, file_path: str) -> str:
|
|
278
|
+
"""Get a concise outline showing imports and declarations with type signatures (theorems, defs, classes, structures).
|
|
279
|
+
|
|
280
|
+
Highly useful and token-efficient. Slow-ish.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
file_path (str): Abs path to Lean file
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
str: Markdown formatted outline or error msg
|
|
287
|
+
"""
|
|
288
|
+
rel_path = setup_client_for_file(ctx, file_path)
|
|
289
|
+
if not rel_path:
|
|
290
|
+
return "Invalid Lean file path: Unable to start LSP server or load file"
|
|
291
|
+
|
|
292
|
+
client: LeanLSPClient = ctx.request_context.lifespan_context.client
|
|
293
|
+
return generate_outline(client, rel_path)
|
|
294
|
+
|
|
295
|
+
|
|
273
296
|
@mcp.tool("lean_diagnostic_messages")
|
|
274
297
|
def diagnostic_messages(
|
|
275
298
|
ctx: Context,
|
|
@@ -706,7 +729,7 @@ def run_code(ctx: Context, code: str) -> List[str] | str:
|
|
|
706
729
|
|
|
707
730
|
@mcp.tool("lean_local_search")
|
|
708
731
|
def local_search(
|
|
709
|
-
ctx: Context, query: str, limit: int = 10
|
|
732
|
+
ctx: Context, query: str, limit: int = 10, project_root: str | None = None
|
|
710
733
|
) -> List[Dict[str, str]] | str:
|
|
711
734
|
"""Confirm declarations exist in the current workspace to prevent hallucinating APIs.
|
|
712
735
|
|
|
@@ -724,11 +747,29 @@ def local_search(
|
|
|
724
747
|
if not _RG_AVAILABLE:
|
|
725
748
|
return _RG_MESSAGE
|
|
726
749
|
|
|
727
|
-
|
|
728
|
-
|
|
750
|
+
lifespan = ctx.request_context.lifespan_context
|
|
751
|
+
stored_root = lifespan.lean_project_path
|
|
752
|
+
|
|
753
|
+
if project_root:
|
|
754
|
+
try:
|
|
755
|
+
resolved_root = Path(project_root).expanduser().resolve()
|
|
756
|
+
except OSError as exc: # pragma: no cover - defensive path handling
|
|
757
|
+
return f"Invalid project root '{project_root}': {exc}"
|
|
758
|
+
if not resolved_root.exists():
|
|
759
|
+
return f"Project root '{project_root}' does not exist."
|
|
760
|
+
lifespan.lean_project_path = resolved_root
|
|
761
|
+
else:
|
|
762
|
+
resolved_root = stored_root
|
|
763
|
+
|
|
764
|
+
if resolved_root is None:
|
|
729
765
|
return "Lean project path not set. Call a file-based tool (like lean_file_contents) first to set the project path."
|
|
730
766
|
|
|
731
|
-
|
|
767
|
+
try:
|
|
768
|
+
return lean_local_search(
|
|
769
|
+
query=query.strip(), limit=limit, project_root=resolved_root
|
|
770
|
+
)
|
|
771
|
+
except RuntimeError as exc:
|
|
772
|
+
return f"lean_local_search error:\n{exc}"
|
|
732
773
|
|
|
733
774
|
|
|
734
775
|
@mcp.tool("lean_leansearch")
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import secrets
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
|
-
from typing import List, Dict, Optional
|
|
5
|
+
from typing import List, Dict, Optional, Callable
|
|
6
6
|
|
|
7
7
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
8
8
|
|
|
@@ -334,3 +334,22 @@ def get_declaration_range(
|
|
|
334
334
|
e,
|
|
335
335
|
)
|
|
336
336
|
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def deprecated(func_or_msg: str | Callable | None = None) -> Callable:
|
|
340
|
+
"""Mark a tool as deprecated. Can be used as @deprecated or @deprecated("msg")."""
|
|
341
|
+
msg = "Will be removed soon."
|
|
342
|
+
|
|
343
|
+
def _decorator(func: Callable) -> Callable:
|
|
344
|
+
doc = func.__doc__ or ""
|
|
345
|
+
func.__doc__ = f"DEPRECATED: {msg}\n\n{doc}"
|
|
346
|
+
return func
|
|
347
|
+
|
|
348
|
+
if isinstance(func_or_msg, str):
|
|
349
|
+
msg = func_or_msg
|
|
350
|
+
return _decorator
|
|
351
|
+
|
|
352
|
+
if func_or_msg is None:
|
|
353
|
+
return _decorator
|
|
354
|
+
|
|
355
|
+
return _decorator(func_or_msg)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lean-lsp-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.0
|
|
4
4
|
Summary: Lean Theorem Prover MCP
|
|
5
5
|
Author-email: Oliver Dressler <hey@oli.show>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,8 +8,8 @@ Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
|
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Requires-Dist: leanclient==0.5.
|
|
12
|
-
Requires-Dist: mcp[cli]==1.21.
|
|
11
|
+
Requires-Dist: leanclient==0.5.5
|
|
12
|
+
Requires-Dist: mcp[cli]==1.21.2
|
|
13
13
|
Requires-Dist: orjson>=3.11.1
|
|
14
14
|
Provides-Extra: lint
|
|
15
15
|
Requires-Dist: ruff>=0.2.0; extra == "lint"
|
|
@@ -66,7 +66,7 @@ MCP server that allows agentic interaction with the [Lean theorem prover](https:
|
|
|
66
66
|
### 3. Configure your IDE/Setup
|
|
67
67
|
|
|
68
68
|
<details>
|
|
69
|
-
<summary><b>VSCode</b></summary>
|
|
69
|
+
<summary><b>VSCode (Click to expand)</b></summary>
|
|
70
70
|
One-click config setup:
|
|
71
71
|
|
|
72
72
|
[](https://insiders.vscode.dev/redirect/mcp/install?name=lean-lsp&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22lean-lsp-mcp%22%5D%7D)
|
|
@@ -118,7 +118,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
118
118
|
</details>
|
|
119
119
|
|
|
120
120
|
<details>
|
|
121
|
-
<summary><b>Cursor</b></summary>
|
|
121
|
+
<summary><b>Cursor (Click to expand)</b></summary>
|
|
122
122
|
1. Open MCP Settings (File > Preferences > Cursor Settings > MCP)
|
|
123
123
|
|
|
124
124
|
2. "+ Add a new global MCP Server" > ("Create File")
|
|
@@ -138,7 +138,7 @@ If that doesn't work, you can try cloning this repository and replace `"lean-lsp
|
|
|
138
138
|
</details>
|
|
139
139
|
|
|
140
140
|
<details>
|
|
141
|
-
<summary><b>Claude Code</b></summary>
|
|
141
|
+
<summary><b>Claude Code (Click to expand)</b></summary>
|
|
142
142
|
Run one of these commands in the root directory of your Lean project (where `lakefile.toml` is located):
|
|
143
143
|
|
|
144
144
|
```bash
|
|
@@ -166,7 +166,11 @@ For the local search tool `lean_local_search`, install [ripgrep](https://github.
|
|
|
166
166
|
|
|
167
167
|
### File interactions (LSP)
|
|
168
168
|
|
|
169
|
-
####
|
|
169
|
+
#### lean_file_outline
|
|
170
|
+
|
|
171
|
+
Get a concise outline of a Lean file showing imports and declarations with type signatures (theorems, definitions, classes, structures).
|
|
172
|
+
|
|
173
|
+
#### lean_file_contents (DEPRECATED)
|
|
170
174
|
|
|
171
175
|
Get the contents of a Lean file, optionally with line number annotations.
|
|
172
176
|
|
|
@@ -6,6 +6,7 @@ src/lean_lsp_mcp/__main__.py
|
|
|
6
6
|
src/lean_lsp_mcp/client_utils.py
|
|
7
7
|
src/lean_lsp_mcp/file_utils.py
|
|
8
8
|
src/lean_lsp_mcp/instructions.py
|
|
9
|
+
src/lean_lsp_mcp/outline_utils.py
|
|
9
10
|
src/lean_lsp_mcp/search_utils.py
|
|
10
11
|
src/lean_lsp_mcp/server.py
|
|
11
12
|
src/lean_lsp_mcp/utils.py
|
|
@@ -20,5 +21,6 @@ tests/test_editor_tools.py
|
|
|
20
21
|
tests/test_file_caching.py
|
|
21
22
|
tests/test_logging.py
|
|
22
23
|
tests/test_misc_tools.py
|
|
24
|
+
tests/test_outline.py
|
|
23
25
|
tests/test_project_tools.py
|
|
24
26
|
tests/test_search_tools.py
|
|
@@ -10,7 +10,7 @@ import pytest
|
|
|
10
10
|
from tests.helpers.mcp_client import MCPClient, result_text
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
@pytest.fixture()
|
|
13
|
+
@pytest.fixture(scope="module")
|
|
14
14
|
def diagnostic_file(test_project_path: Path) -> Path:
|
|
15
15
|
path = test_project_path / "DiagnosticTest.lean"
|
|
16
16
|
content = textwrap.dedent(
|
|
@@ -34,46 +34,30 @@ def diagnostic_file(test_project_path: Path) -> Path:
|
|
|
34
34
|
trivial
|
|
35
35
|
"""
|
|
36
36
|
).strip()
|
|
37
|
-
path.
|
|
37
|
+
if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
|
|
38
|
+
path.write_text(content + "\n", encoding="utf-8")
|
|
38
39
|
return path
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
@pytest.mark.asyncio
|
|
42
|
-
async def
|
|
43
|
+
async def test_diagnostic_messages_line_filtering(
|
|
43
44
|
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
44
45
|
diagnostic_file: Path,
|
|
45
46
|
) -> None:
|
|
46
|
-
"""Test
|
|
47
|
+
"""Test all line range filtering scenarios in one client session."""
|
|
47
48
|
async with mcp_client_factory() as client:
|
|
49
|
+
# Test 1: Get all diagnostic messages without line range filtering
|
|
48
50
|
diagnostics = await client.call_tool(
|
|
49
51
|
"lean_diagnostic_messages",
|
|
50
52
|
{"file_path": str(diagnostic_file)},
|
|
51
53
|
)
|
|
52
54
|
diag_text = result_text(diagnostics)
|
|
53
|
-
|
|
54
55
|
# Should contain both errors
|
|
55
56
|
assert "string" in diag_text.lower() or "error" in diag_text.lower()
|
|
56
|
-
# Check that multiple diagnostics are returned (at least the two errors we created)
|
|
57
57
|
assert diag_text.count("severity") >= 2
|
|
58
|
+
all_diag_text = diag_text
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
@pytest.mark.asyncio
|
|
61
|
-
async def test_diagnostic_messages_with_start_line(
|
|
62
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
63
|
-
diagnostic_file: Path,
|
|
64
|
-
) -> None:
|
|
65
|
-
"""Test getting diagnostic messages starting from a specific line."""
|
|
66
|
-
async with mcp_client_factory() as client:
|
|
67
|
-
# First get all diagnostics to see what we have
|
|
68
|
-
all_diagnostics = await client.call_tool(
|
|
69
|
-
"lean_diagnostic_messages",
|
|
70
|
-
{
|
|
71
|
-
"file_path": str(diagnostic_file),
|
|
72
|
-
},
|
|
73
|
-
)
|
|
74
|
-
all_diag_text = result_text(all_diagnostics)
|
|
75
|
-
|
|
76
|
-
# Get diagnostics starting from line 10 (should only include the second error)
|
|
60
|
+
# Test 2: Get diagnostics starting from line 10
|
|
77
61
|
diagnostics = await client.call_tool(
|
|
78
62
|
"lean_diagnostic_messages",
|
|
79
63
|
{
|
|
@@ -82,35 +66,16 @@ async def test_diagnostic_messages_with_start_line(
|
|
|
82
66
|
},
|
|
83
67
|
)
|
|
84
68
|
diag_text = result_text(diagnostics)
|
|
85
|
-
|
|
86
69
|
# Should contain the second error (line 13: anotherError)
|
|
87
70
|
assert "123" in diag_text or "error" in diag_text.lower()
|
|
88
|
-
# Should have fewer diagnostics than all_diagnostics
|
|
89
71
|
assert len(diag_text) < len(all_diag_text)
|
|
90
72
|
|
|
91
|
-
|
|
92
|
-
@pytest.mark.asyncio
|
|
93
|
-
async def test_diagnostic_messages_with_line_range(
|
|
94
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
95
|
-
diagnostic_file: Path,
|
|
96
|
-
) -> None:
|
|
97
|
-
"""Test getting diagnostic messages for a specific line range."""
|
|
98
|
-
async with mcp_client_factory() as client:
|
|
99
|
-
# First, get all diagnostics to see what lines they're actually on
|
|
100
|
-
all_diagnostics = await client.call_tool(
|
|
101
|
-
"lean_diagnostic_messages",
|
|
102
|
-
{"file_path": str(diagnostic_file)},
|
|
103
|
-
)
|
|
104
|
-
all_diag_text = result_text(all_diagnostics)
|
|
105
|
-
|
|
106
|
-
# Extract line numbers from the diagnostics (format: "l7c23-l7c31")
|
|
73
|
+
# Test 3: Get diagnostics for specific line range
|
|
107
74
|
import re
|
|
108
75
|
|
|
109
76
|
line_matches = re.findall(r"l(\d+)c", all_diag_text)
|
|
110
77
|
if line_matches:
|
|
111
78
|
first_error_line = int(line_matches[0])
|
|
112
|
-
|
|
113
|
-
# Get diagnostics only up to that error line
|
|
114
79
|
diagnostics = await client.call_tool(
|
|
115
80
|
"lean_diagnostic_messages",
|
|
116
81
|
{
|
|
@@ -120,21 +85,10 @@ async def test_diagnostic_messages_with_line_range(
|
|
|
120
85
|
},
|
|
121
86
|
)
|
|
122
87
|
diag_text = result_text(diagnostics)
|
|
123
|
-
|
|
124
|
-
# Should contain the first error
|
|
125
88
|
assert "string" in diag_text.lower() or len(diag_text) > 0
|
|
126
|
-
# Should be fewer diagnostics than all
|
|
127
89
|
assert len(diag_text) < len(all_diag_text)
|
|
128
90
|
|
|
129
|
-
|
|
130
|
-
@pytest.mark.asyncio
|
|
131
|
-
async def test_diagnostic_messages_with_no_errors_in_range(
|
|
132
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
133
|
-
diagnostic_file: Path,
|
|
134
|
-
) -> None:
|
|
135
|
-
"""Test getting diagnostic messages for a range with no errors."""
|
|
136
|
-
async with mcp_client_factory() as client:
|
|
137
|
-
# Get diagnostics only for lines 14-17 (valid theorem, should have no errors)
|
|
91
|
+
# Test 4: Get diagnostics for range with no errors (lines 14-17)
|
|
138
92
|
diagnostics = await client.call_tool(
|
|
139
93
|
"lean_diagnostic_messages",
|
|
140
94
|
{
|
|
@@ -144,9 +98,6 @@ async def test_diagnostic_messages_with_no_errors_in_range(
|
|
|
144
98
|
},
|
|
145
99
|
)
|
|
146
100
|
diag_text = result_text(diagnostics)
|
|
147
|
-
|
|
148
|
-
# Should indicate no errors or be empty
|
|
149
|
-
# The exact format depends on how the tool formats an empty result
|
|
150
101
|
assert (
|
|
151
102
|
"no" in diag_text.lower()
|
|
152
103
|
or len(diag_text.strip()) == 0
|
|
@@ -154,7 +105,7 @@ async def test_diagnostic_messages_with_no_errors_in_range(
|
|
|
154
105
|
)
|
|
155
106
|
|
|
156
107
|
|
|
157
|
-
@pytest.fixture()
|
|
108
|
+
@pytest.fixture(scope="module")
|
|
158
109
|
def declaration_diagnostic_file(test_project_path: Path) -> Path:
|
|
159
110
|
"""Create a test file with multiple declarations, some with errors."""
|
|
160
111
|
path = test_project_path / "DeclarationDiagnosticTest.lean"
|
|
@@ -175,29 +126,28 @@ def declaration_diagnostic_file(test_project_path: Path) -> Path:
|
|
|
175
126
|
def anotherValidFunction : String := "hello"
|
|
176
127
|
"""
|
|
177
128
|
).strip()
|
|
178
|
-
path.
|
|
129
|
+
if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
|
|
130
|
+
path.write_text(content + "\n", encoding="utf-8")
|
|
179
131
|
return path
|
|
180
132
|
|
|
181
133
|
|
|
182
134
|
@pytest.mark.asyncio
|
|
183
|
-
async def
|
|
135
|
+
async def test_diagnostic_messages_declaration_filtering(
|
|
184
136
|
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
185
137
|
declaration_diagnostic_file: Path,
|
|
186
138
|
) -> None:
|
|
187
|
-
"""Test filtering
|
|
139
|
+
"""Test all declaration-based filtering scenarios in one client session."""
|
|
188
140
|
async with mcp_client_factory() as client:
|
|
189
|
-
# Get all diagnostics first to verify file has errors
|
|
141
|
+
# Test 1: Get all diagnostics first to verify file has errors
|
|
190
142
|
all_diagnostics = await client.call_tool(
|
|
191
143
|
"lean_diagnostic_messages",
|
|
192
144
|
{"file_path": str(declaration_diagnostic_file)},
|
|
193
145
|
)
|
|
194
146
|
all_diag_text = result_text(all_diagnostics)
|
|
195
|
-
|
|
196
|
-
# File should have diagnostics (contains intentional errors)
|
|
197
147
|
assert len(all_diag_text) > 0
|
|
198
148
|
assert "string" in all_diag_text.lower() or "type" in all_diag_text.lower()
|
|
199
149
|
|
|
200
|
-
# Get diagnostics for firstTheorem only
|
|
150
|
+
# Test 2: Get diagnostics for firstTheorem only
|
|
201
151
|
diagnostics = await client.call_tool(
|
|
202
152
|
"lean_diagnostic_messages",
|
|
203
153
|
{
|
|
@@ -206,33 +156,10 @@ async def test_diagnostic_messages_with_declaration_name_valid(
|
|
|
206
156
|
},
|
|
207
157
|
)
|
|
208
158
|
diag_text = result_text(diagnostics)
|
|
209
|
-
|
|
210
|
-
# Should contain error from firstTheorem
|
|
211
|
-
# The exact error message may vary, but should reference the theorem
|
|
212
159
|
assert len(diag_text) > 0
|
|
213
|
-
|
|
214
|
-
# Filtered diagnostics should be shorter than or equal to all diagnostics
|
|
215
160
|
assert len(diag_text) <= len(all_diag_text)
|
|
216
161
|
|
|
217
|
-
|
|
218
|
-
@pytest.mark.asyncio
|
|
219
|
-
async def test_diagnostic_messages_with_declaration_name_with_errors(
|
|
220
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
221
|
-
declaration_diagnostic_file: Path,
|
|
222
|
-
) -> None:
|
|
223
|
-
"""Test filtering by declaration that has type errors."""
|
|
224
|
-
async with mcp_client_factory() as client:
|
|
225
|
-
# Get all diagnostics first to verify file has errors
|
|
226
|
-
all_diagnostics = await client.call_tool(
|
|
227
|
-
"lean_diagnostic_messages",
|
|
228
|
-
{"file_path": str(declaration_diagnostic_file)},
|
|
229
|
-
)
|
|
230
|
-
all_diag_text = result_text(all_diagnostics)
|
|
231
|
-
|
|
232
|
-
# File should have diagnostics (contains intentional errors)
|
|
233
|
-
assert len(all_diag_text) > 0
|
|
234
|
-
|
|
235
|
-
# Get diagnostics for secondTheorem (has type error in statement)
|
|
162
|
+
# Test 3: Get diagnostics for secondTheorem (has type error in statement)
|
|
236
163
|
diagnostics = await client.call_tool(
|
|
237
164
|
"lean_diagnostic_messages",
|
|
238
165
|
{
|
|
@@ -241,20 +168,10 @@ async def test_diagnostic_messages_with_declaration_name_with_errors(
|
|
|
241
168
|
},
|
|
242
169
|
)
|
|
243
170
|
diag_text = result_text(diagnostics)
|
|
244
|
-
|
|
245
|
-
# secondTheorem has type errors, should have diagnostics
|
|
246
171
|
assert len(diag_text) > 0
|
|
247
172
|
assert isinstance(diag_text, str)
|
|
248
173
|
|
|
249
|
-
|
|
250
|
-
@pytest.mark.asyncio
|
|
251
|
-
async def test_diagnostic_messages_with_declaration_name_no_errors(
|
|
252
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
253
|
-
declaration_diagnostic_file: Path,
|
|
254
|
-
) -> None:
|
|
255
|
-
"""Test filtering by declaration that has no errors."""
|
|
256
|
-
async with mcp_client_factory() as client:
|
|
257
|
-
# Get diagnostics for validFunction (no errors)
|
|
174
|
+
# Test 4: Get diagnostics for validFunction (no errors)
|
|
258
175
|
diagnostics = await client.call_tool(
|
|
259
176
|
"lean_diagnostic_messages",
|
|
260
177
|
{
|
|
@@ -263,8 +180,6 @@ async def test_diagnostic_messages_with_declaration_name_no_errors(
|
|
|
263
180
|
},
|
|
264
181
|
)
|
|
265
182
|
diag_text = result_text(diagnostics)
|
|
266
|
-
|
|
267
|
-
# Should indicate no errors or be empty
|
|
268
183
|
assert (
|
|
269
184
|
"no" in diag_text.lower()
|
|
270
185
|
or len(diag_text.strip()) == 0
|
|
@@ -273,13 +188,13 @@ async def test_diagnostic_messages_with_declaration_name_no_errors(
|
|
|
273
188
|
|
|
274
189
|
|
|
275
190
|
@pytest.mark.asyncio
|
|
276
|
-
async def
|
|
191
|
+
async def test_diagnostic_messages_declaration_edge_cases(
|
|
277
192
|
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
278
193
|
declaration_diagnostic_file: Path,
|
|
279
194
|
) -> None:
|
|
280
|
-
"""Test
|
|
195
|
+
"""Test edge cases for declaration-based filtering."""
|
|
281
196
|
async with mcp_client_factory() as client:
|
|
282
|
-
#
|
|
197
|
+
# Test 1: Non-existent declaration
|
|
283
198
|
result = await client.call_tool(
|
|
284
199
|
"lean_diagnostic_messages",
|
|
285
200
|
{
|
|
@@ -288,21 +203,10 @@ async def test_diagnostic_messages_with_nonexistent_declaration(
|
|
|
288
203
|
},
|
|
289
204
|
)
|
|
290
205
|
result_str = result_text(result)
|
|
291
|
-
|
|
292
|
-
# Should return error message about declaration not found
|
|
293
206
|
assert "not found" in result_str.lower()
|
|
294
207
|
assert "nonExistentTheorem" in result_str
|
|
295
208
|
|
|
296
|
-
|
|
297
|
-
@pytest.mark.asyncio
|
|
298
|
-
async def test_diagnostic_messages_declaration_name_takes_precedence(
|
|
299
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
300
|
-
declaration_diagnostic_file: Path,
|
|
301
|
-
) -> None:
|
|
302
|
-
"""Test that declaration_name takes precedence over start_line/end_line."""
|
|
303
|
-
async with mcp_client_factory() as client:
|
|
304
|
-
# Use declaration_name with conflicting start_line/end_line
|
|
305
|
-
# declaration_name should take precedence
|
|
209
|
+
# Test 2: declaration_name takes precedence over start_line/end_line
|
|
306
210
|
diagnostics = await client.call_tool(
|
|
307
211
|
"lean_diagnostic_messages",
|
|
308
212
|
{
|
|
@@ -313,13 +217,11 @@ async def test_diagnostic_messages_declaration_name_takes_precedence(
|
|
|
313
217
|
},
|
|
314
218
|
)
|
|
315
219
|
diag_text = result_text(diagnostics)
|
|
316
|
-
|
|
317
220
|
# Should get diagnostics for firstTheorem, not lines 1-3
|
|
318
|
-
# (which would only include imports)
|
|
319
221
|
assert len(diag_text) > 0
|
|
320
222
|
|
|
321
223
|
|
|
322
|
-
@pytest.fixture()
|
|
224
|
+
@pytest.fixture(scope="module")
|
|
323
225
|
def kernel_error_file(test_project_path: Path) -> Path:
|
|
324
226
|
"""File with kernel error as first error (issue #63)."""
|
|
325
227
|
path = test_project_path / "KernelErrorTest.lean"
|
|
@@ -334,7 +236,8 @@ def kernel_error_file(test_project_path: Path) -> Path:
|
|
|
334
236
|
lemma test_lemma : False := by rfl
|
|
335
237
|
"""
|
|
336
238
|
).strip()
|
|
337
|
-
path.
|
|
239
|
+
if not path.exists() or path.read_text(encoding="utf-8") != content + "\n":
|
|
240
|
+
path.write_text(content + "\n", encoding="utf-8")
|
|
338
241
|
return path
|
|
339
242
|
|
|
340
243
|
|
|
@@ -25,19 +25,34 @@ theorem cachedTheorem : cachedValue = 42 := by rfl
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@pytest.mark.asyncio
|
|
28
|
-
async def
|
|
28
|
+
async def test_file_caching(
|
|
29
29
|
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
30
30
|
cache_test_file: Path,
|
|
31
31
|
) -> None:
|
|
32
|
-
"""
|
|
32
|
+
"""Test file caching: disk changes detected and tools share state correctly."""
|
|
33
33
|
|
|
34
34
|
async with mcp_client_factory() as client:
|
|
35
|
+
# Test 1: Multiple tools share file state correctly
|
|
36
|
+
await client.call_tool(
|
|
37
|
+
"lean_diagnostic_messages", {"file_path": str(cache_test_file)}
|
|
38
|
+
)
|
|
39
|
+
await client.call_tool(
|
|
40
|
+
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
41
|
+
)
|
|
42
|
+
hover = await client.call_tool(
|
|
43
|
+
"lean_hover_info",
|
|
44
|
+
{"file_path": str(cache_test_file), "line": 3, "column": 5},
|
|
45
|
+
)
|
|
46
|
+
assert "cachedValue" in result_text(hover)
|
|
47
|
+
|
|
48
|
+
# Test 2: Disk changes are detected and reprocessed correctly
|
|
35
49
|
goal1 = await client.call_tool(
|
|
36
50
|
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
37
51
|
)
|
|
38
52
|
result1 = result_text(goal1)
|
|
39
53
|
assert "no goals" in result1.lower()
|
|
40
54
|
|
|
55
|
+
# Modify file on disk
|
|
41
56
|
cache_test_file.write_text(
|
|
42
57
|
"""import Mathlib
|
|
43
58
|
|
|
@@ -48,6 +63,7 @@ theorem cachedTheorem : cachedValue = 42 := by sorry
|
|
|
48
63
|
encoding="utf-8",
|
|
49
64
|
)
|
|
50
65
|
|
|
66
|
+
# Verify change is detected
|
|
51
67
|
goal2 = await client.call_tool(
|
|
52
68
|
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
53
69
|
)
|
|
@@ -56,25 +72,3 @@ theorem cachedTheorem : cachedValue = 42 := by sorry
|
|
|
56
72
|
assert "cachedValue = 42" in result2, (
|
|
57
73
|
f"Should show goal at sorry, got: {result2}"
|
|
58
74
|
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@pytest.mark.asyncio
|
|
62
|
-
async def test_multiple_tools_share_file(
|
|
63
|
-
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
64
|
-
cache_test_file: Path,
|
|
65
|
-
) -> None:
|
|
66
|
-
"""Different tools must reuse cached file state correctly."""
|
|
67
|
-
|
|
68
|
-
async with mcp_client_factory() as client:
|
|
69
|
-
await client.call_tool(
|
|
70
|
-
"lean_diagnostic_messages", {"file_path": str(cache_test_file)}
|
|
71
|
-
)
|
|
72
|
-
await client.call_tool(
|
|
73
|
-
"lean_goal", {"file_path": str(cache_test_file), "line": 5}
|
|
74
|
-
)
|
|
75
|
-
hover = await client.call_tool(
|
|
76
|
-
"lean_hover_info",
|
|
77
|
-
{"file_path": str(cache_test_file), "line": 3, "column": 5},
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
assert "cachedValue" in result_text(hover)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Test outline generation with various Lean files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncContextManager
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from tests.helpers.mcp_client import MCPClient, result_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mathlib_nat_basic(test_project_path: Path) -> Path:
|
|
17
|
+
"""Path to Mathlib Data.Nat.Basic file."""
|
|
18
|
+
return test_project_path / ".lake/packages/mathlib/Mathlib/Data/Nat/Basic.lean"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_outline_simple_files(
|
|
23
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
24
|
+
test_project_path: Path,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Test outline generation on simple test files."""
|
|
27
|
+
test_files = [
|
|
28
|
+
test_project_path / "StructTest.lean",
|
|
29
|
+
test_project_path / "TheoremTest.lean",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
async with mcp_client_factory() as client:
|
|
33
|
+
for test_file in test_files:
|
|
34
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(test_file)})
|
|
35
|
+
outline = result_text(result)
|
|
36
|
+
|
|
37
|
+
# Basic structure checks
|
|
38
|
+
assert "## Imports" in outline or "## Declarations" in outline
|
|
39
|
+
assert len(outline) > 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.asyncio
|
|
43
|
+
async def test_mathlib_outline_structure(
|
|
44
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
45
|
+
mathlib_nat_basic: Path,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Test outline generation with a real Mathlib file."""
|
|
48
|
+
async with mcp_client_factory() as client:
|
|
49
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
50
|
+
outline = result_text(result)
|
|
51
|
+
|
|
52
|
+
# Basic structure checks (no filename header now)
|
|
53
|
+
assert "## Imports" in outline
|
|
54
|
+
assert "## Declarations" in outline
|
|
55
|
+
|
|
56
|
+
# Should have imports from Mathlib
|
|
57
|
+
assert "Mathlib.Data.Nat.Init" in outline
|
|
58
|
+
|
|
59
|
+
# Should have namespace (new format)
|
|
60
|
+
assert "[Ns:" in outline and "Nat" in outline
|
|
61
|
+
|
|
62
|
+
# Should have instance declarations
|
|
63
|
+
assert "instLinearOrder" in outline or "LinearOrder" in outline
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_mathlib_outline_has_line_numbers(
|
|
68
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
69
|
+
mathlib_nat_basic: Path,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Verify line numbers are present in outline."""
|
|
72
|
+
async with mcp_client_factory() as client:
|
|
73
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
74
|
+
outline = result_text(result)
|
|
75
|
+
|
|
76
|
+
# Should have line numbers in format "[Tag: L27-135]" or "[Tag: L31]"
|
|
77
|
+
line_pattern = r'L(\d+)(?:-(\d+))?'
|
|
78
|
+
matches = re.findall(line_pattern, outline)
|
|
79
|
+
|
|
80
|
+
assert len(matches) > 0, "Should have line number annotations"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_mathlib_outline_has_types(
|
|
85
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
86
|
+
mathlib_nat_basic: Path,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Verify type signatures are included."""
|
|
89
|
+
async with mcp_client_factory() as client:
|
|
90
|
+
result = await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
91
|
+
outline = result_text(result)
|
|
92
|
+
|
|
93
|
+
# Should have type annotations with ":"
|
|
94
|
+
assert "LinearOrder ℕ" in outline or ": " in outline
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_mathlib_outline_file_cleanup(
|
|
99
|
+
mcp_client_factory: Callable[[], AsyncContextManager[MCPClient]],
|
|
100
|
+
mathlib_nat_basic: Path,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Verify file is properly cleaned up after info_trees extraction."""
|
|
103
|
+
async with mcp_client_factory() as client:
|
|
104
|
+
# Get original file content
|
|
105
|
+
original_content = mathlib_nat_basic.read_text()
|
|
106
|
+
|
|
107
|
+
# Generate outline (which inserts and removes #info_trees lines)
|
|
108
|
+
await client.call_tool("lean_file_outline", {"file_path": str(mathlib_nat_basic)})
|
|
109
|
+
|
|
110
|
+
# Read file content again
|
|
111
|
+
final_content = mathlib_nat_basic.read_text()
|
|
112
|
+
|
|
113
|
+
# File should be unchanged
|
|
114
|
+
assert final_content == original_content, "File should be restored to original state after outline generation"
|
|
115
|
+
|
|
116
|
+
# Specifically check that no #info_trees lines remain
|
|
117
|
+
assert "#info_trees" not in final_content, "No #info_trees directives should remain in file"
|
|
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
|