Skip to main content

lean_rs_abi/
supported.rs

1//! The supported Lean toolchain window.
2//!
3//! `lean-rs-abi` accepts the active toolchain at build time iff its `lean.h`
4//! digest matches one entry in [`SUPPORTED_TOOLCHAINS`]. The table is the
5//! single source of truth for the v1.0 compatibility promise.
6//!
7//! Each entry records the SHA-256 of one `include/lean/lean.h`, the
8//! `LEAN_VERSION_STRING` values that ship that exact header (Lean does not
9//! always bump the header between releases—header-identical releases share
10//! one entry), and the set of [`REQUIRED_SYMBOLS`](crate::REQUIRED_SYMBOLS)
11//! that are absent from this toolchain. Runtime layout assumptions in
12//! `lean-rs-sys` are checked against this same window (see
13//! `docs/architecture/02-versioning-and-compatibility.md`).
14//!
15//! See `docs/bump-toolchain.md` for the procedure to extend the window.
16
17/// One ABI-equivalence class in the supported toolchain window.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct SupportedToolchain {
20    /// `LEAN_VERSION_STRING` values that ship this exact header. Releases
21    /// with byte-identical `lean.h` share one entry.
22    pub versions: &'static [&'static str],
23    /// SHA-256 of `include/lean/lean.h`, lowercase hex.
24    pub header_digest: &'static str,
25    /// Entries of [`crate::REQUIRED_SYMBOLS`] that are absent from this
26    /// toolchain. Empty when the full surface is available.
27    pub missing_symbols: &'static [&'static str],
28}
29
30impl SupportedToolchain {
31    /// Return `true` iff `version` (the `LEAN_VERSION_STRING`) is one of
32    /// this entry's grouped releases.
33    #[must_use]
34    pub fn includes(&self, version: &str) -> bool {
35        self.versions.contains(&version)
36    }
37}
38
39/// The supported Lean toolchain window.
40///
41/// Ordered by the first `versions` entry. To add a new toolchain, follow
42/// the checklist in `docs/bump-toolchain.md`.
43// Lower bound of the window is **4.26.0**, not 4.23.0. Empirical
44// verification (multi-toolchain sweep on 2026-05-18) showed that Lean
45// ≤ 4.25.x crashes inside `lean_dec_ref_cold` from the service layer—
46// a refcount-path divergence between 4.25 and 4.26 that the current
47// mirrors do not cover. Narrowing the window to the empirically green
48// range (4.26.0 → current head) is the honest v0.1.0 promise; reopening
49// the lower bound is its own follow-up (investigate the 4.25→4.26
50// refcount divergence).
51pub const SUPPORTED_TOOLCHAINS: &[SupportedToolchain] = &[
52    SupportedToolchain {
53        versions: &["4.26.0"],
54        header_digest: "e0ea3efaccceb5b75c7e9e1ab92952c8aa85c3faee28ee949dfeb8ab428ad218",
55        missing_symbols: &[],
56    },
57    SupportedToolchain {
58        versions: &["4.27.0"],
59        header_digest: "42255d180910bb063d97c87cfb2a61550009ca9ceb6f495069c56bfaa6c92e13",
60        missing_symbols: &[],
61    },
62    SupportedToolchain {
63        versions: &["4.28.0"],
64        header_digest: "624726e5f1f10fd77cd95b8fe8f30389312e57c8fc98e6c2f1989289bdb5fb0e",
65        missing_symbols: &[],
66    },
67    SupportedToolchain {
68        versions: &["4.28.1"],
69        header_digest: "648ecfb615ef0222cd63b5f1bbbc379a06749bc0f5f4c2eb16ffca26fd18fe81",
70        missing_symbols: &[],
71    },
72    SupportedToolchain {
73        versions: &["4.29.0"],
74        header_digest: "671683950ef412474bede2c6a2b50aecf4f99bc29e1ddaf2222ee54ad4ffb91c",
75        missing_symbols: &[],
76    },
77    SupportedToolchain {
78        versions: &["4.29.1"],
79        header_digest: "2e481a0dac7215eb16123eaef97298ae5a6d0bd0c28c534c2818e2d2f2a28efc",
80        missing_symbols: &[],
81    },
82    SupportedToolchain {
83        versions: &["4.30.0"],
84        header_digest: "5a25125970f4f1dcf85a4c403463b387a8ff93535cd4a3054cafdee1759017d7",
85        missing_symbols: &[],
86    },
87    SupportedToolchain {
88        versions: &["4.31.0-rc1", "4.31.0-rc2"],
89        header_digest: "99ef35d69709e38caf836cf9ebbdf94d4474801e04157b8a72622dbdc653ec87",
90        missing_symbols: &[],
91    },
92];
93
94/// Return the [`SupportedToolchain`] entry that includes `version`, if any.
95#[must_use]
96pub fn supported_for(version: &str) -> Option<&'static SupportedToolchain> {
97    SUPPORTED_TOOLCHAINS.iter().find(|t| t.includes(version))
98}
99
100/// Return the [`SupportedToolchain`] entry whose `header_digest` matches the
101/// given lowercase-hex SHA-256 string, if any.
102#[must_use]
103pub fn supported_by_digest(digest: &str) -> Option<&'static SupportedToolchain> {
104    SUPPORTED_TOOLCHAINS.iter().find(|t| t.header_digest == digest)
105}
106
107/// Return `true` iff no [`SupportedToolchain`] entry lists `symbol` under
108/// `missing_symbols`. Combine with [`crate::REQUIRED_SYMBOLS`] for a
109/// membership check via [`crate::symbol_in_all`].
110#[must_use]
111pub fn symbol_present_in_window(symbol: &str) -> bool {
112    SUPPORTED_TOOLCHAINS
113        .iter()
114        .all(|t| !t.missing_symbols.contains(&symbol))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn window_is_non_empty_and_ordered_by_first_version() {
123        assert!(!SUPPORTED_TOOLCHAINS.is_empty());
124        for w in SUPPORTED_TOOLCHAINS.windows(2) {
125            let (Some(prev), Some(next)) = (w.first(), w.get(1)) else {
126                continue;
127            };
128            let (Some(a), Some(b)) = (prev.versions.first(), next.versions.first()) else {
129                continue;
130            };
131            assert!(
132                a < b,
133                "SUPPORTED_TOOLCHAINS must be sorted ascending by first version: {a} >= {b}",
134            );
135        }
136    }
137
138    #[test]
139    fn every_entry_lists_at_least_one_version() {
140        for t in SUPPORTED_TOOLCHAINS {
141            assert!(
142                !t.versions.is_empty(),
143                "entry with digest {} has no versions",
144                t.header_digest
145            );
146        }
147    }
148
149    #[test]
150    fn digests_are_distinct() {
151        for (i, a) in SUPPORTED_TOOLCHAINS.iter().enumerate() {
152            let Some(rest) = SUPPORTED_TOOLCHAINS.get(i + 1..) else {
153                continue;
154            };
155            for b in rest {
156                assert_ne!(
157                    a.header_digest, b.header_digest,
158                    "{:?} and {:?} share a header digest \u{2014} merge their `versions` arrays",
159                    a.versions, b.versions,
160                );
161            }
162        }
163    }
164
165    #[test]
166    fn versions_are_distinct_across_entries() {
167        let mut seen: Vec<&str> = Vec::new();
168        for t in SUPPORTED_TOOLCHAINS {
169            for &v in t.versions {
170                assert!(
171                    !seen.contains(&v),
172                    "version {v} appears in more than one SupportedToolchain entry",
173                );
174                seen.push(v);
175            }
176        }
177    }
178
179    #[test]
180    fn digests_are_64_lowercase_hex() {
181        for t in SUPPORTED_TOOLCHAINS {
182            assert_eq!(
183                t.header_digest.len(),
184                64,
185                "entry for {:?}: digest is not 64 chars",
186                t.versions,
187            );
188            assert!(
189                t.header_digest
190                    .bytes()
191                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
192                "entry for {:?}: digest is not lowercase hex",
193                t.versions,
194            );
195        }
196    }
197
198    #[test]
199    fn lookups_round_trip() {
200        for t in SUPPORTED_TOOLCHAINS {
201            for &v in t.versions {
202                assert_eq!(supported_for(v), Some(t));
203            }
204            assert_eq!(supported_by_digest(t.header_digest), Some(t));
205        }
206        assert!(supported_for("0.0.0").is_none());
207        assert!(supported_by_digest("0").is_none());
208    }
209
210    #[test]
211    fn fully_present_symbols_pass_window_check() {
212        for &s in crate::REQUIRED_SYMBOLS {
213            assert!(symbol_present_in_window(s), "{s} should be in all supported toolchains");
214        }
215    }
216
217    #[test]
218    fn unknown_symbol_passes_window_check() {
219        // No entry can possibly list an unknown symbol under missing_symbols,
220        // so the window-only check trivially passes; the membership check
221        // (`crate::symbol_in_all`) is what catches non-required symbols.
222        assert!(symbol_present_in_window("lean_does_not_exist_zzz"));
223    }
224}