Skip to main content

lean_toolchain/
discover.rs

1//! Typed Lean toolchain discovery.
2//!
3//! Probe precedence (first probe whose `<prefix>/include/lean/lean.h` exists wins):
4//!
5//! 1. `DiscoverOptions::explicit_sysroot`.
6//! 2. `$LEAN_SYSROOT`.
7//! 3. `$ELAN_HOME` + `elan show active-toolchain`.
8//! 4. `lean --print-prefix` (via `PATH`).
9//! 5. `lake env printenv LEAN_SYSROOT` under a workspace `fixtures/lean` directory.
10//!
11//! Each probe is independently gated by a `DiscoverOptions` flag so callers
12//! can lock down behaviour for reproducible builds.
13
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use crate::diagnostics::LinkDiagnostics;
20use crate::fingerprint::ToolchainFingerprint;
21
22/// Which probe produced the resolved toolchain.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum DiscoverySource {
25    /// Caller supplied `DiscoverOptions::explicit_sysroot`.
26    ExplicitSysroot,
27    /// Caller had `LEAN_SYSROOT` exported in the environment.
28    LeanSysrootEnv,
29    /// Resolved via `$ELAN_HOME` + `elan show active-toolchain`.
30    ElanHome,
31    /// Resolved via `lean --print-prefix` on `PATH`.
32    Path,
33    /// Resolved via `lake env printenv LEAN_SYSROOT` under a workspace fixture.
34    LakeFixtureEnv,
35}
36
37/// Knobs that gate which discovery probes run.
38///
39/// `Default` enables every probe; tests and reproducible builds narrow the
40/// set to produce deterministic behaviour.
41// Each `bool` is an independent per-probe toggle, deliberately parallel; a
42// state enum or bitflags would obscure that one-flag-per-probe mapping.
43#[allow(clippy::struct_excessive_bools)]
44#[derive(Clone, Debug)]
45pub struct DiscoverOptions {
46    /// Caller-supplied sysroot, probed first. The ambient probes that follow
47    /// are each gated by their own `allow_*` flag, so a caller that wants an
48    /// explicit sysroot to be the *only* source clears those flags (the build
49    /// helpers do this automatically).
50    pub explicit_sysroot: Option<PathBuf>,
51    /// Consult the `LEAN_SYSROOT` environment variable.
52    pub allow_lean_sysroot_env: bool,
53    /// Consult `lean --print-prefix` (requires `lean` on `PATH`).
54    pub allow_path_lookup: bool,
55    /// Consult `$ELAN_HOME` + `elan show active-toolchain`.
56    pub allow_elan: bool,
57    /// Consult `lake env printenv LEAN_SYSROOT` under `fixtures/lean`.
58    pub allow_lake_env: bool,
59    /// Optional `lean-toolchain` file to parse for the recorded version.
60    pub toolchain_file: Option<PathBuf>,
61}
62
63impl Default for DiscoverOptions {
64    fn default() -> Self {
65        Self {
66            explicit_sysroot: None,
67            allow_lean_sysroot_env: true,
68            allow_path_lookup: true,
69            allow_elan: true,
70            allow_lake_env: true,
71            toolchain_file: None,
72        }
73    }
74}
75
76/// Outcome of a successful discovery.
77#[derive(Clone, Debug)]
78pub struct ToolchainInfo {
79    /// The discovered Lean prefix directory (`<prefix>/include/lean/lean.h` exists).
80    pub prefix: PathBuf,
81    /// `<prefix>/bin/lean` if present on disk.
82    pub lean_binary: Option<PathBuf>,
83    /// `<prefix>/include/lean/lean.h`.
84    pub header_path: PathBuf,
85    /// `<prefix>/lib`.
86    pub lib_dir: PathBuf,
87    /// Version string parsed at discovery time (from a `lean-toolchain` file,
88    /// `version.h`, or `lean --version`). Falls back to `LEAN_VERSION` from
89    /// `lean-rs-abi` when no live source is available.
90    pub version: String,
91    /// Build-baked fingerprint (does not vary with discovery results).
92    pub fingerprint: ToolchainFingerprint,
93    /// Which probe won.
94    pub source: DiscoverySource,
95}
96
97/// Resolve a Lean toolchain following the documented probe precedence.
98///
99/// # Errors
100///
101/// Returns `LinkDiagnostics::MissingLean { tried }` if no probe yields a
102/// directory containing `include/lean/lean.h`. Each entry in `tried` is one
103/// line describing why a probe was skipped or which path it inspected.
104pub fn discover_toolchain(opts: &DiscoverOptions) -> Result<ToolchainInfo, LinkDiagnostics> {
105    let mut tried: Vec<String> = Vec::new();
106
107    if let Some(sysroot) = opts.explicit_sysroot.as_ref() {
108        if has_header(sysroot) {
109            return Ok(build_info(sysroot.clone(), DiscoverySource::ExplicitSysroot, opts));
110        }
111        tried.push(format!(
112            "explicit_sysroot={} (no include/lean/lean.h)",
113            sysroot.display()
114        ));
115    } else {
116        tried.push("explicit_sysroot unset".into());
117    }
118
119    if opts.allow_lean_sysroot_env {
120        match env::var_os("LEAN_SYSROOT") {
121            Some(value) => {
122                let path = PathBuf::from(&value);
123                if has_header(&path) {
124                    return Ok(build_info(path, DiscoverySource::LeanSysrootEnv, opts));
125                }
126                tried.push(format!("LEAN_SYSROOT={} (no include/lean/lean.h)", path.display()));
127            }
128            None => tried.push("LEAN_SYSROOT unset".into()),
129        }
130    } else {
131        tried.push("LEAN_SYSROOT probe disabled".into());
132    }
133
134    if opts.allow_elan {
135        match elan_prefix() {
136            Ok(Some(path)) => {
137                if has_header(&path) {
138                    return Ok(build_info(path, DiscoverySource::ElanHome, opts));
139                }
140                tried.push(format!("elan toolchain {} (no include/lean/lean.h)", path.display()));
141            }
142            Ok(None) => tried.push("ELAN_HOME unset or elan unavailable".into()),
143            Err(reason) => tried.push(reason),
144        }
145    } else {
146        tried.push("elan probe disabled".into());
147    }
148
149    if opts.allow_path_lookup {
150        match lean_print_prefix() {
151            Ok(Some(path)) => {
152                if has_header(&path) {
153                    return Ok(build_info(path, DiscoverySource::Path, opts));
154                }
155                tried.push(format!(
156                    "`lean --print-prefix` = {} (no include/lean/lean.h)",
157                    path.display()
158                ));
159            }
160            Ok(None) => tried.push("`lean --print-prefix` returned nothing".into()),
161            Err(reason) => tried.push(reason),
162        }
163    } else {
164        tried.push("PATH lookup disabled".into());
165    }
166
167    if opts.allow_lake_env {
168        match lake_fixture_prefix() {
169            Ok(Some(path)) => {
170                if has_header(&path) {
171                    return Ok(build_info(path, DiscoverySource::LakeFixtureEnv, opts));
172                }
173                tried.push(format!(
174                    "lake fixture prefix {} (no include/lean/lean.h)",
175                    path.display()
176                ));
177            }
178            Ok(None) => tried.push("lake fixture probe: no workspace `fixtures/lean` found".into()),
179            Err(reason) => tried.push(reason),
180        }
181    } else {
182        tried.push("lake env probe disabled".into());
183    }
184
185    Err(LinkDiagnostics::MissingLean { tried })
186}
187
188fn has_header(prefix: &Path) -> bool {
189    prefix.join("include").join("lean").join("lean.h").is_file()
190}
191
192fn build_info(prefix: PathBuf, source: DiscoverySource, opts: &DiscoverOptions) -> ToolchainInfo {
193    let header_path = prefix.join("include").join("lean").join("lean.h");
194    let lib_dir = prefix.join("lib");
195    let lean_binary = {
196        let candidate = prefix.join("bin").join("lean");
197        if candidate.is_file() { Some(candidate) } else { None }
198    };
199    let version = opts
200        .toolchain_file
201        .as_deref()
202        .and_then(parse_toolchain_file)
203        .or_else(|| parse_version_header(&prefix))
204        .unwrap_or_else(|| crate::LEAN_VERSION.to_string());
205    ToolchainInfo {
206        prefix,
207        lean_binary,
208        header_path,
209        lib_dir,
210        version,
211        fingerprint: ToolchainFingerprint::current(),
212        source,
213    }
214}
215
216fn parse_toolchain_file(path: &Path) -> Option<String> {
217    let text = fs::read_to_string(path).ok()?;
218    // Expected shape: `leanprover/lean4:v4.X.Y` (single line). Any version
219    // string is accepted; the caller validates against the supported window.
220    let line = text.lines().next()?.trim();
221    let (_channel, tag) = line.split_once(':')?;
222    Some(tag.trim_start_matches('v').to_string())
223}
224
225fn parse_version_header(prefix: &Path) -> Option<String> {
226    let version_h = prefix.join("include").join("lean").join("version.h");
227    let text = fs::read_to_string(&version_h).ok()?;
228    for line in text.lines() {
229        if let Some(rest) = line.trim().strip_prefix("#define LEAN_VERSION_STRING") {
230            let trimmed = rest.trim().trim_matches('"');
231            if !trimmed.is_empty() {
232                return Some(trimmed.to_string());
233            }
234        }
235    }
236    None
237}
238
239fn elan_prefix() -> Result<Option<PathBuf>, String> {
240    let Some(elan_home) = env::var_os("ELAN_HOME") else {
241        return Ok(None);
242    };
243    let output = Command::new("elan")
244        .args(["show", "active-toolchain"])
245        .output()
246        .map_err(|err| format!("`elan show active-toolchain` failed: {err}"))?;
247    if !output.status.success() {
248        return Err(format!("`elan show active-toolchain` exited {}", output.status));
249    }
250    let toolchain = String::from_utf8_lossy(&output.stdout)
251        .lines()
252        .next()
253        .unwrap_or("")
254        .split_whitespace()
255        .next()
256        .unwrap_or("")
257        .to_string();
258    if toolchain.is_empty() {
259        return Ok(None);
260    }
261    Ok(Some(PathBuf::from(elan_home).join("toolchains").join(toolchain)))
262}
263
264fn lean_print_prefix() -> Result<Option<PathBuf>, String> {
265    let output = Command::new("lean")
266        .arg("--print-prefix")
267        .output()
268        .map_err(|err| format!("`lean --print-prefix` failed: {err}"))?;
269    if !output.status.success() {
270        return Err(format!("`lean --print-prefix` exited {}", output.status));
271    }
272    let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
273    if prefix.is_empty() {
274        Ok(None)
275    } else {
276        Ok(Some(PathBuf::from(prefix)))
277    }
278}
279
280fn lake_fixture_prefix() -> Result<Option<PathBuf>, String> {
281    let Some(fixture_dir) = find_workspace_fixture() else {
282        return Ok(None);
283    };
284    let output = Command::new("lake")
285        .args(["env", "printenv", "LEAN_SYSROOT"])
286        .current_dir(&fixture_dir)
287        .output()
288        .map_err(|err| format!("`lake env printenv LEAN_SYSROOT` failed: {err}"))?;
289    if !output.status.success() {
290        return Err(format!("`lake env printenv LEAN_SYSROOT` exited {}", output.status));
291    }
292    let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
293    if prefix.is_empty() {
294        Ok(None)
295    } else {
296        Ok(Some(PathBuf::from(prefix)))
297    }
298}
299
300fn find_workspace_fixture() -> Option<PathBuf> {
301    // CARGO_MANIFEST_DIR is set during cargo invocations (build + test); at
302    // arbitrary runtime we fall back to the current dir.
303    let start = env::var_os("CARGO_MANIFEST_DIR")
304        .map(PathBuf::from)
305        .or_else(|| env::current_dir().ok())?;
306    let mut cursor: &Path = start.as_path();
307    loop {
308        let candidate = cursor.join("fixtures").join("lean");
309        if candidate.join("lakefile.lean").is_file() {
310            return Some(candidate);
311        }
312        cursor = cursor.parent()?;
313    }
314}