1use 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
25pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
35 let segments = crate::document::parse_path(path)?;
36 if is_blank_document(content) {
37 return set_in_blank_document(content, &segments, value);
38 }
39 if let Some((last, parents)) = segments.split_last()
40 && last == "-"
41 && array_at_path(&load(content)?, &crate::document::join_path(parents), "set").is_ok()
42 {
43 guard_cst_segments(content, parents, "set", false)?;
44 return append_array_item_preserving(content, &crate::document::join_path(parents), value)
45 .map_err(|error| match error {
46 DocumentError::UnsupportedOperation { format, detail, .. } => {
47 DocumentError::UnsupportedOperation {
48 format,
49 operation: "set".into(),
50 detail,
51 }
52 }
53 other => other,
54 });
55 }
56 let yaml_path = cst_path(&segments, "set")?;
57 guard_cst_segments(content, &segments, "set", true)?;
58 let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
59 format: "YAML".to_string(),
60 detail: error.to_string(),
61 })?;
62 refuse_through_alias(&document, &segments, segments.len() - 1, "set")?;
65 let mut expected = load(content)?;
68 let exists = crate::document::get_path_ref(&expected, path, Addressing::INDEX_ONLY).is_ok();
69 let canonical = load(&save(value)?)?;
72 crate::document::set_path(&mut expected, path, &canonical, Addressing::INDEX_ONLY)?;
73 if exists && matches!(value, Value::Array(_) | Value::Object(_)) {
74 replace_collection_in_place(&mut document, content, &segments, &yaml_path, value)?;
75 } else if exists {
76 let result = if let Value::Number(text) = value {
77 document.set(&yaml_path, text)
78 } else {
79 document.set_value(&yaml_path, &to_noyalib_value(value)?)
80 };
81 result.map_err(|error| DocumentError::UnsupportedOperation {
82 format: "YAML".to_string(),
83 operation: "set".to_string(),
84 detail: error.to_string(),
85 })?;
86 } else {
87 let original = load(content)?;
89 for index in 0..segments.len().saturating_sub(1) {
90 let prefix = crate::document::join_path(&segments[..=index]);
91 if crate::document::get_path(&original, &prefix, Addressing::INDEX_ONLY).is_err() {
92 let parent = cst_path(&segments[..index], "set")?;
93 if document.span_at(&parent).is_some_and(|(start, end)| {
96 let text = content[start..end].trim();
97 let empty = text
98 .strip_prefix('{')
99 .and_then(|inner| inner.strip_suffix('}'))
100 .is_some_and(|inner| inner.trim().is_empty());
101 text.starts_with('{') && !empty
102 }) {
103 return Err(DocumentError::UnsupportedOperation {
104 format: "YAML".into(),
105 operation: "set".into(),
106 detail: "creating parents inside a flow mapping is not supported".into(),
107 });
108 }
109 let mut nested = value.clone();
110 for key in segments[index + 1..].iter().rev() {
111 nested = Value::Object([(key.clone(), nested)].into_iter().collect());
112 }
113 return set_preserving(content, &prefix, &nested);
114 }
115 }
116 let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
117 if last.parse::<usize>().is_ok() {
118 return Err(DocumentError::UnsupportedOperation {
119 format: "YAML".to_string(),
120 operation: "set".to_string(),
121 detail: "cannot create a new sequence index; the element must already exist"
122 .to_string(),
123 });
124 }
125 let parent_path = if parents.is_empty() {
126 String::new()
127 } else {
128 cst_path(parents, "set")?
129 };
130 let result = match value {
131 Value::Array(items) if items.is_empty() => {
135 document.insert_entry(&parent_path, last, "[]")
136 }
137 Value::Object(entries) if entries.is_empty() => {
138 document.insert_entry(&parent_path, last, "{}")
139 }
140 Value::Array(_) | Value::Object(_) => {
144 document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
145 }
146 _ => document.insert_entry(&parent_path, last, yaml_fragment(value, "set")?.trim_end()),
147 };
148 if result.is_err() {
149 let retry = if let Value::Number(text) = value {
154 document
155 .insert_entry_value(&parent_path, last, &YamlValue::Null)
156 .and_then(|()| document.set(&yaml_path, text))
157 } else {
158 document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
159 };
160 if let Err(error) = retry {
161 if !append_to_sequence_item_mapping(
166 &mut document,
167 content,
168 &parent_path,
169 last,
170 value,
171 )? {
172 return Err(DocumentError::UnsupportedOperation {
173 format: "YAML".to_string(),
174 operation: "set".to_string(),
175 detail: error.to_string(),
176 });
177 }
178 }
179 }
180 }
181 document
182 .validate()
183 .map_err(|error| DocumentError::ParseError {
184 format: "YAML".to_string(),
185 detail: error.to_string(),
186 })?;
187 let output = document.to_string();
188 if load(&output)? != expected {
189 return Err(DocumentError::UnsupportedOperation {
190 format: "YAML".to_string(),
191 operation: "set".to_string(),
192 detail: "YAML edit did not preserve the requested document value".to_string(),
193 });
194 }
195 Ok(output)
196}
197
198pub(crate) fn is_blank_document(content: &str) -> bool {
202 let mut markers = 0;
203 content.lines().all(|line| {
204 let line = line.trim();
205 if line == "---" {
206 markers += 1;
207 return markers == 1;
208 }
209 line.is_empty() || line.starts_with('#')
210 })
211}
212
213fn set_in_blank_document(
215 content: &str,
216 segments: &[String],
217 value: &Value,
218) -> DocumentResult<String> {
219 if segments
220 .iter()
221 .any(|segment| segment == "-" || segment.parse::<usize>().is_ok())
222 {
223 return Err(DocumentError::UnsupportedOperation {
224 format: "YAML".to_string(),
225 operation: "set".to_string(),
226 detail: "an empty document has no sequence to index into".to_string(),
227 });
228 }
229 let mut nested = value.clone();
230 for segment in segments.iter().rev() {
231 nested = Value::Object([(segment.clone(), nested)].into_iter().collect());
232 }
233 let rendered = save(&nested)?;
234 let body = rendered.strip_prefix("---\n").unwrap_or(&rendered);
235 let newline = if content.contains("\r\n") {
236 "\r\n"
237 } else {
238 "\n"
239 };
240 let mut output = content.to_string();
241 if !output.is_empty() && !output.ends_with('\n') {
242 output.push_str(newline);
243 }
244 for line in body.trim_end_matches('\n').split('\n') {
245 output.push_str(line);
246 output.push_str(newline);
247 }
248 if load(&output)? != load(&save(&nested)?)? {
249 return Err(DocumentError::UnsupportedOperation {
250 format: "YAML".to_string(),
251 operation: "set".to_string(),
252 detail: "YAML edit did not preserve the requested document value".to_string(),
253 });
254 }
255 Ok(output)
256}
257
258fn replace_collection_in_place(
282 document: &mut noyalib::cst::Document,
283 content: &str,
284 segments: &[String],
285 yaml_path: &str,
286 value: &Value,
287) -> DocumentResult<()> {
288 let unsupported = |detail: String| DocumentError::UnsupportedOperation {
289 format: "YAML".to_string(),
290 operation: "set".to_string(),
291 detail,
292 };
293 let (value_start, value_end) = document
294 .span_at(yaml_path)
295 .ok_or_else(|| unsupported(format!("could not locate the value at `{yaml_path}`")))?;
296 let newline = if content.contains("\r\n") {
297 "\r\n"
298 } else {
299 "\n"
300 };
301 let Some((key_start, key_end)) = document.key_span(yaml_path) else {
302 let dash_column = preceding_sequence_dash_column(content, value_start).ok_or_else(|| {
305 unsupported(format!(
306 "a collection can replace only a block sequence element, and `{yaml_path}` is not one"
307 ))
308 })?;
309 let indent = dash_column + 2;
310 let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
311 let inline = fragment
312 .strip_prefix(newline)
313 .and_then(|rest| rest.strip_prefix(" ".repeat(indent).as_str()))
314 .unwrap_or_else(|| fragment.trim_start());
315 let end = end_of_trailing_inline_comment(content, value_end);
316 return document
317 .replace_span(value_start, end, inline)
318 .map_err(|error| unsupported(error.to_string()));
319 };
320 if value_start < key_end {
321 return Err(through_alias_refused(
322 segments,
323 segments,
324 anchor_before(document, value_start).as_deref(),
325 "set",
326 ));
327 }
328
329 let bytes = content.as_bytes();
330 let key_column = line_column_of(content, key_start);
331 let indent = key_column + document.indent_unit();
332
333 let mut start = value_start;
334 while start > 0 && matches!(bytes[start - 1], b' ' | b'\t' | b'\n' | b'\r') {
335 start -= 1;
336 }
337 let end = end_of_trailing_inline_comment(content, value_end);
338
339 let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
340 document
341 .replace_span(start, end, &fragment)
342 .map_err(|error| unsupported(error.to_string()))
343}
344
345fn append_to_sequence_item_mapping(
349 document: &mut noyalib::cst::Document,
350 content: &str,
351 parent_path: &str,
352 key: &str,
353 value: &Value,
354) -> DocumentResult<bool> {
355 let Some((start, end)) = document.span_at(parent_path) else {
356 return Ok(false);
357 };
358 let Some(dash_column) = preceding_sequence_dash_column(content, start) else {
359 return Ok(false);
360 };
361 if content[start..end].trim_start().starts_with('{') {
362 return Ok(false);
363 }
364 let newline = if content.contains("\r\n") {
365 "\r\n"
366 } else {
367 "\n"
368 };
369 let entry = Value::Object([(key.to_string(), value.clone())].into_iter().collect());
370 let fragment = block_fragment(&entry, document.indent_unit(), dash_column + 2, newline)?;
371 let at = end_of_trailing_inline_comment(content, end);
372 document.replace_span(at, at, &fragment).map_err(|error| {
373 DocumentError::UnsupportedOperation {
374 format: "YAML".to_string(),
375 operation: "set".to_string(),
376 detail: error.to_string(),
377 }
378 })?;
379 Ok(true)
380}
381
382fn block_fragment(
388 value: &Value,
389 indent_unit: usize,
390 indent: usize,
391 newline: &str,
392) -> DocumentResult<String> {
393 match value {
394 Value::Array(items) if items.is_empty() => return Ok(" []".to_string()),
395 Value::Object(entries) if entries.is_empty() => return Ok(" {}".to_string()),
396 _ => {}
397 }
398 let config = noyalib::SerializerConfig::new()
399 .indent(indent_unit)
400 .flow_style(noyalib::FlowStyle::Block);
401 let rendered = noyalib::to_string_value_with_config(&to_noyalib_value(value)?, &config)
405 .map_err(|error| DocumentError::UnsupportedOperation {
406 format: "YAML".to_string(),
407 operation: "set".to_string(),
408 detail: error.to_string(),
409 })?;
410 let pad = " ".repeat(indent);
411 let mut fragment = String::new();
412 for line in rendered.trim_end_matches(['\r', '\n']).lines() {
413 fragment.push_str(newline);
414 if line.is_empty() {
415 continue;
416 }
417 fragment.push_str(&pad);
418 fragment.push_str(line);
419 }
420 Ok(fragment)
421}
422
423fn line_column_of(content: &str, offset: usize) -> usize {
425 let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1);
426 offset - line_start
427}
428
429fn end_of_trailing_inline_comment(content: &str, value_end: usize) -> usize {
432 let bytes = content.as_bytes();
433 let mut index = value_end;
434 while index < bytes.len() && matches!(bytes[index], b' ' | b'\t') {
435 index += 1;
436 }
437 if index >= bytes.len() || bytes[index] != b'#' {
438 return value_end;
439 }
440 while index < bytes.len() && bytes[index] != b'\n' {
441 index += 1;
442 }
443 if index > 0 && bytes[index - 1] == b'\r' {
444 index -= 1;
445 }
446 index
447}
448
449pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
451 let segments = crate::document::parse_path(path)?;
452 let yaml_path = cst_path(&segments, "unset")?;
453 guard_cst_segments(content, &segments, "unset", false)?;
454 let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
455 format: "YAML".to_string(),
456 detail: error.to_string(),
457 })?;
458 refuse_through_alias(&document, &segments, segments.len() - 1, "unset")?;
459 document
460 .remove(&yaml_path)
461 .map_err(|error| DocumentError::UnsupportedOperation {
462 format: "YAML".to_string(),
463 operation: "unset".to_string(),
464 detail: error.to_string(),
465 })?;
466 document
467 .validate()
468 .map_err(|error| DocumentError::ParseError {
469 format: "YAML".to_string(),
470 detail: error.to_string(),
471 })?;
472 Ok(document.to_string())
473}
474
475pub fn append_array_item_preserving(
478 content: &str,
479 path: &str,
480 item: &Value,
481) -> DocumentResult<String> {
482 let segments = array_path_segments(path)?;
483 let yaml_path = cst_path(&segments, "add")?;
484 let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
485 format: "YAML".to_string(),
486 detail: error.to_string(),
487 })?;
488 refuse_through_alias(&document, &segments, segments.len(), "add")?;
489 let loaded = load(content)?;
490 let existing = array_at_path(&loaded, path, "add")?;
491 let previous_len = existing.len();
492 let canonical_item = load(&save(item)?)?;
495 if previous_len == 0 {
496 if path.is_empty()
507 || segments
508 .last()
509 .is_some_and(|part| part.parse::<usize>().is_ok())
510 {
511 let (start, end) = document
512 .span_at(&yaml_path)
513 .ok_or_else(|| DocumentError::PathNotFound { path: path.into() })?;
514 let replacement = flow_fragment(&Value::Array(vec![item.clone()]))?;
515 document
516 .replace_span(start, end, &replacement)
517 .map_err(|error| DocumentError::UnsupportedOperation {
518 format: "YAML".into(),
519 operation: "add".into(),
520 detail: error.to_string(),
521 })?;
522 let output = document.to_string();
523 let edited = load(&output)?;
524 if array_at_path(&edited, path, "add")?.as_slice()
525 != std::slice::from_ref(&canonical_item)
526 {
527 return Err(DocumentError::UnsupportedOperation {
528 format: "YAML".into(),
529 operation: "add".into(),
530 detail: "the source edit did not reproduce the requested item".into(),
531 });
532 }
533 return Ok(output);
534 }
535 return set_preserving(content, path, &Value::Array(vec![item.clone()]));
536 }
537 let mut fragment = yaml_fragment(item, "add")?;
538 {
539 let item_path = format!("{yaml_path}[{}]", previous_len - 1);
540 let (item_start, _) =
541 document
542 .span_at(&item_path)
543 .ok_or_else(|| DocumentError::UnsupportedOperation {
544 format: "YAML".to_string(),
545 operation: "add".to_string(),
546 detail: "could not resolve the existing sequence-item indentation".to_string(),
547 })?;
548 let dash_column = preceding_sequence_dash_column(content, item_start).ok_or_else(|| {
549 DocumentError::UnsupportedOperation {
550 format: "YAML".to_string(),
551 operation: "add".to_string(),
552 detail: "only block sequences can be extended without rebuilding the document"
553 .to_string(),
554 }
555 })?;
556 fragment = indent_continuation_lines(fragment.trim_end(), dash_column + 2);
557 }
558 document
559 .push_back(&yaml_path, fragment.trim_end())
560 .map_err(|error| DocumentError::UnsupportedOperation {
561 format: "YAML".to_string(),
562 operation: "add".to_string(),
563 detail: error.to_string(),
564 })?;
565 document
566 .validate()
567 .map_err(|error| DocumentError::ParseError {
568 format: "YAML".to_string(),
569 detail: error.to_string(),
570 })?;
571 let output = document.to_string();
572 let edited = load(&output)?;
573 let edited_items = array_at_path(&edited, path, "add")?;
574 if edited_items.len() != previous_len + 1 || edited_items.last() != Some(&canonical_item) {
575 return Err(DocumentError::UnsupportedOperation {
576 format: "YAML".to_string(),
577 operation: "add".to_string(),
578 detail: "the source edit did not reproduce the requested keyed item".to_string(),
579 });
580 }
581 Ok(output)
582}
583
584pub fn remove_array_item_preserving(
586 content: &str,
587 path: &str,
588 index: usize,
589) -> DocumentResult<String> {
590 let segments = array_path_segments(path)?;
591 let loaded = load(content)?;
592 if array_at_path(&loaded, path, "remove")?.len() == 1 && index == 0 {
593 return set_preserving(content, path, &Value::Array(Vec::new()));
599 }
600 let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
601 format: "YAML".to_string(),
602 detail: error.to_string(),
603 })?;
604 refuse_through_alias(&document, &segments, segments.len(), "remove")?;
605 let yaml_path = cst_path(&segments, "remove")?;
606 document
607 .remove(&format!("{yaml_path}[{index}]"))
608 .map_err(|error| DocumentError::UnsupportedOperation {
609 format: "YAML".to_string(),
610 operation: "remove".to_string(),
611 detail: error.to_string(),
612 })?;
613 document
614 .validate()
615 .map_err(|error| DocumentError::ParseError {
616 format: "YAML".to_string(),
617 detail: error.to_string(),
618 })?;
619 Ok(document.to_string())
620}
621
622fn array_path_segments(path: &str) -> DocumentResult<Vec<String>> {
625 if path.is_empty() {
626 Ok(Vec::new())
627 } else {
628 crate::document::parse_path(path)
629 }
630}
631
632fn array_at_path<'a>(
633 value: &'a Value,
634 path: &str,
635 operation: &str,
636) -> DocumentResult<&'a Vec<Value>> {
637 let target = if path.is_empty() {
638 value
639 } else {
640 crate::document::get_path_ref(value, path, Addressing::INDEX_ONLY)?
641 };
642 target
643 .as_array()
644 .ok_or_else(|| DocumentError::UnsupportedOperation {
645 format: "YAML".to_string(),
646 operation: operation.to_string(),
647 detail: "target is not an array".to_string(),
648 })
649}
650
651fn preceding_sequence_dash_column(content: &str, value_start: usize) -> Option<usize> {
652 let bytes = content.as_bytes();
653 let mut position = value_start;
654 let dash = loop {
655 position = position.checked_sub(1)?;
656 match bytes.get(position)? {
657 b' ' | b'\t' => {}
658 b'-' => break position,
659 _ => return None,
660 }
661 };
662 let line_start = content.as_bytes()[..dash]
663 .iter()
664 .rposition(|byte| *byte == b'\n')
665 .map_or(0, |newline| newline + 1);
666 Some(dash - line_start)
667}
668
669fn indent_continuation_lines(fragment: &str, indent: usize) -> String {
670 if !fragment.contains('\n') {
671 return fragment.to_string();
672 }
673 let padding = " ".repeat(indent);
674 let mut output = String::with_capacity(fragment.len() + indent * 4);
675 for (index, line) in fragment.split('\n').enumerate() {
676 if index > 0 {
677 output.push('\n');
678 if !line.is_empty() {
679 output.push_str(&padding);
680 }
681 }
682 output.push_str(line);
683 }
684 output
685}
686
687fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
688 let mut path = String::new();
689 for segment in segments {
690 if segment.contains(['.', '\\', '[', ']']) {
691 return Err(DocumentError::UnsupportedOperation {
692 format: "YAML".to_string(),
693 operation: operation.to_string(),
694 detail:
695 "escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
696 .to_string(),
697 });
698 }
699 if let Ok(index) = segment.parse::<usize>() {
700 path.push_str(&format!("[{index}]"));
701 } else {
702 if !path.is_empty() {
703 path.push('.');
704 }
705 path.push_str(segment);
706 }
707 }
708 Ok(path)
709}
710
711fn through_alias_refused(
717 target: &[String],
718 site: &[String],
719 alias: Option<&str>,
720 operation: &str,
721) -> DocumentError {
722 let alias = alias.map_or_else(|| "a YAML alias".to_string(), |name| format!("`*{name}`"));
723 let target = crate::document::join_path(target);
724 let site = crate::document::join_path(site);
725 let location = if site == target {
726 String::new()
727 } else {
728 format!(" at `{site}`")
729 };
730 DocumentError::UnsupportedOperation {
731 format: "YAML".to_string(),
732 operation: operation.to_string(),
733 detail: format!(
734 "`{target}` is reached through the alias {alias}{location}; afdata does not edit \
735 through YAML aliases, because the edit would change the anchored value and every \
736 alias of it. Edit the anchor's own path instead"
737 ),
738 }
739}
740
741fn anchor_before(document: &noyalib::cst::Document, value_start: usize) -> Option<String> {
744 document
745 .anchors()
746 .into_iter()
747 .rev()
748 .find(|anchor| anchor.mark_span.1 <= value_start)
749 .map(|anchor| anchor.name)
750}
751
752fn refuse_through_alias(
763 document: &noyalib::cst::Document,
764 target: &[String],
765 walked: usize,
766 operation: &str,
767) -> DocumentResult<()> {
768 let segments = &target[..walked.min(target.len())];
769 for (index, segment) in segments.iter().enumerate() {
770 let prefix = &segments[..=index];
771 let Some((start, _)) = document.span_at(&cst_path(prefix, operation)?) else {
772 return Ok(());
774 };
775 let through_alias = match segment.parse::<usize>() {
776 Ok(0) if index > 0 => document
777 .span_at(&cst_path(&segments[..index], operation)?)
778 .is_some_and(|(parent_start, _)| start < parent_start),
779 Ok(0) => false,
780 Ok(position) => {
781 let previous = format!(
782 "{}[{}]",
783 cst_path(&segments[..index], operation)?,
784 position - 1
785 );
786 document
787 .span_at(&previous)
788 .is_some_and(|(_, previous_end)| start < previous_end)
789 }
790 Err(_) => document
791 .key_span(&cst_path(prefix, operation)?)
792 .is_some_and(|(_, key_end)| start < key_end),
793 };
794 if through_alias {
795 return Err(through_alias_refused(
796 target,
797 prefix,
798 anchor_before(document, start).as_deref(),
799 operation,
800 ));
801 }
802 }
803 Ok(())
804}
805
806fn guard_cst_segments(
807 content: &str,
808 segments: &[String],
809 operation: &str,
810 allow_missing: bool,
811) -> DocumentResult<()> {
812 let root = load(content)?;
813 let mut current = &root;
814 for (index, segment) in segments.iter().enumerate() {
815 match current {
816 Value::Object(object) => {
817 if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
818 return Err(DocumentError::UnsupportedOperation {
819 format: "YAML".to_string(),
820 operation: operation.to_string(),
821 detail: format!(
822 "mapping key `{segment}` is ambiguous in the CST path grammar"
823 ),
824 });
825 }
826 match object.get(segment) {
827 Some(next) => current = next,
828 None if allow_missing => {
829 if segments[index..]
830 .iter()
831 .any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
832 {
833 return Err(DocumentError::UnsupportedOperation {
834 format: "YAML".to_string(),
835 operation: operation.to_string(),
836 detail: "a missing mapping chain contains a CST-ambiguous key"
837 .to_string(),
838 });
839 }
840 return Ok(());
841 }
842 None => {
843 return Err(DocumentError::PathNotFound {
844 path: crate::document::join_path(&segments[..=index]),
845 });
846 }
847 }
848 }
849 Value::Array(values) => {
850 let array_index =
851 segment
852 .parse::<usize>()
853 .map_err(|_| DocumentError::UnregisteredArray {
854 path: crate::document::join_path(&segments[..index]),
855 })?;
856 current =
857 values
858 .get(array_index)
859 .ok_or_else(|| DocumentError::IndexOutOfBounds {
860 path: crate::document::join_path(&segments[..index]),
861 index: array_index,
862 len: values.len(),
863 })?;
864 }
865 value => {
866 return Err(DocumentError::NotTraversable {
867 path: crate::document::join_path(&segments[..index]),
868 got: value.kind_name().to_string(),
869 });
870 }
871 }
872 }
873 Ok(())
874}
875
876fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
877 match value {
878 Value::Null => Ok(YamlValue::Null),
879 Value::Bool(value) => Ok(YamlValue::Bool(*value)),
880 Value::Integer(value) => Ok(YamlValue::from(*value)),
881 Value::Unsigned(value) => Ok(YamlValue::from(*value)),
882 Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
883 Value::Float(_) => Err(DocumentError::UnsupportedOperation {
884 format: "YAML".to_string(),
885 operation: "set".to_string(),
886 detail: "non-finite YAML float is not representable".to_string(),
887 }),
888 Value::Number(text) if value.is_float() => text
894 .parse::<f64>()
895 .ok()
896 .filter(|value| value.is_finite())
897 .map(YamlValue::from)
898 .ok_or_else(|| DocumentError::UnsupportedOperation {
899 format: "YAML".to_string(),
900 operation: "set".to_string(),
901 detail: "float literal is not representable in YAML".to_string(),
902 }),
903 Value::Number(_) => Err(DocumentError::UnsupportedOperation {
904 format: "YAML".to_string(),
905 operation: "set".to_string(),
906 detail: "integer literal exceeds YAML's 64-bit integer range".to_string(),
907 }),
908 Value::String(value) => Ok(YamlValue::String(value.clone())),
909 Value::Array(values) => Ok(YamlValue::Sequence(
910 values
911 .iter()
912 .map(to_noyalib_value)
913 .collect::<DocumentResult<Vec<_>>>()?,
914 )),
915 Value::Object(values) => {
916 let mut mapping = YamlMapping::new();
917 for (key, value) in values {
918 mapping.insert(key.clone(), to_noyalib_value(value)?);
919 }
920 Ok(YamlValue::Mapping(mapping))
921 }
922 }
923}
924
925pub fn save(value: &Value) -> DocumentResult<String> {
926 render(value, None)
927}
928
929fn flow_fragment(value: &Value) -> DocumentResult<String> {
935 let config = noyalib::SerializerConfig::new().flow_style(noyalib::FlowStyle::Flow);
936 let rendered = render(value, Some(&config))?;
937 let line = rendered.trim_end_matches(['\r', '\n']);
938 if line.contains('\n') {
939 return Err(DocumentError::UnsupportedOperation {
940 format: "YAML".to_string(),
941 operation: "add".to_string(),
942 detail: "the item has no single-line flow spelling".to_string(),
943 });
944 }
945 Ok(line.to_string())
946}
947
948fn render(value: &Value, config: Option<&noyalib::SerializerConfig>) -> DocumentResult<String> {
949 let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
950 while value_contains_text(value, &prefix) {
951 prefix.push('_');
952 }
953 let mut literals = Vec::new();
954 let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
955 let rendered = match config {
956 Some(config) => noyalib::to_string_value_with_config(&yaml_val, config),
957 None => to_string(&yaml_val),
958 };
959 let mut output = rendered.map_err(|e| DocumentError::ParseError {
960 format: "YAML".to_string(),
961 detail: e.to_string(),
962 })?;
963 for (sentinel, literal) in literals {
964 output = output.replace(&sentinel, &literal);
965 }
966 Ok(output)
967}
968
969fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
970 match v {
971 YamlValue::Null => Value::Null,
972 YamlValue::Bool(b) => Value::Bool(b),
973 YamlValue::Number(n) => {
974 if let Some(i) = n.as_i64() {
975 Value::Integer(i)
976 } else if let Some(u) = n.as_u64() {
977 Value::Unsigned(u)
978 } else {
979 Value::Float(n.as_f64())
980 }
981 }
982 YamlValue::String(s) => literals
983 .get(&s)
984 .cloned()
985 .map(Value::Number)
986 .unwrap_or(Value::String(s)),
987 YamlValue::Sequence(seq) => Value::Array(
988 seq.into_iter()
989 .map(|value| value_to_our_value(value, literals))
990 .collect(),
991 ),
992 YamlValue::Mapping(map) => {
993 let mut obj = std::collections::BTreeMap::new();
994 for (key, value) in map {
995 let key = literals.get(&key).cloned().unwrap_or(key);
996 obj.insert(key, value_to_our_value(value, literals));
997 }
998 Value::Object(obj)
999 }
1000 YamlValue::Tagged(t) => {
1001 let (_, value) = t.into_parts();
1003 value_to_our_value(value, literals)
1004 }
1005 }
1006}
1007
1008fn our_value_to_yaml_value(
1009 v: &Value,
1010 prefix: &str,
1011 literals: &mut Vec<(String, String)>,
1012) -> DocumentResult<YamlValue> {
1013 match v {
1014 Value::Null => Ok(YamlValue::Null),
1015 Value::Bool(b) => Ok(YamlValue::Bool(*b)),
1016 Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
1017 Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
1018 Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
1019 Value::Float(_) => Err(DocumentError::UnsupportedOperation {
1020 format: "YAML".to_string(),
1021 operation: "save".to_string(),
1022 detail: "non-finite float is not representable in YAML".to_string(),
1023 }),
1024 Value::Number(text) => {
1025 if !is_json_number(text) {
1026 return Err(DocumentError::UnsupportedOperation {
1027 format: "YAML".to_string(),
1028 operation: "save".to_string(),
1029 detail: "invalid number literal".to_string(),
1030 });
1031 }
1032 let sentinel = format!("{prefix}{}__", literals.len());
1033 literals.push((sentinel.clone(), text.clone()));
1034 Ok(YamlValue::String(sentinel))
1035 }
1036 Value::String(s) => Ok(YamlValue::String(s.clone())),
1037 Value::Array(a) => {
1038 let seq = a
1039 .iter()
1040 .map(|value| our_value_to_yaml_value(value, prefix, literals))
1041 .collect::<DocumentResult<Vec<_>>>()?;
1042 Ok(YamlValue::Sequence(seq))
1043 }
1044 Value::Object(o) => {
1045 let mut mapping = YamlMapping::new();
1046 for (k, v) in o {
1047 mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
1048 }
1049 Ok(YamlValue::Mapping(mapping))
1050 }
1051 }
1052}
1053
1054fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
1055 if let Value::Number(text) = value
1056 && is_json_number(text)
1057 {
1058 return Ok(text.clone());
1059 }
1060 if matches!(value, Value::Array(_) | Value::Object(_)) {
1061 return save(value);
1062 }
1063 to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
1064 format: "YAML".to_string(),
1065 operation: operation.to_string(),
1066 detail: error.to_string(),
1067 })
1068}
1069
1070fn protect_exact_numbers(
1071 content: &str,
1072) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
1073 let document = parse_document(content).map_err(|error| DocumentError::ParseError {
1074 format: "YAML".to_string(),
1075 detail: error.to_string(),
1076 })?;
1077 let mut spans = Vec::new();
1078 collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
1079 let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
1080 while content.contains(&prefix) {
1081 prefix.push('_');
1082 }
1083 let mut protected = content.to_string();
1084 let mut literals = std::collections::HashMap::new();
1085 for (index, (start, end)) in spans.into_iter().enumerate().rev() {
1086 let literal = content[start..end].to_string();
1087 let sentinel = format!("{prefix}{index}__");
1088 let quoted =
1089 serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
1090 format: "YAML".to_string(),
1091 detail: error.to_string(),
1092 })?;
1093 protected.replace_range(start..end, "ed);
1094 literals.insert(sentinel, literal);
1095 }
1096 Ok((protected, literals))
1097}
1098
1099fn collect_exact_number_spans(
1100 node: &GreenNode,
1101 source: &str,
1102 base: usize,
1103 spans: &mut Vec<(usize, usize)>,
1104) {
1105 let mut offset = base;
1106 for child in node.children() {
1107 match child {
1108 GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
1109 GreenChild::Token {
1110 kind: SyntaxKind::PlainScalar,
1111 len,
1112 } => {
1113 let token_end = offset + *len as usize;
1114 let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
1115 let end = offset + text.len();
1116 if should_preserve_number(text) {
1117 spans.push((offset, end));
1118 }
1119 }
1120 GreenChild::Token { .. } => {}
1121 }
1122 offset += child.text_len();
1123 }
1124}
1125
1126fn should_preserve_number(text: &str) -> bool {
1127 if !is_json_number(text) {
1128 return false;
1129 }
1130 text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
1131}
1132
1133fn is_json_number(text: &str) -> bool {
1134 serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
1135}
1136
1137fn value_contains_text(value: &Value, needle: &str) -> bool {
1138 match value {
1139 Value::Number(text) | Value::String(text) => text.contains(needle),
1140 Value::Array(values) => values
1141 .iter()
1142 .any(|value| value_contains_text(value, needle)),
1143 Value::Object(values) => values
1144 .iter()
1145 .any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
1146 _ => false,
1147 }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::{load, save, set_preserving};
1153 use crate::document::Value;
1154
1155 #[test]
1156 fn new_fields_in_flow_mappings_preserve_their_exact_value_and_source() {
1157 use crate::document::{Document, Format};
1158 for (source, path) in [
1159 ("{}\n", "field"),
1160 ("{existing: before}\n", "field"),
1161 ("outer: {}\n", "outer.field"),
1162 ("# keep\r\n{}\r\n", "field"),
1163 ] {
1164 for value in [
1165 Value::String("new".into()),
1166 Value::Bool(true),
1167 Value::Null,
1168 Value::Integer(7),
1169 Value::Unsigned(7),
1170 Value::Float(2.0),
1171 Value::Array(Vec::new()),
1172 Value::Object(Default::default()),
1173 Value::Number("123456789012345678901234567890".into()),
1174 Value::Number("0.10000000000000000000001".into()),
1175 ] {
1176 let mut document = Document::parse(source, Format::Yaml).unwrap();
1177 document
1178 .set(path, value.clone())
1179 .expect("insert into a flow mapping");
1180 assert_eq!(document.value_at(path).unwrap(), value);
1181 assert_eq!(
1182 load(document.source()).unwrap(),
1183 load(&save(document.value()).unwrap()).unwrap()
1184 );
1185 if source.starts_with("# keep") {
1186 assert!(document.source().starts_with("# keep\r\n"));
1187 assert!(document.source().ends_with("\r\n"));
1188 }
1189 }
1190 }
1191 }
1192
1193 #[test]
1194 fn a_refused_key_cannot_fall_back_to_inserting_multiple_entries() {
1195 use crate::document::{Document, Format};
1196 let source = "attachments:\n";
1197 let mut document = Document::parse(source, Format::Yaml).unwrap();
1198 let original = document.value().clone();
1199 assert!(
1200 document
1201 .set("one:\ntwo", Value::String("injected".into()))
1202 .is_err()
1203 );
1204 assert_eq!(document.source(), source);
1205 assert_eq!(document.value(), &original);
1206 }
1207
1208 #[test]
1209 fn preserves_large_integer_and_high_precision_float_literals() {
1210 let source = concat!(
1211 "huge: 123456789012345678901234567890\n",
1212 "precise: 0.1000000000000000055511151231257827\n",
1213 );
1214 let value = load(source).expect("load");
1215 assert_eq!(
1216 value.get("huge"),
1217 Some(&Value::Number("123456789012345678901234567890".to_string()))
1218 );
1219 assert_eq!(
1220 value.get("precise"),
1221 Some(&Value::Number(
1222 "0.1000000000000000055511151231257827".to_string()
1223 ))
1224 );
1225
1226 let rendered = save(&value).expect("save");
1227 assert!(rendered.contains("123456789012345678901234567890"));
1228 assert!(rendered.contains("0.1000000000000000055511151231257827"));
1229 assert_eq!(load(&rendered).expect("reload"), value);
1230 }
1231
1232 #[test]
1233 fn exact_number_set_keeps_the_literal() {
1234 let edited = set_preserving(
1235 "price: 1.0\n",
1236 "price",
1237 &Value::Number("12345678901234567890.123456789".to_string()),
1238 )
1239 .expect("set");
1240 assert_eq!(edited, "price: 12345678901234567890.123456789\n");
1241 assert_eq!(
1242 load(&edited).expect("reload").get("price"),
1243 Some(&Value::Number("12345678901234567890.123456789".to_string()))
1244 );
1245 }
1246
1247 fn contact_source() -> &'static str {
1250 "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n - alice@example.com # work\ntags: []\nrole: ''\n"
1251 }
1252
1253 #[test]
1254 fn replacing_a_collection_keeps_every_other_byte() {
1255 let edited = set_preserving(
1256 contact_source(),
1257 "tags",
1258 &Value::Array(vec![Value::String("vip".into())]),
1259 )
1260 .expect("an empty sequence can grow");
1261 assert_eq!(
1262 edited,
1263 "# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n - alice@example.com # work\ntags:\n - vip\nrole: ''\n"
1264 );
1265
1266 let cleared = set_preserving(&edited, "tags", &Value::Array(Vec::new()))
1269 .expect("a sequence can shrink to empty");
1270 assert_eq!(cleared, contact_source());
1271 }
1272
1273 #[test]
1274 fn replacing_a_collection_quotes_elements_that_need_it() {
1275 let edited = set_preserving(
1276 contact_source(),
1277 "tags",
1278 &Value::Array(vec![
1279 Value::String("true".into()),
1280 Value::String("123".into()),
1281 Value::String("a: b".into()),
1282 Value::String("#c".into()),
1283 Value::String(String::new()),
1284 ]),
1285 )
1286 .expect("tricky scalars are the emitter's problem, not ours");
1287 let loaded = load(&edited).expect("the result parses");
1289 assert_eq!(
1290 crate::document::get_path(&loaded, "tags", crate::document::Addressing::INDEX_ONLY)
1291 .expect("tags"),
1292 Value::Array(vec![
1293 Value::String("true".into()),
1294 Value::String("123".into()),
1295 Value::String("a: b".into()),
1296 Value::String("#c".into()),
1297 Value::String(String::new()),
1298 ])
1299 );
1300 }
1301
1302 #[test]
1303 fn replacing_a_collection_takes_the_comment_that_described_it() {
1304 let edited = set_preserving(
1307 "emails:\n - alice@example.com # work\nrole: ''\n",
1308 "emails",
1309 &Value::Array(vec![Value::String("bob@example.com".into())]),
1310 )
1311 .expect("replace");
1312 assert_eq!(edited, "emails:\n - bob@example.com\nrole: ''\n");
1313 assert!(!edited.contains("# work"), "{edited}");
1314 }
1315
1316 #[test]
1317 fn a_collection_reached_through_an_alias_is_refused() {
1318 let source = "base: &b\n - x\nother: *b\n";
1321 let error = set_preserving(
1322 source,
1323 "other",
1324 &Value::Array(vec![Value::String("y".into())]),
1325 )
1326 .expect_err("an alias target must not be edited through");
1327 assert!(error.to_string().contains("alias"), "{error}");
1328 }
1329
1330 #[test]
1331 fn a_new_key_can_be_created_with_a_collection_value() {
1332 let edited = set_preserving(
1333 "kind: contact\n",
1334 "tags",
1335 &Value::Array(vec![Value::String("vip".into())]),
1336 )
1337 .expect("a missing key is inserted");
1338 assert_eq!(edited, "kind: contact\ntags:\n - vip\n");
1339
1340 let empty = set_preserving("kind: contact\n", "tags", &Value::Array(Vec::new()))
1341 .expect("a missing key can be inserted empty");
1342 assert_eq!(empty, "kind: contact\ntags: []\n");
1343 }
1344
1345 #[test]
1346 fn replacing_a_collection_keeps_the_source_newline_style() {
1347 let edited = set_preserving(
1348 "kind: contact\r\ntags: []\r\nrole: ''\r\n",
1349 "tags",
1350 &Value::Array(vec![Value::String("vip".into())]),
1351 )
1352 .expect("CRLF source");
1353 assert_eq!(edited, "kind: contact\r\ntags:\r\n - vip\r\nrole: ''\r\n");
1354 assert!(
1356 !edited.replace("\r\n", "").contains('\n'),
1357 "mixed endings: {edited:?}"
1358 );
1359 }
1360
1361 #[test]
1362 fn a_new_key_lands_after_a_bare_null_last_entry() {
1363 let source = "kind: draft\nattachments:\n";
1366 let edited = set_preserving(source, "sync_intent", &Value::String("send".into()))
1367 .expect("a new key can follow a bare-null entry");
1368 assert_eq!(edited, "kind: draft\nattachments:\nsync_intent: send\n");
1369 }
1370
1371 #[test]
1372 fn save_rejects_non_finite_float() {
1373 let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
1374 assert!(error.to_string().contains("non-finite"));
1375 }
1376}