Skip to main content

omnist/
oml.rs

1//! OML (Omnist Markup Language) -- the native codec for the Document model.
2//!
3//! Ported from `~/dev/omnist/omnist/oml.py` (issue #10). OML is omnist's own
4//! serialization format: every Document -- every ordered, possibly-repeated,
5//! possibly-interleaved edge list, and all seven scalar kinds (`string`,
6//! `integer`, `number`, `boolean`, `date`, `time`, `datetime`) plus `null` --
7//! round-trips through OML exactly, with no adjustment ever needed (unlike
8//! JSON/YAML/TOML/XML).
9//!
10//! This module implements the **OML-Core** grammar in full for both
11//! [`read_oml`] and [`write_oml`], plus the **OML-Extended** raw-string
12//! (`'...'`, E2) and triple-quoted multiline-string (`"""..."""`, E3)
13//! spellings on read only -- [`write_oml`] only ever emits OML-Core
14//! double-quoted strings, matching the Python reference.
15//!
16//! ## Layout (issue #53)
17//!
18//! `scanner` tokenizes source text, `parser` consumes those tokens into
19//! a [`RawNode`], and `writer` renders a `RawNode` back to OML-Core
20//! source. This top-level module keeps the module doc overview, the four
21//! `pub fn`s ([`read_oml`], [`write_oml`], [`write_oml_compact`],
22//! [`check_oml`]), and the `Codec` adapter --
23//! nothing about `crate::oml::*` paths changed by the split.
24//!
25//! ## Architecture (per issue #1/#10, "architecture freedom")
26//!
27//! Python's reader is a single-pass scanner built around one compiled
28//! "master" regex, deferring line/col computation and scalar-value
29//! construction until actually needed -- a Python-performance-specific
30//! design (see the module's PR #168 for the profile that motivated it), not
31//! a behavioral requirement. This port uses a straightforward hand-written
32//! recursive-descent scanner/parser over byte-indexed `&str` instead: idiomatic
33//! Rust, and there's no equivalent hot-path reason to defer decoding here.
34//! Observable behavior (parse results, round-trips, error content) matches
35//! the Python reference; exact error wording does not need to.
36//!
37//! ## Node representation
38//!
39//! [`crate::document::RawNode`] -- not [`crate::document::Value`] -- is the
40//! type this codec reads into and writes from. `Value::Object`'s `IndexMap`
41//! can't hold a repeated key, so it only represents "repeated label" as a
42//! *contiguous* run (an array value under one key); OML must round-trip
43//! arbitrary **interleaving** of repeated labels losslessly (its whole
44//! reason for existing -- "no adjustment ever needed"), which only
45//! `RawNode`'s literal edge list can hold exactly.
46//!
47//! ## Depth guard (omnist-ts#37 / omnist-ts#70)
48//!
49//! [`write_oml`] takes a plain, unchecked [`crate::document::RawNode`] --
50//! exactly like Python's `write_oml(node)`, which accepts any hand-built
51//! canonical node, not necessarily one that passed through a depth-checked
52//! builder. So the writer calls the shared
53//! `crate::document::check_write_depth` guard itself, at every nesting
54//! level, rather than assuming its input already got checked somewhere
55//! upstream -- the exact bug class omnist-ts#37/#70 were: a writer (or a
56//! second writer) that skipped this because *some* builder happened to
57//! guard depth already.
58
59#[cfg(test)]
60use crate::document::Scalar;
61use crate::document::{self, RawNode};
62use crate::error::{DocumentError, OmnistError, ParseError, WriteError};
63#[cfg(test)]
64use crate::formats::int_cap::MAX_INT_DIGITS;
65use crate::limits::{Limits, Resolved};
66
67mod parser;
68mod scanner;
69mod writer;
70
71use parser::Parser;
72use scanner::Scanner;
73use writer::{write_edges, write_edges_compact, write_scalar};
74
75/// Parse OML source into a canonical [`RawNode`] (edge-list or leaf).
76///
77/// Supports the full OML-Core grammar, plus OML-Extended raw-string (`'...'`)
78/// and triple-quoted multiline-string (`"""..."""`) spellings -- see the
79/// module doc comment.
80///
81/// Reads under the default [`Limits`]. A safety limit crossed is reported
82/// here, for compatibility, as a [`ParseError`] carrying the `document.limit.*`
83/// code and the text position where it was crossed; [`read_oml_with`] reports
84/// the same failure the way spec E-11 requires, as a
85/// [`crate::error::DocumentError`] with a Document path.
86pub fn read_oml(text: &str) -> Result<RawNode, ParseError> {
87    read_oml_resolved(text, &Resolved::DEFAULT).map_err(|e| match e {
88        OmlReadError::Parse(p) => p,
89        OmlReadError::Limit(d, line, col) => {
90            ParseError::new(line, col, d.code.unwrap_or_default(), d.message)
91        }
92    })
93}
94
95/// [`read_oml`] under explicit [`Limits`].
96///
97/// Returns the [`Limits::validate`] error unchanged when the limits are out
98/// of range. Otherwise a syntax error is an [`OmnistError::Parse`] as in
99/// [`read_oml`], and a limit crossed is an [`OmnistError::Document`]
100/// (`document.limit.depth` / `document.limit.nodes` at `$`,
101/// `document.limit.int-digits` at the path of the over-long integer).
102///
103/// ```
104/// use omnist::limits::Limits;
105/// use omnist::oml::read_oml_with;
106///
107/// let limits = Limits::default().with_max_int_digits(3);
108/// assert!(read_oml_with("n: 999\n", &limits).is_ok());
109/// let err = read_oml_with("n: 1000\n", &limits).unwrap_err();
110/// assert_eq!(err.to_string(), "$.n: integer literal exceeds the 3-digit limit (security: \
111///     unbounded-digit int-to-str conversion is superlinear)");
112/// ```
113pub fn read_oml_with(text: &str, limits: &Limits) -> Result<RawNode, OmnistError> {
114    let resolved = limits.resolve()?;
115    read_oml_resolved(text, &resolved).map_err(|e| match e {
116        OmlReadError::Parse(p) => OmnistError::from(p),
117        OmlReadError::Limit(d, _, _) => OmnistError::from(d),
118    })
119}
120
121/// How an OML read can fail: a syntax error, or a safety limit with both its
122/// Document-path form and the text position [`read_oml`] still reports.
123enum OmlReadError {
124    Parse(ParseError),
125    Limit(DocumentError, usize, usize),
126}
127
128fn read_oml_resolved(text: &str, limits: &Resolved) -> Result<RawNode, OmlReadError> {
129    // D-23: the size is checked first, on the input as received (BOM
130    // counted), ahead of every other diagnostic. `read_oml` reports it at 1:1.
131    limits
132        .check_input_size(text.len())
133        .map_err(|d| OmlReadError::Limit(d, 1, 1))?;
134    // D-15/D-21: one leading BOM is stripped, a second is rejected at 1:1
135    // This is the only place OML strips it (see `crate::bom`).
136    let text = crate::bom::strip_leading_bom(text).map_err(|_| {
137        OmlReadError::Parse(ParseError::new(
138            1,
139            1,
140            "parse.unexpected-token",
141            crate::bom::DOUBLED_BOM_MESSAGE,
142        ))
143    })?;
144    let sc = Scanner::new(text, limits.max_int_digits);
145    let mut parser = Parser::new(sc, limits).map_err(|e| classify(e, limits))?;
146    let raw = parser.parse_document().map_err(|e| classify(e, limits))?;
147    if let Some(at) = parser.sc.overcap_at {
148        // An integer literal was over the digit cap and was replaced by a
149        // placeholder (see `crate::limits::IntGuard`); building the Document
150        // refuses it at its Document path.
151        let (line, col) = parser.sc.line_col(at);
152        let err = document::Doc::from_raw_resolved(raw, *limits)
153            .expect_err("a placeholder integer is over the cap, so building the Document fails");
154        return Err(OmlReadError::Limit(err, line, col));
155    }
156    Ok(raw)
157}
158
159/// Splits the parser's own limit errors (`document.limit.depth` /
160/// `document.limit.nodes`, raised with a text position) from syntax errors,
161/// giving each limit its `$` Document-path form.
162fn classify(e: ParseError, limits: &Resolved) -> OmlReadError {
163    match e.code.as_str() {
164        "document.limit.depth" => OmlReadError::Limit(limits.depth_error(), e.line, e.col),
165        "document.limit.nodes" => OmlReadError::Limit(limits.nodes_error(), e.line, e.col),
166        _ => OmlReadError::Parse(e),
167    }
168}
169
170/// Render a canonical [`RawNode`] as OML-Core source, pretty-printed with
171/// `indent` spaces per nesting level.
172///
173/// OML is lossless for every Document: there's never an adjustment to
174/// report, so there's no `strict=`/report machinery -- writing always
175/// succeeds, unless the input itself nests deeper than
176/// [`crate::document::MAX_DEPTH`] (see the module doc comment on the depth
177/// guard).
178pub fn write_oml(node: &RawNode, indent: usize) -> Result<String, WriteError> {
179    match node {
180        RawNode::Leaf(s) => Ok(write_scalar(s)),
181        RawNode::Edges(edges) => write_edges(edges, 0, indent, 0),
182    }
183}
184
185/// Single-line ("compact") rendering: edges joined by `"; "`, no
186/// newlines/padding. Mirrors Python's `write_oml(..., indent=None)`. Both
187/// forms round-trip through [`read_oml`].
188pub fn write_oml_compact(node: &RawNode) -> Result<String, WriteError> {
189    match node {
190        RawNode::Leaf(s) => Ok(write_scalar(s)),
191        RawNode::Edges(edges) => write_edges_compact(edges, 0),
192    }
193}
194
195/// Report what writing OML would adjust, without producing output. Added
196/// for issue #31 (the format registry): OML is lossless for every
197/// `Document` (see this module's doc comment), so there is never anything
198/// to report -- mirrors Python's `check_oml`, which is exactly `return
199/// WriteReport()`. Every other builtin format has a `check_*` function
200/// already; this is the OML counterpart, needed so the `"oml"` registry
201/// entry has a `check` callable like the other four.
202pub fn check_oml(doc: &crate::document::Doc) -> crate::report::WriteReport {
203    let mut rep = crate::report::WriteReport::new();
204    let mut path = String::from("$");
205    crate::formats::visit_grouped(&doc.to_grouped(), &mut path, &mut |visited, path| {
206        if let crate::formats::Visited::Node {
207            value: document::Value::Time(s) | document::Value::Datetime(s),
208        } = visited
209            && crate::formats::fraction_inexact(s)
210        {
211            rep.add(
212                path,
213                "format.temporal-stringified",
214                "fractional second has more than six digits, which OML's literal cannot spell; wrote as a quoted ISO-8601 string",
215                crate::report::Severity::Warning,
216            );
217        }
218    });
219    rep
220}
221
222/// Marker type implementing [`crate::formats::Codec`] for OML -- adapts
223/// [`read_oml`]/[`write_oml`]/[`check_oml`] to the registry's uniform
224/// `Doc`-in/`Doc`-out shape, exactly as `registry::builtins` did by hand
225/// before this issue: `read` bridges `read_oml`'s [`RawNode`] result
226/// through [`crate::document::Doc::from_raw`], and `write` bridges the
227/// other way through `Doc::to_raw` before calling `write_oml` with its
228/// documented default indent (2).
229pub(crate) struct Oml;
230
231impl crate::formats::Codec for Oml {
232    const NAME: &'static str = "oml";
233
234    fn read(text: &str) -> Result<document::Doc, crate::error::OmnistError> {
235        let raw: RawNode = read_oml_with(text, &Limits::default())?;
236        document::Doc::from_raw(raw).map_err(Into::into)
237    }
238
239    fn write(doc: &document::Doc) -> Result<String, crate::error::OmnistError> {
240        write_oml(&doc.to_raw(), 2).map_err(Into::into)
241    }
242
243    fn check(doc: &document::Doc) -> crate::report::WriteReport {
244        check_oml(doc)
245    }
246}
247
248#[cfg(test)]
249mod tests;