use std::sync::LazyLock;
use regex::Regex;
static STRICT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$").expect("a constant pattern compiles")
});
pub(crate) fn strict_number(raw: &str) -> Option<f64> {
let trimmed = raw.trim();
if !STRICT.is_match(trimmed) {
return None;
}
trimmed
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
}
pub(crate) fn is_extractable(value: f64) -> bool {
value.is_finite()
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Value {
Number(f64),
Text(String),
Other,
Seq(Vec<Value>),
Map(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Coercion {
Typed,
Untyped,
}
pub(crate) fn collect(value: &Value, coercion: Coercion) -> Vec<f64> {
let mut out = Vec::new();
walk(value, coercion, &mut out);
out
}
fn walk(value: &Value, coercion: Coercion, out: &mut Vec<f64>) {
match value {
Value::Number(number) => {
if is_extractable(*number) {
out.push(*number);
}
}
Value::Text(text) => {
if coercion == Coercion::Untyped
&& let Some(number) = strict_number(text)
{
out.push(number);
}
}
Value::Seq(items) | Value::Map(items) => {
for item in items {
walk(item, coercion, out);
}
}
Value::Other => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fully_numeric_string_parses() {
assert_eq!(strict_number("42"), Some(42.0));
assert_eq!(strict_number("-1.5e3"), Some(-1500.0));
assert_eq!(strict_number("+7"), Some(7.0));
assert_eq!(strict_number(".5"), Some(0.5));
assert_eq!(strict_number(" 8 "), Some(8.0));
}
#[test]
fn a_partly_numeric_string_is_not_a_number() {
for input in ["12abc", "1.2.3", "0x1A", "1_000", "", "abc", "1,000", "--1"] {
assert_eq!(strict_number(input), None, "{input}");
}
}
#[test]
fn non_finite_values_are_never_extractable() {
assert!(!is_extractable(f64::NAN));
assert!(!is_extractable(f64::INFINITY));
assert!(!is_extractable(f64::NEG_INFINITY));
assert!(is_extractable(0.0));
}
#[test]
fn an_overflowing_literal_is_rejected() {
assert_eq!(strict_number("1e400"), None);
}
#[test]
fn a_typed_format_ignores_numeric_looking_text() {
let document = Value::Map(vec![Value::Number(42.0), Value::Text("7".to_string())]);
assert_eq!(collect(&document, Coercion::Typed), [42.0]);
}
#[test]
fn an_untyped_format_reads_numeric_looking_text() {
let document = Value::Map(vec![Value::Number(42.0), Value::Text("7".to_string())]);
assert_eq!(collect(&document, Coercion::Untyped), [42.0, 7.0]);
}
#[test]
fn nesting_is_walked_in_document_order() {
let document = Value::Map(vec![
Value::Number(1.0),
Value::Seq(vec![Value::Number(2.0), Value::Other, Value::Number(3.0)]),
Value::Map(vec![Value::Number(4.0)]),
]);
assert_eq!(collect(&document, Coercion::Typed), [1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn a_date_is_never_a_number() {
let document = Value::Map(vec![Value::Other, Value::Number(1.0)]);
assert_eq!(collect(&document, Coercion::Untyped), [1.0]);
}
#[test]
fn non_finite_numbers_are_dropped_by_the_walk() {
let document = Value::Seq(vec![
Value::Number(f64::INFINITY),
Value::Number(f64::NAN),
Value::Number(1.0),
]);
assert_eq!(collect(&document, Coercion::Typed), [1.0]);
}
#[test]
fn repeats_are_kept() {
let document = Value::Seq(vec![Value::Number(5.0), Value::Number(5.0)]);
assert_eq!(collect(&document, Coercion::Typed), [5.0, 5.0]);
}
}