facet_yaml/
serializer.rs

1//! YAML serializer implementing the FormatSerializer trait.
2
3extern crate alloc;
4
5#[cfg_attr(feature = "fast", allow(unused_imports))]
6use alloc::{
7    format,
8    string::{String, ToString},
9    vec::Vec,
10};
11use core::fmt::{self, Debug};
12
13use facet_core::Facet;
14use facet_format::{FormatSerializer, ScalarValue, SerializeError, serialize_root};
15use facet_reflect::Peek;
16
17/// Error type for YAML serialization.
18#[derive(Debug)]
19pub struct YamlSerializeError {
20    msg: String,
21}
22
23impl fmt::Display for YamlSerializeError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.write_str(&self.msg)
26    }
27}
28
29impl std::error::Error for YamlSerializeError {}
30
31impl YamlSerializeError {
32    fn new(msg: impl Into<String>) -> Self {
33        Self { msg: msg.into() }
34    }
35}
36
37/// Context for tracking where we are in the output structure.
38#[derive(Debug, Clone, Copy)]
39enum Ctx {
40    /// In a struct/mapping
41    Struct { first: bool, indent: usize },
42    /// In a sequence/list
43    Seq { first: bool, indent: usize },
44}
45
46/// YAML serializer with streaming output.
47pub struct YamlSerializer {
48    out: Vec<u8>,
49    stack: Vec<Ctx>,
50    /// Whether we've written the document start marker
51    doc_started: bool,
52    /// Whether the next value should be inline (after a key)
53    inline_next: bool,
54}
55
56impl YamlSerializer {
57    /// Create a new YAML serializer.
58    pub fn new() -> Self {
59        Self {
60            out: Vec::new(),
61            stack: Vec::new(),
62            doc_started: false,
63            inline_next: false,
64        }
65    }
66
67    /// Consume the serializer and return the output bytes.
68    pub fn finish(self) -> Vec<u8> {
69        self.out
70    }
71
72    /// Current nesting depth (for indentation).
73    fn depth(&self) -> usize {
74        self.stack
75            .last()
76            .map(|ctx| match ctx {
77                Ctx::Struct { indent, .. } => *indent,
78                Ctx::Seq { indent, .. } => *indent,
79            })
80            .unwrap_or(0)
81    }
82
83    /// Check if a string needs quoting.
84    fn needs_quotes(s: &str) -> bool {
85        s.is_empty()
86            || s.contains(':')
87            || s.contains('#')
88            || s.contains('\n')
89            || s.contains('\r')
90            || s.contains('"')
91            || s.contains('\'')
92            || s.starts_with(' ')
93            || s.ends_with(' ')
94            || s.starts_with('-')
95            || s.starts_with('?')
96            || s.starts_with('*')
97            || s.starts_with('&')
98            || s.starts_with('!')
99            || s.starts_with('|')
100            || s.starts_with('>')
101            || s.starts_with('%')
102            || s.starts_with('@')
103            || s.starts_with('`')
104            || s.starts_with('[')
105            || s.starts_with('{')
106            || looks_like_bool(s)
107            || looks_like_null(s)
108            || looks_like_number(s)
109    }
110
111    /// Write a YAML string, quoting if necessary.
112    fn write_string(&mut self, s: &str) {
113        if Self::needs_quotes(s) {
114            self.out.push(b'"');
115            for c in s.chars() {
116                match c {
117                    '"' => self.out.extend_from_slice(b"\\\""),
118                    '\\' => self.out.extend_from_slice(b"\\\\"),
119                    '\n' => self.out.extend_from_slice(b"\\n"),
120                    '\r' => self.out.extend_from_slice(b"\\r"),
121                    '\t' => self.out.extend_from_slice(b"\\t"),
122                    c if c.is_control() => {
123                        self.out
124                            .extend_from_slice(format!("\\u{:04x}", c as u32).as_bytes());
125                    }
126                    c => {
127                        let mut buf = [0u8; 4];
128                        self.out
129                            .extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
130                    }
131                }
132            }
133            self.out.push(b'"');
134        } else {
135            self.out.extend_from_slice(s.as_bytes());
136        }
137    }
138
139    /// Write indentation for a given depth.
140    fn write_indent_for(&mut self, depth: usize) {
141        for _ in 0..depth {
142            self.out.extend_from_slice(b"  ");
143        }
144    }
145}
146
147impl Default for YamlSerializer {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl FormatSerializer for YamlSerializer {
154    type Error = YamlSerializeError;
155
156    fn begin_struct(&mut self) -> Result<(), Self::Error> {
157        // Write document start marker on first content
158        if !self.doc_started {
159            self.out.extend_from_slice(b"---\n");
160            self.doc_started = true;
161        }
162
163        let new_indent = self.depth();
164
165        // If we're inline (after a key:), we need a newline before struct content
166        if self.inline_next {
167            self.out.push(b'\n');
168            self.inline_next = false;
169        }
170
171        self.stack.push(Ctx::Struct {
172            first: true,
173            indent: new_indent,
174        });
175        Ok(())
176    }
177
178    fn field_key(&mut self, key: &str) -> Result<(), Self::Error> {
179        // Get current state
180        let (first, indent) = match self.stack.last() {
181            Some(Ctx::Struct { first, indent }) => (*first, *indent),
182            _ => {
183                return Err(YamlSerializeError::new(
184                    "field_key called outside of a struct",
185                ));
186            }
187        };
188
189        if !first {
190            self.out.push(b'\n');
191        }
192
193        // Write indentation
194        self.write_indent_for(indent);
195
196        self.write_string(key);
197        self.out.extend_from_slice(b": ");
198        self.inline_next = true;
199
200        // Update state
201        if let Some(Ctx::Struct {
202            first: f,
203            indent: i,
204        }) = self.stack.last_mut()
205        {
206            *f = false;
207            *i = indent + 1;
208        }
209
210        Ok(())
211    }
212
213    fn end_struct(&mut self) -> Result<(), Self::Error> {
214        match self.stack.pop() {
215            Some(Ctx::Struct { first, .. }) => {
216                // Empty struct - write {}
217                if first {
218                    if self.inline_next {
219                        self.inline_next = false;
220                    }
221                    self.out.extend_from_slice(b"{}");
222                }
223
224                // Restore parent indent
225                if let Some(Ctx::Struct { indent, .. }) = self.stack.last_mut() {
226                    *indent = indent.saturating_sub(1);
227                }
228
229                Ok(())
230            }
231            _ => Err(YamlSerializeError::new(
232                "end_struct called without matching begin_struct",
233            )),
234        }
235    }
236
237    fn begin_seq(&mut self) -> Result<(), Self::Error> {
238        // Write document start marker on first content
239        if !self.doc_started {
240            self.out.extend_from_slice(b"---\n");
241            self.doc_started = true;
242        }
243
244        let new_indent = self.depth();
245
246        // If we're inline (after a key:), we need a newline before sequence content
247        if self.inline_next {
248            self.out.push(b'\n');
249            self.inline_next = false;
250        }
251
252        self.stack.push(Ctx::Seq {
253            first: true,
254            indent: new_indent,
255        });
256        Ok(())
257    }
258
259    fn end_seq(&mut self) -> Result<(), Self::Error> {
260        match self.stack.pop() {
261            Some(Ctx::Seq { first, .. }) => {
262                // Empty sequence - write []
263                if first {
264                    if self.inline_next {
265                        self.inline_next = false;
266                    }
267                    self.out.extend_from_slice(b"[]");
268                }
269
270                // Restore parent indent
271                if let Some(Ctx::Struct { indent, .. }) = self.stack.last_mut() {
272                    *indent = indent.saturating_sub(1);
273                }
274
275                Ok(())
276            }
277            _ => Err(YamlSerializeError::new(
278                "end_seq called without matching begin_seq",
279            )),
280        }
281    }
282
283    fn scalar(&mut self, scalar: ScalarValue<'_>) -> Result<(), Self::Error> {
284        // Write document start marker on first content
285        if !self.doc_started {
286            self.out.extend_from_slice(b"---\n");
287            self.doc_started = true;
288        }
289
290        // Handle sequence item prefix
291        if let Some(Ctx::Seq { first, indent }) = self.stack.last_mut() {
292            if !*first {
293                self.out.push(b'\n');
294            }
295            *first = false;
296
297            // Write indentation
298            let indent_val = *indent;
299            self.write_indent_for(indent_val);
300            self.out.extend_from_slice(b"- ");
301        }
302
303        self.inline_next = false;
304
305        match scalar {
306            ScalarValue::Null => self.out.extend_from_slice(b"null"),
307            ScalarValue::Bool(v) => {
308                if v {
309                    self.out.extend_from_slice(b"true")
310                } else {
311                    self.out.extend_from_slice(b"false")
312                }
313            }
314            ScalarValue::I64(v) => {
315                #[cfg(feature = "fast")]
316                self.out
317                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
318                #[cfg(not(feature = "fast"))]
319                self.out.extend_from_slice(v.to_string().as_bytes());
320            }
321            ScalarValue::U64(v) => {
322                #[cfg(feature = "fast")]
323                self.out
324                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
325                #[cfg(not(feature = "fast"))]
326                self.out.extend_from_slice(v.to_string().as_bytes());
327            }
328            ScalarValue::I128(v) => {
329                #[cfg(feature = "fast")]
330                self.out
331                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
332                #[cfg(not(feature = "fast"))]
333                self.out.extend_from_slice(v.to_string().as_bytes());
334            }
335            ScalarValue::U128(v) => {
336                #[cfg(feature = "fast")]
337                self.out
338                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
339                #[cfg(not(feature = "fast"))]
340                self.out.extend_from_slice(v.to_string().as_bytes());
341            }
342            ScalarValue::F64(v) => {
343                #[cfg(feature = "fast")]
344                self.out
345                    .extend_from_slice(zmij::Buffer::new().format(v).as_bytes());
346                #[cfg(not(feature = "fast"))]
347                self.out.extend_from_slice(v.to_string().as_bytes());
348            }
349            ScalarValue::Str(s) => self.write_string(&s),
350            ScalarValue::Bytes(_) => {
351                return Err(YamlSerializeError::new(
352                    "bytes serialization not supported for YAML",
353                ));
354            }
355        }
356
357        // Restore parent indent after scalar in struct
358        if let Some(Ctx::Struct { indent, .. }) = self.stack.last_mut() {
359            *indent = indent.saturating_sub(1);
360        }
361
362        Ok(())
363    }
364}
365
366/// Check if string looks like a boolean
367fn looks_like_bool(s: &str) -> bool {
368    matches!(
369        s.to_lowercase().as_str(),
370        "true" | "false" | "yes" | "no" | "on" | "off" | "y" | "n"
371    )
372}
373
374/// Check if string looks like null
375fn looks_like_null(s: &str) -> bool {
376    matches!(s.to_lowercase().as_str(), "null" | "~" | "nil" | "none")
377}
378
379/// Check if string looks like a number
380fn looks_like_number(s: &str) -> bool {
381    if s.is_empty() {
382        return false;
383    }
384    let s = s.trim();
385    s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok()
386}
387
388// ============================================================================
389// Public API
390// ============================================================================
391
392/// Serialize a value to a YAML string.
393///
394/// # Example
395///
396/// ```
397/// use facet::Facet;
398/// use facet_yaml::to_string;
399///
400/// #[derive(Facet)]
401/// struct Config {
402///     name: String,
403///     port: u16,
404/// }
405///
406/// let config = Config {
407///     name: "myapp".to_string(),
408///     port: 8080,
409/// };
410///
411/// let yaml = to_string(&config).unwrap();
412/// assert!(yaml.contains("name: myapp"));
413/// assert!(yaml.contains("port: 8080"));
414/// ```
415pub fn to_string<'facet, T>(value: &T) -> Result<String, SerializeError<YamlSerializeError>>
416where
417    T: Facet<'facet> + ?Sized,
418{
419    let bytes = to_vec(value)?;
420    Ok(String::from_utf8(bytes).expect("YAML output should always be valid UTF-8"))
421}
422
423/// Serialize a value to YAML bytes.
424///
425/// # Example
426///
427/// ```
428/// use facet::Facet;
429/// use facet_yaml::to_vec;
430///
431/// #[derive(Facet)]
432/// struct Point { x: i32, y: i32 }
433///
434/// let point = Point { x: 10, y: 20 };
435/// let bytes = to_vec(&point).unwrap();
436/// assert!(!bytes.is_empty());
437/// ```
438pub fn to_vec<'facet, T>(value: &T) -> Result<Vec<u8>, SerializeError<YamlSerializeError>>
439where
440    T: Facet<'facet> + ?Sized,
441{
442    let mut serializer = YamlSerializer::new();
443    serialize_root(&mut serializer, Peek::new(value))?;
444    let mut output = serializer.finish();
445    // Ensure trailing newline
446    if !output.ends_with(b"\n") {
447        output.push(b'\n');
448    }
449    Ok(output)
450}
451
452/// Serialize a `Peek` instance to a YAML string.
453///
454/// This allows serializing values without requiring ownership, useful when
455/// you already have a `Peek` from reflection operations.
456pub fn peek_to_string<'input, 'facet>(
457    peek: Peek<'input, 'facet>,
458) -> Result<String, SerializeError<YamlSerializeError>> {
459    let mut serializer = YamlSerializer::new();
460    serialize_root(&mut serializer, peek)?;
461    let mut output = serializer.finish();
462    if !output.ends_with(b"\n") {
463        output.push(b'\n');
464    }
465    Ok(String::from_utf8(output).expect("YAML output should always be valid UTF-8"))
466}
467
468/// Serialize a value to YAML and write it to a `std::io::Write` writer.
469///
470/// # Example
471///
472/// ```
473/// use facet::Facet;
474/// use facet_yaml::to_writer;
475///
476/// #[derive(Facet)]
477/// struct Person {
478///     name: String,
479///     age: u32,
480/// }
481///
482/// let person = Person { name: "Alice".into(), age: 30 };
483/// let mut buffer = Vec::new();
484/// to_writer(&mut buffer, &person).unwrap();
485/// assert!(!buffer.is_empty());
486/// ```
487pub fn to_writer<'facet, W, T>(writer: W, value: &T) -> std::io::Result<()>
488where
489    W: std::io::Write,
490    T: Facet<'facet> + ?Sized,
491{
492    peek_to_writer(writer, Peek::new(value))
493}
494
495/// Serialize a `Peek` instance to YAML and write it to a `std::io::Write` writer.
496pub fn peek_to_writer<'input, 'facet, W>(
497    mut writer: W,
498    peek: Peek<'input, 'facet>,
499) -> std::io::Result<()>
500where
501    W: std::io::Write,
502{
503    let mut serializer = YamlSerializer::new();
504    serialize_root(&mut serializer, peek).map_err(|e| std::io::Error::other(format!("{:?}", e)))?;
505    let mut output = serializer.finish();
506    if !output.ends_with(b"\n") {
507        output.push(b'\n');
508    }
509    writer.write_all(&output)
510}