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