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 { indent: usize, has_fields: bool },
42    /// In a sequence/list
43    Seq { indent: usize, has_items: bool },
44}
45
46/// Where we are on the current line
47#[derive(Debug, Clone, Copy, PartialEq)]
48enum LinePos {
49    /// At the start of a new line (or document start)
50    Start,
51    /// Inline after "- " (first field of seq-item struct can go here)
52    AfterSeqMarker,
53    /// Inline somewhere else (after key:, after scalar, etc.)
54    Inline,
55}
56
57/// YAML serializer with streaming output.
58pub struct YamlSerializer {
59    out: Vec<u8>,
60    stack: Vec<Ctx>,
61    /// Whether we've written the document start marker
62    doc_started: bool,
63    /// Current position on the line
64    line_pos: LinePos,
65}
66
67impl YamlSerializer {
68    /// Create a new YAML serializer.
69    pub fn new() -> Self {
70        Self {
71            out: Vec::new(),
72            stack: Vec::new(),
73            doc_started: false,
74            line_pos: LinePos::Start,
75        }
76    }
77
78    /// Consume the serializer and return the output bytes.
79    pub fn finish(self) -> Vec<u8> {
80        self.out
81    }
82
83    /// Ensure document has started
84    fn ensure_doc_started(&mut self) {
85        if !self.doc_started {
86            self.out.extend_from_slice(b"---\n");
87            self.doc_started = true;
88            self.line_pos = LinePos::Start;
89        }
90    }
91
92    /// Write indentation for a given depth.
93    fn write_indent(&mut self, depth: usize) {
94        for _ in 0..depth {
95            self.out.extend_from_slice(b"  ");
96        }
97    }
98
99    /// Start a new line if we're not already at line start
100    fn newline(&mut self) {
101        if self.line_pos != LinePos::Start {
102            self.out.push(b'\n');
103            self.line_pos = LinePos::Start;
104        }
105    }
106
107    /// Prepare to write a sequence item.
108    /// After this, we're positioned right after "- ".
109    fn write_seq_item_prefix(&mut self, seq_indent: usize) {
110        self.newline();
111        self.write_indent(seq_indent);
112        self.out.extend_from_slice(b"- ");
113        self.line_pos = LinePos::AfterSeqMarker;
114    }
115
116    /// Prepare to write a struct field.
117    /// Handles newline and indentation.
118    fn write_field_prefix(&mut self, indent: usize) {
119        self.newline();
120        self.write_indent(indent);
121        self.line_pos = LinePos::Inline;
122    }
123
124    /// Get the current indentation level based on context stack.
125    fn current_indent(&self) -> usize {
126        match self.stack.last() {
127            Some(Ctx::Struct { indent, .. }) => *indent,
128            Some(Ctx::Seq { indent, .. }) => *indent,
129            None => 0,
130        }
131    }
132
133    /// Check if a string should use block scalar syntax.
134    /// Returns true for multiline strings that are suitable for literal block style.
135    fn should_use_block_scalar(s: &str) -> bool {
136        // Must contain at least one newline to benefit from block scalar
137        if !s.contains('\n') {
138            return false;
139        }
140
141        // Don't use block scalar for empty or whitespace-only strings
142        if s.trim().is_empty() {
143            return false;
144        }
145
146        // Avoid carriage returns - they complicate block scalar handling
147        if s.contains('\r') {
148            return false;
149        }
150
151        true
152    }
153
154    /// Write a string using block scalar (literal) syntax.
155    /// Uses `|` for strings with trailing newline, `|-` for strings without.
156    fn write_block_scalar(&mut self, s: &str, indent: usize) {
157        // Determine chomping indicator:
158        // - `|-` (strip): no trailing newline in output
159        // - `|` (clip): single trailing newline
160        // - `|+` (keep): preserve all trailing newlines
161        let chomping = if s.ends_with('\n') {
162            if s.ends_with("\n\n") {
163                "+" // keep multiple trailing newlines
164            } else {
165                "" // clip: single trailing newline (default)
166            }
167        } else {
168            "-" // strip: no trailing newline
169        };
170
171        self.out.push(b'|');
172        self.out.extend_from_slice(chomping.as_bytes());
173
174        // Write each line with proper indentation
175        // For |-/|, trim trailing newlines; for |+, preserve them
176        let content = if chomping == "+" {
177            s.trim_end_matches('\n')
178        } else if chomping == "-" {
179            s
180        } else {
181            s.trim_end_matches('\n')
182        };
183
184        for line in content.split('\n') {
185            self.out.push(b'\n');
186            self.write_indent(indent + 1);
187            self.out.extend_from_slice(line.as_bytes());
188        }
189
190        // For |+, add the trailing newlines
191        if chomping == "+" {
192            let trailing_count = s.len() - s.trim_end_matches('\n').len();
193            for _ in 1..trailing_count {
194                self.out.push(b'\n');
195            }
196        }
197
198        self.line_pos = LinePos::Inline;
199    }
200
201    /// Check if a string needs quoting (for inline/single-line strings).
202    fn needs_quotes(s: &str) -> bool {
203        s.is_empty()
204            || s.contains(':')
205            || s.contains('#')
206            || s.contains('\n')
207            || s.contains('\r')
208            || s.contains('"')
209            || s.contains('\'')
210            || s.starts_with(' ')
211            || s.ends_with(' ')
212            || s.starts_with('-')
213            || s.starts_with('?')
214            || s.starts_with('*')
215            || s.starts_with('&')
216            || s.starts_with('!')
217            || s.starts_with('|')
218            || s.starts_with('>')
219            || s.starts_with('%')
220            || s.starts_with('@')
221            || s.starts_with('`')
222            || s.starts_with('[')
223            || s.starts_with('{')
224            || looks_like_bool(s)
225            || looks_like_null(s)
226            || looks_like_number(s)
227    }
228
229    /// Write a YAML string, using block scalar for multiline or quoting if necessary.
230    fn write_string(&mut self, s: &str) {
231        if Self::should_use_block_scalar(s) {
232            let indent = self.current_indent();
233            self.write_block_scalar(s, indent);
234        } else if Self::needs_quotes(s) {
235            self.out.push(b'"');
236            for c in s.chars() {
237                match c {
238                    '"' => self.out.extend_from_slice(b"\\\""),
239                    '\\' => self.out.extend_from_slice(b"\\\\"),
240                    '\n' => self.out.extend_from_slice(b"\\n"),
241                    '\r' => self.out.extend_from_slice(b"\\r"),
242                    '\t' => self.out.extend_from_slice(b"\\t"),
243                    c if c.is_control() => {
244                        self.out
245                            .extend_from_slice(format!("\\u{:04x}", c as u32).as_bytes());
246                    }
247                    c => {
248                        let mut buf = [0u8; 4];
249                        self.out
250                            .extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
251                    }
252                }
253            }
254            self.out.push(b'"');
255            self.line_pos = LinePos::Inline;
256        } else {
257            self.out.extend_from_slice(s.as_bytes());
258            self.line_pos = LinePos::Inline;
259        }
260    }
261}
262
263impl Default for YamlSerializer {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269impl FormatSerializer for YamlSerializer {
270    type Error = YamlSerializeError;
271
272    fn begin_struct(&mut self) -> Result<(), Self::Error> {
273        self.ensure_doc_started();
274
275        // Check if we're inside a sequence - if so, this struct is a seq item
276        let (struct_indent, seq_indent_for_prefix) = match self.stack.last() {
277            Some(Ctx::Seq { indent, .. }) => {
278                // Struct fields will be at seq_indent + 1 to align after "- "
279                (*indent + 1, Some(*indent))
280            }
281            Some(Ctx::Struct { indent, .. }) => {
282                // Nested struct after a key - indent at parent level + 1
283                (*indent + 1, None)
284            }
285            None => {
286                // Top-level struct
287                (0, None)
288            }
289        };
290
291        // If this is a sequence item, write the "- " prefix and mark parent seq
292        if let Some(seq_indent) = seq_indent_for_prefix {
293            self.write_seq_item_prefix(seq_indent);
294            // Mark parent seq as having items
295            if let Some(Ctx::Seq { has_items, .. }) = self.stack.last_mut() {
296                *has_items = true;
297            }
298        }
299
300        // has_fields starts as false - we haven't written any fields yet
301        // The first field will detect via line_pos that we're inline after "- "
302        self.stack.push(Ctx::Struct {
303            indent: struct_indent,
304            has_fields: false,
305        });
306        Ok(())
307    }
308
309    fn field_key(&mut self, key: &str) -> Result<(), Self::Error> {
310        let (indent, has_fields) = match self.stack.last() {
311            Some(Ctx::Struct { indent, has_fields }) => (*indent, *has_fields),
312            _ => {
313                return Err(YamlSerializeError::new(
314                    "field_key called outside of a struct",
315                ));
316            }
317        };
318
319        // For the first field of a seq item struct, we're right after "- "
320        // Otherwise, we need newline + indent
321        if !has_fields && self.line_pos == LinePos::AfterSeqMarker {
322            // First field of seq-item struct: already have "- " on this line
323            // Don't write newline, just the key
324        } else {
325            // Normal case: newline + indent
326            self.write_field_prefix(indent);
327        }
328
329        self.write_string(key);
330        self.out.extend_from_slice(b": ");
331        self.line_pos = LinePos::Inline;
332
333        // Mark that we've written a field
334        if let Some(Ctx::Struct { has_fields, .. }) = self.stack.last_mut() {
335            *has_fields = true;
336        }
337
338        Ok(())
339    }
340
341    fn end_struct(&mut self) -> Result<(), Self::Error> {
342        match self.stack.pop() {
343            Some(Ctx::Struct { has_fields, .. }) => {
344                // Empty struct - write {}
345                if !has_fields {
346                    self.out.extend_from_slice(b"{}");
347                    self.line_pos = LinePos::Inline;
348                }
349                Ok(())
350            }
351            _ => Err(YamlSerializeError::new(
352                "end_struct called without matching begin_struct",
353            )),
354        }
355    }
356
357    fn begin_seq(&mut self) -> Result<(), Self::Error> {
358        self.ensure_doc_started();
359
360        // Check if we're inside a parent sequence
361        let (new_seq_indent, parent_seq_indent) = match self.stack.last() {
362            Some(Ctx::Seq { indent, .. }) => {
363                // Nested seq items will be at indent + 1
364                (*indent + 1, Some(*indent))
365            }
366            Some(Ctx::Struct { indent, .. }) => {
367                // Seq after a key like "tags: " - items will be indented at struct indent + 1
368                (*indent + 1, None)
369            }
370            None => {
371                // Top-level sequence
372                (0, None)
373            }
374        };
375
376        // If nested inside another sequence, write the "-" prefix
377        if let Some(parent_indent) = parent_seq_indent {
378            self.newline();
379            self.write_indent(parent_indent);
380            self.out.extend_from_slice(b"-");
381            self.line_pos = LinePos::Inline;
382            // Mark parent seq as having items
383            if let Some(Ctx::Seq { has_items, .. }) = self.stack.last_mut() {
384                *has_items = true;
385            }
386        }
387
388        self.stack.push(Ctx::Seq {
389            indent: new_seq_indent,
390            has_items: false,
391        });
392        Ok(())
393    }
394
395    fn end_seq(&mut self) -> Result<(), Self::Error> {
396        match self.stack.pop() {
397            Some(Ctx::Seq { has_items, .. }) => {
398                // Empty sequence - write []
399                if !has_items {
400                    self.out.extend_from_slice(b"[]");
401                    self.line_pos = LinePos::Inline;
402                }
403                Ok(())
404            }
405            _ => Err(YamlSerializeError::new(
406                "end_seq called without matching begin_seq",
407            )),
408        }
409    }
410
411    fn scalar(&mut self, scalar: ScalarValue<'_>) -> Result<(), Self::Error> {
412        self.ensure_doc_started();
413
414        // If we're in a sequence, write the item prefix
415        let seq_indent = match self.stack.last() {
416            Some(Ctx::Seq { indent, .. }) => Some(*indent),
417            _ => None,
418        };
419        if let Some(indent) = seq_indent {
420            self.write_seq_item_prefix(indent);
421            // Mark seq as having items
422            if let Some(Ctx::Seq { has_items, .. }) = self.stack.last_mut() {
423                *has_items = true;
424            }
425        }
426
427        match scalar {
428            ScalarValue::Null => self.out.extend_from_slice(b"null"),
429            ScalarValue::Bool(v) => {
430                if v {
431                    self.out.extend_from_slice(b"true")
432                } else {
433                    self.out.extend_from_slice(b"false")
434                }
435            }
436            ScalarValue::Char(c) => {
437                let mut buf = [0u8; 4];
438                self.write_string(c.encode_utf8(&mut buf));
439            }
440            ScalarValue::I64(v) => {
441                #[cfg(feature = "fast")]
442                self.out
443                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
444                #[cfg(not(feature = "fast"))]
445                self.out.extend_from_slice(v.to_string().as_bytes());
446            }
447            ScalarValue::U64(v) => {
448                #[cfg(feature = "fast")]
449                self.out
450                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
451                #[cfg(not(feature = "fast"))]
452                self.out.extend_from_slice(v.to_string().as_bytes());
453            }
454            ScalarValue::I128(v) => {
455                #[cfg(feature = "fast")]
456                self.out
457                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
458                #[cfg(not(feature = "fast"))]
459                self.out.extend_from_slice(v.to_string().as_bytes());
460            }
461            ScalarValue::U128(v) => {
462                #[cfg(feature = "fast")]
463                self.out
464                    .extend_from_slice(itoa::Buffer::new().format(v).as_bytes());
465                #[cfg(not(feature = "fast"))]
466                self.out.extend_from_slice(v.to_string().as_bytes());
467            }
468            ScalarValue::F64(v) => {
469                #[cfg(feature = "fast")]
470                self.out
471                    .extend_from_slice(zmij::Buffer::new().format(v).as_bytes());
472                #[cfg(not(feature = "fast"))]
473                self.out.extend_from_slice(v.to_string().as_bytes());
474            }
475            ScalarValue::Str(s) | ScalarValue::StringlyTyped(s) => self.write_string(&s),
476            ScalarValue::Bytes(_) => {
477                return Err(YamlSerializeError::new(
478                    "bytes serialization not supported for YAML",
479                ));
480            }
481        }
482
483        self.line_pos = LinePos::Inline;
484        Ok(())
485    }
486}
487
488/// Check if string looks like a boolean
489fn looks_like_bool(s: &str) -> bool {
490    matches!(
491        s.to_lowercase().as_str(),
492        "true" | "false" | "yes" | "no" | "on" | "off" | "y" | "n"
493    )
494}
495
496/// Check if string looks like null
497fn looks_like_null(s: &str) -> bool {
498    matches!(s.to_lowercase().as_str(), "null" | "~" | "nil" | "none")
499}
500
501/// Check if string looks like a number
502fn looks_like_number(s: &str) -> bool {
503    if s.is_empty() {
504        return false;
505    }
506    let s = s.trim();
507    s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok()
508}
509
510// ============================================================================
511// Public API
512// ============================================================================
513
514/// Serialize a value to a YAML string.
515///
516/// # Example
517///
518/// ```
519/// use facet::Facet;
520/// use facet_yaml::to_string;
521///
522/// #[derive(Facet)]
523/// struct Config {
524///     name: String,
525///     port: u16,
526/// }
527///
528/// let config = Config {
529///     name: "myapp".to_string(),
530///     port: 8080,
531/// };
532///
533/// let yaml = to_string(&config).unwrap();
534/// assert!(yaml.contains("name: myapp"));
535/// assert!(yaml.contains("port: 8080"));
536/// ```
537pub fn to_string<'facet, T>(value: &T) -> Result<String, SerializeError<YamlSerializeError>>
538where
539    T: Facet<'facet> + ?Sized,
540{
541    let bytes = to_vec(value)?;
542    Ok(String::from_utf8(bytes).expect("YAML output should always be valid UTF-8"))
543}
544
545/// Serialize a value to YAML bytes.
546///
547/// # Example
548///
549/// ```
550/// use facet::Facet;
551/// use facet_yaml::to_vec;
552///
553/// #[derive(Facet)]
554/// struct Point { x: i32, y: i32 }
555///
556/// let point = Point { x: 10, y: 20 };
557/// let bytes = to_vec(&point).unwrap();
558/// assert!(!bytes.is_empty());
559/// ```
560pub fn to_vec<'facet, T>(value: &T) -> Result<Vec<u8>, SerializeError<YamlSerializeError>>
561where
562    T: Facet<'facet> + ?Sized,
563{
564    let mut serializer = YamlSerializer::new();
565    serialize_root(&mut serializer, Peek::new(value))?;
566    let mut output = serializer.finish();
567    // Ensure trailing newline
568    if !output.ends_with(b"\n") {
569        output.push(b'\n');
570    }
571    Ok(output)
572}
573
574/// Serialize a `Peek` instance to a YAML string.
575///
576/// This allows serializing values without requiring ownership, useful when
577/// you already have a `Peek` from reflection operations.
578pub fn peek_to_string<'input, 'facet>(
579    peek: Peek<'input, 'facet>,
580) -> Result<String, SerializeError<YamlSerializeError>> {
581    let mut serializer = YamlSerializer::new();
582    serialize_root(&mut serializer, peek)?;
583    let mut output = serializer.finish();
584    if !output.ends_with(b"\n") {
585        output.push(b'\n');
586    }
587    Ok(String::from_utf8(output).expect("YAML output should always be valid UTF-8"))
588}
589
590/// Serialize a value to YAML and write it to a `std::io::Write` writer.
591///
592/// # Example
593///
594/// ```
595/// use facet::Facet;
596/// use facet_yaml::to_writer;
597///
598/// #[derive(Facet)]
599/// struct Person {
600///     name: String,
601///     age: u32,
602/// }
603///
604/// let person = Person { name: "Alice".into(), age: 30 };
605/// let mut buffer = Vec::new();
606/// to_writer(&mut buffer, &person).unwrap();
607/// assert!(!buffer.is_empty());
608/// ```
609pub fn to_writer<'facet, W, T>(writer: W, value: &T) -> std::io::Result<()>
610where
611    W: std::io::Write,
612    T: Facet<'facet> + ?Sized,
613{
614    peek_to_writer(writer, Peek::new(value))
615}
616
617/// Serialize a `Peek` instance to YAML and write it to a `std::io::Write` writer.
618pub fn peek_to_writer<'input, 'facet, W>(
619    mut writer: W,
620    peek: Peek<'input, 'facet>,
621) -> std::io::Result<()>
622where
623    W: std::io::Write,
624{
625    let mut serializer = YamlSerializer::new();
626    serialize_root(&mut serializer, peek).map_err(|e| std::io::Error::other(format!("{:?}", e)))?;
627    let mut output = serializer.finish();
628    if !output.ends_with(b"\n") {
629        output.push(b'\n');
630    }
631    writer.write_all(&output)
632}