Skip to main content

sui_compat/
versions.rs

1//! Nix version-string algorithms — single typed implementation
2//! shared by every engine (tree-walker `sui-eval`, bytecode VM
3//! `sui-bytecode`, and any future engine).
4//!
5//! Also hosts [`cppnix_format_float`] — the CppNix-equivalent
6//! formatter every engine routes float Display through (see
7//! sui-compat::versions::cppnix_format_float for the contract).
8//!
9//! Three primitives:
10//!
11//! - [`split_version`] — tokenize a version string on `.` / `-` /
12//!   digit↔non-digit boundaries, matching CppNix's `splitString`
13//!   semantics.
14//! - [`compare_versions`] — three-way comparison returning `-1` /
15//!   `0` / `1`.  Handles the `"pre"` special case (any component
16//!   equal to `"pre"` orders below everything except itself and
17//!   the empty component).
18//! - [`parse_drv_name`] — split a `<name>-<version>` package
19//!   string at the last `-` followed by a digit.
20//!
21//! Lifted from the tree-walker `sui-eval::builtins::versions` so the
22//! VM's previously naive duplicate (split on `.` only, no `pre`
23//! handling) doesn't drift.  The bug that surfaced this extraction:
24//! `compareVersions "1.0-rc1" "1.0-pre1"` returned `0` on the VM
25//! and `1` on cppnix.  Same canonical implementation now lives here.
26
27/// Split a version string into typed components.
28///
29/// Splits on `.` and `-` separators AND on boundaries between
30/// digit and non-digit characters.  Empty components are dropped.
31///
32/// Examples:
33/// - `"1.0-rc1"`   → `["1", "0", "rc", "1"]`
34/// - `"1.0.0-pre"` → `["1", "0", "0", "pre"]`
35/// - `"2024a"`     → `["2024", "a"]`
36#[must_use]
37pub fn split_version(s: &str) -> Vec<String> {
38    let mut parts = Vec::new();
39    let mut current = String::new();
40    let mut prev_digit: Option<bool> = None;
41    for ch in s.chars() {
42        if ch == '.' || ch == '-' {
43            if !current.is_empty() {
44                parts.push(std::mem::take(&mut current));
45            }
46            prev_digit = None;
47        } else {
48            let is_digit = ch.is_ascii_digit();
49            if let Some(was_digit) = prev_digit
50                && is_digit != was_digit
51                && !current.is_empty()
52            {
53                parts.push(std::mem::take(&mut current));
54            }
55            current.push(ch);
56            prev_digit = Some(is_digit);
57        }
58    }
59    if !current.is_empty() {
60        parts.push(current);
61    }
62    parts
63}
64
65/// Three-way comparison of two version strings.
66///
67/// Component-by-component:
68///
69/// 1. If both parse as integers, compare numerically.
70/// 2. Otherwise, the special component `"pre"` is less than any
71///    non-`"pre"` component (including the empty component) — this
72///    matches CppNix's pre-release ordering convention.
73/// 3. Otherwise, compare lexicographically.
74///
75/// Missing components default to `""`, so `"1.0"` and `"1.0.0"`
76/// compare as `0` (CppNix matches this).
77///
78/// Returns `-1`, `0`, or `1`.
79#[must_use]
80pub fn compare_versions(a: &str, b: &str) -> i64 {
81    let pa = split_version(a);
82    let pb = split_version(b);
83    let max_len = pa.len().max(pb.len());
84    for i in 0..max_len {
85        let ca = pa.get(i).map(String::as_str).unwrap_or("");
86        let cb = pb.get(i).map(String::as_str).unwrap_or("");
87        let ord = match (ca.parse::<i64>(), cb.parse::<i64>()) {
88            (Ok(na), Ok(nb)) => na.cmp(&nb),
89            _ => match (ca, cb) {
90                ("pre", "pre") => std::cmp::Ordering::Equal,
91                ("pre", _) => std::cmp::Ordering::Less,
92                (_, "pre") => std::cmp::Ordering::Greater,
93                _ => ca.cmp(cb),
94            },
95        };
96        if ord != std::cmp::Ordering::Equal {
97            return if ord == std::cmp::Ordering::Less { -1 } else { 1 };
98        }
99    }
100    0
101}
102
103/// Format a `f64` the way CppNix does — `printf("%g", f)` semantics
104/// with 6 significant digits, trailing-zero strip, no decimal point
105/// for whole numbers.
106///
107/// Examples matching `nix eval` byte-for-byte (verified on cppnix
108/// 2.30 / cid 2026-05-23):
109/// - `1.0 / 3.0`   → `"0.333333"`
110/// - `10.0 / 3.0`  → `"3.33333"`     (6 sig digits, not 6 decimal places)
111/// - `3.14159`     → `"3.14159"`
112/// - `12.345`      → `"12.345"`
113/// - `1.5`         → `"1.5"`
114/// - `3.0`         → `"3"`
115/// - `5.0 - 2.0`   → `"3"`
116/// - `0.0`         → `"0"`
117/// - `0.0001`      → `"0.0001"`
118/// - `NaN`         → `"NaN"`
119/// - `inf`         → `"inf"`
120///
121/// Used by every engine's float Display impl (`Value::Float`,
122/// `VMValue::Float`, `StringKeyedValue::Float`) so probe JSON
123/// round-trips byte-identically against cppnix.
124#[must_use]
125pub fn cppnix_format_float(f: f64) -> String {
126    if f.is_nan() {
127        return "NaN".to_string();
128    }
129    if f.is_infinite() {
130        return if f > 0.0 { "inf".to_string() } else { "-inf".to_string() };
131    }
132    if f == 0.0 {
133        return "0".to_string();
134    }
135
136    // %g semantics: choose total significant digits = 6.  For values
137    // in the range [1e-4, 1e6) use fixed-point; outside, scientific.
138    let exp = f.abs().log10().floor() as i32;
139    if (-4..6).contains(&exp) {
140        // Fixed-point: after_decimal = 5 - exp, clamped at 0.
141        // (One sig digit before decimal when exp >= 0, |exp| leading
142        // zeros + one sig digit after decimal when exp < 0.)
143        let after_decimal = (5 - exp).max(0) as usize;
144        let raw = format!("{f:.*}", after_decimal);
145        if let Some((whole, frac)) = raw.split_once('.') {
146            let trimmed = frac.trim_end_matches('0');
147            if trimmed.is_empty() {
148                whole.to_string()
149            } else {
150                format!("{whole}.{trimmed}")
151            }
152        } else {
153            raw
154        }
155    } else {
156        // Scientific: 6 sig digits → 5 after the leading digit.
157        // Strip trailing zeros from the mantissa.
158        let raw = format!("{f:.5e}");
159        // raw is like "3.33333e10" or "1.00000e-5"
160        if let Some((mantissa, exp_part)) = raw.split_once('e') {
161            let mantissa_trimmed =
162                if let Some((w, frac)) = mantissa.split_once('.') {
163                    let trimmed = frac.trim_end_matches('0');
164                    if trimmed.is_empty() {
165                        w.to_string()
166                    } else {
167                        format!("{w}.{trimmed}")
168                    }
169                } else {
170                    mantissa.to_string()
171                };
172            // CppNix emits `e+NN` for positive, `e-NN` for negative.
173            // Rust's `{:e}` formatter omits the `+`; restore it.
174            let exp_part_signed = if exp_part.starts_with('-') {
175                exp_part.to_string()
176            } else {
177                format!("+{exp_part}")
178            };
179            format!("{mantissa_trimmed}e{exp_part_signed}")
180        } else {
181            raw
182        }
183    }
184}
185
186/// Parse a `<name>-<version>` package string into `(name, version)`.
187///
188/// The version starts at the last `-` immediately followed by a
189/// digit.  If no such boundary exists, the whole string is the
190/// name and the version is empty.
191#[must_use]
192pub fn parse_drv_name(s: &str) -> (String, String) {
193    let bytes = s.as_bytes();
194    for i in (0..bytes.len()).rev() {
195        if bytes[i] == b'-'
196            && i + 1 < bytes.len()
197            && bytes[i + 1].is_ascii_digit()
198        {
199            return (s[..i].to_string(), s[i + 1..].to_string());
200        }
201    }
202    (s.to_string(), String::new())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use proptest::prelude::*;
209
210    #[test]
211    fn rc_orders_above_pre() {
212        assert_eq!(compare_versions("1.0-rc1", "1.0-pre1"), 1);
213        assert_eq!(compare_versions("1.0-pre1", "1.0-rc1"), -1);
214    }
215
216    #[test]
217    fn numeric_components_compare_numerically() {
218        assert_eq!(compare_versions("1.10", "1.2"), 1);
219        assert_eq!(compare_versions("1.2", "1.10"), -1);
220        assert_eq!(compare_versions("1.0", "1.0"), 0);
221    }
222
223    #[test]
224    fn missing_components_order_below_present_components() {
225        // CppNix matches: "1.0" < "1.0.0" because the missing 3rd
226        // component compares as "" < "0" lexicographically.
227        assert_eq!(compare_versions("1.0", "1.0.0"), -1);
228        assert_eq!(compare_versions("1.0.0", "1.0"), 1);
229    }
230
231    #[test]
232    fn pre_below_everything_except_pre() {
233        assert_eq!(compare_versions("1.0-pre1", "1.0-pre1"), 0);
234        assert_eq!(compare_versions("1.0-pre1", "1.0"), -1);
235        assert_eq!(compare_versions("1.0", "1.0-pre1"), 1);
236        // pre vs any non-pre suffix
237        assert_eq!(compare_versions("1.0-pre", "1.0-alpha"), -1);
238        assert_eq!(compare_versions("1.0-pre", "1.0-beta"), -1);
239        assert_eq!(compare_versions("1.0-pre", "1.0-rc"), -1);
240    }
241
242    #[test]
243    fn split_version_basic_shapes() {
244        assert_eq!(split_version("1.0-rc1"), vec!["1", "0", "rc", "1"]);
245        assert_eq!(split_version("1.0.0-pre"), vec!["1", "0", "0", "pre"]);
246        assert_eq!(split_version("2024a"), vec!["2024", "a"]);
247    }
248
249    #[test]
250    fn cppnix_format_float_known_outputs() {
251        // From `nix eval` on cppnix (verified on cid 2026-05-23):
252        assert_eq!(cppnix_format_float(1.0 / 3.0), "0.333333");
253        assert_eq!(cppnix_format_float(3.14159), "3.14159");
254        assert_eq!(cppnix_format_float(1.5), "1.5");
255        assert_eq!(cppnix_format_float(3.0), "3");
256        assert_eq!(cppnix_format_float(0.0), "0");
257        assert_eq!(cppnix_format_float(-3.14), "-3.14");
258        assert_eq!(cppnix_format_float(-3.0), "-3");
259    }
260
261    #[test]
262    fn cppnix_format_float_nan_and_infinity() {
263        assert_eq!(cppnix_format_float(f64::NAN), "NaN");
264        assert_eq!(cppnix_format_float(f64::INFINITY), "inf");
265        assert_eq!(cppnix_format_float(f64::NEG_INFINITY), "-inf");
266    }
267
268    #[test]
269    fn parse_drv_name_recovers_split() {
270        let (n, v) = parse_drv_name("hello-1.2.3");
271        assert_eq!(n, "hello");
272        assert_eq!(v, "1.2.3");
273        let (n, v) = parse_drv_name("nix-darwin-config");
274        assert_eq!(n, "nix-darwin-config");
275        assert_eq!(v, "");
276    }
277
278    proptest! {
279        /// Antisymmetry: `compare(a, b) == -compare(b, a)`.
280        #[test]
281        fn compare_versions_antisymmetric(
282            a in "[0-9a-z.-]{1,20}",
283            b in "[0-9a-z.-]{1,20}",
284        ) {
285            let ab = compare_versions(&a, &b);
286            let ba = compare_versions(&b, &a);
287            prop_assert_eq!(ab, -ba);
288        }
289
290        /// Reflexivity: `compare(a, a) == 0`.
291        #[test]
292        fn compare_versions_reflexive(a in "[0-9a-z.-]{1,20}") {
293            prop_assert_eq!(compare_versions(&a, &a), 0);
294        }
295    }
296}