leanback 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (23) hide show
  1. {leanback-0.1.2 → leanback-0.1.4}/PKG-INFO +8 -3
  2. {leanback-0.1.2 → leanback-0.1.4}/README.md +6 -1
  3. {leanback-0.1.2 → leanback-0.1.4}/pyproject.toml +10 -7
  4. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/README.md +5 -2
  5. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/env.py +66 -0
  6. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/errors.py +28 -0
  7. leanback-0.1.4/src/leanback/common/lsp_client.py +412 -0
  8. leanback-0.1.4/src/leanback/common/warm.py +171 -0
  9. leanback-0.1.4/src/leanback/common/watchdog.py +182 -0
  10. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/mcp_server.py +100 -24
  11. {leanback-0.1.2 → leanback-0.1.4}/.gitignore +0 -0
  12. {leanback-0.1.2 → leanback-0.1.4}/LICENSE +0 -0
  13. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/__init__.py +0 -0
  14. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/check.py +0 -0
  15. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/__init__.py +0 -0
  16. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/path_manager.py +0 -0
  17. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/project_context.py +0 -0
  18. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/common/util.py +0 -0
  19. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/goals.py +0 -0
  20. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/mathlib_search.py +0 -0
  21. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/mathlib_search_simple.py +0 -0
  22. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/mcp_test.py +0 -0
  23. {leanback-0.1.2 → leanback-0.1.4}/src/leanback/prove.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leanback
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: MCP server for Lean 4 theorem proving — check, prove, goals, eval, search
5
5
  Author: Stefan Szeider
6
6
  License: Apache-2.0
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Scientific/Engineering :: Mathematics
15
15
  Requires-Python: >=3.11
16
- Requires-Dist: mcp>=1.9.4
16
+ Requires-Dist: mcp>=1.26.0
17
17
  Requires-Dist: rich>=14.0.0
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -92,6 +92,8 @@ check(project="/home/user/lean/myproject", file="MyProof.lean")
92
92
  check(project="/home/user/lean/myproject", expr="#check Nat.add", imports=["Mathlib.Tactic"])
93
93
  ```
94
94
 
95
+ Repeated `check(file=...)` calls on the same file reuse a warm Lean server: the first check pays the import cost, subsequent checks after edits take seconds (as long as the file's import lines are unchanged). Idle servers shut down automatically after 10 minutes; set `LEANBACK_NO_WARM=1` to disable warm sessions entirely.
96
+
95
97
  ### prove
96
98
 
97
99
  Verify theorem proofs with tactics, or check proof files.
@@ -121,8 +123,11 @@ Execute Lean expressions and see results.
121
123
  eval(project="/home/user/lean/myproject", code="#eval 2 + 2")
122
124
  eval(project="/home/user/lean/myproject", code="#check List.map")
123
125
  eval(project="/home/user/lean/myproject", code="#eval Nat.gcd 12 18", mathlib=True)
126
+ eval(project="/home/user/lean/myproject", code="#eval myFunc 3", imports=["MyProject.Definitions"], build=True)
124
127
  ```
125
128
 
129
+ With `build=True`, imported modules of the project itself are compiled first (`lake build`), so they don't need to be built manually before use.
130
+
126
131
  ### search
127
132
 
128
133
  Find declarations in Mathlib by pattern.
@@ -135,7 +140,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
135
140
 
136
141
  ## All Tools Are Read-Only
137
142
 
138
- LeanBack never modifies files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
143
+ LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
139
144
 
140
145
  ## Response Format
141
146
 
@@ -73,6 +73,8 @@ check(project="/home/user/lean/myproject", file="MyProof.lean")
73
73
  check(project="/home/user/lean/myproject", expr="#check Nat.add", imports=["Mathlib.Tactic"])
74
74
  ```
75
75
 
76
+ Repeated `check(file=...)` calls on the same file reuse a warm Lean server: the first check pays the import cost, subsequent checks after edits take seconds (as long as the file's import lines are unchanged). Idle servers shut down automatically after 10 minutes; set `LEANBACK_NO_WARM=1` to disable warm sessions entirely.
77
+
76
78
  ### prove
77
79
 
78
80
  Verify theorem proofs with tactics, or check proof files.
@@ -102,8 +104,11 @@ Execute Lean expressions and see results.
102
104
  eval(project="/home/user/lean/myproject", code="#eval 2 + 2")
103
105
  eval(project="/home/user/lean/myproject", code="#check List.map")
104
106
  eval(project="/home/user/lean/myproject", code="#eval Nat.gcd 12 18", mathlib=True)
107
+ eval(project="/home/user/lean/myproject", code="#eval myFunc 3", imports=["MyProject.Definitions"], build=True)
105
108
  ```
106
109
 
110
+ With `build=True`, imported modules of the project itself are compiled first (`lake build`), so they don't need to be built manually before use.
111
+
107
112
  ### search
108
113
 
109
114
  Find declarations in Mathlib by pattern.
@@ -116,7 +121,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
116
121
 
117
122
  ## All Tools Are Read-Only
118
123
 
119
- LeanBack never modifies files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
124
+ LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
120
125
 
121
126
  ## Response Format
122
127
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "leanback"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "MCP server for Lean 4 theorem proving — check, prove, goals, eval, search"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -21,15 +21,15 @@ classifiers = [
21
21
  ]
22
22
  dependencies = [
23
23
  "rich>=14.0.0",
24
- "mcp>=1.9.4",
24
+ "mcp>=1.26.0",
25
25
  ]
26
26
 
27
27
  [dependency-groups]
28
28
  dev = [
29
- "pytest>=8.4.0",
30
- "ruff>=0.11.0",
31
- "mypy>=1.16.0",
32
- "coverage>=7.8.0",
29
+ "pytest>=9.0.0",
30
+ "ruff>=0.15.0",
31
+ "mypy>=1.19.0",
32
+ "coverage>=7.13.0",
33
33
  ]
34
34
 
35
35
  [tool.ruff]
@@ -47,6 +47,9 @@ line-length = 88
47
47
  # Auto-fix issues
48
48
  fix = true
49
49
 
50
+ # Exclude non-Python project directories
51
+ exclude = ["projects/"]
52
+
50
53
  [tool.ruff.format]
51
54
  # Black-compatible formatting
52
55
  quote-style = "double"
@@ -87,7 +90,7 @@ lines-after-imports = 2
87
90
 
88
91
  [tool.ruff.lint.per-file-ignores]
