Skip to main content

rlsp_yaml_parser/
emitter.rs

1// SPDX-License-Identifier: MIT
2
3//! YAML emitter — converts AST `Document<Span>` values back to YAML text.
4//!
5//! The entry points are [`emit`] and [`emit_to_writer`].  Both accept a slice
6//! of documents and an [`EmitConfig`] that controls indentation, line width,
7//! and default scalar/collection styles.
8
9use std::io::{self, Write};
10
11use crate::event::{Chomp, ScalarStyle};
12use crate::node::{Document, Node};
13use crate::pos::Span;
14
15// ---------------------------------------------------------------------------
16// Public configuration
17// ---------------------------------------------------------------------------
18
19/// Whether a collection is emitted in block or flow style.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CollectionStyle {
22    /// Multi-line block style (indented).
23    Block,
24    /// Inline flow style (`{key: val}` / `[a, b]`).
25    Flow,
26}
27
28/// Configuration for the emitter.
29#[derive(Debug, Clone)]
30pub struct EmitConfig {
31    /// Spaces per indentation level (default 2).
32    pub indent_width: usize,
33    /// Soft line-width hint (default 80). Not currently enforced strictly.
34    pub line_width: usize,
35    /// Default style for scalars that have no explicit style set.
36    pub default_scalar_style: ScalarStyle,
37    /// Default style for collections.
38    pub default_collection_style: CollectionStyle,
39}
40
41impl Default for EmitConfig {
42    fn default() -> Self {
43        Self {
44            indent_width: 2,
45            line_width: 80,
46            default_scalar_style: ScalarStyle::Plain,
47            default_collection_style: CollectionStyle::Block,
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// Public entry points
54// ---------------------------------------------------------------------------
55
56/// Emit `documents` to a `String` using `config`.
57///
58/// # Panics
59///
60/// Never panics in practice — panics are unreachable because writing to an
61/// in-memory `Vec<u8>` is infallible and the emitter only produces valid UTF-8.
62#[must_use]
63pub fn emit(documents: &[Document<Span>], config: &EmitConfig) -> String {
64    let mut buf = Vec::new();
65    // Writing to Vec<u8> is infallible.
66    let _: io::Result<()> = emit_to_writer(documents, config, &mut buf);
67    // SAFETY: the emitter only writes ASCII and existing String content.
68    String::from_utf8(buf).unwrap_or_default()
69}
70
71/// Emit `documents` to `writer` using `config`.
72///
73/// # Errors
74///
75/// Returns an error if writing to `writer` fails.
76pub fn emit_to_writer(
77    documents: &[Document<Span>],
78    config: &EmitConfig,
79    writer: &mut dyn Write,
80) -> io::Result<()> {
81    let mut emitter = Emitter { config, writer };
82    for (i, doc) in documents.iter().enumerate() {
83        emitter.emit_document(doc, i > 0)?;
84    }
85    Ok(())
86}
87
88// ---------------------------------------------------------------------------
89// Internal emitter state
90// ---------------------------------------------------------------------------
91
92struct Emitter<'a> {
93    config: &'a EmitConfig,
94    writer: &'a mut dyn Write,
95}
96
97impl Emitter<'_> {
98    fn emit_document(&mut self, doc: &Document<Span>, is_multi: bool) -> io::Result<()> {
99        // Emit document-start marker when there are multiple documents or
100        // a version directive is present.
101        if is_multi || doc.version.is_some() {
102            writeln!(self.writer, "---")?;
103        }
104
105        // Emit comments before the root node.
106        for comment in &doc.comments {
107            writeln!(self.writer, "# {comment}")?;
108        }
109
110        self.emit_node(&doc.root, 0, false)?;
111
112        // Ensure the document ends with a newline.
113        writeln!(self.writer)?;
114        Ok(())
115    }
116
117    fn emit_node(&mut self, node: &Node<Span>, indent: usize, in_flow: bool) -> io::Result<()> {
118        match node {
119            Node::Scalar {
120                value,
121                style,
122                anchor,
123                tag,
124                ..
125            } => self.emit_scalar(value, *style, anchor.as_deref(), tag.as_deref()),
126            Node::Mapping {
127                entries,
128                anchor,
129                tag,
130                ..
131            } => self.emit_mapping(entries, anchor.as_deref(), tag.as_deref(), indent, in_flow),
132            Node::Sequence {
133                items, anchor, tag, ..
134            } => self.emit_sequence(items, anchor.as_deref(), tag.as_deref(), indent, in_flow),
135            Node::Alias { name, .. } => write!(self.writer, "*{name}"),
136        }
137    }
138
139    // -----------------------------------------------------------------------
140    // Scalar
141    // -----------------------------------------------------------------------
142
143    fn emit_scalar(
144        &mut self,
145        value: &str,
146        style: ScalarStyle,
147        anchor: Option<&str>,
148        tag: Option<&str>,
149    ) -> io::Result<()> {
150        if let Some(name) = anchor {
151            write!(self.writer, "&{name} ")?;
152        }
153        if let Some(t) = tag {
154            write!(self.writer, "{} ", format_tag(t))?;
155        }
156        match style {
157            ScalarStyle::Plain => {
158                let s = if needs_quoting(value) {
159                    format!("'{}'", value.replace('\'', "''"))
160                } else {
161                    value.to_owned()
162                };
163                write!(self.writer, "{s}")
164            }
165            ScalarStyle::SingleQuoted => {
166                write!(self.writer, "'{}'", value.replace('\'', "''"))
167            }
168            ScalarStyle::DoubleQuoted => {
169                write!(self.writer, "\"{}\"", escape_double(value))
170            }
171            ScalarStyle::Literal(chomp) => {
172                let indicator = chomp_indicator(chomp);
173                writeln!(self.writer, "|{indicator}")?;
174                for line in value.split('\n') {
175                    writeln!(self.writer, "  {line}")?;
176                }
177                Ok(())
178            }
179            ScalarStyle::Folded(chomp) => {
180                let indicator = chomp_indicator(chomp);
181                writeln!(self.writer, ">{indicator}")?;
182                for line in value.split('\n') {
183                    writeln!(self.writer, "  {line}")?;
184                }
185                Ok(())
186            }
187        }
188    }
189
190    // -----------------------------------------------------------------------
191    // Mapping
192    // -----------------------------------------------------------------------
193
194    fn emit_mapping(
195        &mut self,
196        entries: &[(Node<Span>, Node<Span>)],
197        anchor: Option<&str>,
198        tag: Option<&str>,
199        indent: usize,
200        in_flow: bool,
201    ) -> io::Result<()> {
202        // Determine effective style.
203        let style = if in_flow {
204            CollectionStyle::Flow
205        } else {
206            self.config.default_collection_style
207        };
208
209        // Prefix.
210        if let Some(name) = anchor {
211            write!(self.writer, "&{name} ")?;
212        }
213        if let Some(t) = tag {
214            write!(self.writer, "{} ", format_tag(t))?;
215        }
216
217        if entries.is_empty() {
218            return write!(self.writer, "{{}}");
219        }
220
221        match style {
222            CollectionStyle::Flow => {
223                write!(self.writer, "{{")?;
224                for (i, (key, value)) in entries.iter().enumerate() {
225                    if i > 0 {
226                        write!(self.writer, ", ")?;
227                    }
228                    self.emit_node(key, indent, true)?;
229                    write!(self.writer, ": ")?;
230                    self.emit_node(value, indent, true)?;
231                }
232                write!(self.writer, "}}")
233            }
234            CollectionStyle::Block => {
235                let child_indent = indent + self.config.indent_width;
236                let pad: String = " ".repeat(indent);
237                for (key, value) in entries {
238                    write!(self.writer, "{pad}")?;
239                    self.emit_node(key, child_indent, false)?;
240                    write!(self.writer, ": ")?;
241                    // If value is a block collection, put it on the next line.
242                    if is_block_collection(value, self.config.default_collection_style) {
243                        writeln!(self.writer)?;
244                        write!(self.writer, "{}", " ".repeat(child_indent))?;
245                    }
246                    self.emit_node(value, child_indent, false)?;
247                    writeln!(self.writer)?;
248                }
249                Ok(())
250            }
251        }
252    }
253
254    // -----------------------------------------------------------------------
255    // Sequence
256    // -----------------------------------------------------------------------
257
258    fn emit_sequence(
259        &mut self,
260        items: &[Node<Span>],
261        anchor: Option<&str>,
262        tag: Option<&str>,
263        indent: usize,
264        in_flow: bool,
265    ) -> io::Result<()> {
266        let style = if in_flow {
267            CollectionStyle::Flow
268        } else {
269            self.config.default_collection_style
270        };
271
272        if let Some(name) = anchor {
273            write!(self.writer, "&{name} ")?;
274        }
275        if let Some(t) = tag {
276            write!(self.writer, "{} ", format_tag(t))?;
277        }
278
279        if items.is_empty() {
280            return write!(self.writer, "[]");
281        }
282
283        match style {
284            CollectionStyle::Flow => {
285                write!(self.writer, "[")?;
286                for (i, item) in items.iter().enumerate() {
287                    if i > 0 {
288                        write!(self.writer, ", ")?;
289                    }
290                    self.emit_node(item, indent, true)?;
291                }
292                write!(self.writer, "]")
293            }
294            CollectionStyle::Block => {
295                let pad: String = " ".repeat(indent);
296                let child_indent = indent + self.config.indent_width;
297                for item in items {
298                    write!(self.writer, "{pad}- ")?;
299                    self.emit_node(item, child_indent, false)?;
300                    writeln!(self.writer)?;
301                }
302                Ok(())
303            }
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Helpers
310// ---------------------------------------------------------------------------
311
312/// Returns true if a plain scalar value must be quoted to round-trip safely.
313fn needs_quoting(value: &str) -> bool {
314    // Empty string must be quoted.
315    if value.is_empty() {
316        return true;
317    }
318    // Reserved words that would be interpreted as non-string types.
319    matches!(
320        value,
321        "null"
322            | "Null"
323            | "NULL"
324            | "~"
325            | "true"
326            | "True"
327            | "TRUE"
328            | "false"
329            | "False"
330            | "FALSE"
331            | ".inf"
332            | ".Inf"
333            | ".INF"
334            | "-.inf"
335            | "-.Inf"
336            | "-.INF"
337            | ".nan"
338            | ".NaN"
339            | ".NAN"
340    )
341}
342
343/// Escape special characters for double-quoted scalars.
344fn escape_double(value: &str) -> String {
345    let mut out = String::with_capacity(value.len());
346    for ch in value.chars() {
347        match ch {
348            '"' => out.push_str("\\\""),
349            '\\' => out.push_str("\\\\"),
350            '\n' => out.push_str("\\n"),
351            '\r' => out.push_str("\\r"),
352            '\t' => out.push_str("\\t"),
353            c => out.push(c),
354        }
355    }
356    out
357}
358
359/// Returns the chomping indicator character for block scalars.
360const fn chomp_indicator(chomp: Chomp) -> &'static str {
361    match chomp {
362        Chomp::Strip => "-",
363        Chomp::Clip => "",
364        Chomp::Keep => "+",
365    }
366}
367
368/// Format a tag handle for emission.
369fn format_tag(tag: &str) -> String {
370    tag.strip_prefix("tag:yaml.org,2002:").map_or_else(
371        || {
372            if tag.starts_with('!') {
373                tag.to_owned()
374            } else {
375                format!("!<{tag}>")
376            }
377        },
378        |suffix| format!("!!{suffix}"),
379    )
380}
381
382/// Returns true when a node is a block-style collection (needs its own line).
383const fn is_block_collection(node: &Node<Span>, default_style: CollectionStyle) -> bool {
384    match (node, default_style) {
385        (Node::Mapping { entries, .. }, CollectionStyle::Block) if !entries.is_empty() => true,
386        (Node::Sequence { items, .. }, CollectionStyle::Block) if !items.is_empty() => true,
387        _ => false,
388    }
389}
390
391// ---------------------------------------------------------------------------
392// Tests
393// ---------------------------------------------------------------------------
394
395#[cfg(test)]
396#[allow(
397    clippy::expect_used,
398    clippy::unwrap_used,
399    clippy::indexing_slicing,
400    clippy::panic
401)]
402mod tests {
403    use super::*;
404    use crate::loader::load;
405
406    // -----------------------------------------------------------------------
407    // Helpers
408    // -----------------------------------------------------------------------
409
410    fn null_span() -> Span {
411        use crate::pos::Pos;
412        let p = Pos {
413            byte_offset: 0,
414            char_offset: 0,
415            line: 1,
416            column: 0,
417        };
418        Span { start: p, end: p }
419    }
420
421    fn scalar(value: &str) -> Node<Span> {
422        Node::Scalar {
423            value: value.to_owned(),
424            style: ScalarStyle::Plain,
425            anchor: None,
426            tag: None,
427            loc: null_span(),
428            leading_comments: Vec::new(),
429            trailing_comment: None,
430        }
431    }
432
433    fn doc(root: Node<Span>) -> Document<Span> {
434        Document {
435            root,
436            version: None,
437            tags: vec![],
438            comments: vec![],
439        }
440    }
441
442    fn default_config() -> EmitConfig {
443        EmitConfig::default()
444    }
445
446    fn reload_one(yaml: &str) -> Node<Span> {
447        load(yaml)
448            .expect("reload failed")
449            .into_iter()
450            .next()
451            .unwrap()
452            .root
453    }
454
455    // -----------------------------------------------------------------------
456    // Group 1: Spike test
457    // -----------------------------------------------------------------------
458
459    #[test]
460    fn emit_plain_scalar_round_trips() {
461        let docs = load("hello\n").expect("parse failed");
462        let config = default_config();
463
464        let result = emit(&docs, &config);
465
466        assert!(result.contains("hello"), "result: {result:?}");
467        assert!(load(&result).is_ok(), "reload failed: {result:?}");
468    }
469
470    // -----------------------------------------------------------------------
471    // Group 2: EmitConfig defaults
472    // -----------------------------------------------------------------------
473
474    #[test]
475    fn config_default_indent_width_is_2() {
476        assert_eq!(EmitConfig::default().indent_width, 2);
477    }
478
479    #[test]
480    fn config_default_line_width_is_80() {
481        assert_eq!(EmitConfig::default().line_width, 80);
482    }
483
484    #[test]
485    fn config_default_scalar_style_is_plain() {
486        assert!(matches!(
487            EmitConfig::default().default_scalar_style,
488            ScalarStyle::Plain
489        ));
490    }
491
492    #[test]
493    fn config_default_collection_style_is_block() {
494        assert!(matches!(
495            EmitConfig::default().default_collection_style,
496            CollectionStyle::Block
497        ));
498    }
499
500    #[test]
501    fn config_indent_width_4_used_in_block_mapping() {
502        let config = EmitConfig {
503            indent_width: 4,
504            ..EmitConfig::default()
505        };
506        let root = Node::Mapping {
507            entries: vec![(scalar("key"), scalar("value"))],
508            anchor: None,
509            tag: None,
510            loc: null_span(),
511            leading_comments: Vec::new(),
512            trailing_comment: None,
513        };
514        let result = emit(&[doc(root)], &config);
515        // key appears at indent 0; "value" is on same line after ": "
516        assert!(result.contains("key: value"), "result: {result:?}");
517    }
518
519    // -----------------------------------------------------------------------
520    // Group 3: Plain scalar styles
521    // -----------------------------------------------------------------------
522
523    #[test]
524    fn plain_scalar_emits_unquoted() {
525        let result = emit(&[doc(scalar("hello"))], &default_config());
526        assert!(result.contains("hello"), "result: {result:?}");
527        assert!(!result.contains('"'), "result: {result:?}");
528        assert!(!result.contains('\''), "result: {result:?}");
529    }
530
531    #[test]
532    fn plain_scalar_empty_string_emits_quoted() {
533        let node = Node::Scalar {
534            value: String::new(),
535            style: ScalarStyle::Plain,
536            anchor: None,
537            tag: None,
538            loc: null_span(),
539            leading_comments: Vec::new(),
540            trailing_comment: None,
541        };
542        let result = emit(&[doc(node)], &default_config());
543        // Empty plain scalar must be quoted to distinguish from null.
544        assert!(result.contains("''"), "result: {result:?}");
545    }
546
547    #[test]
548    fn plain_scalar_null_word_emits_single_quoted() {
549        let node = Node::Scalar {
550            value: "null".to_owned(),
551            style: ScalarStyle::Plain,
552            anchor: None,
553            tag: None,
554            loc: null_span(),
555            leading_comments: Vec::new(),
556            trailing_comment: None,
557        };
558        let result = emit(&[doc(node)], &default_config());
559        assert!(result.contains("'null'"), "result: {result:?}");
560    }
561
562    #[test]
563    fn plain_scalar_true_emits_single_quoted() {
564        let node = Node::Scalar {
565            value: "true".to_owned(),
566            style: ScalarStyle::Plain,
567            anchor: None,
568            tag: None,
569            loc: null_span(),
570            leading_comments: Vec::new(),
571            trailing_comment: None,
572        };
573        let result = emit(&[doc(node)], &default_config());
574        assert!(result.contains("'true'"), "result: {result:?}");
575    }
576
577    #[test]
578    fn plain_scalar_false_emits_single_quoted() {
579        let node = Node::Scalar {
580            value: "false".to_owned(),
581            style: ScalarStyle::Plain,
582            anchor: None,
583            tag: None,
584            loc: null_span(),
585            leading_comments: Vec::new(),
586            trailing_comment: None,
587        };
588        let result = emit(&[doc(node)], &default_config());
589        assert!(result.contains("'false'"), "result: {result:?}");
590    }
591
592    #[test]
593    fn plain_scalar_tilde_emits_single_quoted() {
594        let node = Node::Scalar {
595            value: "~".to_owned(),
596            style: ScalarStyle::Plain,
597            anchor: None,
598            tag: None,
599            loc: null_span(),
600            leading_comments: Vec::new(),
601            trailing_comment: None,
602        };
603        let result = emit(&[doc(node)], &default_config());
604        assert!(result.contains("'~'"), "result: {result:?}");
605    }
606
607    #[test]
608    fn plain_scalar_inf_emits_single_quoted() {
609        let node = Node::Scalar {
610            value: ".inf".to_owned(),
611            style: ScalarStyle::Plain,
612            anchor: None,
613            tag: None,
614            loc: null_span(),
615            leading_comments: Vec::new(),
616            trailing_comment: None,
617        };
618        let result = emit(&[doc(node)], &default_config());
619        assert!(result.contains("'.inf'"), "result: {result:?}");
620    }
621
622    // -----------------------------------------------------------------------
623    // Group 4: Single-quoted scalar
624    // -----------------------------------------------------------------------
625
626    #[test]
627    fn single_quoted_scalar_wraps_in_single_quotes() {
628        let node = Node::Scalar {
629            value: "hello world".to_owned(),
630            style: ScalarStyle::SingleQuoted,
631            anchor: None,
632            tag: None,
633            loc: null_span(),
634            leading_comments: Vec::new(),
635            trailing_comment: None,
636        };
637        let result = emit(&[doc(node)], &default_config());
638        assert!(result.contains("'hello world'"), "result: {result:?}");
639    }
640
641    #[test]
642    fn single_quoted_scalar_escapes_embedded_single_quote() {
643        let node = Node::Scalar {
644            value: "it's".to_owned(),
645            style: ScalarStyle::SingleQuoted,
646            anchor: None,
647            tag: None,
648            loc: null_span(),
649            leading_comments: Vec::new(),
650            trailing_comment: None,
651        };
652        let result = emit(&[doc(node)], &default_config());
653        assert!(result.contains("'it''s'"), "result: {result:?}");
654    }
655
656    // -----------------------------------------------------------------------
657    // Group 5: Double-quoted scalar
658    // -----------------------------------------------------------------------
659
660    #[test]
661    fn double_quoted_scalar_wraps_in_double_quotes() {
662        let node = Node::Scalar {
663            value: "hello world".to_owned(),
664            style: ScalarStyle::DoubleQuoted,
665            anchor: None,
666            tag: None,
667            loc: null_span(),
668            leading_comments: Vec::new(),
669            trailing_comment: None,
670        };
671        let result = emit(&[doc(node)], &default_config());
672        assert!(result.contains("\"hello world\""), "result: {result:?}");
673    }
674
675    #[test]
676    fn double_quoted_scalar_escapes_embedded_quote() {
677        let node = Node::Scalar {
678            value: "say \"hi\"".to_owned(),
679            style: ScalarStyle::DoubleQuoted,
680            anchor: None,
681            tag: None,
682            loc: null_span(),
683            leading_comments: Vec::new(),
684            trailing_comment: None,
685        };
686        let result = emit(&[doc(node)], &default_config());
687        assert!(result.contains(r#""say \"hi\"""#), "result: {result:?}");
688    }
689
690    #[test]
691    fn double_quoted_scalar_escapes_newline() {
692        let node = Node::Scalar {
693            value: "line1\nline2".to_owned(),
694            style: ScalarStyle::DoubleQuoted,
695            anchor: None,
696            tag: None,
697            loc: null_span(),
698            leading_comments: Vec::new(),
699            trailing_comment: None,
700        };
701        let result = emit(&[doc(node)], &default_config());
702        assert!(result.contains("\"line1\\nline2\""), "result: {result:?}");
703    }
704
705    // -----------------------------------------------------------------------
706    // Group 6: Block scalar (literal)
707    // -----------------------------------------------------------------------
708
709    #[test]
710    fn literal_block_scalar_clip_emits_pipe() {
711        let node = Node::Scalar {
712            value: "line1\nline2".to_owned(),
713            style: ScalarStyle::Literal(Chomp::Clip),
714            anchor: None,
715            tag: None,
716            loc: null_span(),
717            leading_comments: Vec::new(),
718            trailing_comment: None,
719        };
720        let result = emit(&[doc(node)], &default_config());
721        assert!(result.contains("|\n"), "result: {result:?}");
722        assert!(result.contains("line1"), "result: {result:?}");
723    }
724
725    #[test]
726    fn literal_block_scalar_strip_emits_pipe_minus() {
727        let node = Node::Scalar {
728            value: "line1\nline2".to_owned(),
729            style: ScalarStyle::Literal(Chomp::Strip),
730            anchor: None,
731            tag: None,
732            loc: null_span(),
733            leading_comments: Vec::new(),
734            trailing_comment: None,
735        };
736        let result = emit(&[doc(node)], &default_config());
737        assert!(result.contains("|-\n"), "result: {result:?}");
738    }
739
740    #[test]
741    fn literal_block_scalar_keep_emits_pipe_plus() {
742        let node = Node::Scalar {
743            value: "line1\nline2".to_owned(),
744            style: ScalarStyle::Literal(Chomp::Keep),
745            anchor: None,
746            tag: None,
747            loc: null_span(),
748            leading_comments: Vec::new(),
749            trailing_comment: None,
750        };
751        let result = emit(&[doc(node)], &default_config());
752        assert!(result.contains("|+\n"), "result: {result:?}");
753    }
754
755    // -----------------------------------------------------------------------
756    // Group 7: Block scalar (folded)
757    // -----------------------------------------------------------------------
758
759    #[test]
760    fn folded_block_scalar_clip_emits_gt() {
761        let node = Node::Scalar {
762            value: "line1\nline2".to_owned(),
763            style: ScalarStyle::Folded(Chomp::Clip),
764            anchor: None,
765            tag: None,
766            loc: null_span(),
767            leading_comments: Vec::new(),
768            trailing_comment: None,
769        };
770        let result = emit(&[doc(node)], &default_config());
771        assert!(result.contains(">\n"), "result: {result:?}");
772        assert!(result.contains("line1"), "result: {result:?}");
773    }
774
775    #[test]
776    fn folded_block_scalar_strip_emits_gt_minus() {
777        let node = Node::Scalar {
778            value: "line1\nline2".to_owned(),
779            style: ScalarStyle::Folded(Chomp::Strip),
780            anchor: None,
781            tag: None,
782            loc: null_span(),
783            leading_comments: Vec::new(),
784            trailing_comment: None,
785        };
786        let result = emit(&[doc(node)], &default_config());
787        assert!(result.contains(">-\n"), "result: {result:?}");
788    }
789
790    #[test]
791    fn folded_block_scalar_keep_emits_gt_plus() {
792        let node = Node::Scalar {
793            value: "line1\nline2".to_owned(),
794            style: ScalarStyle::Folded(Chomp::Keep),
795            anchor: None,
796            tag: None,
797            loc: null_span(),
798            leading_comments: Vec::new(),
799            trailing_comment: None,
800        };
801        let result = emit(&[doc(node)], &default_config());
802        assert!(result.contains(">+\n"), "result: {result:?}");
803    }
804
805    // -----------------------------------------------------------------------
806    // Group 8: Block collections
807    // -----------------------------------------------------------------------
808
809    #[test]
810    fn block_mapping_emits_key_colon_value() {
811        let root = Node::Mapping {
812            entries: vec![(scalar("name"), scalar("Alice"))],
813            anchor: None,
814            tag: None,
815            loc: null_span(),
816            leading_comments: Vec::new(),
817            trailing_comment: None,
818        };
819        let result = emit(&[doc(root)], &default_config());
820        assert!(result.contains("name: Alice"), "result: {result:?}");
821    }
822
823    #[test]
824    fn block_mapping_multiple_entries() {
825        let root = Node::Mapping {
826            entries: vec![(scalar("a"), scalar("1")), (scalar("b"), scalar("2"))],
827            anchor: None,
828            tag: None,
829            loc: null_span(),
830            leading_comments: Vec::new(),
831            trailing_comment: None,
832        };
833        let result = emit(&[doc(root)], &default_config());
834        assert!(result.contains("a: 1"), "result: {result:?}");
835        assert!(result.contains("b: 2"), "result: {result:?}");
836    }
837
838    #[test]
839    fn block_sequence_emits_dash_items() {
840        let root = Node::Sequence {
841            items: vec![scalar("a"), scalar("b"), scalar("c")],
842            anchor: None,
843            tag: None,
844            loc: null_span(),
845            leading_comments: Vec::new(),
846            trailing_comment: None,
847        };
848        let result = emit(&[doc(root)], &default_config());
849        assert!(result.contains("- a"), "result: {result:?}");
850        assert!(result.contains("- b"), "result: {result:?}");
851        assert!(result.contains("- c"), "result: {result:?}");
852    }
853
854    #[test]
855    fn block_mapping_empty_emits_braces() {
856        let root = Node::Mapping {
857            entries: vec![],
858            anchor: None,
859            tag: None,
860            loc: null_span(),
861            leading_comments: Vec::new(),
862            trailing_comment: None,
863        };
864        let result = emit(&[doc(root)], &default_config());
865        assert!(result.contains("{}"), "result: {result:?}");
866    }
867
868    #[test]
869    fn block_sequence_empty_emits_brackets() {
870        let root = Node::Sequence {
871            items: vec![],
872            anchor: None,
873            tag: None,
874            loc: null_span(),
875            leading_comments: Vec::new(),
876            trailing_comment: None,
877        };
878        let result = emit(&[doc(root)], &default_config());
879        assert!(result.contains("[]"), "result: {result:?}");
880    }
881
882    // -----------------------------------------------------------------------
883    // Group 9: Flow collections
884    // -----------------------------------------------------------------------
885
886    #[test]
887    fn flow_mapping_emits_braces() {
888        let config = EmitConfig {
889            default_collection_style: CollectionStyle::Flow,
890            ..EmitConfig::default()
891        };
892        let root = Node::Mapping {
893            entries: vec![(scalar("key"), scalar("val"))],
894            anchor: None,
895            tag: None,
896            loc: null_span(),
897            leading_comments: Vec::new(),
898            trailing_comment: None,
899        };
900        let result = emit(&[doc(root)], &config);
901        assert!(result.contains("{key: val}"), "result: {result:?}");
902    }
903
904    #[test]
905    fn flow_sequence_emits_brackets() {
906        let config = EmitConfig {
907            default_collection_style: CollectionStyle::Flow,
908            ..EmitConfig::default()
909        };
910        let root = Node::Sequence {
911            items: vec![scalar("a"), scalar("b")],
912            anchor: None,
913            tag: None,
914            loc: null_span(),
915            leading_comments: Vec::new(),
916            trailing_comment: None,
917        };
918        let result = emit(&[doc(root)], &config);
919        assert!(result.contains("[a, b]"), "result: {result:?}");
920    }
921
922    #[test]
923    fn flow_mapping_empty_emits_braces() {
924        let config = EmitConfig {
925            default_collection_style: CollectionStyle::Flow,
926            ..EmitConfig::default()
927        };
928        let root = Node::Mapping {
929            entries: vec![],
930            anchor: None,
931            tag: None,
932            loc: null_span(),
933            leading_comments: Vec::new(),
934            trailing_comment: None,
935        };
936        let result = emit(&[doc(root)], &config);
937        assert!(result.contains("{}"), "result: {result:?}");
938    }
939
940    #[test]
941    fn flow_sequence_empty_emits_brackets() {
942        let config = EmitConfig {
943            default_collection_style: CollectionStyle::Flow,
944            ..EmitConfig::default()
945        };
946        let root = Node::Sequence {
947            items: vec![],
948            anchor: None,
949            tag: None,
950            loc: null_span(),
951            leading_comments: Vec::new(),
952            trailing_comment: None,
953        };
954        let result = emit(&[doc(root)], &config);
955        assert!(result.contains("[]"), "result: {result:?}");
956    }
957
958    #[test]
959    fn flow_mapping_multiple_entries_comma_separated() {
960        let config = EmitConfig {
961            default_collection_style: CollectionStyle::Flow,
962            ..EmitConfig::default()
963        };
964        let root = Node::Mapping {
965            entries: vec![(scalar("a"), scalar("1")), (scalar("b"), scalar("2"))],
966            anchor: None,
967            tag: None,
968            loc: null_span(),
969            leading_comments: Vec::new(),
970            trailing_comment: None,
971        };
972        let result = emit(&[doc(root)], &config);
973        assert!(result.contains("{a: 1, b: 2}"), "result: {result:?}");
974    }
975
976    // -----------------------------------------------------------------------
977    // Group 10: Anchors and aliases
978    // -----------------------------------------------------------------------
979
980    #[test]
981    fn anchor_emits_ampersand_prefix() {
982        let node = Node::Scalar {
983            value: "shared".to_owned(),
984            style: ScalarStyle::Plain,
985            anchor: Some("ref".to_owned()),
986            tag: None,
987            loc: null_span(),
988            leading_comments: Vec::new(),
989            trailing_comment: None,
990        };
991        let result = emit(&[doc(node)], &default_config());
992        assert!(result.contains("&ref shared"), "result: {result:?}");
993    }
994
995    #[test]
996    fn alias_emits_asterisk_prefix() {
997        let root = Node::Sequence {
998            items: vec![
999                Node::Scalar {
1000                    value: "val".to_owned(),
1001                    style: ScalarStyle::Plain,
1002                    anchor: Some("a".to_owned()),
1003                    tag: None,
1004                    loc: null_span(),
1005                    leading_comments: Vec::new(),
1006                    trailing_comment: None,
1007                },
1008                Node::Alias {
1009                    name: "a".to_owned(),
1010                    loc: null_span(),
1011                    leading_comments: Vec::new(),
1012                    trailing_comment: None,
1013                },
1014            ],
1015            anchor: None,
1016            tag: None,
1017            loc: null_span(),
1018            leading_comments: Vec::new(),
1019            trailing_comment: None,
1020        };
1021        let result = emit(&[doc(root)], &default_config());
1022        assert!(result.contains("&a val"), "result: {result:?}");
1023        assert!(result.contains("*a"), "result: {result:?}");
1024    }
1025
1026    #[test]
1027    fn anchor_on_mapping_emits_before_entries() {
1028        let root = Node::Mapping {
1029            entries: vec![(scalar("k"), scalar("v"))],
1030            anchor: Some("m".to_owned()),
1031            tag: None,
1032            loc: null_span(),
1033            leading_comments: Vec::new(),
1034            trailing_comment: None,
1035        };
1036        let result = emit(&[doc(root)], &default_config());
1037        assert!(result.contains("&m"), "result: {result:?}");
1038    }
1039
1040    #[test]
1041    fn anchor_on_sequence_emits_before_items() {
1042        let root = Node::Sequence {
1043            items: vec![scalar("x")],
1044            anchor: Some("s".to_owned()),
1045            tag: None,
1046            loc: null_span(),
1047            leading_comments: Vec::new(),
1048            trailing_comment: None,
1049        };
1050        let result = emit(&[doc(root)], &default_config());
1051        assert!(result.contains("&s"), "result: {result:?}");
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // Group 11: Tags
1056    // -----------------------------------------------------------------------
1057
1058    #[test]
1059    fn yaml_core_tag_emits_double_bang_shorthand() {
1060        let node = Node::Scalar {
1061            value: "42".to_owned(),
1062            style: ScalarStyle::Plain,
1063            anchor: None,
1064            tag: Some("tag:yaml.org,2002:int".to_owned()),
1065            loc: null_span(),
1066            leading_comments: Vec::new(),
1067            trailing_comment: None,
1068        };
1069        let result = emit(&[doc(node)], &default_config());
1070        assert!(result.contains("!!int"), "result: {result:?}");
1071    }
1072
1073    #[test]
1074    fn local_tag_emits_exclamation_prefix() {
1075        let node = Node::Scalar {
1076            value: "val".to_owned(),
1077            style: ScalarStyle::Plain,
1078            anchor: None,
1079            tag: Some("!local".to_owned()),
1080            loc: null_span(),
1081            leading_comments: Vec::new(),
1082            trailing_comment: None,
1083        };
1084        let result = emit(&[doc(node)], &default_config());
1085        assert!(result.contains("!local"), "result: {result:?}");
1086    }
1087
1088    #[test]
1089    fn unknown_uri_tag_emits_angle_bracket_form() {
1090        let node = Node::Scalar {
1091            value: "val".to_owned(),
1092            style: ScalarStyle::Plain,
1093            anchor: None,
1094            tag: Some("http://example.com/tag".to_owned()),
1095            loc: null_span(),
1096            leading_comments: Vec::new(),
1097            trailing_comment: None,
1098        };
1099        let result = emit(&[doc(node)], &default_config());
1100        assert!(
1101            result.contains("!<http://example.com/tag>"),
1102            "result: {result:?}"
1103        );
1104    }
1105
1106    // -----------------------------------------------------------------------
1107    // Group 12: Multi-document emission
1108    // -----------------------------------------------------------------------
1109
1110    #[test]
1111    fn single_document_no_separator() {
1112        let result = emit(&[doc(scalar("only"))], &default_config());
1113        assert!(!result.starts_with("---"), "result: {result:?}");
1114    }
1115
1116    #[test]
1117    fn two_documents_second_has_separator() {
1118        let docs = vec![doc(scalar("first")), doc(scalar("second"))];
1119        let result = emit(&docs, &default_config());
1120        // The second document should be preceded by "---".
1121        let separator_count = result.matches("---").count();
1122        assert_eq!(separator_count, 1, "result: {result:?}");
1123        assert!(result.contains("first"), "result: {result:?}");
1124        assert!(result.contains("second"), "result: {result:?}");
1125    }
1126
1127    #[test]
1128    fn three_documents_two_separators() {
1129        let docs = vec![doc(scalar("a")), doc(scalar("b")), doc(scalar("c"))];
1130        let result = emit(&docs, &default_config());
1131        let separator_count = result.matches("---").count();
1132        assert_eq!(separator_count, 2, "result: {result:?}");
1133    }
1134
1135    #[test]
1136    fn empty_document_list_emits_empty_string() {
1137        let result = emit(&[], &default_config());
1138        assert!(result.is_empty(), "result: {result:?}");
1139    }
1140
1141    #[test]
1142    fn document_with_version_emits_separator() {
1143        let mut d = doc(scalar("val"));
1144        d.version = Some((1, 2));
1145        let result = emit(&[d], &default_config());
1146        assert!(result.starts_with("---"), "result: {result:?}");
1147    }
1148
1149    // -----------------------------------------------------------------------
1150    // Group 13: Comments
1151    // -----------------------------------------------------------------------
1152
1153    #[test]
1154    fn document_comment_emits_hash_prefix() {
1155        let mut d = doc(scalar("val"));
1156        d.comments = vec!["a comment".to_owned()];
1157        let result = emit(&[d], &default_config());
1158        assert!(result.contains("# a comment"), "result: {result:?}");
1159    }
1160
1161    #[test]
1162    fn multiple_comments_all_emitted() {
1163        let mut d = doc(scalar("val"));
1164        d.comments = vec!["first".to_owned(), "second".to_owned()];
1165        let result = emit(&[d], &default_config());
1166        assert!(result.contains("# first"), "result: {result:?}");
1167        assert!(result.contains("# second"), "result: {result:?}");
1168    }
1169
1170    #[test]
1171    fn comments_appear_before_root_node() {
1172        let mut d = doc(scalar("val"));
1173        d.comments = vec!["note".to_owned()];
1174        let result = emit(&[d], &default_config());
1175        let comment_pos = result.find("# note").expect("comment missing");
1176        let val_pos = result.find("val").expect("value missing");
1177        assert!(comment_pos < val_pos, "result: {result:?}");
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // Group 14: Edge cases and integration
1182    // -----------------------------------------------------------------------
1183
1184    #[test]
1185    fn nested_mapping_indents_child() {
1186        let inner = Node::Mapping {
1187            entries: vec![(scalar("x"), scalar("1"))],
1188            anchor: None,
1189            tag: None,
1190            loc: null_span(),
1191            leading_comments: Vec::new(),
1192            trailing_comment: None,
1193        };
1194        let root = Node::Mapping {
1195            entries: vec![(scalar("outer"), inner)],
1196            anchor: None,
1197            tag: None,
1198            loc: null_span(),
1199            leading_comments: Vec::new(),
1200            trailing_comment: None,
1201        };
1202        let result = emit(&[doc(root)], &default_config());
1203        assert!(result.contains("outer:"), "result: {result:?}");
1204        assert!(result.contains("x: 1"), "result: {result:?}");
1205    }
1206
1207    #[test]
1208    fn nested_sequence_indents_child() {
1209        let inner = Node::Sequence {
1210            items: vec![scalar("a"), scalar("b")],
1211            anchor: None,
1212            tag: None,
1213            loc: null_span(),
1214            leading_comments: Vec::new(),
1215            trailing_comment: None,
1216        };
1217        let root = Node::Sequence {
1218            items: vec![inner],
1219            anchor: None,
1220            tag: None,
1221            loc: null_span(),
1222            leading_comments: Vec::new(),
1223            trailing_comment: None,
1224        };
1225        let result = emit(&[doc(root)], &default_config());
1226        assert!(result.contains("- "), "result: {result:?}");
1227        assert!(result.contains('a'), "result: {result:?}");
1228    }
1229
1230    #[test]
1231    fn emit_output_is_valid_utf8() {
1232        let docs = load("key: value\n").expect("parse failed");
1233        let result = emit(&docs, &default_config());
1234        // String::from_utf8 was already called inside emit; if we reach here it's valid.
1235        assert!(!result.is_empty());
1236    }
1237
1238    #[test]
1239    fn emit_to_writer_matches_emit() {
1240        let docs = load("a: b\n").expect("parse failed");
1241        let config = default_config();
1242
1243        let expected = emit(&docs, &config);
1244        let mut buf = Vec::new();
1245        emit_to_writer(&docs, &config, &mut buf).expect("write failed");
1246        let actual = String::from_utf8(buf).expect("utf-8");
1247
1248        assert_eq!(actual, expected);
1249    }
1250
1251    #[test]
1252    fn scalar_round_trip_plain() {
1253        let yaml = "greeting: hello\n";
1254        let docs = load(yaml).expect("parse failed");
1255        let result = emit(&docs, &default_config());
1256        let reloaded = reload_one(&result);
1257        // Value-level equality: the reloaded mapping must contain the same key/value scalars.
1258        // Spans are intentionally not compared — the emitter may change whitespace.
1259        let Node::Mapping { entries, .. } = reloaded else {
1260            panic!("expected Mapping after reload");
1261        };
1262        let [(key, val)] = entries.as_slice() else {
1263            panic!("expected exactly one entry, got {}", entries.len());
1264        };
1265        assert!(
1266            matches!(key, Node::Scalar { value, .. } if value == "greeting"),
1267            "key mismatch: {key:?}"
1268        );
1269        assert!(
1270            matches!(val, Node::Scalar { value, .. } if value == "hello"),
1271            "value mismatch: {val:?}"
1272        );
1273    }
1274
1275    #[test]
1276    fn flow_style_sequence_round_trips() {
1277        let config = EmitConfig {
1278            default_collection_style: CollectionStyle::Flow,
1279            ..EmitConfig::default()
1280        };
1281        let root = Node::Sequence {
1282            items: vec![scalar("1"), scalar("2"), scalar("3")],
1283            anchor: None,
1284            tag: None,
1285            loc: null_span(),
1286            leading_comments: Vec::new(),
1287            trailing_comment: None,
1288        };
1289        let result = emit(&[doc(root)], &config);
1290        assert!(load(&result).is_ok(), "reload failed: {result:?}");
1291    }
1292
1293    #[test]
1294    fn reserved_word_round_trips_as_string() {
1295        let node = Node::Scalar {
1296            value: "null".to_owned(),
1297            style: ScalarStyle::Plain,
1298            anchor: None,
1299            tag: None,
1300            loc: null_span(),
1301            leading_comments: Vec::new(),
1302            trailing_comment: None,
1303        };
1304        let result = emit(&[doc(node)], &default_config());
1305        // After reload, the value should still be the string "null" (quoted),
1306        // not a null node.
1307        assert!(load(&result).is_ok(), "reload failed: {result:?}");
1308    }
1309}