Skip to main content

serde_saphyr/
wrappers.rs

1use serde_core::de::{self, Deserialize, Deserializer, Visitor};
2use std::fmt;
3use std::marker::PhantomData;
4
5/// Force a sequence to be emitted in flow style: `[a, b, c]`.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct FlowSeq<T>(pub T);
8
9/// Force a mapping to be emitted in flow style: `{k1: v1, k2: v2}`.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct FlowMap<T>(pub T);
12
13/// Force a string value to be emitted in double-quoted style.
14///
15/// This wrapper is transparent during deserialization: the inner value is
16/// deserialized normally and placed into `DoubleQuoted<T>`. `DoubleQuoted<T>` implements
17/// Serde traits only for string-like `T` values.
18///
19/// ```rust
20/// # #[cfg(feature = "serialize")]
21/// # {
22/// use serde::Serialize;
23/// use serde_saphyr::DoubleQuoted;
24///
25/// #[derive(Serialize)]
26/// struct Config {
27///     value: DoubleQuoted<String>,
28/// }
29///
30/// let cfg = Config { value: DoubleQuoted("plain text".to_string()) };
31/// let yaml = serde_saphyr::to_string(&cfg).unwrap();
32/// assert_eq!(yaml, "value: \"plain text\"\n");
33/// # }
34/// ```
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct DoubleQuoted<T>(pub T);
37
38/// Force a string value to be emitted in a single-quoted style. This provides additional
39/// safety constraints, as serializer rejects control characters and other values that require
40/// double-quoted string escaping.
41///
42/// This wrapper is transparent during deserialization: the inner value is
43/// deserialized normally and placed into `SingleQuoted<T>`. `SingleQuoted<T>` implements
44/// Serde traits only for string-like `T` values.
45///
46/// Serialization fails if the value cannot be represented
47/// safely in YAML single-quoted style.
48///
49/// ```rust
50/// # #[cfg(feature = "serialize")]
51/// # {
52/// use serde::Serialize;
53/// use serde_saphyr::SingleQuoted;
54///
55/// #[derive(Serialize)]
56/// struct Config {
57///     value: SingleQuoted<String>,
58/// }
59///
60/// let cfg = Config { value: SingleQuoted("plain text".to_string()) };
61/// let yaml = serde_saphyr::to_string(&cfg).unwrap();
62/// assert_eq!(yaml, "value: 'plain text'\n");
63/// # }
64/// ```
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct SingleQuoted<T>(pub T);
67
68/// Add an empty line after the wrapped value when serializing.
69///
70/// This wrapper is transparent during deserialization and can be nested with
71/// other wrappers like `Commented`, `FlowSeq`, etc.
72/// ```rust
73/// # #[cfg(feature = "serialize")]
74/// # {
75/// use serde::Serialize;
76/// use serde_saphyr::SpaceAfter;
77///
78/// #[derive(Serialize)]
79/// struct Config {
80///     first: SpaceAfter<i32>,
81///     second: i32,
82/// }
83///
84/// let cfg = Config { first: SpaceAfter(1), second: 2 };
85/// let yaml = serde_saphyr::to_string(&cfg).unwrap();
86/// // The output will have an empty line after "first: 1"
87/// # }
88/// ```
89/// **Important:** Avoid using this wrapper with `LitStr`/`LitString` as it may add the empty
90/// line to the string content. For `FoldStr`/`FoldString` and other YAML values
91/// (e.g. `key: value`, quoted scalars), the extra empty line is cosmetic.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct SpaceAfter<T>(pub T);
94
95/// Serialize `None` as YAML tilde (`~`) while otherwise behaving like `Option<T>`.
96///
97/// `Some(value)` is serialized transparently as `value`. `None` is serialized
98/// as `~` instead of the serializer's regular null spelling. Deserialization
99/// delegates to `Option<T>`, so `~`, `null`, and empty YAML values all become
100/// `NullableTilde(None)`.
101///
102/// ```rust
103/// # #[cfg(feature = "serialize")]
104/// # {
105/// use serde::Serialize;
106/// use serde_saphyr::NullableTilde;
107///
108/// #[derive(Serialize)]
109/// struct Config {
110///     maybe: NullableTilde<String>,
111/// }
112///
113/// let cfg = Config { maybe: NullableTilde(None) };
114/// let yaml = serde_saphyr::to_string(&cfg).unwrap();
115/// assert_eq!(yaml, "maybe: ~\n");
116/// # }
117/// ```
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct NullableTilde<T>(pub Option<T>);
120
121/// Attach an inline YAML comment to a value when serializing.
122///
123/// Requires the `comments` feature, which is enabled by default.
124///
125/// This wrapper lets you annotate a scalar with an inline YAML comment that is
126/// emitted after the value when using block style. The typical form is:
127/// `value # comment`. This is the most useful when deserializing the anchor
128/// reference (so a human reader can see what a referenced value represents).
129///
130/// Comment is also captured into its field when deserializing YAML.
131///
132/// Behavior
133/// - Block style (default): the comment appears after the scalar on the same line.
134/// - Flow style (inside `[ ... ]` or `{ ... }`): comments are suppressed to keep
135///   the flow representation compact and unambiguous.
136/// - Complex values (sequences/maps/structs): the comment is ignored; only the
137///   inner value is serialized to preserve indentation and layout.
138/// - Newlines in comments are sanitized to spaces so the comment remains on a
139///   single line (e.g., "a\nb" becomes "a b").
140/// - Deserialization of `Commented<T>` captures nearby source comments when the
141///   `serde-saphyr` deserializer can provide them. Other deserializers treat it
142///   transparently and produce an empty comment string.
143/// - Comment capture is use-site oriented for replayed YAML. Comments from an
144///   anchor definition are not copied through aliases or merge keys; a field
145///   materialized by `<<: *defaults` does not inherit comments that were written
146///   above the field inside `&defaults`.
147/// - For container values, comments attached to the parent value itself are
148///   captured only by `Commented<Container>` and are not inherited by the first
149///   child field or element. A comment inside the container, directly above a
150///   child key or element, is captured by that child.
151/// - When an alias to a container is used as a nested value, leading comments
152///   above the alias follow the same inside-container rule. For example,
153///   `root:\n  # comment\n  *defaults` leaves the comment available to the
154///   expanded container's first child rather than capturing it on the alias use.
155///
156/// Examples
157///
158/// Basic scalar with a comment in block style:
159/// ```rust
160/// # #[cfg(feature = "serialize")]
161/// # {
162/// use serde::Serialize;
163///
164/// // Re-exported from the crate root
165/// use serde_saphyr::Commented;
166///
167/// let out = serde_saphyr::to_string(&Commented(42, "answer".to_string())).unwrap();
168/// assert_eq!(out, "42 # answer\n");
169/// # }
170/// ```
171///
172/// As a mapping value, still inline:
173/// ```rust
174/// # #[cfg(feature = "serialize")]
175/// # {
176/// use serde::Serialize;
177/// use serde_saphyr::Commented;
178///
179/// #[derive(Serialize)]
180/// struct S { xn: Commented<i32> }
181///
182/// let s = S { xn: Commented(5, "send five starships first".into()) };
183/// let out = serde_saphyr::to_string(&s).unwrap();
184/// assert_eq!(out, "xn: 5 # send five starships first\n");
185/// # }
186/// ```
187///
188/// *Important*: Comments are suppressed in flow contexts (no `#` appears), and
189/// ignored for complex inner values during serialization. During deserialization,
190/// parent-side comments on a container such as `root: # comment` are captured by
191/// `Commented<Container>` only; comments inside the container, directly above the
192/// first child key or element, remain available to that child. The same applies
193/// to leading comments above a nested alias whose target is a container.
194#[derive(Clone, Debug, PartialEq, Eq)]
195#[cfg(feature = "comments")]
196pub struct Commented<T>(pub T, pub String);
197
198/// Capture and emit the resolved YAML tag attached to a value.
199///
200/// The first field contains the value and the second contains its resolved tag.
201/// `Some(tag)` is how an arbitrary tag is put on a node: the serializer emits it
202/// as that node's explicit tag. `None` requests no tag and serializes exactly as
203/// the unwrapped `T` would, which is also what deserializing an untagged node
204/// produces.
205///
206/// Resolution deliberately discards source spelling: for example, `!!str` is
207/// captured as `Some("tag:yaml.org,2002:str")`, while a local tag such as
208/// `!widget` is captured as `Some("!widget")`. Tags resolved through a `%TAG`
209/// directive are captured as their complete URI. A present tag always has a
210/// non-empty resolved identity; `Some("")` is invalid when serializing rather
211/// than an alternative spelling of `None`.
212///
213/// During serialization, a resolved global tag URI is emitted using YAML's
214/// verbatim form (`!<...>`). This preserves the resolved tag identity across a
215/// round trip, but does not reproduce tag handles or `%TAG` directives. Local
216/// tags are emitted directly. A non-local identity must have valid absolute-URI
217/// structure; characters requiring URI escaping are percent-encoded on output.
218///
219/// With the `comments` feature, `Tagged<Commented<T>>` and
220/// `Commented<Tagged<T>>` are both supported. Tag capture remains subject to the
221/// deserializer's normal tag semantics and `reject_unsupported_tags` option.
222///
223/// # Clashing tags
224///
225/// A YAML node carries at most one tag, and serializing certain values produces
226/// a tag without being asked: `!!binary` for a byte buffer, or the variant tag
227/// emitted under the `tagged_enums` option. Where such a generated tag meets the
228/// wrapper's own on one node, they must match or wrappers' own tag must
229/// be `None`. Otherwise, error is reported.
230///
231/// Deserializers other than serde-saphyr do not provide YAML tag metadata; when
232/// used with one of them, `Tagged<T>` behaves transparently and gets `None` as
233/// its tag.
234///
235/// ```rust
236/// # #[cfg(all(feature = "serialize", feature = "deserialize"))]
237/// # {
238/// use serde_saphyr::{Tagged, from_str, to_string};
239///
240/// let value: Tagged<String> = from_str("!!str value").unwrap();
241/// assert_eq!(
242///     value,
243///     Tagged("value".into(), Some("tag:yaml.org,2002:str".into())),
244/// );
245/// assert_eq!(
246///     to_string(&value).unwrap(),
247///     "!<tag:yaml.org,2002:str> value\n",
248/// );
249///
250/// # #[cfg(feature = "comments")]
251/// # {
252/// use serde_saphyr::Commented;
253///
254/// let tagged_comment: Tagged<Commented<String>> =
255///     from_str("!widget value # note").unwrap();
256/// assert_eq!(
257///     tagged_comment,
258///     Tagged(
259///         Commented("value".into(), "note".into()),
260///         Some("!widget".into()),
261///     ),
262/// );
263///
264/// let commented_tag: Commented<Tagged<String>> =
265///     from_str("!widget value # note").unwrap();
266/// assert_eq!(
267///     commented_tag,
268///     Commented(
269///         Tagged("value".into(), Some("!widget".into())),
270///         "note".into(),
271///     ),
272/// );
273/// # }
274/// # }
275/// ```
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct Tagged<T>(pub T, pub Option<String>);
278
279#[cfg(all(feature = "garde", feature = "comments"))]
280impl<T: garde::Validate> garde::Validate for Commented<T> {
281    type Context = T::Context;
282
283    fn validate_into(
284        &self,
285        ctx: &Self::Context,
286        parent: &mut dyn FnMut() -> garde::Path,
287        report: &mut garde::Report,
288    ) {
289        self.0.validate_into(ctx, parent, report);
290    }
291}
292
293#[cfg(feature = "garde")]
294impl<T: garde::Validate> garde::Validate for Tagged<T> {
295    type Context = T::Context;
296
297    fn validate_into(
298        &self,
299        ctx: &Self::Context,
300        parent: &mut dyn FnMut() -> garde::Path,
301        report: &mut garde::Report,
302    ) {
303        self.0.validate_into(ctx, parent, report);
304    }
305}
306
307#[cfg(all(feature = "validator", feature = "comments"))]
308impl<T: validator::Validate> validator::Validate for Commented<T> {
309    fn validate(&self) -> Result<(), validator::ValidationErrors> {
310        self.0.validate()
311    }
312}
313
314#[cfg(feature = "validator")]
315impl<T: validator::Validate> validator::Validate for Tagged<T> {
316    fn validate(&self) -> Result<(), validator::ValidationErrors> {
317        self.0.validate()
318    }
319}
320
321#[cfg(all(feature = "validator", feature = "comments"))]
322impl<'v_a, T: validator::ValidateArgs<'v_a>> validator::ValidateArgs<'v_a> for Commented<T> {
323    type Args = T::Args;
324
325    fn validate_with_args(&self, args: Self::Args) -> Result<(), validator::ValidationErrors> {
326        self.0.validate_with_args(args)
327    }
328}
329
330#[cfg(feature = "validator")]
331impl<'v_a, T: validator::ValidateArgs<'v_a>> validator::ValidateArgs<'v_a> for Tagged<T> {
332    type Args = T::Args;
333
334    fn validate_with_args(&self, args: Self::Args) -> Result<(), validator::ValidationErrors> {
335        self.0.validate_with_args(args)
336    }
337}
338
339impl<'de, T: Deserialize<'de>> Deserialize<'de> for FlowSeq<T> {
340    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
341        T::deserialize(deserializer).map(FlowSeq)
342    }
343}
344
345impl<'de, T: Deserialize<'de>> Deserialize<'de> for FlowMap<T> {
346    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
347        T::deserialize(deserializer).map(FlowMap)
348    }
349}
350
351impl<'de, T> Deserialize<'de> for DoubleQuoted<T>
352where
353    T: Deserialize<'de> + AsRef<str>,
354{
355    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
356        T::deserialize(deserializer).map(DoubleQuoted)
357    }
358}
359
360impl<'de, T> Deserialize<'de> for SingleQuoted<T>
361where
362    T: Deserialize<'de> + AsRef<str>,
363{
364    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
365        T::deserialize(deserializer).map(SingleQuoted)
366    }
367}
368
369#[cfg(feature = "comments")]
370impl<'de, T: Deserialize<'de>> Deserialize<'de> for Commented<T> {
371    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
372        struct CommentedVisitor<T>(PhantomData<T>);
373
374        impl<'de, T: Deserialize<'de>> Visitor<'de> for CommentedVisitor<T> {
375            type Value = Commented<T>;
376
377            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
378                formatter.write_str("a commented YAML value")
379            }
380
381            fn visit_newtype_struct<D>(
382                self,
383                deserializer: D,
384            ) -> std::result::Result<Self::Value, D::Error>
385            where
386                D: Deserializer<'de>,
387            {
388                T::deserialize(deserializer).map(|value| Commented(value, String::new()))
389            }
390
391            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
392            where
393                A: de::SeqAccess<'de>,
394            {
395                let value = seq
396                    .next_element()?
397                    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
398                let comment = seq.next_element()?.unwrap_or_default();
399                Ok(Commented(value, comment))
400            }
401        }
402
403        deserializer.deserialize_newtype_struct("__yaml_commented", CommentedVisitor(PhantomData))
404    }
405}
406
407impl<'de, T: Deserialize<'de>> Deserialize<'de> for Tagged<T> {
408    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
409        struct TaggedVisitor<T>(PhantomData<T>);
410
411        impl<'de, T: Deserialize<'de>> Visitor<'de> for TaggedVisitor<T> {
412            type Value = Tagged<T>;
413
414            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
415                formatter.write_str("a tagged YAML value")
416            }
417
418            fn visit_newtype_struct<D>(
419                self,
420                deserializer: D,
421            ) -> std::result::Result<Self::Value, D::Error>
422            where
423                D: Deserializer<'de>,
424            {
425                T::deserialize(deserializer).map(|value| Tagged(value, None))
426            }
427
428            fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
429            where
430                A: de::SeqAccess<'de>,
431            {
432                let value = seq
433                    .next_element()?
434                    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
435                let tag: Option<String> = seq.next_element()?;
436                Ok(Tagged(value, tag))
437            }
438        }
439
440        deserializer.deserialize_newtype_struct("__yaml_tagged", TaggedVisitor(PhantomData))
441    }
442}
443
444impl<'de, T: Deserialize<'de>> Deserialize<'de> for SpaceAfter<T> {
445    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
446        T::deserialize(deserializer).map(SpaceAfter)
447    }
448}
449
450impl<'de, T: Deserialize<'de>> Deserialize<'de> for NullableTilde<T> {
451    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
452        Option::<T>::deserialize(deserializer).map(NullableTilde)
453    }
454}
455
456#[cfg(all(test, feature = "deserialize"))]
457mod tests {
458    use serde::Deserialize;
459
460    #[cfg(feature = "comments")]
461    use crate::Commented;
462    use crate::{DoubleQuoted, FlowMap, FlowSeq, NullableTilde, SingleQuoted, SpaceAfter, Tagged};
463
464    #[derive(Debug, Deserialize, PartialEq)]
465    struct WrappersDoc {
466        seq: FlowSeq<Vec<u32>>,
467        map: FlowMap<std::collections::BTreeMap<String, u32>>,
468        after: SpaceAfter<String>,
469        nullable_tilde_none: NullableTilde<String>,
470        nullable_tilde_some: NullableTilde<String>,
471        #[cfg(feature = "comments")]
472        commented: Commented<bool>,
473        double_quoted: DoubleQuoted<String>,
474        single_quoted: SingleQuoted<String>,
475    }
476
477    #[test]
478    fn wrappers_remain_deserializable_without_serialize() {
479        let value: WrappersDoc = crate::from_str(
480            "seq: [1, 2]\nmap: {a: 1}\nafter: hello\nnullable_tilde_none: ~\nnullable_tilde_some: value\ncommented: true\ndouble_quoted: value\nsingle_quoted: value\n",
481        )
482        .unwrap();
483
484        assert_eq!(value.seq, FlowSeq(vec![1, 2]));
485        assert_eq!(value.after, SpaceAfter("hello".to_string()));
486        assert_eq!(value.nullable_tilde_none, NullableTilde(None));
487        assert_eq!(
488            value.nullable_tilde_some,
489            NullableTilde(Some("value".to_string()))
490        );
491        #[cfg(feature = "comments")]
492        assert_eq!(value.commented, Commented(true, String::new()));
493        assert_eq!(value.double_quoted, DoubleQuoted("value".to_string()));
494        assert_eq!(value.single_quoted, SingleQuoted("value".to_string()));
495        assert_eq!(value.map.0.get("a"), Some(&1));
496    }
497
498    #[test]
499    fn tagged_is_transparent_for_non_yaml_deserializers() {
500        let value: Tagged<u32> = serde_json::from_str("5").unwrap();
501
502        assert_eq!(value, Tagged(5, None));
503    }
504}