lean-lsp-mcp 0.19.1__tar.gz → 0.20.0__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 (37) hide show
  1. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/PKG-INFO +50 -4
  2. lean_lsp_mcp-0.19.1/src/lean_lsp_mcp.egg-info/PKG-INFO → lean_lsp_mcp-0.20.0/README.md +46 -26
  3. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/pyproject.toml +9 -2
  4. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/__init__.py +14 -0
  5. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/instructions.py +11 -10
  6. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/loogle.py +9 -3
  7. lean_lsp_mcp-0.20.0/src/lean_lsp_mcp/repl.py +260 -0
  8. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/server.py +161 -28
  9. lean_lsp_mcp-0.19.1/README.md → lean_lsp_mcp-0.20.0/src/lean_lsp_mcp.egg-info/PKG-INFO +72 -3
  10. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  11. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/requires.txt +4 -0
  12. lean_lsp_mcp-0.20.0/tests/test_logging.py +230 -0
  13. lean_lsp_mcp-0.20.0/tests/test_repl.py +217 -0
  14. lean_lsp_mcp-0.19.1/tests/test_logging.py +0 -90
  15. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/LICENSE +0 -0
  16. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/setup.cfg +0 -0
  17. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/__main__.py +0 -0
  18. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  19. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  20. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/models.py +0 -0
  21. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
  22. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/profile_utils.py +0 -0
  23. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  24. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/utils.py +0 -0
  25. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  26. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  27. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  28. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_diagnostic_line_range.py +0 -0
  29. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_editor_tools.py +0 -0
  30. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_error_handling.py +0 -0
  31. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_file_caching.py +0 -0
  32. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_misc_tools.py +0 -0
  33. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_outline.py +0 -0
  34. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_profile.py +0 -0
  35. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_project_tools.py +0 -0
  36. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_search_tools.py +0 -0
  37. {lean_lsp_mcp-0.19.1 → lean_lsp_mcp-0.20.0}/tests/test_structured_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lean-lsp-mcp
3
- Version: 0.19.1
3
+ Version: 0.20.0
4
4
  Summary: Lean Theorem Prover MCP
5
5
  Author-email: Oliver Dressler <hey@oli.show>
6
6
  License-Expression: MIT
@@ -11,6 +11,9 @@ License-File: LICENSE
11
11
  Requires-Dist: leanclient==0.9.2
12
12
  Requires-Dist: mcp[cli]==1.25.0
13
13
  Requires-Dist: orjson>=3.11.1
14
+ Requires-Dist: certifi>=2024.0.0
15
+ Provides-Extra: yaml
16
+ Requires-Dist: PyYAML>=6.0; extra == "yaml"
14
17
  Provides-Extra: lint
15
18
  Requires-Dist: ruff>=0.2.0; extra == "lint"
16
19
  Provides-Extra: dev
@@ -266,8 +269,10 @@ l1c1-l1c6, severity: 3
266
269
 
267
270
  #### lean_multi_attempt
268
271
 
