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 scalar in a YAML source document while retaining all unrelated
26/// source bytes. Escaped keys, keyed-list routes, and collection replacement
27/// are deliberately rejected until they have a lossless CST path adapter.
28pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
29    let segments = crate::document::parse_path(path)?;
30    let yaml_path = cst_path(&segments, "set")?;
31    guard_cst_segments(content, &segments, "set", true)?;
32    if !matches!(
33        value,
34        Value::Null
35            | Value::Bool(_)
36            | Value::Integer(_)
37            | Value::Unsigned(_)
38            | Value::Float(_)
39            | Value::Number(_)
40            | Value::String(_)
41    ) {
42        return Err(DocumentError::UnsupportedOperation {
43            format: "YAML".to_string(),
44            operation: "set".to_string(),
45            detail: "collection mutation requires a dedicated CST fragment editor".to_string(),
46        });
47    }
48    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
49        format: "YAML".to_string(),
50        detail: error.to_string(),
51    })?;
52    // Existing leaf: replace in place (preserves the scalar's style). Missing
53    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
54    let exists = load(content).ok().is_some_and(|loaded| {
55        crate::document::get_path_ref(&loaded, path, Addressing::INDEX_ONLY).is_ok()
56    });
57    if exists {
58        let result = if let Value::Number(text) = value {
59            document.set(&yaml_path, text)
60        } else {
61            document.set_value(&yaml_path, &to_noyalib_value(value)?)
62        };
63        result.map_err(|error| DocumentError::UnsupportedOperation {
64            format: "YAML".to_string(),
65            operation: "set".to_string(),
66            detail: error.to_string(),
67        })?;
68    } else {
69        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
70        if last.parse::<usize>().is_ok() {
71            return Err(DocumentError::UnsupportedOperation {
72                format: "YAML".to_string(),
73                operation: "set".to_string(),
74                detail: "cannot create a new sequence index; the element must already exist"
75                    .to_string(),
76            });
77        }
78        let parent_path = if parents.is_empty() {
79            String::new()
80        } else {
81            cst_path(parents, "set")?
82        };
83        let fragment = yaml_fragment(value, "set")?;
84        document
85            .insert_entry(&parent_path, last, fragment.trim_end())
86            .map_err(|error| DocumentError::UnsupportedOperation {
87                format: "YAML".to_string(),
88                operation: "set".to_string(),
89                detail: error.to_string(),
90            })?;
91    }
92    document
93        .validate()
94        .map_err(|error| DocumentError::ParseError {
95            format: "YAML".to_string(),
96            detail: error.to_string(),
97        })?;
98    Ok(document.to_string())
99}
100
101/// Remove an existing YAML entry through the lossless CST editor.
102pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
103    let segments = crate::document::parse_path(path)?;
104    let yaml_path = cst_path(&segments, "unset")?;
105    guard_cst_segments(content, &segments, "unset", false)?;
106    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
107        format: "YAML".to_string(),
108        detail: error.to_string(),
109    })?;
110    document
111        .remove(&yaml_path)
112        .map_err(|error| DocumentError::UnsupportedOperation {
113            format: "YAML".to_string(),
114            operation: "unset".to_string(),
115            detail: error.to_string(),
116        })?;
117    document
118        .validate()
119        .map_err(|error| DocumentError::ParseError {
120            format: "YAML".to_string(),
121            detail: error.to_string(),
122        })?;
123    Ok(document.to_string())
124}
125
126/// Append an item to an existing block YAML sequence using the CST's
127/// indentation-aware editor.
128pub fn append_array_item_preserving(
129    content: &str,
130    path: &str,
131    item: &Value,
132) -> DocumentResult<String> {
133    let segments = array_path_segments(path)?;
134    let yaml_path = cst_path(&segments, "add")?;
135    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
136        format: "YAML".to_string(),
137        detail: error.to_string(),
138    })?;
139    let loaded = load(content)?;
140    let existing = array_at_path(&loaded, path, "add")?;
141    let previous_len = existing.len();
142    let mut fragment = yaml_fragment(item, "add")?;
143    if previous_len > 0 {
144        let item_path = format!("{yaml_path}[{}]", previous_len - 1);
145        let (item_start, _) =
146            document
147                .span_at(&item_path)
148                .ok_or_else(|| DocumentError::UnsupportedOperation {
149                    format: "YAML".to_string(),
150                    operation: "add".to_string(),
151                    detail: "could not resolve the existing sequence-item indentation".to_string(),
152                })?;
153        let dash_column = preceding_sequence_dash_column(content, item_start).ok_or_else(|| {
154            DocumentError::UnsupportedOperation {
155                format: "YAML".to_string(),
156                operation: "add".to_string(),
157                detail: "only block sequences can be extended without rebuilding the document"
158                    .to_string(),
159            }
160        })?;
161        fragment = indent_continuation_lines(fragment.trim_end(), dash_column + 2);
162    }
163    document
164        .push_back(&yaml_path, fragment.trim_end())
165        .map_err(|error| DocumentError::UnsupportedOperation {
166            format: "YAML".to_string(),
167            operation: "add".to_string(),
168            detail: error.to_string(),
169        })?;
170    document
171        .validate()
172        .map_err(|error| DocumentError::ParseError {
173            format: "YAML".to_string(),
174            detail: error.to_string(),
175        })?;
176    let output = document.to_string();
177    let edited = load(&output)?;
178    let edited_items = array_at_path(&edited, path, "add")?;
179    if edited_items.len() != previous_len + 1 || edited_items.last() != Some(item) {
180        return Err(DocumentError::UnsupportedOperation {
181            format: "YAML".to_string(),
182            operation: "add".to_string(),
183            detail: "the source edit did not reproduce the requested keyed item".to_string(),
184        });
185    }
186    Ok(output)
187}
188
189/// Remove one item from a YAML sequence by numeric index.
190pub fn remove_array_item_preserving(
191    content: &str,
192    path: &str,
193    index: usize,
194) -> DocumentResult<String> {
195    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
196        format: "YAML".to_string(),
197        detail: error.to_string(),
198    })?;
199    let yaml_path = cst_path(&array_path_segments(path)?, "remove")?;
200    document
201        .remove(&format!("{yaml_path}[{index}]"))
202        .map_err(|error| DocumentError::UnsupportedOperation {
203            format: "YAML".to_string(),
204            operation: "remove".to_string(),
205            detail: error.to_string(),
206        })?;
207    document
208        .validate()
209        .map_err(|error| DocumentError::ParseError {
210            format: "YAML".to_string(),
211            detail: error.to_string(),
212        })?;
213    Ok(document.to_string())
214}
215
216/// An empty keyed-list prefix names a root sequence. Ordinary set/unset paths
217/// still pass through `parse_path` directly and therefore remain non-empty.
218fn array_path_segments(path: &str) -> DocumentResult<Vec<String>> {
219    if path.is_empty() {
220        Ok(Vec::new())
221    } else {
222        crate::document::parse_path(path)
223    }
224}
225
226fn array_at_path<'a>(
227    value: &'a Value,
228    path: &str,
229    operation: &str,
230) -> DocumentResult<&'a Vec<Value>> {
231    let target = if path.is_empty() {
232        value
233    } else {
234        crate::document::get_path_ref(value, path, Addressing::INDEX_ONLY)?
235    };
236    target
237        .as_array()
238        .ok_or_else(|| DocumentError::UnsupportedOperation {
239            format: "YAML".to_string(),
240            operation: operation.to_string(),
241            detail: "target is not an array".to_string(),
242        })
243}
244
245fn preceding_sequence_dash_column(content: &str, value_start: usize) -> Option<usize> {
246    let bytes = content.as_bytes();
247    let mut position = value_start;
248    let dash = loop {
249        position = position.checked_sub(1)?;
250        match bytes.get(position)? {
251            b' ' | b'\t' => {}
252            b'-' => break position,
253            _ => return None,
254        }
255    };
256    let line_start = content.as_bytes()[..dash]
257        .iter()
258        .rposition(|byte| *byte == b'\n')
259        .map_or(0, |newline| newline + 1);
260    Some(dash - line_start)
261}
262
263fn indent_continuation_lines(fragment: &str, indent: usize) -> String {
264    if !fragment.contains('\n') {
265        return fragment.to_string();
266    }
267    let padding = " ".repeat(indent);
268    let mut output = String::with_capacity(fragment.len() + indent * 4);
269    for (index, line) in fragment.split('\n').enumerate() {
270        if index > 0 {
271            output.push('\n');
272            if !line.is_empty() {
273                output.push_str(&padding);
274            }
275        }
276        output.push_str(line);
277    }
278    output
279}
280
281fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
282    let mut path = String::new();
283    for segment in segments {
284        if segment.contains(['.', '\\', '[', ']']) {
285            return Err(DocumentError::UnsupportedOperation {
286                format: "YAML".to_string(),
287                operation: operation.to_string(),
288                detail:
289                    "escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
290                        .to_string(),
291            });
292        }
293        if let Ok(index) = segment.parse::<usize>() {
294            path.push_str(&format!("[{index}]"));
295        } else {
296            if !path.is_empty() {
297                path.push('.');
298            }
299            path.push_str(segment);
300        }
301    }
302    Ok(path)
303}
304
305fn guard_cst_segments(
306    content: &str,
307    segments: &[String],
308    operation: &str,
309    allow_missing: bool,
310) -> DocumentResult<()> {
311    let root = load(content)?;
312    let mut current = &root;
313    for (index, segment) in segments.iter().enumerate() {
314        match current {
315            Value::Object(object) => {
316                if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
317                    return Err(DocumentError::UnsupportedOperation {
318                        format: "YAML".to_string(),
319                        operation: operation.to_string(),
320                        detail: format!(
321                            "mapping key `{segment}` is ambiguous in the CST path grammar"
322                        ),
323                    });
324                }
325                match object.get(segment) {
326                    Some(next) => current = next,
327                    None if allow_missing => {
328                        if segments[index..]
329                            .iter()
330                            .any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
331                        {
332                            return Err(DocumentError::UnsupportedOperation {
333                                format: "YAML".to_string(),
334                                operation: operation.to_string(),
335                                detail: "a missing mapping chain contains a CST-ambiguous key"
336                                    .to_string(),
337                            });
338                        }
339                        return Ok(());
340                    }
341                    None => {
342                        return Err(DocumentError::PathNotFound {
343                            path: crate::document::join_path(&segments[..=index]),
344                        });
345                    }
346                }
347            }
348            Value::Array(values) => {
349                let array_index =
350                    segment
351                        .parse::<usize>()
352                        .map_err(|_| DocumentError::UnregisteredArray {
353                            path: crate::document::join_path(&segments[..index]),
354                        })?;
355                current =
356                    values
357                        .get(array_index)
358                        .ok_or_else(|| DocumentError::IndexOutOfBounds {
359                            path: crate::document::join_path(&segments[..index]),
360                            index: array_index,
361                            len: values.len(),
362                        })?;
363            }
364            value => {
365                return Err(DocumentError::NotTraversable {
366                    path: crate::document::join_path(&segments[..index]),
367                    got: value.kind_name().to_string(),
368                });
369            }
370        }
371    }
372    Ok(())
373}
374
375fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
376    match value {
377        Value::Null => Ok(YamlValue::Null),
378        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
379        Value::Integer(value) => Ok(YamlValue::from(*value)),
380        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
381        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
382        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
383            format: "YAML".to_string(),
384            operation: "set".to_string(),
385            detail: "non-finite YAML float is not representable".to_string(),
386        }),
387        // See the identical fallback in `format::toml::toml_item`: a
388        // float-shaped `Value::Number` literal parses to `f64` cleanly (YAML
389        // floats are canonically `f64` at the format level too); an
390        // integer-shaped one only exists because it overflows `u64`, so it
391        // can never be written through noyalib's numeric `Value`.
392        Value::Number(text) if value.is_float() => text
393            .parse::<f64>()
394            .ok()
395            .filter(|value| value.is_finite())
396            .map(YamlValue::from)
397            .ok_or_else(|| DocumentError::UnsupportedOperation {
398                format: "YAML".to_string(),
399                operation: "set".to_string(),
400                detail: format!("float literal `{text}` is not representable in YAML"),
401            }),
402        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
403            format: "YAML".to_string(),
404            operation: "set".to_string(),
405            detail: format!("integer literal `{text}` exceeds YAML's 64-bit integer range"),
406        }),
407        Value::String(value) => Ok(YamlValue::String(value.clone())),
408        Value::Array(values) => Ok(YamlValue::Sequence(
409            values
410                .iter()
411                .map(to_noyalib_value)
412                .collect::<DocumentResult<Vec<_>>>()?,
413        )),
414        Value::Object(values) => {
415            let mut mapping = YamlMapping::new();
416            for (key, value) in values {
417                mapping.insert(key.clone(), to_noyalib_value(value)?);
418            }
419            Ok(YamlValue::Mapping(mapping))
420        }
421    }
422}
423
424pub fn save(value: &Value) -> DocumentResult<String> {
425    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
426    while value_contains_text(value, &prefix) {
427        prefix.push('_');
428    }
429    let mut literals = Vec::new();
430    let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
431    let mut output = to_string(&yaml_val).map_err(|e| DocumentError::ParseError {
432        format: "YAML".to_string(),
433        detail: e.to_string(),
434    })?;
435    for (sentinel, literal) in literals {
436        output = output.replace(&sentinel, &literal);
437    }
438    Ok(output)
439}
440
441fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
442    match v {
443        YamlValue::Null => Value::Null,
444        YamlValue::Bool(b) => Value::Bool(b),
445        YamlValue::Number(n) => {
446            if let Some(i) = n.as_i64() {
447                Value::Integer(i)
448            } else if let Some(u) = n.as_u64() {
449                Value::Unsigned(u)
450            } else {
451                Value::Float(n.as_f64())
452            }
453        }
454        YamlValue::String(s) => literals
455            .get(&s)
456            .cloned()
457            .map(Value::Number)
458            .unwrap_or(Value::String(s)),
459        YamlValue::Sequence(seq) => Value::Array(
460            seq.into_iter()
461                .map(|value| value_to_our_value(value, literals))
462                .collect(),
463        ),
464        YamlValue::Mapping(map) => {
465            let mut obj = std::collections::BTreeMap::new();
466            for (key, value) in map {
467                let key = literals.get(&key).cloned().unwrap_or(key);
468                obj.insert(key, value_to_our_value(value, literals));
469            }
470            Value::Object(obj)
471        }
472        YamlValue::Tagged(t) => {
473            // Tagged values: recurse on inner value
474            let (_, value) = t.into_parts();
475            value_to_our_value(value, literals)
476        }
477    }
478}
479
480fn our_value_to_yaml_value(
481    v: &Value,
482    prefix: &str,
483    literals: &mut Vec<(String, String)>,
484) -> DocumentResult<YamlValue> {
485    match v {
486        Value::Null => Ok(YamlValue::Null),
487        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
488        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
489        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
490        Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
491        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
492            format: "YAML".to_string(),
493            operation: "save".to_string(),
494            detail: "non-finite float is not representable in YAML".to_string(),
495        }),
496        Value::Number(text) => {
497            if !is_json_number(text) {
498                return Err(DocumentError::UnsupportedOperation {
499                    format: "YAML".to_string(),
500                    operation: "save".to_string(),
501                    detail: format!("invalid number literal `{text}`"),
502                });
503            }
504            let sentinel = format!("{prefix}{}__", literals.len());
505            literals.push((sentinel.clone(), text.clone()));
506            Ok(YamlValue::String(sentinel))
507        }
508        Value::String(s) => Ok(YamlValue::String(s.clone())),
509        Value::Array(a) => {
510            let seq = a
511                .iter()
512                .map(|value| our_value_to_yaml_value(value, prefix, literals))
513                .collect::<DocumentResult<Vec<_>>>()?;
514            Ok(YamlValue::Sequence(seq))
515        }
516        Value::Object(o) => {
517            let mut mapping = YamlMapping::new();
518            for (k, v) in o {
519                mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
520            }
521            Ok(YamlValue::Mapping(mapping))
522        }
523    }
524}
525
526fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
527    if let Value::Number(text) = value
528        && is_json_number(text)
529    {
530        return Ok(text.clone());
531    }
532    if matches!(value, Value::Array(_) | Value::Object(_)) {
533        return save(value);
534    }
535    to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
536        format: "YAML".to_string(),
537        operation: operation.to_string(),
538        detail: error.to_string(),
539    })
540}
541
542fn protect_exact_numbers(
543    content: &str,
544) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
545    let document = parse_document(content).map_err(|error| DocumentError::ParseError {
546        format: "YAML".to_string(),
547        detail: error.to_string(),
548    })?;
549    let mut spans = Vec::new();
550    collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
551    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
552    while content.contains(&prefix) {
553        prefix.push('_');
554    }
555    let mut protected = content.to_string();
556    let mut literals = std::collections::HashMap::new();
557    for (index, (start, end)) in spans.into_iter().enumerate().rev() {
558        let literal = content[start..end].to_string();
559        let sentinel = format!("{prefix}{index}__");
560        let quoted =
561            serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
562                format: "YAML".to_string(),
563                detail: error.to_string(),
564            })?;
565        protected.replace_range(start..end, &quoted);
566        literals.insert(sentinel, literal);
567    }
568    Ok((protected, literals))
569}
570
571fn collect_exact_number_spans(
572    node: &GreenNode,
573    source: &str,
574    base: usize,
575    spans: &mut Vec<(usize, usize)>,
576) {
577    let mut offset = base;
578    for child in node.children() {
579        match child {
580            GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
581            GreenChild::Token {
582                kind: SyntaxKind::PlainScalar,
583                len,
584            } => {
585                let token_end = offset + *len as usize;
586                let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
587                let end = offset + text.len();
588                if should_preserve_number(text) {
589                    spans.push((offset, end));
590                }
591            }
592            GreenChild::Token { .. } => {}
593        }
594        offset += child.text_len();
595    }
596}
597
598fn should_preserve_number(text: &str) -> bool {
599    if !is_json_number(text) {
600        return false;
601    }
602    text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
603}
604
605fn is_json_number(text: &str) -> bool {
606    serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
607}
608
609fn value_contains_text(value: &Value, needle: &str) -> bool {
610    match value {
611        Value::Number(text) | Value::String(text) => text.contains(needle),
612        Value::Array(values) => values
613            .iter()
614            .any(|value| value_contains_text(value, needle)),
615        Value::Object(values) => values
616            .iter()
617            .any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
618        _ => false,
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::{load, save, set_preserving};
625    use crate::document::Value;
626
627    #[test]
628    fn preserves_large_integer_and_high_precision_float_literals() {
629        let source = concat!(
630            "huge: 123456789012345678901234567890\n",
631            "precise: 0.1000000000000000055511151231257827\n",
632        );
633        let value = load(source).expect("load");
634        assert_eq!(
635            value.get("huge"),
636            Some(&Value::Number("123456789012345678901234567890".to_string()))
637        );
638        assert_eq!(
639            value.get("precise"),
640            Some(&Value::Number(
641                "0.1000000000000000055511151231257827".to_string()
642            ))
643        );
644
645        let rendered = save(&value).expect("save");
646        assert!(rendered.contains("123456789012345678901234567890"));
647        assert!(rendered.contains("0.1000000000000000055511151231257827"));
648        assert_eq!(load(&rendered).expect("reload"), value);
649    }
650
651    #[test]
652    fn exact_number_set_keeps_the_literal() {
653        let edited = set_preserving(
654            "price: 1.0\n",
655            "price",
656            &Value::Number("12345678901234567890.123456789".to_string()),
657        )
658        .expect("set");
659        assert_eq!(edited, "price: 12345678901234567890.123456789\n");
660        assert_eq!(
661            load(&edited).expect("reload").get("price"),
662            Some(&Value::Number("12345678901234567890.123456789".to_string()))
663        );
664    }
665
666    #[test]
667    fn save_rejects_non_finite_float() {
668        let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
669        assert!(error.to_string().contains("non-finite"));
670    }
671}