leanback 0.1.0__py3-none-any.whl
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.
- leanback/README.md +19 -0
- leanback/__init__.py +3 -0
- leanback/check.py +365 -0
- leanback/common/__init__.py +0 -0
- leanback/common/env.py +91 -0
- leanback/common/errors.py +517 -0
- leanback/common/path_manager.py +191 -0
- leanback/common/project_context.py +276 -0
- leanback/common/util.py +72 -0
- leanback/goals.py +167 -0
- leanback/mathlib_search.py +190 -0
- leanback/mathlib_search_simple.py +221 -0
- leanback/mcp_server.py +1179 -0
- leanback/mcp_test.py +496 -0
- leanback/prove.py +87 -0
- leanback-0.1.0.dist-info/METADATA +154 -0
- leanback-0.1.0.dist-info/RECORD +20 -0
- leanback-0.1.0.dist-info/WHEEL +4 -0
- leanback-0.1.0.dist-info/entry_points.txt +3 -0
- leanback-0.1.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project context management for LeanBack tools.
|
|
3
|
+
|
|
4
|
+
This module provides a unified context for working with Lean projects,
|
|
5
|
+
handling path resolution, validation, and environment configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from leanback.common.path_manager import PathManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectContext:
|
|
18
|
+
"""
|
|
19
|
+
Context for working with Lean projects.
|
|
20
|
+
|
|
21
|
+
This class provides a unified interface for working with Lean projects,
|
|
22
|
+
handling path resolution, validation, and environment configuration.
|
|
23
|
+
It uses PathManager for path-related operations.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
project_path: str | Path | None = None,
|
|
29
|
+
console: Console | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Initialize a project context.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
project_path: Path to the project directory (string or Path)
|
|
36
|
+
console: Console for output
|
|
37
|
+
"""
|
|
38
|
+
self.path_manager = PathManager()
|
|
39
|
+
self.console = console or Console()
|
|
40
|
+
self.project_path = self._resolve_project_path(project_path)
|
|
41
|
+
self._config = None
|
|
42
|
+
self._errors = []
|
|
43
|
+
|
|
44
|
+
def _resolve_project_path(self, path: str | Path | None) -> Path | None:
|
|
45
|
+
"""
|
|
46
|
+
Resolve project path from an absolute path.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Absolute path to a Lean project directory
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Resolved absolute Path object or None if not found
|
|
53
|
+
"""
|
|
54
|
+
if path is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
potential_path = Path(str(path))
|
|
58
|
+
if (
|
|
59
|
+
potential_path.is_absolute()
|
|
60
|
+
and potential_path.exists()
|
|
61
|
+
and potential_path.is_dir()
|
|
62
|
+
):
|
|
63
|
+
return potential_path.absolute()
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def _get_config(self) -> dict[str, Any] | None:
|
|
68
|
+
"""
|
|
69
|
+
Get the lakefile.toml configuration with caching.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dict with the parsed lakefile.toml or None if not found
|
|
73
|
+
"""
|
|
74
|
+
if self._config is not None:
|
|
75
|
+
return self._config
|
|
76
|
+
|
|
77
|
+
if not self.project_path:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
lakefile_path = self.project_path / "lakefile.toml"
|
|
81
|
+
if not lakefile_path.exists():
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
with open(lakefile_path, "rb") as f:
|
|
86
|
+
self._config = tomllib.load(f)
|
|
87
|
+
return self._config
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def is_valid(self) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Check if this context has a valid project path.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if the project path is valid, False otherwise
|
|
97
|
+
"""
|
|
98
|
+
if not self.project_path:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
return self.path_manager.is_valid_project(self.project_path)
|
|
102
|
+
|
|
103
|
+
def validate(self) -> tuple[bool, list[str]]:
|
|
104
|
+
"""
|
|
105
|
+
Validate project structure and return (success, errors).
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (success, list of error messages)
|
|
109
|
+
"""
|
|
110
|
+
self._errors = []
|
|
111
|
+
|
|
112
|
+
# Check if the path exists
|
|
113
|
+
if not self.project_path:
|
|
114
|
+
self._errors.append("No project path provided or found.")
|
|
115
|
+
return False, self._errors
|
|
116
|
+
|
|
117
|
+
if not self.project_path.exists():
|
|
118
|
+
self._errors.append(
|
|
119
|
+
f"Project directory '{self.project_path}' does not exist."
|
|
120
|
+
)
|
|
121
|
+
return False, self._errors
|
|
122
|
+
|
|
123
|
+
# Check for lean-toolchain file
|
|
124
|
+
toolchain_path = self.project_path / "lean-toolchain"
|
|
125
|
+
if not toolchain_path.exists():
|
|
126
|
+
self._errors.append(
|
|
127
|
+
"No lean-toolchain file found. Create one with the desired Lean version "
|
|
128
|
+
"or create a project with: lake +leanprover-community/mathlib4:lean-toolchain new <name> math"
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
# Verify toolchain file content
|
|
132
|
+
with open(toolchain_path) as f:
|
|
133
|
+
toolchain_content = f.read().strip()
|
|
134
|
+
if not toolchain_content.startswith("leanprover"):
|
|
135
|
+
self._errors.append(
|
|
136
|
+
f"Invalid lean-toolchain format. Expected 'leanprover/lean4:...' but got '{toolchain_content}'. "
|
|
137
|
+
"Update the file with a valid Lean 4 version."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Check for lakefile (either .toml or .lean)
|
|
141
|
+
lakefile_toml = self.project_path / "lakefile.toml"
|
|
142
|
+
lakefile_lean = self.project_path / "lakefile.lean"
|
|
143
|
+
|
|
144
|
+
if not lakefile_toml.exists() and not lakefile_lean.exists():
|
|
145
|
+
self._errors.append(
|
|
146
|
+
"No lakefile.toml or lakefile.lean found. Create one "
|
|
147
|
+
"or create a project with: lake +leanprover-community/mathlib4:lean-toolchain new <name> math"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# If using lakefile.toml, check for basic configuration
|
|
151
|
+
if lakefile_toml.exists():
|
|
152
|
+
config = self._get_config()
|
|
153
|
+
if config:
|
|
154
|
+
# Check for project name (top-level or under [package])
|
|
155
|
+
has_name = "name" in config or "name" in config.get("package", {})
|
|
156
|
+
if not has_name:
|
|
157
|
+
self._errors.append("No 'name' field in lakefile.toml.")
|
|
158
|
+
|
|
159
|
+
# Check for Lean library configuration
|
|
160
|
+
if "lean_lib" not in config:
|
|
161
|
+
self._errors.append("No 'lean_lib' entry in lakefile.toml.")
|
|
162
|
+
elif not config["lean_lib"]:
|
|
163
|
+
self._errors.append("Empty 'lean_lib' array in lakefile.toml.")
|
|
164
|
+
else:
|
|
165
|
+
self._errors.append("Could not parse lakefile.toml.")
|
|
166
|
+
|
|
167
|
+
return len(self._errors) == 0, self._errors
|
|
168
|
+
|
|
169
|
+
def resolve_file_path(self, file_path: str | Path) -> Path | None:
|
|
170
|
+
"""
|
|
171
|
+
Resolve a file path relative to the project with smart resolution.
|
|
172
|
+
|
|
173
|
+
This method tries various strategies to find the file:
|
|
174
|
+
1. Exact path (absolute or relative to project)
|
|
175
|
+
2. Common project structure patterns
|
|
176
|
+
3. Search for the file name in the project
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
file_path: The file path to resolve (can be relative or just a filename)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Resolved absolute Path or None if not found
|
|
183
|
+
"""
|
|
184
|
+
if not self.project_path:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# Convert to Path
|
|
188
|
+
file_path = Path(file_path)
|
|
189
|
+
|
|
190
|
+
# If already absolute and exists, return it
|
|
191
|
+
if file_path.is_absolute() and file_path.exists():
|
|
192
|
+
return file_path
|
|
193
|
+
|
|
194
|
+
# Try exact relative path from project root
|
|
195
|
+
candidate = self.project_path / file_path
|
|
196
|
+
if candidate.exists():
|
|
197
|
+
return candidate
|
|
198
|
+
|
|
199
|
+
# Get the file name without directory
|
|
200
|
+
file_name = file_path.name
|
|
201
|
+
|
|
202
|
+
# Common patterns to try
|
|
203
|
+
patterns = []
|
|
204
|
+
|
|
205
|
+
# If it's just a filename (no directory), try common locations
|
|
206
|
+
if str(file_path) == file_name:
|
|
207
|
+
# Try ProjectName/filename pattern
|
|
208
|
+
project_name = self.project_path.name
|
|
209
|
+
patterns.extend(
|
|
210
|
+
[
|
|
211
|
+
self.project_path / project_name / file_name,
|
|
212
|
+
self.project_path / "src" / file_name,
|
|
213
|
+
self.project_path / "lib" / file_name,
|
|
214
|
+
]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Check all patterns
|
|
218
|
+
for pattern in patterns:
|
|
219
|
+
if pattern.exists():
|
|
220
|
+
return pattern
|
|
221
|
+
|
|
222
|
+
# If still not found, search for the file in the project
|
|
223
|
+
# (excluding .lake directory)
|
|
224
|
+
try:
|
|
225
|
+
for found_file in self.project_path.rglob(file_name):
|
|
226
|
+
# Skip files in .lake directory
|
|
227
|
+
if ".lake" not in found_file.parts:
|
|
228
|
+
return found_file
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def suggest_file_path(self, file_path: str | Path) -> list[str]:
|
|
235
|
+
"""
|
|
236
|
+
Suggest possible file paths when the exact path is not found.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
file_path: The file path that wasn't found
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of suggestions (relative paths from project root)
|
|
243
|
+
"""
|
|
244
|
+
if not self.project_path:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
suggestions = []
|
|
248
|
+
file_name = Path(file_path).name
|
|
249
|
+
|
|
250
|
+
# Remove .lean extension for matching
|
|
251
|
+
base_name = file_name.replace(".lean", "")
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
# Find similar files
|
|
255
|
+
for found_file in self.project_path.rglob("*.lean"):
|
|
256
|
+
# Skip files in .lake directory
|
|
257
|
+
if ".lake" in found_file.parts:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
found_base = found_file.stem
|
|
261
|
+
|
|
262
|
+
# Check for similar names (case-insensitive)
|
|
263
|
+
if (
|
|
264
|
+
base_name.lower() in found_base.lower()
|
|
265
|
+
or found_base.lower() in base_name.lower()
|
|
266
|
+
):
|
|
267
|
+
rel_path = found_file.relative_to(self.project_path)
|
|
268
|
+
suggestions.append(str(rel_path))
|
|
269
|
+
|
|
270
|
+
# Stop after finding 5 suggestions
|
|
271
|
+
if len(suggestions) >= 5:
|
|
272
|
+
break
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
return suggestions
|
leanback/common/util.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for the LeanBack tools.
|
|
3
|
+
|
|
4
|
+
This module contains common utility functions used across the tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_temp_file(content: str, suffix: str = ".lean") -> Path:
|
|
13
|
+
"""
|
|
14
|
+
Create a temporary file with the given content.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
content: Content to write to the file
|
|
18
|
+
suffix: File suffix
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Path to the temporary file
|
|
22
|
+
"""
|
|
23
|
+
fd, temp_path = tempfile.mkstemp(suffix=suffix)
|
|
24
|
+
try:
|
|
25
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
26
|
+
f.write(content)
|
|
27
|
+
return Path(temp_path)
|
|
28
|
+
except Exception:
|
|
29
|
+
# Clean up if something goes wrong
|
|
30
|
+
os.unlink(temp_path)
|
|
31
|
+
raise
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_proof_content(
|
|
35
|
+
theorem: str,
|
|
36
|
+
tactics: list[str],
|
|
37
|
+
imports: list[str],
|
|
38
|
+
) -> tuple[str, dict[int, int]]:
|
|
39
|
+
"""Build a Lean proof file from theorem, tactics, and imports.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
(content, line_to_tactic_map) where line_to_tactic_map maps
|
|
43
|
+
1-based line numbers to 0-based tactic indices. Multi-line
|
|
44
|
+
tactics get entries for each line they span.
|
|
45
|
+
"""
|
|
46
|
+
lines: list[str] = []
|
|
47
|
+
|
|
48
|
+
# Imports
|
|
49
|
+
for imp in imports:
|
|
50
|
+
lines.append(f"import {imp}")
|
|
51
|
+
if imports:
|
|
52
|
+
lines.append("") # blank separator
|
|
53
|
+
|
|
54
|
+
# Theorem statement — wrap bare propositions
|
|
55
|
+
theorem_stmt = theorem.strip()
|
|
56
|
+
if not any(
|
|
57
|
+
theorem_stmt.startswith(kw + " ") for kw in ["theorem", "lemma", "example"]
|
|
58
|
+
):
|
|
59
|
+
theorem_stmt = f"theorem test_theorem : {theorem_stmt}"
|
|
60
|
+
lines.append(f"{theorem_stmt} := by")
|
|
61
|
+
|
|
62
|
+
# Tactics with line mapping (1-based line numbers, 0-based tactic indices)
|
|
63
|
+
line_to_tactic: dict[int, int] = {}
|
|
64
|
+
for i, tactic in enumerate(tactics):
|
|
65
|
+
tactic_lines = tactic.split("\n")
|
|
66
|
+
for tl in tactic_lines:
|
|
67
|
+
lines.append(f" {tl}")
|
|
68
|
+
line_number = len(lines) # 1-based (len after append)
|
|
69
|
+
line_to_tactic[line_number] = i
|
|
70
|
+
|
|
71
|
+
content = "\n".join(lines) + "\n"
|
|
72
|
+
return content, line_to_tactic
|
leanback/goals.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of the leanback-goals tool.
|
|
3
|
+
|
|
4
|
+
This module provides goal state inspection during Lean proof construction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from leanback.common.util import build_proof_content, create_temp_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_goals(data: str) -> list[str]:
|
|
16
|
+
"""Parse Lean's 'unsolved goals' data into individual goal strings.
|
|
17
|
+
|
|
18
|
+
Strips the 'unsolved goals' prefix and splits on blank lines.
|
|
19
|
+
Each chunk is one goal (hypotheses + ⊢ conclusion).
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: The raw data string from a Tactic.unsolvedGoals message.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of goal strings.
|
|
26
|
+
"""
|
|
27
|
+
text = data.strip()
|
|
28
|
+
|
|
29
|
+
# Strip "unsolved goals\n" prefix if present
|
|
30
|
+
prefix = "unsolved goals\n"
|
|
31
|
+
if text.startswith(prefix):
|
|
32
|
+
text = text[len(prefix) :]
|
|
33
|
+
elif text == "unsolved goals":
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
if not text:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
# Split on blank lines (double newline)
|
|
40
|
+
raw_goals = text.split("\n\n")
|
|
41
|
+
return [g.strip() for g in raw_goals if g.strip()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_goals(
|
|
45
|
+
theorem: str,
|
|
46
|
+
tactics: list[str] | None,
|
|
47
|
+
imports: list[str] | None,
|
|
48
|
+
project_path: Path,
|
|
49
|
+
console: Console,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Run a partial proof and return the goal state.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
theorem: Theorem statement.
|
|
55
|
+
tactics: Tactic list (None/empty for initial goal).
|
|
56
|
+
imports: Module imports.
|
|
57
|
+
project_path: Path to Lean project.
|
|
58
|
+
console: Rich console for logging.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Structured dict with success, status, goals, etc.
|
|
62
|
+
"""
|
|
63
|
+
from leanback.check import check_file
|
|
64
|
+
|
|
65
|
+
effective_tactics = list(tactics) if tactics else []
|
|
66
|
+
use_skip = len(effective_tactics) == 0
|
|
67
|
+
|
|
68
|
+
# Use 'skip' for empty tactics to avoid syntax error from empty by block
|
|
69
|
+
build_tactics = ["skip"] if use_skip else effective_tactics
|
|
70
|
+
|
|
71
|
+
content, line_to_tactic = build_proof_content(
|
|
72
|
+
theorem=theorem,
|
|
73
|
+
tactics=build_tactics,
|
|
74
|
+
imports=imports or [],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
temp_file = create_temp_file(content)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
success, errors = check_file(
|
|
81
|
+
file_path=temp_file,
|
|
82
|
+
project_path=project_path,
|
|
83
|
+
console=console,
|
|
84
|
+
)
|
|
85
|
+
finally:
|
|
86
|
+
try:
|
|
87
|
+
os.unlink(temp_file)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# Classify output using kind field
|
|
92
|
+
unsolved_goals_msgs = [e for e in errors if e.kind == "Tactic.unsolvedGoals"]
|
|
93
|
+
sorry_msgs = [e for e in errors if e.kind == "hasSorry"]
|
|
94
|
+
other_errors = [
|
|
95
|
+
e for e in errors if e.severity == "error" and e.kind != "Tactic.unsolvedGoals"
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
tactics_count = len(effective_tactics)
|
|
99
|
+
|
|
100
|
+
# Case 1: Proof complete (no errors)
|
|
101
|
+
if success and not sorry_msgs:
|
|
102
|
+
return {
|
|
103
|
+
"success": True,
|
|
104
|
+
"status": "complete",
|
|
105
|
+
"tactics_applied": tactics_count,
|
|
106
|
+
"goals": [],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Case 2: Sorry used (proof "complete" but not valid)
|
|
110
|
+
if success and sorry_msgs:
|
|
111
|
+
return {
|
|
112
|
+
"success": True,
|
|
113
|
+
"status": "complete",
|
|
114
|
+
"tactics_applied": tactics_count,
|
|
115
|
+
"goals": [],
|
|
116
|
+
"sorry_used": True,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Case 3: Goals remain (unsolved goals, no other errors)
|
|
120
|
+
if unsolved_goals_msgs and not other_errors:
|
|
121
|
+
goals = []
|
|
122
|
+
for msg in unsolved_goals_msgs:
|
|
123
|
+
goals.extend(parse_goals(msg.message))
|
|
124
|
+
return {
|
|
125
|
+
"success": True,
|
|
126
|
+
"status": "incomplete",
|
|
127
|
+
"tactics_applied": tactics_count,
|
|
128
|
+
"goals": goals,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Case 4: Tactic or setup error
|
|
132
|
+
# Try to identify which tactic failed using line mapping
|
|
133
|
+
failed_tactic_info = None
|
|
134
|
+
goals_from_unsolved = []
|
|
135
|
+
|
|
136
|
+
if unsolved_goals_msgs:
|
|
137
|
+
for msg in unsolved_goals_msgs:
|
|
138
|
+
goals_from_unsolved.extend(parse_goals(msg.message))
|
|
139
|
+
|
|
140
|
+
for err in other_errors:
|
|
141
|
+
if err.line in line_to_tactic and not use_skip:
|
|
142
|
+
tactic_idx = line_to_tactic[err.line]
|
|
143
|
+
failed_tactic_info = {
|
|
144
|
+
"index": tactic_idx,
|
|
145
|
+
"tactic": effective_tactics[tactic_idx],
|
|
146
|
+
"error": err.message,
|
|
147
|
+
}
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
result: dict = {
|
|
151
|
+
"success": False,
|
|
152
|
+
"status": "error",
|
|
153
|
+
"tactics_applied": failed_tactic_info["index"] if failed_tactic_info else 0,
|
|
154
|
+
"goals": goals_from_unsolved,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if failed_tactic_info:
|
|
158
|
+
result["failed_tactic"] = failed_tactic_info
|
|
159
|
+
|
|
160
|
+
# Include raw messages for debugging
|
|
161
|
+
result["messages"] = [
|
|
162
|
+
{"severity": e.severity, "message": e.message}
|
|
163
|
+
for e in errors
|
|
164
|
+
if e.severity == "error"
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
return result
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mathlib search functionality for LeanBack.
|
|
3
|
+
|
|
4
|
+
Provides simple regex-based search of Mathlib declarations in your project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .mathlib_search_simple import SimpleMathlibSearch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SearchResult:
|
|
15
|
+
"""Structured search result."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
type_signature: str
|
|
19
|
+
module: str | None = None
|
|
20
|
+
suggested_import: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SearchError(Exception):
|
|
24
|
+
"""Search error with suggestions."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, message: str, error_code: str, suggestions: list[str] | None = None
|
|
28
|
+
):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message = message
|
|
31
|
+
self.error_code = error_code
|
|
32
|
+
self.suggestions = suggestions or []
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
"""Convert to dictionary for JSON output."""
|
|
36
|
+
return {
|
|
37
|
+
"error": True,
|
|
38
|
+
"message": self.message,
|
|
39
|
+
"error_code": self.error_code,
|
|
40
|
+
"suggestions": self.suggestions,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def search_mathlib_declarations(
|
|
45
|
+
pattern: str,
|
|
46
|
+
project_path: Path,
|
|
47
|
+
timeout: int = 30, # Kept for compatibility
|
|
48
|
+
filter_namespace: str | None = None, # Kept for compatibility
|
|
49
|
+
exclude_internal: bool = True, # Kept for compatibility
|
|
50
|
+
max_results: int = 50,
|
|
51
|
+
use_minimal_import: bool = False, # Kept for compatibility
|
|
52
|
+
backend: str = "auto", # Kept for compatibility
|
|
53
|
+
) -> tuple[list[SearchResult], int]:
|
|
54
|
+
"""
|
|
55
|
+
Search Mathlib declarations in the project.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
pattern: Search pattern (exact name, substring, or regex)
|
|
59
|
+
project_path: Path to Lean project
|
|
60
|
+
max_results: Maximum results to return
|
|
61
|
+
Other args kept for backwards compatibility
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (results list, total_found count before truncation)
|
|
65
|
+
"""
|
|
66
|
+
total_scanned = 0
|
|
67
|
+
try:
|
|
68
|
+
# Use simple search on project's Mathlib
|
|
69
|
+
simple_search = SimpleMathlibSearch(project_path)
|
|
70
|
+
# When post-filters are active, scan without limit so filtering
|
|
71
|
+
# doesn't discard matches that happen to appear after max_results.
|
|
72
|
+
has_post_filters = bool(filter_namespace) or exclude_internal
|
|
73
|
+
scan_limit = 10000 if has_post_filters else max_results
|
|
74
|
+
simple_results = simple_search.search(pattern, scan_limit)
|
|
75
|
+
total_scanned = getattr(simple_search, "_last_total_found", 0)
|
|
76
|
+
|
|
77
|
+
# Apply filters if provided
|
|
78
|
+
if filter_namespace:
|
|
79
|
+
simple_results = [
|
|
80
|
+
r
|
|
81
|
+
for r in simple_results
|
|
82
|
+
if r.name.startswith(filter_namespace + ".")
|
|
83
|
+
or r.file.startswith(filter_namespace.replace(".", "/"))
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
if exclude_internal:
|
|
87
|
+
simple_results = [
|
|
88
|
+
r
|
|
89
|
+
for r in simple_results
|
|
90
|
+
if not (
|
|
91
|
+
r.name.endswith("'")
|
|
92
|
+
or r.name.endswith(".match")
|
|
93
|
+
or r.name.endswith(".proof")
|
|
94
|
+
or r.name.endswith(".rec")
|
|
95
|
+
or r.name.endswith(".mk")
|
|
96
|
+
or "._" in r.name
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Rank results: exact matches first, then prefix, then rest
|
|
101
|
+
query_lower = pattern.lower()
|
|
102
|
+
query_suffix = "." + query_lower # e.g. ".add_comm"
|
|
103
|
+
|
|
104
|
+
def _rank(r):
|
|
105
|
+
name_lower = r.name.lower()
|
|
106
|
+
# Exact match (e.g. "add_comm" or "Nat.add_comm" for query "add_comm")
|
|
107
|
+
if name_lower == query_lower or name_lower.endswith(query_suffix):
|
|
108
|
+
return 0
|
|
109
|
+
# Name starts with query (prefix match)
|
|
110
|
+
if name_lower.startswith(query_lower):
|
|
111
|
+
return 1
|
|
112
|
+
# Last component starts with query
|
|
113
|
+
last = name_lower.rsplit(".", 1)[-1]
|
|
114
|
+
if last.startswith(query_lower):
|
|
115
|
+
return 1
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
simple_results.sort(key=_rank)
|
|
119
|
+
|
|
120
|
+
# Truncate to requested max_results after filtering and ranking
|
|
121
|
+
simple_results = simple_results[:max_results]
|
|
122
|
+
|
|
123
|
+
# Convert to SearchResult format
|
|
124
|
+
results = []
|
|
125
|
+
for sr in simple_results:
|
|
126
|
+
# Convert file path to module name
|
|
127
|
+
module = sr.file.replace("/", ".").replace(".lean", "")
|
|
128
|
+
if module.startswith("Mathlib."):
|
|
129
|
+
import_stmt = f"import {module}"
|
|
130
|
+
else:
|
|
131
|
+
import_stmt = f"import Mathlib.{module}"
|
|
132
|
+
|
|
133
|
+
results.append(
|
|
134
|
+
SearchResult(
|
|
135
|
+
name=sr.name,
|
|
136
|
+
type_signature=sr.text,
|
|
137
|
+
module=module,
|
|
138
|
+
suggested_import=import_stmt,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return results, total_scanned
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise SearchError(
|
|
146
|
+
f"Search failed: {e!s}",
|
|
147
|
+
"SEARCH_ERROR",
|
|
148
|
+
["Check that project has Mathlib", "Try a simpler pattern"],
|
|
149
|
+
) from e
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def batch_search_declarations(
|
|
153
|
+
patterns: list[str], project_path: Path, **kwargs
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""
|
|
156
|
+
Search multiple patterns efficiently.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
patterns: List of search patterns
|
|
160
|
+
project_path: Path to project
|
|
161
|
+
**kwargs: Additional arguments passed to search
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dictionary mapping patterns to results or errors
|
|
165
|
+
"""
|
|
166
|
+
results = {}
|
|
167
|
+
|
|
168
|
+
for pattern in patterns:
|
|
169
|
+
try:
|
|
170
|
+
search_results, total_found = search_mathlib_declarations(
|
|
171
|
+
pattern, project_path, **kwargs
|
|
172
|
+
)
|
|
173
|
+
results[pattern] = {
|
|
174
|
+
"success": True,
|
|
175
|
+
"results": [
|
|
176
|
+
{
|
|
177
|
+
"name": r.name,
|
|
178
|
+
"type_signature": r.type_signature,
|
|
179
|
+
"module": r.module,
|
|
180
|
+
"suggested_import": r.suggested_import,
|
|
181
|
+
}
|
|
182
|
+
for r in search_results
|
|
183
|
+
],
|
|
184
|
+
"count": len(search_results),
|
|
185
|
+
"total_found": total_found,
|
|
186
|
+
}
|
|
187
|
+
except SearchError as e:
|
|
188
|
+
results[pattern] = e.to_dict()
|
|
189
|
+
|
|
190
|
+
return results
|