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//! - `normalize(input, options?)` → JSON string of a [`NormalizeSummary`]
11//!   (always returns; `{"kind":"noMatch"}` when the prose is outside the
12//!   grammar). `options` is a JSON string: `{"language": "en"|"ru",
13//!   "numericOrder": "dayFirst"|"monthFirst", "defaultCentury": 1900}` — all
14//!   fields optional.
15
16use edtf_core::{Bound, Edtf, Relation};
17use edtf_normalize::{Language, NoMatchReason, NumericOrder, Options, Outcome, normalize_with};
18use serde::{Deserialize, Serialize};
19use wasm_bindgen::prelude::wasm_bindgen;
20
21/// The JSON shape `parse` returns; one object per valid expression.
22#[derive(Debug, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Summary {
25    /// The canonical (spec-preferred) rendering of the input.
26    pub canonical: String,
27    /// Minimum EDTF conformance level (0, 1 or 2).
28    pub level: u8,
29    /// `"date" | "datetime" | "interval" | "set"`.
30    pub kind: &'static str,
31    /// `"year" | "month" | "season" | "day" | null` (dates/datetimes only).
32    pub precision: Option<&'static str>,
33    /// Earliest calendar day: `"YYYY-MM-DD"`, `"-infinity"`, or null (unknown).
34    pub earliest: Option<String>,
35    /// Latest calendar day: `"YYYY-MM-DD"`, `"infinity"`, or null (unknown).
36    pub latest: Option<String>,
37    /// Any component marked uncertain (`?` or `%`).
38    pub uncertain: bool,
39    /// Any component marked approximate (`~` or `%`).
40    pub approximate: bool,
41    /// Any component with unspecified digits (`X`).
42    pub unspecified: bool,
43}
44
45fn bound_json(b: Bound) -> Option<String> {
46    match b {
47        Bound::Date(d) => Some(d.to_string()),
48        Bound::NegativeInfinity => Some("-infinity".into()),
49        Bound::PositiveInfinity => Some("infinity".into()),
50        Bound::Unknown => None,
51    }
52}
53
54/// Build the [`Summary`] for an input, if it is valid EDTF.
55#[must_use]
56pub fn summarize(input: &str) -> Option<Summary> {
57    let parsed = Edtf::parse(input).ok()?;
58    let bounds = parsed.bounds();
59    let (kind, precision) = match &parsed {
60        Edtf::Date(d) => ("date", Some(precision_str(d))),
61        Edtf::DateTime(dt) => ("datetime", Some(precision_str(&dt.date))),
62        Edtf::Interval(_) => ("interval", None),
63        Edtf::Set(_) => ("set", None),
64    };
65    Some(Summary {
66        canonical: parsed.to_string(),
67        level: parsed.level(),
68        kind,
69        precision,
70        earliest: bound_json(bounds.earliest),
71        latest: bound_json(bounds.latest),
72        uncertain: parsed.is_uncertain(),
73        approximate: parsed.is_approximate(),
74        unspecified: parsed.has_unspecified(),
75    })
76}
77
78fn precision_str(d: &edtf_core::Date) -> &'static str {
79    match d.precision() {
80        edtf_core::Precision::Year => "year",
81        edtf_core::Precision::Month => "month",
82        edtf_core::Precision::Season => "season",
83        edtf_core::Precision::Day => "day",
84    }
85}
86
87/// The JSON shape `relation` returns.
88///
89/// The modality of each of the six coarsened Allen relations between the
90/// two inputs, each `"impossible" | "possible" | "definite"`. Semantics:
91/// `docs/spec-notes.md` D23 (possible-completions over bounds regions;
92/// Unknown bounds are possible-everything, never definite).
93#[derive(Debug, Serialize)]
94pub struct RelationSummary {
95    /// A ends before B starts.
96    pub before: &'static str,
97    /// A starts after B ends.
98    pub after: &'static str,
99    /// Partial overlap on opposite sides.
100    pub overlaps: &'static str,
101    /// B lies within A without being equal.
102    pub contains: &'static str,
103    /// A lies within B without being equal.
104    pub within: &'static str,
105    /// A and B cover exactly the same days.
106    pub equal: &'static str,
107}
108
109/// Build the [`RelationSummary`] for two inputs, if both are valid EDTF.
110#[must_use]
111pub fn relate(a: &str, b: &str) -> Option<RelationSummary> {
112    let rel = Edtf::parse(a).ok()?.relation(&Edtf::parse(b).ok()?);
113    let m = |r: Relation| rel.modality(r).as_str();
114    Some(RelationSummary {
115        before: m(Relation::Before),
116        after: m(Relation::After),
117        overlaps: m(Relation::Overlaps),
118        contains: m(Relation::Contains),
119        within: m(Relation::Within),
120        equal: m(Relation::Equal),
121    })
122}
123
124/// The JSON shape `normalize` returns, discriminated by `kind`:
125/// `"normalized"` | `"ambiguous"` | `"noMatch"`. Semantics and the N-decision
126/// ids inside notes: `docs/normalize-notes.md`.
127#[derive(Debug, Serialize)]
128#[serde(tag = "kind", rename_all = "camelCase")]
129pub enum NormalizeSummary {
130    /// One deterministic answer.
131    #[serde(rename_all = "camelCase")]
132    Normalized {
133        /// Canonical EDTF, guaranteed valid.
134        edtf: String,
135        /// Its conformance level (0, 1 or 2).
136        level: u8,
137        /// Why the output looks the way it does.
138        notes: Vec<NoteJson>,
139    },
140    /// More than one plausible reading; the form should ask, not pick.
141    #[serde(rename_all = "camelCase")]
142    Ambiguous {
143        /// Every plausible reading, in table order.
144        interpretations: Vec<InterpretationJson>,
145    },
146    /// No answer; `reason` is the triage discriminator:
147    /// `"outOfGrammar"` (N11) | `"explicitNoDate"` (N12) |
148    /// `"impossibleDate"` (N14).
149    #[serde(rename_all = "camelCase")]
150    NoMatch {
151        /// Why nothing was produced.
152        reason: &'static str,
153        /// The governing N-decision in docs/normalize-notes.md.
154        decision: &'static str,
155    },
156}
157
158/// One note: the governing N-decision (or null) and a human-readable message.
159#[derive(Debug, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct NoteJson {
162    /// N-decision id in docs/normalize-notes.md, e.g. "N3".
163    pub decision: Option<&'static str>,
164    /// Short explanation of the mapping.
165    pub message: &'static str,
166}
167
168/// One reading of an ambiguous input.
169#[derive(Debug, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct InterpretationJson {
172    /// Canonical EDTF for this reading.
173    pub edtf: String,
174    /// Its conformance level.
175    pub level: u8,
176    /// Which reading this is ("day-month-year", "century (19XX)", …).
177    pub reading: String,
178    /// Why this reading exists.
179    pub notes: Vec<NoteJson>,
180}
181
182/// JSON options accepted by `normalize` (all fields optional). Unknown keys
183/// are rejected, matching the invalid-value behavior — a typo'd option must
184/// never silently fall back to defaults.
185#[derive(Debug, Default, Deserialize)]
186#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
187struct NormalizeOptionsJson {
188    language: Option<String>,
189    numeric_order: Option<String>,
190    default_century: Option<u16>,
191}
192
193fn parse_options(options: Option<&str>) -> Option<Options> {
194    let Some(raw) = options.filter(|s| !s.trim().is_empty()) else {
195        return Some(Options::default());
196    };
197    let parsed: NormalizeOptionsJson = serde_json::from_str(raw).ok()?;
198    let language = match parsed.language.as_deref() {
199        None | Some("en") => Language::English,
200        Some("ru") => Language::Russian,
201        Some(_) => return None,
202    };
203    let numeric_order = match parsed.numeric_order.as_deref() {
204        None => None,
205        Some("dayFirst") => Some(NumericOrder::DayFirst),
206        Some("monthFirst") => Some(NumericOrder::MonthFirst),
207        Some(_) => return None,
208    };
209    // Out-of-domain values are option errors (undefined to JS), never a
210    // fabricated noMatch for in-grammar prose.
211    if parsed.default_century.is_some_and(|c| c > 9999) {
212        return None;
213    }
214    Some(Options {
215        language,
216        numeric_order,
217        default_century: parsed.default_century,
218    })
219}
220
221fn notes_json(notes: &[edtf_normalize::Note]) -> Vec<NoteJson> {
222    notes
223        .iter()
224        .map(|n| NoteJson {
225            decision: n.decision(),
226            message: n.message(),
227        })
228        .collect()
229}
230
231/// Build the [`NormalizeSummary`] for an input. `None` only for invalid
232/// options JSON.
233#[must_use]
234pub fn normalize_summary(input: &str, options: Option<&str>) -> Option<NormalizeSummary> {
235    let opts = parse_options(options)?;
236    Some(match normalize_with(input, opts) {
237        Outcome::Normalized(n) => NormalizeSummary::Normalized {
238            level: n.value.level(),
239            notes: notes_json(&n.notes),
240            edtf: n.edtf,
241        },
242        Outcome::Ambiguous(a) => NormalizeSummary::Ambiguous {
243            interpretations: a
244                .interpretations
245                .into_iter()
246                .map(|i| InterpretationJson {
247                    level: i.value.level(),
248                    notes: notes_json(&i.notes),
249                    edtf: i.edtf,
250                    reading: i.reading,
251                })
252                .collect(),
253        },
254        Outcome::NoMatch { reason } => NormalizeSummary::NoMatch {
255            reason: match reason {
256                NoMatchReason::OutOfGrammar => "outOfGrammar",
257                NoMatchReason::ExplicitNoDate => "explicitNoDate",
258                NoMatchReason::ImpossibleDate => "impossibleDate",
259            },
260            decision: reason.decision(),
261        },
262    })
263}
264
265/// Deterministic prose-date normalization ("1980s" → 198X) as a JSON string.
266///
267/// Always returns a value for valid options (a `noMatch` object with a
268/// `reason` when nothing can be produced); `undefined` only for invalid
269/// options — malformed JSON, unknown keys, or out-of-domain values. An
270/// empty/whitespace options string means defaults. See [`NormalizeSummary`]
271/// for the object shape.
272#[wasm_bindgen]
273#[must_use]
274#[allow(
275    clippy::needless_pass_by_value,
276    reason = "wasm-bindgen passes JS strings owned, not as Option<&str>"
277)]
278pub fn normalize(input: &str, options: Option<String>) -> Option<String> {
279    let summary = normalize_summary(input, options.as_deref())?;
280    serde_json::to_string(&summary).ok()
281}
282
283/// True if `input` is valid EDTF (levels 0–2).
284#[wasm_bindgen(js_name = isValid)]
285#[must_use]
286pub fn is_valid(input: &str) -> bool {
287    edtf_core::is_valid(input)
288}
289
290/// Minimum conformance level of `input`: 0, 1 or 2; -1 if invalid.
291#[wasm_bindgen]
292pub fn level(input: &str) -> i32 {
293    edtf_core::level(input).map_or(-1, i32::from)
294}
295
296/// The canonical (spec-preferred) form of `input`, or `undefined` if invalid.
297#[wasm_bindgen]
298#[must_use]
299pub fn canonical(input: &str) -> Option<String> {
300    Some(Edtf::parse(input).ok()?.to_string())
301}
302
303/// Full parse summary as a JSON string, or `undefined` if invalid.
304/// See [`Summary`] for the object shape.
305#[wasm_bindgen]
306#[must_use]
307pub fn parse(input: &str) -> Option<String> {
308    let summary = summarize(input)?;
309    serde_json::to_string(&summary).ok()
310}
311
312/// Three-valued temporal relation between `a` and `b` as a JSON string, or
313/// `undefined` if either input is invalid. See [`RelationSummary`] for the
314/// object shape.
315#[wasm_bindgen]
316#[must_use]
317pub fn relation(a: &str, b: &str) -> Option<String> {
318    serde_json::to_string(&relate(a, b)?).ok()
319}
320
321#[cfg(test)]
322mod tests {
323    #![allow(
324        clippy::unwrap_used,
325        reason = "test code: a panic here is the failure signal, not a crash path"
326    )]
327
328    use super::*;
329
330    #[test]
331    fn summary_shape() {
332        let s = summarize("2004-06~-11").unwrap();
333        assert_eq!(s.canonical, "2004-06~-11");
334        assert_eq!(s.level, 2);
335        assert_eq!(s.kind, "date");
336        assert_eq!(s.precision, Some("day"));
337        assert_eq!(s.earliest.as_deref(), Some("2004-06-11"));
338        assert_eq!(s.latest.as_deref(), Some("2004-06-11"));
339        assert!(s.approximate);
340        assert!(!s.uncertain);
341        assert!(!s.unspecified);
342    }
343
344    #[test]
345    fn open_interval_summary() {
346        let s = summarize("1985-04-12/..").unwrap();
347        assert_eq!(s.kind, "interval");
348        assert_eq!(s.earliest.as_deref(), Some("1985-04-12"));
349        assert_eq!(s.latest.as_deref(), Some("infinity"));
350    }
351
352    #[test]
353    fn unknown_bound_is_null() {
354        let s = summarize("1986-04/").unwrap();
355        assert_eq!(s.latest, None);
356    }
357
358    #[test]
359    fn invalid_yields_none() {
360        assert!(summarize("1985-02-30").is_none());
361        assert_eq!(level("1985-02-30"), -1);
362        assert!(is_valid("1985-04-12"));
363    }
364
365    #[test]
366    fn relation_shape() {
367        let r = relate("1985~", "199X").unwrap();
368        assert_eq!(r.before, "definite");
369        assert_eq!(r.after, "impossible");
370        assert_eq!(r.equal, "impossible");
371        let j = relation("198X", "1985").unwrap();
372        assert_eq!(
373            j,
374            "{\"before\":\"possible\",\"after\":\"possible\",\
375                \"overlaps\":\"possible\",\"contains\":\"possible\",\
376                \"within\":\"possible\",\"equal\":\"possible\"}"
377        );
378        assert!(relation("junk", "1985").is_none());
379        assert!(relation("1985", "junk").is_none());
380    }
381
382    #[test]
383    fn normalize_shapes() {
384        let j = normalize("circa 1920", None).unwrap();
385        assert!(j.contains("\"kind\":\"normalized\""), "{j}");
386        assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
387        assert!(j.contains("\"level\":1"), "{j}");
388
389        let j = normalize("12/04/1985", None).unwrap();
390        assert!(j.contains("\"kind\":\"ambiguous\""), "{j}");
391        assert!(
392            j.contains("\"1985-04-12\"") && j.contains("\"1985-12-04\""),
393            "{j}"
394        );
395        assert!(j.contains("\"decision\":\"N5\""), "{j}");
396
397        let j = normalize("no idea", None).unwrap();
398        assert_eq!(
399            j,
400            "{\"kind\":\"noMatch\",\"reason\":\"outOfGrammar\",\"decision\":\"N11\"}"
401        );
402        let j = normalize("unknown", None).unwrap();
403        assert!(j.contains("\"reason\":\"explicitNoDate\""), "{j}");
404        assert!(j.contains("\"decision\":\"N12\""), "{j}");
405        let j = normalize("30/02/1985", None).unwrap();
406        assert!(j.contains("\"reason\":\"impossibleDate\""), "{j}");
407    }
408
409    #[test]
410    fn normalize_options() {
411        let ru = Some(String::from("{\"language\":\"ru\"}"));
412        let j = normalize("около 1920 г.", ru).unwrap();
413        assert!(j.contains("\"edtf\":\"1920~\""), "{j}");
414
415        let df = Some(String::from("{\"numericOrder\":\"dayFirst\"}"));
416        let j = normalize("12/04/1985", df).unwrap();
417        assert!(j.contains("\"kind\":\"normalized\""), "{j}");
418        assert!(j.contains("\"edtf\":\"1985-04-12\""), "{j}");
419
420        let dc = Some(String::from("{\"defaultCentury\":1900}"));
421        let j = normalize("the 80s", dc).unwrap();
422        assert!(j.contains("\"edtf\":\"198X\""), "{j}");
423
424        assert!(normalize("1985", Some(String::from("{\"language\":\"tlh\"}"))).is_none());
425        assert!(normalize("1985", Some(String::from("not json"))).is_none());
426        // A typo'd KEY must be an option error, not a silent default (a
427        // misspelled "lang" would otherwise silently run English tables).
428        assert!(normalize("около 1920", Some(String::from("{\"lang\":\"ru\"}"))).is_none());
429        // Out-of-domain defaultCentury is an option error, not a noMatch.
430        assert!(normalize("the 80s", Some(String::from("{\"defaultCentury\":12345}"))).is_none());
431    }
432
433    #[test]
434    fn json_is_camel_case() {
435        let j = parse("1985").unwrap();
436        assert!(j.contains("\"canonical\":\"1985\""));
437        assert!(!j.contains("\"level\":1"), "1985 is level 0: {j}");
438        assert!(j.contains("\"level\":0"));
439    }
440}