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,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
@@ -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