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