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    let yaml_path = cst_path(&segments, "set")?;
37    guard_cst_segments(content, &segments, "set", true)?;
38    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
39        format: "YAML".to_string(),
40        detail: error.to_string(),
41    })?;
42    // Existing leaf: replace in place (preserves the scalar's style). Missing
43    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
44    let exists = load(content).ok().is_some_and(|loaded| {
45        crate::document::get_path_ref(&loaded, path, Addressing::INDEX_ONLY).is_ok()
46    });
47    if exists && matches!(value, Value::Array(_) | Value::Object(_)) {
48        replace_collection_in_place(&mut document, content, &yaml_path, value)?;
49    } else if exists {
50        let result = if let Value::Number(text) = value {
51            document.set(&yaml_path, text)
52        } else {
53            document.set_value(&yaml_path, &to_noyalib_value(value)?)
54        };
55        result.map_err(|error| DocumentError::UnsupportedOperation {
56            format: "YAML".to_string(),
57            operation: "set".to_string(),
58            detail: error.to_string(),
59        })?;
60    } else {
61        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
62        if last.parse::<usize>().is_ok() {
63            return Err(DocumentError::UnsupportedOperation {
64                format: "YAML".to_string(),
65                operation: "set".to_string(),
66                detail: "cannot create a new sequence index; the element must already exist"
67                    .to_string(),
68            });
69        }
70        let parent_path = if parents.is_empty() {
71            String::new()
72        } else {
73            cst_path(parents, "set")?
74        };
75        let result = match value {
76            // An empty collection through the typed path lands on its own line
77            // (`tags:` then `  []`); the fragment spelling keeps it inline,
78            // which is how anyone writes it by hand.
79            Value::Array(items) if items.is_empty() => {
80                document.insert_entry(&parent_path, last, "[]")
81            }
82            Value::Object(entries) if entries.is_empty() => {
83                document.insert_entry(&parent_path, last, "{}")
84            }
85            // A new key is the one place noyalib will emit a collection itself,
86            // indenting it under the key and rolling back if the re-parse
87            // disagrees. Preferred over a hand-built fragment for exactly that.
88            Value::Array(_) | Value::Object(_) => {
89                document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
90            }
91            _ => document.insert_entry(&parent_path, last, yaml_fragment(value, "set")?.trim_end()),
92        };
93        if let Err(error) = result {
94            // `insert_entry` places a new entry relative to the last existing
95            // one, so it needs that entry to have a value node to measure. A
96            // mapping ending in a bare key (`attachments:`, which reads as null)
97            // has none, and the insert fails with nowhere to anchor. At the root
98            // there is still an unambiguous answer — the end of the document,
99            // which is where a person adding a key would put it.
100            if parent_path.is_empty() {
101                append_root_entry(&mut document, content, last, value)?;
102            } else {
103                return Err(DocumentError::UnsupportedOperation {
104                    format: "YAML".to_string(),
105                    operation: "set".to_string(),
106                    detail: error.to_string(),
107                });
108            }
109        }
110    }
111    document
112        .validate()
113        .map_err(|error| DocumentError::ParseError {
114            format: "YAML".to_string(),
115            detail: error.to_string(),
116        })?;
117    Ok(document.to_string())
118}
119
120/// Append a new top-level entry at the end of the document.
121///
122/// The fallback for a root mapping the CST cannot anchor an insertion to. The
123/// entry is written at column zero after the last byte, with the document's own
124/// newline style, and the result is validated like any other edit — so a source
125/// this does not suit fails rather than silently producing something else.
126fn append_root_entry(
127    document: &mut noyalib::cst::Document,
128    content: &str,
129    key: &str,
130    value: &Value,
131) -> DocumentResult<()> {
132    let newline = if content.contains("\r\n") {
133        "\r\n"
134    } else {
135        "\n"
136    };
137    let rendered = yaml_fragment(value, "set")?;
138    let rendered = rendered.trim_end_matches(['\r', '\n']);
139    let separator = if content.is_empty() || content.ends_with('\n') {
140        String::new()
141    } else {
142        newline.to_string()
143    };
144    let entry = if matches!(value, Value::Array(_) | Value::Object(_)) {
145        // A collection cannot share the key's line; indent it one unit under.
146        let pad = " ".repeat(document.indent_unit());
147        let body = rendered
148            .lines()
149            .map(|line| format!("{newline}{pad}{line}"))
150            .collect::<String>();
151        format!("{separator}{key}:{body}{newline}")
152    } else {
153        format!("{separator}{key}: {rendered}{newline}")
154    };
155    let end = content.len();
156    document
157        .replace_span(end, end, &entry)
158        .map_err(|error| DocumentError::UnsupportedOperation {
159            format: "YAML".to_string(),
160            operation: "set".to_string(),
161            detail: error.to_string(),
162        })
163}
164
165/// Splice a block-style collection over the span of an existing value.
166///
167/// noyalib's typed `set_value` refuses a sequence or mapping outright, so the
168/// only route is a fragment written over the value's byte span. Three details
169/// make that safe rather than approximate:
170///
171/// * The start widens back over the whitespace between the `:` and the old
172///   value, because a block collection has to begin on its own line — spliced
173///   at the raw span start it would either sit at column 0 or leave the source's
174///   own newline behind as a blank line.
175/// * The end widens over a trailing `#` comment. The span the CST reports stops
176///   before it, so leaving it would re-attach a comment written about the *old*
177///   last element to whatever the new one happens to be. Silently moving a
178///   comment onto a different value is worse than dropping it with the value it
179///   described.
180/// * A path reached through an alias is refused. `span_at` resolves through
181///   aliases to the anchor, so editing `other: *b` would rewrite `base: &b`
182///   instead — a different key than the caller named.
183///
184/// The cost, which has no workaround in this version: comments and blank lines
185/// *inside* the replaced collection are dropped, because no API models them as
186/// movable. Everything outside the span — surrounding keys, their comments,
187/// key order, quote styles, the trailing newline — is preserved byte for byte.
188fn replace_collection_in_place(
189    document: &mut noyalib::cst::Document,
190    content: &str,
191    yaml_path: &str,
192    value: &Value,
193) -> DocumentResult<()> {
194    let unsupported = |detail: String| DocumentError::UnsupportedOperation {
195        format: "YAML".to_string(),
196        operation: "set".to_string(),
197        detail,
198    };
199    let (value_start, value_end) = document
200        .span_at(yaml_path)
201        .ok_or_else(|| unsupported(format!("could not locate the value at `{yaml_path}`")))?;
202    let (key_start, key_end) = document
203        .key_span(yaml_path)
204        .ok_or_else(|| unsupported(format!("could not locate the key at `{yaml_path}`")))?;
205    if value_start < key_end {
206        return Err(unsupported(format!(
207            "`{yaml_path}` resolves through a YAML alias; editing it would rewrite the anchor \
208             instead. Materialize the alias first."
209        )));
210    }
211
212    let bytes = content.as_bytes();
213    let key_column = line_column_of(content, key_start);
214    let indent = key_column + document.indent_unit();
215    let newline = if content.contains("\r\n") {
216        "\r\n"
217    } else {
218        "\n"
219    };
220
221    let mut start = value_start;
222    while start > 0 && matches!(bytes[start - 1], b' ' | b'\t' | b'\n' | b'\r') {
223        start -= 1;
224    }
225    let end = end_of_trailing_inline_comment(content, value_end);
226
227    let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
228    document
229        .replace_span(start, end, &fragment)
230        .map_err(|error| unsupported(error.to_string()))
231}
232
233/// Render `value` as the bytes that go after a mapping key.
234///
235/// An empty collection stays inline (` []`), because a bare `key:` parses as
236/// null rather than as an empty sequence — the space is load-bearing. Anything
237/// else becomes a block, preceded by a newline and indented under its key.
238fn block_fragment(
239    value: &Value,
240    indent_unit: usize,
241    indent: usize,
242    newline: &str,
243) -> DocumentResult<String> {
244    match value {
245        Value::Array(items) if items.is_empty() => return Ok(" []".to_string()),
246        Value::Object(entries) if entries.is_empty() => return Ok(" {}".to_string()),
247        _ => {}
248    }
249    let config = noyalib::SerializerConfig::new()
250        .indent(indent_unit)
251        .flow_style(noyalib::FlowStyle::Block);
252    // Emitted by noyalib rather than by hand: element quoting has to survive
253    // values like `true`, `123`, `a: b` and `#c`, which are only scalars
254    // because they are quoted.
255    let rendered = noyalib::to_string_value_with_config(&to_noyalib_value(value)?, &config)
256        .map_err(|error| DocumentError::UnsupportedOperation {
257            format: "YAML".to_string(),
258            operation: "set".to_string(),
259            detail: error.to_string(),
260        })?;
261    let pad = " ".repeat(indent);
262    let mut fragment = String::new();
263    for line in rendered.trim_end_matches(['\r', '\n']).lines() {
264        fragment.push_str(newline);
265        if line.is_empty() {
266            continue;
267        }
268        fragment.push_str(&pad);
269        fragment.push_str(line);
270    }
271    Ok(fragment)
272}
273
274/// Column of `offset` within its line, counting bytes from the line start.
275fn line_column_of(content: &str, offset: usize) -> usize {
276    let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1);
277    offset - line_start
278}
279
280/// End of the `#` comment trailing the value that ends at `value_end`, or
281/// `value_end` when the rest of the line holds anything else.
282fn end_of_trailing_inline_comment(content: &str, value_end: usize) -> usize {
283    let bytes = content.as_bytes();
284    let mut index = value_end;
285    while index < bytes.len() && matches!(bytes[index], b' ' | b'\t') {
286        index += 1;
287    }
288    if index >= bytes.len() || bytes[index] != b'#' {
289        return value_end;
290    }
291    while index < bytes.len() && bytes[index] != b'\n' {
292        index += 1;
293    }
294    if index > 0 && bytes[index - 1] == b'\r' {
295        index -= 1;
296    }
297    index
298}
299
300/// Remove an existing YAML entry through the lossless CST editor.
301pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
302    let segments = crate::document::parse_path(path)?;
303    let yaml_path = cst_path(&segments, "unset")?;
304    guard_cst_segments(content, &segments, "unset", false)?;
305    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
306        format: "YAML".to_string(),
307        detail: error.to_string(),
308    })?;
309    document
310        .remove(&yaml_path)
311        .map_err(|error| DocumentError::UnsupportedOperation {
312            format: "YAML".to_string(),
313            operation: "unset".to_string(),
314            detail: error.to_string(),
315        })?;
316    document
317        .validate()
318        .map_err(|error| DocumentError::ParseError {
319            format: "YAML".to_string(),
320            detail: error.to_string(),
321        })?;
322    Ok(document.to_string())
323}
324
325/// Append an item to an existing block YAML sequence using the CST's
326/// indentation-aware editor.
327pub fn append_array_item_preserving(
328    content: &str,
329    path: &str,
330    item: &Value,
331) -> DocumentResult<String> {
332    let segments = array_path_segments(path)?;
333    let yaml_path = cst_path(&segments, "add")?;
334    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
335        format: "YAML".to_string(),
336        detail: error.to_string(),
337    })?;
338    let loaded = load(content)?;
339    let existing = array_at_path(&loaded, path, "add")?;
340    let previous_len = existing.len();
341    if previous_len == 0 {
342        // The CST's `push_back` needs an existing item to take indentation
343        // from, so it refuses on `tags: []`. Writing the whole one-element
344        // sequence gets there instead, and costs nothing: an empty collection
345        // has no interior comments or layout to preserve.
346        return set_preserving(content, path, &Value::Array(vec![item.clone()]));
347    }
348    let mut fragment = yaml_fragment(item, "add")?;
349    {
350        let item_path = format!("{yaml_path}[{}]", previous_len - 1);
351        let (item_start, _) =
352            document
353                .span_at(&item_path)
354                .ok_or_else(|| DocumentError::UnsupportedOperation {
355                    format: "YAML".to_string(),
356                    operation: "add".to_string(),
357                    detail: "could not resolve the existing sequence-item indentation".to_string(),
358                })?;
359        let dash_column = preceding_sequence_dash_column(content, item_start).ok_or_else(|| {
360            DocumentError::UnsupportedOperation {
361                format: "YAML".to_string(),
362                operation: "add".to_string(),
363                detail: "only block sequences can be extended without rebuilding the document"
364                    .to_string(),
365            }
366        })?;
367        fragment = indent_continuation_lines(fragment.trim_end(), dash_column + 2);
368    }
369    document
370        .push_back(&yaml_path, fragment.trim_end())
371        .map_err(|error| DocumentError::UnsupportedOperation {
372            format: "YAML".to_string(),
373            operation: "add".to_string(),
374            detail: error.to_string(),
375        })?;
376    document
377        .validate()
378        .map_err(|error| DocumentError::ParseError {
379            format: "YAML".to_string(),
380            detail: error.to_string(),
381        })?;
382    let output = document.to_string();
383    let edited = load(&output)?;
384    let edited_items = array_at_path(&edited, path, "add")?;
385    if edited_items.len() != previous_len + 1 || edited_items.last() != Some(item) {
386        return Err(DocumentError::UnsupportedOperation {
387            format: "YAML".to_string(),
388            operation: "add".to_string(),
389            detail: "the source edit did not reproduce the requested keyed item".to_string(),
390        });
391    }
392    Ok(output)
393}
394
395/// Remove one item from a YAML sequence by numeric index.
396pub fn remove_array_item_preserving(
397    content: &str,
398    path: &str,
399    index: usize,
400) -> DocumentResult<String> {
401    let segments = array_path_segments(path)?;
402    let loaded = load(content)?;
403    if array_at_path(&loaded, path, "remove")?.len() == 1 && index == 0 {
404        // The CST refuses to delete a sequence's only entry — removing the last
405        // `- item` line would leave a bare `key:`, which reads back as null
406        // rather than as an empty sequence. Writing the empty sequence says
407        // that outright, and mirrors the append path, which likewise falls back
408        // to a whole-value write when there is no item to anchor to.
409        return set_preserving(content, path, &Value::Array(Vec::new()));
410    }
411    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
412        format: "YAML".to_string(),
413        detail: error.to_string(),
414    })?;
415    let yaml_path = cst_path(&segments, "remove")?;
416    document
417        .remove(&format!("{yaml_path}[{index}]"))
418        .map_err(|error| DocumentError::UnsupportedOperation {
419            format: "YAML".to_string(),
420            operation: "remove".to_string(),
421            detail: error.to_string(),
422        })?;
423    document
424        .validate()
425        .map_err(|error| DocumentError::ParseError {
426            format: "YAML".to_string(),
427            detail: error.to_string(),
428        })?;
429    Ok(document.to_string())
430}
431
432/// An empty keyed-list prefix names a root sequence. Ordinary set/unset paths
433/// still pass through `parse_path` directly and therefore remain non-empty.
434fn array_path_segments(path: &str) -> DocumentResult<Vec<String>> {
435    if path.is_empty() {
436        Ok(Vec::new())
437    } else {
438        crate::document::parse_path(path)
439    }
440}
441
442fn array_at_path<'a>(
443    value: &'a Value,
444    path: &str,
445    operation: &str,
446) -> DocumentResult<&'a Vec<Value>> {
447    let target = if path.is_empty() {
448        value
449    } else {
450        crate::document::get_path_ref(value, path, Addressing::INDEX_ONLY)?
451    };
452    target
453        .as_array()
454        .ok_or_else(|| DocumentError::UnsupportedOperation {
455            format: "YAML".to_string(),
456            operation: operation.to_string(),
457            detail: "target is not an array".to_string(),
458        })
459}
460
461fn preceding_sequence_dash_column(content: &str, value_start: usize) -> Option<usize> {
462    let bytes = content.as_bytes();
463    let mut position = value_start;
464    let dash = loop {
465        position = position.checked_sub(1)?;
466        match bytes.get(position)? {
467            b' ' | b'\t' => {}
468            b'-' => break position,
469            _ => return None,
470        }
471    };
472    let line_start = content.as_bytes()[..dash]
473        .iter()
474        .rposition(|byte| *byte == b'\n')
475        .map_or(0, |newline| newline + 1);
476    Some(dash - line_start)
477}
478
479fn indent_continuation_lines(fragment: &str, indent: usize) -> String {
480    if !fragment.contains('\n') {
481        return fragment.to_string();
482    }
483    let padding = " ".repeat(indent);
484    let mut output = String::with_capacity(fragment.len() + indent * 4);
485    for (index, line) in fragment.split('\n').enumerate() {
486        if index > 0 {
487            output.push('\n');
488            if !line.is_empty() {
489                output.push_str(&padding);
490            }
491        }
492        output.push_str(line);
493    }
494    output
495}
496
497fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
498    let mut path = String::new();
499    for segment in segments {
500        if segment.contains(['.', '\\', '[', ']']) {
501            return Err(DocumentError::UnsupportedOperation {
502                format: "YAML".to_string(),
503                operation: operation.to_string(),
504                detail:
505                    "escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
506                        .to_string(),
507            });
508        }
509        if let Ok(index) = segment.parse::<usize>() {
510            path.push_str(&format!("[{index}]"));
511        } else {
512            if !path.is_empty() {
513                path.push('.');
514            }
515            path.push_str(segment);
516        }
517    }
518    Ok(path)
519}
520
521fn guard_cst_segments(
522    content: &str,
523    segments: &[String],
524    operation: &str,
525    allow_missing: bool,
526) -> DocumentResult<()> {
527    let root = load(content)?;
528    let mut current = &root;
529    for (index, segment) in segments.iter().enumerate() {
530        match current {
531            Value::Object(object) => {
532                if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
533                    return Err(DocumentError::UnsupportedOperation {
534                        format: "YAML".to_string(),
535                        operation: operation.to_string(),
536                        detail: format!(
537                            "mapping key `{segment}` is ambiguous in the CST path grammar"
538                        ),
539                    });
540                }
541                match object.get(segment) {
542                    Some(next) => current = next,
543                    None if allow_missing => {
544                        if segments[index..]
545                            .iter()
546                            .any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
547                        {
548                            return Err(DocumentError::UnsupportedOperation {
549                                format: "YAML".to_string(),
550                                operation: operation.to_string(),
551                                detail: "a missing mapping chain contains a CST-ambiguous key"
552                                    .to_string(),
553                            });
554                        }
555                        return Ok(());
556                    }
557                    None => {
558                        return Err(DocumentError::PathNotFound {
559                            path: crate::document::join_path(&segments[..=index]),
560                        });
561                    }
562                }
563            }
564            Value::Array(values) => {
565                let array_index =
566                    segment
567                        .parse::<usize>()
568                        .map_err(|_| DocumentError::UnregisteredArray {
569                            path: crate::document::join_path(&segments[..index]),
570                        })?;
571                current =
572                    values
573                        .get(array_index)
574                        .ok_or_else(|| DocumentError::IndexOutOfBounds {
575                            path: crate::document::join_path(&segments[..index]),
576                            index: array_index,
577                            len: values.len(),
578                        })?;
579            }
580            value => {
581                return Err(DocumentError::NotTraversable {
582                    path: crate::document::join_path(&segments[..index]),
583                    got: value.kind_name().to_string(),
584                });
585            }
586        }
587    }
588    Ok(())
589}
590
591fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
592    match value {
593        Value::Null => Ok(YamlValue::Null),
594        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
595        Value::Integer(value) => Ok(YamlValue::from(*value)),
596        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
597        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
598        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
599            format: "YAML".to_string(),
600            operation: "set".to_string(),
601            detail: "non-finite YAML float is not representable".to_string(),
602        }),
603        // See the identical fallback in `format::toml::toml_item`: a
604        // float-shaped `Value::Number` literal parses to `f64` cleanly (YAML
605        // floats are canonically `f64` at the format level too); an
606        // integer-shaped one only exists because it overflows `u64`, so it
607        // can never be written through noyalib's numeric `Value`.
608        Value::Number(text) if value.is_float() => text
609            .parse::<f64>()
610            .ok()
611            .filter(|value| value.is_finite())
612            .map(YamlValue::from)
613            .ok_or_else(|| DocumentError::UnsupportedOperation {
614                format: "YAML".to_string(),
615                operation: "set".to_string(),
616                detail: format!("float literal `{text}` is not representable in YAML"),
617            }),
618        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
619            format: "YAML".to_string(),
620            operation: "set".to_string(),
621            detail: format!("integer literal `{text}` exceeds YAML's 64-bit integer range"),
622        }),
623        Value::String(value) => Ok(YamlValue::String(value.clone())),
624        Value::Array(values) => Ok(YamlValue::Sequence(
625            values
626                .iter()
627                .map(to_noyalib_value)
628                .collect::<DocumentResult<Vec<_>>>()?,
629        )),
630        Value::Object(values) => {
631            let mut mapping = YamlMapping::new();
632            for (key, value) in values {
633                mapping.insert(key.clone(), to_noyalib_value(value)?);
634            }
635            Ok(YamlValue::Mapping(mapping))
636        }
637    }
638}
639
640pub fn save(value: &Value) -> DocumentResult<String> {
641    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
642    while value_contains_text(value, &prefix) {
643        prefix.push('_');
644    }
645    let mut literals = Vec::new();
646    let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
647    let mut output = to_string(&yaml_val).map_err(|e| DocumentError::ParseError {
648        format: "YAML".to_string(),
649        detail: e.to_string(),
650    })?;
651    for (sentinel, literal) in literals {
652        output = output.replace(&sentinel, &literal);
653    }
654    Ok(output)
655}
656
657fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
658    match v {
659        YamlValue::Null => Value::Null,
660        YamlValue::Bool(b) => Value::Bool(b),
661        YamlValue::Number(n) => {
662            if let Some(i) = n.as_i64() {
663                Value::Integer(i)
664            } else if let Some(u) = n.as_u64() {
665                Value::Unsigned(u)
666            } else {
667                Value::Float(n.as_f64())
668            }
669        }
670        YamlValue::String(s) => literals
671            .get(&s)
672            .cloned()
673            .map(Value::Number)
674            .unwrap_or(Value::String(s)),
675        YamlValue::Sequence(seq) => Value::Array(
676            seq.into_iter()
677                .map(|value| value_to_our_value(value, literals))
678                .collect(),
679        ),
680        YamlValue::Mapping(map) => {
681            let mut obj = std::collections::BTreeMap::new();
682            for (key, value) in map {
683                let key = literals.get(&key).cloned().unwrap_or(key);
684                obj.insert(key, value_to_our_value(value, literals));
685            }
686            Value::Object(obj)
687        }
688        YamlValue::Tagged(t) => {
689            // Tagged values: recurse on inner value
690            let (_, value) = t.into_parts();
691            value_to_our_value(value, literals)
692        }
693    }
694}
695
696fn our_value_to_yaml_value(
697    v: &Value,
698    prefix: &str,
699    literals: &mut Vec<(String, String)>,
700) -> DocumentResult<YamlValue> {
701    match v {
702        Value::Null => Ok(YamlValue::Null),
703        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
704        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
705        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
706        Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
707        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
708            format: "YAML".to_string(),
709            operation: "save".to_string(),
710            detail: "non-finite float is not representable in YAML".to_string(),
711        }),
712        Value::Number(text) => {
713            if !is_json_number(text) {
714                return Err(DocumentError::UnsupportedOperation {
715                    format: "YAML".to_string(),
716                    operation: "save".to_string(),
717                    detail: format!("invalid number literal `{text}`"),
718                });
719            }
720            let sentinel = format!("{prefix}{}__", literals.len());
721            literals.push((sentinel.clone(), text.clone()));
722            Ok(YamlValue::String(sentinel))
723        }
724        Value::String(s) => Ok(YamlValue::String(s.clone())),
725        Value::Array(a) => {
726            let seq = a
727                .iter()
728                .map(|value| our_value_to_yaml_value(value, prefix, literals))
729                .collect::<DocumentResult<Vec<_>>>()?;
730            Ok(YamlValue::Sequence(seq))
731        }
732        Value::Object(o) => {
733            let mut mapping = YamlMapping::new();
734            for (k, v) in o {
735                mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
736            }
737            Ok(YamlValue::Mapping(mapping))
738        }
739    }
740}
741
742fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
743    if let Value::Number(text) = value
744        && is_json_number(text)
745    {
746        return Ok(text.clone());
747    }
748    if matches!(value, Value::Array(_) | Value::Object(_)) {
749        return save(value);
750    }
751    to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
752        format: "YAML".to_string(),
753        operation: operation.to_string(),
754        detail: error.to_string(),
755    })
756}
757
758fn protect_exact_numbers(
759    content: &str,
760) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
761    let document = parse_document(content).map_err(|error| DocumentError::ParseError {
762        format: "YAML".to_string(),
763        detail: error.to_string(),
764    })?;
765    let mut spans = Vec::new();
766    collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
767    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
768    while content.contains(&prefix) {
769        prefix.push('_');
770    }
771    let mut protected = content.to_string();
772    let mut literals = std::collections::HashMap::new();
773    for (index, (start, end)) in spans.into_iter().enumerate().rev() {
774        let literal = content[start..end].to_string();
775        let sentinel = format!("{prefix}{index}__");
776        let quoted =
777            serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
778                format: "YAML".to_string(),
779                detail: error.to_string(),
780            })?;
781        protected.replace_range(start..end, &quoted);
782        literals.insert(sentinel, literal);
783    }
784    Ok((protected, literals))
785}
786
787fn collect_exact_number_spans(
788    node: &GreenNode,
789    source: &str,
790    base: usize,
791    spans: &mut Vec<(usize, usize)>,
792) {
793    let mut offset = base;
794    for child in node.children() {
795        match child {
796            GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
797            GreenChild::Token {
798                kind: SyntaxKind::PlainScalar,
799                len,
800            } => {
801                let token_end = offset + *len as usize;
802                let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
803                let end = offset + text.len();
804                if should_preserve_number(text) {
805                    spans.push((offset, end));
806                }
807            }
808            GreenChild::Token { .. } => {}
809        }
810        offset += child.text_len();
811    }
812}
813
814fn should_preserve_number(text: &str) -> bool {
815    if !is_json_number(text) {
816        return false;
817    }
818    text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
819}
820
821fn is_json_number(text: &str) -> bool {
822    serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
823}
824
825fn value_contains_text(value: &Value, needle: &str) -> bool {
826    match value {
827        Value::Number(text) | Value::String(text) => text.contains(needle),
828        Value::Array(values) => values
829            .iter()
830            .any(|value| value_contains_text(value, needle)),
831        Value::Object(values) => values
832            .iter()
833            .any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
834        _ => false,
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::{load, save, set_preserving};
841    use crate::document::Value;
842
843    #[test]
844    fn preserves_large_integer_and_high_precision_float_literals() {
845        let source = concat!(
846            "huge: 123456789012345678901234567890\n",
847            "precise: 0.1000000000000000055511151231257827\n",
848        );
849        let value = load(source).expect("load");
850        assert_eq!(
851            value.get("huge"),
852            Some(&Value::Number("123456789012345678901234567890".to_string()))
853        );
854        assert_eq!(
855            value.get("precise"),
856            Some(&Value::Number(
857                "0.1000000000000000055511151231257827".to_string()
858            ))
859        );
860
861        let rendered = save(&value).expect("save");
862        assert!(rendered.contains("123456789012345678901234567890"));
863        assert!(rendered.contains("0.1000000000000000055511151231257827"));
864        assert_eq!(load(&rendered).expect("reload"), value);
865    }
866
867    #[test]
868    fn exact_number_set_keeps_the_literal() {
869        let edited = set_preserving(
870            "price: 1.0\n",
871            "price",
872            &Value::Number("12345678901234567890.123456789".to_string()),
873        )
874        .expect("set");
875        assert_eq!(edited, "price: 12345678901234567890.123456789\n");
876        assert_eq!(
877            load(&edited).expect("reload").get("price"),
878            Some(&Value::Number("12345678901234567890.123456789".to_string()))
879        );
880    }
881
882    /// The shape that motivated the fragment editor: a frontmatter block a
883    /// human has edited, where one collection field changes.
884    fn contact_source() -> &'static str {
885        "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n  - alice@example.com  # work\ntags: []\nrole: ''\n"
886    }
887
888    #[test]
889    fn replacing_a_collection_keeps_every_other_byte() {
890        let edited = set_preserving(
891            contact_source(),
892            "tags",
893            &Value::Array(vec![Value::String("vip".into())]),
894        )
895        .expect("an empty sequence can grow");
896        assert_eq!(
897            edited,
898            "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n  - alice@example.com  # work\ntags:\n  - vip\nrole: ''\n"
899        );
900
901        // Back to empty, and the inline spelling returns rather than a bare key
902        // (which would parse as null) or a two-line `tags:\n  []`.
903        let cleared = set_preserving(&edited, "tags", &Value::Array(Vec::new()))
904            .expect("a sequence can shrink to empty");
905        assert_eq!(cleared, contact_source());
906    }
907
908    #[test]
909    fn replacing_a_collection_quotes_elements_that_need_it() {
910        let edited = set_preserving(
911            contact_source(),
912            "tags",
913            &Value::Array(vec![
914                Value::String("true".into()),
915                Value::String("123".into()),
916                Value::String("a: b".into()),
917                Value::String("#c".into()),
918                Value::String(String::new()),
919            ]),
920        )
921        .expect("tricky scalars are the emitter's problem, not ours");
922        // Round-trips as the strings that went in, not as bool/int/comment.
923        let loaded = load(&edited).expect("the result parses");
924        assert_eq!(
925            crate::document::get_path(&loaded, "tags", crate::document::Addressing::INDEX_ONLY)
926                .expect("tags"),
927            Value::Array(vec![
928                Value::String("true".into()),
929                Value::String("123".into()),
930                Value::String("a: b".into()),
931                Value::String("#c".into()),
932                Value::String(String::new()),
933            ])
934        );
935    }
936
937    #[test]
938    fn replacing_a_collection_takes_the_comment_that_described_it() {
939        // The CST's span stops before a trailing comment, so leaving it in
940        // place would re-point `# work` at whatever the new last element is.
941        let edited = set_preserving(
942            "emails:\n  - alice@example.com  # work\nrole: ''\n",
943            "emails",
944            &Value::Array(vec![Value::String("bob@example.com".into())]),
945        )
946        .expect("replace");
947        assert_eq!(edited, "emails:\n  - bob@example.com\nrole: ''\n");
948        assert!(!edited.contains("# work"), "{edited}");
949    }
950
951    #[test]
952    fn a_collection_reached_through_an_alias_is_refused() {
953        // `span_at` resolves through the alias to the anchor, so an unguarded
954        // splice would rewrite `base` when asked for `other`.
955        let source = "base: &b\n  - x\nother: *b\n";
956        let error = set_preserving(
957            source,
958            "other",
959            &Value::Array(vec![Value::String("y".into())]),
960        )
961        .expect_err("an alias target must not be edited through");
962        assert!(error.to_string().contains("alias"), "{error}");
963    }
964
965    #[test]
966    fn a_new_key_can_be_created_with_a_collection_value() {
967        let edited = set_preserving(
968            "kind: contact\n",
969            "tags",
970            &Value::Array(vec![Value::String("vip".into())]),
971        )
972        .expect("a missing key is inserted");
973        assert_eq!(edited, "kind: contact\ntags:\n  - vip\n");
974
975        let empty = set_preserving("kind: contact\n", "tags", &Value::Array(Vec::new()))
976            .expect("a missing key can be inserted empty");
977        assert_eq!(empty, "kind: contact\ntags: []\n");
978    }
979
980    #[test]
981    fn replacing_a_collection_keeps_the_source_newline_style() {
982        let edited = set_preserving(
983            "kind: contact\r\ntags: []\r\nrole: ''\r\n",
984            "tags",
985            &Value::Array(vec![Value::String("vip".into())]),
986        )
987        .expect("CRLF source");
988        assert_eq!(edited, "kind: contact\r\ntags:\r\n  - vip\r\nrole: ''\r\n");
989        // No lone LF survives once every CRLF is accounted for.
990        assert!(
991            !edited.replace("\r\n", "").contains('\n'),
992            "mixed endings: {edited:?}"
993        );
994    }
995
996    #[test]
997    fn a_new_key_lands_after_a_bare_null_last_entry() {
998        // A mapping whose last entry has no value node (`attachments:` reads as
999        // null) gives the CST nothing to anchor an insertion to.
1000        let source = "kind: draft\nattachments:\n";
1001        let edited = set_preserving(source, "sync_intent", &Value::String("send".into()))
1002            .expect("a new key can follow a bare-null entry");
1003        assert_eq!(edited, "kind: draft\nattachments:\nsync_intent: send\n");
1004    }
1005
1006    #[test]
1007    fn save_rejects_non_finite_float() {
1008        let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
1009        assert!(error.to_string().contains("non-finite"));
1010    }
1011}