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