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}