Skip to main content

edtf_wasm/
lib.rs

1//! WebAssembly bindings for [`edtf_core`] — the same validator the database
2//! runs, compiled for JavaScript. The app imports this in place of edtf.js.
3//!
4//! Exported API (JS names):
5//! - `isValid(input)` → `boolean`
6//! - `level(input)` → `0 | 1 | 2 | -1` (-1 = invalid)
7//! - `canonical(input)` → `string | undefined` (spec-preferred form)
8//! - `parse(input)` → JSON string of a [`Summary`] or `undefined`
9//! - `relation(a, b)` → JSON string of a [`RelationSummary`] or `undefined`
10
11use edtf_core::{Bound, Edtf, Relation};
12use serde::Serialize;
13use wasm_bindgen::prelude::wasm_bindgen;
14
15/// The JSON shape `parse` returns; one object per valid expression.
16#[derive(Debug, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Summary {
19    /// The canonical (spec-preferred) rendering of the input.
20    pub canonical: String,
21    /// Minimum EDTF conformance level (0, 1 or 2).
22    pub level: u8,
23    /// `"date" | "datetime" | "interval" | "set"`.
24    pub kind: &'static str,
25    /// `"year" | "month" | "season" | "day" | null` (dates/datetimes only).
26    pub precision: Option<&'static str>,
27    /// Earliest calendar day: `"YYYY-MM-DD"`, `"-infinity"`, or null (unknown).
28    pub earliest: Option<String>,
29    /// Latest calendar day: `"YYYY-MM-DD"`, `"infinity"`, or null (unknown).
30    pub latest: Option<String>,
31    /// Any component marked uncertain (`?` or `%`).
32    pub uncertain: bool,
33    /// Any component marked approximate (`~` or `%`).
34    pub approximate: bool,
35    /// Any component with unspecified digits (`X`).
36    pub unspecified: bool,
37}
38
39fn bound_json(b: Bound) -> Option<String> {
40    match b {
41        Bound::Date(d) => Some(d.to_string()),
42        Bound::NegativeInfinity => Some("-infinity".into()),
43        Bound::PositiveInfinity => Some("infinity".into()),
44        Bound::Unknown => None,
45    }
46}
47
48/// Build the [`Summary`] for an input, if it is valid EDTF.
49pub fn summarize(input: &str) -> Option<Summary> {
50    let parsed = Edtf::parse(input).ok()?;
51    let bounds = parsed.bounds();
52    let (kind, precision) = match &parsed {
53        Edtf::Date(d) => ("date", Some(precision_str(d))),
54        Edtf::DateTime(dt) => ("datetime", Some(precision_str(&dt.date))),
55        Edtf::Interval(_) => ("interval", None),
56        Edtf::Set(_) => ("set", None),
57    };
58    Some(Summary {
59        canonical: parsed.to_string(),
60        level: parsed.level(),
61        kind,
62        precision,
63        earliest: bound_json(bounds.earliest),
64        latest: bound_json(bounds.latest),
65        uncertain: parsed.is_uncertain(),
66        approximate: parsed.is_approximate(),
67        unspecified: parsed.has_unspecified(),
68    })
69}
70
71fn precision_str(d: &edtf_core::Date) -> &'static str {
72    match d.precision() {
73        edtf_core::Precision::Year => "year",
74        edtf_core::Precision::Month => "month",
75        edtf_core::Precision::Season => "season",
76        edtf_core::Precision::Day => "day",
77    }
78}
79
80/// The JSON shape `relation` returns: the modality of each of the six
81/// coarsened Allen relations between the two inputs, each
82/// `"impossible" | "possible" | "definite"`. Semantics: `docs/spec-notes.md`
83/// D23 (possible-completions over bounds regions; Unknown bounds are
84/// possible-everything, never definite).
85#[derive(Debug, Serialize)]
86pub struct RelationSummary {
87    /// A ends before B starts.
88    pub before: &'static str,
89    /// A starts after B ends.
90    pub after: &'static str,
91    /// Partial overlap on opposite sides.
92    pub overlaps: &'static str,
93    /// B lies within A without being equal.
94    pub contains: &'static str,
95    /// A lies within B without being equal.
96    pub within: &'static str,
97    /// A and B cover exactly the same days.
98    pub equal: &'static str,
99}
100
101/// Build the [`RelationSummary`] for two inputs, if both are valid EDTF.
102pub fn relate(a: &str, b: &str) -> Option<RelationSummary> {
103    let rel = Edtf::parse(a).ok()?.relation(&Edtf::parse(b).ok()?);
104    let m = |r: Relation| rel.modality(r).as_str();
105    Some(RelationSummary {
106        before: m(Relation::Before),
107        after: m(Relation::After),
108        overlaps: m(Relation::Overlaps),
109        contains: m(Relation::Contains),
110        within: m(Relation::Within),
111        equal: m(Relation::Equal),
112    })
113}
114
115/// True if `input` is valid EDTF (levels 0–2).
116#[wasm_bindgen(js_name = isValid)]
117pub fn is_valid(input: &str) -> bool {
118    edtf_core::is_valid(input)
119}
120
121/// Minimum conformance level of `input`: 0, 1 or 2; -1 if invalid.
122#[wasm_bindgen]
123pub fn level(input: &str) -> i32 {
124    edtf_core::level(input).map_or(-1, i32::from)
125}
126
127/// The canonical (spec-preferred) form of `input`, or `undefined` if invalid.
128#[wasm_bindgen]
129pub fn canonical(input: &str) -> Option<String> {
130    Some(Edtf::parse(input).ok()?.to_string())
131}
132
133/// Full parse summary as a JSON string, or `undefined` if invalid.
134/// See [`Summary`] for the object shape.
135#[wasm_bindgen]
136pub fn parse(input: &str) -> Option<String> {
137    let summary = summarize(input)?;
138    serde_json::to_string(&summary).ok()
139}
140
141/// Three-valued temporal relation between `a` and `b` as a JSON string, or
142/// `undefined` if either input is invalid. See [`RelationSummary`] for the
143/// object shape.
144#[wasm_bindgen]
145pub fn relation(a: &str, b: &str) -> Option<String> {
146    serde_json::to_string(&relate(a, b)?).ok()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn summary_shape() {
155        let s = summarize("2004-06~-11").unwrap();
156        assert_eq!(s.canonical, "2004-06~-11");
157        assert_eq!(s.level, 2);
158        assert_eq!(s.kind, "date");
159        assert_eq!(s.precision, Some("day"));
160        assert_eq!(s.earliest.as_deref(), Some("2004-06-11"));
161        assert_eq!(s.latest.as_deref(), Some("2004-06-11"));
162        assert!(s.approximate);
163        assert!(!s.uncertain);
164        assert!(!s.unspecified);
165    }
166
167    #[test]
168    fn open_interval_summary() {
169        let s = summarize("1985-04-12/..").unwrap();
170        assert_eq!(s.kind, "interval");
171        assert_eq!(s.earliest.as_deref(), Some("1985-04-12"));
172        assert_eq!(s.latest.as_deref(), Some("infinity"));
173    }
174
175    #[test]
176    fn unknown_bound_is_null() {
177        let s = summarize("1986-04/").unwrap();
178        assert_eq!(s.latest, None);
179    }
180
181    #[test]
182    fn invalid_yields_none() {
183        assert!(summarize("1985-02-30").is_none());
184        assert_eq!(level("1985-02-30"), -1);
185        assert!(is_valid("1985-04-12"));
186    }
187
188    #[test]
189    fn relation_shape() {
190        let r = relate("1985~", "199X").unwrap();
191        assert_eq!(r.before, "definite");
192        assert_eq!(r.after, "impossible");
193        assert_eq!(r.equal, "impossible");
194        let j = relation("198X", "1985").unwrap();
195        assert_eq!(
196            j,
197            "{\"before\":\"possible\",\"after\":\"possible\",\
198                \"overlaps\":\"possible\",\"contains\":\"possible\",\
199                \"within\":\"possible\",\"equal\":\"possible\"}"
200        );
201        assert!(relation("junk", "1985").is_none());
202        assert!(relation("1985", "junk").is_none());
203    }
204
205    #[test]
206    fn json_is_camel_case() {
207        let j = parse("1985").unwrap();
208        assert!(j.contains("\"canonical\":\"1985\""));
209        assert!(!j.contains("\"level\":1"), "1985 is level 0: {j}");
210        assert!(j.contains("\"level\":0"));
211    }
212}