Skip to main content

shape/
coerce.rs

1//! Coerce: the value converted to the node's kind where the conversion is
2//! unambiguous, before the type check; and the strict ISO 8601 date-time
3//! both implementations parse identically.
4
5use crate::context::{State, Update};
6use crate::node::{Kind, Validator};
7use crate::value::{js_number, Value};
8use regex::Regex;
9use std::sync::{Arc, OnceLock};
10
11/// Strict ISO 8601 / RFC 3339 date-time. Calendar ranges are checked so
12/// that 2024-02-30 is rejected rather than rolled over into March.
13fn iso_re() -> &'static Regex {
14    static RE: OnceLock<Regex> = OnceLock::new();
15    RE.get_or_init(|| {
16        Regex::new(
17            r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$",
18        )
19        .unwrap()
20    })
21}
22
23struct IsoParts {
24    y: i64,
25    mo: i64,
26    d: i64,
27    h: i64,
28    mi: i64,
29    s: i64,
30    ms: i64,
31    /// The offset to subtract, in minutes.
32    off: i64,
33}
34
35fn iso_parts(s: &str) -> Option<IsoParts> {
36    let m = iso_re().captures(s)?;
37    let num = |i: usize| {
38        m.get(i)
39            .map(|x| x.as_str().parse::<i64>().unwrap())
40            .unwrap_or(0)
41    };
42    let (y, mo, d, h, mi, sec) = (num(1), num(2), num(3), num(4), num(5), num(6));
43    if !(1..=12).contains(&mo) || h > 23 || mi > 59 || sec > 59 {
44        return None;
45    }
46    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
47    let mut days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][(mo - 1) as usize];
48    if leap && mo == 2 {
49        days = 29;
50    }
51    if d < 1 || days < d {
52        return None;
53    }
54    // The fraction is read to milliseconds, as a JavaScript Date keeps it.
55    let ms = m
56        .get(7)
57        .map(|f| {
58            let digits: String = f.as_str().chars().take(3).collect();
59            let padded = format!("{:0<3}", digits);
60            padded.parse::<i64>().unwrap()
61        })
62        .unwrap_or(0);
63    let zone = m.get(8).unwrap().as_str();
64    let off = if zone == "Z" {
65        0
66    } else {
67        let oh: i64 = zone[1..3].parse().unwrap();
68        let om: i64 = zone[4..6].parse().unwrap();
69        if oh > 23 || om > 59 {
70            return None;
71        }
72        let total = oh * 60 + om;
73        if zone.starts_with('-') {
74            -total
75        } else {
76            total
77        }
78    };
79    Some(IsoParts {
80        y,
81        mo,
82        d,
83        h,
84        mi,
85        s: sec,
86        ms,
87        off,
88    })
89}
90
91pub(crate) fn is_iso_date_time(s: &str) -> bool {
92    iso_parts(s).is_some()
93}
94
95/// Days since the epoch of a civil date (Howard Hinnant's algorithm).
96fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
97    let y = if m <= 2 { y - 1 } else { y };
98    let era = if y >= 0 { y } else { y - 399 } / 400;
99    let yoe = y - era * 400;
100    let mp = (m + 9) % 12;
101    let doy = (153 * mp + 2) / 5 + d - 1;
102    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
103    era * 146097 + doe - 719468
104}
105
106/// A strict ISO 8601 date-time as milliseconds since the epoch.
107pub(crate) fn parse_iso_date_time(s: &str) -> Option<i64> {
108    let p = iso_parts(s)?;
109    let days = days_from_civil(p.y, p.mo, p.d);
110    let secs = days * 86400 + p.h * 3600 + p.mi * 60 + p.s - p.off * 60;
111    Some(secs * 1000 + p.ms)
112}
113
114/// Decimal numeric strings only: no hex, no Infinity, nothing JavaScript's
115/// `Number()` would accept that a strict parse would not.
116fn numeric_re() -> &'static Regex {
117    static RE: OnceLock<Regex> = OnceLock::new();
118    RE.get_or_init(|| Regex::new(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$").unwrap())
119}
120
121/// The value a Coerce node converts `val` to for kind `k`, or None to leave
122/// it alone and let the type check report it.
123pub(crate) fn coerce_to(k: Kind, val: &Value) -> Option<Value> {
124    match k {
125        Kind::Number | Kind::Integer => match val {
126            Value::Str(v) => {
127                let s = v.trim();
128                if numeric_re().is_match(s) {
129                    return s
130                        .parse::<f64>()
131                        .ok()
132                        .filter(|f| f.is_finite())
133                        .map(Value::Num);
134                }
135                None
136            }
137            Value::Bool(b) => Some(Value::Num(if *b { 1.0 } else { 0.0 })),
138            _ => None,
139        },
140        Kind::String => match val {
141            Value::Bool(b) => Some(Value::Str(b.to_string())),
142            Value::Num(f) if f.is_finite() => Some(Value::Str(js_number(*f))),
143            _ => None,
144        },
145        Kind::Boolean => match val {
146            Value::Str(s) => match s.trim().to_lowercase().as_str() {
147                "true" | "1" => Some(Value::Bool(true)),
148                "false" | "0" => Some(Value::Bool(false)),
149                _ => None,
150            },
151            Value::Num(f) if *f == 1.0 => Some(Value::Bool(true)),
152            Value::Num(f) if *f == 0.0 => Some(Value::Bool(false)),
153            _ => None,
154        },
155        Kind::Date => match val {
156            Value::Str(s) => parse_iso_date_time(s.trim()).map(Value::Date),
157            // A JavaScript Date(n) truncates the time value toward zero.
158            Value::Num(f) if f.is_finite() => Some(Value::Date(f.trunc() as i64)),
159            _ => None,
160        },
161        _ => None,
162    }
163}
164
165/// The Coerce check runs ahead of any bound, so a bound sees the converted
166/// value. It has no rendering: Coerce is not part of the shape's text.
167pub(crate) fn coerce_validator() -> Validator {
168    Validator {
169        name: "Coerce".to_string(),
170        func: Arc::new(|state: &mut State<'_>, update: &mut Update| {
171            if let Some(c) = coerce_to(state.node.kind, state.value) {
172                update.val = Some(c);
173            }
174            true
175        }),
176        args: Vec::new(),
177        suffix: None,
178        inner: None,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn iso_date_times() {
188        assert_eq!(parse_iso_date_time("1970-01-01T00:00:00Z"), Some(0));
189        assert_eq!(
190            parse_iso_date_time("2020-01-01T00:00:00Z"),
191            Some(1577836800000)
192        );
193        assert_eq!(
194            parse_iso_date_time("2020-01-01T12:30:00.5+02:00"),
195            Some(1577874600500)
196        );
197        assert_eq!(
198            parse_iso_date_time("2020-01-01T00:00:00.123456-01:30"),
199            Some(1577836800123 + 90 * 60 * 1000)
200        );
201        assert_eq!(parse_iso_date_time("1969-12-31T23:59:59Z"), Some(-1000));
202        assert_eq!(
203            parse_iso_date_time("0001-03-01T00:00:00Z"),
204            Some(-719103 * 86400000)
205        );
206        assert!(parse_iso_date_time("2020-02-30T00:00:00Z").is_none());
207        assert!(parse_iso_date_time("2020-02-29T00:00:00Z").is_some());
208        assert!(parse_iso_date_time("2021-02-29T00:00:00Z").is_none());
209        assert!(parse_iso_date_time("2020-13-01T00:00:00Z").is_none());
210        assert!(parse_iso_date_time("2020-01-01T24:00:00Z").is_none());
211        assert!(parse_iso_date_time("2020-01-01T00:00:00+24:00").is_none());
212        assert!(parse_iso_date_time("2020-01-01T00:00:00+01:60").is_none());
213        assert!(parse_iso_date_time("2020-01-01").is_none());
214        assert!(is_iso_date_time("2000-02-29T00:00:00Z"));
215        assert!(!is_iso_date_time("1900-02-29T00:00:00Z"));
216    }
217
218    #[test]
219    fn conversions() {
220        assert_eq!(
221            coerce_to(Kind::Number, &Value::from(" 5 ")),
222            Some(Value::Num(5.0))
223        );
224        assert_eq!(coerce_to(Kind::Number, &Value::from("0x10")), None);
225        assert_eq!(coerce_to(Kind::Number, &Value::from("1e999")), None);
226        assert_eq!(
227            coerce_to(Kind::Integer, &Value::from(true)),
228            Some(Value::Num(1.0))
229        );
230        assert_eq!(
231            coerce_to(Kind::Number, &Value::from(false)),
232            Some(Value::Num(0.0))
233        );
234        assert_eq!(coerce_to(Kind::Number, &Value::Null), None);
235        assert_eq!(
236            coerce_to(Kind::String, &Value::from(true)),
237            Some(Value::from("true"))
238        );
239        assert_eq!(
240            coerce_to(Kind::String, &Value::from(1.5)),
241            Some(Value::from("1.5"))
242        );
243        assert_eq!(coerce_to(Kind::String, &Value::Num(f64::NAN)), None);
244        assert_eq!(coerce_to(Kind::String, &Value::Null), None);
245        assert_eq!(
246            coerce_to(Kind::Boolean, &Value::from(" TRUE ")),
247            Some(Value::Bool(true))
248        );
249        assert_eq!(
250            coerce_to(Kind::Boolean, &Value::from("0")),
251            Some(Value::Bool(false))
252        );
253        assert_eq!(coerce_to(Kind::Boolean, &Value::from("yes")), None);
254        assert_eq!(
255            coerce_to(Kind::Boolean, &Value::from(1)),
256            Some(Value::Bool(true))
257        );
258        assert_eq!(
259            coerce_to(Kind::Boolean, &Value::from(0)),
260            Some(Value::Bool(false))
261        );
262        assert_eq!(coerce_to(Kind::Boolean, &Value::from(2)), None);
263        assert_eq!(coerce_to(Kind::Boolean, &Value::Null), None);
264        assert_eq!(
265            coerce_to(Kind::Date, &Value::from("2020-01-01T00:00:00Z")),
266            Some(Value::Date(1577836800000))
267        );
268        assert_eq!(coerce_to(Kind::Date, &Value::from("nope")), None);
269        assert_eq!(
270            coerce_to(Kind::Date, &Value::from(1.9)),
271            Some(Value::Date(1))
272        );
273        assert_eq!(coerce_to(Kind::Date, &Value::Num(f64::INFINITY)), None);
274        assert_eq!(coerce_to(Kind::Date, &Value::Null), None);
275        assert_eq!(coerce_to(Kind::Any, &Value::from("1")), None);
276    }
277}