use crate::document::{Addressing, DocumentError, DocumentResult, Value};
use noyalib::{
DuplicateKeyPolicy, Mapping as YamlMapping, ParserConfig, Value as YamlValue,
cst::{GreenChild, GreenNode, SyntaxKind, parse_document},
from_str_with_config, to_string,
};
pub fn load(content: &str) -> DocumentResult<Value> {
let (protected, literals) = protect_exact_numbers(content)?;
let parser_config = ParserConfig::new()
.duplicate_key_policy(DuplicateKeyPolicy::Error)
.lossless_u64_integers(true);
from_str_with_config::<YamlValue>(&protected, &parser_config)
.map(|value| value_to_our_value(value, &literals))
.map_err(|e| DocumentError::ParseError {
format: "YAML".to_string(),
detail: e.to_string(),
})
}
pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
let segments = crate::document::parse_path(path)?;
if is_blank_document(content) {
return set_in_blank_document(content, &segments, value);
}
if let Some((last, parents)) = segments.split_last()
&& last == "-"
&& array_at_path(&load(content)?, &crate::document::join_path(parents), "set").is_ok()
{
guard_cst_segments(content, parents, "set", false)?;
return append_array_item_preserving(content, &crate::document::join_path(parents), value)
.map_err(|error| match error {
DocumentError::UnsupportedOperation { format, detail, .. } => {
DocumentError::UnsupportedOperation {
format,
operation: "set".into(),
detail,
}
}
other => other,
});
}
let yaml_path = cst_path(&segments, "set")?;
guard_cst_segments(content, &segments, "set", true)?;
let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
refuse_through_alias(&document, &segments, segments.len() - 1, "set")?;
let mut expected = load(content)?;
let exists = crate::document::get_path_ref(&expected, path, Addressing::INDEX_ONLY).is_ok();
let canonical = load(&save(value)?)?;
crate::document::set_path(&mut expected, path, &canonical, Addressing::INDEX_ONLY)?;
if exists && matches!(value, Value::Array(_) | Value::Object(_)) {
replace_collection_in_place(&mut document, content, &segments, &yaml_path, value)?;
} else if exists {
let result = if let Value::Number(text) = value {
document.set(&yaml_path, text)
} else {
document.set_value(&yaml_path, &to_noyalib_value(value)?)
};
result.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: error.to_string(),
})?;
} else {
let original = load(content)?;
for index in 0..segments.len().saturating_sub(1) {
let prefix = crate::document::join_path(&segments[..=index]);
if crate::document::get_path(&original, &prefix, Addressing::INDEX_ONLY).is_err() {
let parent = cst_path(&segments[..index], "set")?;
if document.span_at(&parent).is_some_and(|(start, end)| {
let text = content[start..end].trim();
let empty = text
.strip_circumfix('{', '}')
.is_some_and(|inner| inner.trim().is_empty());
text.starts_with('{') && !empty
}) {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".into(),
operation: "set".into(),
detail: "creating parents inside a flow mapping is not supported".into(),
});
}
let mut nested = value.clone();
for key in segments[index + 1..].iter().rev() {
nested = Value::Object([(key.clone(), nested)].into_iter().collect());
}
return set_preserving(content, &prefix, &nested);
}
}
let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
if last.parse::<usize>().is_ok() {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "cannot create a new sequence index; the element must already exist"
.to_string(),
});
}
let parent_path = if parents.is_empty() {
String::new()
} else {
cst_path(parents, "set")?
};
let result = match value {
Value::Array(items) if items.is_empty() => {
document.insert_entry(&parent_path, last, "[]")
}
Value::Object(entries) if entries.is_empty() => {
document.insert_entry(&parent_path, last, "{}")
}
Value::Array(_) | Value::Object(_) => {
document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
}
_ => document.insert_entry(&parent_path, last, yaml_fragment(value, "set")?.trim_end()),
};
if result.is_err() {
let retry = if let Value::Number(text) = value {
document
.insert_entry_value(&parent_path, last, &YamlValue::Null)
.and_then(|()| document.set(&yaml_path, text))
} else {
document.insert_entry_value(&parent_path, last, &to_noyalib_value(value)?)
};
if let Err(error) = retry {
if !append_to_sequence_item_mapping(
&mut document,
content,
&parent_path,
last,
value,
)? {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: error.to_string(),
});
}
}
}
}
document
.validate()
.map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
let output = document.to_string();
if load(&output)? != expected {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "YAML edit did not preserve the requested document value".to_string(),
});
}
Ok(output)
}
pub(crate) fn is_blank_document(content: &str) -> bool {
let mut markers = 0;
content.lines().all(|line| {
let line = line.trim();
if line == "---" {
markers += 1;
return markers == 1;
}
line.is_empty() || line.starts_with('#')
})
}
fn set_in_blank_document(
content: &str,
segments: &[String],
value: &Value,
) -> DocumentResult<String> {
if segments
.iter()
.any(|segment| segment == "-" || segment.parse::<usize>().is_ok())
{
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "an empty document has no sequence to index into".to_string(),
});
}
let mut nested = value.clone();
for segment in segments.iter().rev() {
nested = Value::Object([(segment.clone(), nested)].into_iter().collect());
}
let rendered = save(&nested)?;
let body = rendered.strip_prefix("---\n").unwrap_or(&rendered);
let newline = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let mut output = content.to_string();
if !output.is_empty() && !output.ends_with('\n') {
output.push_str(newline);
}
for line in body.trim_end_matches('\n').split('\n') {
output.push_str(line);
output.push_str(newline);
}
if load(&output)? != load(&save(&nested)?)? {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "YAML edit did not preserve the requested document value".to_string(),
});
}
Ok(output)
}
fn replace_collection_in_place(
document: &mut noyalib::cst::Document,
content: &str,
segments: &[String],
yaml_path: &str,
value: &Value,
) -> DocumentResult<()> {
let unsupported = |detail: String| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail,
};
let (value_start, value_end) = document
.span_at(yaml_path)
.ok_or_else(|| unsupported(format!("could not locate the value at `{yaml_path}`")))?;
let newline = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let Some((key_start, key_end)) = document.key_span(yaml_path) else {
let dash_column = preceding_sequence_dash_column(content, value_start).ok_or_else(|| {
unsupported(format!(
"a collection can replace only a block sequence element, and `{yaml_path}` is not one"
))
})?;
let indent = dash_column + 2;
let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
let inline = fragment
.strip_prefix(newline)
.and_then(|rest| rest.strip_prefix(" ".repeat(indent).as_str()))
.unwrap_or_else(|| fragment.trim_start());
let end = end_of_trailing_inline_comment(content, value_end);
return document
.replace_span(value_start, end, inline)
.map_err(|error| unsupported(error.to_string()));
};
if value_start < key_end {
return Err(through_alias_refused(
segments,
segments,
anchor_before(document, value_start).as_deref(),
"set",
));
}
let bytes = content.as_bytes();
let key_column = line_column_of(content, key_start);
let indent = key_column + document.indent_unit();
let mut start = value_start;
while start > 0 && matches!(bytes[start - 1], b' ' | b'\t' | b'\n' | b'\r') {
start -= 1;
}
let end = end_of_trailing_inline_comment(content, value_end);
let fragment = block_fragment(value, document.indent_unit(), indent, newline)?;
document
.replace_span(start, end, &fragment)
.map_err(|error| unsupported(error.to_string()))
}
fn append_to_sequence_item_mapping(
document: &mut noyalib::cst::Document,
content: &str,
parent_path: &str,
key: &str,
value: &Value,
) -> DocumentResult<bool> {
let Some((start, end)) = document.span_at(parent_path) else {
return Ok(false);
};
let Some(dash_column) = preceding_sequence_dash_column(content, start) else {
return Ok(false);
};
if content[start..end].trim_start().starts_with('{') {
return Ok(false);
}
let newline = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let entry = Value::Object([(key.to_string(), value.clone())].into_iter().collect());
let fragment = block_fragment(&entry, document.indent_unit(), dash_column + 2, newline)?;
let at = end_of_trailing_inline_comment(content, end);
document.replace_span(at, at, &fragment).map_err(|error| {
DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: error.to_string(),
}
})?;
Ok(true)
}
fn block_fragment(
value: &Value,
indent_unit: usize,
indent: usize,
newline: &str,
) -> DocumentResult<String> {
match value {
Value::Array(items) if items.is_empty() => return Ok(" []".to_string()),
Value::Object(entries) if entries.is_empty() => return Ok(" {}".to_string()),
_ => {}
}
let config = noyalib::SerializerConfig::new()
.indent(indent_unit)
.flow_style(noyalib::FlowStyle::Block);
let rendered = noyalib::to_string_value_with_config(&to_noyalib_value(value)?, &config)
.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: error.to_string(),
})?;
let pad = " ".repeat(indent);
let mut fragment = String::new();
for line in rendered.trim_end_matches(['\r', '\n']).lines() {
fragment.push_str(newline);
if line.is_empty() {
continue;
}
fragment.push_str(&pad);
fragment.push_str(line);
}
Ok(fragment)
}
fn line_column_of(content: &str, offset: usize) -> usize {
let line_start = content[..offset].rfind('\n').map_or(0, |index| index + 1);
offset - line_start
}
fn end_of_trailing_inline_comment(content: &str, value_end: usize) -> usize {
let bytes = content.as_bytes();
let mut index = value_end;
while index < bytes.len() && matches!(bytes[index], b' ' | b'\t') {
index += 1;
}
if index >= bytes.len() || bytes[index] != b'#' {
return value_end;
}
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
if index > 0 && bytes[index - 1] == b'\r' {
index -= 1;
}
index
}
pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
let segments = crate::document::parse_path(path)?;
let yaml_path = cst_path(&segments, "unset")?;
guard_cst_segments(content, &segments, "unset", false)?;
let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
refuse_through_alias(&document, &segments, segments.len() - 1, "unset")?;
document
.remove(&yaml_path)
.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "unset".to_string(),
detail: error.to_string(),
})?;
document
.validate()
.map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
Ok(document.to_string())
}
pub fn append_array_item_preserving(
content: &str,
path: &str,
item: &Value,
) -> DocumentResult<String> {
let segments = array_path_segments(path)?;
let yaml_path = cst_path(&segments, "add")?;
let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
refuse_through_alias(&document, &segments, segments.len(), "add")?;
let loaded = load(content)?;
let existing = array_at_path(&loaded, path, "add")?;
let previous_len = existing.len();
let canonical_item = load(&save(item)?)?;
if previous_len == 0 {
if path.is_empty()
|| segments
.last()
.is_some_and(|part| part.parse::<usize>().is_ok())
{
let (start, end) = document
.span_at(&yaml_path)
.ok_or_else(|| DocumentError::PathNotFound { path: path.into() })?;
let replacement = flow_fragment(&Value::Array(vec![item.clone()]))?;
document
.replace_span(start, end, &replacement)
.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".into(),
operation: "add".into(),
detail: error.to_string(),
})?;
let output = document.to_string();
let edited = load(&output)?;
if array_at_path(&edited, path, "add")?.as_slice()
!= std::slice::from_ref(&canonical_item)
{
return Err(DocumentError::UnsupportedOperation {
format: "YAML".into(),
operation: "add".into(),
detail: "the source edit did not reproduce the requested item".into(),
});
}
return Ok(output);
}
return set_preserving(content, path, &Value::Array(vec![item.clone()]));
}
let mut fragment = yaml_fragment(item, "add")?;
{
let item_path = format!("{yaml_path}[{}]", previous_len - 1);
let (item_start, _) =
document
.span_at(&item_path)
.ok_or_else(|| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: "could not resolve the existing sequence-item indentation".to_string(),
})?;
let dash_column = preceding_sequence_dash_column(content, item_start).ok_or_else(|| {
DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: "only block sequences can be extended without rebuilding the document"
.to_string(),
}
})?;
fragment = indent_continuation_lines(fragment.trim_end(), dash_column + 2);
}
document
.push_back(&yaml_path, fragment.trim_end())
.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: error.to_string(),
})?;
document
.validate()
.map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
let output = document.to_string();
let edited = load(&output)?;
let edited_items = array_at_path(&edited, path, "add")?;
if edited_items.len() != previous_len + 1 || edited_items.last() != Some(&canonical_item) {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: "the source edit did not reproduce the requested keyed item".to_string(),
});
}
Ok(output)
}
pub fn remove_array_item_preserving(
content: &str,
path: &str,
index: usize,
) -> DocumentResult<String> {
let segments = array_path_segments(path)?;
let loaded = load(content)?;
if array_at_path(&loaded, path, "remove")?.len() == 1 && index == 0 {
return set_preserving(content, path, &Value::Array(Vec::new()));
}
let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
refuse_through_alias(&document, &segments, segments.len(), "remove")?;
let yaml_path = cst_path(&segments, "remove")?;
document
.remove(&format!("{yaml_path}[{index}]"))
.map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "remove".to_string(),
detail: error.to_string(),
})?;
document
.validate()
.map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
Ok(document.to_string())
}
fn array_path_segments(path: &str) -> DocumentResult<Vec<String>> {
if path.is_empty() {
Ok(Vec::new())
} else {
crate::document::parse_path(path)
}
}
fn array_at_path<'a>(
value: &'a Value,
path: &str,
operation: &str,
) -> DocumentResult<&'a Vec<Value>> {
let target = if path.is_empty() {
value
} else {
crate::document::get_path_ref(value, path, Addressing::INDEX_ONLY)?
};
target
.as_array()
.ok_or_else(|| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail: "target is not an array".to_string(),
})
}
fn preceding_sequence_dash_column(content: &str, value_start: usize) -> Option<usize> {
let bytes = content.as_bytes();
let mut position = value_start;
let dash = loop {
position = position.checked_sub(1)?;
match bytes.get(position)? {
b' ' | b'\t' => {}
b'-' => break position,
_ => return None,
}
};
let line_start = content.as_bytes()[..dash]
.iter()
.rposition(|byte| *byte == b'\n')
.map_or(0, |newline| newline + 1);
Some(dash - line_start)
}
fn indent_continuation_lines(fragment: &str, indent: usize) -> String {
if !fragment.contains('\n') {
return fragment.to_string();
}
let padding = " ".repeat(indent);
let mut output = String::with_capacity(fragment.len() + indent * 4);
for (index, line) in fragment.split('\n').enumerate() {
if index > 0 {
output.push('\n');
if !line.is_empty() {
output.push_str(&padding);
}
}
output.push_str(line);
}
output
}
fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
let mut path = String::new();
for segment in segments {
if segment.contains(['.', '\\', '[', ']']) {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail:
"escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
.to_string(),
});
}
if let Ok(index) = segment.parse::<usize>() {
path.push_str(&format!("[{index}]"));
} else {
if !path.is_empty() {
path.push('.');
}
path.push_str(segment);
}
}
Ok(path)
}
fn through_alias_refused(
target: &[String],
site: &[String],
alias: Option<&str>,
operation: &str,
) -> DocumentError {
let alias = alias.map_or_else(|| "a YAML alias".to_string(), |name| format!("`*{name}`"));
let target = crate::document::join_path(target);
let site = crate::document::join_path(site);
let location = if site == target {
String::new()
} else {
format!(" at `{site}`")
};
DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail: format!(
"`{target}` is reached through the alias {alias}{location}; afdata does not edit \
through YAML aliases, because the edit would change the anchored value and every \
alias of it. Edit the anchor's own path instead"
),
}
}
fn anchor_before(document: &noyalib::cst::Document, value_start: usize) -> Option<String> {
document
.anchors()
.into_iter()
.rev()
.find(|anchor| anchor.mark_span.1 <= value_start)
.map(|anchor| anchor.name)
}
fn refuse_through_alias(
document: &noyalib::cst::Document,
target: &[String],
walked: usize,
operation: &str,
) -> DocumentResult<()> {
let segments = &target[..walked.min(target.len())];
for (index, segment) in segments.iter().enumerate() {
let prefix = &segments[..=index];
let Some((start, _)) = document.span_at(&cst_path(prefix, operation)?) else {
return Ok(());
};
let through_alias = match segment.parse::<usize>() {
Ok(0) if index > 0 => document
.span_at(&cst_path(&segments[..index], operation)?)
.is_some_and(|(parent_start, _)| start < parent_start),
Ok(0) => false,
Ok(position) => {
let previous = format!(
"{}[{}]",
cst_path(&segments[..index], operation)?,
position - 1
);
document
.span_at(&previous)
.is_some_and(|(_, previous_end)| start < previous_end)
}
Err(_) => document
.key_span(&cst_path(prefix, operation)?)
.is_some_and(|(_, key_end)| start < key_end),
};
if through_alias {
return Err(through_alias_refused(
target,
prefix,
anchor_before(document, start).as_deref(),
operation,
));
}
}
Ok(())
}
fn guard_cst_segments(
content: &str,
segments: &[String],
operation: &str,
allow_missing: bool,
) -> DocumentResult<()> {
let root = load(content)?;
let mut current = &root;
for (index, segment) in segments.iter().enumerate() {
match current {
Value::Object(object) => {
if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail: format!(
"mapping key `{segment}` is ambiguous in the CST path grammar"
),
});
}
match object.get(segment) {
Some(next) => current = next,
None if allow_missing => {
if segments[index..]
.iter()
.any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
{
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail: "a missing mapping chain contains a CST-ambiguous key"
.to_string(),
});
}
return Ok(());
}
None => {
return Err(DocumentError::PathNotFound {
path: crate::document::join_path(&segments[..=index]),
});
}
}
}
Value::Array(values) => {
let array_index =
segment
.parse::<usize>()
.map_err(|_| DocumentError::UnregisteredArray {
path: crate::document::join_path(&segments[..index]),
})?;
current =
values
.get(array_index)
.ok_or_else(|| DocumentError::IndexOutOfBounds {
path: crate::document::join_path(&segments[..index]),
index: array_index,
len: values.len(),
})?;
}
value => {
return Err(DocumentError::NotTraversable {
path: crate::document::join_path(&segments[..index]),
got: value.kind_name().to_string(),
});
}
}
}
Ok(())
}
fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
match value {
Value::Null => Ok(YamlValue::Null),
Value::Bool(value) => Ok(YamlValue::Bool(*value)),
Value::Integer(value) => Ok(YamlValue::from(*value)),
Value::Unsigned(value) => Ok(YamlValue::from(*value)),
Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
Value::Float(_) => Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "non-finite YAML float is not representable".to_string(),
}),
Value::Number(text) if value.is_float() => text
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(YamlValue::from)
.ok_or_else(|| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "float literal is not representable in YAML".to_string(),
}),
Value::Number(_) => Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "set".to_string(),
detail: "integer literal exceeds YAML's 64-bit integer range".to_string(),
}),
Value::String(value) => Ok(YamlValue::String(value.clone())),
Value::Array(values) => Ok(YamlValue::Sequence(
values
.iter()
.map(to_noyalib_value)
.collect::<DocumentResult<Vec<_>>>()?,
)),
Value::Object(values) => {
let mut mapping = YamlMapping::new();
for (key, value) in values {
mapping.insert(key.clone(), to_noyalib_value(value)?);
}
Ok(YamlValue::Mapping(mapping))
}
}
}
pub fn save(value: &Value) -> DocumentResult<String> {
render(value, None)
}
fn flow_fragment(value: &Value) -> DocumentResult<String> {
let config = noyalib::SerializerConfig::new().flow_style(noyalib::FlowStyle::Flow);
let rendered = render(value, Some(&config))?;
let line = rendered.trim_end_matches(['\r', '\n']);
if line.contains('\n') {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "add".to_string(),
detail: "the item has no single-line flow spelling".to_string(),
});
}
Ok(line.to_string())
}
fn render(value: &Value, config: Option<&noyalib::SerializerConfig>) -> DocumentResult<String> {
let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
while value_contains_text(value, &prefix) {
prefix.push('_');
}
let mut literals = Vec::new();
let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
let rendered = match config {
Some(config) => noyalib::to_string_value_with_config(&yaml_val, config),
None => to_string(&yaml_val),
};
let mut output = rendered.map_err(|e| DocumentError::ParseError {
format: "YAML".to_string(),
detail: e.to_string(),
})?;
for (sentinel, literal) in literals {
output = output.replace(&sentinel, &literal);
}
Ok(output)
}
fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
match v {
YamlValue::Null => Value::Null,
YamlValue::Bool(b) => Value::Bool(b),
YamlValue::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Integer(i)
} else if let Some(u) = n.as_u64() {
Value::Unsigned(u)
} else {
Value::Float(n.as_f64())
}
}
YamlValue::String(s) => literals
.get(&s)
.cloned()
.map(Value::Number)
.unwrap_or(Value::String(s)),
YamlValue::Sequence(seq) => Value::Array(
seq.into_iter()
.map(|value| value_to_our_value(value, literals))
.collect(),
),
YamlValue::Mapping(map) => {
let mut obj = std::collections::BTreeMap::new();
for (key, value) in map {
let key = literals.get(&key).cloned().unwrap_or(key);
obj.insert(key, value_to_our_value(value, literals));
}
Value::Object(obj)
}
YamlValue::Tagged(t) => {
let (_, value) = t.into_parts();
value_to_our_value(value, literals)
}
}
}
fn our_value_to_yaml_value(
v: &Value,
prefix: &str,
literals: &mut Vec<(String, String)>,
) -> DocumentResult<YamlValue> {
match v {
Value::Null => Ok(YamlValue::Null),
Value::Bool(b) => Ok(YamlValue::Bool(*b)),
Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
Value::Float(_) => Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "save".to_string(),
detail: "non-finite float is not representable in YAML".to_string(),
}),
Value::Number(text) => {
if !is_json_number(text) {
return Err(DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: "save".to_string(),
detail: "invalid number literal".to_string(),
});
}
let sentinel = format!("{prefix}{}__", literals.len());
literals.push((sentinel.clone(), text.clone()));
Ok(YamlValue::String(sentinel))
}
Value::String(s) => Ok(YamlValue::String(s.clone())),
Value::Array(a) => {
let seq = a
.iter()
.map(|value| our_value_to_yaml_value(value, prefix, literals))
.collect::<DocumentResult<Vec<_>>>()?;
Ok(YamlValue::Sequence(seq))
}
Value::Object(o) => {
let mut mapping = YamlMapping::new();
for (k, v) in o {
mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
}
Ok(YamlValue::Mapping(mapping))
}
}
}
fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
if let Value::Number(text) = value
&& is_json_number(text)
{
return Ok(text.clone());
}
if matches!(value, Value::Array(_) | Value::Object(_)) {
return save(value);
}
to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
format: "YAML".to_string(),
operation: operation.to_string(),
detail: error.to_string(),
})
}
fn protect_exact_numbers(
content: &str,
) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
let document = parse_document(content).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
let mut spans = Vec::new();
collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
while content.contains(&prefix) {
prefix.push('_');
}
let mut protected = content.to_string();
let mut literals = std::collections::HashMap::new();
for (index, (start, end)) in spans.into_iter().enumerate().rev() {
let literal = content[start..end].to_string();
let sentinel = format!("{prefix}{index}__");
let quoted =
serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
format: "YAML".to_string(),
detail: error.to_string(),
})?;
protected.replace_range(start..end, "ed);
literals.insert(sentinel, literal);
}
Ok((protected, literals))
}
fn collect_exact_number_spans(
node: &GreenNode,
source: &str,
base: usize,
spans: &mut Vec<(usize, usize)>,
) {
let mut offset = base;
for child in node.children() {
match child {
GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
GreenChild::Token {
kind: SyntaxKind::PlainScalar,
len,
} => {
let token_end = offset + *len as usize;
let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
let end = offset + text.len();
if should_preserve_number(text) {
spans.push((offset, end));
}
}
GreenChild::Token { .. } => {}
}
offset += child.text_len();
}
}
fn should_preserve_number(text: &str) -> bool {
if !is_json_number(text) {
return false;
}
text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
}
fn is_json_number(text: &str) -> bool {
serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
}
fn value_contains_text(value: &Value, needle: &str) -> bool {
match value {
Value::Number(text) | Value::String(text) => text.contains(needle),
Value::Array(values) => values
.iter()
.any(|value| value_contains_text(value, needle)),
Value::Object(values) => values
.iter()
.any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::{load, save, set_preserving};
use crate::document::Value;
#[test]
fn new_fields_in_flow_mappings_preserve_their_exact_value_and_source() {
use crate::document::{Document, Format};
for (source, path) in [
("{}\n", "field"),
("{existing: before}\n", "field"),
("outer: {}\n", "outer.field"),
("# keep\r\n{}\r\n", "field"),
] {
for value in [
Value::String("new".into()),
Value::Bool(true),
Value::Null,
Value::Integer(7),
Value::Unsigned(7),
Value::Float(2.0),
Value::Array(Vec::new()),
Value::Object(Default::default()),
Value::Number("123456789012345678901234567890".into()),
Value::Number("0.10000000000000000000001".into()),
] {
let mut document = Document::parse(source, Format::Yaml).unwrap();
document
.set(path, value.clone())
.expect("insert into a flow mapping");
assert_eq!(document.value_at(path).unwrap(), value);
assert_eq!(
load(document.source()).unwrap(),
load(&save(document.value()).unwrap()).unwrap()
);
if source.starts_with("# keep") {
assert!(document.source().starts_with("# keep\r\n"));
assert!(document.source().ends_with("\r\n"));
}
}
}
}
#[test]
fn a_refused_key_cannot_fall_back_to_inserting_multiple_entries() {
use crate::document::{Document, Format};
let source = "attachments:\n";
let mut document = Document::parse(source, Format::Yaml).unwrap();
let original = document.value().clone();
assert!(
document
.set("one:\ntwo", Value::String("injected".into()))
.is_err()
);
assert_eq!(document.source(), source);
assert_eq!(document.value(), &original);
}
#[test]
fn preserves_large_integer_and_high_precision_float_literals() {
let source = concat!(
"huge: 123456789012345678901234567890\n",
"precise: 0.1000000000000000055511151231257827\n",
);
let value = load(source).expect("load");
assert_eq!(
value.get("huge"),
Some(&Value::Number("123456789012345678901234567890".to_string()))
);
assert_eq!(
value.get("precise"),
Some(&Value::Number(
"0.1000000000000000055511151231257827".to_string()
))
);
let rendered = save(&value).expect("save");
assert!(rendered.contains("123456789012345678901234567890"));
assert!(rendered.contains("0.1000000000000000055511151231257827"));
assert_eq!(load(&rendered).expect("reload"), value);
}
#[test]
fn exact_number_set_keeps_the_literal() {
let edited = set_preserving(
"price: 1.0\n",
"price",
&Value::Number("12345678901234567890.123456789".to_string()),
)
.expect("set");
assert_eq!(edited, "price: 12345678901234567890.123456789\n");
assert_eq!(
load(&edited).expect("reload").get("price"),
Some(&Value::Number("12345678901234567890.123456789".to_string()))
);
}
fn contact_source() -> &'static str {
"# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n - alice@example.com # work\ntags: []\nrole: ''\n"
}
#[test]
fn replacing_a_collection_keeps_every_other_byte() {
let edited = set_preserving(
contact_source(),
"tags",
&Value::Array(vec![Value::String("vip".into())]),
)
.expect("an empty sequence can grow");
assert_eq!(
edited,
"# a leading comment\ndisplay_name: Alice Example\nkind: contact\nemails:\n - alice@example.com # work\ntags:\n - vip\nrole: ''\n"
);
let cleared = set_preserving(&edited, "tags", &Value::Array(Vec::new()))
.expect("a sequence can shrink to empty");
assert_eq!(cleared, contact_source());
}
#[test]
fn replacing_a_collection_quotes_elements_that_need_it() {
let edited = set_preserving(
contact_source(),
"tags",
&Value::Array(vec![
Value::String("true".into()),
Value::String("123".into()),
Value::String("a: b".into()),
Value::String("#c".into()),
Value::String(String::new()),
]),
)
.expect("tricky scalars are the emitter's problem, not ours");
let loaded = load(&edited).expect("the result parses");
assert_eq!(
crate::document::get_path(&loaded, "tags", crate::document::Addressing::INDEX_ONLY)
.expect("tags"),
Value::Array(vec![
Value::String("true".into()),
Value::String("123".into()),
Value::String("a: b".into()),
Value::String("#c".into()),
Value::String(String::new()),
])
);
}
#[test]
fn replacing_a_collection_takes_the_comment_that_described_it() {
let edited = set_preserving(
"emails:\n - alice@example.com # work\nrole: ''\n",
"emails",
&Value::Array(vec![Value::String("bob@example.com".into())]),
)
.expect("replace");
assert_eq!(edited, "emails:\n - bob@example.com\nrole: ''\n");
assert!(!edited.contains("# work"), "{edited}");
}
#[test]
fn a_collection_reached_through_an_alias_is_refused() {
let source = "base: &b\n - x\nother: *b\n";
let error = set_preserving(
source,
"other",
&Value::Array(vec![Value::String("y".into())]),
)
.expect_err("an alias target must not be edited through");
assert!(error.to_string().contains("alias"), "{error}");
}
#[test]
fn a_new_key_can_be_created_with_a_collection_value() {
let edited = set_preserving(
"kind: contact\n",
"tags",
&Value::Array(vec![Value::String("vip".into())]),
)
.expect("a missing key is inserted");
assert_eq!(edited, "kind: contact\ntags:\n - vip\n");
let empty = set_preserving("kind: contact\n", "tags", &Value::Array(Vec::new()))
.expect("a missing key can be inserted empty");
assert_eq!(empty, "kind: contact\ntags: []\n");
}
#[test]
fn replacing_a_collection_keeps_the_source_newline_style() {
let edited = set_preserving(
"kind: contact\r\ntags: []\r\nrole: ''\r\n",
"tags",
&Value::Array(vec![Value::String("vip".into())]),
)
.expect("CRLF source");
assert_eq!(edited, "kind: contact\r\ntags:\r\n - vip\r\nrole: ''\r\n");
assert!(
!edited.replace("\r\n", "").contains('\n'),
"mixed endings: {edited:?}"
);
}
#[test]
fn a_new_key_lands_after_a_bare_null_last_entry() {
let source = "kind: draft\nattachments:\n";
let edited = set_preserving(source, "sync_intent", &Value::String("send".into()))
.expect("a new key can follow a bare-null entry");
assert_eq!(edited, "kind: draft\nattachments:\nsync_intent: send\n");
}
#[test]
fn save_rejects_non_finite_float() {
let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
assert!(error.to_string().contains("non-finite"));
}
}