Skip to main content

noyalib/compat/
serde_yaml.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Drop-in API surface compatible with `serde_yaml` 0.9.
5//!
6//! The upstream `serde_yaml` 0.9 crate is unmaintained. This
7//! module exposes a name-for-name surface so existing codebases
8//! can migrate by editing two lines:
9//!
10//! ## Why migrate from `serde_yaml`?
11//!
12//! - **Maintained.** `serde_yaml` 0.9 was archived by its author
13//!   in 2024; security advisories and YAML-spec corrections do
14//!   not flow into it. noyalib is actively maintained.
15//! - **Faster.** noyalib's deserialiser outpaces `serde_yaml_ng`
16//!   (the most active fork) by **39 – 64 %** on representative
17//!   workloads; the streaming path adds another 22 % on top of
18//!   that for large documents. SIMD-accelerated structural
19//!   discovery and SWAR decimal parsing pull big-document parses
20//!   another 4–9× ahead on the bytes / second metric.
21//!   Numbers are reproducible via `cargo bench --bench
22//!   comparison`.
23//! - **Zero `unsafe`.** noyalib enforces `#![forbid(unsafe_code)]`
24//!   across the entire workspace — every line of parser, scanner,
25//!   formatter, and CST code is checked at compile time. Audits
26//!   that would otherwise need to verify `serde_yaml`'s `unsafe`
27//!   blocks evaporate.
28//! - **Lossless tooling.** noyalib ships a byte-faithful CST
29//!   ([`crate::cst::Document`]) so editing tools can patch a
30//!   single value while preserving every comment, indent, and
31//!   sibling entry — something the original `serde_yaml` cannot
32//!   do at all.
33//! - **No dead branch.** The `compat-serde-yaml` shim does
34//!   **not** re-introduce the unmaintained crate as a dependency.
35//!   Every type the shim exposes is a noyalib-native type
36//!   re-exported under the `serde_yaml` name; downstream
37//!   `cargo audit` / `cargo deny` never picks up the archived
38//!   advisory chain.
39//! - **YAML 1.2 spec compliant.** noyalib passes 406/406 cases
40//!   in the official YAML 1.2 test suite. `serde_yaml` 0.9
41//!   carries known spec deviations that are baked-in for back
42//!   compat; noyalib has the freedom to fix them.
43//!
44//! ## Drop-in migration
45//!
46//! Zero source changes, via Cargo's package rename and the
47//! `noyalib-serde-yaml` companion crate:
48//!
49//! ```toml
50//! # Cargo.toml — the whole migration
51//! serde_yaml = { package = "noyalib-serde-yaml", version = "=0.0.29" }
52//! ```
53//!
54//! Or two lines, depending on noyalib directly:
55//!
56//! ```toml
57//! # Cargo.toml — before
58//! serde_yaml = "0.9"
59//! # Cargo.toml — after
60//! noyalib = { version = "0.0", features = ["compat-serde-yaml"] }
61//! ```
62//!
63//! ```rust,ignore
64//! // anywhere in the codebase
65//! - use serde_yaml::{from_str, to_string, Value};
66//! + use noyalib::compat::serde_yaml::{from_str, to_string, Value};
67//! ```
68//!
69//! Every function delegates to the underlying noyalib engine — no
70//! double-parsing, no extra allocations, no parser fork. Where
71//! `serde_yaml`'s signature differs from noyalib's (the most common
72//! case is taking a `Value` by value vs. by reference), this shim
73//! provides a thin adapter; everything else is a re-export.
74//!
75//! # Zero legacy dependencies
76//!
77//! The shim deliberately **does not depend on the unmaintained
78//! `serde_yaml` 0.9 crate**. Every type the shim exposes is a
79//! noyalib-native type re-exported under the `serde_yaml` name —
80//! you migrate *off* the legacy crate, not into a vendored copy
81//! of it. Downstream `cargo audit` / `cargo deny` runs do not
82//! pick up the archived advisory chain.
83//!
84//! # Known surface differences
85//!
86//! - **`Mapping` is string-keyed.** noyalib's [`Mapping`] uses
87//!   `String` keys; `serde_yaml::Mapping` allowed any [`Value`] as
88//!   a key. The 99 % case (configuration files, RPC payloads) is
89//!   string-keyed and works unchanged. If your code constructs a
90//!   `Mapping` with non-string keys, switch to noyalib's
91//!   [`crate::MappingAny`] directly — the shim does not re-export
92//!   it under the `Mapping` name to keep type errors localised.
93//!
94//! # Migrating an in-flight `serde_yaml::Value`
95//!
96//! Mid-migration codebases sometimes still hold an upstream
97//! `::serde_yaml::Value` produced by an un-migrated module. The
98//! Serde data model is the universal translator: every
99//! Serde-compatible value can be funnelled through
100//! [`crate::from_value`] / [`crate::to_value`] without depending
101//! on the upstream library.
102//!
103//! ```rust,ignore
104//! // Upstream value in flight (un-migrated module hands you one).
105//! let upstream: ::serde_yaml::Value = legacy_call();
106//!
107//! // Lower it into noyalib::Value via the Serde bridge — works
108//! // because both ASTs implement `Serialize` / `Deserialize`.
109//! let lowered: noyalib::Value = noyalib::to_value(&upstream)?;
110//!
111//! // Or go straight to a typed struct, skipping the Value AST.
112//! let cfg: MyConfig = noyalib::from_value(&noyalib::to_value(&upstream)?)?;
113//! ```
114//!
115//! Going the other direction is just as direct:
116//!
117//! ```rust,ignore
118//! let lifted: ::serde_yaml::Value =
119//!     ::serde_yaml::to_value(&my_noyalib_value)?;
120//! ```
121//!
122//! Both directions cost one Serde round-trip — the same wall-clock
123//! cost as a hand-written `From` impl on a representative
124//! `Value` shape. The benefit: zero dependency on the archived
125//! crate.
126//!
127//! # `Error` parity
128//!
129//! The `Error::location() -> Option<Location>` /
130//! `Location::line()` / `Location::column()` / `Location::index()`
131//! chain matches `serde_yaml`'s shape byte-for-byte (1-indexed
132//! line and column, 0-indexed byte offset), so existing
133//! diagnostic-emitting code that destructures these compiles
134//! unchanged.
135//!
136//! # Migration cookbook
137//!
138//! ```rust
139//! use noyalib::compat::serde_yaml as syml;
140//!
141//! #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
142//! struct Config { name: String, port: u16 }
143//!
144//! let yaml = "name: noyalib\nport: 8080\n";
145//! let cfg: Config = syml::from_str(yaml).unwrap();
146//! assert_eq!(cfg, Config { name: "noyalib".into(), port: 8080 });
147//!
148//! let back = syml::to_string(&cfg).unwrap();
149//! let round: Config = syml::from_str(&back).unwrap();
150//! assert_eq!(cfg, round);
151//! ```
152//!
153//! # Behavioural parity with upstream `serde_yaml` 0.9
154//!
155//! Since v0.0.29 the shim is **behavioural**, not just
156//! name-compatible: its entry points parse under
157//! [`crate::ParserConfig::serde_yaml_compat`] and its [`Error`]
158//! renders upstream's wording and locations. The 18-case
159//! `serde_yaml` contract suite (`tests/serde_yaml_contract.rs`,
160//! expectations captured live from `serde_yaml 0.9.34`) pins:
161//! literal `<<` entries with resolved alias values, `0123` as a
162//! string and `0b11` as 3, `1e999` as a string, full `u64`
163//! precision with one-past-`u64::MAX` refused as
164//! `JSON number out of range`, non-scalar keys refused as
165//! `invalid type: sequence, expected a string key`, upstream's
166//! `repetition limit exceeded` alias budget, and libyaml's
167//! error phrasing and end-of-input location convention. One
168//! documented partial: a custom tag under `deserialize_any`
169//! refuses with upstream's message, anchored at the value rather
170//! than the tag.
171//!
172//! Callers who want noyalib's own spec-strict defaults use the
173//! direct API ([`crate::from_str`]); the differences below then
174//! apply:
175//!
176//! - **Custom-tag scalars surface as [`Value::Tagged`]**
177//!   instead of being
178//!   silently coerced to the inner string. `from_str::<Value>`
179//!   on `!Custom 'hello'` returns
180//!   `Value::Tagged(Tag("!Custom"), Value::String("hello"))`,
181//!   not `Value::String("hello")`. Migrants who previously
182//!   exhaustive-matched the six-variant `serde_yaml::Value`
183//!   need to either add a `Value::Tagged(_)` arm or call
184//!   [`Value::untag`](crate::Value::untag) /
185//!   [`Value::untag_ref`](crate::Value::untag_ref) before the
186//!   match. See
187//!   [`docs/MIGRATION-FROM-SERDE-YAML.md`](https://github.com/sebastienrousseau/noyalib/blob/main/docs/MIGRATION-FROM-SERDE-YAML.md#1-valuetagged-is-a-7th-variant--and-noyalib-preserves-scalar-tags-too)
188//!   §1 for the recipe.
189//! - **YAML 1.2 strict booleans by default.** `country: NO`
190//!   stays `"NO"` (the YAML 1.2 fix to the "Norway problem")
191//!   instead of becoming `false`. Opt back into YAML 1.1
192//!   resolver semantics via
193//!   [`ParserConfig::version`](crate::ParserConfig::version)`(`[`YamlVersion::V1_1`](crate::YamlVersion)`)`
194//!   if your existing pipeline depended on the legacy boolean
195//!   recognition.
196//!
197//! Both of these are documented under "Things `noyalib` adds"
198//! and "Behavioural differences worth knowing" in the migration
199//! guide. Neither is reachable via the existing `serde_yaml`
200//! API surface — they are extra information / safer defaults
201//! that flow through unchanged for the typed-deserialise path.
202
203use crate::prelude::*;
204
205// ── Types — re-exported under the serde_yaml names ───────────────────
206
207pub use crate::error::Location;
208pub use crate::value::{Mapping, Number, Sequence, Tag, TaggedValue, Value};
209
210/// Shim result type: [`Result<T, Error>`](core::result::Result) with
211/// the shim's own [`Error`].
212///
213/// # Examples
214///
215/// ```
216/// use noyalib::compat::serde_yaml as syml;
217/// fn parse(s: &str) -> syml::Result<syml::Value> {
218///     syml::from_str(s)
219/// }
220/// assert!(parse("a: 1\n").is_ok());
221/// ```
222pub type Result<T> = core::result::Result<T, Error>;
223
224/// The shim's error type: a [`crate::Error`] rendered the way
225/// `serde_yaml` 0.9 worded and located it.
226///
227/// Since the behavioural-shim rework this is a newtype, not a
228/// re-export of [`crate::Error`] — the payoff is `Display` and
229/// [`Error::location`] parity with upstream on the error classes the
230/// `serde_yaml` contract exercises:
231///
232/// - budget refusals read `repetition limit exceeded` /
233///   `recursion limit exceeded`, unlocated, exactly as upstream;
234/// - a refused non-scalar key reads
235///   `invalid type: sequence, expected a string key` at the key's
236///   location;
237/// - parse errors adopt libyaml's phrasing where the class is
238///   recognisable (`did not find expected node content …, while
239///   parsing a flow node`; `did not find expected ',' or ']' …,
240///   while parsing a flow sequence at …`), and an error at
241///   end-of-input reports the line *after* the last one, column 1 —
242///   libyaml's EOF convention;
243/// - deserialization errors render suffix-style
244///   (`field: message at line L column C`).
245///
246/// Classes without an upstream equivalent keep noyalib's own
247/// wording. The inner [`crate::Error`] stays reachable via
248/// [`Error::into_inner`] / [`Error::inner`].
249#[derive(Debug)]
250pub struct Error(Box<ErrorImpl>);
251
252/// Boxed innards, mirroring upstream's own `Error(Box<ErrorImpl>)`
253/// shape — keeps `Result<T>` at pointer size on the Err side.
254#[derive(Debug)]
255struct ErrorImpl {
256    inner: crate::error::Error,
257    display: String,
258    location: Option<Location>,
259}
260
261impl Error {
262    /// Wrap a noyalib error, rendering it upstream-style against the
263    /// input it came from (the input drives the EOF location
264    /// convention and the flow-sequence context trailer).
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// use noyalib::compat::serde_yaml::Error;
270    /// let input = "title: [oops";
271    /// let inner = noyalib::from_str::<noyalib::Value>(input).unwrap_err();
272    /// let e = Error::from_noyalib_with_input(inner, input);
273    /// // libyaml's end-of-input convention: the line after the last.
274    /// assert_eq!(e.location().unwrap().line(), 2);
275    /// ```
276    #[must_use]
277    pub fn from_noyalib_with_input(inner: crate::error::Error, input: &str) -> Self {
278        let mut location = inner.location();
279        // libyaml reports an error at end-of-input on the line after
280        // the last one, column 1; noyalib says end-of-last-line.
281        if let Some(loc) = location {
282            let at_eof = loc.index() >= input.len();
283            if at_eof && loc.column() > 1 && parse_shaped(&inner) {
284                location = Some(Location::new(loc.line() + 1, 1, loc.index()));
285            }
286        }
287        let display = render_upstream_style(&inner, location, Some(input));
288        Self(Box::new(ErrorImpl {
289            inner,
290            display,
291            location,
292        }))
293    }
294
295    /// The upstream-shaped location, when the error has one.
296    /// 1-based line and column, 0-based byte index — the exact
297    /// `serde_yaml::Location` surface.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// use noyalib::compat::serde_yaml as syml;
303    /// let err = syml::from_str::<syml::Value>("a: [unclosed").unwrap_err();
304    /// let loc = err.location().unwrap();
305    /// assert!(loc.line() >= 1 && loc.column() >= 1);
306    /// let _: usize = loc.index();
307    /// ```
308    #[must_use]
309    pub fn location(&self) -> Option<Location> {
310        self.0.location
311    }
312
313    /// Borrow the underlying [`crate::Error`].
314    ///
315    /// # Examples
316    ///
317    /// ```
318    /// use noyalib::compat::serde_yaml as syml;
319    /// let err = syml::from_str::<syml::Value>("a: [unclosed").unwrap_err();
320    /// assert!(matches!(err.inner().kind(), noyalib::ErrorKind::Syntax));
321    /// ```
322    #[must_use]
323    pub fn inner(&self) -> &crate::error::Error {
324        &self.0.inner
325    }
326
327    /// Unwrap into the underlying [`crate::Error`] — the way back to
328    /// noyalib's own diagnostics (miette/ariadne adapters included).
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use noyalib::compat::serde_yaml as syml;
334    /// let err = syml::from_str::<syml::Value>("a: [unclosed").unwrap_err();
335    /// let inner: noyalib::Error = err.into_inner();
336    /// assert!(inner.location().is_some());
337    /// ```
338    #[must_use]
339    pub fn into_inner(self) -> crate::error::Error {
340        self.0.inner
341    }
342}
343
344impl fmt::Display for Error {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        f.write_str(&self.0.display)
347    }
348}
349
350impl core::error::Error for Error {
351    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
352        Some(&self.0.inner)
353    }
354}
355
356impl From<crate::error::Error> for Error {
357    fn from(inner: crate::error::Error) -> Self {
358        let location = inner.location();
359        let display = render_upstream_style(&inner, location, None);
360        Self(Box::new(ErrorImpl {
361            inner,
362            display,
363            location,
364        }))
365    }
366}
367
368/// Is this a parse-shaped error (the family libyaml's EOF location
369/// convention applies to)?
370fn parse_shaped(e: &crate::error::Error) -> bool {
371    matches!(
372        e.kind(),
373        crate::ErrorKind::Syntax | crate::ErrorKind::EndOfStream
374    )
375}
376
377/// Render a noyalib error the way `serde_yaml` 0.9 would have worded
378/// it. Classes without an upstream analogue keep noyalib's wording.
379fn render_upstream_style(
380    e: &crate::error::Error,
381    location: Option<Location>,
382    input: Option<&str>,
383) -> String {
384    use crate::error::Error as E;
385    let at = |loc: Option<Location>| -> String {
386        loc.map(|l| format!(" at line {} column {}", l.line(), l.column()))
387            .unwrap_or_default()
388    };
389    match e {
390        // Upstream's resource-limit wordings, unlocated like upstream.
391        E::RepetitionLimitExceeded => "repetition limit exceeded".to_owned(),
392        E::Budget(crate::BudgetBreach::AliasAnchorRatio { .. }) => {
393            "repetition limit exceeded".to_owned()
394        }
395        E::RecursionLimitExceeded { .. } => "recursion limit exceeded".to_owned(),
396        E::DuplicateKey(key) | E::DuplicateKeyAt { key, .. } => {
397            format!("duplicate entry with key {key:?}")
398        }
399        // `invalid type: sequence, expected a string key` — noyalib's
400        // own wording already matches upstream, and upstream carries
401        // the position only in `location()`, not in `Display`.
402        E::NonScalarKey { .. } => e.to_string(),
403        E::IntegerOverflow { path, .. } => {
404            let prefix = path
405                .as_deref()
406                .map(|p| format!("{p}: "))
407                .unwrap_or_default();
408            format!("{prefix}JSON number out of range{}", at(location))
409        }
410        E::ParseWithLocation { message, .. } => {
411            if message.starts_with("expected a node but found Flow") {
412                format!(
413                    "did not find expected node content{}, while parsing a flow node",
414                    at(location)
415                )
416            } else if message == "expected ',' or ']' in flow sequence" {
417                let ctx = input
418                    .and_then(|inp| location.and_then(|l| unmatched_open_bracket(inp, l.index())))
419                    .map(|open| {
420                        let l = Location::from_index(input.unwrap_or(""), open);
421                        format!(
422                            ", while parsing a flow sequence at line {} column {}",
423                            l.line(),
424                            l.column()
425                        )
426                    })
427                    .unwrap_or_default();
428                format!("did not find expected ',' or ']'{}{ctx}", at(location))
429            } else if message.starts_with("inconsistent indentation") {
430                // libyaml reports a value indicator at an impossible
431                // column as a misplaced mapping value.
432                format!(
433                    "mapping values are not allowed in this context{}",
434                    at(location)
435                )
436            } else {
437                format!("{message}{}", at(location))
438            }
439        }
440        E::DeserializeWithLocation { message, .. } => {
441            // Upstream renders suffix-style: `field: message at line L
442            // column C`.
443            format!("{message}{}", at(location))
444        }
445        _ => e.to_string(),
446    }
447}
448
449/// Byte offset of the innermost `[` left unclosed at `upto` —
450/// the flow-sequence context libyaml names in its error trailer.
451/// A display aid only: quote-aware enough for real documents, and
452/// absent context simply omits the trailer.
453fn unmatched_open_bracket(input: &str, upto: usize) -> Option<usize> {
454    let mut stack: Vec<usize> = Vec::new();
455    let mut in_single = false;
456    let mut in_double = false;
457    let mut prev_backslash = false;
458    for (i, b) in input.bytes().enumerate().take(upto) {
459        if in_double {
460            if b == b'"' && !prev_backslash {
461                in_double = false;
462            }
463            prev_backslash = b == b'\\' && !prev_backslash;
464            continue;
465        }
466        if in_single {
467            if b == b'\'' {
468                in_single = false;
469            }
470            continue;
471        }
472        match b {
473            b'\'' => in_single = true,
474            b'"' => in_double = true,
475            b'[' => stack.push(i),
476            b']' => {
477                let _ = stack.pop();
478            }
479            _ => {}
480        }
481    }
482    stack.last().copied()
483}
484
485// ── `serde_yaml` low-level types ─────────────────────────────────────
486//
487// `serde_yaml` 0.9 publishes its `Deserializer` / `Serializer` types
488// at the crate root for callers that bypass the convenience helpers
489// (`from_str`, `to_string`, …). We expose noyalib's own types under
490// the same names so existing `serde_yaml::Deserializer` /
491// `::Serializer` references compile without modification.
492
493pub use crate::de::Deserializer;
494pub use crate::ser::Serializer;
495
496// ── Sub-module namespacing ───────────────────────────────────────────
497//
498// `serde_yaml` publishes `mapping`, `value`, and `with` sub-modules
499// alongside its top-level functions. Migrating code commonly imports
500// items via these paths (`use serde_yaml::value::Tag;`,
501// `#[serde(with = "serde_yaml::with::singleton_map")]`). We mirror
502// the layout so those `use` paths continue to resolve.
503
504/// Sub-module mirroring `serde_yaml::value`.
505///
506/// `serde_yaml::value::{Value, Mapping, Number, Sequence, Tag,
507/// TaggedValue}` are also re-exported at the crate root, but code
508/// that imports them via the `value` path keeps working.
509pub mod value {
510    pub use crate::value::{Mapping, Number, Sequence, Tag, TaggedValue, Value};
511}
512
513/// Sub-module mirroring `serde_yaml::mapping`.
514///
515/// In `serde_yaml` 0.9 this housed the `Mapping` type plus its
516/// iterator types. The most common import is `Mapping` itself; we
517/// re-export the full set noyalib exposes so user code using the
518/// path-form import still resolves.
519pub mod mapping {
520    pub use crate::value::Mapping;
521}
522
523/// Sub-module mirroring `serde_yaml::with`.
524///
525/// `serde_yaml::with::singleton_map` and its variants are the
526/// idiomatic way to control enum representation in `#[serde(with =
527/// "...")]` attributes. noyalib's own implementations live under
528/// [`crate::with`]; this re-export gives migrants the `serde_yaml`
529/// path-form so existing `#[serde(with = "serde_yaml::with::…")]`
530/// attributes only need a search-and-replace on the prefix.
531pub mod with {
532    pub use crate::with::{
533        nested_singleton_map, singleton_map, singleton_map_optional, singleton_map_recursive,
534        singleton_map_with,
535    };
536}
537
538// ── Deserialization ──────────────────────────────────────────────────
539
540/// Deserialize a YAML document into the target type — with
541/// **`serde_yaml` 0.9's observable behaviour**, not noyalib's
542/// defaults.
543///
544/// The shim parses under [`crate::ParserConfig::serde_yaml_compat`]:
545/// `<<` merge keys stay literal entries (alias values resolved),
546/// leading-zero integers stay strings and `0b11` is 3, a literal
547/// float overflow stays a string, `u64`-range integers keep full
548/// precision and one past `u64::MAX` errors, non-scalar keys error,
549/// transitive alias expansion is budgeted exactly as upstream
550/// ("repetition limit exceeded"), and a duplicate mapping key is
551/// refused when the target is [`Value`] (`duplicate entry with key
552/// "k"`), as upstream refuses it for its own `Value`; a struct target
553/// refuses a repeated field (``duplicate field `k` ``) and a map target
554/// keeps the last entry, as upstream does. Callers who want noyalib's own
555/// (spec-strict) defaults should use [`crate::from_str`] directly.
556///
557/// # Examples
558///
559/// ```
560/// use noyalib::compat::serde_yaml as syml;
561/// let n: i32 = syml::from_str("42").unwrap();
562/// assert_eq!(n, 42);
563/// ```
564pub fn from_str<T>(s: &str) -> Result<T>
565where
566    T: serde_core::de::DeserializeOwned + 'static,
567{
568    let config = compat_config::<T>();
569    let value: T = crate::from_str_with_config(s, &config)
570        .map_err(|e| Error::from_noyalib_with_input(e, s))?;
571    if let Some(e) = repeated_struct_field::<T>(s, &config) {
572        return Err(Error::from_noyalib_with_input(e, s));
573    }
574    Ok(value)
575}
576
577/// serde_yaml's refusal of a repeated struct field (``duplicate field
578/// `k` ``), which the shim's own parse cannot see: its loader keeps the
579/// last entry of a repeated key, as upstream does for map targets, and
580/// only serde's struct visitor knows the target is a struct. The
581/// streaming walker hands serde every entry, so its `duplicate field`
582/// error is the answer; any other outcome of that walk is ignored, since
583/// the shim's parse above already decided everything else. Skipped for
584/// [`Value`] targets, which refuse every duplicate key on their own.
585fn repeated_struct_field<T>(s: &str, config: &crate::ParserConfig) -> Option<crate::Error>
586where
587    T: serde_core::de::DeserializeOwned + 'static,
588{
589    if core::any::TypeId::of::<T>() == core::any::TypeId::of::<Value>() {
590        return None;
591    }
592    let mut walk = config.clone();
593    walk.merge_key_policy = crate::MergeKeyPolicy::Auto;
594    match crate::streaming::from_str_streaming::<T>(s, &walk) {
595        Some(Err(e)) if e.to_string().contains("duplicate field `") => Some(e),
596        _ => None,
597    }
598}
599
600/// The configuration the shim parses under for target `T`:
601/// [`crate::ParserConfig::serde_yaml_compat`], plus serde_yaml's refusal
602/// of a duplicate mapping key when the target is a [`Value`]
603/// (`duplicate entry with key "k"`). Map targets keep the last entry,
604/// as upstream does; a repeated struct field is caught separately by
605/// [`repeated_struct_field`].
606fn compat_config<T: 'static>() -> crate::ParserConfig {
607    let mut config = crate::ParserConfig::serde_yaml_compat();
608    if core::any::TypeId::of::<T>() == core::any::TypeId::of::<Value>() {
609        config.duplicate_key_policy = crate::DuplicateKeyPolicy::Error;
610    }
611    config
612}
613
614/// Deserialize a YAML document from a byte slice.
615///
616/// # Examples
617///
618/// ```
619/// use noyalib::compat::serde_yaml as syml;
620/// let n: i32 = syml::from_slice(b"7").unwrap();
621/// assert_eq!(n, 7);
622/// ```
623pub fn from_slice<T>(bytes: &[u8]) -> Result<T>
624where
625    T: serde_core::de::DeserializeOwned + 'static,
626{
627    let s = core::str::from_utf8(bytes)
628        .map_err(|e| Error::from(crate::error::Error::Parse(format!("invalid UTF-8: {e}"))))?;
629    from_str(s)
630}
631
632/// Deserialize a YAML document from any [`std::io::Read`] source.
633///
634/// `serde_yaml::from_reader` and noyalib's `from_reader` have
635/// identical signatures, so this is a direct re-export.
636///
637/// # Examples
638///
639/// ```
640/// use noyalib::compat::serde_yaml as syml;
641/// let bytes: &[u8] = b"port: 8080\n";
642/// let m: std::collections::BTreeMap<String, u16> =
643///     syml::from_reader(bytes).unwrap();
644/// assert_eq!(m["port"], 8080);
645/// ```
646#[cfg(feature = "std")]
647pub fn from_reader<R, T>(reader: R) -> Result<T>
648where
649    R: std::io::Read,
650    T: serde_core::de::DeserializeOwned + 'static,
651{
652    let buf = crate::de::read_to_string_bounded(reader, &crate::ParserConfig::serde_yaml_compat())
653        .map_err(Error::from)?;
654    from_str(&buf)
655}
656
657/// Deserialize a typed value from a [`Value`].
658///
659/// `serde_yaml::from_value` takes the [`Value`] by *value*; noyalib
660/// takes it by reference. This adapter accepts the
661/// `serde_yaml`-style by-value form so call sites do not need to
662/// add an `&` during migration.
663///
664/// # Examples
665///
666/// ```
667/// use noyalib::compat::serde_yaml as syml;
668/// let v = syml::Value::Number(syml::Number::Integer(42));
669/// let n: i32 = syml::from_value(v).unwrap();
670/// assert_eq!(n, 42);
671/// ```
672pub fn from_value<T>(value: Value) -> Result<T>
673where
674    T: serde_core::de::DeserializeOwned + 'static,
675{
676    crate::from_value(&value).map_err(Error::from)
677}
678
679// ── Serialization ────────────────────────────────────────────────────
680
681/// Serialize a typed value to a YAML string.
682///
683/// # Examples
684///
685/// ```
686/// use noyalib::compat::serde_yaml as syml;
687/// let s = syml::to_string(&42_i32).unwrap();
688/// assert!(s.contains("42"));
689/// ```
690pub fn to_string<T>(value: &T) -> Result<String>
691where
692    T: serde_core::Serialize,
693{
694    crate::to_string(value).map_err(Error::from)
695}
696
697/// Serialize a typed value to any [`std::io::Write`] sink.
698///
699/// # Examples
700///
701/// ```
702/// use noyalib::compat::serde_yaml as syml;
703/// let mut buf: Vec<u8> = Vec::new();
704/// syml::to_writer(&mut buf, &42_i32).unwrap();
705/// assert!(!buf.is_empty());
706/// ```
707#[cfg(feature = "std")]
708pub fn to_writer<W, T>(writer: W, value: &T) -> Result<()>
709where
710    W: std::io::Write,
711    T: serde_core::Serialize,
712{
713    crate::to_writer(writer, value).map_err(Error::from)
714}
715
716/// Serialize a typed value to a [`Value`].
717///
718/// `serde_yaml::to_value` takes by value; noyalib takes by
719/// reference. Accepts the `serde_yaml` shape and forwards.
720///
721/// # Examples
722///
723/// ```
724/// use noyalib::compat::serde_yaml as syml;
725/// let v = syml::to_value(42_i32).unwrap();
726/// assert_eq!(v.as_i64(), Some(42));
727/// ```
728pub fn to_value<T>(value: T) -> Result<Value>
729where
730    T: serde_core::Serialize,
731{
732    crate::to_value(&value).map_err(Error::from)
733}
734
735// ── Multi-document streams ───────────────────────────────────────────
736//
737// `serde_yaml` exposed `Deserializer::from_str(s).into_iter::<T>()`
738// for multi-document parsing. noyalib's nearest equivalent is
739// `load_all_as`. We expose it under the `serde_yaml` name pattern.
740
741/// Iterate every YAML document in a multi-document stream and
742/// deserialize each into `T`. Mirrors the
743/// `Deserializer::from_str` chained with `into_iter::<T>()` —
744/// the typical `serde_yaml` multi-document idiom.
745///
746/// # Examples
747///
748/// ```
749/// use noyalib::compat::serde_yaml as syml;
750/// let yaml = "1\n---\n2\n---\n3\n";
751/// let docs: Vec<i32> = syml::from_str_multi(yaml).unwrap();
752/// assert_eq!(docs, vec![1, 2, 3]);
753/// ```
754pub fn from_str_multi<T>(s: &str) -> Result<Vec<T>>
755where
756    T: serde_core::de::DeserializeOwned + 'static,
757{
758    crate::load_all_as_with_config::<T>(s, &compat_config::<T>())
759        .map_err(|e| Error::from_noyalib_with_input(e, s))
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
767    struct Config {
768        name: String,
769        port: u16,
770    }
771
772    #[test]
773    fn from_str_round_trips_typed() {
774        let yaml = "name: noyalib\nport: 8080\n";
775        let cfg: Config = from_str(yaml).unwrap();
776        assert_eq!(
777            cfg,
778            Config {
779                name: "noyalib".into(),
780                port: 8080
781            }
782        );
783    }
784
785    #[test]
786    fn from_slice_typed() {
787        let bytes = b"name: noyalib\nport: 8080\n";
788        let cfg: Config = from_slice(bytes).unwrap();
789        assert_eq!(cfg.port, 8080);
790    }
791
792    #[test]
793    fn from_reader_typed() {
794        // `serde_yaml::from_reader` drop-in: deserialize straight from
795        // an `io::Read` (a `&[u8]` is one).
796        let bytes = b"name: noyalib\nport: 8080\n";
797        let cfg: Config = from_reader(&bytes[..]).unwrap();
798        assert_eq!(
799            cfg,
800            Config {
801                name: "noyalib".into(),
802                port: 8080
803            }
804        );
805    }
806
807    #[test]
808    fn to_writer_then_from_str_round_trips() {
809        // `serde_yaml::to_writer` drop-in: serialize into an
810        // `io::Write` sink and confirm the emitted YAML round-trips.
811        let cfg = Config {
812            name: "noyalib".into(),
813            port: 8080,
814        };
815        let mut buf: Vec<u8> = Vec::new();
816        to_writer(&mut buf, &cfg).unwrap();
817        let s = String::from_utf8(buf).expect("valid utf-8");
818        assert!(s.contains("name: noyalib"), "{s}");
819        assert!(s.contains("port: 8080"), "{s}");
820        let back: Config = from_str(&s).unwrap();
821        assert_eq!(cfg, back);
822    }
823
824    #[test]
825    fn from_value_takes_by_value_like_serde_yaml() {
826        let mut m = Mapping::new();
827        let _ = m.insert("name", Value::String("noyalib".into()));
828        let _ = m.insert("port", Value::Number(Number::Integer(8080)));
829        let v = Value::Mapping(m);
830        // Note: by *value*, no `&`. This is the `serde_yaml` shape.
831        let cfg: Config = from_value(v).unwrap();
832        assert_eq!(cfg.port, 8080);
833    }
834
835    #[test]
836    fn to_value_takes_by_value_like_serde_yaml() {
837        let cfg = Config {
838            name: "noyalib".into(),
839            port: 8080,
840        };
841        // By value, no `&`.
842        let v = to_value(cfg).unwrap();
843        match v {
844            Value::Mapping(m) => {
845                assert_eq!(m.get("name"), Some(&Value::String("noyalib".into())));
846            }
847            _ => panic!("expected Mapping"),
848        }
849    }
850
851    #[test]
852    fn round_trip_via_to_string_from_str() {
853        let cfg = Config {
854            name: "noyalib".into(),
855            port: 8080,
856        };
857        let s = to_string(&cfg).unwrap();
858        let back: Config = from_str(&s).unwrap();
859        assert_eq!(cfg, back);
860    }
861
862    #[test]
863    fn multi_doc_stream() {
864        let yaml = "name: a\nport: 1\n---\nname: b\nport: 2\n";
865        let docs: Vec<Config> = from_str_multi(yaml).unwrap();
866        assert_eq!(docs.len(), 2);
867        assert_eq!(docs[0].port, 1);
868        assert_eq!(docs[1].port, 2);
869    }
870
871    #[test]
872    fn error_type_wraps_noyalib_error() {
873        // Since the behavioural-shim rework `serde_yaml::Error` is a
874        // newtype rendering upstream's wording; the underlying
875        // noyalib error stays reachable for callers that want it.
876        let e = Error::from(crate::error::Error::Custom("compat".into()));
877        assert!(e.to_string().contains("compat"), "{e}");
878        assert!(matches!(e.inner(), crate::error::Error::Custom(_)));
879        let _inner: crate::error::Error = e.into_inner();
880    }
881
882    #[test]
883    fn behavioural_defaults_follow_serde_yaml() {
884        // The two spot checks migrants hit first: `<<` stays a
885        // literal key, and a leading-zero integer stays a string —
886        // both the opposite of noyalib's spec-strict defaults, both
887        // exactly what upstream did. The full 18-case contract lives
888        // in tests/serde_yaml_contract.rs.
889        let v: Value = from_str(
890            "a: &a {x: 1}
891b:
892  <<: *a
893",
894        )
895        .unwrap();
896        assert!(v["b"].as_mapping().unwrap().contains_key("<<"));
897        let v: Value = from_str(
898            "n: 0123
899",
900        )
901        .unwrap();
902        assert_eq!(v["n"].as_str(), Some("0123"));
903    }
904
905    #[test]
906    fn deserializer_type_re_exports_under_serde_yaml_name() {
907        // Compile-time check: `serde_yaml::Deserializer` resolves to
908        // noyalib's own `Deserializer<'_>` so existing call sites
909        // that explicitly name the type compile unchanged.
910        let v = Value::from(7_i64);
911        let de = Deserializer::new(&v);
912        let n: i32 = serde_core::Deserialize::deserialize(de).unwrap();
913        assert_eq!(n, 7);
914    }
915
916    #[test]
917    fn serializer_type_re_exports_under_serde_yaml_name() {
918        // Compile-time check: `serde_yaml::Serializer` resolves to
919        // noyalib's own `Serializer`. The full streaming-serializer
920        // surface is documented on `crate::ser::Serializer`; here we
921        // just verify the type is reachable via the compat path.
922        let _ = Serializer;
923    }
924
925    #[test]
926    fn value_submodule_path_resolves() {
927        // `use serde_yaml::value::{Value, Mapping, Number};` is a
928        // common idiom; verify the path-form import resolves to the
929        // same types as the crate-root re-exports.
930        use super::value::{Mapping as MappingV, Number as NumberV, Value as ValueV};
931        let mut m = MappingV::new();
932        let _ = m.insert("k", ValueV::Number(NumberV::Integer(1)));
933        assert_eq!(m.len(), 1);
934    }
935
936    #[test]
937    fn mapping_submodule_path_resolves() {
938        use super::mapping::Mapping as MappingAlias;
939        let m: MappingAlias = MappingAlias::new();
940        assert!(m.is_empty(), "expected empty, got {m:?}");
941    }
942
943    #[test]
944    fn with_submodule_path_resolves() {
945        // Compile-time check: every helper documented on
946        // `serde_yaml::with::*` is reachable via
947        // `noyalib::compat::serde_yaml::with::*`. No runtime
948        // assertion — the import itself is the test.
949        #[allow(unused_imports)]
950        use super::with::{
951            nested_singleton_map, singleton_map, singleton_map_optional, singleton_map_recursive,
952            singleton_map_with,
953        };
954    }
955
956    #[test]
957    fn error_exposes_location_line_column() {
958        // The `serde_yaml::Error::location()` → `Location::line()`
959        // / `Location::column()` chain is the contract that any
960        // diagnostic-emitting migrant relies on. noyalib's `Error`
961        // exposes the exact same shape, 1-indexed.
962        let err = from_str::<Value>("a: [unclosed").unwrap_err();
963        let loc = err.location().expect("parse error must carry a location");
964        assert!(loc.line() >= 1);
965        assert!(loc.column() >= 1);
966        let _: usize = loc.index();
967    }
968}