89
92
  # Tests can use assert and have unused imports
90
- "tests/*" = ["S101", "F401", "F841"]
93
+ "tests/*" = ["S101", "F401", "F841", "RUF059"]
91
94
  "__init__.py" = ["F401"]
92
95
 
93
96
  [tool.mypy]
@@ -12,8 +12,11 @@ MCP server exposing Lean 4 development tools via Model Context Protocol.
12
12
  - `mathlib_search_simple.py`: Regex-based Mathlib search backend
13
13
  - `mcp_test.py`: E2E test client
14
14
  - `common/`: Shared utilities
15
- - `env.py`: Lean environment handling (LeanRunner)
16
- - `errors.py`: Error parsing and formatting
15
+ - `env.py`: Lean environment handling (LeanRunner, lake build helpers)
16
+ - `errors.py`: Error parsing and formatting (CLI JSON and LSP diagnostics)
17
+ - `lsp_client.py`: Synchronous Lean LSP client for warm file checks
18
+ - `warm.py`: Warm worker registry (staleness, idle timeout, atexit cleanup)
19
+ - `watchdog.py`: Process wrapper that reaps workers if the server dies
17
20
  - `project_context.py`: Project validation and context
18
21
  - `path_manager.py`: Path resolution and management
19
22
  - `util.py`: Misc utilities (temp file creation)
@@ -6,9 +6,70 @@ This module provides functionality for executing Lean commands in the correct en
6
6
 
7
7
  import os
8
8
  import subprocess
9
+ import sys
9
10
  from pathlib import Path
10
11
 
11
12
 
13
+ def _find_native_dynlibs(project_path: Path) -> list[Path]:
14
+ """Discover native shared libraries in Lake packages.
15
+
16
+ Scans .lake/packages/*/.lake/build/lib/ for .so (Linux) or .dylib (macOS)
17
+ files that need --load-dynlib for the Lean interpreter.
18
+ """
19
+ packages_dir = project_path / ".lake" / "packages"
20
+ if not packages_dir.is_dir():
21
+ return []
22
+
23
+ if sys.platform == "darwin":
24
+ ext = "dylib"
25
+ elif sys.platform == "win32":
26
+ ext = "dll"
27
+ else:
28
+ ext = "so"
29
+ return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{ext}"))
30
+
31
+
32
+ def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
33
+ """Filter module names to those with source files in the project.
34
+
35
+ A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
36
+ Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
37
+ are already built or come from the cache.
38
+ """
39
+ local = []
40
+ for module in modules:
41
+ source = project_path / (module.replace(".", "/") + ".lean")
42
+ if source.is_file():
43
+ local.append(module)
44
+ return local
45
+
46
+
47
+ def build_modules(
48
+ project_path: Path, modules: list[str], timeout: float = 600
49
+ ) -> tuple[bool, str]:
50
+ """Build the given modules with ``lake build`` in the project directory.
51
+
52
+ Returns:
53
+ Tuple of (success, output). On failure, output contains the
54
+ combined stdout/stderr from lake.
55
+ """
56
+ if not modules:
57
+ return True, ""
58
+ try:
59
+ result = subprocess.run(
60
+ ["lake", "build", *modules],
61
+ cwd=project_path,
62
+ capture_output=True,
63
+ text=True,
64
+ check=False,
65
+ timeout=timeout,
66
+ )
67
+ except subprocess.TimeoutExpired:
68
+ return False, f"lake build timed out after {timeout:.0f} seconds"
69
+ output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
70
+ return result.returncode == 0, output
71
+
72
+
12
73
  class LeanRunner:
13
74
  """
14
75
  Utility for running Lean commands in the appropriate environment.
@@ -53,6 +114,11 @@ class LeanRunner:
53
114
 
54
115
  # Always use lake env lean for projects - this ensures proper dependency management
55
116
  command = ["lake", "env", "lean"]
117
+
118
+ # Load native shared libraries for FFI-dependent packages (e.g., cvc5)
119
+ for lib in _find_native_dynlibs(project_path):
120
+ command.append(f"--load-dynlib={lib}")
121
+
56
122
  if capture_json:
57
123
  command.append("--json")
58
124
  command.append(str(abs_file_path))
@@ -489,6 +489,34 @@ def _get_suggestion_for_message(message: str) -> str | None:
489
489
  return None
490
490
 
491
491
 
492
+ def lsp_diagnostics_to_errors(diagnostics: list[dict], file_name: str) -> list:
493
+ """Convert LSP publishDiagnostics entries to LeanError objects.
494
+
495
+ LSP positions are 0-based for both line and character; Lean's CLI JSON
496
+ output (which LeanError follows) uses 1-based lines and 0-based columns,
497
+ so lines are shifted by one.
498
+ """
499
+ severity_names = {1: "error", 2: "warning", 3: "info", 4: "info"}
500
+ errors = []
501
+ for diag in diagnostics:
502
+ start = diag.get("range", {}).get("start", {})
503
+ end = diag.get("range", {}).get("end", {})
504
+ msg_text = diag.get("message", "Unknown error")
505
+ errors.append(
506
+ LeanError(
507
+ file_name=file_name,
508
+ line=start.get("line", 0) + 1,
509
+ column=start.get("character", 0),
510
+ end_line=end.get("line", 0) + 1 if end else None,
511
+ end_column=end.get("character") if end else None,
512
+ severity=severity_names.get(diag.get("severity", 1), "error"),
513
+ message=msg_text,
514
+ suggestion=_get_suggestion_for_message(msg_text),
515
+ )
516
+ )
517
+ return errors
518
+
519
+
492
520
  def parse_lean_output(output: str, json_format: bool = False) -> list:
493
521
  """
494
522
  Parse Lean's output into structured errors.
