Skip to main content

luna_core/
numeric.rs

1//! Lua numeral conversion core (stone candidate: pure functions, no runtime
2//! types). Two consumers: the lexer (literal tokens, shape pre-validated by
3//! scanning) and the VM/stdlib (`str2num` — luaO_str2num semantics with
4//! whitespace and sign). Versioning is expressed as capability flags so this
5//! module stays dialect-agnostic.
6
7/// Result of parsing a Lua numeric literal — either an integer or a float
8/// (Lua 5.1 collapses everything to float at this layer).
9#[derive(Clone, Copy, PartialEq, Debug)]
10pub enum Num {
11    /// Integer-typed numeral.
12    Int(i64),
13    /// Float-typed numeral.
14    Float(f64),
15}
16
17impl Num {
18    /// Lossy conversion to `f64`. Integer-to-float follows Lua's coercion
19    /// (`Int` is cast as `i as f64`).
20    pub fn as_f64(self) -> f64 {
21        match self {
22            Num::Int(i) => i as f64,
23            Num::Float(f) => f,
24        }
25    }
26
27    fn negate(self) -> Num {
28        match self {
29            Num::Int(i) => Num::Int(i.wrapping_neg()),
30            Num::Float(f) => Num::Float(-f),
31        }
32    }
33}
34
35/// Decode a single ASCII hex digit (`0-9`, `a-f`, `A-F`) into its numeric
36/// value, or return `None` for a non-hex byte.
37pub fn hex_digit(c: u8) -> Option<u32> {
38    match c {
39        b'0'..=b'9' => Some((c - b'0') as u32),
40        b'a'..=b'f' => Some((c - b'a' + 10) as u32),
41        b'A'..=b'F' => Some((c - b'A' + 10) as u32),
42        _ => None,
43    }
44}
45
46/// Decimal numeral (no sign, no surrounding space).
47/// `int_ok = false` forces float results (Lua 5.1: numbers are doubles).
48/// `neg` is whether a leading '-' was stripped by the caller: it widens the
49/// integer range by one unit (PUC l_str2int's `+ neg`) so that the magnitude
50/// 2^63 parses as an integer — letting `tonumber("-9223372036854775808")`
51/// recover minint. The caller still applies the actual negation.
52pub fn dec_literal(text: &[u8], int_ok: bool, neg: bool) -> Option<Num> {
53    let mut i = 0;
54    let mut int_digits = 0;
55    while i < text.len() && text[i].is_ascii_digit() {
56        i += 1;
57        int_digits += 1;
58    }
59    let mut frac_digits = 0;
60    let mut has_dot = false;
61    if i < text.len() && text[i] == b'.' {
62        has_dot = true;
63        i += 1;
64        while i < text.len() && text[i].is_ascii_digit() {
65            i += 1;
66            frac_digits += 1;
67        }
68    }
69    if int_digits + frac_digits == 0 {
70        return None;
71    }
72    let mut has_exp = false;
73    if i < text.len() && matches!(text[i], b'e' | b'E') {
74        has_exp = true;
75        i += 1;
76        if i < text.len() && matches!(text[i], b'+' | b'-') {
77            i += 1;
78        }
79        let mut digits = 0;
80        while i < text.len() && text[i].is_ascii_digit() {
81            i += 1;
82            digits += 1;
83        }
84        if digits == 0 {
85            return None;
86        }
87    }
88    if i != text.len() {
89        return None;
90    }
91    let s = str::from_utf8(text).expect("ascii numeral");
92    if !has_dot && !has_exp && int_ok {
93        // decimal integer; accumulate the magnitude in u64 with PUC's overflow
94        // rule (l_str2int). The `+ neg` widens the last accepted digit so the
95        // magnitude 2^63 is taken as an integer when negative (== minint);
96        // on overflow it becomes a float. The caller applies the sign, so we
97        // return the wrapped magnitude (2^63 as i64 is the minint bit pattern).
98        const MAXBY10: u64 = i64::MAX as u64 / 10;
99        const MAXLAST: u64 = i64::MAX as u64 % 10;
100        let mut a: u64 = 0;
101        let mut overflow = false;
102        for &c in s.as_bytes() {
103            let d = (c - b'0') as u64;
104            if a >= MAXBY10 && (a > MAXBY10 || d > MAXLAST + neg as u64) {
105                overflow = true;
106                break;
107            }
108            a = a * 10 + d;
109        }
110        if !overflow {
111            return Some(Num::Int(a as i64));
112        }
113    }
114    s.parse::<f64>().ok().map(Num::Float)
115}
116
117/// Hex numeral after the `0x` prefix (no sign, no surrounding space).
118pub fn hex_literal(text: &[u8], int_ok: bool, float_ok: bool) -> Option<Num> {
119    let mut i = 0;
120    while i < text.len() && hex_digit(text[i]).is_some() {
121        i += 1;
122    }
123    let int_end = i;
124    let mut has_dot = false;
125    let mut frac = 0..0;
126    if i < text.len() && text[i] == b'.' {
127        has_dot = true;
128        i += 1;
129        let fs = i;
130        while i < text.len() && hex_digit(text[i]).is_some() {
131            i += 1;
132        }
133        frac = fs..i;
134    }
135    if int_end + frac.len() == 0 {
136        return None;
137    }
138    let has_exp = i < text.len() && matches!(text[i], b'p' | b'P');
139    let mut pexp: i64 = 0;
140    if has_exp {
141        i += 1;
142        let mut sign = 1i64;
143        if i < text.len() && matches!(text[i], b'+' | b'-') {
144            sign = if text[i] == b'-' { -1 } else { 1 };
145            i += 1;
146        }
147        let mut digits = 0;
148        let mut e: i64 = 0;
149        while i < text.len() && text[i].is_ascii_digit() {
150            e = (e * 10 + (text[i] - b'0') as i64).min(1 << 40);
151            i += 1;
152            digits += 1;
153        }
154        if digits == 0 {
155            return None;
156        }
157        pexp = sign * e;
158    }
159    if i != text.len() {
160        return None;
161    }
162    if !has_exp && !has_dot {
163        if int_ok {
164            // pure hex integer: wraps modulo 2^64 (5.3+ semantics)
165            let mut v: u64 = 0;
166            for &c in &text[..int_end] {
167                v = v
168                    .wrapping_mul(16)
169                    .wrapping_add(hex_digit(c).unwrap() as u64);
170            }
171            return Some(Num::Int(v as i64));
172        }
173        // ≤5.2 had no integer subtype: PUC `lua_strx2number` accumulates
174        // every hex digit in `lua_Number` (a double), so a 150-digit literal
175        // gives the actual mathematical value (~4e180) rather than the
176        // wrapped low-64 bits. math.lua 5.2 :59 bakes that exact equality.
177        let mut v: f64 = 0.0;
178        for &c in &text[..int_end] {
179            v = v * 16.0 + hex_digit(c).unwrap() as f64;
180        }
181        return Some(Num::Float(v));
182    }
183    if !float_ok {
184        return None;
185    }
186    // value = mant * 2^(4*exp4 + pexp); digits beyond 64 mantissa bits fold
187    // into the exponent (integer part) or the sticky bit (fraction part)
188    let mut mant: u64 = 0;
189    let mut sticky = false;
190    let mut exp4: i64 = 0;
191    for &c in &text[..int_end] {
192        let d = hex_digit(c).unwrap() as u64;
193        if mant >> 60 == 0 {
194            mant = mant * 16 + d;
195        } else {
196            sticky |= d != 0;
197            exp4 += 1;
198        }
199    }
200    for &c in &text[frac] {
201        let d = hex_digit(c).unwrap() as u64;
202        if mant >> 60 == 0 {
203            mant = mant * 16 + d;
204            exp4 -= 1;
205        } else {
206            sticky |= d != 0;
207        }
208    }
209    Some(Num::Float(compose_f64(mant, sticky, exp4 * 4 + pexp)))
210}
211
212/// luaO_str2num: optional surrounding whitespace and sign, decimal or hex.
213/// Used by VM string→number coercion and `tonumber`.
214pub fn str2num(s: &[u8], int_ok: bool, hex_float_ok: bool) -> Option<Num> {
215    let is_space = |c: &&u8| matches!(**c, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r');
216    let mut s = s;
217    while s.first().filter(is_space).is_some() {
218        s = &s[1..];
219    }
220    while s.last().filter(is_space).is_some() {
221        s = &s[..s.len() - 1];
222    }
223    let neg = match s.first() {
224        Some(b'-') => {
225            s = &s[1..];
226            true
227        }
228        Some(b'+') => {
229            s = &s[1..];
230            false
231        }
232        _ => false,
233    };
234    let n = if s.len() > 2 && s[0] == b'0' && matches!(s[1], b'x' | b'X') {
235        hex_literal(&s[2..], int_ok, hex_float_ok)?
236    } else {
237        dec_literal(s, int_ok, neg)?
238    };
239    Some(if neg { n.negate() } else { n })
240}
241
242/// C99 `strtod` applied to a C string, accepting the numeral only when
243/// nothing but whitespace follows it: PUC 5.1's `luaO_str2d`. Unlike
244/// [`str2num`] it reads `inf`/`infinity`/`nan` (any case, `nan(...)` too),
245/// always yields a float, and stops at the first NUL, as C sees the string
246/// end there. (5.1's retry with `strtoul` when `strtod` stops at an `x`
247/// can never finish the string, so it is not modelled.)
248pub fn strtod_str(s: &[u8]) -> Option<f64> {
249    let is_space = |c: u8| matches!(c, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r');
250    let s = &s[..s.iter().position(|&c| c == 0).unwrap_or(s.len())];
251    let mut i = 0;
252    while i < s.len() && is_space(s[i]) {
253        i += 1;
254    }
255    let neg = match s.get(i) {
256        Some(b'-') => {
257            i += 1;
258            true
259        }
260        Some(b'+') => {
261            i += 1;
262            false
263        }
264        _ => false,
265    };
266    let rest = &s[i..];
267    let starts = |w: &[u8]| rest.len() >= w.len() && rest[..w.len()].eq_ignore_ascii_case(w);
268    let (v, used) = if starts(b"infinity") {
269        (f64::INFINITY, 8)
270    } else if starts(b"inf") {
271        (f64::INFINITY, 3)
272    } else if starts(b"nan") {
273        let mut n = 3;
274        // `nan(n-char-sequence)`
275        if rest.get(3) == Some(&b'(') {
276            let close = rest[4..]
277                .iter()
278                .position(|&c| !(c.is_ascii_alphanumeric() || c == b'_'));
279            if let Some(k) = close
280                && rest[4 + k] == b')'
281            {
282                n = 5 + k;
283            }
284        }
285        (f64::NAN, n)
286    } else if rest.len() > 2
287        && rest[0] == b'0'
288        && matches!(rest[1], b'x' | b'X')
289        && let Some((v, n)) = hex_prefix(&rest[2..])
290    {
291        (v, 2 + n)
292    } else {
293        dec_prefix(rest)?
294    };
295    if !rest[used..].iter().all(|&c| is_space(c)) {
296        return None;
297    }
298    Some(if neg { -v } else { v })
299}
300
301/// The longest decimal float numeral at the start of `s` (`strtod`'s
302/// subject sequence) and its length.
303fn dec_prefix(s: &[u8]) -> Option<(f64, usize)> {
304    let digits = |s: &[u8], mut i: usize| {
305        while i < s.len() && s[i].is_ascii_digit() {
306            i += 1;
307        }
308        i
309    };
310    let int_end = digits(s, 0);
311    let mut i = int_end;
312    let mut frac = 0;
313    if i < s.len() && s[i] == b'.' {
314        let e = digits(s, i + 1);
315        frac = e - (i + 1);
316        i = e;
317    }
318    if int_end + frac == 0 {
319        return None;
320    }
321    if i < s.len() && matches!(s[i], b'e' | b'E') {
322        let mut j = i + 1;
323        if j < s.len() && matches!(s[j], b'+' | b'-') {
324            j += 1;
325        }
326        let e = digits(s, j);
327        if e > j {
328            i = e;
329        }
330    }
331    let text = str::from_utf8(&s[..i]).expect("ascii numeral");
332    Some((text.parse::<f64>().ok()?, i))
333}
334
335/// The longest hex float numeral after `0x` at the start of `s`, and its
336/// length; `None` when no hex digit follows (strtod then reads just "0").
337fn hex_prefix(s: &[u8]) -> Option<(f64, usize)> {
338    let hexes = |mut i: usize| {
339        while i < s.len() && hex_digit(s[i]).is_some() {
340            i += 1;
341        }
342        i
343    };
344    let int_end = hexes(0);
345    let mut i = int_end;
346    let mut frac = 0;
347    if i < s.len() && s[i] == b'.' {
348        let e = hexes(i + 1);
349        frac = e - (i + 1);
350        i = e;
351    }
352    if int_end + frac == 0 {
353        return None;
354    }
355    if i < s.len() && matches!(s[i], b'p' | b'P') {
356        let mut j = i + 1;
357        if j < s.len() && matches!(s[j], b'+' | b'-') {
358            j += 1;
359        }
360        let mut e = j;
361        while e < s.len() && s[e].is_ascii_digit() {
362            e += 1;
363        }
364        if e > j {
365            i = e;
366        }
367    }
368    hex_literal(&s[..i], false, true).map(|n| (n.as_f64(), i))
369}
370
371/// Round a 64-bit mantissa (+sticky) to f64 and scale by 2^exp.
372fn compose_f64(mant: u64, sticky: bool, exp: i64) -> f64 {
373    if mant == 0 {
374        return 0.0;
375    }
376    let bits = 64 - mant.leading_zeros() as i64;
377    let (m, extra) = if bits <= 53 {
378        (mant, 0i64)
379    } else {
380        let excess = (bits - 53) as u32;
381        let kept = mant >> excess;
382        let rem = mant & ((1u64 << excess) - 1);
383        let half = 1u64 << (excess - 1);
384        let round_up = rem > half || (rem == half && (sticky || kept & 1 == 1));
385        (kept + round_up as u64, excess as i64)
386    };
387    scale_f64(m as f64, exp + extra)
388}
389
390fn exp2(e: i64) -> f64 {
391    debug_assert!((-1022..=1023).contains(&e));
392    f64::from_bits(((e + 1023) as u64) << 52)
393}
394
395fn scale_f64(mut f: f64, mut e: i64) -> f64 {
396    while e > 1023 {
397        f *= exp2(1023);
398        e -= 1023;
399        if f.is_infinite() {
400            return f;
401        }
402    }
403    while e < -1022 {
404        f *= exp2(-1022);
405        e += 1022;
406        if f == 0.0 {
407            return f;
408        }
409    }
410    f * exp2(e)
411}
412
413/// Lua number → text. Integers print as integers. Floats print with
414/// shortest round-trip digits (the 5.5 "read back correctly" rule) in
415/// C `%g`-style presentation: scientific form when the decimal exponent
416/// falls outside [-4, 14), two-digit signed exponent, and `.0` appended to
417/// integral-looking decimals (PUC lua_number2str). Exact boundary alignment
418/// against PUC 5.5 output is checked by the official strings/math suites.
419pub fn num_to_string(n: Num) -> String {
420    num_to_string_for(n, FloatFmt::TwoStage55)
421}
422
423/// Write i64 decimal into a stack buffer; returns the slice of valid
424/// bytes inside `buf`. 20 chars covers i64::MIN..=i64::MAX (the longest
425/// is "-9223372036854775808" at 20 bytes). Hot in tostring(int) on
426/// numeric-heavy workloads (string_concat builds 5000 of these): skips
427/// the String allocation that `i.to_string()` does.
428#[inline]
429pub fn write_i64_dec(i: i64, buf: &mut [u8; 20]) -> &[u8] {
430    if i == 0 {
431        buf[0] = b'0';
432        return &buf[..1];
433    }
434    let neg = i < 0;
435    // unsigned_abs handles i64::MIN safely (negation overflow case).
436    let mut n = i.unsigned_abs();
437    let mut pos = 20;
438    while n > 0 {
439        pos -= 1;
440        buf[pos] = b'0' + (n % 10) as u8;
441        n /= 10;
442    }
443    if neg {
444        pos -= 1;
445        buf[pos] = b'-';
446    }
447    &buf[pos..]
448}
449
450/// Float rendering flavor per dialect generation — each PUC line
451/// prints floats with a different `LUA_NUMBER_FMT` (v2.14 HD, pinned
452/// by the per-dialect diff corpus):
453#[derive(Clone, Copy, PartialEq, Eq, Debug)]
454pub enum FloatFmt {
455    /// ≤5.2: `%.14g`, and NO ".0" suffix (single number type —
456    /// `tostring(2.0)` is `"2"`). pm.lua :13 and 5.1/511 pin this.
457    Legacy14,
458    /// 5.3/5.4: `%.14g` + ".0" on integer-looking renderings.
459    G14,
460    /// 5.5: `%.15g` → round-trip check → `%.17g` + ".0"
461    /// (lobject.c `tostringbuffFloat`).
462    TwoStage55,
463}
464
465/// Render a number per the dialect's float flavor. Integers are
466/// flavor-independent.
467pub fn num_to_string_for(n: Num, fmt: FloatFmt) -> String {
468    match n {
469        Num::Int(i) => i.to_string(),
470        Num::Float(f) => float_to_string(f, fmt),
471    }
472}
473
474/// C `printf("%.{prec}g", f)` semantics: `prec` significant digits;
475/// scientific form when the decimal exponent is `< -4` or `>= prec`,
476/// fixed form otherwise; trailing zeros (and a bare trailing point)
477/// stripped; scientific exponent printed sign + ≥2 digits.
478fn format_g(f: f64, prec: usize) -> String {
479    debug_assert!(prec >= 1);
480    // Decimal exponent from a correctly-rounded scientific rendering at
481    // the target precision (rounding may bump the exponent: 9.99 → 1e1).
482    let sci = format!("{f:.*e}", prec - 1);
483    let epos = sci.rfind('e').expect("scientific form has exponent");
484    let exp: i32 = sci[epos + 1..].parse().expect("valid exponent");
485    if exp < -4 || exp >= prec as i32 {
486        let mut mant = sci[..epos].to_string();
487        if mant.contains('.') {
488            while mant.ends_with('0') {
489                mant.pop();
490            }
491            if mant.ends_with('.') {
492                mant.pop();
493            }
494        }
495        let (esign, eabs) = if exp < 0 { ('-', -exp) } else { ('+', exp) };
496        format!("{mant}e{esign}{eabs:02}")
497    } else {
498        let decimals = (prec as i32 - 1 - exp).max(0) as usize;
499        let mut s = format!("{f:.decimals$}");
500        if s.contains('.') {
501            while s.ends_with('0') {
502                s.pop();
503            }
504            if s.ends_with('.') {
505                s.pop();
506            }
507        }
508        s
509    }
510}
511
512/// How the host C library's `printf` spells a NaN, as (shows a minus
513/// sign, lowercase body). PUC renders numbers through the platform's
514/// `printf`, which differs here, so luna follows the platform it runs on:
515/// Apple's libc never prints a NaN's sign; glibc and musl print it like any
516/// other sign (`-nan`, and `+nan` / ` nan` under those flags); the Windows
517/// CRT also names the kind (`-nan(ind)` for the default NaN that 0/0 and
518/// friends produce, `nan(snan)` for a signalling one). wasm follows musl,
519/// whose `printf` wasi-libc uses.
520pub(crate) fn nan_spelling(f: f64) -> (bool, &'static str) {
521    let negative = f.is_sign_negative();
522    if cfg!(target_vendor = "apple") {
523        (false, "nan")
524    } else if cfg!(target_os = "windows") {
525        let bits = f.to_bits();
526        let quiet = bits & (1 << 51) != 0;
527        let body = if !quiet {
528            "nan(snan)"
529        } else if bits == 0xFFF8_0000_0000_0000 {
530            "nan(ind)"
531        } else {
532            "nan"
533        };
534        (negative, body)
535    } else {
536        (negative, "nan")
537    }
538}
539
540fn float_to_string(f: f64, fmt: FloatFmt) -> String {
541    if f.is_nan() {
542        let (negative, body) = nan_spelling(f);
543        return if negative {
544            format!("-{body}")
545        } else {
546            body.to_string()
547        };
548    }
549    if f.is_infinite() {
550        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
551    }
552    let mut s = match fmt {
553        // ≤5.4: plain LUA_NUMBER_FMT="%.14g" (lua 5.1.5-5.4.9).
554        FloatFmt::Legacy14 | FloatFmt::G14 => format_g(f, 14),
555        // 5.5 `tostringbuffFloat` (lobject.c): %.15g, read back, and
556        // only if the round-trip is inexact re-print with %.17g.
557        FloatFmt::TwoStage55 => {
558            let first = format_g(f, 15);
559            if first.parse::<f64>() == Ok(f) {
560                first
561            } else {
562                format_g(f, 17)
563            }
564        }
565    };
566    if s.bytes().all(|c| c.is_ascii_digit() || c == b'-') && fmt != FloatFmt::Legacy14 {
567        s.push_str(".0");
568    }
569    s
570}
571
572#[cfg(test)]
573mod tests {
574    #[test]
575    fn strtod_str_reads_what_c_strtod_reads() {
576        assert_eq!(strtod_str(b"  10  "), Some(10.0));
577        assert_eq!(strtod_str(b"10\0zz"), Some(10.0));
578        assert_eq!(strtod_str(b"-inf"), Some(f64::NEG_INFINITY));
579        assert_eq!(strtod_str(b"Infinity"), Some(f64::INFINITY));
580        assert!(strtod_str(b"nan").is_some_and(f64::is_nan));
581        assert!(strtod_str(b"nan(123)").is_some_and(f64::is_nan));
582        assert_eq!(strtod_str(b"0x1p4"), Some(16.0));
583        assert_eq!(strtod_str(b"0x.8"), Some(0.5));
584        assert_eq!(strtod_str(b".5e1"), Some(5.0));
585        assert_eq!(strtod_str(b"0x"), None);
586        assert_eq!(strtod_str(b"1e"), None);
587        assert_eq!(strtod_str(b"infx"), None);
588        assert_eq!(strtod_str(b""), None);
589    }
590
591    use super::*;
592
593    #[test]
594    fn str2num_semantics() {
595        assert_eq!(str2num(b"  42  ", true, true), Some(Num::Int(42)));
596        assert_eq!(str2num(b"-10", true, true), Some(Num::Int(-10)));
597        assert_eq!(str2num(b"+0x10", true, true), Some(Num::Int(16)));
598        assert_eq!(str2num(b"-0x10", true, true), Some(Num::Int(-16)));
599        assert_eq!(str2num(b" 0x1p4 ", true, true), Some(Num::Float(16.0)));
600        assert_eq!(str2num(b"3.5", true, true), Some(Num::Float(3.5)));
601        assert_eq!(str2num(b"1e3", true, true), Some(Num::Float(1000.0)));
602        assert_eq!(str2num(b"", true, true), None);
603        assert_eq!(str2num(b" - 1", true, true), None);
604        assert_eq!(str2num(b"10a", true, true), None);
605        assert_eq!(str2num(b"0x", true, true), None);
606        // 5.1 flavor: everything is a float, no hex floats
607        assert_eq!(str2num(b"42", false, false), Some(Num::Float(42.0)));
608        assert_eq!(str2num(b"0x1p4", false, false), None);
609        // minint boundary: "-9223372036854775808" parses as the integer minint
610        // (PUC l_str2int's `+ neg`), but the positive magnitude 2^63 overflows
611        // to a float, and maxint stays an integer.
612        assert_eq!(
613            str2num(b"-9223372036854775808", true, true),
614            Some(Num::Int(i64::MIN))
615        );
616        assert_eq!(
617            str2num(b"9223372036854775807", true, true),
618            Some(Num::Int(i64::MAX))
619        );
620        assert_eq!(
621            str2num(b"9223372036854775808", true, true),
622            Some(Num::Float(9223372036854775808.0))
623        );
624    }
625
626    #[test]
627    fn number_printing() {
628        assert_eq!(num_to_string(Num::Int(42)), "42");
629        assert_eq!(num_to_string(Num::Int(-1)), "-1");
630        assert_eq!(num_to_string(Num::Float(2.0)), "2.0");
631        assert_eq!(num_to_string(Num::Float(-2.0)), "-2.0");
632        assert_eq!(num_to_string(Num::Float(0.5)), "0.5");
633        assert_eq!(num_to_string(Num::Float(1e300)), "1e+300");
634        assert_eq!(num_to_string(Num::Float(1e-7)), "1e-07");
635        assert_eq!(num_to_string(Num::Float(1e15)), "1e+15");
636        assert_eq!(num_to_string(Num::Float(100.0)), "100.0");
637        assert_eq!(num_to_string(Num::Float(f64::INFINITY)), "inf");
638        assert_eq!(num_to_string(Num::Float(f64::NAN)), "nan");
639        // PUC 5.5 two-stage rule: %.15g, then %.17g when the round-trip
640        // is inexact (lobject.c tostringbuffFloat). Reference spellings
641        // taken from the lua5.5 binary (fixture 226 pins the full matrix).
642        assert_eq!(num_to_string(Num::Float(0.1)), "0.1");
643        assert_eq!(num_to_string(Num::Float(1.0 / 3.0)), "0.33333333333333331");
644        assert_eq!(
645            num_to_string(Num::Float(std::f64::consts::PI)),
646            "3.1415926535897931"
647        );
648        assert_eq!(num_to_string(Num::Float(1e14)), "100000000000000.0");
649        assert_eq!(
650            num_to_string(Num::Float(9007199254740992.0)),
651            "9007199254740992.0"
652        );
653        assert_eq!(num_to_string(Num::Float(5e-324)), "4.94065645841247e-324");
654    }
655
656    #[test]
657    fn hex_float_rounding() {
658        // > 53 significant bits forces rounding; Rust's u64→f64 conversion is
659        // correctly rounded and serves as the reference
660        let Some(Num::Float(f)) = hex_literal(b"1FFFFFFFFFFFFF8.0p0", true, true) else {
661            panic!()
662        };
663        assert_eq!(f, 0x1FFFFFFFFFFFFF8u64 as f64);
664        let Some(Num::Float(g)) = hex_literal(b"1.8p1", true, true) else {
665            panic!()
666        };
667        assert_eq!(g, 3.0);
668        let Some(Num::Float(h)) = hex_literal(b"0.8", true, true) else {
669            panic!()
670        };
671        assert_eq!(h, 0.5);
672    }
673}