Skip to main content

noyalib/
ser.rs

1//! YAML serialization.
2
3// SPDX-License-Identifier: MIT OR Apache-2.0
4// Copyright (c) 2026 Noyalib. All rights reserved.
5
6use crate::prelude::*;
7use core::fmt::Write as _;
8
9use crate::error::{Error, Result};
10use crate::value::{Mapping, Number, Sequence, Tag, TaggedValue, Value};
11
12/// Flow style preference for collections.
13///
14/// Controls whether sequences and mappings should use inline (flow) or
15/// multi-line (block) style.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17#[non_exhaustive]
18pub enum FlowStyle {
19    /// Always use block style (multi-line).
20    #[default]
21    Block,
22    /// Always use flow style (inline, JSON-like).
23    Flow,
24    /// Automatic: use flow for small collections, block for larger ones.
25    Auto,
26}
27
28/// Scalar style preference for strings.
29///
30/// Controls how string values should be quoted.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[non_exhaustive]
33pub enum ScalarStyle {
34    /// Automatic quoting based on content.
35    #[default]
36    Auto,
37    /// Always use double quotes.
38    DoubleQuoted,
39    /// Always use single quotes.
40    SingleQuoted,
41    /// Use literal block style (|) for multiline.
42    Literal,
43    /// Use folded block style (>) for multiline.
44    Folded,
45    /// Plain (unquoted) style when possible.
46    Plain,
47}
48
49/// Configuration options for YAML serialization.
50///
51/// # Examples
52///
53/// ```rust
54/// use noyalib::{FlowStyle, ScalarStyle, SerializerConfig};
55///
56/// let config = SerializerConfig::new()
57///     .indent(4)
58///     .flow_style(FlowStyle::Auto)
59///     .scalar_style(ScalarStyle::DoubleQuoted)
60///     .document_start(true);
61/// ```
62#[derive(Debug, Clone, Copy)]
63#[non_exhaustive]
64pub struct SerializerConfig {
65    /// Number of spaces per indentation level (default: 2).
66    pub indent: usize,
67    /// Whether to include document start marker `---` (default: false).
68    pub document_start: bool,
69    /// Whether to include document end marker `...` (default: false).
70    pub document_end: bool,
71    /// Whether to use block style for multiline strings (default: true).
72    pub block_scalars: bool,
73    /// Minimum number of newlines to trigger block scalar style (default: 1).
74    pub block_scalar_threshold: usize,
75    /// Flow style preference for collections (default: Block).
76    pub flow_style: FlowStyle,
77    /// Scalar style preference for strings (default: Auto).
78    pub scalar_style: ScalarStyle,
79    /// Maximum number of items in a collection to use flow style in Auto mode
80    /// (default: 4).
81    pub flow_threshold: usize,
82    /// Force-quote all string scalars regardless of content (default: false).
83    pub quote_all: bool,
84    /// Prefer single quotes over double quotes when a string scalar needs
85    /// quoting at all (default: false).
86    ///
87    /// A string that contains a character only double-quoted style can
88    /// carry -- a control character, a tab, or any other code point that
89    /// needs an escape sequence -- still gets double-quoted regardless of
90    /// this setting, since single-quoted style has no escape mechanism
91    /// beyond doubling an embedded `'`.
92    pub prefer_single_quotes: bool,
93    /// Compact list indentation under mapping keys (default: false).
94    ///
95    /// When `true`, sequence items under a mapping key align with the key
96    /// instead of being indented an extra level.
97    pub compact_list_indent: bool,
98    /// Line width for folded block scalars (default: 80).
99    pub folded_wrap_chars: usize,
100    /// Minimum string length before block scalar style is considered (default: 80).
101    pub min_fold_chars: usize,
102    /// Maximum nesting depth allowed during serialization (default: 128).
103    pub max_depth: usize,
104}
105
106impl Default for SerializerConfig {
107    fn default() -> Self {
108        Self {
109            indent: 2,
110            document_start: false,
111            document_end: false,
112            block_scalars: true,
113            block_scalar_threshold: 1,
114            flow_style: FlowStyle::Block,
115            scalar_style: ScalarStyle::Auto,
116            flow_threshold: 4,
117            quote_all: false,
118            prefer_single_quotes: false,
119            compact_list_indent: false,
120            folded_wrap_chars: 80,
121            min_fold_chars: 80,
122            max_depth: 128,
123        }
124    }
125}
126
127impl SerializerConfig {
128    /// Create a new configuration with default settings.
129    #[must_use]
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Set the indentation width.
135    #[must_use]
136    pub fn indent(mut self, spaces: usize) -> Self {
137        self.indent = spaces;
138        self
139    }
140
141    /// Enable or disable document start marker `---`.
142    #[must_use]
143    pub fn document_start(mut self, enabled: bool) -> Self {
144        self.document_start = enabled;
145        self
146    }
147
148    /// Enable or disable document end marker `...`.
149    #[must_use]
150    pub fn document_end(mut self, enabled: bool) -> Self {
151        self.document_end = enabled;
152        self
153    }
154
155    /// Enable or disable block scalar style for multiline strings.
156    #[must_use]
157    pub fn block_scalars(mut self, enabled: bool) -> Self {
158        self.block_scalars = enabled;
159        self
160    }
161
162    /// Set minimum newlines to trigger block scalar style.
163    #[must_use]
164    pub fn block_scalar_threshold(mut self, count: usize) -> Self {
165        self.block_scalar_threshold = count;
166        self
167    }
168
169    /// Set the flow style preference for collections.
170    ///
171    /// - `FlowStyle::Block`: Always use multi-line block style
172    /// - `FlowStyle::Flow`: Always use inline flow style
173    /// - `FlowStyle::Auto`: Use flow for small collections
174    #[must_use]
175    pub fn flow_style(mut self, style: FlowStyle) -> Self {
176        self.flow_style = style;
177        self
178    }
179
180    /// Set the scalar style preference for strings.
181    ///
182    /// - `ScalarStyle::Auto`: Quote only when necessary
183    /// - `ScalarStyle::DoubleQuoted`: Always use double quotes
184    /// - `ScalarStyle::SingleQuoted`: Always use single quotes
185    /// - `ScalarStyle::Literal`: Use `|` for multiline
186    /// - `ScalarStyle::Folded`: Use `>` for multiline
187    /// - `ScalarStyle::Plain`: Unquoted when possible
188    #[must_use]
189    pub fn scalar_style(mut self, style: ScalarStyle) -> Self {
190        self.scalar_style = style;
191        self
192    }
193
194    /// Set the threshold for automatic flow style.
195    ///
196    /// Collections with this many or fewer items will use flow style
197    /// when `flow_style` is set to `Auto`.
198    #[must_use]
199    pub fn flow_threshold(mut self, threshold: usize) -> Self {
200        self.flow_threshold = threshold;
201        self
202    }
203
204    /// Force-quote all string scalars regardless of content.
205    #[must_use]
206    pub fn quote_all(mut self, enabled: bool) -> Self {
207        self.quote_all = enabled;
208        self
209    }
210
211    /// Prefer single quotes over double quotes when a string scalar needs
212    /// quoting at all.
213    ///
214    /// A string that needs a character only double-quoted style can carry
215    /// (a control character, a tab, or anything else that needs an escape
216    /// sequence) still gets double-quoted regardless of this setting.
217    /// Output is unchanged when this is left at its default (`false`).
218    #[must_use]
219    pub fn prefer_single_quotes(mut self, enabled: bool) -> Self {
220        self.prefer_single_quotes = enabled;
221        self
222    }
223
224    /// Enable compact list indentation under mapping keys.
225    ///
226    /// When enabled, sequence items align with the key rather than
227    /// being indented an extra level.
228    #[must_use]
229    pub fn compact_list_indent(mut self, enabled: bool) -> Self {
230        self.compact_list_indent = enabled;
231        self
232    }
233
234    /// Set the line width for folded block scalars.
235    #[must_use]
236    pub fn folded_wrap_chars(mut self, chars: usize) -> Self {
237        self.folded_wrap_chars = chars;
238        self
239    }
240
241    /// Set the minimum string length for block scalar style.
242    ///
243    /// Strings shorter than this threshold will not use block scalar
244    /// (`|` / `>`) style, even if they contain newlines.
245    #[must_use]
246    pub fn min_fold_chars(mut self, chars: usize) -> Self {
247        self.min_fold_chars = chars;
248        self
249    }
250
251    /// Set the maximum nesting depth for serialization.
252    #[must_use]
253    pub fn max_depth(mut self, depth: usize) -> Self {
254        self.max_depth = depth;
255        self
256    }
257}
258
259/// Serialize a Rust value to a YAML `String`.
260///
261/// Uses [`SerializerConfig::default`]: 2-space indent, no
262/// `---` / `...` markers, block style for collections, auto-style
263/// scalars, block scalars enabled.
264///
265/// # Errors
266///
267/// Returns [`Error`](crate::Error) when:
268///
269/// - `Error::Serialize` — `T`'s `Serialize` impl returned an
270///   error (custom `serde_core::ser::Error`, non-string mapping key
271///   that cannot be coerced, …).
272/// - `Error::DepthLimit` — the value graph exceeds
273///   `SerializerConfig::max_depth` (default 128). Use
274///   [`to_string_with_config`] to raise the cap when serialising
275///   a deliberately deep structure.
276///
277/// `to_string` itself does not perform any I/O and never returns
278/// `Error::Io`.
279///
280/// # Examples
281///
282/// ```rust
283/// #[derive(serde::Serialize)]
284/// struct Config {
285///     name: String,
286///     port: u16,
287/// }
288///
289/// let config = Config {
290///     name: "myapp".to_string(),
291///     port: 8080,
292/// };
293///
294/// let yaml = noyalib::to_string(&config).unwrap();
295/// assert!(yaml.contains("name: myapp"));
296/// assert!(yaml.contains("port: 8080"));
297/// ```
298pub fn to_string<T>(value: &T) -> Result<String>
299where
300    T: ?Sized + serde_core::Serialize,
301{
302    let v = to_value(value)?;
303    value_to_string(&v, &SerializerConfig::default())
304}
305
306/// Serialize a Rust value to a YAML `String` with a custom
307/// [`SerializerConfig`].
308///
309/// # Errors
310///
311/// Same variant set as [`to_string`]. The active `config`
312/// controls which limit-related errors can fire — in particular,
313/// raising `max_depth` lets deeper graphs through, lowering it
314/// surfaces `Error::DepthLimit` sooner.
315///
316/// # Examples
317///
318/// ```rust
319/// use noyalib::SerializerConfig;
320///
321/// #[derive(serde::Serialize)]
322/// struct Config {
323///     name: String,
324///     port: u16,
325/// }
326///
327/// let config = Config {
328///     name: "myapp".to_string(),
329///     port: 8080,
330/// };
331///
332/// let yaml = noyalib::to_string_with_config(
333///     &config,
334///     &SerializerConfig::new().indent(4).document_start(true),
335/// )
336/// .unwrap();
337/// assert!(yaml.starts_with("---"));
338/// ```
339pub fn to_string_with_config<T>(value: &T, config: &SerializerConfig) -> Result<String>
340where
341    T: ?Sized + serde_core::Serialize,
342{
343    let v = to_value(value)?;
344    value_to_string(&v, config)
345}
346
347/// Serialize a Rust value to YAML and write to a
348/// [`std::io::Write`] sink.
349///
350/// Internally serialises to a `String` then writes the bytes to
351/// `writer` in a single call.
352///
353/// # Errors
354///
355/// - `Error::Io` — the underlying writer returned an I/O error.
356/// - All variants documented on [`to_string`].
357#[cfg(feature = "std")]
358pub fn to_writer<W, T>(writer: W, value: &T) -> Result<()>
359where
360    W: std::io::Write,
361    T: ?Sized + serde_core::Serialize,
362{
363    to_writer_with_config(writer, value, &SerializerConfig::default())
364}
365
366/// Serialize a Rust value to YAML and write to a
367/// [`std::io::Write`] sink, using a custom [`SerializerConfig`].
368///
369/// # Errors
370///
371/// - `Error::Io` — the underlying writer returned an I/O error.
372/// - All variants documented on [`to_string_with_config`].
373#[cfg(feature = "std")]
374pub fn to_writer_with_config<W, T>(writer: W, value: &T, config: &SerializerConfig) -> Result<()>
375where
376    W: std::io::Write,
377    T: ?Sized + serde_core::Serialize,
378{
379    let s = to_string_with_config(value, config)?;
380    let mut writer = writer;
381    writer.write_all(s.as_bytes())?;
382    Ok(())
383}
384
385/// Serialize a Rust type to a YAML string with automatic anchor/alias emission
386/// for shared `Rc` and `Arc` pointers wrapped in `RcAnchor` / `ArcAnchor`.
387///
388/// During this call, every `RcAnchor` / `ArcAnchor` whose pointer is seen for
389/// the first time emits a YAML anchor (`&idNNN`); every subsequent sighting of
390/// the same pointer emits an alias (`*idNNN`). This preserves true DAG
391/// structure in the emitted document — `Rc::clone` siblings become alias
392/// references instead of duplicated subtrees.
393///
394/// Pointer identity is tracked via a thread-local scratchpad that is installed
395/// for the duration of the call and cleared on return. Plain `to_string`
396/// behaviour is unaffected.
397///
398/// # Errors
399///
400/// Returns an error if the type cannot be serialized to YAML.
401///
402/// # Examples
403///
404/// ```rust
405/// use noyalib::{to_string_tracking_shared, RcAnchor};
406/// use std::rc::Rc;
407///
408/// let shared: RcAnchor<String> = RcAnchor::from("hello".to_string());
409/// let doc = vec![shared.clone(), shared.clone(), shared];
410/// let yaml = to_string_tracking_shared(&doc).unwrap();
411/// assert!(yaml.contains("&id001"));
412/// assert!(yaml.contains("*id001"));
413/// ```
414#[cfg(feature = "std")]
415pub fn to_string_tracking_shared<T>(value: &T) -> Result<String>
416where
417    T: ?Sized + serde_core::Serialize,
418{
419    to_string_tracking_shared_with_config(value, &SerializerConfig::default())
420}
421
422/// Serialize with automatic anchor/alias emission and a custom configuration.
423///
424/// See [`to_string_tracking_shared`] for behaviour.
425///
426/// # Errors
427///
428/// Returns an error if the type cannot be serialized to YAML.
429#[cfg(feature = "std")]
430pub fn to_string_tracking_shared_with_config<T>(
431    value: &T,
432    config: &SerializerConfig,
433) -> Result<String>
434where
435    T: ?Sized + serde_core::Serialize,
436{
437    let _scope = crate::anchors::shared_tracking::AnchorScope::enter();
438    to_string_with_config(value, config)
439}
440
441/// Write YAML to a writer with automatic anchor/alias emission for shared
442/// `Rc` / `Arc` pointers.
443///
444/// See [`to_string_tracking_shared`] for behaviour.
445///
446/// # Errors
447///
448/// Returns an error if the type cannot be serialized or writing fails.
449#[cfg(feature = "std")]
450pub fn to_writer_tracking_shared<W, T>(writer: W, value: &T) -> Result<()>
451where
452    W: std::io::Write,
453    T: ?Sized + serde_core::Serialize,
454{
455    to_writer_tracking_shared_with_config(writer, value, &SerializerConfig::default())
456}
457
458/// Write YAML to a writer with automatic anchor/alias emission and a custom
459/// configuration.
460///
461/// See [`to_string_tracking_shared`] for behaviour.
462///
463/// # Errors
464///
465/// Returns an error if the type cannot be serialized or writing fails.
466#[cfg(feature = "std")]
467pub fn to_writer_tracking_shared_with_config<W, T>(
468    writer: W,
469    value: &T,
470    config: &SerializerConfig,
471) -> Result<()>
472where
473    W: std::io::Write,
474    T: ?Sized + serde_core::Serialize,
475{
476    let s = to_string_tracking_shared_with_config(value, config)?;
477    let mut writer = writer;
478    writer.write_all(s.as_bytes())?;
479    Ok(())
480}
481
482/// Serialize a Rust value to YAML and write into a [`core::fmt::Write`]
483/// sink — the no_std-friendly counterpart to [`to_writer`].
484///
485/// # Errors
486///
487/// - `Error::Serialize` — the destination's `write_str` returned
488///   `fmt::Error`, propagated through `Error::Serialize`.
489/// - All variants documented on [`to_string`].
490pub fn to_fmt_writer<W, T>(writer: &mut W, value: &T) -> Result<()>
491where
492    W: fmt::Write,
493    T: ?Sized + serde_core::Serialize,
494{
495    to_fmt_writer_with_config(writer, value, &SerializerConfig::default())
496}
497
498/// Serialize a Rust value to YAML and write into a [`core::fmt::Write`]
499/// sink, using a custom [`SerializerConfig`].
500///
501/// # Errors
502///
503/// - `Error::Serialize` — the destination's `write_str` returned
504///   `fmt::Error`.
505/// - All variants documented on [`to_string_with_config`].
506pub fn to_fmt_writer_with_config<W, T>(
507    writer: &mut W,
508    value: &T,
509    config: &SerializerConfig,
510) -> Result<()>
511where
512    W: fmt::Write,
513    T: ?Sized + serde_core::Serialize,
514{
515    let s = to_string_with_config(value, config)?;
516    writer
517        .write_str(&s)
518        .map_err(|e| Error::Serialize(e.to_string()))
519}
520
521/// Serialize a Rust value into a dynamic [`Value`] tree via the
522/// Serde data model.
523///
524/// Use this when the typed serialise output should land in
525/// noyalib's [`Value`] for further programmatic editing
526/// (`Value::merge`, `Value::interpolate_properties`, …) before a
527/// final emit.
528///
529/// **Note**: [`TaggedValue`]'s `Serialize` impl (which `Value::Tagged`
530/// delegates to) routes through `serialize_map` with a single entry keyed
531/// by the tag string — the right shape for interop with a generic
532/// serializer that has no YAML-tag concept, `serde_json` and friends
533/// included — wrapped in a newtype struct carrying a private marker name.
534/// This crate's own [`Serializer`] *does* have a tag concept: it
535/// recognises the marker (never the map's shape) when it builds the
536/// resulting [`Value`] and reconstructs [`Value::Tagged`], so
537/// `to_value`/`to_string` on a `Value` containing `Tagged` round-trips the
538/// tag rather than losing it to a degenerate one-entry mapping, while a
539/// genuine one-entry mapping keyed by a `!`-string stays a mapping. Refs
540/// #350, #377. For direct emission of a `Value` you already hold, without going
541/// through the `Serialize` pipeline at all, see [`to_string_value`] /
542/// [`to_writer_value`].
543///
544/// # Errors
545///
546/// - `Error::Serialize` — `T`'s `Serialize` impl returned an
547///   error.
548/// - `Error::Custom` — surfaces upstream `serde_core::ser::Error`
549///   conversions that don't fit the structured variants.
550pub fn to_value<T>(value: &T) -> Result<Value>
551where
552    T: ?Sized + serde_core::Serialize,
553{
554    // The public `to_value` / `to_string` family keeps
555    // `T: ?Sized + serde_core::Serialize` so callers can serialise structs
556    // holding borrowed references. `Value::Tagged`'s tag survives this path
557    // via `SerializeMap::end`'s single-entry-map reconstruction (see its
558    // doc comment); users who already hold a `Value` and want to skip the
559    // `Serialize` pipeline entirely can still call [`to_string_value`] /
560    // [`to_string_value_with_config`] / [`to_writer_value`].
561    value.serialize(Serializer)
562}
563
564/// Serialize a [`Value`] directly to a YAML `String`, preserving
565/// [`Value::Tagged`] shape losslessly.
566///
567/// This function bypasses the `Serialize` pipeline entirely and writes
568/// the YAML-tag prefix directly. [`to_string`]/[`to_value`] also preserve
569/// `Value::Tagged` when `T` is (or contains) a `Value` — see the note on
570/// [`to_value`] — but this one skips the round trip through `Serializer`
571/// altogether.
572///
573/// Use this whenever you hold a `Value` that may contain
574/// `Value::Tagged` and want the emitted YAML to round-trip back
575/// into an equivalent `Value::Tagged`.
576///
577/// # Errors
578///
579/// All variants documented on [`to_string`].
580///
581/// # Examples
582///
583/// ```
584/// use noyalib::{from_str, to_string_value, Value};
585/// let v: Value = from_str("!Color '#ff8800'\n").unwrap();
586/// assert!(matches!(v, Value::Tagged(_)));
587/// let s = to_string_value(&v).unwrap();
588/// // Re-parsing yields an equivalent `Value::Tagged`.
589/// let back: Value = from_str(&s).unwrap();
590/// assert!(matches!(back, Value::Tagged(_)));
591/// ```
592pub fn to_string_value(value: &Value) -> Result<String> {
593    value_to_string(value, &SerializerConfig::default())
594}
595
596/// Serialize a [`Value`] to a YAML `String` with a custom
597/// [`SerializerConfig`], preserving [`Value::Tagged`] shape
598/// losslessly. See [`to_string_value`] for the rationale.
599///
600/// # Errors
601///
602/// All variants documented on [`to_string_with_config`].
603pub fn to_string_value_with_config(value: &Value, config: &SerializerConfig) -> Result<String> {
604    value_to_string(value, config)
605}
606
607/// Write a [`Value`] to an [`std::io::Write`] sink, preserving
608/// [`Value::Tagged`] shape losslessly. See [`to_string_value`]
609/// for the rationale.
610///
611/// # Errors
612///
613/// - `Error::Io` — the underlying writer returned an I/O error.
614/// - All variants documented on [`to_string`].
615#[cfg(feature = "std")]
616pub fn to_writer_value<W>(writer: W, value: &Value) -> Result<()>
617where
618    W: std::io::Write,
619{
620    to_writer_value_with_config(writer, value, &SerializerConfig::default())
621}
622
623/// Write a [`Value`] to an [`std::io::Write`] sink with a custom
624/// [`SerializerConfig`], preserving [`Value::Tagged`] shape
625/// losslessly. See [`to_string_value`] for the rationale.
626///
627/// # Errors
628///
629/// - `Error::Io` — the underlying writer returned an I/O error.
630/// - All variants documented on [`to_string_with_config`].
631#[cfg(feature = "std")]
632pub fn to_writer_value_with_config<W>(
633    writer: W,
634    value: &Value,
635    config: &SerializerConfig,
636) -> Result<()>
637where
638    W: std::io::Write,
639{
640    let s = to_string_value_with_config(value, config)?;
641    let mut writer = writer;
642    writer.write_all(s.as_bytes())?;
643    Ok(())
644}
645
646fn value_to_string(value: &Value, config: &SerializerConfig) -> Result<String> {
647    let mut output = String::with_capacity(estimate_yaml_size(value));
648    if config.document_start {
649        output.push_str("---\n");
650    }
651    write_value(&mut output, value, 0, true, &Cx::new(config), 0)?;
652    if config.document_end {
653        // A block scalar ends its own last line; a second break before
654        // `...` would be read as one more kept line under `|+`.
655        if !output.ends_with('\n') {
656            output.push('\n');
657        }
658        output.push_str("...");
659    }
660    Ok(output)
661}
662
663fn estimate_yaml_size(value: &Value) -> usize {
664    match value {
665        Value::Null => 4,
666        Value::Bool(_) => 5,
667        Value::Number(_) => 12,
668        Value::String(s) => s.len() + 4,
669        Value::Sequence(seq) => 4 + seq.iter().map(|v| estimate_yaml_size(v) + 4).sum::<usize>(),
670        Value::Mapping(map) => {
671            4 + map
672                .iter()
673                .map(|(k, v)| k.len() + estimate_yaml_size(v) + 6)
674                .sum::<usize>()
675        }
676        Value::Tagged(t) => 20 + estimate_yaml_size(t.value()),
677    }
678}
679
680/// Write `total_spaces` space characters to `output` without heap allocation.
681#[inline]
682fn write_indent(output: &mut String, total_spaces: usize) {
683    const SPACES: &str = "                                                                ";
684    // 64 spaces - covers indent up to depth 32 with indent=2
685    let mut remaining = total_spaces;
686    while remaining > 0 {
687        let n = remaining.min(SPACES.len());
688        output.push_str(&SPACES[..n]);
689        remaining -= n;
690    }
691}
692
693/// The emitter's context: the caller's configuration, and whether the
694/// writer is inside a flow collection. Inside one, every nested
695/// collection is written in flow form too (block layout does not parse
696/// there), a string never takes a block scalar, and a plain scalar must
697/// not contain a flow indicator. Dereferences to the configuration.
698#[derive(Debug, Clone, Copy)]
699struct Cx<'a> {
700    cfg: &'a SerializerConfig,
701    in_flow: bool,
702}
703
704impl<'a> Cx<'a> {
705    fn new(cfg: &'a SerializerConfig) -> Self {
706        Self {
707            cfg,
708            in_flow: false,
709        }
710    }
711
712    /// The context for the contents of a flow collection.
713    fn flow(self) -> Self {
714        Self {
715            in_flow: true,
716            ..self
717        }
718    }
719}
720
721impl core::ops::Deref for Cx<'_> {
722    type Target = SerializerConfig;
723
724    fn deref(&self) -> &SerializerConfig {
725        self.cfg
726    }
727}
728
729fn write_value(
730    output: &mut String,
731    value: &Value,
732    indent: usize,
733    is_root: bool,
734    config: &Cx<'_>,
735    depth: usize,
736) -> Result<()> {
737    if crate::parser::budget::depth_exceeded(depth, config.max_depth) {
738        return Err(Error::RecursionLimitExceeded { depth });
739    }
740    match value {
741        Value::Null => output.push_str("null"),
742        Value::Bool(b) => output.push_str(if *b { "true" } else { "false" }),
743        Value::Number(n) => write_number(output, n),
744        Value::String(s) => write_string(output, s, indent, config),
745        Value::Sequence(seq) => write_sequence(output, seq, indent, is_root, config, depth)?,
746        Value::Mapping(map) => write_mapping(output, map, indent, is_root, config, depth)?,
747        // Only the serializer's own wrapper types create a directive
748        // (`Tag::directive`); a tag read from a document or built with
749        // `Tag::new` is written back as the tag it is, whatever its name.
750        Value::Tagged(tagged) if tagged.tag().is_directive() => {
751            let tag_str = tagged.tag().as_str();
752            write_internal_tag(
753                output,
754                tag_str,
755                tagged.value(),
756                indent,
757                is_root,
758                config,
759                depth,
760            )?;
761        }
762        Value::Tagged(tagged) => write_user_tag(output, tagged, indent, config, depth)?,
763    }
764    Ok(())
765}
766
767fn write_number(output: &mut String, n: &Number) {
768    match n {
769        Number::Integer(n) => {
770            #[cfg(feature = "fast-int")]
771            {
772                let mut buf = itoa::Buffer::new();
773                output.push_str(buf.format(*n));
774            }
775            #[cfg(not(feature = "fast-int"))]
776            {
777                let _ = write!(output, "{n}");
778            }
779        }
780        #[cfg(feature = "lossless-u64")]
781        Number::Unsigned(n) => {
782            #[cfg(feature = "fast-int")]
783            {
784                let mut buf = itoa::Buffer::new();
785                output.push_str(buf.format(*n));
786            }
787            #[cfg(not(feature = "fast-int"))]
788            {
789                let _ = write!(output, "{n}");
790            }
791        }
792        // Shared with `Number`'s `Display` impl (see #348) so the
793        // two never disagree on how a float prints.
794        Number::Float(n) => {
795            let _ = crate::value::write_float(output, *n);
796        }
797    }
798}
799
800/// Write the tag, then its payload. A scalar payload sits on this same
801/// line after a space (`!tag value`); a non-empty mapping/sequence
802/// payload starts block layout on the next line instead, with no
803/// trailing space after the tag (that space would never be followed by
804/// anything, so it would survive only as trailing whitespace). `indent`
805/// here is already the slot this caller computed for the *whole* tagged
806/// value via `needs_block_layout`/`indicator_takes_a_space` (see
807/// `write_mapping`/`write_sequence`), so the payload is written at that
808/// same `indent`, not one deeper.
809fn write_user_tag(
810    output: &mut String,
811    tagged: &TaggedValue,
812    indent: usize,
813    config: &Cx<'_>,
814    depth: usize,
815) -> Result<()> {
816    let tag_str = tagged.tag().as_str();
817    let inner = tagged.value();
818    // A tag body NO spelling can carry -- a control character (the
819    // scanner rejects those in shorthand and verbatim forms alike; a tab
820    // is one) or a `>` (verbatim's terminator, rejected in shorthand as
821    // a non-URI char) -- resolves the serde-model ambiguity the other
822    // way: in the serde data model a tagged value is indistinguishable
823    // from the single-entry mapping keyed by its `!`-leading spelling,
824    // so emit that mapping with a quoted key and let it re-parse as what
825    // it is (found by fuzz_roundtrip on the key `"!\t"`).
826    let as_mapping = tag_str.bytes().any(|b| b < 0x20 || b == 0x7f || b == b'>');
827    // Inside a flow collection that mapping needs its own braces.
828    let braced = as_mapping && config.in_flow;
829    if braced {
830        output.push('{');
831    }
832    if as_mapping {
833        write_key_string(output, tag_str, indent, config);
834        output.push(':');
835    } else {
836        write_tag_spelling(output, tag_str);
837    }
838    if indicator_takes_a_space(inner, config) {
839        output.push(' ');
840    }
841    write_value(output, inner, indent, false, config, depth + 1)?;
842    if braced {
843        output.push('}');
844    }
845    Ok(())
846}
847
848/// Write a tag in the shortest spelling that re-parses to exactly the
849/// stored tag.
850///
851/// A tag whose body holds characters the shorthand spelling cannot
852/// carry -- flow indicators, blanks, or an interior `!` (a handle
853/// separator there) -- is emitted in the verbatim form `!<...>`, which
854/// re-parses to exactly the stored tag (`!<!!str>` is `!!str`). Emitting
855/// it raw produced YAML that split at the first such byte:
856/// `!<tag:example.com,2026:x>` re-emitted as shorthand died at the comma
857/// (found by fuzz_roundtrip).
858fn write_tag_spelling(output: &mut String, tag_str: &str) {
859    let shorthand_body = tag_str
860        .strip_prefix("!!")
861        .or_else(|| tag_str.strip_prefix('!'));
862    // A body starting with `<` would turn the shorthand into the
863    // verbatim opener `!<` itself.
864    let needs_verbatim = shorthand_body.is_some_and(|body| {
865        body.starts_with('<')
866            || body
867                .bytes()
868                .any(|b| matches!(b, b',' | b'[' | b']' | b'{' | b'}' | b'!' | b' ' | b'\t'))
869    });
870    match shorthand_body {
871        // A tag a `%TAG` directive resolved is held as a bare URI with
872        // no `!`. Written as it stands it is not a tag at all, and the
873        // document reads back as a plain scalar, so the value is lost.
874        // The verbatim form carries it without needing the directive.
875        None => {
876            output.push_str("!<");
877            output.push_str(tag_str);
878            output.push('>');
879        }
880        Some(_) if needs_verbatim => {
881            output.push_str("!<");
882            output.push_str(tag_str);
883            output.push('>');
884        }
885        Some(_) => output.push_str(tag_str),
886    }
887}
888
889/// Fast check whether a plain scalar would be interpreted as a number by a YAML
890/// parser.  This is intentionally over-inclusive to ensure roundtrip safety —
891/// it's cheaper to quote a few extra strings than to lose data.
892fn looks_like_number(s: &str) -> bool {
893    let bytes = s.as_bytes();
894    if bytes.is_empty() {
895        return false;
896    }
897
898    // YAML special float literals (case variants)
899    if matches!(
900        s,
901        ".inf"
902            | ".Inf"
903            | ".INF"
904            | "+.inf"
905            | "+.Inf"
906            | "+.INF"
907            | "-.inf"
908            | "-.Inf"
909            | "-.INF"
910            | ".nan"
911            | ".NaN"
912            | ".NAN"
913    ) {
914        return true;
915    }
916
917    // Skip any leading signs (yaml-rust2 is permissive with e.g. "++1")
918    let mut i = 0;
919    while i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
920        i += 1;
921    }
922    if i >= bytes.len() {
923        return false;
924    }
925
926    let rest = &bytes[i..];
927
928    // A digit, or "." and a digit (floats like .5), can open a number.
929    // That is the cheap filter; the parser's own resolver has the last
930    // word, so digit-leading text it keeps as a string (`2026-12-31`,
931    // `1.2.3`, `3rd`, `1/2`) is written plain, as the author wrote it.
932    // The resolver sees the text after the signs: a permissive reader
933    // (yaml-rust2 accepts `++1`) may take stacked signs as one, so what
934    // follows them decides.
935    let candidate =
936        rest[0].is_ascii_digit() || (rest[0] == b'.' && rest.len() > 1 && rest[1].is_ascii_digit());
937    candidate && resolves_as_non_string(&s[i..])
938}
939
940/// The parser's verdict on a plain scalar: would it read back as a
941/// number, a boolean, or null rather than a string?
942///
943/// Runs the resolver the loaders and the streaming path share, with the
944/// YAML 1.1 legacy forms enabled (`0`-prefixed octals, sexagesimals) so a
945/// string is quoted whenever any reader configuration would turn it into
946/// something else.
947fn resolves_as_non_string(s: &str) -> bool {
948    !matches!(
949        crate::streaming::resolve_plain_ext(s, false, true, false, true, true, false),
950        crate::streaming::Scalar::Str(_)
951    )
952}
953
954/// A `:` ends a plain scalar only when a space, a tab, a flow indicator,
955/// or the end of the text follows it (YAML 1.2 plain scalars), so
956/// `word:count`, `10:00:00Z`, and `http://` stay plain.
957fn colon_ends_plain(bytes: &[u8], i: usize) -> bool {
958    match bytes.get(i + 1) {
959        None => true,
960        Some(&next) => matches!(next, b' ' | b'\t' | b',' | b'[' | b']' | b'{' | b'}'),
961    }
962}
963
964/// A `#` starts a comment only at the start of the text or after
965/// whitespace, so `a#b` stays plain.
966fn hash_starts_comment(bytes: &[u8], i: usize) -> bool {
967    i == 0 || matches!(bytes[i - 1], b' ' | b'\t')
968}
969
970/// Lookup table: true if the byte can require the string to be quoted.
971/// Covers: control chars (except tab), colon, hash, newline, etc. A colon
972/// or a hash is then judged in context by `colon_ends_plain` and
973/// `hash_starts_comment`.
974static NEEDS_QUOTE_BYTE: [bool; 128] = {
975    let mut t = [false; 128];
976    // Control characters (except tab 0x09)
977    let mut i = 0u8;
978    while i < 0x20 {
979        if i != b'\t' {
980            t[i as usize] = true;
981        }
982        i += 1;
983    }
984    // YAML structural characters
985    t[b':' as usize] = true;
986    t[b'#' as usize] = true;
987    t[b'\n' as usize] = true;
988    t[b'\r' as usize] = true;
989    t[b'\0' as usize] = true;
990    t
991};
992
993/// Characters that require quoting when they appear as the first character.
994static FIRST_CHAR_QUOTE: [bool; 128] = {
995    let mut t = [false; 128];
996    t[b' ' as usize] = true;
997    t[b'-' as usize] = true;
998    t[b'&' as usize] = true;
999    t[b'*' as usize] = true;
1000    t[b'!' as usize] = true;
1001    t[b'|' as usize] = true;
1002    t[b'>' as usize] = true;
1003    t[b'%' as usize] = true;
1004    t[b'@' as usize] = true;
1005    t[b'`' as usize] = true;
1006    t[b'{' as usize] = true;
1007    t[b'}' as usize] = true;
1008    t[b'[' as usize] = true;
1009    t[b']' as usize] = true;
1010    t[b',' as usize] = true;
1011    t[b'?' as usize] = true;
1012    t[b'\'' as usize] = true;
1013    t[b'"' as usize] = true;
1014    t
1015};
1016
1017/// A character only double-quoted style can carry faithfully: CR, NEL
1018/// (U+0085), LS (U+2028), PS (U+2029) — and a BOM (U+FEFF). A literal
1019/// block scalar normalises `\r` into the block's own line breaks, and
1020/// the three Unicode separators pass through plain and single-quoted
1021/// styles as raw bytes that 1.1-era parsers (and this crate's own
1022/// reader) fold as line breaks, so a round trip changes the string
1023/// (#335). A raw BOM is worse: the reader must not accept one inside
1024/// a document at all (§5.2), and a string-leading BOM emitted plain
1025/// is stream-skipped on re-parse, reinterpreting the rest of the
1026/// scalar as markup (found by fuzz_roundtrip).
1027///
1028/// The C1 block (U+0080 to U+009F, NEL included) and the non-characters
1029/// U+FFFE and U+FFFF sit outside `c-printable` (§5.1): this crate's
1030/// reader takes them raw, but libyaml-based tools reject the document,
1031/// so they force the same style with a hex escape (#379).
1032fn needs_double_quoted_escape(s: &str) -> bool {
1033    s.chars().any(|c| {
1034        matches!(
1035            c,
1036            '\r' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
1037        ) || ('\u{7f}'..='\u{9f}').contains(&c)
1038            || (c < '\u{20}' && c != '\t' && c != '\n')
1039    })
1040}
1041
1042/// Write a mapping key. Keys are implicit (`key: value`) in every
1043/// form this serializer emits, and an implicit key must fit on one
1044/// line — the block scalar styles are not grammar there at all — so
1045/// a string the value writer would render as a `|`/`>` block (any
1046/// string holding a line break) is written double-quoted with
1047/// escapes instead. Found by fuzz_roundtrip: a multi-line key
1048/// emitted as a `|-` block produced YAML that no longer parsed
1049/// ("expected block mapping key or end").
1050///
1051/// A plain `<<` key is the merge key: on reload its value is merged into
1052/// the mapping instead of kept under the key, so the string `<<` is
1053/// always quoted as a key.
1054fn write_key_string(output: &mut String, s: &str, indent: usize, config: &Cx<'_>) {
1055    if s.contains('\n') {
1056        write_double_quoted(output, s);
1057    } else if s == "<<" {
1058        match quoted_style(s, config.prefer_single_quotes) {
1059            StrStyle::Single => write_single_quoted(output, s),
1060            _ => write_double_quoted(output, s),
1061        }
1062    } else {
1063        write_string(output, s, indent, config);
1064    }
1065}
1066
1067/// The style [`write_string`] gives a string scalar.
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069enum StrStyle {
1070    Plain,
1071    Single,
1072    Double,
1073    Block,
1074}
1075
1076fn write_string(output: &mut String, s: &str, indent: usize, config: &Cx<'_>) {
1077    match string_style(s, config) {
1078        StrStyle::Plain => output.push_str(s),
1079        StrStyle::Single => write_single_quoted(output, s),
1080        StrStyle::Double => write_double_quoted(output, s),
1081        StrStyle::Block => write_block_scalar(output, s, indent, config),
1082    }
1083}
1084
1085/// Choose the style for a string scalar in the writer's context. Every
1086/// choice must read back as exactly `s`.
1087fn string_style(s: &str, config: &Cx<'_>) -> StrStyle {
1088    // Empty string must be quoted
1089    if s.is_empty() {
1090        return if config.prefer_single_quotes {
1091            StrStyle::Single
1092        } else {
1093            StrStyle::Double
1094        };
1095    }
1096
1097    // Force-quote all strings when configured. A scalar starting with
1098    // `...` emitted at the start of a line reads back as the
1099    // document-end marker (explicit-key emission places keys at column
1100    // 0), so it can never go plain either -- same family as the `-`
1101    // first-byte rule, which already covers `---` (found by
1102    // fuzz_roundtrip on a `? ...` explicit key). Single-quoted style has
1103    // no escapes, and a line break inside it folds to a space on
1104    // re-parse, so a string it cannot carry verbatim falls back to
1105    // double quotes regardless of the setting.
1106    if config.quote_all || s.starts_with("...") {
1107        return quoted_style(s, true);
1108    }
1109
1110    // Fast path: short ASCII strings that are clearly safe as plain
1111    // scalars, in every context (no flow indicator can pass the byte
1112    // filter). Avoids the full scan for the majority of mapping keys.
1113    if is_simple_plain(s) {
1114        return StrStyle::Plain;
1115    }
1116
1117    // Block scalar for multiline strings -- unless the string carries a
1118    // character a block scalar cannot represent: `str::lines` and the
1119    // block's own line breaks erase a `\r` (#335). A flow collection
1120    // cannot hold a block scalar at all.
1121    if config.block_scalars && !config.in_flow && !needs_double_quoted_escape(s) {
1122        let newlines = s.bytes().filter(|&b| b == b'\n').count();
1123        if newlines >= config.block_scalar_threshold {
1124            return StrStyle::Block;
1125        }
1126    }
1127
1128    // CR and the Unicode line separators are representable only with
1129    // double-quoted escapes (#335).
1130    if needs_double_quoted_escape(s) {
1131        return StrStyle::Double;
1132    }
1133    if is_plain_safe(s, config.in_flow) {
1134        return StrStyle::Plain;
1135    }
1136    quoted_style(s, config.prefer_single_quotes)
1137}
1138
1139/// Single quotes when they are preferred and can carry `s` verbatim,
1140/// double quotes otherwise. Single quotes cannot carry a control
1141/// character (a line break folds to a space on re-parse) nor any
1142/// character only a double-quoted escape spells faithfully.
1143fn quoted_style(s: &str, prefer_single: bool) -> StrStyle {
1144    if prefer_single && single_quote_safe(s) && !needs_double_quoted_escape(s) {
1145        StrStyle::Single
1146    } else {
1147        StrStyle::Double
1148    }
1149}
1150
1151/// Short, alnum-bounded, built only from bytes that never need quoting,
1152/// and not a word or number the resolver would turn into another type.
1153///
1154/// The conditions are all ANDed. An earlier version of this guard read
1155/// `a && b && c && !config.block_scalars || no_newline`, which let
1156/// *every* newline-free string take the fast path regardless of its
1157/// first byte (`"-"` slipped through unquoted and re-parsed as a block
1158/// sequence entry, not a scalar). The alnum bounds already exclude every
1159/// `FIRST_CHAR_QUOTE` member and tab.
1160fn is_simple_plain(s: &str) -> bool {
1161    let bytes = s.as_bytes();
1162    bytes.len() <= 64
1163        && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
1164        && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
1165        && bytes
1166            .iter()
1167            .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/'))
1168        && !is_reserved_word(s)
1169        && !looks_like_number(s)
1170}
1171
1172/// The plain spellings that resolve to a boolean or null.
1173fn is_reserved_word(s: &str) -> bool {
1174    matches!(
1175        s,
1176        "true" | "false" | "null" | "~" | "True" | "False" | "TRUE" | "FALSE" | "Null" | "NULL"
1177    )
1178}
1179
1180/// A flow indicator: inside `[...]` / `{...}` it ends or nests a
1181/// collection wherever it appears in a plain scalar (YAML 1.2 §7.3.3,
1182/// `ns-plain-safe(c)`), not only at the first byte. Shared with the CST
1183/// editor's plain-scalar check for flow leaves.
1184pub(crate) fn has_flow_indicator(s: &str) -> bool {
1185    s.bytes()
1186        .any(|b| matches!(b, b',' | b'[' | b']' | b'{' | b'}'))
1187}
1188
1189/// Whether `s` can be written as a plain scalar and read back unchanged,
1190/// in block context or (`in_flow`) inside a flow collection, where
1191/// `[viewer, admin]` written for the one string `viewer, admin` reads
1192/// back as two items.
1193fn is_plain_safe(s: &str, in_flow: bool) -> bool {
1194    let bytes = s.as_bytes();
1195    let (first, last) = (bytes[0], bytes[bytes.len() - 1]);
1196    // A leading or trailing tab must quote. `NEEDS_QUOTE_BYTE`
1197    // deliberately excludes tab so an *interior* tab stays unescaped in
1198    // a plain scalar, but YAML 1.2 still requires quoting when a plain
1199    // scalar's content starts or ends in white space (tab included), or
1200    // the boundary is lost on re-parse. A trailing space likewise.
1201    if (first < 128 && FIRST_CHAR_QUOTE[first as usize])
1202        || first == b'\t'
1203        || matches!(last, b'\t' | b' ')
1204        || is_reserved_word(s)
1205        || looks_like_number(s)
1206        || (in_flow && has_flow_indicator(s))
1207    {
1208        return false;
1209    }
1210    !(0..bytes.len()).any(|i| byte_forces_quotes(bytes, i))
1211}
1212
1213/// Whether the byte at `i` keeps a string from being plain. A colon or a
1214/// hash counts only where YAML gives it meaning; see `colon_ends_plain`
1215/// and `hash_starts_comment`.
1216fn byte_forces_quotes(bytes: &[u8], i: usize) -> bool {
1217    match bytes[i] {
1218        b':' => colon_ends_plain(bytes, i),
1219        b'#' => hash_starts_comment(bytes, i),
1220        b => b < 128 && NEEDS_QUOTE_BYTE[b as usize],
1221    }
1222}
1223
1224/// Whether `s` can be represented as a YAML single-quoted scalar with no
1225/// escapes beyond doubling an embedded `'`.
1226///
1227/// Single-quoted style has no escape mechanism at all besides doubling the
1228/// quote character itself — a literal backslash, double quote, `#`, `:`,
1229/// and so on all pass straight through unescaped. What it *cannot* carry is
1230/// a control character (tab, newline, carriage return, and the rest of the
1231/// C0/C1 ranges) or any other non-printable code point: those need one of
1232/// double-quoted style's escape sequences, so a string containing one must
1233/// fall back to double-quoted even when `prefer_single_quotes` is set.
1234fn single_quote_safe(s: &str) -> bool {
1235    !s.chars()
1236        .any(|c| c.is_control() || matches!(c, '\u{fffe}' | '\u{ffff}'))
1237}
1238
1239/// Write a single-quoted string, escaping embedded single quotes.
1240fn write_single_quoted(output: &mut String, s: &str) {
1241    output.push('\'');
1242    for c in s.chars() {
1243        if c == '\'' {
1244            output.push_str("''");
1245        } else {
1246            output.push(c);
1247        }
1248    }
1249    output.push('\'');
1250}
1251
1252/// Write a double-quoted string with bulk-copy between escape points.
1253fn write_double_quoted(output: &mut String, s: &str) {
1254    output.push('"');
1255    let mut start = 0;
1256    for (i, c) in s.char_indices() {
1257        let esc = match c {
1258            '"' => "\\\"",
1259            '\\' => "\\\\",
1260            '\n' => "\\n",
1261            '\r' => "\\r",
1262            '\t' => "\\t",
1263            '\0' => "\\0",
1264            // Named escapes for the non-ASCII line-break characters
1265            // (YAML 1.2 section 5.7): emitted raw they read back as
1266            // line breaks in 1.1-era parsers and in this crate's own
1267            // reader (#335).
1268            '\u{0085}' => "\\N",
1269            '\u{2028}' => "\\L",
1270            '\u{2029}' => "\\P",
1271            // A raw BOM must never reach the output stream — the
1272            // reader rejects one inside a document (§5.2).
1273            '\u{feff}' => "\\uFEFF",
1274            // The two non-characters sit outside `c-printable` (§5.1)
1275            // and have no named escape (#379).
1276            '\u{fffe}' | '\u{ffff}' => {
1277                output.push_str(&s[start..i]);
1278                let _ = write!(output, "\\u{:04X}", c as u32);
1279                start = i + c.len_utf8();
1280                continue;
1281            }
1282            c if (c as u32) < 0x20 || ('\u{7f}'..='\u{9f}').contains(&c) => {
1283                // Other control characters -- C0, DEL and the C1 block
1284                // (#379; NEL is matched above): flush and write the
1285                // two-digit hex escape. A C1 character is two bytes in
1286                // UTF-8, so advance by its width, not by one.
1287                output.push_str(&s[start..i]);
1288                let _ = write!(output, "\\x{:02X}", c as u32);
1289                start = i + c.len_utf8();
1290                continue;
1291            }
1292            _ => continue,
1293        };
1294        output.push_str(&s[start..i]);
1295        output.push_str(esc);
1296        start = i + c.len_utf8();
1297    }
1298    output.push_str(&s[start..]);
1299    output.push('"');
1300}
1301
1302/// Write a string using YAML literal block scalar style (|).
1303/// Write a block scalar's content lines at `indent + 1`.
1304///
1305/// An empty line gets **no** indentation. The block's indent is detected from
1306/// its first non-empty line, so an empty one has nothing to say; writing the
1307/// indent anyway leaves it standing as trailing whitespace on a line that
1308/// holds nothing, which `git diff --check` and `yamllint` reject.
1309///
1310/// This must stay one function. It had three identical copies — `|` auto, `|`
1311/// explicit and `>` — and the empty-line rule was missing from all three, so
1312/// fixing any one of them would have left the other two writing it.
1313fn write_block_scalar_body(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1314    for line in s.lines() {
1315        output.push('\n');
1316        if !line.is_empty() {
1317            write_indent(output, config.indent * (indent + 1));
1318            output.push_str(line);
1319        }
1320    }
1321}
1322
1323/// The explicit indentation indicator digit(s), if the block needs one.
1324///
1325/// YAML 1.2.2 §8.1.1.1: a literal/folded block scalar's indentation is
1326/// normally *auto-detected* from its first non-empty content line. That
1327/// detection breaks when the first content line itself starts with a
1328/// space or a tab — the leading whitespace gets folded into the detected
1329/// indentation, inflating it past what later, less-indented lines carry,
1330/// which a parser then rejects as inconsistent indentation. The fix is an
1331/// explicit indentation indicator between the block style character
1332/// (`|`/`>`) and the chomping indicator, stating the indentation as a
1333/// number of columns *beyond the parent node's own indentation*.
1334///
1335/// [`write_block_scalar_body`] always places content `config.indent`
1336/// columns beyond the indentation this function's caller was handed for
1337/// this value's slot (the parent node's own indentation) — so whenever an
1338/// indicator is needed, its value is exactly `config.indent`, independent
1339/// of nesting depth.
1340fn block_scalar_indent_indicator(s: &str, config: &SerializerConfig) -> String {
1341    let first_content_line = s.lines().find(|line| !line.is_empty());
1342    match first_content_line {
1343        Some(line) if line.starts_with(' ') || line.starts_with('\t') => config.indent.to_string(),
1344        _ => String::new(),
1345    }
1346}
1347
1348/// The chomping indicator for a block scalar holding `s` (YAML 1.2.2
1349/// §8.1.1.2): strip when the text has no trailing line break, keep when
1350/// it has more than one, clip otherwise -- except that a text with **no
1351/// content line at all** (`"\n"`) also takes keep.
1352///
1353/// Clip preserves the final break of the last content line, and a block
1354/// with no content line has none, so `k: |` followed by one empty line
1355/// read back as the empty string and the value was lost on the first
1356/// round trip (#383). Under keep the empty line is the value.
1357///
1358/// One function for the three block writers: the rule had three copies
1359/// and the no-content case was missing from all of them.
1360fn block_chomping(s: &str) -> &'static str {
1361    if !s.ends_with('\n') {
1362        "-"
1363    } else if s.ends_with("\n\n") || s.lines().all(str::is_empty) {
1364        "+"
1365    } else {
1366        ""
1367    }
1368}
1369
1370/// Begin an entry's line: a line break, unless the output already ends
1371/// with one, then the indentation.
1372///
1373/// A block scalar closes its own last line, so a break written blindly
1374/// after one opens a blank line. Under clip chomping that was cosmetic;
1375/// under keep chomping (`|+`) the parser keeps the blank line, and a
1376/// keep-chomped value grew by one newline per round trip whenever a
1377/// sibling entry followed it (#385).
1378fn start_line(output: &mut String, indent_spaces: usize) {
1379    if !output.ends_with('\n') {
1380        output.push('\n');
1381    }
1382    write_indent(output, indent_spaces);
1383}
1384
1385fn write_block_scalar(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1386    let chomping = block_chomping(s);
1387
1388    output.push('|');
1389    output.push_str(&block_scalar_indent_indicator(s, config));
1390    output.push_str(chomping);
1391
1392    write_block_scalar_body(output, s, indent, config);
1393
1394    // `str::lines()` never yields an extra empty element for the string's
1395    // *final* line terminator, but every other trailing blank line DOES
1396    // get its own element (and so its own newline from the loop above).
1397    // That means the body loop always emits exactly one newline fewer
1398    // than `s` actually ends with, regardless of how many trailing
1399    // newlines there are: for `"text\n"` the loop emits none after
1400    // "text" (`.lines()` is just `["text"]`), for `"text\n\n"` it emits
1401    // one (`["text", ""]`), for `"text\n\n\n"` it emits two
1402    // (`["text", "", ""]`), and so on -- one behind `s`'s own count every
1403    // time. So exactly one more newline (never a count derived from `s`)
1404    // closes the gap.
1405    //
1406    // The previous version pushed `s.len() - s.trim_end_matches('\n').len()`
1407    // newlines here -- the *full* trailing-newline count -- which double
1408    // counted every trailing newline past the first and grew the string by
1409    // one extra `\n` on every serialize/parse round trip.
1410    if s.ends_with('\n') {
1411        output.push('\n');
1412    }
1413}
1414
1415/// Whether a value can be safely rendered inside an `Auto`-mode flow
1416/// collection. A flow collection may only contain scalars and other flow
1417/// collections — a nested *block* collection would produce invalid YAML
1418/// (`[a, - x]`). So in `Auto` mode the whole subtree must stay within the
1419/// flow threshold for any ancestor to flow; otherwise we fall back to block.
1420///
1421/// `Tagged` values are conservatively treated as block-only: they carry
1422/// anchors, custom tags, and the internal block-scalar/anchor magic tags that
1423/// have no valid flow representation here.
1424fn auto_flow_eligible(value: &Value, config: &Cx<'_>) -> bool {
1425    match value {
1426        Value::Sequence(s) => {
1427            s.len() <= config.flow_threshold && s.iter().all(|v| auto_flow_eligible(v, config))
1428        }
1429        Value::Mapping(m) => {
1430            m.len() <= config.flow_threshold && m.iter().all(|(_, v)| auto_flow_eligible(v, config))
1431        }
1432        Value::Tagged(_) => false,
1433        _ => true,
1434    }
1435}
1436
1437/// Decide whether a collection of `len` items holding `values` should render
1438/// in flow style under the active `config`.
1439fn use_flow<'a, I>(len: usize, values: impl Fn() -> I, config: &Cx<'_>) -> bool
1440where
1441    I: Iterator<Item = &'a Value>,
1442{
1443    if config.in_flow {
1444        return true;
1445    }
1446    match config.flow_style {
1447        FlowStyle::Block => false,
1448        FlowStyle::Flow => true,
1449        FlowStyle::Auto => {
1450            len <= config.flow_threshold && values().all(|v| auto_flow_eligible(v, config))
1451        }
1452    }
1453}
1454
1455fn write_sequence(
1456    output: &mut String,
1457    seq: &Sequence,
1458    indent: usize,
1459    is_root: bool,
1460    config: &Cx<'_>,
1461    depth: usize,
1462) -> Result<()> {
1463    if seq.is_empty() {
1464        output.push_str("[]");
1465        return Ok(());
1466    }
1467
1468    if use_flow(seq.len(), || seq.iter(), config) {
1469        return write_flow_sequence(output, seq, config, depth);
1470    }
1471
1472    for (i, value) in seq.iter().enumerate() {
1473        if i > 0 || !is_root {
1474            start_line(output, config.indent * indent);
1475        }
1476        output.push('-');
1477
1478        match value {
1479            Value::Mapping(m) if !m.is_empty() => {
1480                // The item's first key shares the dash's line, so the dash
1481                // always takes its space here whatever the *value* looks
1482                // like — `- key: 1` and `- key:` alike.
1483                output.push(' ');
1484                for (j, (k, v)) in m.iter().enumerate() {
1485                    if j > 0 {
1486                        start_line(output, config.indent * (indent + 1));
1487                    }
1488                    write_key_string(output, k, indent + 1, config);
1489                    output.push(':');
1490                    if indicator_takes_a_space(v, config) {
1491                        output.push(' ');
1492                    }
1493                    // `compact_list_indent`: a sequence value starts at its
1494                    // own key's indentation (`indent + 1`, matching `k`'s
1495                    // own column) rather than one level deeper — the same
1496                    // rule `write_mapping` applies, extended to a mapping
1497                    // that is itself a sequence item. Every other
1498                    // block-layout value (a mapping, or a sequence with the
1499                    // option off) still gets the extra level.
1500                    let next_indent = if needs_block_layout(v, config) {
1501                        if config.compact_list_indent && matches!(v, Value::Sequence(_)) {
1502                            indent + 1
1503                        } else {
1504                            indent + 2
1505                        }
1506                    } else {
1507                        indent + 1
1508                    };
1509                    write_value(output, v, next_indent, false, config, depth + 1)?;
1510                }
1511            }
1512            Value::Sequence(inner) if config.compact_list_indent && !inner.is_empty() => {
1513                // `compact_list_indent`: a sequence item that is itself a
1514                // sequence is written inline (`- - a`), with the nested
1515                // dash sharing this item's own dash's line the same way a
1516                // nested mapping's first key does above. Continuation
1517                // elements align with that nested dash (`indent + 1`).
1518                output.push(' ');
1519                write_sequence(output, inner, indent + 1, true, config, depth + 1)?;
1520            }
1521            _ => {
1522                if indicator_takes_a_space(value, config) {
1523                    output.push(' ');
1524                }
1525                // A block collection item nests one level under the dash.
1526                // A scalar item uses the indent for one thing, a block
1527                // scalar's body, which belongs `config.indent` columns past
1528                // the dash -- the parent node's indentation the block's
1529                // indentation indicator counts from -- so it takes this
1530                // sequence's own level. One level deeper put the body four
1531                // columns past the dash under a `|2` indicator, and the
1532                // surplus read back as content (#387).
1533                let item_indent = if needs_block_layout(value, config) {
1534                    indent + 1
1535                } else {
1536                    indent
1537                };
1538                write_value(output, value, item_indent, false, config, depth + 1)?;
1539            }
1540        }
1541    }
1542    Ok(())
1543}
1544
1545/// Whether the indicator introducing `value` — the `:` after a key, or a
1546/// sequence item's `-` — must be followed by a space.
1547///
1548/// An inline scalar needs one (`key: 1`, `- 1`). A block collection does not:
1549/// it begins on the *next* line, so the space would be left dangling at the
1550/// end of this one. That is invisible, it re-parses identically, and it is
1551/// exactly what `git diff --check` and `yamllint`'s `trailing-spaces` reject.
1552///
1553/// The exception is an anchor-wrapped block value, which renders as
1554/// `&idNNN\n  ...`: the `&` is on *this* line, so the space is real
1555/// separation rather than leftovers. A regular (user) tag is the same
1556/// story: `!tag\n  ...` also has visible text — the tag name — on this
1557/// line, so the space is real separation there too.
1558///
1559/// [`write_mapping`] has always applied this rule; [`write_sequence`] carried
1560/// its own copy of the key-writing and did not, which is the whole of the bug
1561/// this function exists to stop recurring. One rule, one place, both callers.
1562fn indicator_takes_a_space(value: &Value, config: &Cx<'_>) -> bool {
1563    !needs_block_layout(value, config)
1564        || matches!(
1565            value,
1566            Value::Tagged(t) if !t.tag().is_directive()
1567                || t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF
1568        )
1569}
1570
1571/// Whether a value needs block-style layout (indented on the line after `:`)
1572/// rather than inline scalar layout. Anchor-wrapped block collections must be
1573/// treated like the inner collection; a regular (user) tag likewise needs
1574/// block layout exactly when *its* payload does (a tagged mapping/sequence
1575/// under a key must be indented one level deeper, the same as an untagged
1576/// one — see [`write_value`]'s `Value::Tagged` arm).
1577///
1578/// Every `__noya_` arm below must predict what [`write_internal_tag`] will
1579/// actually emit for that tag. When the two disagree and the writer emits a
1580/// block *mapping* while this says inline, the mapping's keys are written at
1581/// the parent's column and YAML reads them as siblings of the key they were
1582/// meant to be under — the value silently becomes null and its contents move
1583/// up a level. `SpaceAfter`/`Commented` around a struct did exactly that
1584/// until this function learned to look through them.
1585/// A non-empty collection the writer will lay out in block form, not
1586/// in flow form (`[...]` / `{...}` on the current line).
1587fn is_block_collection(v: &Value, config: &Cx<'_>) -> bool {
1588    match v {
1589        Value::Mapping(m) => {
1590            !m.is_empty() && !use_flow(m.len(), || m.iter().map(|(_, v)| v), config)
1591        }
1592        Value::Sequence(s) => !s.is_empty() && !use_flow(s.len(), || s.iter(), config),
1593        _ => false,
1594    }
1595}
1596
1597fn needs_block_layout(v: &Value, config: &Cx<'_>) -> bool {
1598    let Value::Tagged(t) = v else {
1599        return is_block_collection(v, config);
1600    };
1601    let inner = t.value();
1602    if !t.tag().is_directive() {
1603        return needs_block_layout(inner, config);
1604    }
1605    match t.tag().as_str() {
1606        // `[id, inner]` — `&id` goes on the key's line, the inner value
1607        // lays itself out from there.
1608        crate::fmt::MAGIC_ANCHOR_DEF => match inner {
1609            Value::Sequence(seq) if seq.len() == 2 => needs_block_layout(&seq[1], config),
1610            _ => false,
1611        },
1612        // `*id` is always inline.
1613        crate::fmt::MAGIC_ANCHOR_REF => false,
1614        // A flow wrapper holding the shape it names emits one line. Holding
1615        // anything else, the writer falls back to plain output, and the
1616        // layout has to follow the fallback rather than the wrapper's name.
1617        crate::fmt::MAGIC_FLOW_SEQ => {
1618            !matches!(inner, Value::Sequence(_)) && needs_block_layout(inner, config)
1619        }
1620        crate::fmt::MAGIC_FLOW_MAP => {
1621            !matches!(inner, Value::Mapping(_)) && needs_block_layout(inner, config)
1622        }
1623        // A block-scalar wrapper around a string writes its own `|`/`>`
1624        // header; around anything else it falls back the same way.
1625        crate::fmt::MAGIC_LIT_STR | crate::fmt::MAGIC_FOLD_STR => {
1626            !matches!(inner, Value::String(_)) && needs_block_layout(inner, config)
1627        }
1628        // `[inner, comment]` — the comment is appended to whatever `inner`
1629        // emits, so the layout is entirely `inner`'s.
1630        crate::fmt::MAGIC_COMMENTED => match inner {
1631            Value::Sequence(seq) if seq.len() == 2 => needs_block_layout(&seq[0], config),
1632            other => needs_block_layout(other, config),
1633        },
1634        // A trailing blank line does not change how the value itself lays out.
1635        crate::fmt::MAGIC_SPACE_AFTER => needs_block_layout(inner, config),
1636        // Any other directive falls through to plain output.
1637        _ => needs_block_layout(inner, config),
1638    }
1639}
1640
1641fn write_mapping(
1642    output: &mut String,
1643    map: &Mapping,
1644    indent: usize,
1645    is_root: bool,
1646    config: &Cx<'_>,
1647    depth: usize,
1648) -> Result<()> {
1649    if map.is_empty() {
1650        output.push_str("{}");
1651        return Ok(());
1652    }
1653
1654    if use_flow(map.len(), || map.iter().map(|(_, v)| v), config) {
1655        return write_flow_mapping(output, map, config, depth);
1656    }
1657
1658    for (i, (key, value)) in map.iter().enumerate() {
1659        if i > 0 || !is_root {
1660            start_line(output, config.indent * indent);
1661        }
1662        write_key_string(output, key, indent, config);
1663
1664        output.push(':');
1665        if indicator_takes_a_space(value, config) {
1666            output.push(' ');
1667        }
1668        let next_indent = if needs_block_layout(value, config) {
1669            // `compact_list_indent`: when on, sequence values
1670            // under a mapping key align with the key column
1671            // instead of being bumped one indent level deeper.
1672            // This is the visual style preferred by some style
1673            // guides (Kubernetes manifests, GitHub Actions
1674            // workflows). Mappings and other non-sequence block
1675            // values keep the standard indent.
1676            if config.compact_list_indent && matches!(value, Value::Sequence(_)) {
1677                indent
1678            } else {
1679                indent + 1
1680            }
1681        } else {
1682            indent
1683        };
1684        write_value(output, value, next_indent, false, config, depth + 1)?;
1685    }
1686    Ok(())
1687}
1688
1689fn write_internal_tag(
1690    output: &mut String,
1691    tag: &str,
1692    value: &Value,
1693    indent: usize,
1694    is_root: bool,
1695    config: &Cx<'_>,
1696    depth: usize,
1697) -> Result<()> {
1698    match (tag, value) {
1699        (crate::fmt::MAGIC_FLOW_SEQ, Value::Sequence(seq)) => {
1700            write_flow_sequence(output, seq, config, depth)
1701        }
1702        (crate::fmt::MAGIC_FLOW_MAP, Value::Mapping(map)) => {
1703            write_flow_mapping(output, map, config, depth)
1704        }
1705        // Inside a flow collection a block scalar cannot appear, a
1706        // comment would swallow the rest of the line, and a blank line
1707        // has no meaning: the value is written on its own.
1708        (
1709            crate::fmt::MAGIC_LIT_STR
1710            | crate::fmt::MAGIC_FOLD_STR
1711            | crate::fmt::MAGIC_COMMENTED
1712            | crate::fmt::MAGIC_SPACE_AFTER,
1713            _,
1714        ) if config.in_flow => write_flow_hinted(output, tag, value, config, depth),
1715        (crate::fmt::MAGIC_LIT_STR, Value::String(s)) => {
1716            write_literal_block(output, s, indent, config);
1717            Ok(())
1718        }
1719        (crate::fmt::MAGIC_FOLD_STR, Value::String(s)) => {
1720            write_folded_block(output, s, indent, config);
1721            Ok(())
1722        }
1723        // value is a sequence [inner_value, comment_string]
1724        (crate::fmt::MAGIC_COMMENTED, Value::Sequence(seq)) if seq.len() == 2 => {
1725            let start = output.len();
1726            write_value(output, &seq[0], indent, is_root, config, depth)?;
1727            if let Value::String(comment) = &seq[1] {
1728                // A value that spans lines may end inside a block scalar,
1729                // where `# ...` is content: its comment goes below it.
1730                let inline = !output[start..].contains('\n');
1731                write_comment(output, comment, inline, config.indent * indent);
1732            }
1733            Ok(())
1734        }
1735        (crate::fmt::MAGIC_SPACE_AFTER, _) => {
1736            write_value(output, value, indent, is_root, config, depth)?;
1737            output.push('\n');
1738            // `start_line` opens the next entry with a newline only when the
1739            // output does not already end in one, so a single `\n` here is
1740            // absorbed and the blank line never appears. Nested values need a
1741            // second one to survive that. At the root there is no next entry
1742            // to separate from, and one `\n` is simply the terminator.
1743            if !is_root {
1744                output.push('\n');
1745            }
1746            Ok(())
1747        }
1748        // value is a sequence [String(id), inner_value]
1749        (crate::fmt::MAGIC_ANCHOR_DEF, Value::Sequence(seq)) => {
1750            if let [Value::String(id), inner] = seq.as_slice() {
1751                write_anchor_def(output, id, inner, indent, config, depth)?;
1752            }
1753            Ok(())
1754        }
1755        // value is String(id). Emit "*id".
1756        (crate::fmt::MAGIC_ANCHOR_REF, Value::String(id)) => {
1757            output.push('*');
1758            output.push_str(id);
1759            Ok(())
1760        }
1761        (crate::fmt::MAGIC_ANCHOR_DEF | crate::fmt::MAGIC_ANCHOR_REF, _) => Ok(()),
1762        // A directive holding a shape it does not apply to falls through
1763        // to regular output.
1764        _ => write_value(output, value, indent, is_root, config, depth),
1765    }
1766}
1767
1768/// Write `comment` after a value: its first line on the value's line when
1769/// `inline`, every other line as a `# ` line of its own at `column`.
1770///
1771/// Comment text is never written raw. A line break in it (LF, CR, NEL,
1772/// LS, PS, all of which the reader treats as one) would end the comment
1773/// and turn the rest into YAML, so `x\nrole: admin` used to add a key; it
1774/// starts a new comment line instead. A character no YAML stream may
1775/// carry (other C0/C1 controls, a BOM, U+FFFE/U+FFFF) is replaced with
1776/// U+FFFD so the document stays loadable. The column only has to sit
1777/// left of any block scalar body in the value, which a trailing comment
1778/// line then cleanly ends.
1779fn write_comment(output: &mut String, comment: &str, inline: bool, column: usize) {
1780    let is_break = |c: char| matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}');
1781    for (i, line) in comment.split(is_break).enumerate() {
1782        if i == 0 && inline {
1783            output.push_str(" #");
1784        } else {
1785            start_line(output, column);
1786            output.push('#');
1787        }
1788        if !line.is_empty() {
1789            output.push(' ');
1790        }
1791        output.extend(line.chars().map(|c| {
1792            if needs_double_quoted_escape(&c.to_string()) {
1793                '\u{fffd}'
1794            } else {
1795                c
1796            }
1797        }));
1798    }
1799}
1800
1801/// A block-only directive met inside a flow collection: write the value
1802/// it wraps (for a comment, the first element of `[value, comment]`).
1803fn write_flow_hinted(
1804    output: &mut String,
1805    tag: &str,
1806    value: &Value,
1807    config: &Cx<'_>,
1808    depth: usize,
1809) -> Result<()> {
1810    let inner = match (tag, value) {
1811        (crate::fmt::MAGIC_COMMENTED, Value::Sequence(seq)) if seq.len() == 2 => &seq[0],
1812        _ => value,
1813    };
1814    write_value(output, inner, 0, false, config, depth)
1815}
1816
1817/// Emit `&id` before the inner value. For block collections the inner
1818/// starts on a new line; for scalars it follows on the same line.
1819fn write_anchor_def(
1820    output: &mut String,
1821    id: &str,
1822    inner: &Value,
1823    indent: usize,
1824    config: &Cx<'_>,
1825    depth: usize,
1826) -> Result<()> {
1827    output.push('&');
1828    output.push_str(id);
1829    match inner {
1830        Value::Mapping(m) if is_block_collection(inner, config) => {
1831            output.push('\n');
1832            write_indent(output, config.indent * indent);
1833            // `is_root = true` suppresses the leading newline
1834            // inside write_mapping so the anchor line and
1835            // the first key are correctly adjacent.
1836            write_mapping(output, m, indent, true, config, depth + 1)
1837        }
1838        Value::Sequence(s) if is_block_collection(inner, config) => {
1839            output.push('\n');
1840            write_indent(output, config.indent * indent);
1841            write_sequence(output, s, indent, true, config, depth + 1)
1842        }
1843        _ => {
1844            output.push(' ');
1845            write_value(output, inner, indent, false, config, depth + 1)
1846        }
1847    }
1848}
1849
1850fn write_flow_sequence(
1851    output: &mut String,
1852    seq: &Sequence,
1853    config: &Cx<'_>,
1854    depth: usize,
1855) -> Result<()> {
1856    let config = &config.flow();
1857    output.push('[');
1858    for (i, value) in seq.iter().enumerate() {
1859        if i > 0 {
1860            output.push_str(", ");
1861        }
1862        write_value(output, value, 0, false, config, depth + 1)?;
1863    }
1864    output.push(']');
1865    Ok(())
1866}
1867
1868fn write_flow_mapping(
1869    output: &mut String,
1870    map: &Mapping,
1871    config: &Cx<'_>,
1872    depth: usize,
1873) -> Result<()> {
1874    let config = &config.flow();
1875    output.push('{');
1876    for (i, (key, value)) in map.iter().enumerate() {
1877        if i > 0 {
1878            output.push_str(", ");
1879        }
1880        write_key_string(output, key, 0, config);
1881        output.push_str(": ");
1882        write_value(output, value, 0, false, config, depth + 1)?;
1883    }
1884    output.push('}');
1885    Ok(())
1886}
1887
1888fn write_literal_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1889    let chomping = block_chomping(s);
1890
1891    output.push('|');
1892    output.push_str(&block_scalar_indent_indicator(s, config));
1893    output.push_str(chomping);
1894
1895    write_block_scalar_body(output, s, indent, config);
1896}
1897
1898fn write_folded_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1899    let chomping = block_chomping(s);
1900
1901    output.push('>');
1902    output.push_str(&block_scalar_indent_indicator(s, config));
1903    output.push_str(chomping);
1904
1905    write_block_scalar_body(output, s, indent, config);
1906}
1907
1908/// Serialize an iterable of values as a multi-document YAML
1909/// string with `---` document-start markers between each.
1910///
1911/// # Errors
1912///
1913/// All variants documented on [`to_string`]; the first failing
1914/// document short-circuits and returns its error — earlier
1915/// documents are not emitted.
1916///
1917/// # Examples
1918///
1919/// ```rust
1920/// let docs = vec![1, 2, 3];
1921/// let yaml = noyalib::to_string_multi(&docs).unwrap();
1922/// assert!(yaml.contains("---"));
1923/// ```
1924pub fn to_string_multi<T: serde_core::Serialize>(values: &[T]) -> Result<String> {
1925    to_string_multi_with_config(values, &SerializerConfig::default())
1926}
1927
1928/// Serialize an iterable of values as a multi-document YAML
1929/// string with a custom [`SerializerConfig`].
1930///
1931/// # Errors
1932///
1933/// All variants documented on [`to_string_with_config`].
1934pub fn to_string_multi_with_config<T: serde_core::Serialize>(
1935    values: &[T],
1936    config: &SerializerConfig,
1937) -> Result<String> {
1938    let mut output = String::new();
1939    for (i, value) in values.iter().enumerate() {
1940        if i > 0 {
1941            output.push('\n');
1942        }
1943        output.push_str("---\n");
1944        let v = to_value(value)?;
1945        write_value(&mut output, &v, 0, true, &Cx::new(config), 0)?;
1946        output.push('\n');
1947    }
1948    Ok(output)
1949}
1950
1951/// Serialize multiple values as multi-document YAML to a writer.
1952///
1953/// # Errors
1954///
1955/// Returns an error if any value cannot be serialized or writing fails.
1956#[cfg(feature = "std")]
1957pub fn to_writer_multi<W, T>(writer: W, values: &[T]) -> Result<()>
1958where
1959    W: std::io::Write,
1960    T: serde_core::Serialize,
1961{
1962    to_writer_multi_with_config(writer, values, &SerializerConfig::default())
1963}
1964
1965/// Serialize multiple values as multi-document YAML to a writer with custom
1966/// configuration.
1967///
1968/// # Errors
1969///
1970/// Returns an error if any value cannot be serialized or writing fails.
1971#[cfg(feature = "std")]
1972pub fn to_writer_multi_with_config<W, T>(
1973    writer: W,
1974    values: &[T],
1975    config: &SerializerConfig,
1976) -> Result<()>
1977where
1978    W: std::io::Write,
1979    T: serde_core::Serialize,
1980{
1981    let s = to_string_multi_with_config(values, config)?;
1982    let mut writer = writer;
1983    writer.write_all(s.as_bytes())?;
1984    Ok(())
1985}
1986
1987/// A YAML serializer.
1988#[derive(Debug, Copy, Clone)]
1989pub struct Serializer;
1990
1991impl serde_core::ser::Serializer for Serializer {
1992    type Ok = Value;
1993    type Error = Error;
1994
1995    type SerializeSeq = SerializeSeq;
1996    type SerializeTuple = SerializeSeq;
1997    type SerializeTupleStruct = SerializeSeq;
1998    type SerializeTupleVariant = SerializeTupleVariant;
1999    type SerializeMap = SerializeMap;
2000    type SerializeStruct = SerializeMap;
2001    type SerializeStructVariant = SerializeStructVariant;
2002
2003    fn serialize_bool(self, v: bool) -> Result<Value> {
2004        Ok(Value::Bool(v))
2005    }
2006
2007    fn serialize_i8(self, v: i8) -> Result<Value> {
2008        self.serialize_i64(i64::from(v))
2009    }
2010
2011    fn serialize_i16(self, v: i16) -> Result<Value> {
2012        self.serialize_i64(i64::from(v))
2013    }
2014
2015    fn serialize_i32(self, v: i32) -> Result<Value> {
2016        self.serialize_i64(i64::from(v))
2017    }
2018
2019    fn serialize_i64(self, v: i64) -> Result<Value> {
2020        Ok(Value::Number(Number::Integer(v)))
2021    }
2022
2023    fn serialize_u8(self, v: u8) -> Result<Value> {
2024        self.serialize_i64(i64::from(v))
2025    }
2026
2027    fn serialize_u16(self, v: u16) -> Result<Value> {
2028        self.serialize_i64(i64::from(v))
2029    }
2030
2031    fn serialize_u32(self, v: u32) -> Result<Value> {
2032        self.serialize_i64(i64::from(v))
2033    }
2034
2035    fn serialize_u64(self, v: u64) -> Result<Value> {
2036        if let Ok(v) = i64::try_from(v) {
2037            return Ok(Value::Number(Number::Integer(v)));
2038        }
2039        // Values above `i64::MAX` require the `lossless-u64` feature.
2040        // Without it the `Number::Unsigned` variant does not exist and
2041        // there is no lossless representation to fall back to — return
2042        // an explicit serialise-time error so callers can surface the
2043        // limit to their users.
2044        #[cfg(feature = "lossless-u64")]
2045        {
2046            Ok(Value::Number(Number::Unsigned(v)))
2047        }
2048        #[cfg(not(feature = "lossless-u64"))]
2049        {
2050            Err(Error::Serialize(format!(
2051                "u64 value {v} exceeds i64::MAX and cannot be represented losslessly; \
2052                 enable the `lossless-u64` Cargo feature to opt in to unsigned integer support"
2053            )))
2054        }
2055    }
2056
2057    fn serialize_f32(self, v: f32) -> Result<Value> {
2058        self.serialize_f64(f64::from(v))
2059    }
2060
2061    fn serialize_f64(self, v: f64) -> Result<Value> {
2062        Ok(Value::Number(Number::Float(v)))
2063    }
2064
2065    fn serialize_char(self, v: char) -> Result<Value> {
2066        self.serialize_str(&v.to_string())
2067    }
2068
2069    fn serialize_str(self, v: &str) -> Result<Value> {
2070        Ok(Value::String(v.to_owned()))
2071    }
2072
2073    fn serialize_bytes(self, v: &[u8]) -> Result<Value> {
2074        // YAML 1.2.2 §10.4: byte buffers serialise as a `!!binary`
2075        // tagged scalar carrying the RFC 4648 base64 encoding of
2076        // the payload. This is the round-trip partner of the
2077        // `deserialize_bytes` path that recognises `!!binary` and
2078        // base64-decodes on demand. Holds for any `serde_bytes`
2079        // wrapper (`ByteBuf`, `Bytes`) and any `&[u8]` /
2080        // `Vec<u8>`-shaped target the caller annotates with
2081        // `#[serde(with = "serde_bytes")]`.
2082        let encoded = crate::base64::encode(v);
2083        Ok(Value::Tagged(Box::new(TaggedValue::new(
2084            Tag::new("!!binary"),
2085            Value::String(encoded),
2086        ))))
2087    }
2088
2089    fn serialize_none(self) -> Result<Value> {
2090        Ok(Value::Null)
2091    }
2092
2093    fn serialize_some<T>(self, value: &T) -> Result<Value>
2094    where
2095        T: ?Sized + serde_core::Serialize,
2096    {
2097        value.serialize(self)
2098    }
2099
2100    fn serialize_unit(self) -> Result<Value> {
2101        Ok(Value::Null)
2102    }
2103
2104    fn serialize_unit_struct(self, _name: &'static str) -> Result<Value> {
2105        self.serialize_unit()
2106    }
2107
2108    fn serialize_unit_variant(
2109        self,
2110        _name: &'static str,
2111        _variant_index: u32,
2112        variant: &'static str,
2113    ) -> Result<Value> {
2114        self.serialize_str(variant)
2115    }
2116
2117    fn serialize_newtype_struct<T>(self, name: &'static str, value: &T) -> Result<Value>
2118    where
2119        T: ?Sized + serde_core::Serialize,
2120    {
2121        // Intercept formatting hint magic names
2122        match name {
2123            crate::fmt::MAGIC_FLOW_SEQ
2124            | crate::fmt::MAGIC_FLOW_MAP
2125            | crate::fmt::MAGIC_LIT_STR
2126            | crate::fmt::MAGIC_FOLD_STR
2127            | crate::fmt::MAGIC_SPACE_AFTER => {
2128                let inner = value.serialize(Self)?;
2129                Ok(Value::Tagged(Box::new(TaggedValue::new(
2130                    Tag::directive(name),
2131                    inner,
2132                ))))
2133            }
2134            crate::fmt::MAGIC_COMMENTED => {
2135                // value is a tuple (inner_value, comment_string)
2136                let inner = value.serialize(Self)?;
2137                Ok(Value::Tagged(Box::new(TaggedValue::new(
2138                    Tag::directive(name),
2139                    inner,
2140                ))))
2141            }
2142            crate::fmt::MAGIC_ANCHOR_DEF | crate::fmt::MAGIC_ANCHOR_REF => {
2143                // ANCHOR_DEF: value serializes as Sequence([String(id), inner]).
2144                // ANCHOR_REF: value serializes as String(id).
2145                let inner = value.serialize(Self)?;
2146                Ok(Value::Tagged(Box::new(TaggedValue::new(
2147                    Tag::directive(name),
2148                    inner,
2149                ))))
2150            }
2151            crate::fmt::MAGIC_TAGGED => {
2152                // `TaggedValue::serialize`'s wire form: a single-entry map
2153                // keyed by the tag string (#350). The marker name, not the
2154                // map's shape, is what rebuilds `Value::Tagged` here, so a
2155                // genuine one-entry mapping keyed by a `!`-string reaches
2156                // `SerializeMap::end` below and stays a mapping (#377).
2157                match value.serialize(Self)? {
2158                    Value::Mapping(map) if map.len() == 1 => {
2159                        let (tag, inner) = map
2160                            .into_iter()
2161                            .next()
2162                            .expect("length checked to be exactly one entry");
2163                        Ok(Value::Tagged(Box::new(TaggedValue::new(
2164                            Tag::new(tag),
2165                            inner,
2166                        ))))
2167                    }
2168                    other => Ok(other),
2169                }
2170            }
2171            _ => value.serialize(self),
2172        }
2173    }
2174
2175    fn serialize_newtype_variant<T>(
2176        self,
2177        _name: &'static str,
2178        _variant_index: u32,
2179        variant: &'static str,
2180        value: &T,
2181    ) -> Result<Value>
2182    where
2183        T: ?Sized + serde_core::Serialize,
2184    {
2185        let mut map = Mapping::new();
2186        let _ = map.insert(variant.to_owned(), value.serialize(Self)?);
2187        Ok(Value::Mapping(map))
2188    }
2189
2190    fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq> {
2191        Ok(SerializeSeq {
2192            vec: Vec::with_capacity(len.unwrap_or(0)),
2193        })
2194    }
2195
2196    fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple> {
2197        self.serialize_seq(Some(len))
2198    }
2199
2200    fn serialize_tuple_struct(
2201        self,
2202        _name: &'static str,
2203        len: usize,
2204    ) -> Result<Self::SerializeTupleStruct> {
2205        self.serialize_seq(Some(len))
2206    }
2207
2208    fn serialize_tuple_variant(
2209        self,
2210        _name: &'static str,
2211        _variant_index: u32,
2212        variant: &'static str,
2213        len: usize,
2214    ) -> Result<Self::SerializeTupleVariant> {
2215        Ok(SerializeTupleVariant {
2216            name: variant.to_owned(),
2217            vec: Vec::with_capacity(len),
2218        })
2219    }
2220
2221    fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
2222        Ok(SerializeMap {
2223            map: Mapping::new(),
2224            key: None,
2225        })
2226    }
2227
2228    fn serialize_struct(self, _name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
2229        self.serialize_map(Some(len))
2230    }
2231
2232    fn serialize_struct_variant(
2233        self,
2234        _name: &'static str,
2235        _variant_index: u32,
2236        variant: &'static str,
2237        _len: usize,
2238    ) -> Result<Self::SerializeStructVariant> {
2239        Ok(SerializeStructVariant {
2240            name: variant.to_owned(),
2241            map: Mapping::new(),
2242        })
2243    }
2244}
2245
2246/// Serializer for sequences.
2247#[derive(Debug)]
2248pub struct SerializeSeq {
2249    vec: Vec<Value>,
2250}
2251
2252impl serde_core::ser::SerializeSeq for SerializeSeq {
2253    type Ok = Value;
2254    type Error = Error;
2255
2256    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2257    where
2258        T: ?Sized + serde_core::Serialize,
2259    {
2260        self.vec.push(value.serialize(Serializer)?);
2261        Ok(())
2262    }
2263
2264    fn end(self) -> Result<Value> {
2265        Ok(Value::Sequence(self.vec))
2266    }
2267}
2268
2269impl serde_core::ser::SerializeTuple for SerializeSeq {
2270    type Ok = Value;
2271    type Error = Error;
2272
2273    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2274    where
2275        T: ?Sized + serde_core::Serialize,
2276    {
2277        serde_core::ser::SerializeSeq::serialize_element(self, value)
2278    }
2279
2280    fn end(self) -> Result<Value> {
2281        serde_core::ser::SerializeSeq::end(self)
2282    }
2283}
2284
2285impl serde_core::ser::SerializeTupleStruct for SerializeSeq {
2286    type Ok = Value;
2287    type Error = Error;
2288
2289    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2290    where
2291        T: ?Sized + serde_core::Serialize,
2292    {
2293        serde_core::ser::SerializeSeq::serialize_element(self, value)
2294    }
2295
2296    fn end(self) -> Result<Value> {
2297        serde_core::ser::SerializeSeq::end(self)
2298    }
2299}
2300
2301/// Serializer for tuple variants.
2302#[derive(Debug)]
2303pub struct SerializeTupleVariant {
2304    name: String,
2305    vec: Vec<Value>,
2306}
2307
2308impl serde_core::ser::SerializeTupleVariant for SerializeTupleVariant {
2309    type Ok = Value;
2310    type Error = Error;
2311
2312    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2313    where
2314        T: ?Sized + serde_core::Serialize,
2315    {
2316        self.vec.push(value.serialize(Serializer)?);
2317        Ok(())
2318    }
2319
2320    fn end(self) -> Result<Value> {
2321        let mut map = Mapping::new();
2322        let _ = map.insert(self.name, Value::Sequence(self.vec));
2323        Ok(Value::Mapping(map))
2324    }
2325}
2326
2327/// Serializer for maps.
2328#[derive(Debug)]
2329pub struct SerializeMap {
2330    map: Mapping,
2331    key: Option<String>,
2332}
2333
2334impl serde_core::ser::SerializeMap for SerializeMap {
2335    type Ok = Value;
2336    type Error = Error;
2337
2338    fn serialize_key<T>(&mut self, key: &T) -> Result<()>
2339    where
2340        T: ?Sized + serde_core::Serialize,
2341    {
2342        let key_value = key.serialize(Serializer)?;
2343        let key_str = match key_value {
2344            Value::String(s) => s,
2345            Value::Number(Number::Integer(n)) => n.to_string(),
2346            #[cfg(feature = "lossless-u64")]
2347            Value::Number(Number::Unsigned(n)) => n.to_string(),
2348            Value::Bool(b) => b.to_string(),
2349            _ => return Err(Error::Serialize("map key must be a string".to_string())),
2350        };
2351        self.key = Some(key_str);
2352        Ok(())
2353    }
2354
2355    fn serialize_value<T>(&mut self, value: &T) -> Result<()>
2356    where
2357        T: ?Sized + serde_core::Serialize,
2358    {
2359        let key = self
2360            .key
2361            .take()
2362            .ok_or_else(|| Error::Serialize("missing key".to_string()))?;
2363        let _ = self.map.insert(key, value.serialize(Serializer)?);
2364        Ok(())
2365    }
2366
2367    fn end(self) -> Result<Value> {
2368        Ok(Value::Mapping(self.map))
2369    }
2370}
2371
2372impl serde_core::ser::SerializeStruct for SerializeMap {
2373    type Ok = Value;
2374    type Error = Error;
2375
2376    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2377    where
2378        T: ?Sized + serde_core::Serialize,
2379    {
2380        let _ = self
2381            .map
2382            .insert(key.to_owned(), value.serialize(Serializer)?);
2383        Ok(())
2384    }
2385
2386    fn end(self) -> Result<Value> {
2387        Ok(Value::Mapping(self.map))
2388    }
2389}
2390
2391/// Serializer for struct variants.
2392#[derive(Debug)]
2393pub struct SerializeStructVariant {
2394    name: String,
2395    map: Mapping,
2396}
2397
2398impl serde_core::ser::SerializeStructVariant for SerializeStructVariant {
2399    type Ok = Value;
2400    type Error = Error;
2401
2402    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2403    where
2404        T: ?Sized + serde_core::Serialize,
2405    {
2406        let _ = self
2407            .map
2408            .insert(key.to_owned(), value.serialize(Serializer)?);
2409        Ok(())
2410    }
2411
2412    fn end(self) -> Result<Value> {
2413        let mut map = Mapping::new();
2414        let _ = map.insert(self.name, Value::Mapping(self.map));
2415        Ok(Value::Mapping(map))
2416    }
2417}
2418
2419#[cfg(test)]
2420mod tests {
2421    use super::*;
2422    use crate::Value;
2423
2424    #[test]
2425    fn test_serialization_recursion_limit() {
2426        let mut root = Value::Sequence(vec![Value::Null]);
2427        for _ in 0..200 {
2428            root = Value::Sequence(vec![root]);
2429        }
2430
2431        let config = SerializerConfig::default().max_depth(128);
2432        let result = to_string_with_config(&root, &config);
2433
2434        match result {
2435            Err(Error::RecursionLimitExceeded { depth }) => assert!(depth > 128),
2436            _ => panic!("Expected RecursionLimitExceeded error, got {result:?}"),
2437        }
2438    }
2439
2440    // Regression for #84: `flow_style` was stored but never consulted by the
2441    // emit path, so `FlowStyle::Flow` / `Auto` silently produced block output.
2442    #[test]
2443    fn test_flow_style_flow_emits_inline_collections() {
2444        let seq = Value::Sequence(vec![
2445            Value::from(0),
2446            Value::from(1),
2447            Value::from(2),
2448            Value::from(3),
2449            Value::from(4),
2450        ]);
2451        let mut map = Mapping::new();
2452        let _ = map.insert("a", Value::from(1));
2453        let _ = map.insert("b", Value::from(2));
2454        let _ = map.insert("c", Value::from(3));
2455        let map = Value::Mapping(map);
2456
2457        let config = SerializerConfig::new().flow_style(FlowStyle::Flow);
2458        assert_eq!(
2459            to_string_with_config(&seq, &config).unwrap().trim_end(),
2460            "[0, 1, 2, 3, 4]"
2461        );
2462        assert_eq!(
2463            to_string_with_config(&map, &config).unwrap().trim_end(),
2464            "{a: 1, b: 2, c: 3}"
2465        );
2466    }
2467
2468    #[test]
2469    fn test_flow_style_auto_respects_threshold() {
2470        let small = Value::Sequence((0..3).map(Value::from).collect());
2471        let large = Value::Sequence((0..10).map(Value::from).collect());
2472
2473        let config = SerializerConfig::new()
2474            .flow_style(FlowStyle::Auto)
2475            .flow_threshold(4);
2476
2477        // Small collection (<= threshold) flows inline.
2478        assert_eq!(
2479            to_string_with_config(&small, &config).unwrap().trim_end(),
2480            "[0, 1, 2]"
2481        );
2482        // Large collection (> threshold) stays block.
2483        assert!(
2484            to_string_with_config(&large, &config)
2485                .unwrap()
2486                .starts_with("- 0")
2487        );
2488    }
2489
2490    #[test]
2491    fn test_flow_style_auto_falls_back_when_child_exceeds_threshold() {
2492        // Outer has 2 items (<= threshold) but the inner sequence has 10
2493        // (> threshold). Flowing the outer would emit an invalid block child
2494        // inside flow, so Auto must keep the outer in block style.
2495        let inner = Value::Sequence((0..10).map(Value::from).collect());
2496        let outer = Value::Sequence(vec![Value::from(0), inner]);
2497
2498        let config = SerializerConfig::new()
2499            .flow_style(FlowStyle::Auto)
2500            .flow_threshold(4);
2501        let out = to_string_with_config(&outer, &config).unwrap();
2502        assert!(out.starts_with("- 0"), "outer should stay block: {out:?}");
2503    }
2504
2505    #[test]
2506    fn test_flow_style_block_is_default_unchanged() {
2507        let seq = Value::Sequence((0..3).map(Value::from).collect());
2508        // Default config (Block) and the no-config helper both stay block.
2509        assert!(to_string(&seq).unwrap().starts_with("- 0"));
2510        assert!(
2511            to_string_with_config(&seq, &SerializerConfig::new())
2512                .unwrap()
2513                .starts_with("- 0")
2514        );
2515    }
2516}