lean-lsp-mcp 0.19.2__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.2 → lean_lsp_mcp-0.20.0}/PKG-INFO +49 -4
  2. lean_lsp_mcp-0.19.2/src/lean_lsp_mcp.egg-info/PKG-INFO → lean_lsp_mcp-0.20.0/README.md +46 -27
  3. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/pyproject.toml +8 -1
  4. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/__init__.py +14 -0
  5. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/instructions.py +11 -10
  6. lean_lsp_mcp-0.20.0/src/lean_lsp_mcp/repl.py +260 -0
  7. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/server.py +161 -28
  8. lean_lsp_mcp-0.19.2/README.md → lean_lsp_mcp-0.20.0/src/lean_lsp_mcp.egg-info/PKG-INFO +72 -3
  9. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/SOURCES.txt +2 -0
  10. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/requires.txt +3 -0
  11. lean_lsp_mcp-0.20.0/tests/test_logging.py +230 -0
  12. lean_lsp_mcp-0.20.0/tests/test_repl.py +217 -0
  13. lean_lsp_mcp-0.19.2/tests/test_logging.py +0 -90
  14. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/LICENSE +0 -0
  15. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/setup.cfg +0 -0
  16. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/__main__.py +0 -0
  17. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/client_utils.py +0 -0
  18. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/file_utils.py +0 -0
  19. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/loogle.py +0 -0
  20. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/models.py +0 -0
  21. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/outline_utils.py +0 -0
  22. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/profile_utils.py +0 -0
  23. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/search_utils.py +0 -0
  24. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp/utils.py +0 -0
  25. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/dependency_links.txt +0 -0
  26. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/entry_points.txt +0 -0
  27. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/src/lean_lsp_mcp.egg-info/top_level.txt +0 -0
  28. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_diagnostic_line_range.py +0 -0
  29. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_editor_tools.py +0 -0
  30. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_error_handling.py +0 -0
  31. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_file_caching.py +0 -0
  32. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_misc_tools.py +0 -0
  33. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_outline.py +0 -0
  34. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_profile.py +0 -0
  35. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_project_tools.py +0 -0
  36. {lean_lsp_mcp-0.19.2 → lean_lsp_mcp-0.20.0}/tests/test_search_tools.py +0 -0
  37. {lean_lsp_mcp-0.19.2 → 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.2
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
@@ -12,6 +12,8 @@ Requires-Dist: leanclient==0.9.2
12
12
  Requires-Dist: mcp[cli]==1.25.0
13
13
  Requires-Dist: orjson>=3.11.1
14
14
  Requires-Dist: certifi>=2024.0.0
15
+ Provides-Extra: yaml
16
+ Requires-Dist: PyYAML>=6.0; extra == "yaml"
15
17
  Provides-Extra: lint
16
18
  Requires-Dist: ruff>=0.2.0; extra == "lint"
17
19
  Provides-Extra: dev
@@ -267,8 +269,10 @@ l1c1-l1c6, severity: 3
267
269
 
268
270
  #### lean_multi_attempt
269
271
 
270
- Attempt multiple lean code snippets on a line and return goal state and diagnostics for each snippet.
271
- 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)).
272
276
 
273
277
  <details>
274
278
  <summary>Example output (attempting `rw [Nat.pow_sub (Fintype.card_pos_of_nonempty S)]` and `by_contra h_neq`)</summary>
@@ -484,7 +488,12 @@ This MCP server works out-of-the-box without any configuration. However, a few o
484
488
  ### Environment Variables
485
489
 
486
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.
487
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.
488
497
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
489
498
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
490
499
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
@@ -545,6 +554,36 @@ uvx lean-lsp-mcp --transport streamable-http
545
554
 
546
555
  Clients should then include the token in the `Authorization` header.
547
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
+
548
587
  ### Local Loogle
549
588
 
550
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.
@@ -591,9 +630,15 @@ uv sync --all-extras
591
630
  uv run pytest tests
592
631
  ```
593
632
 
594
- ## Publications using lean-lsp-mcp
633
+ ## Publications and Formalization Projects using lean-lsp-mcp
595
634
 
596
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)
597
642
 
598
643
  ## Related Projects
599
644
 
@@ -1,27 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: lean-lsp-mcp
3
- Version: 0.19.2
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
- Requires-Dist: certifi>=2024.0.0
15
- Provides-Extra: lint
16
- Requires-Dist: ruff>=0.2.0; extra == "lint"
17
- Provides-Extra: dev
18
- Requires-Dist: ruff>=0.2.0; extra == "dev"
19
- Requires-Dist: pytest>=8.3; extra == "dev"
20
- Requires-Dist: anyio>=4.4; extra == "dev"
21
- Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
- Requires-Dist: pytest-timeout>=2.3; extra == "dev"
23
- Dynamic: license-file
24
-
25
1
  <h1 align="center">
26
2
  lean-lsp-mcp
27
3
  </h1>
@@ -267,8 +243,10 @@ l1c1-l1c6, severity: 3
267
243
 
268
244
  #### lean_multi_attempt
269
245
 
270
- Attempt multiple lean code snippets on a line and return goal state and diagnostics for each snippet.
271
- 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)).
272
250
 
273
251
  <details>
274
252
  <summary>Example output (attempting `rw [Nat.pow_sub (Fintype.card_pos_of_nonempty S)]` and `by_contra h_neq`)</summary>
@@ -484,7 +462,12 @@ This MCP server works out-of-the-box without any configuration. However, a few o
484
462
  ### Environment Variables
485
463
 
486
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.
487
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.
488
471
  - `LEAN_LSP_MCP_TOKEN`: Secret token for bearer authentication when using `streamable-http` or `sse` transport.
489
472
  - `LEAN_STATE_SEARCH_URL`: URL for a self-hosted [premise-search.com](https://premise-search.com) instance.
490
473
  - `LEAN_HAMMER_URL`: URL for a self-hosted [Lean Hammer Premise Search](https://github.com/hanwenzhu/lean-premise-server) instance.
@@ -545,6 +528,36 @@ uvx lean-lsp-mcp --transport streamable-http
545
528
 
546
529
  Clients should then include the token in the `Authorization` header.
547
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
+
548
561
  ### Local Loogle
549
562
 
550
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.
@@ -591,9 +604,15 @@ uv sync --all-extras
591
604
  uv run pytest tests
592
605
  ```
593
606
 
594
- ## Publications using lean-lsp-mcp
607
+ ## Publications and Formalization Projects using lean-lsp-mcp
595
608
 
596
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)
597
616
 
598
617
  ## Related Projects
599
618
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lean-lsp-mcp"
3
- version = "0.19.2"
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"
@@ -12,9 +12,16 @@ dependencies = ["leanclient==0.9.2", "mcp[cli]==1.25.0", "orjson>=3.11.1", "cert
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
  """
@@ -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()