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}