Skip to main content

noyalib/
sval_adapter.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! `sval` adapter — stream noyalib values through any
5//! [`sval::Stream`] consumer.
6//!
7//! Provides an alternative to the default serde route for callers
8//! who want to avoid `serde_derive`'s compile-time overhead or the
9//! binary-size cost of serde monomorphisation. `sval` is a small,
10//! streaming serialization framework: instead of materialising a
11//! data graph, the producer walks the source and emits events on
12//! a [`sval::Stream`] (similar in spirit to YAML's own event-driven
13//! parser).
14//!
15//! Gated behind the `sval` Cargo feature.
16//!
17//! # API surface
18//!
19//! * `impl sval::Value for [crate::Value]` — stream a noyalib
20//!   value graph to any [`sval::Stream`].
21//! * `impl sval::Value for [crate::Number]` — stream a single
22//!   number.
23//! * `to_sval_writer` — high-level helper that streams a
24//!   noyalib [`crate::Value`] to a writer that implements
25//!   [`sval::Stream`].
26//!
27//! serde remains the default route for typed deserialise; `sval`
28//! is an additive, opt-in surface for callers that prefer the
29//! streaming framework. The two routes share `Value`, so a
30//! roundtrip-via-`Value` works as expected.
31//!
32//! # Non-finite floats
33//!
34//! YAML's `.nan` / `.inf` / `-.inf` literals deserialise into
35//! `Value::Number(Number::Float(f64::NAN | INFINITY | NEG_INFINITY))`.
36//! The default streaming behaviour forwards them verbatim to
37//! `sval::Stream::f64`. **Some sval consumers — notably
38//! `sval_json` — reject non-finite floats at runtime.** Callers
39//! whose downstream cannot tolerate that should either filter
40//! out non-finites before streaming, or wrap their `sval::Stream`
41//! impl with a finite-coercion shim. The
42//! `to_sval_writer_with_config` entry point exposes a
43//! `coerce_non_finite_to_null` knob for top-level non-finite
44//! scalars; nested non-finites land in a follow-up cut.
45//!
46//! # Inverse direction
47//!
48//! v0.0.6 ships the noyalib → sval direction only. A
49//! sval → noyalib adapter (built on `sval_buffer::Value`) is
50//! tracked for a follow-up cut; for now, callers that need to
51//! ingest sval graphs into noyalib should go through serde or
52//! the `Value` AST.
53//!
54//! # Example
55//!
56//! ```
57//! use noyalib::Value;
58//! let v: Value = noyalib::from_str("name: noyalib").unwrap();
59//! // The `impl sval::Value for Value` lets you hand any
60//! // noyalib-parsed graph to any `sval::Stream` consumer.
61//! // Concrete stream impls are supplied by ecosystem crates
62//! // such as `sval_fmt`, `sval_json`, or your own — noyalib
63//! // does not pull those in.
64//! assert!(matches!(v, Value::Mapping(_)));
65//! ```
66
67use crate::value::{Mapping, MappingAny, Number, Tag, TaggedValue, Value};
68
69impl sval::Value for Value {
70    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
71        match self {
72            Self::Null => stream.null(),
73            Self::Bool(b) => stream.bool(*b),
74            Self::Number(n) => n.stream(stream),
75            Self::String(s) => stream.value(s.as_str()),
76            Self::Sequence(items) => stream_seq(items, stream),
77            Self::Mapping(m) => m.stream(stream),
78            Self::Tagged(t) => t.stream(stream),
79        }
80    }
81}
82
83impl sval::Value for Number {
84    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
85        match self {
86            Self::Integer(i) => stream.i64(*i),
87            #[cfg(feature = "lossless-u64")]
88            Self::Unsigned(u) => stream.u64(*u),
89            Self::Float(f) => stream.f64(*f),
90        }
91    }
92}
93
94impl sval::Value for Mapping {
95    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
96        stream.map_begin(Some(self.len()))?;
97        for (k, v) in self {
98            stream.map_key_begin()?;
99            stream.value(k.as_str())?;
100            stream.map_key_end()?;
101            stream.map_value_begin()?;
102            stream.value(v)?;
103            stream.map_value_end()?;
104        }
105        stream.map_end()
106    }
107}
108
109impl sval::Value for MappingAny {
110    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
111        stream.map_begin(Some(self.len()))?;
112        for (k, v) in self {
113            stream.map_key_begin()?;
114            stream.value(k)?;
115            stream.map_key_end()?;
116            stream.map_value_begin()?;
117            stream.value(v)?;
118            stream.map_value_end()?;
119        }
120        stream.map_end()
121    }
122}
123
124impl sval::Value for Tag {
125    /// A `Tag` on its own streams as plain text (the tag string).
126    /// The richer "tag-around-value" shape is in
127    /// [`impl sval::Value for TaggedValue`].
128    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
129        stream.value(self.as_str())
130    }
131}
132
133impl sval::Value for TaggedValue {
134    fn stream<'sval, S: sval::Stream<'sval> + ?Sized>(&'sval self, stream: &mut S) -> sval::Result {
135        // sval has no first-class "YAML tag" concept; surface the
136        // tag as a sval `tag` annotation around the inner value
137        // so consumers that care can introspect it, then stream
138        // the inner value normally.
139        let label = sval::Label::new_computed(self.tag().as_str());
140        stream.tagged_begin(None, Some(&label), None)?;
141        stream.value(self.value())?;
142        stream.tagged_end(None, Some(&label), None)
143    }
144}
145
146fn stream_seq<'sval, S: sval::Stream<'sval> + ?Sized>(
147    items: &'sval [Value],
148    stream: &mut S,
149) -> sval::Result {
150    stream.seq_begin(Some(items.len()))?;
151    for item in items {
152        stream.seq_value_begin()?;
153        stream.value(item)?;
154        stream.seq_value_end()?;
155    }
156    stream.seq_end()
157}
158
159/// Stream a [`Value`] into a writer that implements
160/// [`sval::Stream`].
161///
162/// Convenience wrapper around the [`sval::Value`] impl on
163/// [`Value`]: just calls `value.stream(stream)`. Useful as a
164/// named entry point so call sites read as
165/// `noyalib::sval_adapter::to_sval_writer(&mut stream, &value)`.
166///
167/// Pair with [`to_sval_writer_with_config`] to coerce non-finite
168/// floats (NaN, ±∞) into `Null` before emission — required for
169/// downstream sval consumers that reject non-finites
170/// (`sval_json` is the canonical example).
171///
172/// # Errors
173///
174/// Returns the underlying [`sval::Error`] from the stream
175/// implementation.
176pub fn to_sval_writer<'sval, S: sval::Stream<'sval> + ?Sized>(
177    stream: &mut S,
178    value: &'sval Value,
179) -> sval::Result {
180    sval::Value::stream(value, stream)
181}
182
183/// Knobs for [`to_sval_writer_with_config`].
184///
185/// Constructed via [`Self::default`]; tweak fields inline.
186#[derive(Debug, Clone, Copy, Default)]
187pub struct SvalConfig {
188    /// When `true`, non-finite floats (`f64::NAN`, `INFINITY`,
189    /// `NEG_INFINITY`) are emitted as `stream.null()` instead of
190    /// `stream.f64(_)`. Downstream sval consumers that reject
191    /// non-finites (notably `sval_json`) will accept the
192    /// resulting stream. Defaults to `false` — verbatim
193    /// forwarding matches the trait-impl behaviour.
194    pub coerce_non_finite_to_null: bool,
195}
196
197/// [`to_sval_writer`] with caller-supplied knobs.
198///
199/// The configured coercions are applied as a thin wrapper on top
200/// of the raw `sval::Value` impl — every non-`Value::Number` event
201/// is forwarded unchanged.
202///
203/// # Errors
204///
205/// Returns the underlying [`sval::Error`] from the stream
206/// implementation.
207pub fn to_sval_writer_with_config<'sval, S: sval::Stream<'sval> + ?Sized>(
208    stream: &mut S,
209    value: &'sval Value,
210    config: &SvalConfig,
211) -> sval::Result {
212    if config.coerce_non_finite_to_null
213        && let Value::Number(Number::Float(f)) = value
214        && !f.is_finite()
215    {
216        return stream.null();
217    }
218    // Numbers are forwarded as-is via the per-type impl when no
219    // coercion is requested or the float is finite. For
220    // composite shapes we'd need a full walking wrapper; that
221    // arrives in a follow-up cut once the API shape settles.
222    sval::Value::stream(value, stream)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::value::{Mapping, Number, Sequence};
229
230    /// Minimal `sval::Stream` that just records the event
231    /// sequence as strings — enough to assert structure.
232    #[derive(Default)]
233    struct Recorder(Vec<String>);
234
235    impl sval::Stream<'_> for Recorder {
236        fn null(&mut self) -> sval::Result {
237            self.0.push("null".into());
238            Ok(())
239        }
240        fn bool(&mut self, v: bool) -> sval::Result {
241            self.0.push(format!("bool({v})"));
242            Ok(())
243        }
244        fn i64(&mut self, v: i64) -> sval::Result {
245            self.0.push(format!("i64({v})"));
246            Ok(())
247        }
248        fn f64(&mut self, v: f64) -> sval::Result {
249            self.0.push(format!("f64({v})"));
250            Ok(())
251        }
252        fn text_begin(&mut self, _: Option<usize>) -> sval::Result {
253            self.0.push("text_begin".into());
254            Ok(())
255        }
256        fn text_fragment_computed(&mut self, fragment: &str) -> sval::Result {
257            self.0.push(format!("text({fragment})"));
258            Ok(())
259        }
260        fn text_end(&mut self) -> sval::Result {
261            self.0.push("text_end".into());
262            Ok(())
263        }
264        fn map_begin(&mut self, _: Option<usize>) -> sval::Result {
265            self.0.push("map_begin".into());
266            Ok(())
267        }
268        fn map_end(&mut self) -> sval::Result {
269            self.0.push("map_end".into());
270            Ok(())
271        }
272        fn map_key_begin(&mut self) -> sval::Result {
273            self.0.push("map_key_begin".into());
274            Ok(())
275        }
276        fn map_key_end(&mut self) -> sval::Result {
277            self.0.push("map_key_end".into());
278            Ok(())
279        }
280        fn map_value_begin(&mut self) -> sval::Result {
281            self.0.push("map_value_begin".into());
282            Ok(())
283        }
284        fn map_value_end(&mut self) -> sval::Result {
285            self.0.push("map_value_end".into());
286            Ok(())
287        }
288        fn seq_begin(&mut self, _: Option<usize>) -> sval::Result {
289            self.0.push("seq_begin".into());
290            Ok(())
291        }
292        fn seq_end(&mut self) -> sval::Result {
293            self.0.push("seq_end".into());
294            Ok(())
295        }
296        fn seq_value_begin(&mut self) -> sval::Result {
297            self.0.push("seq_value_begin".into());
298            Ok(())
299        }
300        fn seq_value_end(&mut self) -> sval::Result {
301            self.0.push("seq_value_end".into());
302            Ok(())
303        }
304    }
305
306    #[test]
307    fn null_streams_null_event() {
308        let mut r = Recorder::default();
309        sval::Value::stream(&Value::Null, &mut r).unwrap();
310        assert_eq!(r.0, vec!["null".to_string()]);
311    }
312
313    #[test]
314    fn bool_streams_bool_event() {
315        let mut r = Recorder::default();
316        sval::Value::stream(&Value::Bool(true), &mut r).unwrap();
317        assert_eq!(r.0, vec!["bool(true)".to_string()]);
318    }
319
320    #[test]
321    fn integer_streams_i64_event() {
322        let mut r = Recorder::default();
323        sval::Value::stream(&Value::Number(Number::Integer(42)), &mut r).unwrap();
324        assert_eq!(r.0, vec!["i64(42)".to_string()]);
325    }
326
327    #[test]
328    fn float_streams_f64_event() {
329        let mut r = Recorder::default();
330        sval::Value::stream(&Value::Number(Number::Float(2.5)), &mut r).unwrap();
331        assert_eq!(r.0, vec!["f64(2.5)".to_string()]);
332    }
333
334    #[test]
335    fn sequence_streams_seq_events() {
336        let mut r = Recorder::default();
337        let v: Sequence = vec![Value::Bool(true), Value::Bool(false)];
338        sval::Value::stream(&Value::Sequence(v), &mut r).unwrap();
339        let s = r.0.join(",");
340        assert!(s.contains("seq_begin"));
341        assert!(s.contains("bool(true)"));
342        assert!(s.contains("bool(false)"));
343        assert!(s.contains("seq_end"));
344    }
345
346    #[test]
347    fn tagged_value_wraps_inner() {
348        use crate::value::{Tag, TaggedValue};
349        let mut r = Recorder::default();
350        let tv = TaggedValue::new(Tag::new("!timestamp"), Value::String("2026".into()));
351        sval::Value::stream(&Value::Tagged(Box::new(tv)), &mut r).unwrap();
352        // The tagged_begin/tagged_end events use Label::new_computed
353        // and don't surface through the Recorder's matched callbacks,
354        // but the inner value's text events must still appear.
355        let s = r.0.join(",");
356        assert!(s.contains("text(2026)"));
357    }
358
359    #[test]
360    fn mapping_any_streams_value_keyed_events() {
361        use crate::value::{MappingAny, Number};
362        let mut r = Recorder::default();
363        let mut m = MappingAny::new();
364        let _ = m.insert(Value::Number(Number::Integer(7)), Value::Bool(true));
365        sval::Value::stream(&m, &mut r).unwrap();
366        let s = r.0.join(",");
367        assert!(s.contains("map_begin"));
368        assert!(s.contains("i64(7)"));
369        assert!(s.contains("bool(true)"));
370        assert!(s.contains("map_end"));
371    }
372
373    #[test]
374    fn number_impl_streams_directly() {
375        let mut r = Recorder::default();
376        sval::Value::stream(&Number::Integer(99), &mut r).unwrap();
377        assert_eq!(r.0, vec!["i64(99)".to_string()]);
378    }
379
380    #[test]
381    fn to_sval_writer_helper_streams() {
382        let mut r = Recorder::default();
383        let v = Value::Bool(false);
384        to_sval_writer(&mut r, &v).unwrap();
385        assert_eq!(r.0, vec!["bool(false)".to_string()]);
386    }
387
388    #[test]
389    fn tag_streams_as_text() {
390        let mut r = Recorder::default();
391        let t = Tag::new("!timestamp");
392        sval::Value::stream(&t, &mut r).unwrap();
393        let s = r.0.join(",");
394        assert!(s.contains("text(!timestamp)"));
395    }
396
397    #[test]
398    fn nan_coerced_to_null_via_config() {
399        let mut r = Recorder::default();
400        let v = Value::Number(Number::Float(f64::NAN));
401        let cfg = SvalConfig {
402            coerce_non_finite_to_null: true,
403        };
404        to_sval_writer_with_config(&mut r, &v, &cfg).unwrap();
405        assert_eq!(r.0, vec!["null".to_string()]);
406    }
407
408    #[test]
409    fn finite_float_passes_through_config() {
410        let mut r = Recorder::default();
411        let v = Value::Number(Number::Float(2.5));
412        let cfg = SvalConfig {
413            coerce_non_finite_to_null: true,
414        };
415        to_sval_writer_with_config(&mut r, &v, &cfg).unwrap();
416        assert_eq!(r.0, vec!["f64(2.5)".to_string()]);
417    }
418
419    #[test]
420    fn null_string_streams_via_value_route() {
421        // Exercises the Value::String branch of the dispatcher.
422        let mut r = Recorder::default();
423        sval::Value::stream(&Value::String("hello".into()), &mut r).unwrap();
424        let s = r.0.join(",");
425        assert!(s.contains("text(hello)"));
426    }
427
428    #[test]
429    fn mapping_streams_map_events() {
430        let mut r = Recorder::default();
431        let mut m = Mapping::new();
432        let _ = m.insert("k".to_string(), Value::Bool(true));
433        sval::Value::stream(&Value::Mapping(m), &mut r).unwrap();
434        let s = r.0.join(",");
435        assert!(s.contains("map_begin"));
436        assert!(s.contains("map_key_begin"));
437        assert!(s.contains("text(k)"));
438        assert!(s.contains("map_value_begin"));
439        assert!(s.contains("bool(true)"));
440        assert!(s.contains("map_end"));
441    }
442}