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}