@@ -0,0 +1,412 @@
1
+ """
2
+ Synchronous Lean LSP client for warm-environment re-checking.
3
+
4
+ LeanBack keeps a warm ``lake serve`` process per Lean project so that
5
+ re-checking an edited file skips the expensive Mathlib import: the imported
6
+ environment stays resident across edits, so a body-only edit re-checks in a
7
+ fraction of a second instead of tens of seconds.
8
+
9
+ This module drives a single ``lake serve`` process over the Language Server
10
+ Protocol (LSP). It is deliberately single-threaded: there is no background
11
+ reader thread. Every read happens inside a blocking wait bounded by a deadline,
12
+ using ``select.select`` on the server's stdout (POSIX only — the warm path is
13
+ POSIX-only; other platforms fall back to the cold path elsewhere).
14
+
15
+ Framing follows the LSP base protocol: ``Content-Length: N\\r\\n\\r\\n`` followed
16
+ by an N-byte UTF-8 JSON body. The client tolerates all interleaved server noise
17
+ (``$/lean/fileProgress`` notifications, intermediate ``publishDiagnostics``,
18
+ and server-to-client ``workspace/*/refresh`` requests).
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import select
24
+ import subprocess
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+
30
+ # Timeout (seconds) for the initialize handshake. The server answers
31
+ # ``initialize`` quickly; this only guards against a server that never starts.
32
+ _HANDSHAKE_TIMEOUT = 60.0
33
+
34
+ # Whole-file replacement range for an Incremental (sync kind 2) didChange: a
35
+ # single content-change event spanning the entire document. The end line is
36
+ # deliberately huge so it covers any current file length.
37
+ _WHOLE_FILE_RANGE = {
38
+ "start": {"line": 0, "character": 0},
39
+ "end": {"line": 10_000_000, "character": 0},
40
+ }
41
+
42
+
43
+ class LspTimeoutError(Exception):
44
+ """Raised when an LSP operation does not complete within its deadline."""
45
+
46
+
47
+ class LspProtocolError(Exception):
48
+ """Raised on a protocol failure: dead process, malformed frame, or an
49
+ error response from the server."""
50
+
51
+
52
+ class LeanLspWorker:
53
+ """Drive a single warm ``lake serve`` process over LSP.
54
+
55
+ The worker spawns the server, performs the LSP handshake, and lets callers
56
+ re-check the on-disk content of files. The first check of a path issues a
57
+ ``didOpen``; later checks of the same path issue a whole-file ``didChange``,
58
+ which reuses the warm imported environment.
59
+
60
+ The worker is single-threaded and not safe for concurrent use.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ project_path: Path,
66
+ server_command: list[str] | None = None,
67
+ use_watchdog: bool = True,
68
+ ) -> None:
69
+ """Spawn the server and perform the LSP handshake.
70
+
71
+ Args:
72
+ project_path: Absolute path to the Lean project. Used as the
73
+ server's working directory and as its ``rootUri``.
74
+ server_command: Full command to launch the server. Defaults to
75
+ ``["lake", "serve"]``. Tests override this with a fake server.
76
+ use_watchdog: On POSIX, wrap the command in
77
+ ``leanback.common.watchdog`` so the server is killed if this
78
+ process is SIGKILLed. Ignored on non-POSIX platforms.
79
+
80
+ Raises:
81
+ LspProtocolError: If the server dies or returns an error during the
82
+ handshake.
83
+ LspTimeoutError: If the handshake does not complete in time.
84
+ """
85
+ self._project_path = Path(project_path).resolve()
86
+ self._closed = False
87
+ self._id = 0
88
+ self._buf = b""
89
+ # uri -> latest publishDiagnostics payload {"version": int|None,
90
+ # "diagnostics": list[dict]}.
91
+ self._diagnostics: dict[str, dict] = {}
92
+ # uris that have received a didOpen, and their current version int.
93
+ self._versions: dict[str, int] = {}
94
+ self._last_used = time.monotonic()
95
+
96
+ command = list(server_command) if server_command else ["lake", "serve"]
97
+ if use_watchdog and os.name == "posix":
98
+ command = [
99
+ sys.executable,
100
+ "-m",
101
+ "leanback.common.watchdog",
102
+ str(os.getpid()),
103
+ "--",
104
+ *command,
105
+ ]
106
+
107
+ # stderr goes to DEVNULL: it must never be a pipe, which would fill and
108
+ # deadlock the server. stdout is unbuffered (bufsize=0) so a single
109
+ # read() returns whatever bytes are available without waiting to fill a
110
+ # buffer — this pairs cleanly with select() on the raw fd.
111
+ self._proc = subprocess.Popen(
112
+ command,
113
+ cwd=self._project_path,
114
+ stdin=subprocess.PIPE,
115
+ stdout=subprocess.PIPE,
116
+ stderr=subprocess.DEVNULL,
117
+ bufsize=0,
118
+ )
119
+
120
+ try:
121
+ self._handshake()
122
+ except BaseException:
123
+ self.shutdown()
124
+ raise
125
+
126
+ @property
127
+ def alive(self) -> bool:
128
+ """Whether the server process is still running and not shut down."""
129
+ return not self._closed and self._proc.poll() is None
130
+
131
+ @property
132
+ def last_used(self) -> float:
133
+ """``time.monotonic()`` timestamp of the most recent ``check_file``."""
134
+ return self._last_used
135
+
136
+ def check_file(self, file_path: Path, timeout: float = 600.0) -> list[dict]:
137
+ """Check the current on-disk content of ``file_path``.
138
+
139
+ The first call for a given path issues a ``didOpen``; later calls issue
140
+ a whole-file ``didChange``. The method then waits for the server to
141
+ finalize diagnostics for the new version and returns them.
142
+
143
+ Args:
144
+ file_path: Absolute path to a ``.lean`` file that exists on disk.
145
+ timeout: Maximum seconds to wait for diagnostics to finalize.
146
+
147
+ Returns:
148
+ The list of diagnostic entries (raw LSP dicts) from the
149
+ ``publishDiagnostics`` payload for the checked version. Empty if the
150
+ file is clean.
151
+
152
+ Raises:
153
+ LspProtocolError: If the server has exited or returns an error.
154
+ LspTimeoutError: If diagnostics do not finalize within ``timeout``.
155
+ """
156
+ self._ensure_alive()
157
+ self._last_used = time.monotonic()
158
+
159
+ path = Path(file_path).resolve()
160
+ uri = path.as_uri()
161
+ text = path.read_text(encoding="utf-8")
162
+
163
+ if uri not in self._versions:
164
+ version = 1
165
+ self._versions[uri] = version
166
+ self._notify(
167
+ "textDocument/didOpen",
168
+ {
169
+ "textDocument": {
170
+ "uri": uri,
171
+ "languageId": "lean4",
172
+ "version": version,
173
+ "text": text,
174
+ }
175
+ },
176
+ )
177
+ else:
178
+ version = self._versions[uri] + 1
179
+ self._versions[uri] = version
180
+ self._notify(
181
+ "textDocument/didChange",
182
+ {
183
+ "textDocument": {"uri": uri, "version": version},
184
+ "contentChanges": [{"range": _WHOLE_FILE_RANGE, "text": text}],
185
+ },
186
+ )
187
+
188
+ deadline = time.monotonic() + timeout
189
+ rid = self._request(
190
+ "textDocument/waitForDiagnostics", {"uri": uri, "version": version}
191
+ )
192
+ self._await_response(rid, deadline)
193
+
194
+ payload = self._diagnostics.get(uri)
195
+ if payload is None:
196
+ return []
197
+ return payload["diagnostics"]
198
+
199
+ def shutdown(self, timeout: float = 5.0) -> None:
200
+ """Shut the server down and terminate the process. Idempotent.
201
+
202
+ Sends a best-effort ``shutdown`` request and ``exit`` notification with
203
+ a short deadline, then terminates (and, if needed, kills) the process.
204
+
205
+ Args:
206
+ timeout: Maximum seconds to wait for the ``shutdown`` response.
207
+ """
208
+ if self._closed:
209
+ return
210
+ self._closed = True
211
+
212
+ if self._proc.poll() is None:
213
+ try:
214
+ rid = self._request("shutdown", None)
215
+ self._await_response(rid, time.monotonic() + timeout)
216
+ except (LspProtocolError, LspTimeoutError):
217
+ pass
218
+ try:
219
+ self._notify("exit", None)
220
+ except LspProtocolError:
221
+ pass
222
+
223
+ self._terminate()
224
+
225
+ # -- internals ---------------------------------------------------------
226
+
227
+ def _handshake(self) -> None:
228
+ """Perform the ``initialize`` / ``initialized`` LSP handshake."""
229
+ rid = self._request(
230
+ "initialize",
231
+ {
232
+ "processId": os.getpid(),
233
+ "rootUri": self._project_path.as_uri(),
234
+ "capabilities": {
235
+ "textDocument": {
236
+ "synchronization": {
237
+ "didSave": True,
238
+ "dynamicRegistration": False,
239
+ },
240
+ "publishDiagnostics": {"relatedInformation": True},
241
+ }
242
+ },
243
+ },
244
+ )
245
+ self._await_response(rid, time.monotonic() + _HANDSHAKE_TIMEOUT)
246
+ self._notify("initialized", {})
247
+
248
+ def _terminate(self) -> None:
249
+ """Terminate the process: SIGTERM, then SIGKILL after a short grace."""
250
+ if self._proc.poll() is not None:
251
+ return
252
+ try:
253
+ self._proc.terminate()
254
+ self._proc.wait(timeout=5.0)
255
+ except subprocess.TimeoutExpired:
256
+ self._proc.kill()
257
+ try:
258
+ self._proc.wait(timeout=5.0)
259
+ except subprocess.TimeoutExpired:
260
+ pass
261
+ except ProcessLookupError:
262
+ pass
263
+
264
+ def _ensure_alive(self) -> None:
265
+ """Raise if the worker is closed or the process has exited."""
266
+ if self._closed:
267
+ raise LspProtocolError("worker has been shut down")
268
+ if self._proc.poll() is not None:
269
+ raise LspProtocolError("Lean server process has exited")
270
+
271
+ def _next_id(self) -> int:
272
+ self._id += 1
273
+ return self._id
274
+
275
+ def _send(self, obj: dict) -> None:
276
+ """Frame and write a JSON-RPC message to the server's stdin."""
277
+ if self._proc.poll() is not None:
278
+ raise LspProtocolError("Lean server process has exited")
279
+ data = json.dumps(obj).encode("utf-8")
280
+ header = f"Content-Length: {len(data)}\r\n\r\n".encode("ascii")
281
+ try:
282
+ self._proc.stdin.write(header + data)
283
+ self._proc.stdin.flush()
284
+ except (BrokenPipeError, OSError) as exc:
285
+ raise LspProtocolError(f"failed to write to Lean server: {exc}")
286
+
287
+ def _request(self, method: str, params: object) -> int:
288
+ """Send a JSON-RPC request and return its id."""
289
+ rid = self._next_id()
290
+ self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
291
+ return rid
292
+
293
+ def _notify(self, method: str, params: object) -> None:
294
+ """Send a JSON-RPC notification (no id, no response expected)."""
295
+ self._send({"jsonrpc": "2.0", "method": method, "params": params})
296
+
297
+ def _respond_null(self, rid: object) -> None:
298
+ """Answer a server-to-client request with a null result (politeness)."""
299
+ self._send({"jsonrpc": "2.0", "id": rid, "result": None})
300
+
301
+ def _await_response(self, rid: int, deadline: float) -> object:
302
+ """Read and dispatch messages until the response for ``rid`` arrives.
303
+
304
+ Args:
305
+ rid: The request id to wait for.
306
+ deadline: ``time.monotonic()`` value past which to give up.
307
+
308
+ Returns:
309
+ The ``result`` field of the matching response.
310
+
311
+ Raises:
312
+ LspProtocolError: On a dead process, malformed frame, or error
313
+ response.
314
+ LspTimeoutError: If the response does not arrive by ``deadline``.
315
+ """
316
+ while True:
317
+ msg = self._read_message(deadline)
318
+ if "method" in msg:
319
+ if "id" in msg:
320
+ # Server-to-client request (e.g. semanticTokens/refresh):
321
+ # answer politely and keep waiting.
322
+ self._respond_null(msg["id"])
323
+ else:
324
+ self._handle_notification(msg)
325
+ continue
326
+ if msg.get("id") != rid:
327
+ # A response to some other (already abandoned) request.
328
+ continue
329
+ if "error" in msg:
330
+ raise LspProtocolError(f"LSP error response: {msg['error']}")
331
+ return msg.get("result")
332
+
333
+ def _handle_notification(self, msg: dict) -> None:
334
+ """Store publishDiagnostics payloads; ignore all other notifications."""
335
+ if msg.get("method") != "textDocument/publishDiagnostics":
336
+ return
337
+ params = msg.get("params", {})
338
+ uri = params.get("uri")
339
+ if uri is None:
340
+ return
341
+ version = params.get("version")
342
+ diagnostics = params.get("diagnostics", [])
343
+ existing = self._diagnostics.get(uri)
344
+ # Keep the latest payload for the uri: overwrite unless this one is
345
+ # tagged with a strictly older version.
346
+ if (
347
+ existing is None
348
+ or version is None
349
+ or existing["version"] is None
350
+ or version >= existing["version"]
351
+ ):
352
+ self._diagnostics[uri] = {
353
+ "version": version,
354
+ "diagnostics": diagnostics,
355
+ }
356
+
357
+ def _read_message(self, deadline: float) -> dict:
358
+ """Read one complete JSON-RPC message before ``deadline``.
359
+
360
+ Accumulates bytes from the server's stdout into an internal buffer and
361
+ parses framed messages out of it.
362
+
363
+ Raises:
364
+ LspProtocolError: If the process exits or a frame is malformed.
365
+ LspTimeoutError: If no complete message arrives by ``deadline``.
366
+ """
367
+ while True:
368
+ frame = self._try_parse_frame()
369
+ if frame is not None:
370
+ return frame
371
+
372
+ remaining = deadline - time.monotonic()
373
+ if remaining <= 0:
374
+ raise LspTimeoutError("timed out waiting for LSP message")
375
+
376
+ readable, _, _ = select.select([self._proc.stdout], [], [], remaining)
377
+ if not readable:
378
+ if self._proc.poll() is not None:
379
+ raise LspProtocolError("Lean server process has exited")
380
+ continue
381
+
382
+ chunk = self._proc.stdout.read(65536)
383
+ if not chunk:
384
+ raise LspProtocolError("Lean server closed its output stream")
385
+ self._buf += chunk
386
+
387
+ def _try_parse_frame(self) -> dict | None:
388
+ """Parse one framed message from the buffer, or return None if partial.
389
+
390
+ Raises:
391
+ LspProtocolError: If the header lacks Content-Length or the body is
392
+ not valid JSON.
393
+ """
394
+ if b"\r\n\r\n" not in self._buf:
395
+ return None
396
+ head, _, rest = self._buf.partition(b"\r\n\r\n")
397
+
398
+ length = None
399
+ for line in head.split(b"\r\n"):
400
+ if line.lower().startswith(b"content-length:"):
401
+ length = int(line.split(b":", 1)[1].strip())
402
+ if length is None:
403
+ raise LspProtocolError(f"missing Content-Length in header: {head!r}")
404
+
405
+ if len(rest) < length:
406
+ return None
407
+ body = rest[:length]
408
+ self._buf = rest[length:]
409
+ try:
410
+ return json.loads(body.decode("utf-8"))
411
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
412
+ raise LspProtocolError(f"malformed LSP message body: {exc}")
@@ -0,0 +1,171 @@
1
+ """
2
+ Warm Lean LSP worker registry.
3
+
4
+ Keeps one persistent ``lake serve`` worker per project so that re-checking an
5
+ edited file reuses the in-memory import environment instead of re-loading all
6
+ ``.olean`` files in a fresh process (~0.2 s instead of 20-120 s for
7
+ Mathlib-scale imports; the speedup applies when the file's import header is
8
+ unchanged between calls).
9
+
10
+ Lifecycle safeguards:
11
+ - Workers are spawned under the watchdog wrapper (see ``watchdog.py``), so
12
+ they die even if LeanBack is SIGKILLed.
13
+ - A worker is invalidated when the project configuration changes
14
+ (lean-toolchain, lakefile, lake-manifest mtimes).
15
+ - An idle worker is shut down after ``IDLE_TIMEOUT`` seconds (workers hold
16
+ the imported environment in RAM — several GB for full Mathlib).
17
+ - All workers are shut down atexit.
18
+
19
+ The warm path is POSIX-only and can be disabled with LEANBACK_NO_WARM=1;
20
+ callers must fall back to the cold ``lake env lean`` path whenever
21
+ ``check_file_warm`` returns None.
22
+ """
23
+
24
+ import atexit
25
+ import logging
26
+ import os
27
+ import threading
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from leanback.common.lsp_client import (
32
+ LeanLspWorker,
33
+ LspProtocolError,
34
+ LspTimeoutError,
35
+ )
36
+
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Seconds a worker may sit unused before the sweeper shuts it down.
41
+ IDLE_TIMEOUT = 600.0
42
+
43
+ # Seconds between sweeper runs.
44
+ SWEEP_INTERVAL = 60.0
45
+
46
+ # Project files whose modification invalidates a warm worker.
47
+ _CONFIG_FILES = (
48
+ "lean-toolchain",
49
+ "lakefile.lean",
50
+ "lakefile.toml",
51
+ "lake-manifest.json",
52
+ )
53
+
54
+
55
+ def warm_enabled() -> bool:
56
+ """Whether the warm LSP path may be used on this platform/configuration."""
57
+ return os.name == "posix" and os.environ.get("LEANBACK_NO_WARM") != "1"
58
+
59
+
60
+ def _config_snapshot(project_path: Path) -> tuple:
61
+ """Snapshot mtimes of the files that invalidate a warm worker."""
62
+ snapshot = []
63
+ for name in _CONFIG_FILES:
64
+ path = project_path / name
65
+ try:
66
+ snapshot.append((name, path.stat().st_mtime_ns))
67
+ except OSError:
68
+ snapshot.append((name, None))
69
+ return tuple(snapshot)
70
+
71
+
72
+ class _WorkerRegistry:
73
+ """Registry of warm workers, keyed by resolved project path."""
74
+
75
+ def __init__(self):
76
+ self._lock = threading.Lock()
77
+ self._workers: dict[Path, tuple[LeanLspWorker, tuple]] = {}
78
+ self._project_locks: dict[Path, threading.Lock] = {}
79
+ self._sweeper: threading.Thread | None = None
80
+
81
+ def project_lock(self, project_path: Path) -> threading.Lock:
82
+ """Per-project lock serializing warm checks (the worker is single-threaded)."""
83
+ with self._lock:
84
+ return self._project_locks.setdefault(project_path, threading.Lock())
85
+
86
+ def get(self, project_path: Path) -> LeanLspWorker:
87
+ """Return a live, non-stale worker for the project, spawning if needed."""
88
+ snapshot = _config_snapshot(project_path)
89
+ with self._lock:
90
+ entry = self._workers.get(project_path)
91
+ if entry is not None:
92
+ worker, spawn_snapshot = entry
93
+ if worker.alive and spawn_snapshot == snapshot:
94
+ return worker
95
+ del self._workers[project_path]
96
+ else:
97
+ worker = None
98
+ # Shut down the stale/dead worker outside the registry lock.
99
+ if worker is not None:
100
+ worker.shutdown()
101
+ logger.info("Respawning warm worker for %s (stale or dead)", project_path)
102
+ new_worker = LeanLspWorker(project_path)
103
+ with self._lock:
104
+ self._workers[project_path] = (new_worker, snapshot)
105
+ self._ensure_sweeper()
106
+ return new_worker
107
+
108
+ def discard(self, project_path: Path) -> None:
109
+ """Shut down and forget the project's worker (if any)."""
110
+ with self._lock:
111
+ entry = self._workers.pop(project_path, None)
112
+ if entry is not None:
113
+ entry[0].shutdown()
114
+
115
+ def shutdown_all(self) -> None:
116
+ """Shut down every worker. Registered atexit."""
117
+ with self._lock:
118
+ entries = list(self._workers.values())
119
+ self._workers.clear()
120
+ for worker, _ in entries:
121
+ worker.shutdown()
122
+
123
+ def _ensure_sweeper(self) -> None:
124
+ # Caller holds self._lock.
125
+ if self._sweeper is None or not self._sweeper.is_alive():
126
+ self._sweeper = threading.Thread(
127
+ target=self._sweep_loop, name="leanback-warm-sweeper", daemon=True
128
+ )
129
+ self._sweeper.start()
130
+
131
+ def _sweep_loop(self) -> None:
132
+ while True:
133
+ time.sleep(SWEEP_INTERVAL)
134
+ now = time.monotonic()
135
+ with self._lock:
136
+ idle = [
137
+ path
138
+ for path, (worker, _) in self._workers.items()
139
+ if now - worker.last_used > IDLE_TIMEOUT
140
+ ]
141
+ entries = [self._workers.pop(path) for path in idle]
142
+ for worker, _ in entries:
143
+ logger.info("Shutting down idle warm worker")
144
+ worker.shutdown()
145
+
146
+
147
+ _registry = _WorkerRegistry()
148
+ atexit.register(_registry.shutdown_all)
149
+
150
+
151
+ def check_file_warm(
152
+ project_path: Path, file_path: Path, timeout: float = 600.0
153
+ ) -> list[dict] | None:
154
+ """Check a file via the warm LSP worker.
155
+
156
+ Returns the LSP diagnostics list on success, or None when the warm path
157
+ is unavailable or failed — the caller must then fall back to the cold
158
+ path. On failure the project's worker is discarded, so the next call
159
+ starts fresh.
160
+ """
161
+ if not warm_enabled():
162
+ return None
163
+ project_path = project_path.resolve()
164
+ with _registry.project_lock(project_path):
165
+ try:
166
+ worker = _registry.get(project_path)
167
+ return worker.check_file(file_path, timeout=timeout)
168
+ except (LspTimeoutError, LspProtocolError, OSError) as exc:
169
+ logger.warning("Warm check failed (%s); falling back to cold path", exc)
170
+ _registry.discard(project_path)
171
+ return None
@@ -0,0 +1,182 @@
1
+ """
2
+ Watchdog wrapper that kills a child process when its grandparent dies.
3
+
4
+ LeanBack spawns long-lived ``lake serve`` workers. If LeanBack itself is
5
+ SIGKILLed, those workers would be orphaned and keep multiple GB of RAM
6
+ resident. To defend against this, LeanBack does not spawn the worker
7
+ directly; it spawns this watchdog instead::
8
+
9
+ python -m leanback.common.watchdog <parent_pid> -- <cmd> [args...]
10
+
11
+ The watchdog execs the command as its own child, passing its stdin, stdout,
12
+ and stderr straight through (so the grandparent's LSP pipes reach the worker
13
+ unchanged). It then polls ``<parent_pid>`` and, when that process disappears,
14
+ terminates the child (SIGTERM, then SIGKILL after a grace period).
15
+
16
+ POSIX-only. Stdlib-only, no third-party imports.
17
+
18
+ Interface notes:
19
+ - The watchdog NEVER writes to stdout — that stream belongs to the child's
20
+ LSP protocol and any extra byte would corrupt it. All diagnostics go to
21
+ stderr.
22
+ - At startup the watchdog prints ``watchdog: child pid <N>`` to stderr.
23
+ This is a stable, documented interface: callers (and tests) may parse it
24
+ to learn the child's PID.
25
+ """
26
+
27
+ import os
28
+ import signal
29
+ import subprocess
30
+ import sys
31
+ import time
32
+
33
+
34
+ # How often (in seconds) to poll the parent PID. Overridable via
35
+ # --poll-interval so tests can run quickly.
36
+ DEFAULT_POLL_INTERVAL = 2.0
37
+
38
+ # How long (in seconds) to wait for the child to exit after SIGTERM before
39
+ # escalating to SIGKILL.
40
+ TERM_GRACE = 5.0
41
+
42
+
43
+ def _terminate_child(proc: subprocess.Popen, grace: float = TERM_GRACE) -> None:
44
+ """Terminate the child: SIGTERM, wait up to ``grace`` seconds, then SIGKILL.
45
+
46
+ Safe to call when the child has already exited.
47
+ """
48
+ if proc.poll() is not None:
49
+ return
50
+ try:
51
+ proc.terminate()
52
+ except ProcessLookupError:
53
+ return
54
+ try:
55
+ proc.wait(timeout=grace)
56
+ return
57
+ except subprocess.TimeoutExpired:
58
+ pass
59
+ try:
60
+ proc.kill()
61
+ except ProcessLookupError:
62
+ return
63
+ try:
64
+ proc.wait(timeout=grace)
65
+ except subprocess.TimeoutExpired:
66
+ pass
67
+
68
+
69
+ def _parse_args(argv: list[str]) -> tuple[int, float, list[str]]:
70
+ """Parse ``<parent_pid> [--poll-interval S] -- <cmd> [args...]``.
71
+
72
+ Returns:
73
+ Tuple of (parent_pid, poll_interval, command).
74
+
75
+ Raises:
76
+ ValueError: If the arguments are malformed.
77
+ """
78
+ poll_interval = DEFAULT_POLL_INTERVAL
79
+ parent_pid: int | None = None
80
+ i = 0
81
+ while i < len(argv):
82
+ arg = argv[i]
83
+ if arg == "--":
84
+ i += 1
85
+ break
86
+ elif arg == "--poll-interval":
87
+ if i + 1 >= len(argv):
88
+ raise ValueError("--poll-interval requires a value")
89
+ poll_interval = float(argv[i + 1])
90
+ i += 2
91
+ elif arg.startswith("--poll-interval="):
92
+ poll_interval = float(arg.split("=", 1)[1])
93
+ i += 1
94
+ elif parent_pid is None:
95
+ parent_pid = int(arg)
96
+ i += 1
97
+ else:
98
+ raise ValueError(f"unexpected argument before '--': {arg!r}")
99
+
100
+ command = argv[i:]
101
+ if parent_pid is None:
102
+ raise ValueError("missing parent_pid")
103
+ if not command:
104
+ raise ValueError("missing command after '--'")
105
+ return parent_pid, poll_interval, command
106
+
107
+
108
+ def main(argv: list[str]) -> int:
109
+ """Run the watchdog.
110
+
111
+ Args:
112
+ argv: Arguments after the program name, i.e.
113
+ ``[<parent_pid>, [--poll-interval, S,] '--', <cmd>, args...]``.
114
+
115
+ Returns:
116
+ The child's return code if it exits on its own or via a forwarded
117
+ signal; ``1`` if the parent died first.
118
+ """
119
+ try:
120
+ parent_pid, poll_interval, command = _parse_args(argv)
121
+ except ValueError as exc:
122
+ print(f"watchdog: {exc}", file=sys.stderr)
123
+ print(
124
+ "watchdog: usage: python -m leanback.common.watchdog "
125
+ "<parent_pid> [--poll-interval S] -- <cmd> [args...]",
126
+ file=sys.stderr,
127
+ )
128
+ return 2
129
+
130
+ # Spawn the child, inheriting our stdin/stdout/stderr. We deliberately do
131
+ # NOT create pipes: the grandparent's LSP pipes must pass straight through.
132
+ proc = subprocess.Popen(command)
133
+ print(f"watchdog: child pid {proc.pid}", file=sys.stderr, flush=True)
134
+
135
+ # Forward SIGTERM/SIGINT that we receive to the child, then exit with its
136
+ # return code. A small flag records that we were signalled.
137
+ signalled = False
138
+
139
+ def _forward(signum: int, _frame: object) -> None:
140
+ nonlocal signalled
141
+ signalled = True
142
+
143
+ signal.signal(signal.SIGTERM, _forward)
144
+ signal.signal(signal.SIGINT, _forward)
145
+
146
+ try:
147
+ while True:
148
+ # Did the child exit on its own?
149
+ rc = proc.poll()
150
+ if rc is not None:
151
+ return rc
152
+
153
+ # Were we asked to shut down?
154
+ if signalled:
155
+ _terminate_child(proc)
156
+ rc = proc.poll()
157
+ return rc if rc is not None else 1
158
+
159
+ # Is the parent still alive?
160
+ try:
161
+ os.kill(parent_pid, 0)
162
+ except ProcessLookupError:
163
+ print(
164
+ f"watchdog: parent {parent_pid} gone, killing child {proc.pid}",
165
+ file=sys.stderr,
166
+ flush=True,
167
+ )
168
+ _terminate_child(proc)
169
+ return 1
170
+ except PermissionError:
171
+ # Parent exists but is owned by another user — still alive.
172
+ pass
173
+
174
+ time.sleep(poll_interval)
175
+ finally:
176
+ # Belt and suspenders: never leave the child running if we unwind.
177
+ if proc.poll() is None:
178
+ _terminate_child(proc)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main(sys.argv[1:]))
@@ -14,6 +14,8 @@ import time
14
14
  from pathlib import Path
