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.27.0**. The prior 4.26.0 lower bound
44// was dropped on 2026-07-19: a full-matrix sweep showed 4.26.0 no longer
45// builds the bundled `lean-rs-host` shim on either CI platform—it lacks
46// `Lean.Elab.ContextInfo.cmdEnv?` and `String.trimAscii`, and rejects a
47// call in `Environment.lean` on a type mismatch the shim sources now
48// rely on. Rather than reintroduce version-conditional shim code for the
49// oldest release, the window starts at 4.27.0 (upper bound 4.25.x is
50// excluded by the same refcount divergence that has always bounded it—
51// ≤ 4.25.x crashes inside `lean_dec_ref_cold` from the service layer).
52pub const SUPPORTED_TOOLCHAINS: &[SupportedToolchain] = &[
53    SupportedToolchain {
54        versions: &["4.27.0"],
55        header_digest: "42255d180910bb063d97c87cfb2a61550009ca9ceb6f495069c56bfaa6c92e13",
56        missing_symbols: &[],
57    },
58    SupportedToolchain {
59        versions: &["4.28.0"],
60        header_digest: "624726e5f1f10fd77cd95b8fe8f30389312e57c8fc98e6c2f1989289bdb5fb0e",
61        missing_symbols: &[],
62    },
63    SupportedToolchain {
64        versions: &["4.28.1"],
65        header_digest: "648ecfb615ef0222cd63b5f1bbbc379a06749bc0f5f4c2eb16ffca26fd18fe81",
66        missing_symbols: &[],
67    },
68    SupportedToolchain {
69        versions: &["4.29.0"],
70        header_digest: "671683950ef412474bede2c6a2b50aecf4f99bc29e1ddaf2222ee54ad4ffb91c",
71        missing_symbols: &[],
72    },
73    SupportedToolchain {
74        versions: &["4.29.1"],
75        header_digest: "2e481a0dac7215eb16123eaef97298ae5a6d0bd0c28c534c2818e2d2f2a28efc",
76        missing_symbols: &[],
77    },
78    SupportedToolchain {
79        versions: &["4.30.0"],
80        header_digest: "5a25125970f4f1dcf85a4c403463b387a8ff93535cd4a3054cafdee1759017d7",
81        missing_symbols: &[],
82    },
83    SupportedToolchain {
84        versions: &["4.31.0-rc1", "4.31.0-rc2"],
85        header_digest: "99ef35d69709e38caf836cf9ebbdf94d4474801e04157b8a72622dbdc653ec87",
86        missing_symbols: &[],
87    },
88    SupportedToolchain {
89        versions: &["4.31.0"],
90        header_digest: "486fe204404c0fdfb753b7e089c1c0d38fbdb396206030497696165e31218992",
91        missing_symbols: &[],
92    },
93    SupportedToolchain {
94        versions: &["4.32.0-rc1", "4.32.0"],
95        header_digest: "22eed50aa703c4403010fabc12a7231ffa34dc979bd59ca1bfbac13c29a1dad2",
96        missing_symbols: &[],
97    },
98    // 4.33.0-rc1 ships a *new* `lean.h` digest, but the change is confined to
99    // two C11 `_Atomic(...)` qualifiers—`m_canceled` (a `uint8_t` inside the
100    // opaque `lean_task_imp`, reached only via our `*mut c_void` `imp` field)
101    // and `m_imp` (a pointer in `lean_task_object`). `_Atomic(T)` for a
102    // lock-free scalar/pointer has the same size and alignment as `T`, so a
103    // probe against both headers reports byte-identical size, alignment, and
104    // field offsets for all 10 mirrored structs. `repr.rs` is unchanged; all
105    // 88 REQUIRED_SYMBOLS resolve. Added 2026-07-19 as the new head.
106    SupportedToolchain {
107        versions: &["4.33.0-rc1"],
108        header_digest: "9018878554c5552ff3754865780d21825c2d0c5c4b47491b37bf6fe046adcd56",
109        missing_symbols: &[],
110    },
111];
112
113/// Return the [`SupportedToolchain`] entry that includes `version`, if any.
114#[must_use]
115pub fn supported_for(version: &str) -> Option<&'static SupportedToolchain> {
116    SUPPORTED_TOOLCHAINS.iter().find(|t| t.includes(version))
117}
118
119/// Return the [`SupportedToolchain`] entry whose `header_digest` matches the
120/// given lowercase-hex SHA-256 string, if any.
121#[must_use]
122pub fn supported_by_digest(digest: &str) -> Option<&'static SupportedToolchain> {
123    SUPPORTED_TOOLCHAINS.iter().find(|t| t.header_digest == digest)
124}
125
126/// Return `true` iff no [`SupportedToolchain`] entry lists `symbol` under
127/// `missing_symbols`. Combine with [`crate::REQUIRED_SYMBOLS`] for a
128/// membership check via [`crate::symbol_in_all`].
129#[must_use]
130pub fn symbol_present_in_window(symbol: &str) -> bool {
131    SUPPORTED_TOOLCHAINS
132        .iter()
133        .all(|t| !t.missing_symbols.contains(&symbol))
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// `SemVer` precedence key for a Lean version string: numeric release
141    /// core (e.g. `4.31.0`) first, then a flag that ranks a final release
142    /// *after* its pre-releases (`false` for `-rcN`, `true` for a final),
143    /// then the pre-release identifier. Tuple `Ord` composes these in the
144    /// right priority. The naive `&str` comparison gets the rc/final pair
145    /// backwards—`"4.31.0" < "4.31.0-rc1"` lexically—so the ordering
146    /// invariant compares these keys instead (`SemVer` §11).
147    fn precedence_key(version: &str) -> (Vec<u64>, bool, &str) {
148        let (core, pre) = match version.split_once('-') {
149            Some((core, pre)) => (core, pre),
150            None => (version, ""),
151        };
152        let core_nums = core.split('.').map(|n| n.parse().unwrap_or(0)).collect();
153        (core_nums, pre.is_empty(), pre)
154    }
155
156    #[test]
157    fn window_is_non_empty_and_ordered_by_first_version() {
158        assert!(!SUPPORTED_TOOLCHAINS.is_empty());
159        for w in SUPPORTED_TOOLCHAINS.windows(2) {
160            let (Some(prev), Some(next)) = (w.first(), w.get(1)) else {
161                continue;
162            };
163            let (Some(a), Some(b)) = (prev.versions.first(), next.versions.first()) else {
164                continue;
165            };
166            assert!(
167                precedence_key(a) < precedence_key(b),
168                "SUPPORTED_TOOLCHAINS must be sorted ascending by first version: {a} >= {b}",
169            );
170        }
171    }
172
173    #[test]
174    fn every_entry_lists_at_least_one_version() {
175        for t in SUPPORTED_TOOLCHAINS {
176            assert!(
177                !t.versions.is_empty(),
178                "entry with digest {} has no versions",
179                t.header_digest
180            );
181        }
182    }
183
184    #[test]
185    fn digests_are_distinct() {
186        for (i, a) in SUPPORTED_TOOLCHAINS.iter().enumerate() {
187            let Some(rest) = SUPPORTED_TOOLCHAINS.get(i + 1..) else {
188                continue;
189            };
190            for b in rest {
191                assert_ne!(
192                    a.header_digest, b.header_digest,
193                    "{:?} and {:?} share a header digest \u{2014} merge their `versions` arrays",
194                    a.versions, b.versions,
195                );
196            }
197        }
198    }
199
200    #[test]
201    fn versions_are_distinct_across_entries() {
202        let mut seen: Vec<&str> = Vec::new();
203        for t in SUPPORTED_TOOLCHAINS {
204            for &v in t.versions {
205                assert!(
206                    !seen.contains(&v),
207                    "version {v} appears in more than one SupportedToolchain entry",
208                );
209                seen.push(v);
210            }
211        }
212    }
213
214    #[test]
215    fn digests_are_64_lowercase_hex() {
216        for t in SUPPORTED_TOOLCHAINS {
217            assert_eq!(
218                t.header_digest.len(),
219                64,
220                "entry for {:?}: digest is not 64 chars",
221                t.versions,
222            );
223            assert!(
224                t.header_digest
225                    .bytes()
226                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
227                "entry for {:?}: digest is not lowercase hex",
228                t.versions,
229            );
230        }
231    }
232
233    #[test]
234    fn lookups_round_trip() {
235        for t in SUPPORTED_TOOLCHAINS {
236            for &v in t.versions {
237                assert_eq!(supported_for(v), Some(t));
238            }
239            assert_eq!(supported_by_digest(t.header_digest), Some(t));
240        }
241        assert!(supported_for("0.0.0").is_none());
242        assert!(supported_by_digest("0").is_none());
243    }
244
245    #[test]
246    fn fully_present_symbols_pass_window_check() {
247        for &s in crate::REQUIRED_SYMBOLS {
248            assert!(symbol_present_in_window(s), "{s} should be in all supported toolchains");
249        }
250    }
251
252    #[test]
253    fn unknown_symbol_passes_window_check() {
254        // No entry can possibly list an unknown symbol under missing_symbols,
255        // so the window-only check trivially passes; the membership check
256        // (`crate::symbol_in_all`) is what catches non-required symbols.
257        assert!(symbol_present_in_window("lean_does_not_exist_zzz"));
258    }
259}