269
- Attempt multiple lean code snippets on a line and return goal state and diagnostics for each snippet.
270
- This tool is useful to screen different proof attempts before using the most promising one.
272
+ Attempt multiple tactics on a line and return goal state and diagnostics for each.
273
+ Useful to screen different proof attempts before committing to one.
274
+
275
+ When `LEAN_REPL=true`, uses the REPL tactic mode for up to 5x faster execution (see [Environment Variables](#environment-variables)).
271
276
 
272
277
  <details>
273
278
  <summary>Example output (attempting `rw [Nat.pow_sub (Fintype.card_pos_of_nonempty S)]` and `by_contra h_neq`)</summary>
@@ -483,7 +488,12 @@ This MCP server works out-of-the-box without any configuration. However, a few o
483
488
  ### Environment Variables
484
489
 
485
490
  - `LEAN_LOG_LEVEL`: Log level for the server. Options are "INFO", "WARNING", "ERROR", "NONE". Defaults to "INFO".
491
+ - `LEAN_LOG_FILE_CONFIG`: Config file path for logging, with priority over `LEAN_LOG_LEVEL`. If not set, logs are printed to stdout.
486
492
  - `LEAN_PROJECT_PATH`: Path to your Lean project root. Set this if the server cannot automatically detect your project.
493
+ - `LEAN_REPL`: Set to `true`, `1`, or `yes` to enable fast REPL-based `lean_multi_attempt` (~5x faster, see [REPL Setup](#repl-setup)).
494
+ - `LEAN_REPL_PATH`: Path to the `repl` binary. Auto-detected from `.lake/packages/repl/` if not set.
495
+ - `LEAN_REPL_TIMEOUT`: Per-command timeout in seconds (default: 60).
496
+ - `LEAN_REPL_MEM_MB`: Max memory per REPL in MB (default: 8192). Only enforced on Linux/macOS.
487
497
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
488
498
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
489
499
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
@@ -544,6 +554,36 @@ uvx lean-lsp-mcp --transport streamable-http
544
554
 
545
555
  Clients should then include the token in the `Authorization` header.
546
556
 
557
+ ### REPL Setup
558
+
559
+ Enable fast REPL-based `lean_multi_attempt` (~5x faster). Uses [leanprover-community/repl](https://github.com/leanprover-community/repl) tactic mode.
560
+
561
+ **1. Add REPL to your Lean project's `lakefile.toml`:**
562
+
563
+ ```toml
564
+ [[require]]
565
+ name = "repl"
566
+ git = "https://github.com/leanprover-community/repl"
567
+ rev = "v4.25.0" # Match your Lean version
568
+ ```
569
+
570
+ **2. Build it:**
571
+
572
+ ```bash
573
+ lake build repl
574
+ ```
575
+
576
+ **3. Enable via CLI or environment variable:**
577
+
578
+ ```bash
579
+ uvx lean-lsp-mcp --repl
580
+
581
+ # Or via environment variable
582
+ export LEAN_REPL=true
583
+ ```
584
+
585
+ The REPL binary is auto-detected from `.lake/packages/repl/`. Falls back to LSP if not found.
586
+
547
587
  ### Local Loogle
548
588
 
549
589
  Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
@@ -590,9 +630,15 @@ uv sync --all-extras
590
630
  uv run pytest tests
591
631
  ```
592
632
 
593
- ## Publications using lean-lsp-mcp
633
+ ## Publications and Formalization Projects using lean-lsp-mcp
594
634
 
595
635
  - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
636
+ - Numina-Lean-Agent: An Open and General Agentic Reasoning System for Formal Mathematics [arxiv](https://arxiv.org/abs/2601.14027) [github](https://github.com/project-numina/numina-lean-agent)
637
+ - A Group-Theoretic Approach to Shannon Capacity of Graphs and a Limit Theorem from Lattice Packings [github](https://github.com/jzuiddam/GroupTheoreticShannonCapacity/)
638
+
639
+ ## Talks
640
+
641
+ lean-lsp-mcp: Tools for agentic interaction with Lean (Lean Together 2026) [youtube](https://www.youtube.com/watch?v=uttbYaTaF-E)
596
642
 
597
643
  ## Related Projects
598
644
 
@@ -1,26 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: lean-lsp-mcp
3
- Version: 0.19.1
4
- Summary: Lean Theorem Prover MCP
5
- Author-email: Oliver Dressler <hey@oli.show>
6
- License-Expression: MIT
7
- Project-URL: Repository, https://github.com/oOo0oOo/lean-lsp-mcp
8
- Requires-Python: >=3.10
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: leanclient==0.9.2
12
- Requires-Dist: mcp[cli]==1.25.0
13
- Requires-Dist: orjson>=3.11.1
14
- Provides-Extra: lint
15
- Requires-Dist: ruff>=0.2.0; extra == "lint"
16
- Provides-Extra: dev
17
- Requires-Dist: ruff>=0.2.0; extra == "dev"
18
- Requires-Dist: pytest>=8.3; extra == "dev"
19
- Requires-Dist: anyio>=4.4; extra == "dev"
20
- Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
21
- Requires-Dist: pytest-timeout>=2.3; extra == "dev"
22
- Dynamic: license-file
23
-
24
1
  <h1 align="center">
25
2
  lean-lsp-mcp
26
3
  </h1>
@@ -266,8 +243,10 @@ l1c1-l1c6, severity: 3
266
243
 
267
244
  #### lean_multi_attempt
268
245
 
269
- Attempt multiple lean code snippets on a line and return goal state and diagnostics for each snippet.
270
- This tool is useful to screen different proof attempts before using the most promising one.
246
+ Attempt multiple tactics on a line and return goal state and diagnostics for each.
247
+ Useful to screen different proof attempts before committing to one.
248
+
249
+ When `LEAN_REPL=true`, uses the REPL tactic mode for up to 5x faster execution (see [Environment Variables](#environment-variables)).
271
250
 
272
251
  <details>
273
252
  <summary>Example output (attempting `rw [Nat.pow_sub (Fintype.card_pos_of_nonempty S)]` and `by_contra h_neq`)</summary>
@@ -483,7 +462,12 @@ This MCP server works out-of-the-box without any configuration. However, a few o
483
462
  ### Environment Variables
484
463
 
485
464
  - `LEAN_LOG_LEVEL`: Log level for the server. Options are "INFO", "WARNING", "ERROR", "NONE". Defaults to "INFO".
465
+ - `LEAN_LOG_FILE_CONFIG`: Config file path for logging, with priority over `LEAN_LOG_LEVEL`. If not set, logs are printed to stdout.
486
466
  - `LEAN_PROJECT_PATH`: Path to your Lean project root. Set this if the server cannot automatically detect your project.
467
+ - `LEAN_REPL`: Set to `true`, `1`, or `yes` to enable fast REPL-based `lean_multi_attempt` (~5x faster, see [REPL Setup](#repl-setup)).
468
+ - `LEAN_REPL_PATH`: Path to the `repl` binary. Auto-detected from `.lake/packages/repl/` if not set.
469
+ - `LEAN_REPL_TIMEOUT`: Per-command timeout in seconds (default: 60).
470
+ - `LEAN_REPL_MEM_MB`: Max memory per REPL in MB (default: 8192). Only enforced on Linux/macOS.
487
471
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
488
472
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
489
473
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
@@ -544,6 +528,36 @@ uvx lean-lsp-mcp --transport streamable-http
544
528
 
545
529
  Clients should then include the token in the `Authorization` header.
546
530
 
531
+ ### REPL Setup
532
+
533
+ Enable fast REPL-based `lean_multi_attempt` (~5x faster). Uses [leanprover-community/repl](https://github.com/leanprover-community/repl) tactic mode.
534
+
535
+ **1. Add REPL to your Lean project's `lakefile.toml`:**
536
+
537
+ ```toml
538
+ [[require]]
539
+ name = "repl"
540
+ git = "https://github.com/leanprover-community/repl"
541
+ rev = "v4.25.0" # Match your Lean version
542
+ ```
543
+
544
+ **2. Build it:**
545
+
546
+ ```bash
547
+ lake build repl
548
+ ```
549
+
550
+ **3. Enable via CLI or environment variable:**
551
+
552
+ ```bash
553
+ uvx lean-lsp-mcp --repl
554
+
555
+ # Or via environment variable
556
+ export LEAN_REPL=true
557
+ ```
558
+
559
+ The REPL binary is auto-detected from `.lake/packages/repl/`. Falls back to LSP if not found.
560
+
547
561
  ### Local Loogle
548
562
 
549
563
  Run loogle locally to avoid the remote API's rate limit (3 req/30s). First run takes ~5-10 minutes to build; subsequent runs start in seconds.
@@ -590,9 +604,15 @@ uv sync --all-extras
590
604
  uv run pytest tests
591
605
  ```
592
606
 
593
- ## Publications using lean-lsp-mcp
607
+ ## Publications and Formalization Projects using lean-lsp-mcp
594
608
 
595
609
  - Ax-Prover: A Deep Reasoning Agentic Framework for Theorem Proving in Mathematics and Quantum Physics [arxiv](https://arxiv.org/abs/2510.12787)
610
+ - Numina-Lean-Agent: An Open and General Agentic Reasoning System for Formal Mathematics [arxiv](https://arxiv.org/abs/2601.14027) [github](https://github.com/project-numina/numina-lean-agent)
611
+ - A Group-Theoretic Approach to Shannon Capacity of Graphs and a Limit Theorem from Lattice Packings [github](https://github.com/jzuiddam/GroupTheoreticShannonCapacity/)
612
+
613
+ ## Talks
614
+
615
+ lean-lsp-mcp: Tools for agentic interaction with Lean (Lean Together 2026) [youtube](https://www.youtube.com/watch?v=uttbYaTaF-E)
596
616
 
597
617
  ## Related Projects
598
618
 
@@ -1,20 +1,27 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.19.1"
3
+ version = "0.20.0"
4
4
  description = "Lean Theorem Prover MCP"
5
5
  authors = [{ name = "Oliver Dressler", email = "hey@oli.show" }]
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
8
8
  license = "MIT"
9
- dependencies = ["leanclient==0.9.2", "mcp[cli]==1.25.0", "orjson>=3.11.1"]
9
+ dependencies = ["leanclient==0.9.2", "mcp[cli]==1.25.0", "orjson>=3.11.1", "certifi>=2024.0.0"]
10
10
 
11
11
  [project.urls]
12
12
  Repository = "https://github.com/oOo0oOo/lean-lsp-mcp"
13
13
 
14
14
  [project.optional-dependencies]
15
+ yaml = ["PyYAML>=6.0"]
15
16
  lint = ["ruff>=0.2.0"]
16
17
  dev = ["ruff>=0.2.0", "pytest>=8.3", "anyio>=4.4", "pytest-asyncio>=0.23", "pytest-timeout>=2.3"]
17
18
 
19
+ [tool.ruff]
20
+ exclude = [
21
+ "tests/test_project/.lake",
22
+ "tests/mathlib_project/.lake",
23
+ ]
24
+
18
25
  [tool.pytest.ini_options]
19
26
  asyncio_mode = "auto"
20
27
  markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
@@ -36,6 +36,16 @@ def main():
36
36
  type=str,
37
37
  help="Override loogle cache location (default: ~/.cache/lean-lsp-mcp/loogle)",
38
38
  )
39
+ parser.add_argument(
40
+ "--repl",
41
+ action="store_true",
42
+ help="Enable fast REPL-based multi-attempt (~5x faster). Requires Lean REPL.",
43
+ )
44
+ parser.add_argument(
45
+ "--repl-timeout",
46
+ type=int,
47
+ help="REPL command timeout in seconds (default: 60)",
48
+ )
39
49
  args = parser.parse_args()
40
50
 
41
51
  # Set env vars from CLI args (CLI takes precedence over env vars)
@@ -43,6 +53,10 @@ def main():
43
53
  os.environ["LEAN_LOOGLE_LOCAL"] = "true"
44
54
  if args.loogle_cache_dir:
45
55
  os.environ["LEAN_LOOGLE_CACHE_DIR"] = args.loogle_cache_dir
56
+ if args.repl:
57
+ os.environ["LEAN_REPL"] = "true"
58
+ if args.repl_timeout:
59
+ os.environ["LEAN_REPL_TIMEOUT"] = str(args.repl_timeout)
46
60
 
47
61
  mcp.settings.host = args.host
48
62
  mcp.settings.port = args.port
@@ -16,19 +16,19 @@ INSTRUCTIONS = """## General Rules
16
16
  - **lean_profile_proof**: Profile a theorem for performance. Shows tactic hotspots. SLOW!
17
17
 
18
18
  ## Search Tools (rate limited)
19
- - **lean_leansearch** (3/30s): Natural language mathlib
20
- - **lean_loogle** (3/30s): Type pattern mathlib
19
+ - **lean_leansearch** (3/30s): Natural language -> mathlib
20
+ - **lean_loogle** (3/30s): Type pattern -> mathlib
21
21
  - **lean_leanfinder** (10/30s): Semantic/conceptual search
22
- - **lean_state_search** (3/30s): Goal closing lemmas
23
- - **lean_hammer_premise** (3/30s): Goal premises for simp/aesop
22
+ - **lean_state_search** (3/30s): Goal -> closing lemmas
23
+ - **lean_hammer_premise** (3/30s): Goal -> premises for simp/aesop
24
24
 
25
25
  ## Search Decision Tree
26
- 1. "Does X exist locally?" lean_local_search
27
- 2. "I need a lemma that says X" lean_leansearch
28
- 3. "Find lemma with type pattern" lean_loogle
29
- 4. "What's the Lean name for concept X?" lean_leanfinder
30
- 5. "What closes this goal?" lean_state_search
31
- 6. "What to feed simp?" lean_hammer_premise
26
+ 1. "Does X exist locally?" -> lean_local_search
27
+ 2. "I need a lemma that says X" -> lean_leansearch
28
+ 3. "Find lemma with type pattern" -> lean_loogle
29
+ 4. "What's the Lean name for concept X?" -> lean_leanfinder
30
+ 5. "What closes this goal?" -> lean_state_search
31
+ 6. "What to feed simp?" -> lean_hammer_premise
32
32
 
33
33
  After finding a name: lean_local_search to verify, lean_hover_info for signature.
34
34
 
@@ -37,4 +37,5 @@ List tools return JSON arrays. Empty = `[]`.
37
37
 
38
38
  ## Error Handling
39
39
  Check `isError` in responses: `true` means failure (timeout/LSP error), while `[]` with `isError: false` means no results found.
40
+
40
41
  """
@@ -8,12 +8,14 @@ import json
8
8
  import logging
9
9
  import os
10
10
  import shutil
11
+ import ssl
11
12
  import subprocess
12
13
  import urllib.parse
13
14
  import urllib.request
14
15
  from pathlib import Path
15
16
  from typing import Any
16
17
 
18
+ import certifi
17
19
  import orjson
18
20
 
19
21
  from lean_lsp_mcp.models import LoogleResult
@@ -35,7 +37,8 @@ def loogle_remote(query: str, num_results: int) -> list[LoogleResult] | str:
35
37
  f"https://loogle.lean-lang.org/json?q={urllib.parse.quote(query)}",
36
38
  headers={"User-Agent": "lean-lsp-mcp/0.1"},
37
39
  )
38
- with urllib.request.urlopen(req, timeout=10) as response:
40
+ ssl_ctx = ssl.create_default_context(cafile=certifi.where())
41
+ with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as response:
39
42
  results = orjson.loads(response.read())
40
43
  if "hits" not in results:
41
44
  return "No results found."
@@ -139,9 +142,12 @@ class LoogleManager:
139
142
  self._run(["lake", "exe", "cache", "get"], timeout=600)
140
143
  except Exception as e:
141
144
  logger.warning(f"Cache download: {e}")
142
- logger.info("Building loogle...")
145
+ logger.info("Building loogle (this may a few minutes)...")
143
146
  try:
144
- return self._run(["lake", "build"], timeout=900).returncode == 0
147
+ result = self._run(["lake", "build"], timeout=900)
148
+ if result.returncode != 0:
149
+ logger.error(f"Build failed: {result.stderr[:1000]}")
150
+ return result.returncode == 0
145
151
  except Exception as e:
146
152
  logger.error(f"Build error: {e}")
147
153
  return False
@@ -0,0 +1,260 @@
1
+ """Lean REPL for fast multi-attempt tactic execution using tactic mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import platform
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ if platform.system() != "Windows":
13
+ import resource
14
+
15
+
16
+ class ReplError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class SnippetResult:
22
+ goals: list[str] = field(default_factory=list)
23
+ messages: list[dict[str, Any]] = field(default_factory=list)
24
+ proof_status: str | None = None
25
+ error: str | None = None
26
+
27
+
28
+ def repl_enabled() -> bool:
29
+ return os.environ.get("LEAN_REPL", "").lower() in ("1", "true", "yes")
30
+
31
+
32
+ def find_repl_binary(project_dir: str | None = None) -> str | None:
33
+ """Find REPL binary: env var > .lake/packages > PATH."""
34
+ import shutil
35
+ from pathlib import Path
36
+
37
+ # 1. Explicit env var
38
+ if path := os.environ.get("LEAN_REPL_PATH"):
39
+ return path if Path(path).exists() or shutil.which(path) else None
40
+
41
+ # 2. Auto-detect from .lake/packages (common location after `lake build`)
42
+ if project_dir:
43
+ candidates = [
44
+ Path(project_dir)
45
+ / ".lake"
46
+ / "packages"
47
+ / "repl"
48
+ / ".lake"
49
+ / "build"
50
+ / "bin"
51
+ / "repl",
52
+ Path(project_dir) / ".lake" / "build" / "bin" / "repl",
53
+ ]
54
+ for p in candidates:
55
+ if p.exists():
56
+ return str(p)
57
+
58
+ # 3. Fall back to PATH
59
+ if found := shutil.which("repl"):
60
+ return found
61
+
62
+ return None
63
+
64
+
65
+ def _split_imports(code: str) -> tuple[str, str]:
66
+ """Split code into (header with imports, body)."""
67
+ lines = code.splitlines()
68
+ i = 0
69
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith("import ")):
70
+ i += 1
71
+
72
+ # Deduplicate imports while preserving order
73
+ imports = [ln.strip() for ln in lines[:i] if ln.startswith("import ")]
74
+ header = "\n".join(dict.fromkeys(imports))
75
+ return header, "\n".join(lines[i:])
76
+
77
+
78
+ class Repl:
79
+ """Lean REPL using tactic mode for fast multi-attempt."""
80
+
81
+ def __init__(self, project_dir: str, repl_path: str | None = None):
82
+ self.project_dir = project_dir
83
+ self.repl_path = repl_path or find_repl_binary(project_dir) or "repl"
84
+ self.timeout = int(os.environ.get("LEAN_REPL_TIMEOUT", "60"))
85
+ self.mem_mb = int(os.environ.get("LEAN_REPL_MEM_MB", "8192"))
86
+ self._proc: asyncio.subprocess.Process | None = None
87
+ self._header: str | None = None
88
+ self._header_env: int | None = None
89
+ self._lock = asyncio.Lock()
90
+
91
+ async def _start(self) -> None:
92
+ """Start REPL subprocess."""
93
+ kwargs: dict[str, Any] = {
94
+ "cwd": self.project_dir,
95
+ "stdin": asyncio.subprocess.PIPE,
96
+ "stdout": asyncio.subprocess.PIPE,
97
+ "stderr": asyncio.subprocess.PIPE,
98
+ }
99
+ if platform.system() != "Windows":
100
+ kwargs["start_new_session"] = True
101
+ # Memory limit on Unix systems
102
+ if platform.system() == "Linux":
103
+ limit = resource.RLIMIT_AS # Virtual memory
104
+ else: # macOS
105
+ limit = resource.RLIMIT_RSS # Resident set size
106
+ mem = self.mem_mb * 1024 * 1024
107
+ kwargs["preexec_fn"] = lambda: resource.setrlimit(limit, (mem, mem))
108
+ self._proc = await asyncio.create_subprocess_exec(
109
+ "lake", "env", self.repl_path, **kwargs
110
+ )
111
+
112
+ async def _send(self, cmd: dict[str, Any]) -> dict[str, Any]:
113
+ """Send command and return response."""
114
+ if not self._proc or not self._proc.stdin or not self._proc.stdout:
115
+ raise ReplError("REPL not running")
116
+
117
+ self._proc.stdin.write((json.dumps(cmd) + "\n\n").encode())
118
+ await self._proc.stdin.drain()
119
+
120
+ lines = []
121
+ while True:
122
+ line = await self._proc.stdout.readline()
123
+ if not line or not line.strip():
124
+ break
125
+ lines.append(line)
126
+
127
+ if not lines:
128
+ raise ReplError("No response from REPL")
129
+ return json.loads(b"".join(lines))
130
+
131
+ async def _send_cmd(self, code: str, env: int | None = None) -> dict[str, Any]:
132
+ """Send a command (code) to the REPL."""
133
+ cmd: dict[str, Any] = {"cmd": code}
134
+ if env is not None:
135
+ cmd["env"] = env
136
+ return await self._send(cmd)
137
+
138
+ async def _send_tactic(self, tactic: str, proof_state: int) -> dict[str, Any]:
139
+ """Send a tactic to run in a proof state."""
140
+ return await self._send({"tactic": tactic, "proofState": proof_state})
141
+
142
+ async def _ensure_header(self, header: str) -> int | None:
143
+ """Ensure REPL is running with given header, return header env."""
144
+ if self._header != header:
145
+ await self.close()
146
+ self._header = header
147
+ self._header_env = None
148
+
149
+ if not self._proc or self._proc.returncode is not None:
150
+ await self._start()
151
+ if header:
152
+ resp = await self._send_cmd(header, env=None)
153
+ if "error" in resp:
154
+ raise ReplError(f"Failed to load imports: {resp['error']}")
155
+ self._header_env = resp.get("env")
156
+
157
+ return self._header_env
158
+
159
+ async def run_snippets(
160
+ self, base_code: str, snippets: list[str]
161
+ ) -> list[SnippetResult]:
162
+ """Run multiple tactic snippets using tactic mode.
163
+
164
+ 1. Load header (imports) - cached across calls
165
+ 2. Send body + sorry to get proofState
166
+ 3. Run each tactic via tactic mode (very fast)
167
+ """
168
+ header, body = _split_imports(base_code)
169
+
170
+ async with self._lock:
171
+ try:
172
+ # Load imports (cached)
173
+ header_env = await asyncio.wait_for(
174
+ self._ensure_header(header), timeout=self.timeout
175
+ )
176
+
177
+ # Send body with sorry to get proof state
178
+ if not body.strip():
179
+ return [SnippetResult(error="No proof body") for _ in snippets]
180
+
181
+ # Ensure proper whitespace before sorry
182
+ body_with_sorry = body.rstrip() + "\n sorry"
183
+ resp = await asyncio.wait_for(
184
+ self._send_cmd(body_with_sorry, env=header_env),
185
+ timeout=self.timeout,
186
+ )
187
+
188
+ if "error" in resp:
189
+ return [SnippetResult(error=resp["error"]) for _ in snippets]
190
+
191
+ # Get proof state from the sorry
192
+ sorries = resp.get("sorries", [])
193
+ if not sorries:
194
+ # No sorry = no proof goal, check messages for errors
195
+ msgs = resp.get("messages", [])
196
+ err = "; ".join(
197
+ m.get("data", "") for m in msgs if m.get("severity") == "error"
198
+ )
199
+ return [
200
+ SnippetResult(error=err or "No proof goal found")
201
+ for _ in snippets
202
+ ]
203
+
204
+ proof_state = sorries[0].get("proofState")
205
+ if proof_state is None:
206
+ return [
207
+ SnippetResult(error="No proofState returned") for _ in snippets
208
+ ]
209
+
210
+ # Run each tactic in tactic mode
211
+ results = []
212
+ for snippet in snippets:
213
+ try:
214
+ resp = await asyncio.wait_for(
215
+ self._send_tactic(snippet.strip(), proof_state),
216
+ timeout=self.timeout,
217
+ )
218
+
219
+ if "error" in resp:
220
+ # Lean error (tactic failed)
221
+ results.append(SnippetResult(error=resp["error"]))
222
+ else:
223
+ goals = resp.get("goals", [])
224
+ messages = resp.get("messages", [])
225
+ proof_status = resp.get("proofStatus")
226
+ results.append(
227
+ SnippetResult(
228
+ goals=goals,
229
+ messages=messages,
230
+ proof_status=proof_status,
231
+ )
232
+ )
233
+ except Exception as e:
234
+ results.append(SnippetResult(error=str(e)))
235
+
236
+ return results
237
+
238
+ except Exception as e:
239
+ await self.close()
240
+ raise ReplError(str(e)) from e
241
+
242
+ async def close(self) -> None:
243
+ if not self._proc:
244
+ return
245
+ proc, self._proc = self._proc, None
246
+ self._header = None
247
+ self._header_env = None
248
+ try:
249
+ if platform.system() != "Windows":
250
+ os.killpg(os.getpgid(proc.pid), 9)
251
+ else:
252
+ proc.kill()
253
+ except (ProcessLookupError, OSError):
254
+ pass
255
+ try:
256
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
257
+ except asyncio.TimeoutError:
258
+ pass
259
+ if hasattr(proc, "_transport") and proc._transport:
260
+ proc._transport.close()