Skip to main content

omnist/
registry.rs

1//! Format registry -- read/write/check a [`crate::document::Doc`] by format
2//! *name* at runtime, plus register your own format plugins. Ported from
3//! `~/dev/omnist/omnist/registry.py` (issue #31; see also the TypeScript
4//! port's `registry.ts` for the same architecture-freedom call made there).
5//!
6//! Python's registry is a plain `dict[str, Format]` of arbitrary callables --
7//! genuine runtime plugin registration, exercised by
8//! `tests/test_canonical.py::TestRegistry`: a caller can `register_format`
9//! an arbitrary `(name, read, write, check?)` tuple at runtime and every
10//! `Doc`-level API that takes a format name (`from_format`/`to_format`/
11//! `check_format`) transparently picks it up. A closed `enum` dispatch (the
12//! `omnist-cli` `Fmt` enum's approach, or a `match` over the five builtins)
13//! can't express "register a new format at runtime under an arbitrary
14//! name," so this module reaches for the same dynamic-dispatch idiom Rust
15//! uses in place of Python's first-class functions: `Arc<dyn Fn(...) + Send + Sync>` trait objects, keyed by name in an `IndexMap` behind an
16//! `RwLock` inside a `OnceLock` (this crate's only piece of global mutable
17//! state). `Arc` (not `Box`) so [`get_format`] can hand back an owned,
18//! independently usable [`Format`] without holding the registry lock across
19//! the caller's use of it -- mirroring Python's `_LOCK`-guarded dict lookup,
20//! which also releases the lock before the caller touches the returned
21//! `Format`.
22//!
23//! ## Uniform signatures across five differently-shaped codecs
24//!
25//! [`crate::formats::json::write_json`] takes an extra `indent: Option<
26//! usize>` the other three format writers don't, and [`crate::oml`]'s
27//! `read_oml`/`write_oml` operate on [`crate::document::RawNode`] rather
28//! than `Doc` directly (see `oml.rs`'s own module doc on why). The
29//! [`ReadFn`]/[`WriteFn`] the registry stores are `Doc`-in/`Doc`-out with no
30//! format-specific options, matching Python's registry entries as actually
31//! invoked from this port's zero-arg call sites (Python's `Doc.to_format`
32//! forwards `**o` through, but nothing in the Python test suite or
33//! `docs/api.md` exercises that with the builtins, so this port keeps the
34//! simpler no-options signature and documents the gap here rather than
35//! silently reproducing untested surface). The five builtins are registered
36//! as thin wrapper closures around the existing per-format functions with
37//! their default options (`indent: None`, `strict: false`, no report
38//! requested for writers; `Doc::from_raw`/`to_raw` bridging OML's `RawNode`
39//! shape) -- `get_format("json").read`/`.write` are *not* literally
40//! `read_json`/`write_json` (Rust can't express "the same fn item" through
41//! an `Arc<dyn Fn>` the way Python's `is` can point at the same function
42//! object), but they call straight through with no other logic, matching
43//! Python's actual invariant in spirit: no behavior is added or changed at
44//! the registry boundary.
45//!
46//! ## OML's `check_oml`
47//!
48//! Rust's port had no `check_oml` before this issue -- OML is lossless for
49//! every `Doc` (see `oml.rs`'s module doc: "no adjustment ever needed"), so
50//! nothing needed to call it. Python's `check_oml` exists purely to satisfy
51//! the registry `Format` tuple's fourth slot and always returns an empty
52//! `WriteReport`; this issue adds the same trivial function to `oml.rs` for
53//! the same reason (used only via the `"oml"` registry entry's `check`).
54
55use std::sync::{Arc, OnceLock, RwLock};
56
57use indexmap::IndexMap;
58
59use crate::document::Doc;
60use crate::error::{FormatError, OmnistError};
61use crate::report::WriteReport;
62
63/// `text -> Doc` reader callable.
64pub type ReadFn = dyn Fn(&str) -> Result<Doc, OmnistError> + Send + Sync;
65/// `Doc -> text` writer callable.
66pub type WriteFn = dyn Fn(&Doc) -> Result<String, OmnistError> + Send + Sync;
67/// `Doc -> WriteReport` check callable; simulates a write without producing
68/// output.
69pub type CheckFn = dyn Fn(&Doc) -> WriteReport + Send + Sync;
70
71/// A registered format: a name plus `read`/`write` callables and an
72/// optional `check`. Mirrors Python's `Format` `NamedTuple` (`name, read,
73/// write, check`); a plugin registered with [`Format::new`] alone has no
74/// `check`, and [`crate::document::Doc::check_format`] errors cleanly (not
75/// a panic) if invoked on it -- matching
76/// `test_plugin_without_check_raises_on_check_format`.
77#[derive(Clone)]
78pub struct Format {
79    /// Registered format name (e.g. `"json"`).
80    pub name: String,
81    /// Text to `Doc` reader callable.
82    pub read: Arc<ReadFn>,
83    /// `Doc` to text writer callable.
84    pub write: Arc<WriteFn>,
85    /// Optional `Doc` write simulation callable.
86    pub check: Option<Arc<CheckFn>>,
87}
88
89impl Format {
90    /// Build a `Format` with no `check` callable. Use [`Format::with_check`]
91    /// to attach one.
92    pub fn new(
93        name: impl Into<String>,
94        read: impl Fn(&str) -> Result<Doc, OmnistError> + Send + Sync + 'static,
95        write: impl Fn(&Doc) -> Result<String, OmnistError> + Send + Sync + 'static,
96    ) -> Self {
97        Self {
98            name: name.into(),
99            read: Arc::new(read),
100            write: Arc::new(write),
101            check: None,
102        }
103    }
104
105    /// Attach a `check` callable, returning `self` for chaining.
106    pub fn with_check(
107        mut self,
108        check: impl Fn(&Doc) -> WriteReport + Send + Sync + 'static,
109    ) -> Self {
110        self.check = Some(Arc::new(check));
111        self
112    }
113}
114
115fn registry() -> &'static RwLock<IndexMap<String, Format>> {
116    static REGISTRY: OnceLock<RwLock<IndexMap<String, Format>>> = OnceLock::new();
117    REGISTRY.get_or_init(|| RwLock::new(builtins()))
118}
119
120fn builtins() -> IndexMap<String, Format> {
121    use crate::formats::Codec;
122    use crate::formats::json::Json;
123    use crate::formats::toml::Toml;
124    use crate::formats::xml::Xml;
125    use crate::formats::yaml::Yaml;
126    use crate::oml::Oml;
127
128    let mut m = IndexMap::new();
129    let mut add = |fmt: Format| {
130        m.insert(fmt.name.clone(), fmt);
131    };
132
133    add(Json::format());
134    add(Yaml::format());
135    add(Toml::format());
136    add(Xml::format());
137    add(Oml::format());
138
139    m
140}
141
142/// Register (or replace) a format plugin, usable everywhere a format name is
143/// accepted, including [`crate::document::Doc::from_format`]/`to_format`/
144/// `check_format`.
145pub fn register_format(fmt: Format) {
146    registry().write().unwrap().insert(fmt.name.clone(), fmt);
147}
148
149/// The registered [`Format`] for `name`. An [`OmnistError::Format`] if
150/// unknown, naming every currently-registered format name, sorted --
151/// mirrors Python's `get_format`'s `f"unknown format {name!r}; registered:
152/// {known}"` message. Unlike Python, there is no `"(none)"` fallback for an
153/// empty registry: [`register_format`] only ever adds entries and the five
154/// builtins always register on first access (see `builtins`), so the
155/// registry can never actually be empty here -- an untestable dead branch
156/// for that case was deliberately not carried over (playbook's "unreachable
157/// dead code" gap classification), rather than kept under an unreachable
158/// coverage-ignore.
159pub fn get_format(name: &str) -> Result<Format, OmnistError> {
160    let reg = registry().read().unwrap();
161    reg.get(name).cloned().ok_or_else(|| {
162        let mut known: Vec<&str> = reg.keys().map(String::as_str).collect();
163        known.sort_unstable();
164        FormatError::new(format!(
165            "unknown format '{name}'; registered: {}",
166            known.join(", ")
167        ))
168        .into()
169    })
170}
171
172/// The names of all registered formats, sorted.
173pub fn formats() -> Vec<String> {
174    let reg = registry().read().unwrap();
175    let mut names: Vec<String> = reg.keys().cloned().collect();
176    names.sort_unstable();
177    names
178}
179
180#[cfg(test)]
181mod tests;