Skip to main content

chrono_node/core/
json.rs

1//! The shared JSON shape of requests and results (see `harness/lib/serialize.js` and
2//! `tests/golden/README.md`). Used by the golden runner and the `chrono-rs-json` CLI.
3
4use serde_json::{json, Map, Value};
5
6use super::components::Component;
7use super::components::{ComponentMap, ParsingComponents};
8use super::context::ParsingOption;
9use super::js_date::JsDate;
10use super::reference::{ParsingReference, ReferenceInput, ReferenceWithTimezone};
11use super::result::ParsingResult;
12use super::timezone::{TimezoneInput, TimezoneSpec};
13
14/// Byte offset -> JavaScript string index (UTF-16 code units).
15pub fn utf16_index(text: &str, byte_index: usize) -> usize {
16    text.get(..byte_index).map_or(0, |prefix| prefix.encode_utf16().count())
17}
18
19fn component_map_json(map: &ComponentMap) -> Value {
20    let mut obj = Map::new();
21    for (c, v) in map.iter() {
22        obj.insert(c.name().to_string(), json!(v));
23    }
24    Value::Object(obj)
25}
26
27pub fn components_to_json(c: &ParsingComponents) -> Value {
28    let mut tags: Vec<&str> = c.tags().iter().map(String::as_str).collect();
29    tags.sort_unstable();
30    json!({
31        "known": component_map_json(c.known_values()),
32        "implied": component_map_json(c.implied_values()),
33        "date": c.date().to_iso_string(),
34        "tags": tags,
35    })
36}
37
38/// One result; `text` is the full parsed text, to convert `index` to UTF-16.
39pub fn result_to_json(text: &str, r: &ParsingResult) -> Value {
40    json!({
41        "index": utf16_index(text, r.index),
42        "text": r.text,
43        "start": components_to_json(&r.start),
44        "end": r.end.as_ref().map(components_to_json),
45    })
46}
47
48pub fn results_to_json(text: &str, results: &[ParsingResult]) -> Value {
49    Value::Array(results.iter().map(|r| result_to_json(text, r)).collect())
50}
51
52/// JavaScript string index (UTF-16 code units) -> byte offset, or `None` inside a character.
53pub fn byte_index(text: &str, utf16: usize) -> Option<usize> {
54    let mut units = 0;
55    for (b, ch) in text.char_indices() {
56        if units == utf16 {
57            return Some(b);
58        }
59        units += ch.len_utf16();
60        if units > utf16 {
61            return None;
62        }
63    }
64    (units == utf16).then_some(text.len())
65}
66
67fn component_map_from_json(v: Option<&Value>) -> Result<ComponentMap, String> {
68    let mut map = ComponentMap::new();
69    let obj = v.and_then(Value::as_object).ok_or("components must be an object")?;
70    for (k, v) in obj {
71        let c = Component::from_name(k).ok_or_else(|| format!("unknown component {k}"))?;
72        map.set(
73            c,
74            v.as_i64().ok_or_else(|| format!("component {k} must be an integer"))?,
75        );
76    }
77    Ok(map)
78}
79
80fn components_from_json(reference: ReferenceWithTimezone, v: &Value) -> Result<ParsingComponents, String> {
81    let tags = v
82        .get("tags")
83        .and_then(Value::as_array)
84        .ok_or("tags must be an array")?
85        .iter()
86        .map(|t| t.as_str().map(str::to_string).ok_or("tags must be strings"))
87        .collect::<Result<Vec<_>, _>>()?;
88    Ok(ParsingComponents::from_parts(
89        reference,
90        component_map_from_json(v.get("known"))?,
91        component_map_from_json(v.get("implied"))?,
92        tags,
93    ))
94}
95
96/// The inverse of [`result_to_json`], for a result in the context of `text` and `reference`.
97/// Component key order isn't carried (it doesn't affect any output, only iteration order).
98pub fn result_from_json(text: &str, reference: ReferenceWithTimezone, v: &Value) -> Result<ParsingResult, String> {
99    let index = v.get("index").and_then(Value::as_u64).ok_or("missing index")? as usize;
100    let index = byte_index(text, index).ok_or_else(|| format!("index {index} isn't a character boundary"))?;
101    let matched = v.get("text").and_then(Value::as_str).ok_or("missing text")?;
102    // Refiners slice the source at the result's end: it must be the source's own text there.
103    let end = index.checked_add(matched.len()).ok_or("text too long")?;
104    if text.get(index..end) != Some(matched) {
105        return Err(format!("text {matched:?} isn't the source text at index {index}"));
106    }
107    let start = components_from_json(reference, v.get("start").ok_or("missing start")?)?;
108    let end = match v.get("end") {
109        None | Some(Value::Null) => None,
110        Some(e) => Some(components_from_json(reference, e)?),
111    };
112    Ok(ParsingResult::new(reference, index, matched, Some(start), end))
113}
114
115/// A parse request in the golden-case / harness shape.
116#[derive(Clone, Debug)]
117pub struct Request {
118    pub text: String,
119    pub config: String,
120    pub reference: ReferenceInput,
121    /// What "now" was when the case was recorded (the resolved reference instant).
122    pub now: JsDate,
123    pub option: ParsingOption,
124}
125
126/// Parses `{text, config, reference, options}`. Unknown option keys are an error (fail closed).
127pub fn parse_request(v: &Value) -> Result<Request, String> {
128    let text = v.get("text").and_then(Value::as_str).ok_or("missing text")?.to_string();
129    let config = v
130        .get("config")
131        .and_then(Value::as_str)
132        .unwrap_or("en.casual")
133        .to_string();
134    if let Some(tz) = v.get("system_timezone").and_then(Value::as_str) {
135        if tz != "UTC" {
136            return Err(format!("system_timezone must be UTC, got {tz}"));
137        }
138    }
139
140    let r = v.get("reference").ok_or("missing reference")?;
141    let kind = r.get("kind").and_then(Value::as_str).ok_or("missing reference.kind")?;
142    let instant_str = r
143        .get("instant")
144        .and_then(Value::as_str)
145        .ok_or("missing reference.instant")?;
146    let instant = JsDate::parse_iso(instant_str).ok_or_else(|| format!("bad instant {instant_str}"))?;
147    let timezone = match r.get("timezone_input") {
148        None | Some(Value::Null) => None,
149        Some(Value::String(s)) => Some(TimezoneInput::Abbr(s.clone())),
150        Some(Value::Number(n)) => Some(TimezoneInput::Minutes(
151            n.as_i64().ok_or("timezone_input must be an integer")?,
152        )),
153        Some(other) => return Err(format!("bad timezone_input {other}")),
154    };
155    let reference = match kind {
156        "none" => ReferenceInput::Now,
157        "date" => ReferenceInput::Date(instant),
158        "object" => ReferenceInput::Reference(ParsingReference {
159            instant: Some(instant),
160            timezone,
161        }),
162        other => return Err(format!("bad reference.kind {other}")),
163    };
164
165    let mut option = ParsingOption::default();
166    if let Some(opts) = v.get("options").and_then(Value::as_object) {
167        for (key, value) in opts {
168            match key.as_str() {
169                "forwardDate" => option.forward_date = value.as_bool().ok_or("forwardDate must be a boolean")?,
170                "timezones" => {
171                    for (abbr, offset) in value.as_object().ok_or("timezones must be an object")? {
172                        let minutes = offset.as_i64().ok_or("timezone overrides must be integer minutes")?;
173                        option.timezones.insert(abbr.clone(), TimezoneSpec::Fixed(minutes));
174                    }
175                }
176                other => return Err(format!("unsupported option {other}")),
177            }
178        }
179    }
180
181    Ok(Request {
182        text,
183        config,
184        reference,
185        now: instant,
186        option,
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn utf16_indexes() {
196        let text = "😀 é now";
197        assert_eq!(utf16_index(text, text.find("now").unwrap()), 5);
198    }
199
200    #[test]
201    fn byte_indexes_invert_utf16_indexes() {
202        let text = "😀 é now";
203        for (b, _) in text.char_indices().chain([(text.len(), ' ')]) {
204            assert_eq!(byte_index(text, utf16_index(text, b)), Some(b));
205        }
206        assert_eq!(byte_index(text, 1), None); // inside the surrogate pair
207        assert_eq!(byte_index(text, 99), None);
208    }
209
210    #[test]
211    fn results_round_trip_through_json() {
212        let text = "до 😀 завтра";
213        let reference = ReferenceWithTimezone::from_input(
214            &ReferenceInput::Date(JsDate::parse_iso("2012-08-10T12:00:00.000Z").unwrap()),
215            &Default::default(),
216            JsDate::parse_iso("2012-08-10T12:00:00.000Z").unwrap(),
217        );
218        let mut start = ParsingComponents::new(reference, &[(Component::Day, 11)]);
219        start.add_tag("casualReference/tomorrow");
220        let end = ParsingComponents::new(reference, &[(Component::Hour, 9)]);
221        let r = ParsingResult::new(
222            reference,
223            text.find("завтра").unwrap(),
224            "завтра",
225            Some(start),
226            Some(end),
227        );
228        let v = result_to_json(text, &r);
229        // Component key order isn't carried (JSON objects here are sorted); everything else is.
230        let back = result_from_json(text, reference, &v).unwrap();
231        assert_eq!(result_to_json(text, &back), v);
232        assert_eq!((back.index, back.start.tags()), (r.index, r.start.tags()));
233        assert!(result_from_json(
234            text,
235            reference,
236            &json!({ "index": 4, "text": "x", "start": v["start"] })
237        )
238        .is_err());
239        // Matched text past the source's end, or ending inside a character: an error, not a panic.
240        let at = |index: usize, t: &str| json!({ "index": index, "text": t, "start": v["start"], "end": null });
241        assert!(result_from_json("a", reference, &at(0, "aa")).is_err());
242        assert!(result_from_json("éa", reference, &at(0, "e")).is_err());
243        assert!(result_from_json(text, reference, &at(9, "завтр")).is_err());
244    }
245
246    #[test]
247    fn request_roundtrip() {
248        let req = parse_request(&json!({
249            "text": "Friday at 4pm",
250            "config": "en.casual",
251            "reference": {"kind": "object", "instant": "2021-06-09T12:00:00.000Z", "timezone_input": "CDT", "timezone_offset": -300},
252            "system_timezone": "UTC",
253            "options": {"forwardDate": true, "timezones": {"XYZ": 60}},
254        }))
255        .unwrap();
256        assert!(req.option.forward_date);
257        assert_eq!(req.option.timezones.get("XYZ"), Some(&TimezoneSpec::Fixed(60)));
258        assert!(matches!(req.reference, ReferenceInput::Reference(_)));
259        assert!(parse_request(&json!({"text": "x", "reference": {"kind": "none", "instant": "2012-08-10T12:00:00.000Z"}, "options": {"debug": true}})).is_err());
260    }
261}