Skip to main content

noyalib/
tag_registry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Streaming-path registry for custom YAML tag pass-through.
5//!
6//! The default streaming deserializer routes values carrying a non-core
7//! tag (anything outside `!!int`, `!!str`, `!!bool`, …) through the AST
8//! fallback so the tag survives as `Value::Tagged(...)`. That's correct
9//! when a caller is reading into `Value`, but it forces an unnecessary
10//! allocation when the caller already knows the tag maps to a concrete
11//! Rust type.
12//!
13//! [`TagRegistry`] lets a caller declare which custom tags should
14//! instead be *stripped* on the streaming path so the inner scalar,
15//! sequence, or mapping deserializes directly into the target type —
16//! no AST detour, no `#[serde(rename = "!…")]` dance.
17//!
18//! # Examples
19//!
20//! ```
21//! use noyalib::{from_str_with_config, ParserConfig, TagRegistry};
22//! use std::sync::Arc;
23//!
24//! #[derive(Debug, serde::Deserialize, PartialEq)]
25//! struct Celsius(f64);
26//!
27//! let registry = Arc::new(TagRegistry::new().with("!Celsius"));
28//! let cfg = ParserConfig::new().tag_registry(registry);
29//!
30//! // Without the registry, `!Celsius 42.5` becomes Value::Tagged and a
31//! // plain `f64` deserialize fails. With the registry, the tag is
32//! // stripped on the streaming path and the scalar deserializes
33//! // directly into the target type.
34//! let c: Celsius = from_str_with_config("!Celsius 42.5", &cfg).unwrap();
35//! assert_eq!(c, Celsius(42.5));
36//! ```
37//!
38//! # When you still need `#[serde(rename = "!tag")]`
39//!
40//! The registry strips tags; it does not dispatch. If you're reading a
41//! tagged value into an *enum* where the tag chooses the variant
42//! (`!bang` → `Msg::Bang`, `!quiet` → `Msg::Quiet`), keep using
43//! `#[serde(rename)]` — serde itself needs the tag string to pick the
44//! variant. The registry is for "I already know the type; just get out
45//! of my way" newtype/unit-style use cases (the robotics, scientific,
46//! and units scenarios in particular).
47
48use crate::prelude::FxHashSet;
49use crate::prelude::*;
50
51/// A set of custom YAML tags that the streaming deserializer should
52/// strip and pass through instead of routing via the AST fallback.
53///
54/// Clone-cheap (just a set of owned strings); typically wrapped in an
55/// `Arc` and shared across calls. See the [module-level
56/// documentation](crate::tag_registry) for semantics.
57///
58/// # Examples
59///
60/// ```
61/// use noyalib::TagRegistry;
62/// let mut reg = TagRegistry::new();
63/// let _ = reg.register("!Celsius");
64/// let _ = reg.register("!Meters");
65/// assert!(reg.contains("!Celsius"));
66/// assert_eq!(reg.len(), 2);
67/// ```
68#[derive(Debug, Clone, Default)]
69pub struct TagRegistry {
70    known: FxHashSet<String>,
71}
72
73impl TagRegistry {
74    /// Construct an empty registry.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// let reg = noyalib::TagRegistry::new();
80    /// assert!(reg.is_empty());
81    /// ```
82    #[must_use]
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Mark `tag` as known. Accepts both handle-prefixed forms
88    /// (`!Celsius`, `!!custom`) and verbatim tag text. Returns `&mut
89    /// self` so callers can chain registrations.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// let mut reg = noyalib::TagRegistry::new();
95    /// let _ = reg.register("!Celsius").register("!Fahrenheit");
96    /// assert!(reg.contains("!Celsius"));
97    /// assert!(reg.contains("!Fahrenheit"));
98    /// ```
99    pub fn register(&mut self, tag: impl Into<String>) -> &mut Self {
100        let _ = self.known.insert(tag.into());
101        self
102    }
103
104    /// Chainable `register` that consumes `self` — convenient when
105    /// building a registry inline.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// let reg = noyalib::TagRegistry::new()
111    ///     .with("!Celsius")
112    ///     .with("!Meters");
113    /// assert_eq!(reg.len(), 2);
114    /// ```
115    #[must_use]
116    pub fn with(mut self, tag: impl Into<String>) -> Self {
117        let _ = self.known.insert(tag.into());
118        self
119    }
120
121    /// Is `tag` registered as a strip-through tag?
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// let reg = noyalib::TagRegistry::new().with("!Celsius");
127    /// assert!(reg.contains("!Celsius"));
128    /// assert!(!reg.contains("!Fahrenheit"));
129    /// ```
130    #[must_use]
131    pub fn contains(&self, tag: &str) -> bool {
132        self.known.contains(tag)
133    }
134
135    /// Number of tags registered.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// let reg = noyalib::TagRegistry::new().with("!A").with("!B");
141    /// assert_eq!(reg.len(), 2);
142    /// ```
143    #[must_use]
144    pub fn len(&self) -> usize {
145        self.known.len()
146    }
147
148    /// Returns `true` when no tags are registered.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// assert!(noyalib::TagRegistry::new().is_empty());
154    /// ```
155    #[must_use]
156    pub fn is_empty(&self) -> bool {
157        self.known.is_empty()
158    }
159
160    /// Remove every registration.
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// let mut reg = noyalib::TagRegistry::new().with("!A");
166    /// reg.clear();
167    /// assert!(reg.is_empty());
168    /// ```
169    pub fn clear(&mut self) {
170        self.known.clear();
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn new_is_empty() {
180        assert!(
181            TagRegistry::new().is_empty(),
182            "expected empty, got {:?}",
183            TagRegistry::new()
184        );
185    }
186
187    #[test]
188    fn register_and_contains() {
189        let mut reg = TagRegistry::new();
190        let _ = reg.register("!Celsius");
191        assert!(reg.contains("!Celsius"));
192        assert!(!reg.contains("!Fahrenheit"));
193    }
194
195    #[test]
196    fn with_chain() {
197        let reg = TagRegistry::new().with("!a").with("!b").with("!c");
198        assert_eq!(reg.len(), 3);
199    }
200
201    #[test]
202    fn clear_empties() {
203        let mut reg = TagRegistry::new().with("!a");
204        reg.clear();
205        assert!(reg.is_empty(), "expected empty, got {reg:?}");
206    }
207
208    #[test]
209    fn duplicates_collapse() {
210        let mut reg = TagRegistry::new();
211        let _ = reg.register("!x");
212        let _ = reg.register("!x");
213        assert_eq!(reg.len(), 1);
214    }
215}