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;