15
15
 
16
16
  from mcp.server.fastmcp import FastMCP
17
+ from mcp.server.fastmcp.exceptions import ToolError
18
+ from mcp.server.fastmcp.tools.tool_manager import ToolManager
17
19
 
18
20
 
19
21
  def _json(obj: object) -> str:
@@ -53,8 +55,10 @@ logger = logging.getLogger(__name__)
53
55
  mcp = FastMCP(
54
56
  "LeanBack",
55
57
  instructions="""\
56
- LeanBack provides tools for Lean 4 theorem proving. All tools are read-only they never \
57
- modify files. Use your own file tools to create/edit .lean files, then use LeanBack to verify them.
58
+ LeanBack provides tools for Lean 4 theorem proving. All tools are read-only for source files \
59
+ they never modify your .lean files. Use your own file tools to create/edit .lean files, then use \
60
+ LeanBack to verify them. (Exception: eval with build=true compiles project modules, writing build \
61
+ artifacts to .lake/.)
58
62
 
59
63
  ## Getting Started
60
64
  1. Call project_info(project="/path/to/project") to check if a project is ready
@@ -82,11 +86,32 @@ modify files. Use your own file tools to create/edit .lean files, then use LeanB
82
86
  - All tools require 'project' as an absolute path (e.g., "/home/user/lean/myproject")
83
87
  - check has NO mathlib flag — use imports=["Mathlib.Tactic"] instead
84
88
  - goals, prove, and eval with mathlib=True all import Mathlib.Tactic (simp, ring, omega, linarith, norm_num, decide, aesop)
89
+ - eval can import modules of the project itself (imports=["MyProject.Definitions"]); pass build=true so they are compiled first
85
90
  - Tool calls typically take 5-30 seconds. The first call may be slower (Lean server startup).
91
+ - Iterating on a file? check(file=...) is the fast loop: repeated checks of the same file reuse a warm Lean server (seconds instead of a fresh import each time). Keep the import lines unchanged between edits to stay warm.
86
92
  """,
87
93
  )
