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}