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