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, config, 0)?;
652 if config.document_end {
653 output.push_str("\n...");
654 }
655 Ok(output)
656}
657
658fn estimate_yaml_size(value: &Value) -> usize {
659 match value {
660 Value::Null => 4,
661 Value::Bool(_) => 5,
662 Value::Number(_) => 12,
663 Value::String(s) => s.len() + 4,
664 Value::Sequence(seq) => 4 + seq.iter().map(|v| estimate_yaml_size(v) + 4).sum::<usize>(),
665 Value::Mapping(map) => {
666 4 + map
667 .iter()
668 .map(|(k, v)| k.len() + estimate_yaml_size(v) + 6)
669 .sum::<usize>()
670 }
671 Value::Tagged(t) => 20 + estimate_yaml_size(t.value()),
672 }
673}
674
675/// Write `total_spaces` space characters to `output` without heap allocation.
676#[inline]
677fn write_indent(output: &mut String, total_spaces: usize) {
678 const SPACES: &str = " ";
679 // 64 spaces - covers indent up to depth 32 with indent=2
680 let mut remaining = total_spaces;
681 while remaining > 0 {
682 let n = remaining.min(SPACES.len());
683 output.push_str(&SPACES[..n]);
684 remaining -= n;
685 }
686}
687
688fn write_value(
689 output: &mut String,
690 value: &Value,
691 indent: usize,
692 is_root: bool,
693 config: &SerializerConfig,
694 depth: usize,
695) -> Result<()> {
696 if depth > config.max_depth {
697 return Err(Error::RecursionLimitExceeded { depth });
698 }
699 match value {
700 Value::Null => output.push_str("null"),
701 Value::Bool(b) => output.push_str(if *b { "true" } else { "false" }),
702 Value::Number(Number::Integer(n)) => {
703 #[cfg(feature = "fast-int")]
704 {
705 let mut buf = itoa::Buffer::new();
706 output.push_str(buf.format(*n));
707 }
708 #[cfg(not(feature = "fast-int"))]
709 {
710 let _ = write!(output, "{n}");
711 }
712 }
713 #[cfg(feature = "lossless-u64")]
714 Value::Number(Number::Unsigned(n)) => {
715 #[cfg(feature = "fast-int")]
716 {
717 let mut buf = itoa::Buffer::new();
718 output.push_str(buf.format(*n));
719 }
720 #[cfg(not(feature = "fast-int"))]
721 {
722 let _ = write!(output, "{n}");
723 }
724 }
725 Value::Number(Number::Float(n)) => {
726 // Shared with `Number`'s `Display` impl (see #348) so the
727 // two never disagree on how a float prints.
728 let _ = crate::value::write_float(output, *n);
729 }
730 Value::String(s) => write_string(output, s, indent, config),
731 Value::Sequence(seq) => write_sequence(output, seq, indent, is_root, config, depth)?,
732 Value::Mapping(map) => write_mapping(output, map, indent, is_root, config, depth)?,
733 Value::Tagged(tagged) => {
734 let tag_str = tagged.tag().as_str();
735 if tag_str.starts_with("__noya_") {
736 write_internal_tag(
737 output,
738 tag_str,
739 tagged.value(),
740 indent,
741 is_root,
742 config,
743 depth,
744 )?;
745 } else {
746 // Write the tag, then its payload. A scalar payload sits on
747 // this same line after a space (`!tag value`); a non-empty
748 // mapping/sequence payload starts block layout on the next
749 // line instead, with no trailing space after the tag (that
750 // space would never be followed by anything, so it would
751 // survive only as trailing whitespace). `indent` here is
752 // already the slot this caller computed for the *whole*
753 // tagged value via `needs_block_layout`/`indicator_takes_a_space`
754 // above (see `write_mapping`/`write_sequence`), so the
755 // payload is written at that same `indent`, not one deeper.
756 // A tag whose body holds characters the shorthand
757 // spelling cannot carry — flow indicators, blanks, or
758 // an interior `!` (a handle separator there) — is
759 // emitted in the verbatim form `!<...>`, which
760 // re-parses to exactly the stored tag (`!<!str>` is
761 // `!!str`). Emitting it raw produced YAML that split
762 // at the first such byte: `!<tag:example.com,2026:x>`
763 // re-emitted as shorthand died at the comma (found by
764 // fuzz_roundtrip).
765 // …and a tag body NO spelling can carry — a control
766 // character (the scanner rejects those in shorthand
767 // and verbatim forms alike; a tab is one) or a `>`
768 // (verbatim's terminator, rejected in shorthand as a
769 // non-URI char) — resolves the serde-model ambiguity
770 // the other way: in the serde data model a tagged
771 // value is indistinguishable from the single-entry
772 // mapping keyed by its `!`-leading spelling, so emit
773 // that mapping with a quoted key and let it re-parse
774 // as what it is (found by fuzz_roundtrip on the key
775 // `"!\t"`).
776 if tag_str.bytes().any(|b| b < 0x20 || b == 0x7f || b == b'>') {
777 write_key_string(output, tag_str, indent, config);
778 output.push(':');
779 let inner = tagged.value();
780 if indicator_takes_a_space(inner) {
781 output.push(' ');
782 }
783 write_value(output, inner, indent, false, config, depth + 1)?;
784 return Ok(());
785 }
786 let shorthand_body = tag_str
787 .strip_prefix("!!")
788 .or_else(|| tag_str.strip_prefix('!'));
789 let needs_verbatim = shorthand_body.is_some_and(|body| {
790 body.bytes().any(|b| {
791 matches!(b, b',' | b'[' | b']' | b'{' | b'}' | b'!' | b' ' | b'\t')
792 })
793 });
794 if needs_verbatim {
795 output.push_str("!<");
796 output.push_str(&tag_str[1..]);
797 output.push('>');
798 } else {
799 output.push_str(tag_str);
800 }
801 let inner = tagged.value();
802 if indicator_takes_a_space(inner) {
803 output.push(' ');
804 }
805 write_value(output, inner, indent, false, config, depth + 1)?;
806 }
807 }
808 }
809 Ok(())
810}
811
812/// Fast check whether a plain scalar would be interpreted as a number by a YAML
813/// parser. This is intentionally over-inclusive to ensure roundtrip safety —
814/// it's cheaper to quote a few extra strings than to lose data.
815fn looks_like_number(s: &str) -> bool {
816 let bytes = s.as_bytes();
817 if bytes.is_empty() {
818 return false;
819 }
820
821 // YAML special float literals (case variants)
822 if matches!(
823 s,
824 ".inf"
825 | ".Inf"
826 | ".INF"
827 | "+.inf"
828 | "+.Inf"
829 | "+.INF"
830 | "-.inf"
831 | "-.Inf"
832 | "-.INF"
833 | ".nan"
834 | ".NaN"
835 | ".NAN"
836 ) {
837 return true;
838 }
839
840 // Skip any leading signs (yaml-rust2 is permissive with e.g. "++1")
841 let mut i = 0;
842 while i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
843 i += 1;
844 }
845 if i >= bytes.len() {
846 return false;
847 }
848
849 let rest = &bytes[i..];
850
851 // A digit, or "." and a digit (floats like .5), can open a number.
852 // That is the cheap filter; the parser's own resolver has the last
853 // word, so digit-leading text it keeps as a string (`2026-12-31`,
854 // `1.2.3`, `3rd`, `1/2`) is written plain, as the author wrote it.
855 // The resolver sees the text after the signs: a permissive reader
856 // (yaml-rust2 accepts `++1`) may take stacked signs as one, so what
857 // follows them decides.
858 let candidate =
859 rest[0].is_ascii_digit() || (rest[0] == b'.' && rest.len() > 1 && rest[1].is_ascii_digit());
860 candidate && resolves_as_non_string(&s[i..])
861}
862
863/// The parser's verdict on a plain scalar: would it read back as a
864/// number, a boolean, or null rather than a string?
865///
866/// Runs the resolver the loaders and the streaming path share, with the
867/// YAML 1.1 legacy forms enabled (`0`-prefixed octals, sexagesimals) so a
868/// string is quoted whenever any reader configuration would turn it into
869/// something else.
870fn resolves_as_non_string(s: &str) -> bool {
871 !matches!(
872 crate::streaming::resolve_plain_ext(s, false, true, false, true, true, false),
873 crate::streaming::Scalar::Str(_)
874 )
875}
876
877/// A `:` ends a plain scalar only when a space, a tab, a flow indicator,
878/// or the end of the text follows it (YAML 1.2 plain scalars), so
879/// `word:count`, `10:00:00Z`, and `http://` stay plain.
880fn colon_ends_plain(bytes: &[u8], i: usize) -> bool {
881 match bytes.get(i + 1) {
882 None => true,
883 Some(&next) => matches!(next, b' ' | b'\t' | b',' | b'[' | b']' | b'{' | b'}'),
884 }
885}
886
887/// A `#` starts a comment only at the start of the text or after
888/// whitespace, so `a#b` stays plain.
889fn hash_starts_comment(bytes: &[u8], i: usize) -> bool {
890 i == 0 || matches!(bytes[i - 1], b' ' | b'\t')
891}
892
893/// Lookup table: true if the byte can require the string to be quoted.
894/// Covers: control chars (except tab), colon, hash, newline, etc. A colon
895/// or a hash is then judged in context by `colon_ends_plain` and
896/// `hash_starts_comment`.
897static NEEDS_QUOTE_BYTE: [bool; 128] = {
898 let mut t = [false; 128];
899 // Control characters (except tab 0x09)
900 let mut i = 0u8;
901 while i < 0x20 {
902 if i != b'\t' {
903 t[i as usize] = true;
904 }
905 i += 1;
906 }
907 // YAML structural characters
908 t[b':' as usize] = true;
909 t[b'#' as usize] = true;
910 t[b'\n' as usize] = true;
911 t[b'\r' as usize] = true;
912 t[b'\0' as usize] = true;
913 t
914};
915
916/// Characters that require quoting when they appear as the first character.
917static FIRST_CHAR_QUOTE: [bool; 128] = {
918 let mut t = [false; 128];
919 t[b' ' as usize] = true;
920 t[b'-' as usize] = true;
921 t[b'&' as usize] = true;
922 t[b'*' as usize] = true;
923 t[b'!' as usize] = true;
924 t[b'|' as usize] = true;
925 t[b'>' as usize] = true;
926 t[b'%' as usize] = true;
927 t[b'@' as usize] = true;
928 t[b'`' as usize] = true;
929 t[b'{' as usize] = true;
930 t[b'}' as usize] = true;
931 t[b'[' as usize] = true;
932 t[b']' as usize] = true;
933 t[b',' as usize] = true;
934 t[b'?' as usize] = true;
935 t[b'\'' as usize] = true;
936 t[b'"' as usize] = true;
937 t
938};
939
940/// A character only double-quoted style can carry faithfully: CR, NEL
941/// (U+0085), LS (U+2028), PS (U+2029) — and a BOM (U+FEFF). A literal
942/// block scalar normalises `\r` into the block's own line breaks, and
943/// the three Unicode separators pass through plain and single-quoted
944/// styles as raw bytes that 1.1-era parsers (and this crate's own
945/// reader) fold as line breaks, so a round trip changes the string
946/// (#335). A raw BOM is worse: the reader must not accept one inside
947/// a document at all (§5.2), and a string-leading BOM emitted plain
948/// is stream-skipped on re-parse, reinterpreting the rest of the
949/// scalar as markup (found by fuzz_roundtrip).
950///
951/// The C1 block (U+0080 to U+009F, NEL included) and the non-characters
952/// U+FFFE and U+FFFF sit outside `c-printable` (§5.1): this crate's
953/// reader takes them raw, but libyaml-based tools reject the document,
954/// so they force the same style with a hex escape (#379).
955fn needs_double_quoted_escape(s: &str) -> bool {
956 s.chars().any(|c| {
957 matches!(
958 c,
959 '\r' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
960 ) || ('\u{7f}'..='\u{9f}').contains(&c)
961 || (c < '\u{20}' && c != '\t' && c != '\n')
962 })
963}
964
965/// Write a mapping key. Keys are implicit (`key: value`) in every
966/// form this serializer emits, and an implicit key must fit on one
967/// line — the block scalar styles are not grammar there at all — so
968/// a string the value writer would render as a `|`/`>` block (any
969/// string holding a line break) is written double-quoted with
970/// escapes instead. Found by fuzz_roundtrip: a multi-line key
971/// emitted as a `|-` block produced YAML that no longer parsed
972/// ("expected block mapping key or end").
973fn write_key_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
974 if s.contains('\n') {
975 write_double_quoted(output, s);
976 } else {
977 write_string(output, s, indent, config);
978 }
979}
980
981fn write_string(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
982 let bytes = s.as_bytes();
983
984 // Empty string must be quoted
985 if bytes.is_empty() {
986 if config.prefer_single_quotes {
987 output.push_str("''");
988 } else {
989 output.push_str("\"\"");
990 }
991 return;
992 }
993
994 // Force-quote all strings when configured. Single-quoted style has
995 // no escapes, so a string only double-quoted style can carry still
996 // falls back regardless of the setting.
997 if config.quote_all {
998 if needs_double_quoted_escape(s) {
999 write_double_quoted(output, s);
1000 } else {
1001 write_single_quoted(output, s);
1002 }
1003 return;
1004 }
1005
1006 // A scalar starting with `...` emitted at the start of a line
1007 // reads back as the document-end marker (explicit-key emission
1008 // places keys at column 0), so it can never go plain — same
1009 // family as the `-` first-byte rule below, which already covers
1010 // `---` (found by fuzz_roundtrip on a `? ...` explicit key).
1011 if s.starts_with("...") {
1012 if needs_double_quoted_escape(s) {
1013 write_double_quoted(output, s);
1014 } else {
1015 write_single_quoted(output, s);
1016 }
1017 return;
1018 }
1019
1020 // Fast path: short ASCII strings that are clearly safe as plain scalars.
1021 // Avoids the full lookup table scan for the majority of mapping keys.
1022 //
1023 // The intent is: short, alnum-bounded, no newline. All four conditions
1024 // below are ANDed — `||` binds looser than `&&`, and an earlier version
1025 // of this guard read `a && b && c && !config.block_scalars ||
1026 // no_newline`, which let *every* newline-free string take the fast path
1027 // regardless of its first byte (`"-"` slipped through unquoted and
1028 // re-parsed as a block sequence entry, not a scalar). The first/last
1029 // alnum checks already exclude every `FIRST_CHAR_QUOTE` member (none of
1030 // them are alphanumeric) and tab (also not alphanumeric), but the
1031 // explicit `FIRST_CHAR_QUOTE` check is kept here too as defense in
1032 // depth against the alnum check alone being loosened later.
1033 if bytes.len() <= 64
1034 && bytes[0].is_ascii_alphanumeric()
1035 && bytes[bytes.len() - 1].is_ascii_alphanumeric()
1036 && bytes.iter().all(|&b| b != b'\n')
1037 && !(bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize])
1038 {
1039 let safe = bytes.iter().all(|&b| {
1040 b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'
1041 });
1042 if safe
1043 && !matches!(
1044 s,
1045 "true"
1046 | "false"
1047 | "null"
1048 | "~"
1049 | "True"
1050 | "False"
1051 | "TRUE"
1052 | "FALSE"
1053 | "Null"
1054 | "NULL"
1055 )
1056 && !looks_like_number(s)
1057 {
1058 output.push_str(s);
1059 return;
1060 }
1061 }
1062
1063 // Block scalar for multiline strings -- unless the string carries a
1064 // character a block scalar cannot represent: `str::lines` and the
1065 // block's own line breaks erase a `\r` (#335).
1066 if config.block_scalars && !needs_double_quoted_escape(s) {
1067 let newlines = bytes.iter().filter(|&&b| b == b'\n').count();
1068 if newlines >= config.block_scalar_threshold {
1069 write_block_scalar(output, s, indent, config);
1070 return;
1071 }
1072 }
1073
1074 // CR and the Unicode line separators are representable only with
1075 // double-quoted escapes (#335).
1076 if needs_double_quoted_escape(s) {
1077 write_double_quoted(output, s);
1078 return;
1079 }
1080
1081 // Single-pass quoting decision
1082 let mut needs_quotes = false;
1083 let mut has_control = false;
1084
1085 // Check first character
1086 if bytes[0] < 128 && FIRST_CHAR_QUOTE[bytes[0] as usize] {
1087 needs_quotes = true;
1088 }
1089
1090 // A leading or trailing tab must quote. `NEEDS_QUOTE_BYTE` deliberately
1091 // excludes tab so an *interior* tab stays unescaped in a plain scalar,
1092 // but YAML 1.2 still requires quoting when a plain scalar's content
1093 // starts or ends in white space (tab included), or the boundary is
1094 // lost on re-parse.
1095 if bytes[0] == b'\t' || bytes[bytes.len() - 1] == b'\t' {
1096 needs_quotes = true;
1097 }
1098
1099 // Check last character (trailing space)
1100 if bytes[bytes.len() - 1] == b' ' {
1101 needs_quotes = true;
1102 }
1103
1104 // Reserved words
1105 if !needs_quotes {
1106 needs_quotes = matches!(
1107 s,
1108 "true" | "false" | "null" | "~" | "True" | "False" | "TRUE" | "FALSE" | "Null" | "NULL"
1109 ) || looks_like_number(s);
1110 }
1111
1112 // Single pass through interior bytes. A colon or a hash counts only
1113 // where YAML gives it meaning; see `colon_ends_plain` and
1114 // `hash_starts_comment`.
1115 if !needs_quotes {
1116 for (i, &b) in bytes.iter().enumerate() {
1117 if b >= 128 || !NEEDS_QUOTE_BYTE[b as usize] {
1118 continue;
1119 }
1120 if (b == b':' && !colon_ends_plain(bytes, i))
1121 || (b == b'#' && !hash_starts_comment(bytes, i))
1122 {
1123 continue;
1124 }
1125 if b < 0x20 && b != b'\t' {
1126 has_control = true;
1127 }
1128 needs_quotes = true;
1129 // Don't break - we need to know if there are control chars
1130 }
1131 }
1132
1133 if !needs_quotes {
1134 // Plain scalar - zero-copy output
1135 output.push_str(s);
1136 return;
1137 }
1138
1139 if config.prefer_single_quotes && single_quote_safe(s) {
1140 write_single_quoted(output, s);
1141 return;
1142 }
1143
1144 // Use double quotes for all quoted strings
1145 let _ = has_control;
1146 write_double_quoted(output, s);
1147}
1148
1149/// Whether `s` can be represented as a YAML single-quoted scalar with no
1150/// escapes beyond doubling an embedded `'`.
1151///
1152/// Single-quoted style has no escape mechanism at all besides doubling the
1153/// quote character itself — a literal backslash, double quote, `#`, `:`,
1154/// and so on all pass straight through unescaped. What it *cannot* carry is
1155/// a control character (tab, newline, carriage return, and the rest of the
1156/// C0/C1 ranges) or any other non-printable code point: those need one of
1157/// double-quoted style's escape sequences, so a string containing one must
1158/// fall back to double-quoted even when `prefer_single_quotes` is set.
1159fn single_quote_safe(s: &str) -> bool {
1160 !s.chars()
1161 .any(|c| c.is_control() || matches!(c, '\u{fffe}' | '\u{ffff}'))
1162}
1163
1164/// Write a single-quoted string, escaping embedded single quotes.
1165fn write_single_quoted(output: &mut String, s: &str) {
1166 output.push('\'');
1167 for c in s.chars() {
1168 if c == '\'' {
1169 output.push_str("''");
1170 } else {
1171 output.push(c);
1172 }
1173 }
1174 output.push('\'');
1175}
1176
1177/// Write a double-quoted string with bulk-copy between escape points.
1178fn write_double_quoted(output: &mut String, s: &str) {
1179 output.push('"');
1180 let mut start = 0;
1181 for (i, c) in s.char_indices() {
1182 let esc = match c {
1183 '"' => "\\\"",
1184 '\\' => "\\\\",
1185 '\n' => "\\n",
1186 '\r' => "\\r",
1187 '\t' => "\\t",
1188 '\0' => "\\0",
1189 // Named escapes for the non-ASCII line-break characters
1190 // (YAML 1.2 section 5.7): emitted raw they read back as
1191 // line breaks in 1.1-era parsers and in this crate's own
1192 // reader (#335).
1193 '\u{0085}' => "\\N",
1194 '\u{2028}' => "\\L",
1195 '\u{2029}' => "\\P",
1196 // A raw BOM must never reach the output stream — the
1197 // reader rejects one inside a document (§5.2).
1198 '\u{feff}' => "\\uFEFF",
1199 // The two non-characters sit outside `c-printable` (§5.1)
1200 // and have no named escape (#379).
1201 '\u{fffe}' | '\u{ffff}' => {
1202 output.push_str(&s[start..i]);
1203 let _ = write!(output, "\\u{:04X}", c as u32);
1204 start = i + c.len_utf8();
1205 continue;
1206 }
1207 c if (c as u32) < 0x20 || ('\u{7f}'..='\u{9f}').contains(&c) => {
1208 // Other control characters -- C0, DEL and the C1 block
1209 // (#379; NEL is matched above): flush and write the
1210 // two-digit hex escape. A C1 character is two bytes in
1211 // UTF-8, so advance by its width, not by one.
1212 output.push_str(&s[start..i]);
1213 let _ = write!(output, "\\x{:02X}", c as u32);
1214 start = i + c.len_utf8();
1215 continue;
1216 }
1217 _ => continue,
1218 };
1219 output.push_str(&s[start..i]);
1220 output.push_str(esc);
1221 start = i + c.len_utf8();
1222 }
1223 output.push_str(&s[start..]);
1224 output.push('"');
1225}
1226
1227/// Write a string using YAML literal block scalar style (|).
1228/// Write a block scalar's content lines at `indent + 1`.
1229///
1230/// An empty line gets **no** indentation. The block's indent is detected from
1231/// its first non-empty line, so an empty one has nothing to say; writing the
1232/// indent anyway leaves it standing as trailing whitespace on a line that
1233/// holds nothing, which `git diff --check` and `yamllint` reject.
1234///
1235/// This must stay one function. It had three identical copies — `|` auto, `|`
1236/// explicit and `>` — and the empty-line rule was missing from all three, so
1237/// fixing any one of them would have left the other two writing it.
1238fn write_block_scalar_body(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1239 for line in s.lines() {
1240 output.push('\n');
1241 if !line.is_empty() {
1242 write_indent(output, config.indent * (indent + 1));
1243 output.push_str(line);
1244 }
1245 }
1246}
1247
1248/// The explicit indentation indicator digit(s), if the block needs one.
1249///
1250/// YAML 1.2.2 §8.1.1.1: a literal/folded block scalar's indentation is
1251/// normally *auto-detected* from its first non-empty content line. That
1252/// detection breaks when the first content line itself starts with a
1253/// space or a tab — the leading whitespace gets folded into the detected
1254/// indentation, inflating it past what later, less-indented lines carry,
1255/// which a parser then rejects as inconsistent indentation. The fix is an
1256/// explicit indentation indicator between the block style character
1257/// (`|`/`>`) and the chomping indicator, stating the indentation as a
1258/// number of columns *beyond the parent node's own indentation*.
1259///
1260/// [`write_block_scalar_body`] always places content `config.indent`
1261/// columns beyond the indentation this function's caller was handed for
1262/// this value's slot (the parent node's own indentation) — so whenever an
1263/// indicator is needed, its value is exactly `config.indent`, independent
1264/// of nesting depth.
1265fn block_scalar_indent_indicator(s: &str, config: &SerializerConfig) -> String {
1266 let first_content_line = s.lines().find(|line| !line.is_empty());
1267 match first_content_line {
1268 Some(line) if line.starts_with(' ') || line.starts_with('\t') => config.indent.to_string(),
1269 _ => String::new(),
1270 }
1271}
1272
1273/// The chomping indicator for a block scalar holding `s` (YAML 1.2.2
1274/// §8.1.1.2): strip when the text has no trailing line break, keep when
1275/// it has more than one, clip otherwise -- except that a text with **no
1276/// content line at all** (`"\n"`) also takes keep.
1277///
1278/// Clip preserves the final break of the last content line, and a block
1279/// with no content line has none, so `k: |` followed by one empty line
1280/// read back as the empty string and the value was lost on the first
1281/// round trip (#383). Under keep the empty line is the value.
1282///
1283/// One function for the three block writers: the rule had three copies
1284/// and the no-content case was missing from all of them.
1285fn block_chomping(s: &str) -> &'static str {
1286 if !s.ends_with('\n') {
1287 "-"
1288 } else if s.ends_with("\n\n") || s.lines().all(str::is_empty) {
1289 "+"
1290 } else {
1291 ""
1292 }
1293}
1294
1295/// Begin an entry's line: a line break, unless the output already ends
1296/// with one, then the indentation.
1297///
1298/// A block scalar closes its own last line, so a break written blindly
1299/// after one opens a blank line. Under clip chomping that was cosmetic;
1300/// under keep chomping (`|+`) the parser keeps the blank line, and a
1301/// keep-chomped value grew by one newline per round trip whenever a
1302/// sibling entry followed it (#385).
1303fn start_line(output: &mut String, indent_spaces: usize) {
1304 if !output.ends_with('\n') {
1305 output.push('\n');
1306 }
1307 write_indent(output, indent_spaces);
1308}
1309
1310fn write_block_scalar(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1311 let chomping = block_chomping(s);
1312
1313 output.push('|');
1314 output.push_str(&block_scalar_indent_indicator(s, config));
1315 output.push_str(chomping);
1316
1317 write_block_scalar_body(output, s, indent, config);
1318
1319 // `str::lines()` never yields an extra empty element for the string's
1320 // *final* line terminator, but every other trailing blank line DOES
1321 // get its own element (and so its own newline from the loop above).
1322 // That means the body loop always emits exactly one newline fewer
1323 // than `s` actually ends with, regardless of how many trailing
1324 // newlines there are: for `"text\n"` the loop emits none after
1325 // "text" (`.lines()` is just `["text"]`), for `"text\n\n"` it emits
1326 // one (`["text", ""]`), for `"text\n\n\n"` it emits two
1327 // (`["text", "", ""]`), and so on -- one behind `s`'s own count every
1328 // time. So exactly one more newline (never a count derived from `s`)
1329 // closes the gap.
1330 //
1331 // The previous version pushed `s.len() - s.trim_end_matches('\n').len()`
1332 // newlines here -- the *full* trailing-newline count -- which double
1333 // counted every trailing newline past the first and grew the string by
1334 // one extra `\n` on every serialize/parse round trip.
1335 if s.ends_with('\n') {
1336 output.push('\n');
1337 }
1338}
1339
1340/// Whether a value can be safely rendered inside an `Auto`-mode flow
1341/// collection. A flow collection may only contain scalars and other flow
1342/// collections — a nested *block* collection would produce invalid YAML
1343/// (`[a, - x]`). So in `Auto` mode the whole subtree must stay within the
1344/// flow threshold for any ancestor to flow; otherwise we fall back to block.
1345///
1346/// `Tagged` values are conservatively treated as block-only: they carry
1347/// anchors, custom tags, and the internal block-scalar/anchor magic tags that
1348/// have no valid flow representation here.
1349fn auto_flow_eligible(value: &Value, config: &SerializerConfig) -> bool {
1350 match value {
1351 Value::Sequence(s) => {
1352 s.len() <= config.flow_threshold && s.iter().all(|v| auto_flow_eligible(v, config))
1353 }
1354 Value::Mapping(m) => {
1355 m.len() <= config.flow_threshold && m.iter().all(|(_, v)| auto_flow_eligible(v, config))
1356 }
1357 Value::Tagged(_) => false,
1358 _ => true,
1359 }
1360}
1361
1362/// Decide whether a collection of `len` items holding `values` should render
1363/// in flow style under the active `config`.
1364fn use_flow<'a, I>(len: usize, values: impl Fn() -> I, config: &SerializerConfig) -> bool
1365where
1366 I: Iterator<Item = &'a Value>,
1367{
1368 match config.flow_style {
1369 FlowStyle::Block => false,
1370 FlowStyle::Flow => true,
1371 FlowStyle::Auto => {
1372 len <= config.flow_threshold && values().all(|v| auto_flow_eligible(v, config))
1373 }
1374 }
1375}
1376
1377fn write_sequence(
1378 output: &mut String,
1379 seq: &Sequence,
1380 indent: usize,
1381 is_root: bool,
1382 config: &SerializerConfig,
1383 depth: usize,
1384) -> Result<()> {
1385 if seq.is_empty() {
1386 output.push_str("[]");
1387 return Ok(());
1388 }
1389
1390 if use_flow(seq.len(), || seq.iter(), config) {
1391 return write_flow_sequence(output, seq, config, depth);
1392 }
1393
1394 for (i, value) in seq.iter().enumerate() {
1395 if i > 0 || !is_root {
1396 start_line(output, config.indent * indent);
1397 }
1398 output.push('-');
1399
1400 match value {
1401 Value::Mapping(m) if !m.is_empty() => {
1402 // The item's first key shares the dash's line, so the dash
1403 // always takes its space here whatever the *value* looks
1404 // like — `- key: 1` and `- key:` alike.
1405 output.push(' ');
1406 for (j, (k, v)) in m.iter().enumerate() {
1407 if j > 0 {
1408 start_line(output, config.indent * (indent + 1));
1409 }
1410 write_key_string(output, k, indent + 1, config);
1411 output.push(':');
1412 if indicator_takes_a_space(v) {
1413 output.push(' ');
1414 }
1415 // `compact_list_indent`: a sequence value starts at its
1416 // own key's indentation (`indent + 1`, matching `k`'s
1417 // own column) rather than one level deeper — the same
1418 // rule `write_mapping` applies, extended to a mapping
1419 // that is itself a sequence item. Every other
1420 // block-layout value (a mapping, or a sequence with the
1421 // option off) still gets the extra level.
1422 let next_indent = if needs_block_layout(v) {
1423 if config.compact_list_indent && matches!(v, Value::Sequence(_)) {
1424 indent + 1
1425 } else {
1426 indent + 2
1427 }
1428 } else {
1429 indent + 1
1430 };
1431 write_value(output, v, next_indent, false, config, depth + 1)?;
1432 }
1433 }
1434 Value::Sequence(inner) if config.compact_list_indent && !inner.is_empty() => {
1435 // `compact_list_indent`: a sequence item that is itself a
1436 // sequence is written inline (`- - a`), with the nested
1437 // dash sharing this item's own dash's line the same way a
1438 // nested mapping's first key does above. Continuation
1439 // elements align with that nested dash (`indent + 1`).
1440 output.push(' ');
1441 write_sequence(output, inner, indent + 1, true, config, depth + 1)?;
1442 }
1443 _ => {
1444 if indicator_takes_a_space(value) {
1445 output.push(' ');
1446 }
1447 // A block collection item nests one level under the dash.
1448 // A scalar item uses the indent for one thing, a block
1449 // scalar's body, which belongs `config.indent` columns past
1450 // the dash -- the parent node's indentation the block's
1451 // indentation indicator counts from -- so it takes this
1452 // sequence's own level. One level deeper put the body four
1453 // columns past the dash under a `|2` indicator, and the
1454 // surplus read back as content (#387).
1455 let item_indent = if needs_block_layout(value) {
1456 indent + 1
1457 } else {
1458 indent
1459 };
1460 write_value(output, value, item_indent, false, config, depth + 1)?;
1461 }
1462 }
1463 }
1464 Ok(())
1465}
1466
1467/// Whether the indicator introducing `value` — the `:` after a key, or a
1468/// sequence item's `-` — must be followed by a space.
1469///
1470/// An inline scalar needs one (`key: 1`, `- 1`). A block collection does not:
1471/// it begins on the *next* line, so the space would be left dangling at the
1472/// end of this one. That is invisible, it re-parses identically, and it is
1473/// exactly what `git diff --check` and `yamllint`'s `trailing-spaces` reject.
1474///
1475/// The exception is an anchor-wrapped block value, which renders as
1476/// `&idNNN\n ...`: the `&` is on *this* line, so the space is real
1477/// separation rather than leftovers. A regular (user) tag is the same
1478/// story: `!tag\n ...` also has visible text — the tag name — on this
1479/// line, so the space is real separation there too.
1480///
1481/// [`write_mapping`] has always applied this rule; [`write_sequence`] carried
1482/// its own copy of the key-writing and did not, which is the whole of the bug
1483/// this function exists to stop recurring. One rule, one place, both callers.
1484fn indicator_takes_a_space(value: &Value) -> bool {
1485 !needs_block_layout(value)
1486 || matches!(
1487 value,
1488 Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF
1489 || !t.tag().as_str().starts_with("__noya_")
1490 )
1491}
1492
1493/// Whether a value needs block-style layout (indented on the line after `:`)
1494/// rather than inline scalar layout. Anchor-wrapped block collections must be
1495/// treated like the inner collection; a regular (user) tag likewise needs
1496/// block layout exactly when *its* payload does (a tagged mapping/sequence
1497/// under a key must be indented one level deeper, the same as an untagged
1498/// one — see [`write_value`]'s `Value::Tagged` arm).
1499fn needs_block_layout(v: &Value) -> bool {
1500 match v {
1501 Value::Mapping(m) => !m.is_empty(),
1502 Value::Sequence(s) => !s.is_empty(),
1503 Value::Tagged(t) if t.tag().as_str() == crate::fmt::MAGIC_ANCHOR_DEF => {
1504 if let Value::Sequence(seq) = t.value() {
1505 if seq.len() == 2 {
1506 return needs_block_layout(&seq[1]);
1507 }
1508 }
1509 false
1510 }
1511 Value::Tagged(t) if !t.tag().as_str().starts_with("__noya_") => {
1512 needs_block_layout(t.value())
1513 }
1514 _ => false,
1515 }
1516}
1517
1518fn write_mapping(
1519 output: &mut String,
1520 map: &Mapping,
1521 indent: usize,
1522 is_root: bool,
1523 config: &SerializerConfig,
1524 depth: usize,
1525) -> Result<()> {
1526 if map.is_empty() {
1527 output.push_str("{}");
1528 return Ok(());
1529 }
1530
1531 if use_flow(map.len(), || map.iter().map(|(_, v)| v), config) {
1532 return write_flow_mapping(output, map, config, depth);
1533 }
1534
1535 for (i, (key, value)) in map.iter().enumerate() {
1536 if i > 0 || !is_root {
1537 start_line(output, config.indent * indent);
1538 }
1539 write_key_string(output, key, indent, config);
1540
1541 output.push(':');
1542 if indicator_takes_a_space(value) {
1543 output.push(' ');
1544 }
1545 let next_indent = if needs_block_layout(value) {
1546 // `compact_list_indent`: when on, sequence values
1547 // under a mapping key align with the key column
1548 // instead of being bumped one indent level deeper.
1549 // This is the visual style preferred by some style
1550 // guides (Kubernetes manifests, GitHub Actions
1551 // workflows). Mappings and other non-sequence block
1552 // values keep the standard indent.
1553 if config.compact_list_indent && matches!(value, Value::Sequence(_)) {
1554 indent
1555 } else {
1556 indent + 1
1557 }
1558 } else {
1559 indent
1560 };
1561 write_value(output, value, next_indent, false, config, depth + 1)?;
1562 }
1563 Ok(())
1564}
1565
1566fn write_internal_tag(
1567 output: &mut String,
1568 tag: &str,
1569 value: &Value,
1570 indent: usize,
1571 is_root: bool,
1572 config: &SerializerConfig,
1573 depth: usize,
1574) -> Result<()> {
1575 match tag {
1576 crate::fmt::MAGIC_FLOW_SEQ => {
1577 if let Value::Sequence(seq) = value {
1578 write_flow_sequence(output, seq, config, depth)?;
1579 } else {
1580 write_value(output, value, indent, is_root, config, depth)?;
1581 }
1582 }
1583 crate::fmt::MAGIC_FLOW_MAP => {
1584 if let Value::Mapping(map) = value {
1585 write_flow_mapping(output, map, config, depth)?;
1586 } else {
1587 write_value(output, value, indent, is_root, config, depth)?;
1588 }
1589 }
1590 crate::fmt::MAGIC_LIT_STR => {
1591 if let Value::String(s) = value {
1592 write_literal_block(output, s, indent, config);
1593 } else {
1594 write_value(output, value, indent, is_root, config, depth)?;
1595 }
1596 }
1597 crate::fmt::MAGIC_FOLD_STR => {
1598 if let Value::String(s) = value {
1599 write_folded_block(output, s, indent, config);
1600 } else {
1601 write_value(output, value, indent, is_root, config, depth)?;
1602 }
1603 }
1604 crate::fmt::MAGIC_COMMENTED => {
1605 // value is a sequence [inner_value, comment_string]
1606 if let Value::Sequence(seq) = value {
1607 if seq.len() == 2 {
1608 write_value(output, &seq[0], indent, is_root, config, depth)?;
1609 if let Value::String(comment) = &seq[1] {
1610 output.push_str(" # ");
1611 output.push_str(comment);
1612 }
1613 } else {
1614 write_value(output, value, indent, is_root, config, depth)?;
1615 }
1616 } else {
1617 write_value(output, value, indent, is_root, config, depth)?;
1618 }
1619 }
1620 crate::fmt::MAGIC_SPACE_AFTER => {
1621 write_value(output, value, indent, is_root, config, depth)?;
1622 output.push('\n');
1623 }
1624 crate::fmt::MAGIC_ANCHOR_DEF => {
1625 // value is a sequence [String(id), inner_value]. Emit "&id" before
1626 // the inner value. For block collections the inner starts on a new
1627 // line; for scalars it follows on the same line.
1628 if let Value::Sequence(seq) = value {
1629 if seq.len() == 2 {
1630 if let Value::String(id) = &seq[0] {
1631 let inner = &seq[1];
1632 output.push('&');
1633 output.push_str(id);
1634 match inner {
1635 Value::Mapping(m) if !m.is_empty() => {
1636 output.push('\n');
1637 write_indent(output, config.indent * indent);
1638 // `is_root = true` suppresses the leading newline
1639 // inside write_mapping so the anchor line and
1640 // the first key are correctly adjacent.
1641 write_mapping(output, m, indent, true, config, depth + 1)?;
1642 }
1643 Value::Sequence(s) if !s.is_empty() => {
1644 output.push('\n');
1645 write_indent(output, config.indent * indent);
1646 write_sequence(output, s, indent, true, config, depth + 1)?;
1647 }
1648 _ => {
1649 output.push(' ');
1650 write_value(output, inner, indent, false, config, depth + 1)?;
1651 }
1652 }
1653 }
1654 }
1655 }
1656 }
1657 crate::fmt::MAGIC_ANCHOR_REF => {
1658 // value is String(id). Emit "*id".
1659 if let Value::String(id) = value {
1660 output.push('*');
1661 output.push_str(id);
1662 }
1663 }
1664 _ => {
1665 // Unknown internal tag — fall through to regular output
1666 write_value(output, value, indent, is_root, config, depth)?;
1667 }
1668 }
1669 Ok(())
1670}
1671
1672fn write_flow_sequence(
1673 output: &mut String,
1674 seq: &Sequence,
1675 config: &SerializerConfig,
1676 depth: usize,
1677) -> Result<()> {
1678 output.push('[');
1679 for (i, value) in seq.iter().enumerate() {
1680 if i > 0 {
1681 output.push_str(", ");
1682 }
1683 write_value(output, value, 0, false, config, depth + 1)?;
1684 }
1685 output.push(']');
1686 Ok(())
1687}
1688
1689fn write_flow_mapping(
1690 output: &mut String,
1691 map: &Mapping,
1692 config: &SerializerConfig,
1693 depth: usize,
1694) -> Result<()> {
1695 output.push('{');
1696 for (i, (key, value)) in map.iter().enumerate() {
1697 if i > 0 {
1698 output.push_str(", ");
1699 }
1700 write_key_string(output, key, 0, config);
1701 output.push_str(": ");
1702 write_value(output, value, 0, false, config, depth + 1)?;
1703 }
1704 output.push('}');
1705 Ok(())
1706}
1707
1708fn write_literal_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1709 let chomping = block_chomping(s);
1710
1711 output.push('|');
1712 output.push_str(&block_scalar_indent_indicator(s, config));
1713 output.push_str(chomping);
1714
1715 write_block_scalar_body(output, s, indent, config);
1716}
1717
1718fn write_folded_block(output: &mut String, s: &str, indent: usize, config: &SerializerConfig) {
1719 let chomping = block_chomping(s);
1720
1721 output.push('>');
1722 output.push_str(&block_scalar_indent_indicator(s, config));
1723 output.push_str(chomping);
1724
1725 write_block_scalar_body(output, s, indent, config);
1726}
1727
1728/// Serialize an iterable of values as a multi-document YAML
1729/// string with `---` document-start markers between each.
1730///
1731/// # Errors
1732///
1733/// All variants documented on [`to_string`]; the first failing
1734/// document short-circuits and returns its error — earlier
1735/// documents are not emitted.
1736///
1737/// # Examples
1738///
1739/// ```rust
1740/// let docs = vec![1, 2, 3];
1741/// let yaml = noyalib::to_string_multi(&docs).unwrap();
1742/// assert!(yaml.contains("---"));
1743/// ```
1744pub fn to_string_multi<T: serde_core::Serialize>(values: &[T]) -> Result<String> {
1745 to_string_multi_with_config(values, &SerializerConfig::default())
1746}
1747
1748/// Serialize an iterable of values as a multi-document YAML
1749/// string with a custom [`SerializerConfig`].
1750///
1751/// # Errors
1752///
1753/// All variants documented on [`to_string_with_config`].
1754pub fn to_string_multi_with_config<T: serde_core::Serialize>(
1755 values: &[T],
1756 config: &SerializerConfig,
1757) -> Result<String> {
1758 let mut output = String::new();
1759 for (i, value) in values.iter().enumerate() {
1760 if i > 0 {
1761 output.push('\n');
1762 }
1763 output.push_str("---\n");
1764 let v = to_value(value)?;
1765 write_value(&mut output, &v, 0, true, config, 0)?;
1766 output.push('\n');
1767 }
1768 Ok(output)
1769}
1770
1771/// Serialize multiple values as multi-document YAML to a writer.
1772///
1773/// # Errors
1774///
1775/// Returns an error if any value cannot be serialized or writing fails.
1776#[cfg(feature = "std")]
1777pub fn to_writer_multi<W, T>(writer: W, values: &[T]) -> Result<()>
1778where
1779 W: std::io::Write,
1780 T: serde_core::Serialize,
1781{
1782 to_writer_multi_with_config(writer, values, &SerializerConfig::default())
1783}
1784
1785/// Serialize multiple values as multi-document YAML to a writer with custom
1786/// configuration.
1787///
1788/// # Errors
1789///
1790/// Returns an error if any value cannot be serialized or writing fails.
1791#[cfg(feature = "std")]
1792pub fn to_writer_multi_with_config<W, T>(
1793 writer: W,
1794 values: &[T],
1795 config: &SerializerConfig,
1796) -> Result<()>
1797where
1798 W: std::io::Write,
1799 T: serde_core::Serialize,
1800{
1801 let s = to_string_multi_with_config(values, config)?;
1802 let mut writer = writer;
1803 writer.write_all(s.as_bytes())?;
1804 Ok(())
1805}
1806
1807/// A YAML serializer.
1808#[derive(Debug, Copy, Clone)]
1809pub struct Serializer;
1810
1811impl serde_core::ser::Serializer for Serializer {
1812 type Ok = Value;
1813 type Error = Error;
1814
1815 type SerializeSeq = SerializeSeq;
1816 type SerializeTuple = SerializeSeq;
1817 type SerializeTupleStruct = SerializeSeq;
1818 type SerializeTupleVariant = SerializeTupleVariant;
1819 type SerializeMap = SerializeMap;
1820 type SerializeStruct = SerializeMap;
1821 type SerializeStructVariant = SerializeStructVariant;
1822
1823 fn serialize_bool(self, v: bool) -> Result<Value> {
1824 Ok(Value::Bool(v))
1825 }
1826
1827 fn serialize_i8(self, v: i8) -> Result<Value> {
1828 self.serialize_i64(i64::from(v))
1829 }
1830
1831 fn serialize_i16(self, v: i16) -> Result<Value> {
1832 self.serialize_i64(i64::from(v))
1833 }
1834
1835 fn serialize_i32(self, v: i32) -> Result<Value> {
1836 self.serialize_i64(i64::from(v))
1837 }
1838
1839 fn serialize_i64(self, v: i64) -> Result<Value> {
1840 Ok(Value::Number(Number::Integer(v)))
1841 }
1842
1843 fn serialize_u8(self, v: u8) -> Result<Value> {
1844 self.serialize_i64(i64::from(v))
1845 }
1846
1847 fn serialize_u16(self, v: u16) -> Result<Value> {
1848 self.serialize_i64(i64::from(v))
1849 }
1850
1851 fn serialize_u32(self, v: u32) -> Result<Value> {
1852 self.serialize_i64(i64::from(v))
1853 }
1854
1855 fn serialize_u64(self, v: u64) -> Result<Value> {
1856 if let Ok(v) = i64::try_from(v) {
1857 return Ok(Value::Number(Number::Integer(v)));
1858 }
1859 // Values above `i64::MAX` require the `lossless-u64` feature.
1860 // Without it the `Number::Unsigned` variant does not exist and
1861 // there is no lossless representation to fall back to — return
1862 // an explicit serialise-time error so callers can surface the
1863 // limit to their users.
1864 #[cfg(feature = "lossless-u64")]
1865 {
1866 Ok(Value::Number(Number::Unsigned(v)))
1867 }
1868 #[cfg(not(feature = "lossless-u64"))]
1869 {
1870 Err(Error::Serialize(format!(
1871 "u64 value {v} exceeds i64::MAX and cannot be represented losslessly; \
1872 enable the `lossless-u64` Cargo feature to opt in to unsigned integer support"
1873 )))
1874 }
1875 }
1876
1877 fn serialize_f32(self, v: f32) -> Result<Value> {
1878 self.serialize_f64(f64::from(v))
1879 }
1880
1881 fn serialize_f64(self, v: f64) -> Result<Value> {
1882 Ok(Value::Number(Number::Float(v)))
1883 }
1884
1885 fn serialize_char(self, v: char) -> Result<Value> {
1886 self.serialize_str(&v.to_string())
1887 }
1888
1889 fn serialize_str(self, v: &str) -> Result<Value> {
1890 Ok(Value::String(v.to_owned()))
1891 }
1892
1893 fn serialize_bytes(self, v: &[u8]) -> Result<Value> {
1894 // YAML 1.2.2 §10.4: byte buffers serialise as a `!!binary`
1895 // tagged scalar carrying the RFC 4648 base64 encoding of
1896 // the payload. This is the round-trip partner of the
1897 // `deserialize_bytes` path that recognises `!!binary` and
1898 // base64-decodes on demand. Holds for any `serde_bytes`
1899 // wrapper (`ByteBuf`, `Bytes`) and any `&[u8]` /
1900 // `Vec<u8>`-shaped target the caller annotates with
1901 // `#[serde(with = "serde_bytes")]`.
1902 let encoded = crate::base64::encode(v);
1903 Ok(Value::Tagged(Box::new(TaggedValue::new(
1904 Tag::new("!!binary"),
1905 Value::String(encoded),
1906 ))))
1907 }
1908
1909 fn serialize_none(self) -> Result<Value> {
1910 Ok(Value::Null)
1911 }
1912
1913 fn serialize_some<T>(self, value: &T) -> Result<Value>
1914 where
1915 T: ?Sized + serde_core::Serialize,
1916 {
1917 value.serialize(self)
1918 }
1919
1920 fn serialize_unit(self) -> Result<Value> {
1921 Ok(Value::Null)
1922 }
1923
1924 fn serialize_unit_struct(self, _name: &'static str) -> Result<Value> {
1925 self.serialize_unit()
1926 }
1927
1928 fn serialize_unit_variant(
1929 self,
1930 _name: &'static str,
1931 _variant_index: u32,
1932 variant: &'static str,
1933 ) -> Result<Value> {
1934 self.serialize_str(variant)
1935 }
1936
1937 fn serialize_newtype_struct<T>(self, name: &'static str, value: &T) -> Result<Value>
1938 where
1939 T: ?Sized + serde_core::Serialize,
1940 {
1941 // Intercept formatting hint magic names
1942 match name {
1943 crate::fmt::MAGIC_FLOW_SEQ
1944 | crate::fmt::MAGIC_FLOW_MAP
1945 | crate::fmt::MAGIC_LIT_STR
1946 | crate::fmt::MAGIC_FOLD_STR
1947 | crate::fmt::MAGIC_SPACE_AFTER => {
1948 let inner = value.serialize(Self)?;
1949 Ok(Value::Tagged(Box::new(TaggedValue::new(
1950 Tag::new(name),
1951 inner,
1952 ))))
1953 }
1954 crate::fmt::MAGIC_COMMENTED => {
1955 // value is a tuple (inner_value, comment_string)
1956 let inner = value.serialize(Self)?;
1957 Ok(Value::Tagged(Box::new(TaggedValue::new(
1958 Tag::new(name),
1959 inner,
1960 ))))
1961 }
1962 crate::fmt::MAGIC_ANCHOR_DEF | crate::fmt::MAGIC_ANCHOR_REF => {
1963 // ANCHOR_DEF: value serializes as Sequence([String(id), inner]).
1964 // ANCHOR_REF: value serializes as String(id).
1965 let inner = value.serialize(Self)?;
1966 Ok(Value::Tagged(Box::new(TaggedValue::new(
1967 Tag::new(name),
1968 inner,
1969 ))))
1970 }
1971 crate::fmt::MAGIC_TAGGED => {
1972 // `TaggedValue::serialize`'s wire form: a single-entry map
1973 // keyed by the tag string (#350). The marker name, not the
1974 // map's shape, is what rebuilds `Value::Tagged` here, so a
1975 // genuine one-entry mapping keyed by a `!`-string reaches
1976 // `SerializeMap::end` below and stays a mapping (#377).
1977 match value.serialize(Self)? {
1978 Value::Mapping(map) if map.len() == 1 => {
1979 let (tag, inner) = map
1980 .into_iter()
1981 .next()
1982 .expect("length checked to be exactly one entry");
1983 Ok(Value::Tagged(Box::new(TaggedValue::new(
1984 Tag::new(tag),
1985 inner,
1986 ))))
1987 }
1988 other => Ok(other),
1989 }
1990 }
1991 _ => value.serialize(self),
1992 }
1993 }
1994
1995 fn serialize_newtype_variant<T>(
1996 self,
1997 _name: &'static str,
1998 _variant_index: u32,
1999 variant: &'static str,
2000 value: &T,
2001 ) -> Result<Value>
2002 where
2003 T: ?Sized + serde_core::Serialize,
2004 {
2005 let mut map = Mapping::new();
2006 let _ = map.insert(variant.to_owned(), value.serialize(Self)?);
2007 Ok(Value::Mapping(map))
2008 }
2009
2010 fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq> {
2011 Ok(SerializeSeq {
2012 vec: Vec::with_capacity(len.unwrap_or(0)),
2013 })
2014 }
2015
2016 fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple> {
2017 self.serialize_seq(Some(len))
2018 }
2019
2020 fn serialize_tuple_struct(
2021 self,
2022 _name: &'static str,
2023 len: usize,
2024 ) -> Result<Self::SerializeTupleStruct> {
2025 self.serialize_seq(Some(len))
2026 }
2027
2028 fn serialize_tuple_variant(
2029 self,
2030 _name: &'static str,
2031 _variant_index: u32,
2032 variant: &'static str,
2033 len: usize,
2034 ) -> Result<Self::SerializeTupleVariant> {
2035 Ok(SerializeTupleVariant {
2036 name: variant.to_owned(),
2037 vec: Vec::with_capacity(len),
2038 })
2039 }
2040
2041 fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
2042 Ok(SerializeMap {
2043 map: Mapping::new(),
2044 key: None,
2045 })
2046 }
2047
2048 fn serialize_struct(self, _name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
2049 self.serialize_map(Some(len))
2050 }
2051
2052 fn serialize_struct_variant(
2053 self,
2054 _name: &'static str,
2055 _variant_index: u32,
2056 variant: &'static str,
2057 _len: usize,
2058 ) -> Result<Self::SerializeStructVariant> {
2059 Ok(SerializeStructVariant {
2060 name: variant.to_owned(),
2061 map: Mapping::new(),
2062 })
2063 }
2064}
2065
2066/// Serializer for sequences.
2067#[derive(Debug)]
2068pub struct SerializeSeq {
2069 vec: Vec<Value>,
2070}
2071
2072impl serde_core::ser::SerializeSeq for SerializeSeq {
2073 type Ok = Value;
2074 type Error = Error;
2075
2076 fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2077 where
2078 T: ?Sized + serde_core::Serialize,
2079 {
2080 self.vec.push(value.serialize(Serializer)?);
2081 Ok(())
2082 }
2083
2084 fn end(self) -> Result<Value> {
2085 Ok(Value::Sequence(self.vec))
2086 }
2087}
2088
2089impl serde_core::ser::SerializeTuple for SerializeSeq {
2090 type Ok = Value;
2091 type Error = Error;
2092
2093 fn serialize_element<T>(&mut self, value: &T) -> Result<()>
2094 where
2095 T: ?Sized + serde_core::Serialize,
2096 {
2097 serde_core::ser::SerializeSeq::serialize_element(self, value)
2098 }
2099
2100 fn end(self) -> Result<Value> {
2101 serde_core::ser::SerializeSeq::end(self)
2102 }
2103}
2104
2105impl serde_core::ser::SerializeTupleStruct for SerializeSeq {
2106 type Ok = Value;
2107 type Error = Error;
2108
2109 fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2110 where
2111 T: ?Sized + serde_core::Serialize,
2112 {
2113 serde_core::ser::SerializeSeq::serialize_element(self, value)
2114 }
2115
2116 fn end(self) -> Result<Value> {
2117 serde_core::ser::SerializeSeq::end(self)
2118 }
2119}
2120
2121/// Serializer for tuple variants.
2122#[derive(Debug)]
2123pub struct SerializeTupleVariant {
2124 name: String,
2125 vec: Vec<Value>,
2126}
2127
2128impl serde_core::ser::SerializeTupleVariant for SerializeTupleVariant {
2129 type Ok = Value;
2130 type Error = Error;
2131
2132 fn serialize_field<T>(&mut self, value: &T) -> Result<()>
2133 where
2134 T: ?Sized + serde_core::Serialize,
2135 {
2136 self.vec.push(value.serialize(Serializer)?);
2137 Ok(())
2138 }
2139
2140 fn end(self) -> Result<Value> {
2141 let mut map = Mapping::new();
2142 let _ = map.insert(self.name, Value::Sequence(self.vec));
2143 Ok(Value::Mapping(map))
2144 }
2145}
2146
2147/// Serializer for maps.
2148#[derive(Debug)]
2149pub struct SerializeMap {
2150 map: Mapping,
2151 key: Option<String>,
2152}
2153
2154impl serde_core::ser::SerializeMap for SerializeMap {
2155 type Ok = Value;
2156 type Error = Error;
2157
2158 fn serialize_key<T>(&mut self, key: &T) -> Result<()>
2159 where
2160 T: ?Sized + serde_core::Serialize,
2161 {
2162 let key_value = key.serialize(Serializer)?;
2163 let key_str = match key_value {
2164 Value::String(s) => s,
2165 Value::Number(Number::Integer(n)) => n.to_string(),
2166 #[cfg(feature = "lossless-u64")]
2167 Value::Number(Number::Unsigned(n)) => n.to_string(),
2168 Value::Bool(b) => b.to_string(),
2169 _ => return Err(Error::Serialize("map key must be a string".to_string())),
2170 };
2171 self.key = Some(key_str);
2172 Ok(())
2173 }
2174
2175 fn serialize_value<T>(&mut self, value: &T) -> Result<()>
2176 where
2177 T: ?Sized + serde_core::Serialize,
2178 {
2179 let key = self
2180 .key
2181 .take()
2182 .ok_or_else(|| Error::Serialize("missing key".to_string()))?;
2183 let _ = self.map.insert(key, value.serialize(Serializer)?);
2184 Ok(())
2185 }
2186
2187 fn end(self) -> Result<Value> {
2188 Ok(Value::Mapping(self.map))
2189 }
2190}
2191
2192impl serde_core::ser::SerializeStruct for SerializeMap {
2193 type Ok = Value;
2194 type Error = Error;
2195
2196 fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2197 where
2198 T: ?Sized + serde_core::Serialize,
2199 {
2200 let _ = self
2201 .map
2202 .insert(key.to_owned(), value.serialize(Serializer)?);
2203 Ok(())
2204 }
2205
2206 fn end(self) -> Result<Value> {
2207 Ok(Value::Mapping(self.map))
2208 }
2209}
2210
2211/// Serializer for struct variants.
2212#[derive(Debug)]
2213pub struct SerializeStructVariant {
2214 name: String,
2215 map: Mapping,
2216}
2217
2218impl serde_core::ser::SerializeStructVariant for SerializeStructVariant {
2219 type Ok = Value;
2220 type Error = Error;
2221
2222 fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
2223 where
2224 T: ?Sized + serde_core::Serialize,
2225 {
2226 let _ = self
2227 .map
2228 .insert(key.to_owned(), value.serialize(Serializer)?);
2229 Ok(())
2230 }
2231
2232 fn end(self) -> Result<Value> {
2233 let mut map = Mapping::new();
2234 let _ = map.insert(self.name, Value::Mapping(self.map));
2235 Ok(Value::Mapping(map))
2236 }
2237}
2238
2239#[cfg(test)]
2240mod tests {
2241 use super::*;
2242 use crate::Value;
2243
2244 #[test]
2245 fn test_serialization_recursion_limit() {
2246 let mut root = Value::Sequence(vec![Value::Null]);
2247 for _ in 0..200 {
2248 root = Value::Sequence(vec![root]);
2249 }
2250
2251 let config = SerializerConfig::default().max_depth(128);
2252 let result = to_string_with_config(&root, &config);
2253
2254 match result {
2255 Err(Error::RecursionLimitExceeded { depth }) => assert!(depth > 128),
2256 _ => panic!("Expected RecursionLimitExceeded error, got {result:?}"),
2257 }
2258 }
2259
2260 // Regression for #84: `flow_style` was stored but never consulted by the
2261 // emit path, so `FlowStyle::Flow` / `Auto` silently produced block output.
2262 #[test]
2263 fn test_flow_style_flow_emits_inline_collections() {
2264 let seq = Value::Sequence(vec![
2265 Value::from(0),
2266 Value::from(1),
2267 Value::from(2),
2268 Value::from(3),
2269 Value::from(4),
2270 ]);
2271 let mut map = Mapping::new();
2272 let _ = map.insert("a", Value::from(1));
2273 let _ = map.insert("b", Value::from(2));
2274 let _ = map.insert("c", Value::from(3));
2275 let map = Value::Mapping(map);
2276
2277 let config = SerializerConfig::new().flow_style(FlowStyle::Flow);
2278 assert_eq!(
2279 to_string_with_config(&seq, &config).unwrap().trim_end(),
2280 "[0, 1, 2, 3, 4]"
2281 );
2282 assert_eq!(
2283 to_string_with_config(&map, &config).unwrap().trim_end(),
2284 "{a: 1, b: 2, c: 3}"
2285 );
2286 }
2287
2288 #[test]
2289 fn test_flow_style_auto_respects_threshold() {
2290 let small = Value::Sequence((0..3).map(Value::from).collect());
2291 let large = Value::Sequence((0..10).map(Value::from).collect());
2292
2293 let config = SerializerConfig::new()
2294 .flow_style(FlowStyle::Auto)
2295 .flow_threshold(4);
2296
2297 // Small collection (<= threshold) flows inline.
2298 assert_eq!(
2299 to_string_with_config(&small, &config).unwrap().trim_end(),
2300 "[0, 1, 2]"
2301 );
2302 // Large collection (> threshold) stays block.
2303 assert!(
2304 to_string_with_config(&large, &config)
2305 .unwrap()
2306 .starts_with("- 0")
2307 );
2308 }
2309
2310 #[test]
2311 fn test_flow_style_auto_falls_back_when_child_exceeds_threshold() {
2312 // Outer has 2 items (<= threshold) but the inner sequence has 10
2313 // (> threshold). Flowing the outer would emit an invalid block child
2314 // inside flow, so Auto must keep the outer in block style.
2315 let inner = Value::Sequence((0..10).map(Value::from).collect());
2316 let outer = Value::Sequence(vec![Value::from(0), inner]);
2317
2318 let config = SerializerConfig::new()
2319 .flow_style(FlowStyle::Auto)
2320 .flow_threshold(4);
2321 let out = to_string_with_config(&outer, &config).unwrap();
2322 assert!(out.starts_with("- 0"), "outer should stay block: {out:?}");
2323 }
2324
2325 #[test]
2326 fn test_flow_style_block_is_default_unchanged() {
2327 let seq = Value::Sequence((0..3).map(Value::from).collect());
2328 // Default config (Block) and the no-config helper both stay block.
2329 assert!(to_string(&seq).unwrap().starts_with("- 0"));
2330 assert!(
2331 to_string_with_config(&seq, &SerializerConfig::new())
2332 .unwrap()
2333 .starts_with("- 0")
2334 );
2335 }
2336}