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.30.0**. Releases 4.27.0–4.29.1 were
44// dropped on 2026-07-28: their `libleanshared` does not export
45// `_l___private_Lean_Util_CollectAxioms_0__Lean_CollectAxioms_collectAndGet___boxed`,
46// which the compiled `lean-rs-host` shim dylib references through
47// `Lean.collectAxioms` (in the shims since v0.1.18), so the mandatory host
48// shim fails `dlopen` on those toolchains — the window claimed support the
49// runtime never had. Verified by `nm -gU`: 4.29.1 exports zero
50// `collectAndGet` symbols, 4.30.0 and later export four. The earlier 4.26.0
51// drop (2026-07-19) was for shim *build* failures; ≤ 4.25.x is excluded by
52// the refcount divergence that crashes inside `lean_dec_ref_cold`.
53pub const SUPPORTED_TOOLCHAINS: &[SupportedToolchain] = &[
54 SupportedToolchain {
55 versions: &["4.30.0"],
56 header_digest: "5a25125970f4f1dcf85a4c403463b387a8ff93535cd4a3054cafdee1759017d7",
57 missing_symbols: &[],
58 },
59 SupportedToolchain {
60 versions: &["4.31.0-rc1", "4.31.0-rc2"],
61 header_digest: "99ef35d69709e38caf836cf9ebbdf94d4474801e04157b8a72622dbdc653ec87",
62 missing_symbols: &[],
63 },
64 SupportedToolchain {
65 versions: &["4.31.0"],
66 header_digest: "486fe204404c0fdfb753b7e089c1c0d38fbdb396206030497696165e31218992",
67 missing_symbols: &[],
68 },
69 SupportedToolchain {
70 versions: &["4.32.0-rc1", "4.32.0", "4.32.2"],
71 header_digest: "22eed50aa703c4403010fabc12a7231ffa34dc979bd59ca1bfbac13c29a1dad2",
72 missing_symbols: &[],
73 },
74 // 4.33.0-rc1 ships a *new* `lean.h` digest, but the change is confined to
75 // two C11 `_Atomic(...)` qualifiers—`m_canceled` (a `uint8_t` inside the
76 // opaque `lean_task_imp`, reached only via our `*mut c_void` `imp` field)
77 // and `m_imp` (a pointer in `lean_task_object`). `_Atomic(T)` for a
78 // lock-free scalar/pointer has the same size and alignment as `T`, so a
79 // probe against both headers reports byte-identical size, alignment, and
80 // field offsets for all 10 mirrored structs. `repr.rs` is unchanged; all
81 // 88 REQUIRED_SYMBOLS resolve. Added 2026-07-19 as the new head.
82 SupportedToolchain {
83 versions: &["4.33.0-rc1", "4.33.0-rc2", "4.33.0"],
84 header_digest: "9018878554c5552ff3754865780d21825c2d0c5c4b47491b37bf6fe046adcd56",
85 missing_symbols: &[],
86 },
87 // 4.34.0-rc1 ships a *new* `lean.h` digest, but the change is confined to
88 // TSan instrumentation: `#define LEAN_TSAN` guards and three
89 // `lean_internal_*_rc` static-inline helpers that read/write `m_rc`
90 // through seq-cst atomics only when compiled under ThreadSanitizer. No
91 // struct declaration changes, so the probe against both headers reports
92 // byte-identical size, alignment, and field offsets for all 10 mirrored
93 // structs. `repr.rs` is unchanged; all 88 REQUIRED_SYMBOLS resolve.
94 // Added 2026-08-11 as the new head.
95 SupportedToolchain {
96 versions: &["4.34.0-rc1"],
97 header_digest: "19510ea01b07c55bd49066566e586179fe77f11120eeab7a29da50fa93cb1c8a",
98 missing_symbols: &[],
99 },
100];
101
102/// Return the [`SupportedToolchain`] entry that includes `version`, if any.
103#[must_use]
104pub fn supported_for(version: &str) -> Option<&'static SupportedToolchain> {
105 SUPPORTED_TOOLCHAINS.iter().find(|t| t.includes(version))
106}
107
108/// Return the [`SupportedToolchain`] entry whose `header_digest` matches the
109/// given lowercase-hex SHA-256 string, if any.
110#[must_use]
111pub fn supported_by_digest(digest: &str) -> Option<&'static SupportedToolchain> {
112 SUPPORTED_TOOLCHAINS.iter().find(|t| t.header_digest == digest)
113}
114
115/// Return `true` iff no [`SupportedToolchain`] entry lists `symbol` under
116/// `missing_symbols`. Combine with [`crate::REQUIRED_SYMBOLS`] for a
117/// membership check via [`crate::symbol_in_all`].
118#[must_use]
119pub fn symbol_present_in_window(symbol: &str) -> bool {
120 SUPPORTED_TOOLCHAINS
121 .iter()
122 .all(|t| !t.missing_symbols.contains(&symbol))
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 /// `SemVer` precedence key for a Lean version string: numeric release
130 /// core (e.g. `4.31.0`) first, then a flag that ranks a final release
131 /// *after* its pre-releases (`false` for `-rcN`, `true` for a final),
132 /// then the pre-release identifier. Tuple `Ord` composes these in the
133 /// right priority. The naive `&str` comparison gets the rc/final pair
134 /// backwards—`"4.31.0" < "4.31.0-rc1"` lexically—so the ordering
135 /// invariant compares these keys instead (`SemVer` §11).
136 fn precedence_key(version: &str) -> (Vec<u64>, bool, &str) {
137 let (core, pre) = match version.split_once('-') {
138 Some((core, pre)) => (core, pre),
139 None => (version, ""),
140 };
141 let core_nums = core.split('.').map(|n| n.parse().unwrap_or(0)).collect();
142 (core_nums, pre.is_empty(), pre)
143 }
144
145 #[test]
146 fn window_is_non_empty_and_ordered_by_first_version() {
147 assert!(!SUPPORTED_TOOLCHAINS.is_empty());
148 for w in SUPPORTED_TOOLCHAINS.windows(2) {
149 let (Some(prev), Some(next)) = (w.first(), w.get(1)) else {
150 continue;
151 };
152 let (Some(a), Some(b)) = (prev.versions.first(), next.versions.first()) else {
153 continue;
154 };
155 assert!(
156 precedence_key(a) < precedence_key(b),
157 "SUPPORTED_TOOLCHAINS must be sorted ascending by first version: {a} >= {b}",
158 );
159 }
160 }
161
162 #[test]
163 fn every_entry_lists_at_least_one_version() {
164 for t in SUPPORTED_TOOLCHAINS {
165 assert!(
166 !t.versions.is_empty(),
167 "entry with digest {} has no versions",
168 t.header_digest
169 );
170 }
171 }
172
173 #[test]
174 fn digests_are_distinct() {
175 for (i, a) in SUPPORTED_TOOLCHAINS.iter().enumerate() {
176 let Some(rest) = SUPPORTED_TOOLCHAINS.get(i + 1..) else {
177 continue;
178 };
179 for b in rest {
180 assert_ne!(
181 a.header_digest, b.header_digest,
182 "{:?} and {:?} share a header digest \u{2014} merge their `versions` arrays",
183 a.versions, b.versions,
184 );
185 }
186 }
187 }
188
189 #[test]
190 fn versions_are_distinct_across_entries() {
191 let mut seen: Vec<&str> = Vec::new();
192 for t in SUPPORTED_TOOLCHAINS {
193 for &v in t.versions {
194 assert!(
195 !seen.contains(&v),
196 "version {v} appears in more than one SupportedToolchain entry",
197 );
198 seen.push(v);
199 }
200 }
201 }
202
203 #[test]
204 fn digests_are_64_lowercase_hex() {
205 for t in SUPPORTED_TOOLCHAINS {
206 assert_eq!(
207 t.header_digest.len(),
208 64,
209 "entry for {:?}: digest is not 64 chars",
210 t.versions,
211 );
212 assert!(
213 t.header_digest
214 .bytes()
215 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
216 "entry for {:?}: digest is not lowercase hex",
217 t.versions,
218 );
219 }
220 }
221
222 #[test]
223 fn lookups_round_trip() {
224 for t in SUPPORTED_TOOLCHAINS {
225 for &v in t.versions {
226 assert_eq!(supported_for(v), Some(t));
227 }
228 assert_eq!(supported_by_digest(t.header_digest), Some(t));
229 }
230 assert!(supported_for("0.0.0").is_none());
231 assert!(supported_by_digest("0").is_none());
232 }
233
234 #[test]
235 fn fully_present_symbols_pass_window_check() {
236 for &s in crate::REQUIRED_SYMBOLS {
237 assert!(symbol_present_in_window(s), "{s} should be in all supported toolchains");
238 }
239 }
240
241 #[test]
242 fn unknown_symbol_passes_window_check() {
243 // No entry can possibly list an unknown symbol under missing_symbols,
244 // so the window-only check trivially passes; the membership check
245 // (`crate::symbol_in_all`) is what catches non-required symbols.
246 assert!(symbol_present_in_window("lean_does_not_exist_zzz"));
247 }
248}