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