88
94
 
89
95
 
96
+ class _StrictToolManager(ToolManager):
97
+ """Rejects unknown parameters instead of silently dropping them."""
98
+
99
+ async def call_tool(self, name, arguments, context=None, **kwargs):
100
+ tool = self.get_tool(name)
101
+ if tool and arguments:
102
+ known = set(tool.parameters.get("properties", {}).keys())
103
+ unknown = set(arguments.keys()) - known
104
+ if unknown:
105
+ raise ToolError(
106
+ f"Unknown parameter(s) for '{name}': {', '.join(sorted(unknown))}. "
107
+ f"Valid parameters: {', '.join(sorted(known))}"
108
+ )
109
+ return await super().call_tool(name, arguments, context, **kwargs)
110
+
111
+
112
+ mcp._tool_manager = _StrictToolManager()
113
+
114
+
90
115
  def _resolve_project(project: str) -> Path | None:
91
116
  """Resolve and validate a project path. Returns Path or None."""
92
117
  p = Path(project)
@@ -425,7 +450,7 @@ def check(
425
450
 
426
451
  Parameters:
427
452
  - project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
428
- - file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean")
453
+ - file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged.
429
454
  - check_all: Check each file individually for detailed error reporting (slower but more thorough)
430
455
  - expr: A Lean expression or command to check (e.g., "2 + 2 = 4", "Nat.add", "#eval 1 + 1"). Bare expressions are auto-wrapped in #check.
431
456
  - imports: List of modules to import when checking expressions (e.g., ["Mathlib.Tactic"]). First Mathlib import takes ~20-30s.
@@ -553,25 +578,39 @@ def check(
553
578
  }
554
579
  return _json(result)
555
580
 
556
- success, errors = check_file(
557
- file_path=file_path,
558
- project_path=project_path,
559
- console=console,
560
- )
561
-
562
- error_count = sum(1 for e in errors if e.severity == "error")
563
- warning_count = sum(1 for e in errors if e.severity == "warning")
564
- info_count = sum(1 for e in errors if e.severity == "info")
565
-
566
581
  try:
567
582
  display_path = file_path.relative_to(project_path)
568
583
  except ValueError:
569
584
  display_path = file
570
585
 
586
+ # Warm path: a persistent per-project Lean LSP worker makes
587
+ # repeated checks of the same file fast (the import environment
588
+ # stays in memory). Falls back to the cold path on any failure.
589
+ from leanback.common.errors import lsp_diagnostics_to_errors
590
+ from leanback.common.warm import check_file_warm
591
+
592
+ engine = "cold"
593
+ warm_diags = check_file_warm(project_path, file_path)
594
+ if warm_diags is not None:
595
+ errors = lsp_diagnostics_to_errors(warm_diags, str(display_path))
596
+ success = not any(e.severity == "error" for e in errors)
597
+ engine = "warm"
598
+ else:
599
+ success, errors = check_file(
600
+ file_path=file_path,
601
+ project_path=project_path,
602
+ console=console,
603
+ )
604
+
605
+ error_count = sum(1 for e in errors if e.severity == "error")
606
+ warning_count = sum(1 for e in errors if e.severity == "warning")
607
+ info_count = sum(1 for e in errors if e.severity == "info")
608
+
571
609
  result = {
572
610
  "success": success,
573
611
  "type": "file_check",
574
612
  "file_path": str(display_path),
613
+ "engine": engine,
575
614
  "messages": _serialize_errors(errors),
576
615
  "statistics": {
577
616
  "total_errors": error_count,
@@ -612,6 +651,9 @@ def check(
612
651
  "total_info": info_count,
613
652
  "files_with_errors": len(files_with_errors),
614
653
  },
654
+ "hint": "This checked the entire project via 'lake build'. "
655
+ "To check specific code, use expr=. "
656
+ "To execute code, use the eval tool.",
615
657
  }
616
658
  return _json(result)
617
659
 
@@ -894,6 +936,7 @@ def eval(
894
936
  file: str | None = None,
895
937
  imports: list[str] | None = None,
896
938
  mathlib: bool = False,
939
+ build: bool = False,
897
940
  ) -> str:
898
941
  """Execute Lean expressions and see their results.
899
942
 
@@ -906,6 +949,7 @@ def eval(
906
949
  - file: Path to a .lean file to execute instead of inline code
907
950
  - imports: Additional modules to import (e.g., ["Mathlib.Data.Finset.Basic"])
908
951
  - mathlib: Import Mathlib.Tactic for all standard tactics (simp, ring, omega, linarith, norm_num, decide, aesop, etc.). First call takes ~8-11s for import.
952
+ - build: Compile imported project modules first (runs `lake build` on them). Use when importing a module of THIS project (e.g., imports=["MyProject.Definitions"]) that may not be compiled yet — otherwise the import fails with "object file does not exist". Adds build time on first use; near-instant when already up to date. Note: this writes build artifacts to the project's .lake directory.
909
953
 
910
954
  Use either 'code' OR 'file', not both. The tool can handle multi-line code.
911
955
 
@@ -992,6 +1036,30 @@ def eval(
992
1036
  if "Mathlib.Tactic" not in resolved_imports:
993
1037
  resolved_imports.insert(0, "Mathlib.Tactic")
994
1038
 
1039
+ built_modules: list[str] = []
1040
+ if build:
1041
+ from leanback.common.env import build_modules, find_local_modules
1042
+
1043
+ # Consider both the imports parameter and import lines inside
1044
+ # the code itself (relevant when executing a file).
1045
+ candidates = list(resolved_imports)
1046
+ candidates += re.findall(
1047
+ r"^import\s+([\w.]+)", code_to_execute, re.MULTILINE
1048
+ )
1049
+ local_modules = find_local_modules(project_path, candidates)
1050
+ build_ok, build_output = build_modules(project_path, local_modules)
1051
+ if not build_ok:
1052
+ return _json(
1053
+ {
1054
+ "success": False,
1055
+ "error_code": "BUILD_FAILED",
1056
+ "message": f"lake build failed for modules {local_modules}. "
1057
+ "Fix the errors in those modules first.",
1058
+ "build_output": build_output[:2000],
1059
+ }
1060
+ )
1061
+ built_modules = local_modules
1062
+
995
1063
  success, errors = check_lean_expr(
996
1064
  expr=code_to_execute,
997
1065
  imports=resolved_imports,
@@ -999,17 +1067,25 @@ def eval(
999
1067
  console=console,
1000
1068
  )
1001
1069
 
1002
- return _json(
1003
- {
1004
- "success": success,
1005
- "type": "code_execution",
1006
- "code": code_to_execute,
1007
- "imports": resolved_imports,
1008
- "messages": _serialize_errors(
1009
- errors, sanitize_paths=True, source_file=file
1010
- ),
1011
- }
1012
- )
1070
+ result = {
1071
+ "success": success,
1072
+ "type": "code_execution",
1073
+ "code": code_to_execute,
1074
+ "imports": resolved_imports,
1075
+ "messages": _serialize_errors(
1076
+ errors, sanitize_paths=True, source_file=file
1077
+ ),
1078
+ }
1079
+ if built_modules:
1080
+ result["built"] = built_modules
1081
+ if not build and any(
1082
+ ".olean" in e.message and "does not exist" in e.message for e in errors
1083
+ ):
1084
+ result["hint"] = (
1085
+ "An imported module is not compiled. "
1086
+ "Retry with build=true to compile project modules automatically."
1087
+ )
1088
+ return _json(result)
1013
1089
 
1014
1090
  except Exception as e:
1015
1091
  return _json(
File without changes
File without changes
File without changes
File without changes
File without changes