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,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple Mathlib search using Python regex on source files.
|
|
3
|
+
|
|
4
|
+
No complex parsing, no indexes, just fast pattern matching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .common.errors import LeanBackError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SimpleSearchResult:
|
|
16
|
+
"""A search result from Mathlib."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
file: str
|
|
20
|
+
line: int
|
|
21
|
+
text: str
|
|
22
|
+
kind: str | None = None # theorem, def, lemma, etc.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SimpleMathlibSearch:
|
|
26
|
+
"""Fast Mathlib search using regex on source files."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, project_path: Path | None = None):
|
|
29
|
+
self.project_path = project_path or Path.cwd()
|
|
30
|
+
self.mathlib_dir = self._find_mathlib_in_project()
|
|
31
|
+
|
|
32
|
+
if not self.mathlib_dir:
|
|
33
|
+
raise LeanBackError(
|
|
34
|
+
"Mathlib not found in project",
|
|
35
|
+
"Ensure your project has Mathlib as a dependency",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _find_mathlib_in_project(self) -> Path | None:
|
|
39
|
+
"""Find Mathlib in the project's lake packages."""
|
|
40
|
+
lake_packages = self.project_path / ".lake" / "packages"
|
|
41
|
+
if not lake_packages.exists():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# Check common names first, then fall back to scanning
|
|
45
|
+
for name in ("mathlib", "mathlib4"):
|
|
46
|
+
candidate = lake_packages / name
|
|
47
|
+
if candidate.exists() and (candidate / "Mathlib").exists():
|
|
48
|
+
return candidate
|
|
49
|
+
|
|
50
|
+
# Scan all packages for one containing a Mathlib/ directory
|
|
51
|
+
for item in lake_packages.iterdir():
|
|
52
|
+
if item.is_dir() and (item / "Mathlib").exists():
|
|
53
|
+
return item
|
|
54
|
+
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _get_namespaces_at_line(self, content: str, target_line: int) -> list[str]:
|
|
58
|
+
"""Get the active namespace stack at a given line number."""
|
|
59
|
+
namespaces = []
|
|
60
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
61
|
+
if i >= target_line:
|
|
62
|
+
break
|
|
63
|
+
stripped = line.strip()
|
|
64
|
+
if stripped.startswith("namespace "):
|
|
65
|
+
ns = stripped.split("namespace ", 1)[1].split()[0].strip()
|
|
66
|
+
namespaces.append(ns)
|
|
67
|
+
elif stripped.startswith("end ") and namespaces:
|
|
68
|
+
end_ns = stripped.split("end ", 1)[1].split()[0].strip()
|
|
69
|
+
# Pop matching namespace
|
|
70
|
+
if namespaces and namespaces[-1] == end_ns:
|
|
71
|
+
namespaces.pop()
|
|
72
|
+
return namespaces
|
|
73
|
+
|
|
74
|
+
def _fully_qualify_name(self, name: str, namespaces: list[str]) -> str:
|
|
75
|
+
"""Prepend active namespaces to a declaration name."""
|
|
76
|
+
if not namespaces or "." in name:
|
|
77
|
+
return name
|
|
78
|
+
return ".".join(namespaces) + "." + name
|
|
79
|
+
|
|
80
|
+
def search(self, query: str, max_results: int = 50) -> list[SimpleSearchResult]:
|
|
81
|
+
"""
|
|
82
|
+
Search for declarations using Python's built-in regex.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
query: Search query (can be name, pattern, or regex)
|
|
86
|
+
max_results: Maximum results to return
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of search results
|
|
90
|
+
"""
|
|
91
|
+
if not query.strip():
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
results = []
|
|
95
|
+
|
|
96
|
+
# Always search for declarations (theorem/def/lemma/etc.) matching the query.
|
|
97
|
+
# This avoids matching usages like `rw [add_comm]`.
|
|
98
|
+
decl_keywords = r"(?:protected\s+)?(?:noncomputable\s+)?(?:private\s+)?(theorem|def|lemma|instance|structure|class|inductive|abbrev|axiom)"
|
|
99
|
+
|
|
100
|
+
# For dotted names like "SimpleGraph.neighborFinset", we need to search
|
|
101
|
+
# for the last component and then verify the namespace matches
|
|
102
|
+
if self._looks_like_exact_name(query):
|
|
103
|
+
# Extract the last component for the regex search
|
|
104
|
+
parts = query.rsplit(".", 1)
|
|
105
|
+
last_component = re.escape(parts[-1])
|
|
106
|
+
namespace_prefix = parts[0] if len(parts) > 1 else None
|
|
107
|
+
pattern = re.compile(
|
|
108
|
+
rf"^\s*{decl_keywords}\s+\S*{last_component}\b",
|
|
109
|
+
re.MULTILINE,
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
namespace_prefix = None
|
|
113
|
+
escaped_query = re.escape(query)
|
|
114
|
+
# Non-dotted query: match declarations whose name contains the query
|
|
115
|
+
pattern = re.compile(
|
|
116
|
+
rf"^\s*{decl_keywords}\s+\S*{escaped_query}\S*",
|
|
117
|
+
re.MULTILINE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Search through all Lean files — max_results only truncates output,
|
|
121
|
+
# it does not limit the scan scope.
|
|
122
|
+
mathlib_path = self.mathlib_dir / "Mathlib"
|
|
123
|
+
total_found = 0
|
|
124
|
+
|
|
125
|
+
for lean_file in mathlib_path.rglob("*.lean"):
|
|
126
|
+
try:
|
|
127
|
+
# Read file content
|
|
128
|
+
with open(lean_file, encoding="utf-8") as f:
|
|
129
|
+
content = f.read()
|
|
130
|
+
|
|
131
|
+
# Search for matches
|
|
132
|
+
for match in pattern.finditer(content):
|
|
133
|
+
# Get line number and full line
|
|
134
|
+
line_no = content[: match.start()].count("\n") + 1
|
|
135
|
+
|
|
136
|
+
# Get the full line containing the match
|
|
137
|
+
line_start = content.rfind("\n", 0, match.start()) + 1
|
|
138
|
+
line_end = content.find("\n", match.end())
|
|
139
|
+
if line_end == -1:
|
|
140
|
+
line_end = len(content)
|
|
141
|
+
line_text = content[line_start:line_end].strip()
|
|
142
|
+
|
|
143
|
+
# Extract declaration kind and name from the full line
|
|
144
|
+
kind = None
|
|
145
|
+
name = "unknown"
|
|
146
|
+
|
|
147
|
+
# Strip modifiers to find the keyword
|
|
148
|
+
clean_line = line_text
|
|
149
|
+
for modifier in ("protected ", "noncomputable ", "private "):
|
|
150
|
+
if clean_line.startswith(modifier):
|
|
151
|
+
clean_line = clean_line[len(modifier) :]
|
|
152
|
+
|
|
153
|
+
for k in [
|
|
154
|
+
"theorem",
|
|
155
|
+
"def",
|
|
156
|
+
"lemma",
|
|
157
|
+
"instance",
|
|
158
|
+
"structure",
|
|
159
|
+
"class",
|
|
160
|
+
"inductive",
|
|
161
|
+
"abbrev",
|
|
162
|
+
"axiom",
|
|
163
|
+
]:
|
|
164
|
+
if clean_line.startswith(k + " "):
|
|
165
|
+
kind = k
|
|
166
|
+
# Extract name after kind
|
|
167
|
+
after_kind = clean_line.split(k, 1)[1].strip()
|
|
168
|
+
name_part = (
|
|
169
|
+
after_kind.split()[0] if after_kind else "unknown"
|
|
170
|
+
)
|
|
171
|
+
name = (
|
|
172
|
+
name_part.split(":")[0]
|
|
173
|
+
.split("(")[0]
|
|
174
|
+
.split("{")[0]
|
|
175
|
+
.strip()
|
|
176
|
+
)
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if name == "unknown":
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Fully qualify the name using namespace context
|
|
183
|
+
namespaces = self._get_namespaces_at_line(content, line_no)
|
|
184
|
+
fq_name = self._fully_qualify_name(name, namespaces)
|
|
185
|
+
|
|
186
|
+
# If searching for a dotted name, verify the namespace matches
|
|
187
|
+
if namespace_prefix:
|
|
188
|
+
if not fq_name.startswith(
|
|
189
|
+
namespace_prefix + "."
|
|
190
|
+
) and not fq_name.endswith("." + query.rsplit(".", 1)[-1]):
|
|
191
|
+
# Also check if the query appears as-is in the fq_name
|
|
192
|
+
if query not in fq_name:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
total_found += 1
|
|
196
|
+
|
|
197
|
+
# Only store up to max_results
|
|
198
|
+
if len(results) < max_results:
|
|
199
|
+
rel_path = lean_file.relative_to(self.mathlib_dir)
|
|
200
|
+
results.append(
|
|
201
|
+
SimpleSearchResult(
|
|
202
|
+
name=fq_name,
|
|
203
|
+
file=str(rel_path),
|
|
204
|
+
line=line_no,
|
|
205
|
+
text=line_text,
|
|
206
|
+
kind=kind,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
except Exception:
|
|
211
|
+
# Skip files that can't be read
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Store total_found for callers to access
|
|
215
|
+
self._last_total_found = total_found
|
|
216
|
+
return results
|
|
217
|
+
|
|
218
|
+
def _looks_like_exact_name(self, query: str) -> bool:
|
|
219
|
+
"""Check if query looks like an exact declaration name."""
|
|
220
|
+
# Names typically have dots and use camelCase or snake_case
|
|
221
|
+
return "." in query and all(c.isalnum() or c in "._" for c in query)
|