Skip to main content

noyalib/value/
number.rs

1//! YAML number type (`Number`).
2
3// SPDX-License-Identifier: MIT OR Apache-2.0
4// Copyright (c) 2026 Noyalib. All rights reserved.
5
6use crate::prelude::*;
7use core::cmp::Ordering;
8use core::hash::{Hash, Hasher};
9use core::str::FromStr;
10
11/// Represents a YAML number.
12///
13/// Marked `#[non_exhaustive]` so future numeric variants can be
14/// added without breaking downstream `match` arms. Callers must
15/// include a `_ => { … }` wildcard when pattern-matching. Adding
16/// `Unsigned(u64)` behind `feature = "lossless-u64"` is the first
17/// use of this contract; see
18/// [ADR-0004](https://github.com/sebastienrousseau/noyalib/blob/main/docs/adr/0004-lossless-u64-integers.md).
19#[derive(Debug, Clone, Copy)]
20#[non_exhaustive]
21pub enum Number {
22    /// A signed integer.
23    Integer(i64),
24    /// An unsigned integer that cannot be represented by `i64`.
25    #[cfg(feature = "lossless-u64")]
26    #[cfg_attr(docsrs, doc(cfg(feature = "lossless-u64")))]
27    Unsigned(u64),
28    /// A floating-point number.
29    Float(f64),
30}
31
32impl Number {
33    /// Returns the number as an `i64` if it is an integer.
34    ///
35    /// Floats return `None` even when their value happens to be a
36    /// whole number; the type tag is part of the test.
37    ///
38    /// # Examples
39    ///
40    /// ```
41    /// use noyalib::Number;
42    /// assert_eq!(Number::Integer(42).as_i64(), Some(42));
43    /// assert_eq!(Number::Float(1.0).as_i64(), None);
44    /// ```
45    #[must_use]
46    pub fn as_i64(&self) -> Option<i64> {
47        match self {
48            Self::Integer(n) => Some(*n),
49            #[cfg(feature = "lossless-u64")]
50            Self::Unsigned(n) => i64::try_from(*n).ok(),
51            Self::Float(_) => None,
52        }
53    }
54
55    /// Returns the number as a `u64` if it is a non-negative integer.
56    ///
57    /// Negative integers and floats return `None`.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use noyalib::Number;
63    /// assert_eq!(Number::Integer(42).as_u64(), Some(42));
64    /// assert_eq!(Number::Integer(-1).as_u64(), None);
65    /// assert_eq!(Number::Float(1.0).as_u64(), None);
66    /// ```
67    #[must_use]
68    pub fn as_u64(&self) -> Option<u64> {
69        match self {
70            Self::Integer(n) if *n >= 0 => Some(*n as u64),
71            #[cfg(feature = "lossless-u64")]
72            Self::Unsigned(n) => Some(*n),
73            _ => None,
74        }
75    }
76
77    /// Returns the number as an `f64`.
78    ///
79    /// Always succeeds — integers are widened to `f64` (with the
80    /// usual `i64 → f64` precision loss for magnitudes above
81    /// 2^53), floats pass through unchanged.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use noyalib::Number;
87    /// assert_eq!(Number::Integer(42).as_f64(), 42.0);
88    /// assert_eq!(Number::Float(0.5).as_f64(), 0.5);
89    /// ```
90    #[must_use]
91    pub fn as_f64(&self) -> f64 {
92        match self {
93            Self::Integer(n) => *n as f64,
94            #[cfg(feature = "lossless-u64")]
95            Self::Unsigned(n) => *n as f64,
96            Self::Float(n) => *n,
97        }
98    }
99
100    /// Returns `true` if the number is an integer.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use noyalib::Number;
106    /// assert!(Number::Integer(42).is_integer());
107    /// assert!(!Number::Float(1.0).is_integer());
108    /// ```
109    #[must_use]
110    pub fn is_integer(&self) -> bool {
111        match self {
112            Self::Integer(_) => true,
113            #[cfg(feature = "lossless-u64")]
114            Self::Unsigned(_) => true,
115            Self::Float(_) => false,
116        }
117    }
118
119    /// Returns `true` if the number is a float.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use noyalib::Number;
125    /// assert!(Number::Float(1.0).is_float());
126    /// assert!(!Number::Integer(42).is_float());
127    /// ```
128    #[must_use]
129    pub fn is_float(&self) -> bool {
130        matches!(self, Self::Float(_))
131    }
132
133    /// Returns `true` if the number can be represented as an `i64`.
134    ///
135    /// True for all integer values, false for floats.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use noyalib::Number;
141    /// assert!(Number::Integer(42).is_i64());
142    /// assert!(!Number::Float(42.0).is_i64());
143    /// ```
144    #[must_use]
145    pub fn is_i64(&self) -> bool {
146        match self {
147            Self::Integer(_) => true,
148            #[cfg(feature = "lossless-u64")]
149            Self::Unsigned(n) => i64::try_from(*n).is_ok(),
150            Self::Float(_) => false,
151        }
152    }
153
154    /// Returns `true` if the number can be represented as a `u64`.
155    ///
156    /// True for non-negative integers, false otherwise.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use noyalib::Number;
162    /// assert!(Number::Integer(42).is_u64());
163    /// assert!(!Number::Integer(-1).is_u64());
164    /// assert!(!Number::Float(1.0).is_u64());
165    /// ```
166    #[must_use]
167    pub fn is_u64(&self) -> bool {
168        match self {
169            Self::Integer(n) => *n >= 0,
170            #[cfg(feature = "lossless-u64")]
171            Self::Unsigned(_) => true,
172            Self::Float(_) => false,
173        }
174    }
175
176    /// Returns `true` if the number can be represented as an `f64`.
177    ///
178    /// Always true — both integers and floats convert to `f64`
179    /// (with the usual precision caveats for very large
180    /// integers).
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use noyalib::Number;
186    /// assert!(Number::Integer(42).is_f64());
187    /// assert!(Number::Float(1.0).is_f64());
188    /// ```
189    #[must_use]
190    pub fn is_f64(&self) -> bool {
191        true
192    }
193
194    /// Returns `true` if the number is `NaN` (Not a Number).
195    ///
196    /// Integers are never `NaN` — only floats with the IEEE 754
197    /// NaN bit pattern.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use noyalib::Number;
203    /// assert!(Number::Float(f64::NAN).is_nan());
204    /// assert!(!Number::Float(0.0).is_nan());
205    /// assert!(!Number::Integer(0).is_nan());
206    /// ```
207    #[must_use]
208    pub fn is_nan(&self) -> bool {
209        match self {
210            Self::Float(n) => n.is_nan(),
211            Self::Integer(_) => false,
212            #[cfg(feature = "lossless-u64")]
213            Self::Unsigned(_) => false,
214        }
215    }
216
217    /// Returns `true` if the number is positive or negative infinity.
218    ///
219    /// Integers are always finite — only `Number::Float(±inf)`
220    /// returns true.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use noyalib::Number;
226    /// assert!(Number::Float(f64::INFINITY).is_infinite());
227    /// assert!(Number::Float(f64::NEG_INFINITY).is_infinite());
228    /// assert!(!Number::Integer(i64::MAX).is_infinite());
229    /// ```
230    #[must_use]
231    pub fn is_infinite(&self) -> bool {
232        match self {
233            Self::Float(n) => n.is_infinite(),
234            Self::Integer(_) => false,
235            #[cfg(feature = "lossless-u64")]
236            Self::Unsigned(_) => false,
237        }
238    }
239
240    /// Returns `true` if the number is neither infinite nor `NaN`.
241    ///
242    /// Integers are always finite; floats are finite when neither
243    /// `±∞` nor `NaN`.
244    ///
245    /// # Examples
246    ///
247    /// ```
248    /// use noyalib::Number;
249    /// assert!(Number::Integer(0).is_finite());
250    /// assert!(Number::Float(0.5).is_finite());
251    /// assert!(!Number::Float(f64::NAN).is_finite());
252    /// assert!(!Number::Float(f64::INFINITY).is_finite());
253    /// ```
254    #[must_use]
255    pub fn is_finite(&self) -> bool {
256        match self {
257            Self::Float(n) => n.is_finite(),
258            Self::Integer(_) => true,
259            #[cfg(feature = "lossless-u64")]
260            Self::Unsigned(_) => true,
261        }
262    }
263}
264
265impl fmt::Display for Number {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::Integer(n) => write!(f, "{n}"),
269            #[cfg(feature = "lossless-u64")]
270            Self::Unsigned(n) => write!(f, "{n}"),
271            Self::Float(n) => write_float(f, *n),
272        }
273    }
274}
275
276/// Formats a float the same way the YAML emitter does: `.nan`, `.inf`,
277/// `-.inf` for the specials, and a float-preserving decimal form (`4.0`,
278/// never `4`) otherwise.
279///
280/// This is the single source of truth for float formatting, shared by
281/// [`Number`]'s `Display` impl and the serializer's value writer
282/// (`crate::ser::write_value`) so the two never drift apart again — see
283/// issue #348, where `Display` used `core::fmt`'s default `f64`
284/// formatting (which drops the trailing `.0` from whole floats) while the
285/// serializer already emitted `4.0`.
286///
287/// Generic over `W: fmt::Write` so it works both against a
288/// `fmt::Formatter` (`Display`) and a `String` output buffer
289/// (serializer).
290pub(crate) fn write_float<W: fmt::Write>(output: &mut W, n: f64) -> fmt::Result {
291    if n.is_nan() {
292        output.write_str(".nan")
293    } else if n.is_infinite() {
294        if n > 0.0 {
295            output.write_str(".inf")
296        } else {
297            output.write_str("-.inf")
298        }
299    } else {
300        #[cfg(feature = "fast-float")]
301        {
302            let mut buf = ryu::Buffer::new();
303            output.write_str(buf.format(n))
304        }
305        #[cfg(not(feature = "fast-float"))]
306        {
307            // `{:?}` preserves float-ness for whole numbers (`1.0` not
308            // `1`) so the value round-trips back as `Number::Float`.
309            write!(output, "{n:?}")
310        }
311    }
312}
313
314/// Error returned when parsing a number from a string fails.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct ParseNumberError {
317    _private: (),
318}
319
320impl fmt::Display for ParseNumberError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(f, "invalid number")
323    }
324}
325
326impl core::error::Error for ParseNumberError {}
327
328impl FromStr for Number {
329    type Err = ParseNumberError;
330
331    fn from_str(s: &str) -> Result<Self, Self::Err> {
332        let s = s.trim();
333
334        // Handle special float values
335        match s {
336            ".nan" | ".NaN" | ".NAN" => return Ok(Self::Float(f64::NAN)),
337            ".inf" | ".Inf" | ".INF" => return Ok(Self::Float(f64::INFINITY)),
338            "+.inf" | "+.Inf" | "+.INF" => return Ok(Self::Float(f64::INFINITY)),
339            "-.inf" | "-.Inf" | "-.INF" => return Ok(Self::Float(f64::NEG_INFINITY)),
340            _ => {}
341        }
342
343        // Try parsing as integer first
344        if let Ok(n) = s.parse::<i64>() {
345            return Ok(Self::Integer(n));
346        }
347        #[cfg(feature = "lossless-u64")]
348        if let Ok(n) = s.parse::<u64>() {
349            return Ok(Self::Unsigned(n));
350        }
351
352        // Handle hex (0x), octal (0o), and binary (0b) integers
353        if s.len() > 2 {
354            let (prefix, rest) = s.split_at(2);
355            match prefix {
356                "0x" | "0X" => {
357                    if let Ok(n) = i64::from_str_radix(rest, 16) {
358                        return Ok(Self::Integer(n));
359                    }
360                    #[cfg(feature = "lossless-u64")]
361                    if let Ok(n) = u64::from_str_radix(rest, 16) {
362                        return Ok(Self::Unsigned(n));
363                    }
364                }
365                "0o" | "0O" => {
366                    if let Ok(n) = i64::from_str_radix(rest, 8) {
367                        return Ok(Self::Integer(n));
368                    }
369                    #[cfg(feature = "lossless-u64")]
370                    if let Ok(n) = u64::from_str_radix(rest, 8) {
371                        return Ok(Self::Unsigned(n));
372                    }
373                }
374                "0b" | "0B" => {
375                    if let Ok(n) = i64::from_str_radix(rest, 2) {
376                        return Ok(Self::Integer(n));
377                    }
378                    #[cfg(feature = "lossless-u64")]
379                    if let Ok(n) = u64::from_str_radix(rest, 2) {
380                        return Ok(Self::Unsigned(n));
381                    }
382                }
383                _ => {}
384            }
385        }
386
387        // Try parsing as float
388        if let Ok(n) = s.parse::<f64>() {
389            return Ok(Self::Float(n));
390        }
391
392        Err(ParseNumberError { _private: () })
393    }
394}
395
396impl PartialEq for Number {
397    fn eq(&self, other: &Self) -> bool {
398        match (self, other) {
399            (Self::Integer(a), Self::Integer(b)) => a == b,
400            #[cfg(feature = "lossless-u64")]
401            (Self::Unsigned(a), Self::Unsigned(b)) => a == b,
402            (Self::Float(a), Self::Float(b)) => {
403                // Treat NaN == NaN to satisfy the Eq contract (reflexivity)
404                (a.is_nan() && b.is_nan()) || a == b
405            }
406            _ => false,
407        }
408    }
409}
410
411impl Eq for Number {}
412
413impl Hash for Number {
414    fn hash<H: Hasher>(&self, state: &mut H) {
415        match self {
416            Self::Integer(n) => {
417                0u8.hash(state);
418                n.hash(state);
419            }
420            #[cfg(feature = "lossless-u64")]
421            Self::Unsigned(n) => {
422                2u8.hash(state);
423                n.hash(state);
424            }
425            Self::Float(n) => {
426                1u8.hash(state);
427                // Eq/Hash contract: equal values must hash equal. Two
428                // edge cases break naive `to_bits()` hashing:
429                //   - `+0.0 == -0.0` is true under IEEE 754 (and our
430                //     PartialEq), but `to_bits()` gives 0x0000… vs
431                //     0x8000…. Normalise zeros to a single bit pattern.
432                //   - PartialEq treats NaN == NaN as true (so `Eq` is
433                //     reflexive), but distinct NaN payloads have
434                //     distinct bits. Hash a fixed sentinel for NaN.
435                let bits = if n.is_nan() {
436                    0x7FF8_0000_0000_0001
437                } else if *n == 0.0 {
438                    0
439                } else {
440                    n.to_bits()
441                };
442                bits.hash(state);
443            }
444        }
445    }
446}
447
448impl PartialOrd for Number {
449    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
450        Some(self.cmp(other))
451    }
452}
453
454/// Total ordering for [`Number`].
455///
456/// Same-kind comparisons (`Integer` vs `Integer`, `Unsigned` vs `Unsigned`,
457/// `Float` vs `Float`) use exact arithmetic. Cross-kind comparisons between
458/// integers and floats widen the integer side to [`f64`], matching the
459/// existing `Integer` ↔ `Float` arms. Values with magnitude above 2^53 may
460/// compare [`Ordering::Equal`] to nearby floats because of IEEE rounding; for
461/// exact ordering, compare within the same kind.
462impl Ord for Number {
463    fn cmp(&self, other: &Self) -> Ordering {
464        match (self, other) {
465            (Self::Integer(a), Self::Integer(b)) => a.cmp(b),
466            #[cfg(feature = "lossless-u64")]
467            (Self::Unsigned(a), Self::Unsigned(b)) => a.cmp(b),
468            #[cfg(feature = "lossless-u64")]
469            (Self::Integer(a), Self::Unsigned(b)) => {
470                if *a < 0 {
471                    Ordering::Less
472                } else {
473                    (*a as u64).cmp(b)
474                }
475            }
476            #[cfg(feature = "lossless-u64")]
477            (Self::Unsigned(a), Self::Integer(b)) => {
478                if *b < 0 {
479                    Ordering::Greater
480                } else {
481                    a.cmp(&(*b as u64))
482                }
483            }
484            (Self::Float(a), Self::Float(b)) => {
485                // Handle NaN: treat all NaN as equal and greater than any non-NaN
486                match (a.is_nan(), b.is_nan()) {
487                    (true, true) => Ordering::Equal,
488                    (true, false) => Ordering::Greater,
489                    (false, true) => Ordering::Less,
490                    (false, false) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
491                }
492            }
493            (Self::Integer(a), Self::Float(b)) => {
494                if b.is_nan() {
495                    Ordering::Less
496                } else if *a > (1_i64 << 53) || *a < -(1_i64 << 53) {
497                    // Large integer outside f64 safe range — compare via string
498                    // to avoid precision loss from i64→f64 cast.
499                    let a_f = *a as f64;
500                    if (a_f as i64) == *a {
501                        a_f.partial_cmp(b).unwrap_or(Ordering::Equal)
502                    } else {
503                        // Precision lost — compare integer magnitude vs float
504                        if *a > 0 {
505                            if *b < (1_i64 << 53) as f64 {
506                                Ordering::Greater
507                            } else {
508                                (*a as f64).partial_cmp(b).unwrap_or(Ordering::Equal)
509                            }
510                        } else if *b > -(1_i64 << 53) as f64 {
511                            Ordering::Less
512                        } else {
513                            (*a as f64).partial_cmp(b).unwrap_or(Ordering::Equal)
514                        }
515                    }
516                } else {
517                    (*a as f64).partial_cmp(b).unwrap_or(Ordering::Equal)
518                }
519            }
520            (Self::Float(a), Self::Integer(b)) => {
521                // Delegate to the Integer-Float case and invert.
522                match Self::Integer(*b).cmp(&Self::Float(*a)) {
523                    Ordering::Less => Ordering::Greater,
524                    Ordering::Greater => Ordering::Less,
525                    Ordering::Equal => Ordering::Equal,
526                }
527            }
528            #[cfg(feature = "lossless-u64")]
529            (Self::Unsigned(a), Self::Float(b)) => {
530                if b.is_nan() {
531                    Ordering::Less
532                } else {
533                    (*a as f64).partial_cmp(b).unwrap_or(Ordering::Equal)
534                }
535            }
536            #[cfg(feature = "lossless-u64")]
537            (Self::Float(a), Self::Unsigned(b)) => match Self::Unsigned(*b).cmp(&Self::Float(*a)) {
538                Ordering::Less => Ordering::Greater,
539                Ordering::Greater => Ordering::Less,
540                Ordering::Equal => Ordering::Equal,
541            },
542        }
543    }
544}
545
546// ============================================================================
547// Number From impls
548// ============================================================================
549
550impl From<i8> for Number {
551    fn from(v: i8) -> Self {
552        Self::Integer(i64::from(v))
553    }
554}
555
556impl From<i16> for Number {
557    fn from(v: i16) -> Self {
558        Self::Integer(i64::from(v))
559    }
560}
561
562impl From<i32> for Number {
563    fn from(v: i32) -> Self {
564        Self::Integer(i64::from(v))
565    }
566}
567
568impl From<i64> for Number {
569    fn from(v: i64) -> Self {
570        Self::Integer(v)
571    }
572}
573
574impl From<isize> for Number {
575    fn from(v: isize) -> Self {
576        Self::Integer(v as i64)
577    }
578}
579
580impl From<u8> for Number {
581    fn from(v: u8) -> Self {
582        Self::Integer(i64::from(v))
583    }
584}
585
586impl From<u16> for Number {
587    fn from(v: u16) -> Self {
588        Self::Integer(i64::from(v))
589    }
590}
591
592impl From<u32> for Number {
593    fn from(v: u32) -> Self {
594        Self::Integer(i64::from(v))
595    }
596}
597
598impl From<u64> for Number {
599    fn from(v: u64) -> Self {
600        if let Ok(v) = i64::try_from(v) {
601            Self::Integer(v)
602        } else {
603            #[cfg(feature = "lossless-u64")]
604            {
605                Self::Unsigned(v)
606            }
607            #[cfg(not(feature = "lossless-u64"))]
608            {
609                Self::Float(v as f64)
610            }
611        }
612    }
613}
614
615impl From<usize> for Number {
616    fn from(v: usize) -> Self {
617        Self::from(v as u64)
618    }
619}
620
621impl From<f32> for Number {
622    fn from(v: f32) -> Self {
623        Self::Float(f64::from(v))
624    }
625}
626
627impl From<f64> for Number {
628    fn from(v: f64) -> Self {
629        Self::Float(v)
630    }
631}