Skip to main content

agent_first_data/document/format/
yaml.rs

1//! YAML format backend. Reads via the noyalib `Value` view; mutates via the
2//! lossless `cst::Document` editor so comments, ordering, styles, and untouched
3//! source bytes are preserved.
4
5use crate::document::{Addressing, DocumentError, DocumentResult, Value};
6use noyalib::{
7    DuplicateKeyPolicy, Mapping as YamlMapping, ParserConfig, Value as YamlValue,
8    cst::{GreenChild, GreenNode, SyntaxKind, parse_document},
9    from_str_with_config, to_string,
10};
11
12pub fn load(content: &str) -> DocumentResult<Value> {
13    let (protected, literals) = protect_exact_numbers(content)?;
14    let parser_config = ParserConfig::new()
15        .duplicate_key_policy(DuplicateKeyPolicy::Error)
16        .lossless_u64_integers(true);
17    from_str_with_config::<YamlValue>(&protected, &parser_config)
18        .map(|value| value_to_our_value(value, &literals))
19        .map_err(|e| DocumentError::ParseError {
20            format: "YAML".to_string(),
21            detail: e.to_string(),
22        })
23}
24
25/// Replace one value in a YAML source document while retaining all unrelated
26/// source bytes. Escaped keys and keyed-list routes are still rejected until
27/// they have a lossless CST path adapter.
28///
29/// Scalars go through the CST's own `set_value`, which matches the site's
30/// quoting. Collections cannot: noyalib's `set_value` refuses to grow a scalar
31/// site into a sequence or mapping under any circumstances, so a collection is
32/// emitted as a block fragment and spliced over the value's span — see
33/// `replace_collection_in_place`, which also documents what that costs.
34pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
35    let segments = crate::document::parse_path(path)?;
36    if is_blank_document(content) {
37        return set_in_blank_document(content, &segments, value);
38    }
39    if let Some((last, parents)) = segments.split_last()
40        && last == "-"
41        && array_at_path(&load(content)?, &crate::document::join_path(parents), "set").is_ok()
42    {
43        guard_cst_segments(content, parents, "set", false)?;
44        return append_array_item_preserving(content, &crate::document::join_path(parents), value)
45            .map_err(|error| match error {
46                DocumentError::UnsupportedOperation { format, detail, .. } => {
47                    DocumentError::UnsupportedOperation {
48                        format,
49                        operation: "set".into(),
50                        detail,
51                    }
52                }
53                other => other,
54            });
55    }
56    let yaml_path = cst_path(&segments, "set")?;
57    guard_cst_segments(content, &segments, "set", true)?;
58    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
59        format: "YAML".to_string(),
60        detail: error.to_string(),
61    })?;
62    // The leaf itself may be an alias site: a scalar written there replaces the
63    // `*name` reference, which is the key the caller named.
64    refuse_through_alias(&document, &segments, segments.len() - 1, "set")?;
65    // Existing leaf: replace in place (preserves the scalar's style). Missing
66    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
67    let mut expected = load(content)?;
68    let exists = crate::document::get_path_ref(&expected, path, Addressing::INDEX_ONLY).is_ok();
69    // Float and small unsigned inputs canonicalize to the reader's numeric
70    // variants. Compare that representation while retaining exact literals.
71    let canonical = load(&save(value)?)?;
72    crate::document::set_path(&mut expected, path, &canonical, Addressing::INDEX_ONLY)?;
73    if exists && matches!(value, Value::Array(_) | Value::Object(_)) {
74        replace_collection_in_place(&mut document, content, &segments, &yaml_path, value)?;
75    } else if exists {
76        let result = if let Value::Number(text) = value {
77            document.set(&yaml_path, text)
78        } else {
79            document.set_value(&yaml_path, &to_noyalib_value(value)?)
80        };
81        result.map_err(|error| DocumentError::UnsupportedOperation {
82            format: "YAML".to_string(),
83            operation: "set".to_string(),
84            detail: error.to_string(),
85        })?;
86    } else {
87        // Grow missing mapping parents from the deepest existing ancestor.
88        let original = load(content)?;
89        for index in 0..segments.len().saturating_sub(1) {
90            let prefix = crate::document::join_path(&segments[..=index]);
91            if crate::document::get_path(&original, &prefix, Addressing::INDEX_ONLY).is_err() {
92                let parent = cst_path(&segments[..index], "set")?;
93                // An empty flow mapping has nothing to lay out around, so it
94                // grows like a block one — however much space is inside `{ }`.
95                if document.span_at(&parent).is_some_and(|(start, end)| {
96                    let text = content[start..end].trim();
97                    let empty = text
98                        .strip_circumfix('{', '}')
99                        .is_some_and(|inner| inner.trim().is_empty());
100                    text.starts_with('{') && !empty
101                }) {
102                    return Err(DocumentError::UnsupportedOperation {
103                        format: "YAML".into(),
104                        operation: "set".into(),
105                        detail: "creating parents inside a flow mapping is not supported".into(),
106                    });
107                }
108                let mut nested = value.clone();
109                for key in segments[index + 1..].iter().rev() {
110                    nested = Value::Object([(key.clone(), nested)].into_iter().collect());
111                }
112                return set_preserving(content, &prefix, &nested);
113            }
114        }
115        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
116        if last.parse::<usize>().is_ok() {
117            return Err(DocumentError::UnsupportedOperation {
118                format: "YAML".to_string(),
119                operation: "set".to_string(),
120                detail: "cannot create a new sequence index; the element must already exist"
121                    .to_string(),
122            });
123        }
124        let parent_path = if parents.is_empty() {
125            String::new()
126        } else {
127            cst_path(parents, "set")?
128        };
129        let result = match value {
130            // An empty collection through the typed path lands on its own line
131            // (`tags:` then `  []`); the fragment spelling keeps it inline,
132            // which is how anyone writes it by hand.
133            Value::Array(items) if items.is_empty() => {
134                document.insert_entry(&parent_path, last, "[]")
135            }
136            Value::Object(entries) if entries.is_empty() => {
137                document.insert_entry(&parent_path, last, "{}")
138            }
139            // A new key is the one place noyalib will emit a collection itself,
140            // indenting it under the key and rolling back if the re-parse
141            // disagrees. Preferred over a hand-built fragment for exactly that.
142            Value::Array(_) | Value::Object(_) => {
143                document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
144            }
145            _ => document.insert_entry(&parent_path, last, yaml_fragment(value, "set")?.trim_end()),
146        };
147        if result.is_err() {
148            // The fragment tier requires a block anchor; typed insertion also
149            // handles empty and flow mappings, including the key's quoting.
150            // Exact numbers are inserted through a null site, then replaced
151            // verbatim so their spelling never passes through f64.
152            let retry = if let Value::Number(text) = value {
153                document
154                    .insert_entry_value(&parent_path, last, &YamlValue::Null)
155                    .and_then(|()| document.set(&yaml_path, text))
156            } else {
157                document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
158            };
159            if let Err(error) = retry {
160                // noyalib finds no anchor in a sequence item's mapping that
161                // holds a single key (`- name: web`), which is exactly what
162                // `add` leaves behind; lay the entry out under the dash.
163                // Upstream: https://github.com/sebastienrousseau/noyalib/issues/499
164                if !append_to_sequence_item_mapping(
165                    &mut document,
166                    content,
167                    &parent_path,
168                    last,
169                    value,
170                )? {
171                    return Err(DocumentError::UnsupportedOperation {
172                        format: "YAML".to_string(),
173                        operation: "set".to_string(),
174                        detail: error.to_string(),
175                    });
176                }
177            }
178        }
179    }
180    document
181        .validate()
182        .map_err(|error| DocumentError::ParseError {
183            format: "YAML".to_string(),
184            detail: error.to_string(),
185        })?;
186    let output = document.to_string();
187    if load(&output)? != expected {
188        return Err(DocumentError::UnsupportedOperation {
189            format: "YAML".to_string(),
190            operation: "set".to_string(),
191            detail: "YAML edit did not preserve the requested document value".to_string(),
192        });
193    }
194    Ok(output)
195}
196
197/// True when `content` holds no YAML node at all: only blank lines, comments,
198/// and at most an opening `---`. Such a document reads as null — a blank
199/// frontmatter template, a fresh config file — and a `set` starts its mapping.
200pub(crate) fn is_blank_document(content: &str) -> bool {
201    let mut markers = 0;
202    content.lines().all(|line| {
203        let line = line.trim();
204        if line == "---" {
205            markers += 1;
206            return markers == 1;
207        }
208        line.is_empty() || line.starts_with('#')
209    })
210}
211
212/// Append the mapping `path = value` after whatever comments `content` has.
213fn set_in_blank_document(
214    content: &str,
215    segments: &[String],
216    value: &Value,
217) -> DocumentResult<String> {
218    if segments
219        .iter()
220        .any(|segment| segment == "-" || segment.parse::<usize>().is_ok())
221    {
222        return Err(DocumentError::UnsupportedOperation {
223            format: "YAML".to_string(),
224            operation: "set".to_string(),
225            detail: "an empty document has no sequence to index into".to_string(),
226        });
227    }
228    let mut nested = value.clone();
229    for segment in segments.iter().rev() {
230        nested = Value::Object([(segment.clone(), nested)].into_iter().collect());
231    }
232    let rendered = save(&nested)?;
233    let body = rendered.strip_prefix("---\n").unwrap_or(&rendered);
234    let newline = if content.contains("\r\n") {
235        "\r\n"
236    } else {
237        "\n"
238    };
239    let mut output = content.to_string();
240    if !output.is_empty() && !output.ends_with('\n') {
241        output.push_str(newline);
242    }
243    for line in body.trim_end_matches('\n').split('\n') {
244        output.push_str(line);
245        output.push_str(newline);
246    }
247    if load(&output)? != load(&save(&nested)?)? {
248        return Err(DocumentError::UnsupportedOperation {
249            format: "YAML".to_string(),
250            operation: "set".to_string(),
251            detail: "YAML edit did not preserve the requested document value".to_string(),
252        });
253    }
254    Ok(output)
255}
256
257/// Splice a block-style collection over the span of an existing value.
258///
259/// noyalib's typed `set_value` refuses a sequence or mapping outright, so the
260/// only route is a fragment written over the value's byte span. Three details
261/// make that safe rather than approximate:
262///
263/// * The start widens back over the whitespace between the `:` and the old
264///   value, because a block collection has to begin on its own line — spliced
265///   at the raw span start it would either sit at column 0 or leave the source's
266///   own newline behind as a blank line.
267/// * The end widens over a trailing `#` comment. The span the CST reports stops
268///   before it, so leaving it would re-attach a comment written about the *old*
269///   last element to whatever the new one happens to be. Silently moving a
270///   comment onto a different value is worse than dropping it with the value it
271///   described.
272/// * A path reached through an alias is refused. `span_at` resolves through
273///   aliases to the anchor, so editing `other: *b` would rewrite `base: &b`
274///   instead — a different key than the caller named.
275///
276/// The cost, which has no workaround in this version: comments and blank lines
277/// *inside* the replaced collection are dropped, because no API models them as
278/// movable. Everything outside the span — surrounding keys, their comments,
279/// key order, quote styles, the trailing newline — is preserved byte for byte.
280fn replace_collection_in_place(
281    document: &mut noyalib::cst::Document,
282    content: &str,
283    segments: &[String],
284    yaml_path: &str,
285    value: &Value,
286) -> DocumentResult<()> {
287    let unsupported = |detail: String| DocumentError::UnsupportedOperation {
288        format: "YAML".to_string(),
289        operation: "set".to_string(),
290        detail,
291    };
292    let (value_start, value_end) = document
293        .span_at(yaml_path)
294        .ok_or_else(|| unsupported(format!("could not locate the value at `{yaml_path}`")))?;
295    let newline = if content.contains("\r\n") {
296        "\r\n"
297    } else {
298        "\n"
299    };
300    let Some((key_start, key_end)) = document.key_span(yaml_path) else {
301        // A sequence element has no key: the collection starts on the dash's
302        // own line and continues two columns in from it.
303        let dash_column = preceding_sequence_dash_column(content, value_start).ok_or_else(|| {
304            unsupported(format!(
305                "a collection can replace only a block sequence element, and `{yaml_path}` is not one"
306            ))
307        })?;
308        let indent = dash_column + 2;
309        let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
310        let inline = fragment
311            .strip_prefix(newline)
312            .and_then(|rest| rest.strip_prefix(" ".repeat(indent).as_str()))
313            .unwrap_or_else(|| fragment.trim_start());
314        let end = end_of_trailing_inline_comment(content, value_end);
315        return document
316            .replace_span(value_start, end, inline)
317            .map_err(|error| unsupported(error.to_string()));
318    };
319    if value_start < key_end {
320        return Err(through_alias_refused(
321            segments,
322            segments,
323            anchor_before(document, value_start).as_deref(),
324            "set",
325        ));
326    }
327
328    let bytes = content.as_bytes();
329    let key_column = line_column_of(content, key_start);
330    let indent = key_column + document.indent_unit();
331
332    let mut start = value_start;
333    while start > 0 && matches!(bytes[start - 1], b' ' | b'\t' | b'\n' | b'\r') {
334        start -= 1;
335    }
336    let end = end_of_trailing_inline_comment(content, value_end);
337
338    let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
339    document
340        .replace_span(start, end, &fragment)
341        .map_err(|error| unsupported(error.to_string()))
342}
343
344/// Append `key: value` to the block mapping at `parent_path` when that mapping
345/// is a sequence element. Returns `false` when it is not one, leaving the
346/// caller's own error to report.
347fn append_to_sequence_item_mapping(
348    document: &mut noyalib::cst::Document,
349    content: &str,
350    parent_path: &str,
351    key: &str,
352    value: &Value,
353) -> DocumentResult<bool> {
354    let Some((start, end)) = document.span_at(parent_path) else {
355        return Ok(false);
356    };
357    let Some(dash_column) = preceding_sequence_dash_column(content, start) else {
358        return Ok(false);
359    };
360    if content[start..end].trim_start().starts_with('{') {
361        return Ok(false);
362    }
363    let newline = if content.contains("\r\n") {
364        "\r\n"
365    } else {
366        "\n"
367    };
368    let entry = Value::Object([(key.to_string(), value.clone())].into_iter().collect());
369    let fragment = block_fragment(&entry, document.indent_unit(), dash_column + 2, newline)?;
370    let at = end_of_trailing_inline_comment(content, end);
371    document.replace_span(at, at, &fragment).map_err(|error| {
372        DocumentError::UnsupportedOperation {
373            format: "YAML".to_string(),
374            operation: "set".to_string(),
375            detail: error.to_string(),
376        }
377    })?;
378    Ok(true)
379}
380
381/// Render `value` as the bytes that go after a mapping key.
382///
383/// An empty collection stays inline (` []`), because a bare `key:` parses as
384/// null rather than as an empty sequence — the space is load-bearing. Anything
385/// else becomes a block, preceded by a newline and indented under its key.
386fn block_fragment(
387    value: &Value,
388    indent_unit: usize,
389    indent: usize,
390    newline: &str,
391) -> DocumentResult<String> {
392    match value {
393        Value::Array(items) if items.is_empty() => return Ok(" []".to_string()),
394        Value::Object(entries) if entries.is_empty() => return Ok(" {}".to_string()),
395        _ => {}
396    }
397    let config = noyalib::SerializerConfig::new()
398        .indent(indent_unit)
399        .flow_style(noyalib::FlowStyle::Block);
400    // Emitted by noyalib rather than by hand: element quoting has to survive
401    // values like `true`, `123`, `a: b` and `#c`, which are only scalars
402    // because they are quoted.
403    let rendered = noyalib::to_string_value_with_config(&to_noyalib_value(value)?, &config)
404        .map_err(|error| DocumentError::UnsupportedOperation {
405            format: "YAML".to_string(),
406            operation: "set".to_string(),
407            detail: error.to_string(),
408        })?;
409    let pad = " ".repeat(indent);
410    let mut fragment = String::new();
411    for line in rendered.trim_end_matches(['\r', '\n']).lines() {
412        fragment.push_str(newline);
413        if line.is_empty() {
414            continue;
415        }
416        fragment.push_str(&pad);
417        fragment.push_str(line);
418    }
419    Ok(fragment)
420}
421
422/// Column of `offset` within its line, counting bytes from the line start.
423fn line_column_of(content: &str, offset: usize) -> usize {
424    let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1);
425    offset - line_start
426}
427
428/// End of the `#` comment trailing the value that ends at `value_end`, or
429/// `value_end` when the rest of the line holds anything else.
430fn end_of_trailing_inline_comment(content: &str, value_end: usize) -> usize {
431    let bytes = content.as_bytes();
432    let mut index = value_end;
433    while index < bytes.len() && matches!(bytes[index], b' ' | b'\t') {
434        index += 1;
435    }
436    if index >= bytes.len() || bytes[index] != b'#' {
437        return value_end;
438    }
439    while index < bytes.len() && bytes[index] != b'\n' {
440        index += 1;
441    }
442    if index > 0 && bytes[index - 1] == b'\r' {
443        index -= 1;
444    }
445    index
446}
447
448/// Remove an existing YAML entry through the lossless CST editor.
449pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
450    let segments = crate::document::parse_path(path)?;
451    let yaml_path = cst_path(&segments, "unset")?;
452    guard_cst_segments(content, &segments, "unset", false)?;
453    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
454        format: "YAML".to_string(),
455        detail: error.to_string(),
456    })?;
457    refuse_through_alias(&document, &segments, segments.len() - 1, "unset")?;
458    document
459        .remove(&yaml_path)
460        .map_err(|error| DocumentError::UnsupportedOperation {
461            format: "YAML".to_string(),
462            operation: "unset".to_string(),
463            detail: error.to_string(),
464        })?;
465    document
466        .validate()
467        .map_err(|error| DocumentError::ParseError {
468            format: "YAML".to_string(),
469            detail: error.to_string(),
470        })?;
471    Ok(document.to_string())
472}
473
474/// Append an item to an existing block YAML sequence using the CST's
475/// indentation-aware editor.
476pub fn append_array_item_preserving(
477    content: &str,
478    path: &str,
479    item: &Value,
480) -> DocumentResult<String> {
481    let segments = array_path_segments(path)?;
482    let yaml_path = cst_path(&segments, "add")?;
483    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
484        format: "YAML".to_string(),
485        detail: error.to_string(),
486    })?;
487    refuse_through_alias(&document, &segments, segments.len(), "add")?;
488    let loaded = load(content)?;
489    let existing = array_at_path(&loaded, path, "add")?;
490    let previous_len = existing.len();
491    // Validate before the empty-sequence shortcut too: JSON would otherwise
492    // silently serialize a non-finite float as null.
493    let canonical_item = load(&save(item)?)?;
494    if previous_len == 0 {
495        // The CST's `push_back` needs an existing item to take indentation
496        // from, so it refuses on `tags: []`. Writing the whole one-element
497        // sequence gets there instead, and costs nothing: an empty collection
498        // has no interior comments or layout to preserve.
499        // A sequence element has a value span but no mapping-key span, so the
500        // mapping-oriented collection writer cannot grow an empty nested array.
501        // A one-line flow sequence fills the empty span without touching its
502        // dash or surrounding comments, just as it does for a root empty
503        // sequence. It comes from the YAML emitter, not a JSON one: JSON leaves
504        // DEL and C1 controls raw, which YAML forbids in source.
505        if path.is_empty()
506            || segments
507                .last()
508                .is_some_and(|part| part.parse::<usize>().is_ok())
509        {
510            let (start, end) = document
511                .span_at(&yaml_path)
512                .ok_or_else(|| DocumentError::PathNotFound { path: path.into() })?;
513            let replacement = flow_fragment(&Value::Array(vec![item.clone()]))?;
514            document
515                .replace_span(start, end, &replacement)
516                .map_err(|error| DocumentError::UnsupportedOperation {
517                    format: "YAML".into(),
518                    operation: "add".into(),
519                    detail: error.to_string(),
520                })?;
521            let output = document.to_string();
522            let edited = load(&output)?;
523            if array_at_path(&edited, path, "add")?.as_slice()
524                != std::slice::from_ref(&canonical_item)
525            {
526                return Err(DocumentError::UnsupportedOperation {
527                    format: "YAML".into(),
528                    operation: "add".into(),
529                    detail: "the source edit did not reproduce the requested item".into(),
530                });
531            }
532            return Ok(output);
533        }
534        return set_preserving(content, path, &Value::Array(vec![item.clone()]));
535    }
536    let mut fragment = yaml_fragment(item, "add")?;
537    {
538        let item_path = format!("{yaml_path}[{}]", previous_len - 1);
539        let (item_start, _) =
540            document
541                .span_at(&item_path)
542                .ok_or_else(|| DocumentError::UnsupportedOperation {
543                    format: "YAML".to_string(),
544                    operation: "add".to_string(),
545                    detail: "could not resolve the existing sequence-item indentation".to_string(),
546                })?;
547        let dash_column = preceding_sequence_dash_column(content, item_start).ok_or_else(|| {
548            DocumentError::UnsupportedOperation {
549                format: "YAML".to_string(),
550                operation: "add".to_string(),
551                detail: "only block sequences can be extended without rebuilding the document"
552                    .to_string(),
553            }
554        })?;
555        fragment = indent_continuation_lines(fragment.trim_end(), dash_column + 2);
556    }
557    document
558        .push_back(&yaml_path, fragment.trim_end())
559        .map_err(|error| DocumentError::UnsupportedOperation {
560            format: "YAML".to_string(),
561            operation: "add".to_string(),
562            detail: error.to_string(),
563        })?;
564    document
565        .validate()
566        .map_err(|error| DocumentError::ParseError {
567            format: "YAML".to_string(),
568            detail: error.to_string(),
569        })?;
570    let output = document.to_string();
571    let edited = load(&output)?;
572    let edited_items = array_at_path(&edited, path, "add")?;
573    if edited_items.len() != previous_len + 1 || edited_items.last() != Some(&canonical_item) {
574        return Err(DocumentError::UnsupportedOperation {
575            format: "YAML".to_string(),
576            operation: "add".to_string(),
577            detail: "the source edit did not reproduce the requested keyed item".to_string(),
578        });
579    }
580    Ok(output)
581}
582
583/// Remove one item from a YAML sequence by numeric index.
584pub fn remove_array_item_preserving(
585    content: &str,
586    path: &str,
587    index: usize,
588) -> DocumentResult<String> {
589    let segments = array_path_segments(path)?;
590    let loaded = load(content)?;
591    if array_at_path(&loaded, path, "remove")?.len() == 1 && index == 0 {
592        // The CST refuses to delete a sequence's only entry — removing the last
593        // `- item` line would leave a bare `key:`, which reads back as null
594        // rather than as an empty sequence. Writing the empty sequence says
595        // that outright, and mirrors the append path, which likewise falls back
596        // to a whole-value write when there is no item to anchor to.
597        return set_preserving(content, path, &Value::Array(Vec::new()));
598    }
599    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
600        format: "YAML".to_string(),
601        detail: error.to_string(),
602    })?;
603    refuse_through_alias(&document, &segments, segments.len(), "remove")?;
604    let yaml_path = cst_path(&segments, "remove")?;
605    document
606        .remove(&format!("{yaml_path}[{index}]"))
607        .map_err(|error| DocumentError::UnsupportedOperation {
608            format: "YAML".to_string(),
609            operation: "remove".to_string(),
610            detail: error.to_string(),
611        })?;
612    document
613        .validate()
614        .map_err(|error| DocumentError::ParseError {
615            format: "YAML".to_string(),
616            detail: error.to_string(),
617        })?;
618    Ok(document.to_string())
619}
620
621/// An empty keyed-list prefix names a root sequence. Ordinary set/unset paths
622/// still pass through `parse_path` directly and therefore remain non-empty.
623fn array_path_segments(path: &str) -> DocumentResult<Vec<String>> {
624    if path.is_empty() {
625        Ok(Vec::new())
626    } else {
627        crate::document::parse_path(path)
628    }
629}
630
631fn array_at_path<'a>(
632    value: &'a Value,
633    path: &str,
634    operation: &str,
635) -> DocumentResult<&'a Vec<Value>> {
636    let target = if path.is_empty() {
637        value
638    } else {
639        crate::document::get_path_ref(value, path, Addressing::INDEX_ONLY)?
640    };
641    target
642        .as_array()
643        .ok_or_else(|| DocumentError::UnsupportedOperation {
644            format: "YAML".to_string(),
645            operation: operation.to_string(),
646            detail: "target is not an array".to_string(),
647        })
648}
649
650fn preceding_sequence_dash_column(content: &str, value_start: usize) -> Option<usize> {
651    let bytes = content.as_bytes();
652    let mut position = value_start;
653    let dash = loop {
654        position = position.checked_sub(1)?;
655        match bytes.get(position)? {
656            b' ' | b'\t' => {}
657            b'-' => break position,
658            _ => return None,
659        }
660    };
661    let line_start = content.as_bytes()[..dash]
662        .iter()
663        .rposition(|byte| *byte == b'\n')
664        .map_or(0, |newline| newline + 1);
665    Some(dash - line_start)
666}
667
668fn indent_continuation_lines(fragment: &str, indent: usize) -> String {
669    if !fragment.contains('\n') {
670        return fragment.to_string();
671    }
672    let padding = " ".repeat(indent);
673    let mut output = String::with_capacity(fragment.len() + indent * 4);
674    for (index, line) in fragment.split('\n').enumerate() {
675        if index > 0 {
676            output.push('\n');
677            if !line.is_empty() {
678                output.push_str(&padding);
679            }
680        }
681        output.push_str(line);
682    }
683    output
684}
685
686fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
687    let mut path = String::new();
688    for segment in segments {
689        if segment.contains(['.', '\\', '[', ']']) {
690            return Err(DocumentError::UnsupportedOperation {
691                format: "YAML".to_string(),
692                operation: operation.to_string(),
693                detail:
694                    "escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
695                        .to_string(),
696            });
697        }
698        if let Ok(index) = segment.parse::<usize>() {
699            path.push_str(&format!("[{index}]"));
700        } else {
701            if !path.is_empty() {
702                path.push('.');
703            }
704            path.push_str(segment);
705        }
706    }
707    Ok(path)
708}
709
710/// The refusal for an edit whose target is reached through `*alias`.
711///
712/// Every CST write resolves an alias to its anchor's bytes, so the edit would
713/// land in `&alias` and show up at every alias of it — a different key than
714/// the caller named. One message for every verb that meets one.
715fn through_alias_refused(
716    target: &[String],
717    site: &[String],
718    alias: Option<&str>,
719    operation: &str,
720) -> DocumentError {
721    let alias = alias.map_or_else(|| "a YAML alias".to_string(), |name| format!("`*{name}`"));
722    let target = crate::document::join_path(target);
723    let site = crate::document::join_path(site);
724    let location = if site == target {
725        String::new()
726    } else {
727        format!(" at `{site}`")
728    };
729    DocumentError::UnsupportedOperation {
730        format: "YAML".to_string(),
731        operation: operation.to_string(),
732        detail: format!(
733            "`{target}` is reached through the alias {alias}{location}; afdata does not edit \
734             through YAML aliases, because the edit would change the anchored value and every \
735             alias of it. Edit the anchor's own path instead"
736        ),
737    }
738}
739
740/// The anchor whose value begins at `value_start`: the closest one declared
741/// before it, since an anchor mark sits directly in front of its value.
742fn anchor_before(document: &noyalib::cst::Document, value_start: usize) -> Option<String> {
743    document
744        .anchors()
745        .into_iter()
746        .rev()
747        .find(|anchor| anchor.mark_span.1 <= value_start)
748        .map(|anchor| anchor.name)
749}
750
751/// Refuse when any of the first `walked` nodes along `target` is reached
752/// through an alias. A writer that replaces the node at `target` itself passes
753/// one less than its length, since an alias site written over is the key the
754/// caller named.
755///
756/// `span_at` resolves an alias to its anchor's value, which always sits
757/// earlier in the source than the alias site. So a node is reached through an
758/// alias exactly when its value starts before something that must precede it:
759/// its own key, the end of the previous sequence element, or its parent's
760/// start. The value view cannot tell; only positions can.
761fn refuse_through_alias(
762    document: &noyalib::cst::Document,
763    target: &[String],
764    walked: usize,
765    operation: &str,
766) -> DocumentResult<()> {
767    let segments = &target[..walked.min(target.len())];
768    for (index, segment) in segments.iter().enumerate() {
769        let prefix = &segments[..=index];
770        let Some((start, _)) = document.span_at(&cst_path(prefix, operation)?) else {
771            // Missing, or an implicit null: nothing below it to reach.
772            return Ok(());
773        };
774        let through_alias = match segment.parse::<usize>() {
775            Ok(0) if index > 0 => document
776                .span_at(&cst_path(&segments[..index], operation)?)
777                .is_some_and(|(parent_start, _)| start < parent_start),
778            Ok(0) => false,
779            Ok(position) => {
780                let previous = format!(
781                    "{}[{}]",
782                    cst_path(&segments[..index], operation)?,
783                    position - 1
784                );
785                document
786                    .span_at(&previous)
787                    .is_some_and(|(_, previous_end)| start < previous_end)
788            }
789            Err(_) => document
790                .key_span(&cst_path(prefix, operation)?)
791                .is_some_and(|(_, key_end)| start < key_end),
792        };
793        if through_alias {
794            return Err(through_alias_refused(
795                target,
796                prefix,
797                anchor_before(document, start).as_deref(),
798                operation,
799            ));
800        }
801    }
802    Ok(())
803}
804
805fn guard_cst_segments(
806    content: &str,
807    segments: &[String],
808    operation: &str,
809    allow_missing: bool,
810) -> DocumentResult<()> {
811    let root = load(content)?;
812    let mut current = &root;
813    for (index, segment) in segments.iter().enumerate() {
814        match current {
815            Value::Object(object) => {
816                if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
817                    return Err(DocumentError::UnsupportedOperation {
818                        format: "YAML".to_string(),
819                        operation: operation.to_string(),
820                        detail: format!(
821                            "mapping key `{segment}` is ambiguous in the CST path grammar"
822                        ),
823                    });
824                }
825                match object.get(segment) {
826                    Some(next) => current = next,
827                    None if allow_missing => {
828                        if segments[index..]
829                            .iter()
830                            .any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
831                        {
832                            return Err(DocumentError::UnsupportedOperation {
833                                format: "YAML".to_string(),
834                                operation: operation.to_string(),
835                                detail: "a missing mapping chain contains a CST-ambiguous key"
836                                    .to_string(),
837                            });
838                        }
839                        return Ok(());
840                    }
841                    None => {
842                        return Err(DocumentError::PathNotFound {
843                            path: crate::document::join_path(&segments[..=index]),
844                        });
845                    }
846                }
847            }
848            Value::Array(values) => {
849                let array_index =
850                    segment
851                        .parse::<usize>()
852                        .map_err(|_| DocumentError::UnregisteredArray {
853                            path: crate::document::join_path(&segments[..index]),
854                        })?;
855                current =
856                    values
857                        .get(array_index)
858                        .ok_or_else(|| DocumentError::IndexOutOfBounds {
859                            path: crate::document::join_path(&segments[..index]),
860                            index: array_index,
861                            len: values.len(),
862                        })?;
863            }
864            value => {
865                return Err(DocumentError::NotTraversable {
866                    path: crate::document::join_path(&segments[..index]),
867                    got: value.kind_name().to_string(),
868                });
869            }
870        }
871    }
872    Ok(())
873}
874
875fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
876    match value {
877        Value::Null => Ok(YamlValue::Null),
878        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
879        Value::Integer(value) => Ok(YamlValue::from(*value)),
880        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
881        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
882        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
883            format: "YAML".to_string(),
884            operation: "set".to_string(),
885            detail: "non-finite YAML float is not representable".to_string(),
886        }),
887        // See the identical fallback in `format::toml::toml_item`: a
888        // float-shaped `Value::Number` literal parses to `f64` cleanly (YAML
889        // floats are canonically `f64` at the format level too); an
890        // integer-shaped one only exists because it overflows `u64`, so it
891        // can never be written through noyalib's numeric `Value`.
892        Value::Number(text) if value.is_float() => text
893            .parse::<f64>()
894            .ok()
895            .filter(|value| value.is_finite())
896            .map(YamlValue::from)
897            .ok_or_else(|| DocumentError::UnsupportedOperation {
898                format: "YAML".to_string(),
899                operation: "set".to_string(),
900                detail: "float literal is not representable in YAML".to_string(),
901            }),
902        Value::Number(_) => Err(DocumentError::UnsupportedOperation {
903            format: "YAML".to_string(),
904            operation: "set".to_string(),
905            detail: "integer literal exceeds YAML's 64-bit integer range".to_string(),
906        }),
907        Value::String(value) => Ok(YamlValue::String(value.clone())),
908        Value::Array(values) => Ok(YamlValue::Sequence(
909            values
910                .iter()
911                .map(to_noyalib_value)
912                .collect::<DocumentResult<Vec<_>>>()?,
913        )),
914        Value::Object(values) => {
915            let mut mapping = YamlMapping::new();
916            for (key, value) in values {
917                mapping.insert(key.clone(), to_noyalib_value(value)?);
918            }
919            Ok(YamlValue::Mapping(mapping))
920        }
921    }
922}
923
924pub fn save(value: &Value) -> DocumentResult<String> {
925    render(value, None)
926}
927
928/// `value` as one line of flow YAML (`["a\x7Fb"]`), for filling an empty
929/// flow collection in place. Emitted by noyalib like every other fragment, so
930/// a scalar YAML forbids raw (DEL, C1 controls) is escaped rather than written
931/// into source the reader would then reject, and exact numbers keep their
932/// spelling.
933fn flow_fragment(value: &Value) -> DocumentResult<String> {
934    let config = noyalib::SerializerConfig::new().flow_style(noyalib::FlowStyle::Flow);
935    let rendered = render(value, Some(&config))?;
936    let line = rendered.trim_end_matches(['\r', '\n']);
937    if line.contains('\n') {
938        return Err(DocumentError::UnsupportedOperation {
939            format: "YAML".to_string(),
940            operation: "add".to_string(),
941            detail: "the item has no single-line flow spelling".to_string(),
942        });
943    }
944    Ok(line.to_string())
945}
946
947fn render(value: &Value, config: Option<&noyalib::SerializerConfig>) -> DocumentResult<String> {
948    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
949    while value_contains_text(value, &prefix) {
950        prefix.push('_');
951    }
952    let mut literals = Vec::new();
953    let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
954    let rendered = match config {
955        Some(config) => noyalib::to_string_value_with_config(&yaml_val, config),
956        None => to_string(&yaml_val),
957    };
958    let mut output = rendered.map_err(|e| DocumentError::ParseError {
959        format: "YAML".to_string(),
960        detail: e.to_string(),
961    })?;
962    for (sentinel, literal) in literals {
963        output = output.replace(&sentinel, &literal);
964    }
965    Ok(output)
966}
967
968fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
969    match v {
970        YamlValue::Null => Value::Null,
971        YamlValue::Bool(b) => Value::Bool(b),
972        YamlValue::Number(n) => {
973            if let Some(i) = n.as_i64() {
974                Value::Integer(i)
975            } else if let Some(u) = n.as_u64() {
976                Value::Unsigned(u)
977            } else {
978                Value::Float(n.as_f64())
979            }
980        }
981        YamlValue::String(s) => literals
982            .get(&s)
983            .cloned()
984            .map(Value::Number)
985            .unwrap_or(Value::String(s)),
986        YamlValue::Sequence(seq) => Value::Array(
987            seq.into_iter()
988                .map(|value| value_to_our_value(value, literals))
989                .collect(),
990        ),
991        YamlValue::Mapping(map) => {
992            let mut obj = std::collections::BTreeMap::new();
993            for (key, value) in map {
994                let key = literals.get(&key).cloned().unwrap_or(key);
995                obj.insert(key, value_to_our_value(value, literals));
996            }
997            Value::Object(obj)
998        }
999        YamlValue::Tagged(t) => {
1000            // Tagged values: recurse on inner value
1001            let (_, value) = t.into_parts();
1002            value_to_our_value(value, literals)
1003        }
1004    }
1005}
1006
1007fn our_value_to_yaml_value(
1008    v: &Value,
1009    prefix: &str,
1010    literals: &mut Vec<(String, String)>,
1011) -> DocumentResult<YamlValue> {
1012    match v {
1013        Value::Null => Ok(YamlValue::Null),
1014        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
1015        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
1016        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
1017        Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
1018        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
1019            format: "YAML".to_string(),
1020            operation: "save".to_string(),
1021            detail: "non-finite float is not representable in YAML".to_string(),
1022        }),
1023        Value::Number(text) => {
1024            if !is_json_number(text) {
1025                return Err(DocumentError::UnsupportedOperation {
1026                    format: "YAML".to_string(),
1027                    operation: "save".to_string(),
1028                    detail: "invalid number literal".to_string(),
1029                });
1030            }
1031            let sentinel = format!("{prefix}{}__", literals.len());
1032            literals.push((sentinel.clone(), text.clone()));
1033            Ok(YamlValue::String(sentinel))
1034        }
1035        Value::String(s) => Ok(YamlValue::String(s.clone())),
1036        Value::Array(a) => {
1037            let seq = a
1038                .iter()
1039                .map(|value| our_value_to_yaml_value(value, prefix, literals))
1040                .collect::<DocumentResult<Vec<_>>>()?;
1041            Ok(YamlValue::Sequence(seq))
1042        }
1043        Value::Object(o) => {
1044            let mut mapping = YamlMapping::new();
1045            for (k, v) in o {
1046                mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
1047            }
1048            Ok(YamlValue::Mapping(mapping))
1049        }
1050    }
1051}
1052
1053fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
1054    if let Value::Number(text) = value
1055        && is_json_number(text)
1056    {
1057        return Ok(text.clone());
1058    }
1059    if matches!(value, Value::Array(_) | Value::Object(_)) {
1060        return save(value);
1061    }
1062    to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
1063        format: "YAML".to_string(),
1064        operation: operation.to_string(),
1065        detail: error.to_string(),
1066    })
1067}
1068
1069fn protect_exact_numbers(
1070    content: &str,
1071) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
1072    let document = parse_document(content).map_err(|error| DocumentError::ParseError {
1073        format: "YAML".to_string(),
1074        detail: error.to_string(),
1075    })?;
1076    let mut spans = Vec::new();
1077    collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
1078    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
1079    while content.contains(&prefix) {
1080        prefix.push('_');
1081    }
1082    let mut protected = content.to_string();
1083    let mut literals = std::collections::HashMap::new();
1084    for (index, (start, end)) in spans.into_iter().enumerate().rev() {
1085        let literal = content[start..end].to_string();
1086        let sentinel = format!("{prefix}{index}__");
1087        let quoted =
1088            serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
1089                format: "YAML".to_string(),
1090                detail: error.to_string(),
1091            })?;
1092        protected.replace_range(start..end, &quoted);
1093        literals.insert(sentinel, literal);
1094    }
1095    Ok((protected, literals))
1096}
1097
1098fn collect_exact_number_spans(
1099    node: &GreenNode,
1100    source: &str,
1101    base: usize,
1102    spans: &mut Vec<(usize, usize)>,
1103) {
1104    let mut offset = base;
1105    for child in node.children() {
1106        match child {
1107            GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
1108            GreenChild::Token {
1109                kind: SyntaxKind::PlainScalar,
1110                len,
1111            } => {
1112                let token_end = offset + *len as usize;
1113                let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
1114                let end = offset + text.len();
1115                if should_preserve_number(text) {
1116                    spans.push((offset, end));
1117                }
1118            }
1119            GreenChild::Token { .. } => {}
1120        }
1121        offset += child.text_len();
1122    }
1123}
1124
1125fn should_preserve_number(text: &str) -> bool {
1126    if !is_json_number(text) {
1127        return false;
1128    }
1129    text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
1130}
1131
1132fn is_json_number(text: &str) -> bool {
1133    serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
1134}
1135
1136fn value_contains_text(value: &Value, needle: &str) -> bool {
1137    match value {
1138        Value::Number(text) | Value::String(text) => text.contains(needle),
1139        Value::Array(values) => values
1140            .iter()
1141            .any(|value| value_contains_text(value, needle)),
1142        Value::Object(values) => values
1143            .iter()
1144            .any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
1145        _ => false,
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::{load, save, set_preserving};
1152    use crate::document::Value;
1153
1154    #[test]
1155    fn new_fields_in_flow_mappings_preserve_their_exact_value_and_source() {
1156        use crate::document::{Document, Format};
1157        for (source, path) in [
1158            ("{}\n", "field"),
1159            ("{existing: before}\n", "field"),
1160            ("outer: {}\n", "outer.field"),
1161            ("# keep\r\n{}\r\n", "field"),
1162        ] {
1163            for value in [
1164                Value::String("new".into()),
1165                Value::Bool(true),
1166                Value::Null,
1167                Value::Integer(7),
1168                Value::Unsigned(7),
1169                Value::Float(2.0),
1170                Value::Array(Vec::new()),
1171                Value::Object(Default::default()),
1172                Value::Number("123456789012345678901234567890".into()),
1173                Value::Number("0.10000000000000000000001".into()),
1174            ] {
1175                let mut document = Document::parse(source, Format::Yaml).unwrap();
1176                document
1177                    .set(path, value.clone())
1178                    .expect("insert into a flow mapping");
1179                assert_eq!(document.value_at(path).unwrap(), value);
1180                assert_eq!(
1181                    load(document.source()).unwrap(),
1182                    load(&save(document.value()).unwrap()).unwrap()
1183                );
1184                if source.starts_with("# keep") {
1185                    assert!(document.source().starts_with("# keep\r\n"));
1186                    assert!(document.source().ends_with("\r\n"));
1187                }
1188            }
1189        }
1190    }
1191
1192    #[test]
1193    fn a_refused_key_cannot_fall_back_to_inserting_multiple_entries() {
1194        use crate::document::{Document, Format};
1195        let source = "attachments:\n";
1196        let mut document = Document::parse(source, Format::Yaml).unwrap();
1197        let original = document.value().clone();
1198        assert!(
1199            document
1200                .set("one:\ntwo", Value::String("injected".into()))
1201                .is_err()
1202        );
1203        assert_eq!(document.source(), source);
1204        assert_eq!(document.value(), &original);
1205    }
1206
1207    #[test]
1208    fn preserves_large_integer_and_high_precision_float_literals() {
1209        let source = concat!(
1210            "huge: 123456789012345678901234567890\n",
1211            "precise: 0.1000000000000000055511151231257827\n",
1212        );
1213        let value = load(source).expect("load");
1214        assert_eq!(
1215            value.get("huge"),
1216            Some(&Value::Number("123456789012345678901234567890".to_string()))
1217        );
1218        assert_eq!(
1219            value.get("precise"),
1220            Some(&Value::Number(
1221                "0.1000000000000000055511151231257827".to_string()
1222            ))
1223        );
1224
1225        let rendered = save(&value).expect("save");
1226        assert!(rendered.contains("123456789012345678901234567890"));
1227        assert!(rendered.contains("0.1000000000000000055511151231257827"));
1228        assert_eq!(load(&rendered).expect("reload"), value);
1229    }
1230
1231    #[test]
1232    fn exact_number_set_keeps_the_literal() {
1233        let edited = set_preserving(
1234            "price: 1.0\n",
1235            "price",
1236            &Value::Number("12345678901234567890.123456789".to_string()),
1237        )
1238        .expect("set");
1239        assert_eq!(edited, "price: 12345678901234567890.123456789\n");
1240        assert_eq!(
1241            load(&edited).expect("reload").get("price"),
1242            Some(&Value::Number("12345678901234567890.123456789".to_string()))
1243        );
1244    }
1245
1246    /// The shape that motivated the fragment editor: a frontmatter block a
1247    /// human has edited, where one collection field changes.
1248    fn contact_source() -> &'static str {
1249        "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n  - alice@example.com  # work\ntags: []\nrole: ''\n"
1250    }
1251
1252    #[test]
1253    fn replacing_a_collection_keeps_every_other_byte() {
1254        let edited = set_preserving(
1255            contact_source(),
1256            "tags",
1257            &Value::Array(vec![Value::String("vip".into())]),
1258        )
1259        .expect("an empty sequence can grow");
1260        assert_eq!(
1261            edited,
1262            "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n  - alice@example.com  # work\ntags:\n  - vip\nrole: ''\n"
1263        );
1264
1265        // Back to empty, and the inline spelling returns rather than a bare key
1266        // (which would parse as null) or a two-line `tags:\n  []`.
1267        let cleared = set_preserving(&edited, "tags", &Value::Array(Vec::new()))
1268            .expect("a sequence can shrink to empty");
1269        assert_eq!(cleared, contact_source());
1270    }
1271
1272    #[test]
1273    fn replacing_a_collection_quotes_elements_that_need_it() {
1274        let edited = set_preserving(
1275            contact_source(),
1276            "tags",
1277            &Value::Array(vec![
1278                Value::String("true".into()),
1279                Value::String("123".into()),
1280                Value::String("a: b".into()),
1281                Value::String("#c".into()),
1282                Value::String(String::new()),
1283            ]),
1284        )
1285        .expect("tricky scalars are the emitter's problem, not ours");
1286        // Round-trips as the strings that went in, not as bool/int/comment.
1287        let loaded = load(&edited).expect("the result parses");
1288        assert_eq!(
1289            crate::document::get_path(&loaded, "tags", crate::document::Addressing::INDEX_ONLY)
1290                .expect("tags"),
1291            Value::Array(vec![
1292                Value::String("true".into()),
1293                Value::String("123".into()),
1294                Value::String("a: b".into()),
1295                Value::String("#c".into()),
1296                Value::String(String::new()),
1297            ])
1298        );
1299    }
1300
1301    #[test]
1302    fn replacing_a_collection_takes_the_comment_that_described_it() {
1303        // The CST's span stops before a trailing comment, so leaving it in
1304        // place would re-point `# work` at whatever the new last element is.
1305        let edited = set_preserving(
1306            "emails:\n  - alice@example.com  # work\nrole: ''\n",
1307            "emails",
1308            &Value::Array(vec![Value::String("bob@example.com".into())]),
1309        )
1310        .expect("replace");
1311        assert_eq!(edited, "emails:\n  - bob@example.com\nrole: ''\n");
1312        assert!(!edited.contains("# work"), "{edited}");
1313    }
1314
1315    #[test]
1316    fn a_collection_reached_through_an_alias_is_refused() {
1317        // `span_at` resolves through the alias to the anchor, so an unguarded
1318        // splice would rewrite `base` when asked for `other`.
1319        let source = "base: &b\n  - x\nother: *b\n";
1320        let error = set_preserving(
1321            source,
1322            "other",
1323            &Value::Array(vec![Value::String("y".into())]),
1324        )
1325        .expect_err("an alias target must not be edited through");
1326        assert!(error.to_string().contains("alias"), "{error}");
1327    }
1328
1329    #[test]
1330    fn a_new_key_can_be_created_with_a_collection_value() {
1331        let edited = set_preserving(
1332            "kind: contact\n",
1333            "tags",
1334            &Value::Array(vec![Value::String("vip".into())]),
1335        )
1336        .expect("a missing key is inserted");
1337        assert_eq!(edited, "kind: contact\ntags:\n  - vip\n");
1338
1339        let empty = set_preserving("kind: contact\n", "tags", &Value::Array(Vec::new()))
1340            .expect("a missing key can be inserted empty");
1341        assert_eq!(empty, "kind: contact\ntags: []\n");
1342    }
1343
1344    #[test]
1345    fn replacing_a_collection_keeps_the_source_newline_style() {
1346        let edited = set_preserving(
1347            "kind: contact\r\ntags: []\r\nrole: ''\r\n",
1348            "tags",
1349            &Value::Array(vec![Value::String("vip".into())]),
1350        )
1351        .expect("CRLF source");
1352        assert_eq!(edited, "kind: contact\r\ntags:\r\n  - vip\r\nrole: ''\r\n");
1353        // No lone LF survives once every CRLF is accounted for.
1354        assert!(
1355            !edited.replace("\r\n", "").contains('\n'),
1356            "mixed endings: {edited:?}"
1357        );
1358    }
1359
1360    #[test]
1361    fn a_new_key_lands_after_a_bare_null_last_entry() {
1362        // A mapping whose last entry has no value node (`attachments:` reads as
1363        // null) gives the CST nothing to anchor an insertion to.
1364        let source = "kind: draft\nattachments:\n";
1365        let edited = set_preserving(source, "sync_intent", &Value::String("send".into()))
1366            .expect("a new key can follow a bare-null entry");
1367        assert_eq!(edited, "kind: draft\nattachments:\nsync_intent: send\n");
1368    }
1369
1370    #[test]
1371    fn save_rejects_non_finite_float() {
1372        let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
1373        assert!(error.to_string().contains("non-finite"));
1374    }
1375}