Skip to main content

omnist/formats/
mod.rs

1//! Codecs over the canonical Document model. Ported from
2//! `~/dev/omnist/omnist/formats.py`; each format's reader parses text into
3//! [`crate::document::Doc`] and its writer projects a `Doc` back to text.
4//!
5//! Unlike [`crate::oml`] (omnist's own format, always lossless), JSON/YAML/
6//! TOML/XML can each fail to hold some value losslessly. Writing is
7//! **lenient by default**: the writer adjusts the value and records the
8//! change in a [`crate::report::WriteReport`]; `strict = true` raises
9//! [`crate::error::WriteError`] (carrying the report) instead. See
10//! [`crate::report`].
11//!
12//! This issue (#16) added the first of the four: [`json`]. Issue #18 added
13//! [`yaml`]; issue #20 added [`toml`]; issue #22 adds [`xml`], the last and
14//! structurally different one -- see `xml.rs`'s own doc comment.
15
16pub(crate) mod float_fmt;
17pub(crate) mod int_cap;
18pub mod json;
19pub(crate) mod string_escape;
20pub(crate) mod textpos;
21pub mod toml;
22pub mod xml;
23pub mod yaml;
24
25use std::borrow::Cow;
26
27use crate::document::{Doc, Value};
28use crate::error::OmnistError;
29use crate::report::WriteReport;
30
31/// A temporal leaf's text as every writer spells it (spec E-37): the seconds
32/// field is always written (`12:00` -> `12:00:00`; an omitted field is `00`)
33/// and a datetime's date/time separator is `T`. Nothing else changes: the
34/// offset, every fractional digit and the clock reading are kept. `datetime`
35/// selects the date-time shape; a `date` is passed through [`Cow::Borrowed`]
36/// by the callers and never reaches this function.
37pub(crate) fn temporal_text(s: &str, datetime: bool) -> Cow<'_, str> {
38    trim_exact_fraction(spell_temporal(s, datetime))
39}
40
41/// The Document model holds a fractional second of at most six digits (the
42/// OML grammar's `1*6DIGIT`). Digits beyond the sixth are insignificant when
43/// they are all zeros: they are dropped, the value is unchanged.
44fn trim_exact_fraction(text: Cow<'_, str>) -> Cow<'_, str> {
45    let Some(dot) = text.find('.') else {
46        return text;
47    };
48    let digits = text[dot + 1..]
49        .bytes()
50        .take_while(u8::is_ascii_digit)
51        .count();
52    if digits <= 6 || fraction_inexact(&text) {
53        return text;
54    }
55    let mut out = text.into_owned();
56    out.replace_range(dot + 7..dot + 1 + digits, "");
57    Cow::Owned(out)
58}
59
60/// Whether a temporal's fractional second has a non-zero digit beyond the
61/// sixth: such a value cannot be written as a six-digit literal without
62/// altering it, so a writer stringifies it with
63/// `format.temporal-stringified` (E-37 rule 4) instead.
64pub(crate) fn fraction_inexact(s: &str) -> bool {
65    let Some(dot) = s.find('.') else {
66        return false;
67    };
68    let digits: &str = &s[dot + 1..];
69    let n = digits.bytes().take_while(u8::is_ascii_digit).count();
70    n > 6 && digits.as_bytes()[6..n].iter().any(|&b| b != b'0')
71}
72
73fn spell_temporal(s: &str, datetime: bool) -> Cow<'_, str> {
74    let bytes = s.as_bytes();
75    let start = if datetime { 11 } else { 0 };
76    let clock_ok = bytes.len() >= start + 5
77        && bytes[start + 2] == b':'
78        && [0, 1, 3, 4]
79            .iter()
80            .all(|&i| bytes[start + i].is_ascii_digit());
81    let missing_seconds = clock_ok && bytes.get(start + 5) != Some(&b':');
82    let spaced = datetime && bytes.len() > 10 && bytes[10] == b' ';
83    if !missing_seconds && !spaced {
84        return Cow::Borrowed(s);
85    }
86    let mut out = s.to_string();
87    if missing_seconds {
88        out.insert_str(start + 5, ":00");
89    }
90    if spaced {
91        out.replace_range(10..11, "T");
92    }
93    Cow::Owned(out)
94}
95
96/// The (read, write, check) contract every builtin format codec already
97/// implements as a naming convention -- expressed once (issue #52).
98///
99/// `read`/`write`/`check` intentionally match the registry's
100/// [`crate::registry::ReadFn`]/[`crate::registry::WriteFn`]/
101/// [`crate::registry::CheckFn`] signatures exactly: no `strict`, no
102/// `report`, no format-specific options (`write_json`'s `indent`,
103/// `write_oml`'s `RawNode`/`indent` shape). Each format's richer public
104/// `read_X`/`write_X(doc, ..., strict, report)`/`check_X` functions are
105/// unchanged and remain the actual public API -- a `Codec` impl is thin
106/// `pub(crate)` plumbing that calls them with the registry's documented
107/// defaults (`indent: None`, `strict: false`, no report requested; OML's
108/// `Doc::from_raw`/`to_raw` bridging), exactly what the hand-written
109/// wrapper closures in `registry::builtins` did before this issue -- see
110/// `registry.rs`'s module doc for why those defaults are the right ones
111/// and why the registry's signatures are simpler than the public
112/// functions'.
113///
114/// A richer trait sketch (scan/emit split, `strict`/`report`-aware
115/// `write`) was considered and rejected: `write_json` needs `strict` to
116/// pick lenient vs. strict *content* (NaN/Infinity substitution), not just
117/// whether `finish_write` raises, so a `strict`-unaware `emit` provided
118/// method would be wrong for JSON specifically and each impl would have to
119/// override `write` anyway -- collapsing the supposed savings. This
120/// leaner shape captures the actual duplication (the registry's adapter
121/// closures) without forcing an artificial decomposition that fights each
122/// format's real differences.
123pub(crate) trait Codec: 'static {
124    /// The name this codec is registered under (`"json"`, `"yaml"`, ...).
125    const NAME: &'static str;
126
127    fn read(text: &str) -> Result<Doc, OmnistError>;
128    fn write(doc: &Doc) -> Result<String, OmnistError>;
129    fn check(doc: &Doc) -> WriteReport;
130
131    /// Build this codec's [`crate::registry::Format`] entry -- the
132    /// wrapper-closure boilerplate `registry::builtins` used to hand-write
133    /// once per format, now written once here instead.
134    fn format() -> crate::registry::Format {
135        crate::registry::Format::new(Self::NAME, Self::read, Self::write).with_check(Self::check)
136    }
137}
138
139/// One position `visit_grouped` reaches, passed to its callback alongside
140/// the current path. Kept as a single enum (rather than two separate
141/// closures) so callers only need one `&mut` capture of their accumulator
142/// (e.g. a `WriteReport`) -- two closures both borrowing the same
143/// `&mut WriteReport` for the whole walk don't borrow-check, since both
144/// would be alive simultaneously across the recursion.
145pub(crate) enum Visited<'a> {
146    /// A `(label, child)` map entry, fired once per label regardless of
147    /// whether `label`'s value is a same-label array -- e.g. for
148    /// `yaml.rs`'s NEL-in-label scan, which must not fire once per array
149    /// entry.
150    Edge { label: &'a str },
151    /// A value actually reached by the traversal (a leaf, or a
152    /// non-`Object` node on the way down) -- e.g. `json.rs`'s
153    /// NaN/Infinity check or `yaml.rs`'s NEL-in-value check, both of
154    /// which only care about a subset of node kinds and filter for it
155    /// themselves.
156    Node { value: &'a Value },
157}
158
159/// Shared traversal for the two codec scanners built directly over a grouped
160/// `Value` tree (`json::collect_leaves`/`check_json`, `yaml::scan_nel`) --
161/// see issue #51. Both re-implemented the same recursion and the same
162/// same-label-array path-numbering rule; this walker does it once.
163///
164/// `path` is a single reused buffer: every recursive step pushes its segment
165/// (via [`crate::report::push_child_path`]), recurses, then truncates back --
166/// so a full walk of an all-valid document allocates a path `String` only
167/// when `f` itself decides to keep one (e.g. to store it in a
168/// `WriteReport`), never once per edge just to *have* a path available
169/// (issue #44).
170///
171/// `toml.rs::strip_nulls` (which transforms the tree, not merely visits it)
172/// and `xml.rs::scan_xml_into` (a different tree type, `RawNode`, not
173/// `Value`) don't fit this shape and keep their own recursion -- they still
174/// use [`crate::report::child_path`] for the path-numbering rule itself.
175pub(crate) fn visit_grouped(
176    node: &Value,
177    path: &mut String,
178    f: &mut impl FnMut(Visited<'_>, &str),
179) {
180    match node {
181        Value::Object(map) => {
182            for (label, child) in map {
183                let base = path.len();
184                // E-36: the edge's own path quotes a label that needs it.
185                crate::report::push_child_path(path, label, None);
186                f(Visited::Edge { label }, path.as_str());
187
188                match child {
189                    Value::Array(items) => {
190                        path.truncate(base);
191                        for (i, item) in items.iter().enumerate() {
192                            let ibase = path.len();
193                            crate::report::push_child_path(
194                                path,
195                                label,
196                                crate::report::occurrence_index(i, items.len()),
197                            );
198                            visit_grouped(item, path, f);
199                            path.truncate(ibase);
200                        }
201                    }
202                    other => {
203                        visit_grouped(other, path, f);
204                        path.truncate(base);
205                    }
206                }
207            }
208        }
209        other => f(Visited::Node { value: other }, path.as_str()),
210    }
211}
212
213#[cfg(test)]
214mod temporal_text_tests {
215    use super::temporal_text;
216    use std::borrow::Cow;
217
218    #[test]
219    fn an_omitted_seconds_field_is_written_as_00() {
220        assert_eq!(temporal_text("12:00", false), "12:00:00");
221        assert_eq!(temporal_text("12:00+01:00", false), "12:00:00+01:00");
222        assert_eq!(temporal_text("12:00Z", false), "12:00:00Z");
223        assert_eq!(
224            temporal_text("2024-01-15T12:00", true),
225            "2024-01-15T12:00:00"
226        );
227        assert_eq!(
228            temporal_text("2024-01-15T12:00-05:00", true),
229            "2024-01-15T12:00:00-05:00"
230        );
231    }
232
233    #[test]
234    fn a_datetime_separator_is_t() {
235        assert_eq!(
236            temporal_text("2024-01-15 12:00:00", true),
237            "2024-01-15T12:00:00"
238        );
239        assert_eq!(
240            temporal_text("2024-01-15 12:00", true),
241            "2024-01-15T12:00:00"
242        );
243    }
244
245    #[test]
246    fn nothing_else_changes() {
247        for (s, datetime) in [
248            ("12:00:00", false),
249            ("12:00:00.5", false),
250            ("12:00:00.123456+01:00", false),
251            ("2024-01-15T12:00:00.5Z", true),
252            ("2024-01-15", true),
253            ("garbage", false),
254            ("12", false),
255            ("ab:cd", false),
256            ("", true),
257        ] {
258            assert!(
259                matches!(temporal_text(s, datetime), Cow::Borrowed(t) if t == s),
260                "{s:?}"
261            );
262        }
263    }
264}
265
266#[cfg(test)]
267mod path_quoting_tests {
268    use crate::document::{Doc, Value};
269    use crate::formats::{json, toml, yaml};
270    use indexmap::IndexMap;
271
272    fn one(label: &str, v: Value) -> Doc {
273        let mut m = IndexMap::new();
274        m.insert(label.to_string(), v);
275        Doc::of(&Value::Object(m)).unwrap()
276    }
277
278    const CASES: [(&str, &str); 3] = [("a.b", r#"$["a.b"]"#), ("", r#"$[""]"#), ("a b", "$.a b")];
279
280    #[test]
281    fn json_check_and_write_adjustments_quote_the_label() {
282        for (label, want) in CASES {
283            let nan = one(label, Value::Float(f64::NAN));
284            assert_eq!(json::check_json(&nan).adjustments()[0].path, want);
285            let t = one(label, Value::Time("12:00:00".to_string()));
286            assert_eq!(json::check_json(&t).adjustments()[0].path, want);
287            let mut rep = crate::report::WriteReport::new();
288            json::write_json(&t, None, false, Some(&mut rep)).unwrap();
289            assert_eq!(rep.adjustments()[0].path, want);
290        }
291    }
292
293    #[test]
294    fn yaml_adjustments_quote_the_label() {
295        for (label, want) in CASES {
296            let t = one(label, Value::Time("12:00:00".to_string()));
297            assert_eq!(yaml::check_yaml(&t).adjustments()[0].path, want);
298            let nel = one(label, Value::Str("a\u{85}b".to_string()));
299            assert_eq!(yaml::check_yaml(&nel).adjustments()[0].path, want);
300        }
301        let key = one("a.b\u{85}", Value::Int(1.into()));
302        assert_eq!(
303            yaml::check_yaml(&key).adjustments()[0].path,
304            "$[\"a.b\\u0085\"]"
305        );
306    }
307
308    #[test]
309    fn toml_time_with_an_offset_or_z_is_quoted_and_reported_at_a_quoted_path() {
310        for (label, want) in CASES {
311            for t in ["12:00:00+01:00", "12:00Z", "12:00:00z", "12:00:00-05:00"] {
312                let doc = one(label, Value::Time(t.to_string()));
313                let mut rep = crate::report::WriteReport::new();
314                let text = toml::write_toml(&doc, false, Some(&mut rep)).unwrap();
315                assert!(text.contains('"'), "{t}: {text}");
316                assert_eq!(rep.adjustments()[0].path, want);
317                assert_eq!(rep.adjustments()[0].code, "format.temporal-stringified");
318                assert!(toml::read_toml(&text).is_ok(), "{text}");
319            }
320        }
321    }
322}