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::{Disabled, 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.
636pub(crate) fn 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) => Disabled::Flag(flag),
753                    // The raw text round-trips; evaluation happens at
754                    // activation (Entry::resolved_disabled).
755                    Node::Expr(raw) => Disabled::Expr(raw),
756                    other => {
757                        return Err(yaml_error(format!(
758                            "disabled: expected a boolean or a !!js expression, found {}",
759                            node_kind(&other)
760                        )));
761                    }
762                };
763            }
764            // Null defaults for list fields: `inject:` / `group:` tails.
765            "inject" => {
766                entry.inject = match value {
767                    Node::Null => Vec::new(),
768                    other => string_list(other, "inject")?,
769                };
770            }
771            "group" => {
772                entry.group = match value {
773                    Node::Null => Vec::new(),
774                    other => {
775                        entry_list_from_node(other).map_err(|error| prepend(error, "group: "))?
776                    }
777                };
778            }
779            "config" => entry.config = optional_config(value),
780            // Unknown entry keys are dropped, matching the serde path.
781            _ => {}
782        }
783    }
784    Ok(entry)
785}
786
787/// Convert one patch mapping; every key without a typed slot lands in
788/// `extra`, matching serde's flatten.
789pub(crate) fn patch_from_node(node: Node) -> Result<PatchOptions> {
790    let Node::Object(map) = node else {
791        return Err(yaml_error(format!(
792            "expected a mapping (a loader patch entry), found {}",
793            node_kind(&node)
794        )));
795    };
796    let mut patch = PatchOptions::default();
797    for (key, value) in map {
798        match key.as_str() {
799            "id" => patch.id = optional_string(value, "id")?,
800            "insert" => {
801                patch.insert = match value {
802                    Node::Null => None,
803                    Node::Array(_) => Some(
804                        entry_list_from_node(value).map_err(|error| prepend(error, "insert: "))?,
805                    ),
806                    other => {
807                        return Err(yaml_error(format!(
808                            "insert: expected a sequence of entries, found {}",
809                            node_kind(&other)
810                        )));
811                    }
812                };
813            }
814            "name" => patch.name = optional_string(value, "name")?,
815            "config" => patch.config = optional_config(value),
816            "disabled" => {
817                // The patch override needs an already-evaluated boolean:
818                // patches apply at composition time, before any activation
819                // could evaluate an expression. Entry rows carry the
820                // expression slot instead.
821                patch.disabled = match value {
822                    Node::Null => None,
823                    Node::Bool(flag) => Some(flag),
824                    Node::Expr(_) => {
825                        return Err(yaml_error(
826                            "disabled: !!js expressions are not supported in patches; put the \
827                             expression on the entry itself",
828                        ));
829                    }
830                    other => {
831                        return Err(yaml_error(format!(
832                            "disabled: expected a boolean, found {}",
833                            node_kind(&other)
834                        )));
835                    }
836                };
837            }
838            "inject" => {
839                patch.inject = match value {
840                    Node::Null => None,
841                    Node::Array(_) => Some(string_list(value, "inject")?),
842                    other => {
843                        return Err(yaml_error(format!(
844                            "inject: expected a list of strings, found {}",
845                            node_kind(&other)
846                        )));
847                    }
848                };
849            }
850            other => {
851                patch.extra.insert(other.to_owned(), value);
852            }
853        }
854    }
855    Ok(patch)
856}
857
858fn optional_string(node: Node, field: &str) -> Result<Option<String>> {
859    match node {
860        Node::Null => Ok(None),
861        Node::String(value) => Ok(Some(value)),
862        other => Err(yaml_error(format!(
863            "{field}: expected a string, found {}",
864            node_kind(&other)
865        ))),
866    }
867}
868
869fn string_value(node: Node, field: &str) -> Result<String> {
870    match node {
871        Node::String(value) => Ok(value),
872        other => Err(yaml_error(format!(
873            "{field}: expected a string, found {}",
874            node_kind(&other)
875        ))),
876    }
877}
878
879fn string_list(node: Node, field: &str) -> Result<Vec<String>> {
880    let Node::Array(items) = node else {
881        return Err(yaml_error(format!(
882            "{field}: expected a list of strings, found {}",
883            node_kind(&node)
884        )));
885    };
886    items
887        .into_iter()
888        .enumerate()
889        .map(|(index, item)| {
890            string_value(item, &format!("{field}[{}]", index))
891                .map_err(|error| prepend(error, &format!("{field}: entry {}: ", index + 1)))
892        })
893        .collect()
894}
895
896/// Config is any node; a null config means "absent".
897fn optional_config(node: Node) -> Option<Node> {
898    match node {
899        Node::Null => None,
900        other => Some(other),
901    }
902}
903
904// ---------------------------------------------------------------- emit ---
905
906/// Render a document as YAML in the crate's stable layout: `entries`
907/// first (omitted when empty), then unknown top-level keys.
908pub fn emit_document(document: &Document) -> String {
909    let mut map = NodeMap::new();
910    if !document.entries.is_empty() {
911        map.insert("entries".to_owned(), entries_to_node(&document.entries));
912    }
913    for (key, value) in &document.extra {
914        map.insert(key.clone(), value.clone());
915    }
916    let mut out = String::new();
917    if map.is_empty() {
918        out.push_str("{}\n");
919    } else {
920        emit_map(&map, 0, "", &mut out);
921    }
922    out
923}
924
925/// Render a top-level entry list as YAML (the include/patch file shape).
926pub fn emit_entry_list(entries: &[EntryOptions]) -> String {
927    let mut out = String::new();
928    if entries.is_empty() {
929        out.push_str("[]\n");
930    } else {
931        emit_node(&entries_to_node(entries), 0, "", &mut out);
932    }
933    out
934}
935
936/// Entries as a node tree, in the crate's stable field order
937/// (`id`, `name`, `disabled`, `inject`, `group`, `config`) with default
938/// fields omitted — the same shape the serde writer produced.
939fn entries_to_node(entries: &[EntryOptions]) -> Node {
940    Node::Array(entries.iter().map(entry_to_node).collect())
941}
942
943fn entry_to_node(entry: &EntryOptions) -> Node {
944    let mut map = NodeMap::new();
945    if let Some(id) = &entry.id {
946        map.insert("id".to_owned(), Node::String(id.clone()));
947    }
948    map.insert("name".to_owned(), Node::String(entry.name.clone()));
949    match &entry.disabled {
950        // The default flag is omitted; an expression writes its `!!js`
951        // form back for the next boot to re-evaluate.
952        Disabled::Flag(true) => {
953            map.insert("disabled".to_owned(), Node::Bool(true));
954        }
955        Disabled::Expr(raw) => {
956            map.insert("disabled".to_owned(), Node::Expr(raw.clone()));
957        }
958        Disabled::Flag(false) => {}
959    }
960    if !entry.inject.is_empty() {
961        map.insert(
962            "inject".to_owned(),
963            Node::Array(
964                entry
965                    .inject
966                    .iter()
967                    .map(|name| Node::String(name.clone()))
968                    .collect(),
969            ),
970        );
971    }
972    if !entry.group.is_empty() {
973        map.insert("group".to_owned(), entries_to_node(&entry.group));
974    }
975    if let Some(config) = &entry.config {
976        map.insert("config".to_owned(), config.clone());
977    }
978    Node::Object(map)
979}
980
981fn spaces(indent: usize) -> String {
982    " ".repeat(indent)
983}
984
985/// Emit one node at `indent`; the first line starts with `first_lead`
986/// (spaces, or `"- "` when the node opens a sequence item).
987fn emit_node(node: &Node, indent: usize, first_lead: &str, out: &mut String) {
988    match node {
989        Node::Array(items) => emit_seq(items, indent, first_lead, out),
990        Node::Object(map) => emit_map(map, indent, first_lead, out),
991        scalar => {
992            out.push_str(first_lead);
993            out.push_str(&scalar_text(scalar));
994            out.push('\n');
995        }
996    }
997}
998
999fn emit_map(map: &NodeMap, indent: usize, first_lead: &str, out: &mut String) {
1000    let indented = spaces(indent);
1001    for (position, (key, value)) in map.iter().enumerate() {
1002        let lead: &str = if position == 0 { first_lead } else { &indented };
1003        let key_text = scalar_text(&Node::String(key.clone()));
1004        match value {
1005            Node::Array(items) if items.is_empty() => {
1006                out.push_str(&format!("{lead}{key_text}: []\n"));
1007            }
1008            Node::Object(inner) if inner.is_empty() => {
1009                out.push_str(&format!("{lead}{key_text}: {{}}\n"));
1010            }
1011            Node::Object(inner) => {
1012                out.push_str(&format!("{lead}{key_text}:\n"));
1013                emit_map(inner, indent + 2, &spaces(indent + 2), out);
1014            }
1015            // Block sequences under a map key start at the key's own
1016            // indent — the layout libyaml produces.
1017            Node::Array(items) => {
1018                out.push_str(&format!("{lead}{key_text}:\n"));
1019                emit_seq(items, indent, &spaces(indent), out);
1020            }
1021            scalar => {
1022                out.push_str(&format!("{lead}{key_text}: {}\n", scalar_text(scalar)));
1023            }
1024        }
1025    }
1026}
1027
1028fn emit_seq(items: &[Node], indent: usize, first_lead: &str, out: &mut String) {
1029    let indented = spaces(indent);
1030    for (position, item) in items.iter().enumerate() {
1031        let lead: &str = if position == 0 { first_lead } else { &indented };
1032        match item {
1033            Node::Object(map) if map.is_empty() => {
1034                out.push_str(&format!("{lead}- {{}}\n"));
1035            }
1036            Node::Object(map) => emit_map(map, indent + 2, &format!("{lead}- "), out),
1037            Node::Array(inner) if inner.is_empty() => {
1038                out.push_str(&format!("{lead}- []\n"));
1039            }
1040            Node::Array(inner) => {
1041                out.push_str(&format!("{lead}-\n"));
1042                emit_seq(inner, indent + 2, &spaces(indent + 2), out);
1043            }
1044            scalar => {
1045                out.push_str(&format!("{lead}- {}\n", scalar_text(scalar)));
1046            }
1047        }
1048    }
1049}
1050
1051/// The YAML text for one scalar node: `!!js <expr>` for expressions, plain
1052/// when safe, single-quoted for strings needing quoting, double-quoted for
1053/// control characters.
1054fn scalar_text(node: &Node) -> String {
1055    match node {
1056        Node::Null => "null".to_owned(),
1057        Node::Bool(value) => value.to_string(),
1058        Node::Int(value) => value.to_string(),
1059        Node::UInt(value) => value.to_string(),
1060        Node::Float(value) => float_text(*value),
1061        Node::String(value) => quote_scalar(value),
1062        Node::Expr(value) => format!("!!js {}", quote_scalar(value)),
1063        Node::Array(_) | Node::Object(_) => unreachable!("scalars only"),
1064    }
1065}
1066
1067/// Float rendering matching the previous writer: shortest round-trip
1068/// form, `.0` on integral values, YAML's `.inf`/`.nan` spellings.
1069fn float_text(value: f64) -> String {
1070    if value.is_nan() {
1071        ".nan".to_owned()
1072    } else if value == f64::INFINITY {
1073        ".inf".to_owned()
1074    } else if value == f64::NEG_INFINITY {
1075        "-.inf".to_owned()
1076    } else {
1077        let mut buffer = ryu::Buffer::new();
1078        buffer.format_finite(value).to_owned()
1079    }
1080}
1081
1082/// How a string scalar must be quoted.
1083enum Quote {
1084    Plain,
1085    Single,
1086    Double,
1087}
1088
1089fn classify(text: &str) -> Quote {
1090    if text.is_empty() {
1091        return Quote::Single;
1092    }
1093    if text.chars().any(|c| (c as u32) < 0x20 || c as u32 == 0x7f) {
1094        return Quote::Double;
1095    }
1096    let first = text.chars().next().expect("non-empty");
1097    if "#%,[]{}&*!|>'\"`@".contains(first) {
1098        return Quote::Single;
1099    }
1100    if (first == '-' || first == '?') && text.chars().nth(1).is_none_or(|next| next == ' ') {
1101        return Quote::Single;
1102    }
1103    if text == "---" || text == "..." {
1104        return Quote::Single;
1105    }
1106    if text.ends_with(':')
1107        || text.contains(": ")
1108        || text.contains(" #")
1109        || text.starts_with(' ')
1110        || text.ends_with(' ')
1111    {
1112        return Quote::Single;
1113    }
1114    // A string that would resolve as null, bool, number, or float must be
1115    // quoted to stay a string.
1116    if parse_null(text).is_some()
1117        || parse_bool(text).is_some()
1118        || parse_f64(text).is_some()
1119        || matches!(try_int(text), Ok(Some(_)))
1120    {
1121        return Quote::Single;
1122    }
1123    Quote::Plain
1124}
1125
1126fn quote_scalar(text: &str) -> String {
1127    match classify(text) {
1128        Quote::Plain => text.to_owned(),
1129        Quote::Single => format!("'{}'", text.replace('\'', "''")),
1130        Quote::Double => double_quote(text),
1131    }
1132}
1133
1134fn double_quote(text: &str) -> String {
1135    let mut out = String::with_capacity(text.len() + 2);
1136    out.push('"');
1137    for character in text.chars() {
1138        match character {
1139            '"' => out.push_str("\\\""),
1140            '\\' => out.push_str("\\\\"),
1141            '\n' => out.push_str("\\n"),
1142            '\r' => out.push_str("\\r"),
1143            '\t' => out.push_str("\\t"),
1144            '\0' => out.push_str("\\0"),
1145            other if (other as u32) < 0x20 || other as u32 == 0x7f => {
1146                out.push_str(&format!("\\x{:02x}", other as u32));
1147            }
1148            other => out.push(other),
1149        }
1150    }
1151    out.push('"');
1152    out
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    /// Parse through the dialect and through the serde oracle; both must
1160    /// agree on the tree (compared by Debug text: NaN ≠ NaN under PartialEq).
1161    fn parity(source: &str) {
1162        let mine = parse_node(source).expect("dialect parse");
1163        let oracle: Node = serde_yaml_ng::from_str(source).expect("oracle parse");
1164        assert_eq!(
1165            format!("{mine:?}"),
1166            format!("{oracle:?}"),
1167            "dialect and serde disagree on {source:?}"
1168        );
1169    }
1170
1171    #[test]
1172    fn scalar_resolution_matches_the_serde_oracle() {
1173        for value in [
1174            "null",
1175            "Null",
1176            "NULL",
1177            "~",
1178            "true",
1179            "True",
1180            "TRUE",
1181            "false",
1182            "False",
1183            "FALSE",
1184            "0",
1185            "-0",
1186            "42",
1187            "+5",
1188            "-17",
1189            "9223372036854775807",
1190            "9223372036854775808",
1191            "18446744073709551615",
1192            "0x1A",
1193            "0o17",
1194            "0b101",
1195            "-0x10",
1196            "007",
1197            "1_000",
1198            "1.5",
1199            "-0.0",
1200            "1e300",
1201            ".inf",
1202            "-.inf",
1203            ".nan",
1204            "yes",
1205            "No",
1206            "text",
1207            "http://x",
1208            "a b",
1209            "${{ env.X }}",
1210        ] {
1211            parity(&format!("key: {value}\n"));
1212            parity(&format!("- {value}\n"));
1213        }
1214        for value in ["", "0", "true", "42", " x "] {
1215            parity(&format!("key: '{value}'\n"));
1216            parity(&format!("key: \"{value}\"\n"));
1217        }
1218        parity("key:\n");
1219        parity("key: |\n  line1\n  line2\n");
1220        parity("key: >\n  folded text\n");
1221        parity("");
1222        parity("---\n");
1223    }
1224
1225    #[test]
1226    fn structural_shapes_match_the_serde_oracle() {
1227        parity("[]\n");
1228        parity("{}\n");
1229        parity("- 1\n- a\n- [1, 2]\n- {x: 1}\n");
1230        parity("a:\n  b:\n    c: 1\n");
1231        parity("a: &anchor 1\nb: *anchor\n");
1232        parity("base: &b\n  x: 1\nover: *b\n");
1233        parity("list:\n- 1\n- 2\n");
1234        parity("- id: a\n  name: n\n  group:\n  - id: c\n");
1235        // Plain non-string keys coerce to their text, like the serde path.
1236        parity("m:\n  1: value\n");
1237    }
1238
1239    #[test]
1240    fn core_tags_match_the_serde_oracle() {
1241        parity("a: !!str 5\n");
1242        parity("a: !!bool 'true'\n");
1243        parity("a: !!int 0x1f\n");
1244        parity("a: !!float 5\n");
1245        parity("a: !!null ~\n");
1246        parity("a: !!python/object 'x'\n");
1247    }
1248
1249    /// Local `!tags` are serde enum syntax — the oracle rejects them for
1250    /// `Node`, and so does the dialect (with a clearer message).
1251    #[test]
1252    fn local_tags_fail_like_the_serde_path() {
1253        for source in ["a: !local 5\n", "a: !local 'x'\n"] {
1254            assert!(serde_yaml_ng::from_str::<Node>(source).is_err(), "{source}");
1255            let error = parse_node(source).unwrap_err().to_string();
1256            assert!(error.contains("local tags are not supported"), "{error}");
1257        }
1258    }
1259
1260    /// Complex (non-scalar) mapping keys are errors on both paths.
1261    #[test]
1262    fn complex_mapping_keys_fail_like_the_serde_path() {
1263        let source = "? [a]\n: v\n";
1264        assert!(serde_yaml_ng::from_str::<Node>(source).is_err());
1265        let error = parse_node(source).unwrap_err().to_string();
1266        assert!(error.contains("mapping keys must be scalars"), "{error}");
1267    }
1268
1269    #[test]
1270    fn js_tag_becomes_an_expression_node() {
1271        let node = parse_node("- id: a\n  config:\n    model: !!js process.env.MODEL\n").unwrap();
1272        let entry = &node.as_array().unwrap()[0];
1273        let config = entry.as_object().unwrap()["config"].as_object().unwrap();
1274        assert_eq!(config["model"], Node::Expr("process.env.MODEL".to_owned()));
1275        // Quoted !!js scalars are expressions too (js-yaml resolves any
1276        // string scalar for the type).
1277        let node = parse_node("k: !!js 'quoted expression'\n").unwrap();
1278        assert_eq!(
1279            node.as_object().unwrap()["k"],
1280            Node::Expr("quoted expression".to_owned())
1281        );
1282    }
1283
1284    #[test]
1285    fn integer_overflow_fails_like_the_serde_path() {
1286        let error = parse_node("a: 18446744073709551616\n")
1287            .unwrap_err()
1288            .to_string();
1289        assert!(error.contains("integer out of range"), "{error}");
1290        // The same value through the oracle also fails.
1291        assert!(serde_yaml_ng::from_str::<Node>("a: 18446744073709551616\n").is_err());
1292    }
1293
1294    #[test]
1295    fn syntax_errors_carry_line_and_column() {
1296        // libyaml anchors unclosed-flow errors at the position where the
1297        // problem became unavoidable (here, the start of the line after).
1298        let error = parse_node("a: [unclosed\n").unwrap_err().to_string();
1299        assert!(error.contains("line 2"), "{error}");
1300        let error = parse_node("valid: 1\nbroken: [x\n")
1301            .unwrap_err()
1302            .to_string();
1303        assert!(error.contains("line 3"), "{error}");
1304    }
1305
1306    #[test]
1307    fn multiple_documents_and_unknown_anchors_fail() {
1308        let error = parse_node("---\na: 1\n---\nb: 2\n")
1309            .unwrap_err()
1310            .to_string();
1311        assert!(error.contains("more than one document"), "{error}");
1312        let error = parse_node("a: *missing\n").unwrap_err().to_string();
1313        assert!(error.contains("unknown anchor"), "{error}");
1314    }
1315
1316    #[test]
1317    fn converters_reject_wrong_shapes_with_field_context() {
1318        let error = parse_document("- id: a\n").unwrap_err().to_string();
1319        assert!(
1320            error.contains("expected a mapping with an `entries` list"),
1321            "{error}"
1322        );
1323        let error = parse_document("entries: 5\n").unwrap_err().to_string();
1324        assert!(error.contains("entries:"), "{error}");
1325        let error = parse_entry_list("entries:\n  - id: a\n")
1326            .unwrap_err()
1327            .to_string();
1328        assert!(error.contains("expected a sequence of entries"), "{error}");
1329        let error = parse_entry_list("- name: 5\n").unwrap_err().to_string();
1330        assert!(
1331            error.contains("entry 1: name: expected a string"),
1332            "{error}"
1333        );
1334        let error = parse_entry_list("- inject: [1]\n").unwrap_err().to_string();
1335        assert!(error.contains("inject"), "{error}");
1336        let error = parse_entry_list("- disabled: maybe\n")
1337            .unwrap_err()
1338            .to_string();
1339        assert!(
1340            error.contains("disabled: expected a boolean or a !!js expression"),
1341            "{error}"
1342        );
1343        // Patches need an evaluated boolean; entry rows carry expressions.
1344        let error = crate::yaml::patch_list_from_node(
1345            parse_node("- id: x\n  disabled: !!js process.platform\n").unwrap(),
1346        )
1347        .unwrap_err()
1348        .to_string();
1349        assert!(
1350            error.contains("disabled: !!js expressions are not supported in patches"),
1351            "{error}"
1352        );
1353    }
1354
1355    /// `disabled` round-trips through both slot forms: the expression's
1356    /// raw text survives parse → emit → parse, and the plain flag stays a
1357    /// flag (omitted when off).
1358    #[test]
1359    fn disabled_expressions_round_trip() {
1360        let source = "- id: x\n  name: n\n  disabled: !!js process.platform === 'win32'\n";
1361        let entries = parse_entry_list(source).unwrap();
1362        assert_eq!(
1363            entries[0].disabled,
1364            Disabled::Expr("process.platform === 'win32'".to_owned())
1365        );
1366        let text = emit_entry_list(&entries);
1367        assert!(
1368            text.contains("disabled: !!js process.platform === 'win32'\n"),
1369            "{text}"
1370        );
1371        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1372
1373        let entries = parse_entry_list("- name: n\n  disabled: true\n").unwrap();
1374        assert_eq!(entries[0].disabled, Disabled::Flag(true));
1375        let text = emit_entry_list(&entries);
1376        assert!(text.contains("disabled: true\n"), "{text}");
1377        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1378
1379        // The off flag is omitted on write.
1380        let entries = parse_entry_list("- name: n\n").unwrap();
1381        assert_eq!(entries[0].disabled, Disabled::Flag(false));
1382        let text = emit_entry_list(&entries);
1383        assert!(!text.contains("disabled"), "{text}");
1384    }
1385
1386    #[test]
1387    fn converters_keep_unknown_entry_keys_dropped_and_patch_extras() {
1388        let entries = parse_entry_list("- id: a\n  name: n\n  mystery: value\n").unwrap();
1389        assert_eq!(entries.len(), 1);
1390        assert_eq!(entries[0].name, "n");
1391
1392        let patches = crate::yaml::patch_list_from_node(
1393            parse_node("- id: a\n  intercept: db\n  group: []\n").unwrap(),
1394        )
1395        .unwrap();
1396        assert_eq!(patches[0].extra.len(), 2);
1397        assert!(patches[0].extra.contains_key("intercept"));
1398        assert!(patches[0].extra.contains_key("group"));
1399
1400        let patches = crate::yaml::patch_list_from_node(
1401            parse_node("- insert: [{id: x, name: n}]\n").unwrap(),
1402        )
1403        .unwrap();
1404        assert_eq!(
1405            patches[0].insert.as_ref().unwrap()[0].id.as_deref(),
1406            Some("x")
1407        );
1408    }
1409
1410    #[test]
1411    fn document_round_trips_with_extras_in_order() {
1412        let document = parse_document(
1413            "entries:\n  - id: a\n    name: n\n    config:\n      x: 1\nmeta: kept\n",
1414        )
1415        .unwrap();
1416        assert_eq!(document.entries.len(), 1);
1417        assert_eq!(document.extra.len(), 1);
1418        let text = emit_document(&document);
1419        assert_eq!(parse_document(&text).unwrap(), document, "{text}");
1420        assert!(text.contains("meta: kept"), "{text}");
1421    }
1422
1423    #[test]
1424    fn emitter_matches_the_previous_writer_layout() {
1425        let entries = parse_entry_list(
1426            "- 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",
1427        )
1428        .unwrap();
1429        let text = emit_entry_list(&entries);
1430        let expected = "\
1431- id: w1
1432  name: worker
1433  config: 8080
1434- id: g
1435  name: group
1436  group:
1437  - id: c1
1438    name: adapter-http
1439    config:
1440      host: localhost
1441      empty: ''
1442      list:
1443      - 1
1444      - 2.5
1445";
1446        assert_eq!(text, expected);
1447        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1448    }
1449
1450    #[test]
1451    fn emitter_quotes_and_numbers_match_the_previous_writer() {
1452        for (text, expected) in [
1453            ("x: -foo\n", "x: -foo\n"),
1454            ("x: '#hash'\n", "x: '#hash'\n"),
1455            ("x: 'trailing:'\n", "x: 'trailing:'\n"),
1456            ("x: 'true'\n", "x: 'true'\n"),
1457            ("x: '0x1A'\n", "x: '0x1A'\n"),
1458            ("x: it's\n", "x: it's\n"),
1459            ("x: qu\"ote\n", "x: qu\"ote\n"),
1460            ("x: http://x\n", "x: http://x\n"),
1461            ("x: ${{ env.X }}\n", "x: ${{ env.X }}\n"),
1462            ("x: 0.5\n", "x: 0.5\n"),
1463            ("x: -0.0\n", "x: -0.0\n"),
1464            ("x: 1e300\n", "x: 1e300\n"),
1465            ("x: 8080\n", "x: 8080\n"),
1466        ] {
1467            let node = parse_node(text).unwrap();
1468            let emitted = emit_node_test(&node);
1469            assert_eq!(emitted, expected, "input {text:?}");
1470            assert_eq!(parse_node(&emitted).unwrap(), node, "round trip {text:?}");
1471        }
1472        // The empty document renders like the serde writer's.
1473        assert_eq!(emit_document(&Document::default()), "{}\n");
1474        // Empty containers stay inline; strings with control characters go
1475        // double-quoted and round-trip.
1476        let node = parse_node("a: []\nb: {}\nc: \"tab\\there\"\n").unwrap();
1477        let emitted = emit_node_test(&node);
1478        assert_eq!(emitted, "a: []\nb: {}\nc: \"tab\\there\"\n");
1479        assert_eq!(parse_node(&emitted).unwrap(), node);
1480    }
1481
1482    fn emit_node_test(node: &Node) -> String {
1483        let mut out = String::new();
1484        emit_node(node, 0, "", &mut out);
1485        out
1486    }
1487
1488    #[test]
1489    fn expressions_emit_and_round_trip() {
1490        let entries = parse_entry_list(
1491            "- id: a\n  name: n\n  config:\n    model: !!js process.env.MODEL || 'default'\n",
1492        )
1493        .unwrap();
1494        let text = emit_entry_list(&entries);
1495        assert!(
1496            text.contains("model: !!js process.env.MODEL || 'default'\n"),
1497            "{text}"
1498        );
1499        assert_eq!(parse_entry_list(&text).unwrap(), entries);
1500    }
1501
1502    /// A bundle-shaped fixture: every field shape the shipped patch files
1503    /// use (`id`, `name`, `inject`, `disabled`, `config`, `!!js`),
1504    /// round-tripped through the entry list and the patch list.
1505    #[test]
1506    fn bundle_shaped_fixture_round_trips() {
1507        let entries = parse_entry_list(
1508            "\
1509- id: base-sandbox
1510  name: '@dsh/base'
1511  inject: [database]
1512  disabled: !!js process.platform === 'darwin'
1513  config:
1514    level: !!js process.env.DSH_LOG_LEVEL || 'info'
1515    retries: 3
1516    nested:
1517      keep: true
1518",
1519        )
1520        .unwrap();
1521        assert_eq!(entries.len(), 1);
1522        assert_eq!(
1523            entries[0].disabled,
1524            Disabled::Expr("process.platform === 'darwin'".to_owned())
1525        );
1526        let text = emit_entry_list(&entries);
1527        assert_eq!(parse_entry_list(&text).unwrap(), entries, "{text}");
1528        assert!(
1529            text.contains("disabled: !!js process.platform === 'darwin'"),
1530            "{text}"
1531        );
1532
1533        let patches = crate::yaml::patch_list_from_node(
1534            parse_node(
1535                "\
1536- id: base-sandbox
1537  config:
1538    level: !!js process.env.DSH_LOG_LEVEL || 'info'
1539- insert:
1540    - id: web-app
1541      name: '@dsh/web-app'
1542      config:
1543        theme: dark
1544        gate: !!js process.platform === 'darwin'
1545",
1546            )
1547            .unwrap(),
1548        )
1549        .unwrap();
1550        assert_eq!(patches.len(), 2);
1551        assert_eq!(
1552            patches[1].insert.as_ref().unwrap()[0].id.as_deref(),
1553            Some("web-app")
1554        );
1555    }
1556
1557    #[test]
1558    fn anchors_resolve_into_shared_clones() {
1559        let node = parse_node("a: &x {v: 1}\nb: *x\n").unwrap();
1560        let map = node.as_object().unwrap();
1561        assert_eq!(map["a"], map["b"]);
1562    }
1563}