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,517 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error parsing and formatting utilities for Lean output.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for parsing Lean's error messages and
|
|
5
|
+
formatting them in a user-friendly way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LeanBackError(Exception):
|
|
15
|
+
"""General LeanBack exception with user-friendly messages."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, suggestion: str | None = None):
|
|
18
|
+
self.message = message
|
|
19
|
+
self.suggestion = suggestion
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LeanError:
|
|
25
|
+
"""Data structure for a parsed Lean error."""
|
|
26
|
+
|
|
27
|
+
file_name: str
|
|
28
|
+
line: int
|
|
29
|
+
column: int
|
|
30
|
+
end_line: int | None = None
|
|
31
|
+
end_column: int | None = None
|
|
32
|
+
severity: str = "error"
|
|
33
|
+
message: str = ""
|
|
34
|
+
suggestion: str | None = None
|
|
35
|
+
kind: str | None = None
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
"""Format error as string."""
|
|
39
|
+
# Try to use relative path for more readable output
|
|
40
|
+
file_path = Path(self.file_name)
|
|
41
|
+
try:
|
|
42
|
+
# Attempt to make the path relative to the current working directory
|
|
43
|
+
if file_path.is_absolute():
|
|
44
|
+
relative_path = file_path.relative_to(Path.cwd())
|
|
45
|
+
file_display = str(relative_path)
|
|
46
|
+
else:
|
|
47
|
+
file_display = self.file_name
|
|
48
|
+
except ValueError:
|
|
49
|
+
# If we can't make it relative, use the original path
|
|
50
|
+
file_display = self.file_name
|
|
51
|
+
|
|
52
|
+
location = f"{file_display}:{self.line}:{self.column}"
|
|
53
|
+
if self.end_line and self.end_column:
|
|
54
|
+
location += f"-{self.end_line}:{self.end_column}"
|
|
55
|
+
|
|
56
|
+
result = f"{location}: {self.severity}: {self.message}"
|
|
57
|
+
if self.suggestion:
|
|
58
|
+
result += f"\nHint: {self.suggestion}"
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_lean_json_output(json_output: str) -> list:
|
|
63
|
+
"""
|
|
64
|
+
Parse Lean's JSON output format into structured errors.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
json_output: The JSON output from Lean (--json flag)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List of parsed LeanError objects
|
|
71
|
+
"""
|
|
72
|
+
errors = []
|
|
73
|
+
|
|
74
|
+
# Skip parsing if output is empty or doesn't look like JSON
|
|
75
|
+
if not json_output or not json_output.strip():
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
# Quick check to avoid attempting JSON parsing on plaintext
|
|
79
|
+
if not json_output.strip().startswith("{"):
|
|
80
|
+
# Check if this looks like a standard Lean text error
|
|
81
|
+
if "error:" in json_output or "warning:" in json_output:
|
|
82
|
+
return parse_lean_text_output(json_output)
|
|
83
|
+
# Otherwise, create a general parsing error
|
|
84
|
+
errors.append(
|
|
85
|
+
LeanError(
|
|
86
|
+
file_name="unknown",
|
|
87
|
+
line=0,
|
|
88
|
+
column=0,
|
|
89
|
+
severity="error",
|
|
90
|
+
message="Unexpected output format from Lean (not JSON)",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return errors
|
|
94
|
+
|
|
95
|
+
# Lean sometimes outputs multiple JSON objects line by line instead of a proper array
|
|
96
|
+
# We need to handle both formats
|
|
97
|
+
if "\n{" in json_output or (
|
|
98
|
+
json_output.count("}") > 1 and json_output.count("{") > 1
|
|
99
|
+
):
|
|
100
|
+
# Looks like line-delimited JSON objects - process each line separately
|
|
101
|
+
for line in json_output.strip().split("\n"):
|
|
102
|
+
if line.strip() and line.strip().startswith("{"):
|
|
103
|
+
# Process individual JSON object
|
|
104
|
+
errors.extend(parse_single_json_object(line))
|
|
105
|
+
return errors
|
|
106
|
+
else:
|
|
107
|
+
# Appears to be a single JSON object - process normally
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(json_output)
|
|
110
|
+
return process_json_messages(data)
|
|
111
|
+
except json.JSONDecodeError as e:
|
|
112
|
+
# Try line-by-line parsing as a fallback
|
|
113
|
+
try:
|
|
114
|
+
for line in json_output.strip().split("\n"):
|
|
115
|
+
if line.strip() and line.strip().startswith("{"):
|
|
116
|
+
# Process individual JSON object
|
|
117
|
+
errors.extend(parse_single_json_object(line))
|
|
118
|
+
if errors:
|
|
119
|
+
return errors
|
|
120
|
+
except Exception:
|
|
121
|
+
pass # Fall through to the error reporting below
|
|
122
|
+
|
|
123
|
+
# Provide more detailed error message
|
|
124
|
+
errors.append(
|
|
125
|
+
LeanError(
|
|
126
|
+
file_name="unknown",
|
|
127
|
+
line=0,
|
|
128
|
+
column=0,
|
|
129
|
+
severity="error",
|
|
130
|
+
message=f"Failed to parse Lean output as JSON: {e!s}. This might indicate a Lean crash or invalid output format.",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return errors
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_single_json_object(json_str: str) -> list:
|
|
137
|
+
"""
|
|
138
|
+
Parse a single JSON object from Lean output.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
json_str: A single JSON object as string
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List of parsed LeanError objects
|
|
145
|
+
"""
|
|
146
|
+
errors = []
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(json_str)
|
|
149
|
+
|
|
150
|
+
# If this is a message object rather than a container of messages
|
|
151
|
+
if "severity" in data and "data" in data:
|
|
152
|
+
# Process as a single message
|
|
153
|
+
file_name = data.get("fileName", "unknown")
|
|
154
|
+
pos = data.get("pos", {})
|
|
155
|
+
line = pos.get("line", 0) if isinstance(pos, dict) else 0
|
|
156
|
+
column = pos.get("column", 0) if isinstance(pos, dict) else 0
|
|
157
|
+
end_pos = data.get("endPos", {})
|
|
158
|
+
end_line = (
|
|
159
|
+
end_pos.get("line") if end_pos and isinstance(end_pos, dict) else None
|
|
160
|
+
)
|
|
161
|
+
end_column = (
|
|
162
|
+
end_pos.get("column") if end_pos and isinstance(end_pos, dict) else None
|
|
163
|
+
)
|
|
164
|
+
severity = data.get("severity", "error").lower()
|
|
165
|
+
if severity == "information":
|
|
166
|
+
severity = "info"
|
|
167
|
+
|
|
168
|
+
# Get message from data
|
|
169
|
+
msg_text = data.get("data", "Unknown error")
|
|
170
|
+
if isinstance(msg_text, dict):
|
|
171
|
+
msg_text = str(msg_text)
|
|
172
|
+
|
|
173
|
+
# Use the unified suggestion function
|
|
174
|
+
suggestion = _get_suggestion_for_message(msg_text)
|
|
175
|
+
|
|
176
|
+
errors.append(
|
|
177
|
+
LeanError(
|
|
178
|
+
file_name=file_name,
|
|
179
|
+
line=line,
|
|
180
|
+
column=column,
|
|
181
|
+
end_line=end_line,
|
|
182
|
+
end_column=end_column,
|
|
183
|
+
severity=severity,
|
|
184
|
+
message=msg_text,
|
|
185
|
+
suggestion=suggestion,
|
|
186
|
+
kind=data.get("kind"),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
elif "messages" in data:
|
|
190
|
+
# Process as a container of messages
|
|
191
|
+
errors.extend(process_json_messages(data))
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
# Skip invalid JSON objects
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
return errors
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def process_json_messages(data: dict) -> list:
|
|
200
|
+
"""
|
|
201
|
+
Process messages from a parsed JSON object.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
data: Parsed JSON data
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
List of parsed LeanError objects
|
|
208
|
+
"""
|
|
209
|
+
errors = []
|
|
210
|
+
|
|
211
|
+
# Extract errors from the parsed JSON
|
|
212
|
+
for message in data.get("messages", []):
|
|
213
|
+
# Skip goals or other non-error messages
|
|
214
|
+
if "data" in message and "goalState" in message["data"]:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Extract basic information
|
|
218
|
+
file_name = message.get("file_name", "unknown")
|
|
219
|
+
pos = message.get("pos", {})
|
|
220
|
+
line = pos.get("line", 0)
|
|
221
|
+
column = pos.get("column", 0)
|
|
222
|
+
end_pos = message.get("end_pos", {})
|
|
223
|
+
end_line = end_pos.get("line") if end_pos else None
|
|
224
|
+
end_column = end_pos.get("column") if end_pos else None
|
|
225
|
+
severity = message.get("severity", "error").lower()
|
|
226
|
+
if severity == "information":
|
|
227
|
+
severity = "info"
|
|
228
|
+
msg_text = message.get("text", "Unknown error")
|
|
229
|
+
|
|
230
|
+
# Use the unified suggestion function
|
|
231
|
+
suggestion = _get_suggestion_for_message(msg_text)
|
|
232
|
+
|
|
233
|
+
errors.append(
|
|
234
|
+
LeanError(
|
|
235
|
+
file_name=file_name,
|
|
236
|
+
line=line,
|
|
237
|
+
column=column,
|
|
238
|
+
end_line=end_line,
|
|
239
|
+
end_column=end_column,
|
|
240
|
+
severity=severity,
|
|
241
|
+
message=msg_text,
|
|
242
|
+
suggestion=suggestion,
|
|
243
|
+
kind=message.get("kind"),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return errors
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_lean_text_output(text_output: str) -> list:
|
|
251
|
+
"""
|
|
252
|
+
Parse Lean's text output format into structured errors.
|
|
253
|
+
This is a fallback when JSON output isn't available.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
text_output: The text output from Lean
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of parsed LeanError objects
|
|
260
|
+
"""
|
|
261
|
+
errors = []
|
|
262
|
+
|
|
263
|
+
# Enhanced regex patterns for different error formats
|
|
264
|
+
# Standard Lean error format: "file.lean:10:5: error: message"
|
|
265
|
+
standard_pattern = (
|
|
266
|
+
r"(.*?\.lean):(\d+):(\d+)(?:-(\d+):(\d+))?: (error|warning|info): (.*)"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Lake build error format: "error: file.lean:10:5: message"
|
|
270
|
+
lake_pattern = (
|
|
271
|
+
r"(error|warning|info): (.*?\.lean):(\d+):(\d+)(?:-(\d+):(\d+))?: (.*)"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Alternative format without severity prefix
|
|
275
|
+
alt_pattern = r"(.*?\.lean):(\d+):(\d+)(?:-(\d+):(\d+))?: (.*)"
|
|
276
|
+
|
|
277
|
+
# Multi-line error context tracking
|
|
278
|
+
current_error = None
|
|
279
|
+
context_lines = []
|
|
280
|
+
|
|
281
|
+
lines = text_output.split("\n")
|
|
282
|
+
i = 0
|
|
283
|
+
|
|
284
|
+
while i < len(lines):
|
|
285
|
+
line = lines[i]
|
|
286
|
+
|
|
287
|
+
# Try standard pattern first
|
|
288
|
+
match = re.match(standard_pattern, line)
|
|
289
|
+
if match:
|
|
290
|
+
# Save previous error if exists
|
|
291
|
+
if current_error:
|
|
292
|
+
if context_lines:
|
|
293
|
+
current_error.message += "\n" + "\n".join(context_lines)
|
|
294
|
+
errors.append(current_error)
|
|
295
|
+
context_lines = []
|
|
296
|
+
|
|
297
|
+
file_name = match.group(1)
|
|
298
|
+
line_num = int(match.group(2))
|
|
299
|
+
col_num = int(match.group(3))
|
|
300
|
+
end_line = int(match.group(4)) if match.group(4) else None
|
|
301
|
+
end_col = int(match.group(5)) if match.group(5) else None
|
|
302
|
+
severity = match.group(6)
|
|
303
|
+
message = match.group(7)
|
|
304
|
+
|
|
305
|
+
current_error = LeanError(
|
|
306
|
+
file_name=file_name,
|
|
307
|
+
line=line_num,
|
|
308
|
+
column=col_num,
|
|
309
|
+
end_line=end_line,
|
|
310
|
+
end_column=end_col,
|
|
311
|
+
severity=severity,
|
|
312
|
+
message=message,
|
|
313
|
+
suggestion=_get_suggestion_for_message(message),
|
|
314
|
+
)
|
|
315
|
+
i += 1
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
# Try lake pattern
|
|
319
|
+
match = re.match(lake_pattern, line)
|
|
320
|
+
if match:
|
|
321
|
+
if current_error:
|
|
322
|
+
if context_lines:
|
|
323
|
+
current_error.message += "\n" + "\n".join(context_lines)
|
|
324
|
+
errors.append(current_error)
|
|
325
|
+
context_lines = []
|
|
326
|
+
|
|
327
|
+
severity = match.group(1)
|
|
328
|
+
file_name = match.group(2)
|
|
329
|
+
line_num = int(match.group(3))
|
|
330
|
+
col_num = int(match.group(4))
|
|
331
|
+
end_line = int(match.group(5)) if match.group(5) else None
|
|
332
|
+
end_col = int(match.group(6)) if match.group(6) else None
|
|
333
|
+
message = match.group(7)
|
|
334
|
+
|
|
335
|
+
current_error = LeanError(
|
|
336
|
+
file_name=file_name,
|
|
337
|
+
line=line_num,
|
|
338
|
+
column=col_num,
|
|
339
|
+
end_line=end_line,
|
|
340
|
+
end_column=end_col,
|
|
341
|
+
severity=severity,
|
|
342
|
+
message=message,
|
|
343
|
+
suggestion=_get_suggestion_for_message(message),
|
|
344
|
+
)
|
|
345
|
+
i += 1
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# Try alternative pattern (no severity)
|
|
349
|
+
match = re.match(alt_pattern, line)
|
|
350
|
+
if match and ":" in match.group(6): # Ensure it's likely an error message
|
|
351
|
+
if current_error:
|
|
352
|
+
if context_lines:
|
|
353
|
+
current_error.message += "\n" + "\n".join(context_lines)
|
|
354
|
+
errors.append(current_error)
|
|
355
|
+
context_lines = []
|
|
356
|
+
|
|
357
|
+
file_name = match.group(1)
|
|
358
|
+
line_num = int(match.group(2))
|
|
359
|
+
col_num = int(match.group(3))
|
|
360
|
+
end_line = int(match.group(4)) if match.group(4) else None
|
|
361
|
+
end_col = int(match.group(5)) if match.group(5) else None
|
|
362
|
+
message = match.group(6)
|
|
363
|
+
|
|
364
|
+
# Determine severity from message content
|
|
365
|
+
severity = "error"
|
|
366
|
+
if message.lower().startswith("warning:"):
|
|
367
|
+
severity = "warning"
|
|
368
|
+
message = message[8:].strip()
|
|
369
|
+
elif message.lower().startswith("info:"):
|
|
370
|
+
severity = "info"
|
|
371
|
+
message = message[5:].strip()
|
|
372
|
+
|
|
373
|
+
current_error = LeanError(
|
|
374
|
+
file_name=file_name,
|
|
375
|
+
line=line_num,
|
|
376
|
+
column=col_num,
|
|
377
|
+
end_line=end_line,
|
|
378
|
+
end_column=end_col,
|
|
379
|
+
severity=severity,
|
|
380
|
+
message=message,
|
|
381
|
+
suggestion=_get_suggestion_for_message(message),
|
|
382
|
+
)
|
|
383
|
+
i += 1
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
# Check if this is a context line (starts with whitespace and current_error exists)
|
|
387
|
+
if current_error and line and (line.startswith(" ") or line.startswith("\t")):
|
|
388
|
+
context_lines.append(line)
|
|
389
|
+
elif current_error and not line.strip():
|
|
390
|
+
# Empty line might separate errors
|
|
391
|
+
if context_lines:
|
|
392
|
+
current_error.message += "\n" + "\n".join(context_lines)
|
|
393
|
+
errors.append(current_error)
|
|
394
|
+
current_error = None
|
|
395
|
+
context_lines = []
|
|
396
|
+
|
|
397
|
+
i += 1
|
|
398
|
+
|
|
399
|
+
# Don't forget the last error
|
|
400
|
+
if current_error:
|
|
401
|
+
if context_lines:
|
|
402
|
+
current_error.message += "\n" + "\n".join(context_lines)
|
|
403
|
+
errors.append(current_error)
|
|
404
|
+
|
|
405
|
+
# If no errors were found but there's error-like content, try to extract it
|
|
406
|
+
if not errors and text_output.strip():
|
|
407
|
+
# Look for common error indicators
|
|
408
|
+
for line in lines:
|
|
409
|
+
if any(
|
|
410
|
+
indicator in line.lower()
|
|
411
|
+
for indicator in ["error:", "failed", "unknown", "cannot"]
|
|
412
|
+
):
|
|
413
|
+
# Extract as a general error
|
|
414
|
+
errors.append(
|
|
415
|
+
LeanError(
|
|
416
|
+
file_name="unknown",
|
|
417
|
+
line=0,
|
|
418
|
+
column=0,
|
|
419
|
+
severity="error",
|
|
420
|
+
message=line.strip(),
|
|
421
|
+
suggestion=_get_suggestion_for_message(line),
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
# If still no errors, include the full output
|
|
427
|
+
if not errors:
|
|
428
|
+
errors.append(
|
|
429
|
+
LeanError(
|
|
430
|
+
file_name="unknown",
|
|
431
|
+
line=0,
|
|
432
|
+
column=0,
|
|
433
|
+
severity="error",
|
|
434
|
+
message=text_output.strip()[:500], # Limit message length
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return errors
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _get_suggestion_for_message(message: str) -> str | None:
|
|
442
|
+
"""
|
|
443
|
+
Get a helpful suggestion based on the error message content.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
message: The error message
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
A suggestion string or None
|
|
450
|
+
"""
|
|
451
|
+
msg_lower = message.lower()
|
|
452
|
+
|
|
453
|
+
# Simple suggestions that identify the issue without prescribing solutions
|
|
454
|
+
if "unknown identifier" in msg_lower:
|
|
455
|
+
# Try to extract the identifier (including dot-qualified names like Nat.add_comm)
|
|
456
|
+
match = re.search(
|
|
457
|
+
r"unknown identifier [`']?([\w.]+)[`']?", message, re.IGNORECASE
|
|
458
|
+
)
|
|
459
|
+
if match:
|
|
460
|
+
ident = match.group(1)
|
|
461
|
+
return f"'{ident}' is not defined in the current scope."
|
|
462
|
+
return "Identifier not found in the current scope."
|
|
463
|
+
|
|
464
|
+
elif "unknown tactic" in msg_lower:
|
|
465
|
+
match = re.search(r"unknown tactic [`']?([\w.]+)[`']?", message, re.IGNORECASE)
|
|
466
|
+
if match:
|
|
467
|
+
tactic = match.group(1)
|
|
468
|
+
return f"Tactic '{tactic}' is not available."
|
|
469
|
+
return "This tactic is not available."
|
|
470
|
+
|
|
471
|
+
elif "does not exist" in msg_lower and "import" in msg_lower:
|
|
472
|
+
return "Import path not found."
|
|
473
|
+
|
|
474
|
+
elif "unknown module prefix" in msg_lower:
|
|
475
|
+
match = re.search(
|
|
476
|
+
r"unknown module prefix [`']?([\w.]+)[`']?", message, re.IGNORECASE
|
|
477
|
+
)
|
|
478
|
+
if match:
|
|
479
|
+
module = match.group(1)
|
|
480
|
+
if module == "Mathlib":
|
|
481
|
+
return "Mathlib is not available in this project."
|
|
482
|
+
return f"Module '{module}' not found."
|
|
483
|
+
return "Module not found."
|
|
484
|
+
|
|
485
|
+
elif "failed to synthesize instance" in msg_lower:
|
|
486
|
+
return "Required type class instance not found."
|
|
487
|
+
|
|
488
|
+
# For other error types, return None to avoid being prescriptive
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def parse_lean_output(output: str, json_format: bool = False) -> list:
|
|
493
|
+
"""
|
|
494
|
+
Parse Lean's output into structured errors.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
output: The output from Lean
|
|
498
|
+
json_format: Whether the output is in JSON format
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
List of parsed LeanError objects
|
|
502
|
+
"""
|
|
503
|
+
if not output or not output.strip():
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
if json_format:
|
|
507
|
+
# Check if it actually looks like JSON before parsing it as such
|
|
508
|
+
if output.strip().startswith("{"):
|
|
509
|
+
try:
|
|
510
|
+
return parse_lean_json_output(output)
|
|
511
|
+
except Exception:
|
|
512
|
+
return parse_lean_text_output(output)
|
|
513
|
+
else:
|
|
514
|
+
# If we expected JSON but got text, try parsing as text as a fallback
|
|
515
|
+
return parse_lean_text_output(output)
|
|
516
|
+
else:
|
|
517
|
+
return parse_lean_text_output(output)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized path management for LeanBack tools.
|
|
3
|
+
|
|
4
|
+
This module provides a unified interface for managing paths to project directories
|
|
5
|
+
and constructing the LEAN_PATH environment variable for projects using standard
|
|
6
|
+
Lake workflow with per-project dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PathManager:
|
|
15
|
+
"""
|
|
16
|
+
Centralized path management for LeanBack tools.
|
|
17
|
+
|
|
18
|
+
This class handles all operations related to finding, validating, and
|
|
19
|
+
constructing paths for projects using standard Lake workflow.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_MAX_CACHE_SIZE = 1000
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
"""Initialize a new PathManager."""
|
|
26
|
+
self._cache: dict[str, Any] = {}
|
|
27
|
+
|
|
28
|
+
def _cache_set(self, key: str, value: Any) -> None:
|
|
29
|
+
"""Set a cache entry, clearing the cache if it exceeds the size limit."""
|
|
30
|
+
if len(self._cache) >= self._MAX_CACHE_SIZE:
|
|
31
|
+
self._cache.clear()
|
|
32
|
+
self._cache[key] = value
|
|
33
|
+
|
|
34
|
+
def is_valid_project(self, project_path: str | Path, refresh: bool = False) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Check if a path is a valid Lean project.
|
|
37
|
+
|
|
38
|
+
A valid project must have:
|
|
39
|
+
- A lean-toolchain file
|
|
40
|
+
- Either lakefile.toml or lakefile.lean
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
project_path: Path to the project directory
|
|
44
|
+
refresh: Whether to refresh the cached check
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if the path is a valid project, False otherwise
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(project_path, str):
|
|
50
|
+
project_path = Path(project_path)
|
|
51
|
+
|
|
52
|
+
cache_key = f"valid_project:{project_path}"
|
|
53
|
+
if refresh or cache_key not in self._cache:
|
|
54
|
+
# Check if the directory exists
|
|
55
|
+
if not project_path.exists() or not project_path.is_dir():
|
|
56
|
+
self._cache_set(cache_key, False)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# Check for lean-toolchain file
|
|
60
|
+
if not (project_path / "lean-toolchain").exists():
|
|
61
|
+
self._cache_set(cache_key, False)
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Check for lakefile (either .toml or .lean)
|
|
65
|
+
if (
|
|
66
|
+
not (project_path / "lakefile.toml").exists()
|
|
67
|
+
and not (project_path / "lakefile.lean").exists()
|
|
68
|
+
):
|
|
69
|
+
self._cache_set(cache_key, False)
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
self._cache_set(cache_key, True)
|
|
73
|
+
|
|
74
|
+
return self._cache[cache_key]
|
|
75
|
+
|
|
76
|
+
def project_uses_mathlib(
|
|
77
|
+
self, project_path: str | Path, refresh: bool = False
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Check if a project uses mathlib by looking at its lakefile.toml.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
project_path: Path to the project directory
|
|
84
|
+
refresh: Whether to refresh the cached check
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if the project uses mathlib, False otherwise
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(project_path, str):
|
|
90
|
+
project_path = Path(project_path)
|
|
91
|
+
|
|
92
|
+
cache_key = f"project_uses_mathlib:{project_path}"
|
|
93
|
+
if refresh or cache_key not in self._cache:
|
|
94
|
+
# First check if it's a valid project
|
|
95
|
+
if not self.is_valid_project(project_path, refresh=refresh):
|
|
96
|
+
self._cache_set(cache_key, False)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Look for lakefile.toml
|
|
100
|
+
lakefile_path = project_path / "lakefile.toml"
|
|
101
|
+
if not lakefile_path.exists():
|
|
102
|
+
# For now, assume lakefile.lean doesn't use mathlib
|
|
103
|
+
self._cache_set(cache_key, False)
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
with open(lakefile_path, "rb") as f:
|
|
108
|
+
lake_config = tomllib.load(f)
|
|
109
|
+
|
|
110
|
+
# Check if mathlib is in dependencies
|
|
111
|
+
has_mathlib = False
|
|
112
|
+
|
|
113
|
+
# Check for dependencies.mathlib section
|
|
114
|
+
if "dependencies" in lake_config and isinstance(
|
|
115
|
+
lake_config["dependencies"], dict
|
|
116
|
+
):
|
|
117
|
+
has_mathlib = "mathlib" in lake_config["dependencies"]
|
|
118
|
+
|
|
119
|
+
self._cache_set(cache_key, has_mathlib)
|
|
120
|
+
|
|
121
|
+
except Exception:
|
|
122
|
+
# If we can't parse the lakefile, assume it doesn't use mathlib
|
|
123
|
+
self._cache_set(cache_key, False)
|
|
124
|
+
|
|
125
|
+
return self._cache[cache_key]
|
|
126
|
+
|
|
127
|
+
def find_project_root(self, start_path: str | Path | None = None) -> Path | None:
|
|
128
|
+
"""
|
|
129
|
+
Find the nearest Lean project root directory by traversing up from the start path.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
start_path: The directory to start the search from (defaults to current directory)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Path to the project root or None if not found
|
|
136
|
+
"""
|
|
137
|
+
if start_path is None:
|
|
138
|
+
start_path = Path.cwd()
|
|
139
|
+
elif isinstance(start_path, str):
|
|
140
|
+
start_path = Path(start_path)
|
|
141
|
+
|
|
142
|
+
# Store the original relative format of the path
|
|
143
|
+
original_path = start_path
|
|
144
|
+
|
|
145
|
+
# Use absolute path for traversal but keep track of the relative position
|
|
146
|
+
current = start_path.absolute()
|
|
147
|
+
|
|
148
|
+
# Traverse up to find a lean-toolchain file or lakefile.toml
|
|
149
|
+
while current != current.parent: # Stop at root
|
|
150
|
+
if (current / "lean-toolchain").exists() and (
|
|
151
|
+
(current / "lakefile.toml").exists()
|
|
152
|
+
or (current / "lakefile.lean").exists()
|
|
153
|
+
):
|
|
154
|
+
# Found the project root - return the path in the same format as input
|
|
155
|
+
if original_path.is_absolute():
|
|
156
|
+
return current
|
|
157
|
+
else:
|
|
158
|
+
try:
|
|
159
|
+
return current.relative_to(Path.cwd())
|
|
160
|
+
except ValueError:
|
|
161
|
+
return current
|
|
162
|
+
current = current.parent
|
|
163
|
+
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def file_has_mathlib_import(
|
|
167
|
+
self, file_path: str | Path, refresh: bool = False
|
|
168
|
+
) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Check if a Lean file imports mathlib.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
file_path: Path to the Lean file
|
|
174
|
+
refresh: Whether to refresh the cached check
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
True if the file imports mathlib, False otherwise
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(file_path, str):
|
|
180
|
+
file_path = Path(file_path)
|
|
181
|
+
|
|
182
|
+
cache_key = f"file_has_mathlib_import:{file_path}"
|
|
183
|
+
if refresh or cache_key not in self._cache:
|
|
184
|
+
try:
|
|
185
|
+
with open(file_path) as f:
|
|
186
|
+
content = f.read()
|
|
187
|
+
self._cache_set(cache_key, "import Mathlib" in content)
|
|
188
|
+
except (OSError, UnicodeDecodeError):
|
|
189
|
+
self._cache_set(cache_key, False)
|
|
190
|
+
|
|
191
|
+
return self._cache[cache_key]
|