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