Skip to main content

cordis_include/
yaml.rs

1//! The entry-list YAML dialect: tag-preserving parse and emit.
2//!
3//! Upstream's config and patch files are written in a js-yaml dialect where
4//! `!!js` scalars round-trip as expression nodes the loader evaluates at
5//! entry activation. `serde_yaml_ng` silently drops non-standard tags (a
6//! `!!js` scalar arrives as its plain text), so this module drives
7//! `unsafe-libyaml`'s parser directly — the same event stream serde_yaml_ng
8//! consumes internally, built into [`Node`] with `!!js` kept as
9//! [`Node::Expr`] — and pairs it with a hand-written emitter that prints
10//! expressions back as `!!js <expr>`.
11//!
12//! The dialect matches what the serde path produced before the switch:
13//! scalar resolution (null/bool/int/float by content, YAML 1.2 core-ish,
14//! leading-zero digit runs staying strings), quoted scalars always strings,
15//! local `!tags` on plain scalars dropped, anchors and aliases resolved,
16//! and a single document per input. The gain: `!!js` survives, and syntax
17//! errors carry line and column.
18//!
19//! The `unsafe-libyaml` driving layer is the only unsafe code in this
20//! crate, item-scoped in the private `sys` module with SAFETY notes (the
21//! same pattern `cordis-loader` uses for libloading).
22
23use crate::Document;
24use crate::error::{IncludeError, Result};
25use crate::node::{Node, NodeMap};
26use crate::options::EntryOptions;
27use crate::patch::PatchOptions;
28
29/// The full tag URI js-yaml's `!!js` resolves to.
30const JS_TAG: &str = "tag:yaml.org,2002:js";
31
32/// Core YAML tags (`!!bool`, `!!int`, `!!float`, `!!null`, `!!str`).
33fn core_tag(tag: &str) -> Option<&'static str> {
34    const CORE: [&str; 5] = ["bool", "int", "float", "null", "str"];
35    let local = tag.strip_prefix("tag:yaml.org,2002:")?;
36    CORE.into_iter().find(|candidate| *candidate == local)
37}
38
39/// A dialect failure with location context when the parser produced one.
40#[derive(Debug)]
41struct DialectError {
42    message: String,
43}
44
45impl std::fmt::Display for DialectError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(&self.message)
48    }
49}
50
51impl std::error::Error for DialectError {}
52
53fn yaml_error(message: impl Into<String>) -> IncludeError {
54    IncludeError::Parse {
55        format: "yaml",
56        source: Box::new(DialectError {
57            message: message.into(),
58        }),
59    }
60}
61
62// ------------------------------------------------------------------ sys ---
63
64/// Safe surface over `unsafe-libyaml`'s parser: one event per call with
65/// decoded anchor/tag/value strings and the event's start mark.
66mod sys {
67    #![allow(unsafe_code)]
68    // SAFETY notes in this module refer to `unsafe-libyaml`'s C-translation
69    // contract: event structs are owned, `yaml_parser_parse` writes exactly
70    // one event per successful call, and every produced event must be
71    // deleted. The parser is kept at a stable heap address for its lifetime.
72    // `sys` is the conventional alias; the whole module is the documented
73    // unsafe boundary, so the name not repeating "unsafe" loses nothing.
74    #[allow(clippy::unsafe_removed_from_name)]
75    use unsafe_libyaml as sys;
76
77    /// One parser event with its data decoded to owned strings.
78    pub(super) struct Event {
79        pub kind: EventKind,
80        pub anchor: Option<String>,
81        pub tag: Option<String>,
82        pub value: String,
83        pub style: ScalarStyle,
84        /// Zero-based start position of the event.
85        pub line: u64,
86        pub column: u64,
87    }
88
89    pub(super) enum EventKind {
90        StreamStart,
91        StreamEnd,
92        DocumentStart,
93        DocumentEnd,
94        Alias,
95        Scalar,
96        SequenceStart,
97        SequenceEnd,
98        MappingStart,
99        MappingEnd,
100    }
101
102    pub(super) enum ScalarStyle {
103        Plain,
104        Quoted,
105    }
106
107    /// A parser failure with the problem and its zero-based location.
108    pub(super) struct Error {
109        pub problem: String,
110        pub line: u64,
111        pub column: u64,
112    }
113
114    pub(super) struct Parser {
115        raw: std::boxed::Box<std::mem::MaybeUninit<sys::yaml_parser_t>>,
116    }
117
118    impl Parser {
119        pub(super) fn new(input: &[u8]) -> Parser {
120            let mut raw = std::boxed::Box::new(std::mem::MaybeUninit::uninit());
121            // SAFETY: `raw` is heap-stable for the parser's lifetime (never
122            // moved out), and `yaml_parser_initialize` fully initializes
123            // every field of a valid `yaml_parser_t`.
124            unsafe {
125                let parser = raw.as_mut_ptr();
126                if sys::yaml_parser_initialize(parser).fail {
127                    panic!("libyaml parser allocation failed");
128                }
129                sys::yaml_parser_set_encoding(parser, sys::YAML_UTF8_ENCODING);
130                sys::yaml_parser_set_input_string(parser, input.as_ptr(), input.len() as u64);
131            }
132            Parser { raw }
133        }
134
135        pub(super) fn next(&mut self) -> std::result::Result<Event, Error> {
136            let mut event = std::mem::MaybeUninit::<sys::yaml_event_t>::uninit();
137            // SAFETY: the parser pointer stays valid (stable box), and the
138            // event out-pointer receives exactly one initialized event on
139            // success. The event is converted and deleted before return so
140            // its owned buffers never leak.
141            unsafe {
142                let parser = self.raw.as_mut_ptr();
143                if sys::yaml_parser_parse(parser, event.as_mut_ptr()).fail {
144                    return Err(Self::error(parser));
145                }
146                let converted = convert_event(&*event.as_ptr());
147                sys::yaml_event_delete(event.as_mut_ptr());
148                Ok(converted)
149            }
150        }
151
152        /// SAFETY: `parser` points to an initialized parser that just
153        /// reported a failure; the problem/context fields are C strings or
154        /// null. The explicit dereference below creates the one shared
155        /// reference every field read goes through — no implicit autoref
156        /// ever forms through the raw pointer.
157        unsafe fn error(parser: *mut sys::yaml_parser_t) -> Error {
158            unsafe {
159                let parser: &sys::yaml_parser_t = &*parser;
160                let problem = cstring(std::ptr::addr_of!(parser.problem).cast())
161                    .unwrap_or_else(|| "libyaml parser failed".to_owned());
162                let mark = std::ptr::addr_of!(parser.problem_mark).read();
163                Error {
164                    problem,
165                    line: mark.line,
166                    column: mark.column,
167                }
168            }
169        }
170    }
171
172    impl Drop for Parser {
173        fn drop(&mut self) {
174            // SAFETY: the box holds an initialized parser (see `new`).
175            // `yaml_parser_t` is a plain C struct (no destructor to run);
176            // the delete call frees its internal buffers and the box frees
177            // the storage.
178            unsafe {
179                sys::yaml_parser_delete(self.raw.as_mut_ptr());
180            }
181        }
182    }
183
184    /// SAFETY: `ptr` is null or a valid C string.
185    unsafe fn cstring(ptr: *const u8) -> Option<String> {
186        unsafe {
187            if ptr.is_null() {
188                return None;
189            }
190            let mut length = 0usize;
191            while *ptr.add(length) != 0 {
192                length += 1;
193            }
194            let bytes = std::slice::from_raw_parts(ptr, length);
195            Some(String::from_utf8_lossy(bytes).into_owned())
196        }
197    }
198
199    /// SAFETY: `event` was produced by a successful `yaml_parser_parse`
200    /// call and has not been deleted yet.
201    unsafe fn convert_event(event: &sys::yaml_event_t) -> Event {
202        let mark = event.start_mark;
203        let base = |kind| Event {
204            kind,
205            anchor: None,
206            tag: None,
207            value: String::new(),
208            style: ScalarStyle::Plain,
209            line: mark.line,
210            column: mark.column,
211        };
212        // SAFETY: reading the union member that matches `type_` is the
213        // contract of `yaml_event_t`; scalar buffers are `length` bytes.
214        unsafe {
215            match event.type_ {
216                sys::YAML_STREAM_START_EVENT => base(EventKind::StreamStart),
217                sys::YAML_STREAM_END_EVENT => base(EventKind::StreamEnd),
218                sys::YAML_DOCUMENT_START_EVENT => base(EventKind::DocumentStart),
219                sys::YAML_DOCUMENT_END_EVENT => base(EventKind::DocumentEnd),
220                sys::YAML_ALIAS_EVENT => Event {
221                    anchor: cstring(event.data.alias.anchor),
222                    ..base(EventKind::Alias)
223                },
224                sys::YAML_SCALAR_EVENT => {
225                    let length = event.data.scalar.length as usize;
226                    let bytes = if event.data.scalar.value.is_null() {
227                        &[][..]
228                    } else {
229                        std::slice::from_raw_parts(event.data.scalar.value, length)
230                    };
231                    Event {
232                        kind: EventKind::Scalar,
233                        anchor: cstring(event.data.scalar.anchor),
234                        tag: cstring(event.data.scalar.tag),
235                        value: String::from_utf8_lossy(bytes).into_owned(),
236                        style: match event.data.scalar.style {
237                            sys::YAML_PLAIN_SCALAR_STYLE => ScalarStyle::Plain,
238                            _ => ScalarStyle::Quoted,
239                        },
240                        line: mark.line,
241                        column: mark.column,
242                    }
243                }
244                sys::YAML_SEQUENCE_START_EVENT => Event {
245                    anchor: cstring(event.data.sequence_start.anchor),
246                    tag: cstring(event.data.sequence_start.tag),
247                    ..base(EventKind::SequenceStart)
248                },
249                sys::YAML_SEQUENCE_END_EVENT => base(EventKind::SequenceEnd),
250                sys::YAML_MAPPING_START_EVENT => Event {
251                    anchor: cstring(event.data.mapping_start.anchor),
252                    tag: cstring(event.data.mapping_start.tag),
253                    ..base(EventKind::MappingStart)
254                },
255                sys::YAML_MAPPING_END_EVENT => base(EventKind::MappingEnd),
256                _ => unreachable!("libyaml produced an event outside the parser state machine"),
257            }
258        }
259    }
260}
261
262// ---------------------------------------------------------------- parse ---
263
264/// Parse one YAML document into a [`Node`] tree, preserving `!!js`
265/// scalars as [`Node::Expr`]. An empty input yields [`Node::Null`].
266///
267/// # Errors
268///
269/// Syntax errors carry the parser's line and column; more than one
270/// document and unresolved aliases are rejected.
271pub fn parse_node(source: &str) -> Result<Node> {
272    let mut parser = sys::Parser::new(source.as_bytes());
273    let mut anchors: Vec<(String, Node)> = Vec::new();
274    let mut stack: Vec<Frame> = Vec::new();
275    let mut root: Option<Node> = None;
276    let mut documents = 0usize;
277
278    loop {
279        let event = parser.next().map_err(|error| {
280            yaml_error(format!(
281                "{} at line {} column {}",
282                error.problem,
283                error.line + 1,
284                error.column + 1
285            ))
286        })?;
287        match event.kind {
288            sys::EventKind::StreamStart | sys::EventKind::DocumentEnd => {}
289            sys::EventKind::DocumentStart => {
290                documents += 1;
291                if documents > 1 {
292                    return Err(yaml_error(
293                        "deserializing from YAML containing more than one document is not supported",
294                    ));
295                }
296            }
297            sys::EventKind::StreamEnd => break,
298            sys::EventKind::Scalar => {
299                // A scalar in key position becomes its raw text — the
300                // serde path's leniency for plain non-string keys.
301                if let Some(Frame::Map {
302                    key: slot @ None, ..
303                }) = stack.last_mut()
304                {
305                    *slot = Some(event.value.clone());
306                } else {
307                    let node = resolve_scalar(&event)?;
308                    register_anchor(&mut anchors, &event.anchor, &node);
309                    feed(&mut stack, &mut root, node)?;
310                }
311            }
312            sys::EventKind::Alias => {
313                let Some(anchor) = &event.anchor else {
314                    return Err(yaml_error("alias without an anchor"));
315                };
316                let Some(node) = anchors
317                    .iter()
318                    .rev()
319                    .find(|(name, _)| name == anchor)
320                    .map(|(_, node)| node.clone())
321                else {
322                    return Err(yaml_error(format!(
323                        "unknown anchor {anchor:?} at line {} column {}",
324                        event.line + 1,
325                        event.column + 1
326                    )));
327                };
328                feed(&mut stack, &mut root, node)?;
329            }
330            sys::EventKind::SequenceStart => {
331                reject_local_tag(&event)?;
332                stack.push(Frame::Seq {
333                    items: Vec::new(),
334                    anchor: event.anchor,
335                });
336            }
337            sys::EventKind::SequenceEnd => {
338                let Some(Frame::Seq { items, anchor }) = stack.pop() else {
339                    return Err(yaml_error("unbalanced sequence end"));
340                };
341                let node = Node::Array(items);
342                register_anchor(&mut anchors, &anchor, &node);
343                feed(&mut stack, &mut root, node)?;
344            }
345            sys::EventKind::MappingStart => {
346                reject_local_tag(&event)?;
347                stack.push(Frame::Map {
348                    map: NodeMap::new(),
349                    key: None,
350                    anchor: event.anchor,
351                });
352            }
353            sys::EventKind::MappingEnd => {
354                let Some(Frame::Map {
355                    map,
356                    key: None,
357                    anchor,
358                }) = stack.pop()
359                else {
360                    return Err(yaml_error("unbalanced mapping end"));
361                };
362                let node = Node::Object(map);
363                register_anchor(&mut anchors, &anchor, &node);
364                feed(&mut stack, &mut root, node)?;
365            }
366        }
367    }
368    Ok(root.unwrap_or(Node::Null))
369}
370
371/// One open container on the parse stack.
372enum Frame {
373    Seq {
374        items: Vec<Node>,
375        anchor: Option<String>,
376    },
377    Map {
378        map: NodeMap,
379        key: Option<String>,
380        anchor: Option<String>,
381    },
382}
383
384fn register_anchor(anchors: &mut Vec<(String, Node)>, anchor: &Option<String>, node: &Node) {
385    if let Some(anchor) = anchor {
386        anchors.push((anchor.clone(), node.clone()));
387    }
388}
389
390/// Hand one completed node to the enclosing container (or the root).
391fn feed(stack: &mut [Frame], root: &mut Option<Node>, node: Node) -> Result<()> {
392    match stack.last_mut() {
393        None => {
394            if root.is_some() {
395                return Err(yaml_error("multiple root values in one document"));
396            }
397            *root = Some(node);
398        }
399        Some(Frame::Seq { items, .. }) => items.push(node),
400        Some(Frame::Map { map, key, .. }) => match key.take() {
401            None => {
402                return Err(yaml_error(format!(
403                    "mapping keys must be scalars, found {}",
404                    node_kind(&node)
405                )));
406            }
407            Some(name) => {
408                map.insert(name, node);
409            }
410        },
411    }
412    Ok(())
413}
414
415/// Local tags on containers are serde enum syntax as well.
416fn reject_local_tag(event: &sys::Event) -> Result<()> {
417    if event.tag.as_deref().is_some_and(|tag| tag.starts_with('!')) {
418        return Err(yaml_error(format!(
419            "local tags are not supported: {}",
420            event.tag.as_deref().unwrap_or_default()
421        )));
422    }
423    Ok(())
424}
425
426/// Resolve one scalar event into a [`Node`], mirroring the serde path's
427/// rules except that `!!js` becomes [`Node::Expr`].
428fn resolve_scalar(event: &sys::Event) -> Result<Node> {
429    let value = &event.value;
430    if let Some(tag) = &event.tag {
431        if tag == JS_TAG {
432            return Ok(Node::Expr(value.clone()));
433        }
434        if let Some(core) = core_tag(tag) {
435            return match core {
436                "bool" => parse_bool(value)
437                    .map(Node::Bool)
438                    .ok_or_else(|| yaml_error(format!("invalid boolean {value:?}"))),
439                "int" => {
440                    try_int(value)?.ok_or_else(|| yaml_error(format!("invalid integer {value:?}")))
441                }
442                "float" => parse_f64(value)
443                    .map(Node::Float)
444                    .ok_or_else(|| yaml_error(format!("invalid float {value:?}"))),
445                "null" => parse_null(value)
446                    .map(|()| Node::Null)
447                    .ok_or_else(|| yaml_error(format!("invalid null {value:?}"))),
448                "str" => Ok(Node::String(value.clone())),
449                _ => unreachable!("core_tag filters its output"),
450            };
451        }
452        if tag.starts_with('!') {
453            // Local tags are serde's enum syntax; the typed paths rejected
454            // them, and so does the dialect.
455            return Err(yaml_error(format!("local tags are not supported: {tag}")));
456        }
457        // Other non-core global tags (or any tag on a quoted scalar): the
458        // serde path dropped these to strings; keep that.
459        return Ok(Node::String(value.clone()));
460    }
461    if matches!(event.style, sys::ScalarStyle::Plain) {
462        resolve_plain(value)
463    } else {
464        Ok(Node::String(value.clone()))
465    }
466}
467
468/// Resolve an untagged plain scalar by content — the exact rules the serde
469/// path applied, kept byte-for-byte so files parsed before the switch
470/// compose identically after it.
471fn resolve_plain(value: &str) -> Result<Node> {
472    if value.is_empty() || parse_null(value).is_some() {
473        return Ok(Node::Null);
474    }
475    if let Some(boolean) = parse_bool(value) {
476        return Ok(Node::Bool(boolean));
477    }
478    if let Some(node) = try_int(value)? {
479        return Ok(node);
480    }
481    if !digits_but_not_number(value) {
482        if let Some(float) = parse_f64(value) {
483            return Ok(Node::Float(float));
484        }
485    }
486    Ok(Node::String(value.to_owned()))
487}
488
489/// Unsigned integers within `i64` range become [`Node::Int`]; only values
490/// above it stay [`Node::UInt`].
491fn small_uint(value: u64) -> Node {
492    if value <= i64::MAX as u64 {
493        Node::Int(value as i64)
494    } else {
495        Node::UInt(value)
496    }
497}
498
499/// Leading-zero digit runs are strings per YAML 1.2, not numbers.
500fn digits_but_not_number(scalar: &str) -> bool {
501    let scalar = scalar.strip_prefix(['-', '+']).unwrap_or(scalar);
502    scalar.len() > 1 && scalar.starts_with('0') && scalar[1..].bytes().all(|b| b.is_ascii_digit())
503}
504
505/// The unsigned integer forms: optional `+`, `0x`/`0o`/`0b` prefixes
506/// without a sign, or plain decimal.
507fn parse_unsigned(scalar: &str, radix_skip: fn(&str, u32) -> Option<u64>) -> Option<u64> {
508    let unpositive = scalar.strip_prefix('+').unwrap_or(scalar);
509    if let Some(rest) = unpositive.strip_prefix("0x") {
510        if !rest.starts_with(['+', '-']) {
511            if let Some(int) = radix_skip(rest, 16) {
512                return Some(int);
513            }
514        }
515    }
516    if let Some(rest) = unpositive.strip_prefix("0o") {
517        if !rest.starts_with(['+', '-']) {
518            if let Some(int) = radix_skip(rest, 8) {
519                return Some(int);
520            }
521        }
522    }
523    if let Some(rest) = unpositive.strip_prefix("0b") {
524        if !rest.starts_with(['+', '-']) {
525            if let Some(int) = radix_skip(rest, 2) {
526                return Some(int);
527            }
528        }
529    }
530    if unpositive.starts_with(['+', '-']) {
531        return None;
532    }
533    if digits_but_not_number(scalar) {
534        return None;
535    }
536    radix_skip(unpositive, 10)
537}
538
539/// The negative integer forms: `-0x`/`-0o`/`-0b` and plain negative
540/// decimal; plain positive decimals also parse (callers reach this only
541/// after the unsigned forms failed).
542fn parse_negative(scalar: &str, radix_skip: fn(&str, u32) -> Option<i64>) -> Option<i64> {
543    for prefix in ["-0x", "-0o", "-0b"] {
544        if let Some(rest) = scalar.strip_prefix(prefix) {
545            let radix = match prefix {
546                "-0x" => 16,
547                "-0o" => 8,
548                _ => 2,
549            };
550            if let Some(int) = radix_skip(rest, radix) {
551                return Some(-int);
552            }
553        }
554    }
555    if digits_but_not_number(scalar) {
556        return None;
557    }
558    radix_skip(scalar, 10)
559}
560
561fn u64_radix(text: &str, radix: u32) -> Option<u64> {
562    u64::from_str_radix(text, radix).ok()
563}
564
565fn i64_radix(text: &str, radix: u32) -> Option<i64> {
566    i64::from_str_radix(text, radix).ok()
567}
568
569/// Parse an integer scalar into a node, or `None` when it is not one.
570/// Integers that parse but fall outside `i64`/`u64` range are an error —
571/// the tree has no slot for them (matching the serde path).
572fn try_int(scalar: &str) -> Result<Option<Node>> {
573    if let Some(unsigned) = parse_unsigned(scalar, u64_radix) {
574        return Ok(Some(small_uint(unsigned)));
575    }
576    if let Some(signed) = parse_negative(scalar, i64_radix) {
577        return Ok(Some(Node::Int(signed)));
578    }
579    // Values that parse as wider integers overflow the tree's number slots.
580    if parse_unsigned(scalar, |text, radix| {
581        u128::from_str_radix(text, radix).ok().map(|_| 0)
582    })
583    .is_some()
584        || parse_negative(scalar, |text, radix| {
585            i128::from_str_radix(text, radix).ok().map(|_| 0)
586        })
587        .is_some()
588    {
589        return Err(yaml_error(format!("integer out of range: {scalar:?}")));
590    }
591    Ok(None)
592}
593
594fn parse_null(scalar: &str) -> Option<()> {
595    match scalar {
596        "null" | "Null" | "NULL" | "~" => Some(()),
597        _ => None,
598    }
599}
600
601fn parse_bool(scalar: &str) -> Option<bool> {
602    match scalar {
603        "true" | "True" | "TRUE" => Some(true),
604        "false" | "False" | "FALSE" => Some(false),
605        _ => None,
606    }
607}
608
609fn parse_f64(scalar: &str) -> Option<f64> {
610    let unpositive = if let Some(unpositive) = scalar.strip_prefix('+') {
611        if unpositive.starts_with(['+', '-']) {
612            return None;
613        }
614        unpositive
615    } else {
616        scalar
617    };
618    if let ".inf" | ".Inf" | ".INF" = unpositive {
619        return Some(f64::INFINITY);
620    }
621    if let "-.inf" | "-.Inf" | "-.INF" = scalar {
622        return Some(f64::NEG_INFINITY);
623    }
624    if let ".nan" | ".NaN" | ".NAN" = scalar {
625        return Some(f64::NAN.copysign(1.0));
626    }
627    if let Ok(float) = unpositive.parse::<f64>() {
628        if float.is_finite() {
629            return Some(float);
630        }
631    }
632    None
633}
634
635/// A human name for a node kind, for conversion diagnostics.
636fn node_kind(node: &Node) -> &'static str {
637    match node {
638        Node::Null => "null",
639        Node::Bool(_) => "a boolean",
640        Node::Int(_) | Node::UInt(_) => "an integer",
641        Node::Float(_) => "a float",
642        Node::String(_) => "a string",
643        Node::Expr(_) => "a !!js expression",
644        Node::Array(_) => "a sequence",
645        Node::Object(_) => "a mapping",
646    }
647}
648
649// ----------------------------------------------------------- converters ---
650
651/// Parse one YAML document into a [`Document`]: an `entries` list plus
652/// unknown top-level keys, null documents yielding the default.
653pub fn parse_document(source: &str) -> Result<Document> {
654    document_from_node(parse_node(source)?)
655}
656
657/// Parse one YAML document into a top-level entry list (the include/patch
658/// file shape).
659pub fn parse_entry_list(source: &str) -> Result<Vec<EntryOptions>> {
660    entry_list_from_node(parse_node(source)?)
661}
662
663/// Convert a parsed tree into a [`Document`].
664pub fn document_from_node(node: Node) -> Result<Document> {
665    let Node::Object(map) = node else {
666        return Err(yaml_error(format!(
667            "expected a mapping with an `entries` list, found {}",
668            node_kind(&node)
669        )));
670    };
671    let mut document = Document::default();
672    for (key, value) in map {
673        if key == "entries" {
674            // A null entries list is the field's default (an empty file
675            // tail like `entries:`), matching the previous leniency.
676            document.entries = match value {
677                Node::Null => Vec::new(),
678                other => {
679                    entry_list_from_node(other).map_err(|error| prepend(error, "entries: "))?
680                }
681            };
682        } else {
683            document.extra.insert(key, value);
684        }
685    }
686    Ok(document)
687}
688
689/// Convert a parsed tree into an entry list.
690pub fn entry_list_from_node(node: Node) -> Result<Vec<EntryOptions>> {
691    let Node::Array(items) = node else {
692        return Err(yaml_error(format!(
693            "expected a sequence of entries, found {}",
694            node_kind(&node)
695        )));
696    };
697    items
698        .into_iter()
699        .enumerate()
700        .map(|(index, node)| {
701            entry_from_node(node).map_err(|error| prepend(error, &format!("entry {}: ", index + 1)))
702        })
703        .collect()
704}
705
706/// Convert a parsed tree into a patch list.
707pub fn patch_list_from_node(node: Node) -> Result<Vec<PatchOptions>> {
708    let Node::Array(items) = node else {
709        return Err(yaml_error(format!(
710            "expected a sequence of patch entries, found {}",
711            node_kind(&node)
712        )));
713    };
714    items
715        .into_iter()
716        .enumerate()
717        .map(|(index, node)| {
718            patch_from_node(node).map_err(|error| prepend(error, &format!("patch {}: ", index + 1)))
719        })
720        .collect()
721}
722
723fn prepend(error: IncludeError, context: &str) -> IncludeError {
724    let message = match &error {
725        IncludeError::Parse { source, .. } => source.to_string(),
726        other => other.to_string(),
727    };
728    yaml_error(format!("{context}{message}"))
729}
730
731/// Convert one entry mapping.
732fn entry_from_node(node: Node) -> Result<EntryOptions> {
733    let Node::Object(map) = node else {
734        return Err(yaml_error(format!(
735            "expected a mapping (an entry), found {}",
736            node_kind(&node)
737        )));
738    };
739    let mut entry = EntryOptions::default();
740    for (key, value) in map {
741        match key.as_str() {
742            "id" => entry.id = optional_string(value, "id")?,
743            "name" => {
744                entry.name = match value {
745                    // A null name is the field's default (`name:` tails).
746                    Node::Null => String::new(),
747                    other => string_value(other, "name")?,
748                };
749            }
750            "disabled" => {
751                entry.disabled = match value {
752                    Node::Bool(flag) => flag,
753                    Node::Expr(_) => {
754                        return Err(yaml_error(
755                            "disabled: !!js expressions are not supported yet",
756                        ));
757                    }
758                    other => {
759                        return Err(yaml_error(format!(
760                            "disabled: expected a boolean, found {}",
761                            node_kind(&other)
762                        )));
763                    }
764                };
765            }
766            // Null defaults for list fields: `inject:` / `group:` tails.
767            "inject" => {
768                entry.inject = match value {
769                    Node::Null => Vec::new(),
770                    other => string_list(other, "inject")?,
771                };
772            }
773            "group" => {
774                entry.group = match value {
775                    Node::Null => Vec::new(),
776                    other => {
777                        entry_list_from_node(other).map_err(|error| prepend(error, "group: "))?
778                    }
779                };
780            }
781            "config" => entry.config = optional_config(value),
782            // Unknown entry keys are dropped, matching the serde path.
783            _ => {}
784        }
785    }
786    Ok(entry)
787}
788
789/// Convert one patch mapping; every key without a typed slot lands in
790/// `extra`, matching serde's flatten.
791pub(crate) fn patch_from_node(node: Node) -> Result<PatchOptions> {
792    let Node::Object(map) = node else {
793        return Err(yaml_error(format!(
794            "expected a mapping (a loader patch entry), found {}",
795            node_kind(&node)
796        )));
797    };
798    let mut patch = PatchOptions::default();
799    for (key, value) in map {
800        match key.as_str() {
801            "id" => patch.id = optional_string(value, "id")?,
802            "insert" => {
803                patch.insert = match value {
804                    Node::Null => None,
805                    Node::Array(_) => Some(
806                        entry_list_from_node(value).map_err(|error| prepend(error, "insert: "))?,
807                    ),
808                    other => {
809                        return Err(yaml_error(format!(
810                            "insert: expected a sequence of entries, found {}",
811                            node_kind(&other)
812                        )));
813                    }
814                };
815            }
816            "name" => patch.name = optional_string(value, "name")?,
817            "config" => patch.config = optional_config(value),
818            "disabled" => {
819                patch.disabled = match value {
820                    Node::Null => None,
821                    Node::Bool(flag) => Some(flag),
822                    Node::Expr(_) => {
823                        return Err(yaml_error(
824                            "disabled: !!js expressions are not supported yet",
825                        ));
826                    }
827                    other => {
828                        return Err(yaml_error(format!(
829                            "disabled: expected a boolean, found {}",
830                            node_kind(&other)
831                        )));
832                    }
833                };
834            }
835            "inject" => {
836                patch.inject = match value {
837                    Node::Null => None,
838                    Node::Array(_) => Some(string_list(value, "inject")?),
839                    other => {
840                        return Err(yaml_error(format!(
841                            "inject: expected a list of strings, found {}",
842                            node_kind(&other)
843                        )));
844                    }
845                };
846            }
847            other => {
848                patch.extra.insert(other.to_owned(), value);
849            }
850        }
851    }
852    Ok(patch)
853}
854
855fn optional_string(node: Node, field: &str) -> Result<Option<String>> {
856    match node {
857        Node::Null => Ok(None),
858        Node::String(value) => Ok(Some(value)),
859        other => Err(yaml_error(format!(
860            "{field}: expected a string, found {}",
861            node_kind(&other)
862        ))),
863    }
864}
865
866fn string_value(node: Node, field: &str) -> Result<String> {
867    match node {
868        Node::String(value) => Ok(value),
869        other => Err(yaml_error(format!(
870            "{field}: expected a string, found {}",
871            node_kind(&other)
872        ))),
873    }
874}
875
876fn string_list(node: Node, field: &str) -> Result<Vec<String>> {
877    let Node::Array(items) = node else {
878        return Err(yaml_error(format!(
879            "{field}: expected a list of strings, found {}",
880            node_kind(&node)
881        )));
882    };
883    items
884        .into_iter()
885        .enumerate()
886        .map(|(index, item)| {
887            string_value(item, &format!("{field}[{}]", index))
888                .map_err(|error| prepend(error, &format!("{field}: entry {}: ", index + 1)))
889        })
890        .collect()
891}
892
893/// Config is any node; a null config means "absent".
894fn optional_config(node: Node) -> Option<Node> {
895    match node {
896        Node::Null => None,
897        other => Some(other),
898    }
899}
900
901// ---------------------------------------------------------------- emit ---
902
903/// Render a document as YAML in the crate's stable layout: `entries`
904/// first (omitted when empty), then unknown top-level keys.
905pub fn emit_document(document: &Document) -> String {
906    let mut map = NodeMap::new();
907    if !document.entries.is_empty() {
908        map.insert("entries".to_owned(), entries_to_node(&document.entries));
909    }
910    for (key, value) in &document.extra {
911        map.insert(key.clone(), value.clone());
912    }
913    let mut out = String::new();
914    if map.is_empty() {
915        out.push_str("{}\n");
916    } else {
917        emit_map(&map, 0, "", &mut out);
918    }
919    out
920}
921
922/// Render a top-level entry list as YAML (the include/patch file shape).
923pub fn emit_entry_list(entries: &[EntryOptions]) -> String {
924    let mut out = String::new();
925    if entries.is_empty() {
926        out.push_str("[]\n");
927    } else {
928        emit_node(&entries_to_node(entries), 0, "", &mut out);
929    }
930    out
931}
932
933/// Entries as a node tree, in the crate's stable field order
934/// (`id`, `name`, `disabled`, `inject`, `group`, `config`) with default
935/// fields omitted — the same shape the serde writer produced.
936fn entries_to_node(entries: &[EntryOptions]) -> Node {
937    Node::Array(entries.iter().map(entry_to_node).collect())
938}
939
940fn entry_to_node(entry: &EntryOptions) -> Node {
941    let mut map = NodeMap::new();
942    if let Some(id) = &entry.id {
943        map.insert("id".to_owned(), Node::String(id.clone()));
944    }
945    map.insert("name".to_owned(), Node::String(entry.name.clone()));
946    if entry.disabled {
947        map.insert("disabled".to_owned(), Node::Bool(true));
948    }
949    if !entry.inject.is_empty() {
950        map.insert(
951            "inject".to_owned(),
952            Node::Array(
953                entry
954                    .inject
955                    .iter()
956                    .map(|name| Node::String(name.clone()))
957                    .collect(),
958            ),
959        );
960    }
961    if !entry.group.is_empty() {
962        map.insert("group".to_owned(), entries_to_node(&entry.group));
963    }
964    if let Some(config) = &entry.config {
965        map.insert("config".to_owned(), config.clone());
966    }
967    Node::Object(map)
968}
969
970fn spaces(indent: usize) -> String {
971    " ".repeat(indent)
972}
973
974/// Emit one node at `indent`; the first line starts with `first_lead`
975/// (spaces, or `"- "` when the node opens a sequence item).
976fn emit_node(node: &Node, indent: usize, first_lead: &str, out: &mut String) {
977    match node {
978        Node::Array(items) => emit_seq(items, indent, first_lead, out),
979        Node::Object(map) => emit_map(map, indent, first_lead, out),
980        scalar => {
981            out.push_str(first_lead);
982            out.push_str(&scalar_text(scalar));
983            out.push('\n');
984        }
985    }
986}
987
988fn emit_map(map: &NodeMap, indent: usize, first_lead: &str, out: &mut String) {
989    let indented = spaces(indent);
990    for (position, (key, value)) in map.iter().enumerate() {
991        let lead: &str = if position == 0 { first_lead } else { &indented };
992        let key_text = scalar_text(&Node::String(key.clone()));
993        match value {
994            Node::Array(items) if items.is_empty() => {
995                out.push_str(&format!("{lead}{key_text}: []\n"));
996            }
997            Node::Object(inner) if inner.is_empty() => {
998                out.push_str(&format!("{lead}{key_text}: {{}}\n"));
999            }
1000            Node::Object(inner) => {
1001                out.push_str(&format!("{lead}{key_text}:\n"));
1002                emit_map(inner, indent + 2, &spaces(indent + 2), out);
1003            }
1004            // Block sequences under a map key start at the key's own
1005            // indent — the layout libyaml produces.
1006            Node::Array(items) => {
1007                out.push_str(&format!("{lead}{key_text}:\n"));
1008                emit_seq(items, indent, &spaces(indent), out);
1009            }
1010            scalar => {
1011                out.push_str(&format!("{lead}{key_text}: {}\n", scalar_text(scalar)));
1012            }
1013        }
1014    }
1015}
1016
1017fn emit_seq(items: &[Node], indent: usize, first_lead: &str, out: &mut String) {
1018    let indented = spaces(indent);
1019    for (position, item) in items.iter().enumerate() {
1020        let lead: &str = if position == 0 { first_lead } else { &indented };
1021        match item {
1022            Node::Object(map) if map.is_empty() => {
1023                out.push_str(&format!("{lead}- {{}}\n"));
1024            }
1025            Node::Object(map) => emit_map(map, indent + 2, &format!("{lead}- "), out),
1026            Node::Array(inner) if inner.is_empty() => {
1027                out.push_str(&format!("{lead}- []\n"));
1028            }
1029            Node::Array(inner) => {
1030                out.push_str(&format!("{lead}-\n"));
1031                emit_seq(inner, indent + 2, &spaces(indent + 2), out);
1032            }
1033            scalar => {
1034                out.push_str(&format!("{lead}- {}\n", scalar_text(scalar)));
1035            }
1036        }
1037    }
1038}
1039
1040/// The YAML text for one scalar node: `!!js <expr>` for expressions, plain
1041/// when safe, single-quoted for strings needing quoting, double-quoted for
1042/// control characters.
1043fn scalar_text(node: &Node) -> String {
1044    match node {
1045        Node::Null => "null".to_owned(),
1046        Node::Bool(value) => value.to_string(),
1047        Node::Int(value) => value.to_string(),
1048        Node::UInt(value) => value.to_string(),
1049        Node::Float(value) => float_text(*value),
1050        Node::String(value) => quote_scalar(value),
1051        Node::Expr(value) => format!("!!js {}", quote_scalar(value)),
1052        Node::Array(_) | Node::Object(_) => unreachable!("scalars only"),
1053    }
1054}
1055
1056/// Float rendering matching the previous writer: shortest round-trip
1057/// form, `.0` on integral values, YAML's `.inf`/`.nan` spellings.
1058fn float_text(value: f64) -> String {
1059    if value.is_nan() {
1060        ".nan".to_owned()
1061    } else if value == f64::INFINITY {
1062        ".inf".to_owned()
1063    } else if value == f64::NEG_INFINITY {
1064        "-.inf".to_owned()
1065    } else {
1066        let mut buffer = ryu::Buffer::new();
1067        buffer.format_finite(value).to_owned()
1068    }
1069}
1070
1071/// How a string scalar must be quoted.
1072enum Quote {
1073    Plain,
1074    Single,
1075    Double,
1076}
1077
1078fn classify(text: &str) -> Quote {
1079    if text.is_empty() {
1080        return Quote::Single;
1081    }
1082    if text.chars().any(|c| (c as u32) < 0x20 || c as u32 == 0x7f) {
1083        return Quote::Double;
1084    }
1085    let first = text.chars().next().expect("non-empty");
1086    if "#%,[]{}&*!|>'\"`@".contains(first) {
1087        return Quote::Single;
1088    }
1089    if (first == '-' || first == '?') && text.chars().nth(1).is_none_or(|next| next == ' ') {
1090        return Quote::Single;
1091    }
1092    if text == "---" || text == "..." {
1093        return Quote::Single;
1094    }
1095    if text.ends_with(':')
1096        || text.contains(": ")
1097        || text.contains(" #")
1098        || text.starts_with(' ')
1099        || text.ends_with(' ')
1100    {
1101        return Quote::Single;
1102    }
1103    // A string that would resolve as null, bool, number, or float must be
1104    // quoted to stay a string.
1105    if parse_null(text).is_some()
1106        || parse_bool(text).is_some()
1107        || parse_f64(text).is_some()
1108        || matches!(try_int(text), Ok(Some(_)))
1109    {
1110        return Quote::Single;
1111    }
1112    Quote::Plain
1113}
1114
1115fn quote_scalar(text: &str) -> String {
1116    match classify(text) {
1117        Quote::Plain => text.to_owned(),
1118        Quote::Single => format!("'{}'", text.replace('\'', "''")),
1119        Quote::Double => double_quote(text),
1120    }
1121}
1122
1123fn double_quote(text: &str) -> String {
1124    let mut out = String::with_capacity(text.len() + 2);
1125    out.push('"');
1126    for character in text.chars() {
1127        match character {
1128            '"' => out.push_str("\\\""),
1129            '\\' => out.push_str("\\\\"),
1130            '\n' => out.push_str("\\n"),
1131            '\r' => out.push_str("\\r"),
1132            '\t' => out.push_str("\\t"),
1133            '\0' => out.push_str("\\0"),
1134            other if (other as u32) < 0x20 || other as u32 == 0x7f => {
1135                out.push_str(&format!("\\x{:02x}", other as u32));
1136            }
1137            other => out.push(other),
1138        }
1139    }
1140    out.push('"');
1141    out
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147
1148    /// Parse through the dialect and through the serde oracle; both must
1149    /// agree on the tree (compared by Debug text: NaN ≠ NaN under PartialEq).
1150    fn parity(source: &str) {
1151        let mine = parse_node(source).expect("dialect parse");
1152        let oracle: Node = serde_yaml_ng::from_str(source).expect("oracle parse");
1153        assert_eq!(
1154            format!("{mine:?}"),
1155            format!("{oracle:?}"),
1156            "dialect and serde disagree on {source:?}"
1157        );
1158    }
1159
1160    #[test]
1161    fn scalar_resolution_matches_the_serde_oracle() {
1162        for value in [
1163            "null",
1164            "Null",
1165            "NULL",
1166            "~",
1167            "true",
1168            "True",
1169            "TRUE",
1170            "false",
1171            "False",
1172            "FALSE",
1173            "0",
1174            "-0",
1175            "42",
1176            "+5",
1177            "-17",
1178            "9223372036854775807",
1179            "9223372036854775808",
1180            "18446744073709551615",
1181            "0x1A",
1182            "0o17",
1183            "0b101",
1184            "-0x10",
1185            "007",
1186            "1_000",
1187            "1.5",
1188            "-0.0",
1189            "1e300",
1190            ".inf",
1191            "-.inf",
1192            ".nan",
1193            "yes",
1194            "No",
1195            "text",
1196            "http://x",
1197            "a b",
1198            "${{ env.X }}",
1199        ] {
1200            parity(&format!("key: {value}\n"));
1201            parity(&format!("- {value}\n"));
1202        }
1203        for value in ["", "0", "true", "42", " x "] {
1204            parity(&format!("key: '{value}'\n"));
1205            parity(&format!("key: \"{value}\"\n"));
1206        }
1207        parity("key:\n");
1208        parity("key: |\n  line1\n  line2\n");
1209        parity("key: >\n  folded text\n");
1210        parity("");
1211        parity("---\n");
1212    }
1213
1214    #[test]
1215    fn structural_shapes_match_the_serde_oracle() {
1216        parity("[]\n");
1217        parity("{}\n");
1218        parity("- 1\n- a\n- [1, 2]\n- {x: 1}\n");
1219        parity("a:\n  b:\n    c: 1\n");
1220        parity("a: &anchor 1\nb: *anchor\n");
1221        parity("base: &b\n  x: 1\nover: *b\n");
1222        parity("list:\n- 1\n- 2\n");
1223        parity("- id: a\n  name: n\n  group:\n  - id: c\n");
1224        // Plain non-string keys coerce to their text, like the serde path.
1225        parity("m:\n  1: value\n");
1226    }
1227
1228    #[test]
1229    fn core_tags_match_the_serde_oracle() {
1230        parity("a: !!str 5\n");
1231        parity("a: !!bool 'true'\n");
1232        parity("a: !!int 0x1f\n");
1233        parity("a: !!float 5\n");
1234        parity("a: !!null ~\n");
1235        parity("a: !!python/object 'x'\n");
1236    }
1237
1238    /// Local `!tags` are serde enum syntax — the oracle rejects them for
1239    /// `Node`, and so does the dialect (with a clearer message).
1240    #[test]
1241    fn local_tags_fail_like_the_serde_path() {
1242        for source in ["a: !local 5\n", "a: !local 'x'\n"] {
1243            assert!(serde_yaml_ng::from_str::<Node>(source).is_err(), "{source}");
1244            let error = parse_node(source).unwrap_err().to_string();
1245            assert!(error.contains("local tags are not supported"), "{error}");
1246        }
1247    }
1248
1249    /// Complex (non-scalar) mapping keys are errors on both paths.
1250    #[test]
1251    fn complex_mapping_keys_fail_like_the_serde_path() {
1252        let source = "? [a]\n: v\n";
1253        assert!(serde_yaml_ng::from_str::<Node>(source).is_err());
1254        let error = parse_node(source).unwrap_err().to_string();
1255        assert!(error.contains("mapping keys must be scalars"), "{error}");
1256    }
1257
1258    #[test]
1259    fn js_tag_becomes_an_expression_node() {
1260        let node = parse_node("- id: a\n  config:\n    model: !!js process.env.MODEL\n").unwrap();
1261        let entry = &node.as_array().unwrap()[0];
1262        let config = entry.as_object().unwrap()["config"].as_object().unwrap();
1263        assert_eq!(config["model"], Node::Expr("process.env.MODEL".to_owned()));
1264        // Quoted !!js scalars are expressions too (js-yaml resolves any
1265        // string scalar for the type).
1266        let node = parse_node("k: !!js 'quoted expression'\n").unwrap();
1267        assert_eq!(
1268            node.as_object().unwrap()["k"],
1269            Node::Expr("quoted expression".to_owned())
1270        );
1271    }
1272
1273    #[test]
1274    fn integer_overflow_fails_like_the_serde_path() {
1275        let error = parse_node("a: 18446744073709551616\n")
1276            .unwrap_err()
1277            .to_string();
1278        assert!(error.contains("integer out of range"), "{error}");
1279        // The same value through the oracle also fails.
1280        assert!(serde_yaml_ng::from_str::<Node>("a: 18446744073709551616\n").is_err());
1281    }
1282
1283    #[test]
1284    fn syntax_errors_carry_line_and_column() {
1285        // libyaml anchors unclosed-flow errors at the position where the
1286        // problem became unavoidable (here, the start of the line after).
1287        let error = parse_node("a: [unclosed\n").unwrap_err().to_string();
1288        assert!(error.contains("line 2"), "{error}");
1289        let error = parse_node("valid: 1\nbroken: [x\n")
1290            .unwrap_err()
1291            .to_string();
1292        assert!(error.contains("line 3"), "{error}");
1293    }
1294
1295    #[test]
1296    fn multiple_documents_and_unknown_anchors_fail() {
1297        let error = parse_node("---\na: 1\n---\nb: 2\n")
1298            .unwrap_err()
1299            .to_string();
1300        assert!(error.contains("more than one document"), "{error}");
1301        let error = parse_node("a: *missing\n").unwrap_err().to_string();
1302        assert!(error.contains("unknown anchor"), "{error}");
1303    }
1304
1305    #[test]
1306    fn converters_reject_wrong_shapes_with_field_context() {
1307        let error = parse_document("- id: a\n").unwrap_err().to_string();
1308        assert!(
1309            error.contains("expected a mapping with an `entries` list"),
1310            "{error}"
1311        );
1312        let error = parse_document("entries: 5\n").unwrap_err().to_string();
1313        assert!(error.contains("entries:"), "{error}");
1314        let error = parse_entry_list("entries:\n  - id: a\n")
1315            .unwrap_err()
1316            .to_string();
1317        assert!(error.contains("expected a sequence of entries"), "{error}");
1318        let error = parse_entry_list("- name: 5\n").unwrap_err().to_string();
1319        assert!(
1320            error.contains("entry 1: name: expected a string"),
1321            "{error}"
1322        );
1323        let error = parse_entry_list("- inject: [1]\n").unwrap_err().to_string();
1324        assert!(error.contains("inject"), "{error}");
1325        let error = parse_entry_list("- disabled: maybe\n")
1326            .unwrap_err()
1327            .to_string();
1328        assert!(error.contains("disabled: expected a boolean"), "{error}");
1329        let error = parse_entry_list("- disabled: !!js process.platform\n")
1330            .unwrap_err()
1331            .to_string();
1332        assert!(
1333            error.contains("disabled: !!js expressions are not supported yet"),
1334            "{error}"
1335        );
1336    }
1337
1338    #[test]
1339    fn converters_keep_unknown_entry_keys_dropped_and_patch_extras() {
1340        let entries = parse_entry_list("- id: a\n  name: n\n  mystery: value\n").unwrap();
1341        assert_eq!(entries.len(), 1);
1342        assert_eq!(entries[0].name, "n");
1343
1344        let patches = crate::yaml::patch_list_from_node(
1345            parse_node("- id: a\n  intercept: db\n  group: []\n").unwrap(),
1346        )
1347        .unwrap();
1348        assert_eq!(patches[0].extra.len(), 2);
1349        assert!(patches[0].extra.contains_key("intercept"));
1350        assert!(patches[0].extra.contains_key("group"));
1351
1352        let patches = crate::yaml::patch_list_from_node(
1353            parse_node("- insert: [{id: x, name: n}]\n").unwrap(),
1354        )
1355        .unwrap();
1356        assert_eq!(
1357            patches[0].insert.as_ref().unwrap()[0].id.as_deref(),
1358            Some("x")
1359        );
1360    }
1361
1362    #[test]
1363    fn document_round_trips_with_extras_in_order() {
1364        let document = parse_document(
1365            "entries:\n  - id: a\n    name: n\n    config:\n      x: 1\nmeta: kept\n",
1366        )
1367        .unwrap();
1368        assert_eq!(document.entries.len(), 1);
1369        assert_eq!(document.extra.len(), 1);
1370        let text = emit_document(&document);
1371        assert_eq!(parse_document(&text).unwrap(), document, "{text}");
1372        assert!(text.contains("meta: kept"), "{text}");
1373    }
1374
1375    #[test]
1376    fn emitter_matches_the_previous_writer_layout() {
1377        let entries = parse_entry_list(
1378            "- id: w1\n  name: worker\n  config: 8080\n- id: g\n  name: group\n  group:\n  - id: c1\n    name: adapter-http\n    config:\n      host: localhost\n      empty: ''\n      list:\n      - 1\n      - 2.5\n",
1379        )
1380        .unwrap();
1381        let text = emit_entry_list(&entries);
1382        let expected = "\
1383- id: w1
1384  name: worker
1385  config: 8080
1386- id: g
1387  name: group
1388  group:
1389  - id: c1
1390    name: adapter-http
1391    config:
1392      host: localhost
1393      empty: ''
1394      list:
1395      - 1
1396      - 2.5
1397";
1398        assert_eq!(text, expected);
1399        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1400    }
1401
1402    #[test]
1403    fn emitter_quotes_and_numbers_match_the_previous_writer() {
1404        for (text, expected) in [
1405            ("x: -foo\n", "x: -foo\n"),
1406            ("x: '#hash'\n", "x: '#hash'\n"),
1407            ("x: 'trailing:'\n", "x: 'trailing:'\n"),
1408            ("x: 'true'\n", "x: 'true'\n"),
1409            ("x: '0x1A'\n", "x: '0x1A'\n"),
1410            ("x: it's\n", "x: it's\n"),
1411            ("x: qu\"ote\n", "x: qu\"ote\n"),
1412            ("x: http://x\n", "x: http://x\n"),
1413            ("x: ${{ env.X }}\n", "x: ${{ env.X }}\n"),
1414            ("x: 0.5\n", "x: 0.5\n"),
1415            ("x: -0.0\n", "x: -0.0\n"),
1416            ("x: 1e300\n", "x: 1e300\n"),
1417            ("x: 8080\n", "x: 8080\n"),
1418        ] {
1419            let node = parse_node(text).unwrap();
1420            let emitted = emit_node_test(&node);
1421            assert_eq!(emitted, expected, "input {text:?}");
1422            assert_eq!(parse_node(&emitted).unwrap(), node, "round trip {text:?}");
1423        }
1424        // The empty document renders like the serde writer's.
1425        assert_eq!(emit_document(&Document::default()), "{}\n");
1426        // Empty containers stay inline; strings with control characters go
1427        // double-quoted and round-trip.
1428        let node = parse_node("a: []\nb: {}\nc: \"tab\\there\"\n").unwrap();
1429        let emitted = emit_node_test(&node);
1430        assert_eq!(emitted, "a: []\nb: {}\nc: \"tab\\there\"\n");
1431        assert_eq!(parse_node(&emitted).unwrap(), node);
1432    }
1433
1434    fn emit_node_test(node: &Node) -> String {
1435        let mut out = String::new();
1436        emit_node(node, 0, "", &mut out);
1437        out
1438    }
1439
1440    #[test]
1441    fn expressions_emit_and_round_trip() {
1442        let entries = parse_entry_list(
1443            "- id: a\n  name: n\n  config:\n    model: !!js process.env.MODEL || 'default'\n",
1444        )
1445        .unwrap();
1446        let text = emit_entry_list(&entries);
1447        assert!(
1448            text.contains("model: !!js process.env.MODEL || 'default'\n"),
1449            "{text}"
1450        );
1451        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1452    }
1453
1454    /// A bundle-shaped fixture: every field shape the shipped patch files
1455    /// use (`id`, `name`, `inject`, `config`, `!!js`), node-level
1456    /// round-tripped (the `disabled: !!js` form needs the typed slot the
1457    /// next stage adds, so it stays at node level for now).
1458    #[test]
1459    fn bundle_shaped_fixture_round_trips() {
1460        let fixture = "\
1461- id: base-sandbox
1462  name: '@dsh/base'
1463  inject: [database]
1464  config:
1465    level: !!js process.env.DSH_LOG_LEVEL || 'info'
1466    retries: 3
1467    nested:
1468      keep: true
1469- insert:
1470    - id: web-app
1471      name: '@dsh/web-app'
1472      config:
1473        theme: dark
1474        gate: !!js process.platform === 'darwin'
1475";
1476        let node = parse_node(fixture).unwrap();
1477        let emitted = {
1478            let mut out = String::new();
1479            emit_node(&node, 0, "", &mut out);
1480            out
1481        };
1482        assert_eq!(parse_node(&emitted).unwrap(), node, "{emitted}");
1483        assert!(
1484            emitted.contains("!!js process.platform === 'darwin'"),
1485            "{emitted}"
1486        );
1487
1488        let patches = crate::yaml::patch_list_from_node(parse_node(fixture).unwrap()).unwrap();
1489        assert_eq!(patches.len(), 2);
1490        assert_eq!(
1491            patches[1].insert.as_ref().unwrap()[0].id.as_deref(),
1492            Some("web-app")
1493        );
1494    }
1495
1496    #[test]
1497    fn anchors_resolve_into_shared_clones() {
1498        let node = parse_node("a: &x {v: 1}\nb: *x\n").unwrap();
1499        let map = node.as_object().unwrap();
1500        assert_eq!(map["a"], map["b"]);
1501    }
1502}