use std::sync::LazyLock;
use regex::Regex;
use serde_json::{Value as Json, json};
use crate::row::Typed;
use crate::value::{Value, number_json};
static RANGE_NUM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$").expect("valid"));
static RANGE_ISO: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ][0-9]{2}:[0-9]{2}(?::[0-9]{2})?(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})?)?$",
)
.expect("valid")
});
#[derive(Clone, Debug, PartialEq)]
pub enum Bound {
Open,
Num(f64),
Iso(String),
}
impl Bound {
fn to_json(&self) -> Json {
match self {
Bound::Open => Json::Null,
Bound::Num(n) => number_json(*n),
Bound::Iso(s) => Json::String(s.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Range {
pub lo: Bound,
pub hi: Bound,
pub exclusive_end: bool,
}
impl Range {
#[must_use]
pub fn to_json(&self) -> Json {
json!({
"__range": true,
"lo": self.lo.to_json(),
"hi": self.hi.to_json(),
"exclusiveEnd": self.exclusive_end,
})
}
}
fn has_js_line_terminator(s: &str) -> bool {
s.contains(['\n', '\r', '\u{2028}', '\u{2029}'])
}
#[must_use]
pub fn detect_range(s: &str) -> Option<Range> {
if has_js_line_terminator(s) {
return None;
}
let at = s.find("..")?;
let lo_raw = &s[..at];
let after = &s[at + 2..];
let (dots, hi_raw) = if let Some(rest) = after.strip_prefix('.') {
(3, rest)
} else {
(2, after)
};
if lo_raw.ends_with('.') || hi_raw.starts_with('.') {
return None;
}
if lo_raw.is_empty() && hi_raw.is_empty() {
return None;
}
let present: Vec<&str> = [lo_raw, hi_raw]
.into_iter()
.filter(|b| !b.is_empty())
.collect();
let exclusive_end = dots == 3;
if present.iter().all(|b| RANGE_NUM.is_match(b)) {
let num = |b: &str| {
if b.is_empty() {
Bound::Open
} else {
Bound::Num(b.parse::<f64>().expect("RANGE_NUM strings are Rust floats"))
}
};
return Some(Range {
lo: num(lo_raw),
hi: num(hi_raw),
exclusive_end,
});
}
if present.iter().all(|b| RANGE_ISO.is_match(b)) {
let iso = |b: &str| {
if b.is_empty() {
Bound::Open
} else {
Bound::Iso(b.to_owned())
}
};
return Some(Range {
lo: iso(lo_raw),
hi: iso(hi_raw),
exclusive_end,
});
}
None
}
#[must_use]
pub fn typed_value(v: &Value) -> Typed {
match v {
Value::Null => Typed::null(),
Value::Bool(b) => Typed::bool(*b),
Value::Number(n) => Typed::number(*n),
Value::String(s) => match detect_range(s) {
Some(range) => Typed {
val_json: Some(range.to_json().to_string()),
..Typed::string(s.clone())
},
None => Typed::string(s.clone()),
},
Value::Array(_) | Value::Mapping(_) => Typed::json(v.to_json().to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn range(s: &str) -> Option<Json> {
detect_range(s).map(|r| r.to_json())
}
#[test]
fn numeric_ranges() {
assert_eq!(
range("1..5"),
Some(json!({"__range": true, "lo": 1, "hi": 5, "exclusiveEnd": false}))
);
assert_eq!(
range("1...5"),
Some(json!({"__range": true, "lo": 1, "hi": 5, "exclusiveEnd": true}))
);
assert_eq!(
range("..5"),
Some(json!({"__range": true, "lo": null, "hi": 5, "exclusiveEnd": false}))
);
assert_eq!(
range("1.."),
Some(json!({"__range": true, "lo": 1, "hi": null, "exclusiveEnd": false}))
);
assert_eq!(
range("1.5..2.5"),
Some(json!({"__range": true, "lo": 1.5, "hi": 2.5, "exclusiveEnd": false}))
);
assert_eq!(
range("-5..5"),
Some(json!({"__range": true, "lo": -5, "hi": 5, "exclusiveEnd": false}))
);
assert_eq!(
range("1e2..1E+3"),
Some(json!({"__range": true, "lo": 100, "hi": 1000, "exclusiveEnd": false}))
);
}
#[test]
fn iso_ranges_keep_strings() {
assert_eq!(
range("2026-01-01..2026-01-31"),
Some(
json!({"__range": true, "lo": "2026-01-01", "hi": "2026-01-31", "exclusiveEnd": false})
)
);
assert_eq!(
range("2026-01-01...2026-02-01"),
Some(
json!({"__range": true, "lo": "2026-01-01", "hi": "2026-02-01", "exclusiveEnd": true})
)
);
assert_eq!(
range("..2026-01-31"),
Some(json!({"__range": true, "lo": null, "hi": "2026-01-31", "exclusiveEnd": false}))
);
assert!(range("2026-01-01T10:00:00Z..2026-01-01 11:00").is_some());
assert!(range("2026-01-01T10:00:00.5+01:00..").is_some());
}
#[test]
fn non_ranges() {
for s in [
"hello",
"a..z",
"1..2026-01-01",
"1.2.3..4.5.6",
"../foo",
"1....5",
"..",
"3.14",
"",
"1..5\n",
"1.\u{2028}.5",
"\u{0661}..5",
"+1..5",
"1..5.",
".1..5",
] {
assert_eq!(range(s), None, "{s:?}");
}
}
#[test]
fn typed_values() {
assert_eq!(typed_value(&Value::Null), Typed::null());
assert_eq!(typed_value(&Value::Bool(true)), Typed::bool(true));
assert_eq!(typed_value(&Value::Number(3.0)), Typed::number(3.0));
assert_eq!(
typed_value(&Value::String("canon".into())),
Typed::string("canon")
);
let win = typed_value(&Value::String("1..5".into()));
assert_eq!(win.ty, ValueType::String);
assert_eq!(win.val_text.as_deref(), Some("1..5"));
assert_eq!(
serde_json::from_str::<Json>(win.val_json.as_deref().unwrap()).unwrap(),
json!({"__range": true, "lo": 1, "hi": 5, "exclusiveEnd": false})
);
let arr = typed_value(&Value::Array(vec![
Value::Mapping(Mapping::from(vec![("a".to_owned(), Value::Number(1.0))])),
Value::Number(f64::NAN),
]));
assert_eq!(arr.ty, ValueType::Json);
assert_eq!(arr.val_json.as_deref(), Some(r#"[{"a":1},null]"#));
}
use crate::row::ValueType;
use crate::value::Mapping;
}