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.
@@ -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]