use crate::selector;
use anyhow::Context;
use serde::Deserialize;
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::Path;
mod navigate;
mod preserve;
pub mod query;
mod toml_preserve;
mod yaml_cst;
mod yaml_splice;
use navigate::reject_blank_merge_overlay;
pub use navigate::{
deep_merge, delete_at_selector, delete_where, move_at_path, navigate_mut, set_at_path,
update_matching,
};
use toml_preserve::apply_value_diff;
use yaml_cst::{apply_yaml_mapping_diff, apply_yaml_sequence_diff, try_remove_subsequence};
pub use yaml_splice::needs_yaml_quoting;
#[derive(Debug, Clone, Copy)]
pub enum FileFormat {
Json,
Yaml,
Toml,
}
pub fn detect_format(path: &str) -> anyhow::Result<FileFormat> {
match Path::new(path).extension().and_then(|e| e.to_str()) {
Some("json") => Ok(FileFormat::Json),
Some("yaml" | "yml") => Ok(FileFormat::Yaml),
Some("toml") => Ok(FileFormat::Toml),
Some(ext) => Err(crate::exit::InvalidInputError {
msg: format!(
"unsupported file extension: .{ext} (supported: .json, .yaml, .yml, .toml)"
),
}
.into()),
None => Err(crate::exit::InvalidInputError {
msg: "file has no extension; doc commands require .json, .yaml, .yml, or .toml".into(),
}
.into()),
}
}
pub fn serialize_value(value: &serde_json::Value, format: &FileFormat) -> anyhow::Result<String> {
match format {
FileFormat::Json => {
let mut s = serde_json::to_string_pretty(value)?;
s.push('\n');
Ok(s)
}
FileFormat::Yaml => Ok(serde_yaml_ng::to_string(value)?),
FileFormat::Toml => {
let s = toml_edit::ser::to_string_pretty(value).map_err(|e| {
anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("TOML serialization error: {e}"),
})
})?;
Ok(s)
}
}
}
pub fn presentation_style_changed(original: &str, new_text: &str, format: &FileFormat) -> bool {
if original == new_text {
return false;
}
match format {
FileFormat::Yaml => {
yaml_block_sequence_style_marks(original) != yaml_block_sequence_style_marks(new_text)
|| yaml_alias_identity_counts(original) != yaml_alias_identity_counts(new_text)
}
FileFormat::Json | FileFormat::Toml => false,
}
}
pub fn style_changed_for_path(path: &str, original: &str, new_text: &str) -> bool {
let Ok(fmt) = detect_format(path) else {
return false;
};
presentation_style_changed(original, new_text, &fmt)
}
fn yaml_alias_identity_counts(text: &str) -> (usize, usize, usize) {
let mut anchors = 0usize;
let mut aliases = 0usize;
let mut merges = 0usize;
for line in text.lines() {
let code = yaml_line_without_comment(line);
anchors += count_yaml_prefixed_idents(code, '&');
aliases += count_yaml_prefixed_idents(code, '*');
merges += code.matches("<<:").count();
}
(anchors, aliases, merges)
}
fn yaml_line_without_comment(line: &str) -> &str {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return "";
}
match line.find(" #") {
Some(i) => &line[..i],
None => line,
}
}
fn count_yaml_prefixed_idents(s: &str, prefix: char) -> usize {
let chars: Vec<char> = s.chars().collect();
let mut n = 0usize;
let mut i = 0usize;
while i < chars.len() {
if chars[i] == prefix {
let next_ok = chars
.get(i + 1)
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_');
if next_ok {
n += 1;
i += 2;
while i < chars.len()
&& (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '-')
{
i += 1;
}
continue;
}
}
i += 1;
}
n
}
fn yaml_block_sequence_style_marks(text: &str) -> std::collections::BTreeSet<(usize, usize)> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
let dash_indent = line.len() - trimmed.len();
if trimmed == "-" {
return Some((dash_indent, 0));
}
let rest = trimmed.strip_prefix('-')?;
if rest.starts_with(' ') || rest.starts_with('\t') {
let pad = rest.len() - rest.trim_start().len();
Some((dash_indent, pad))
} else {
None
}
})
.collect()
}
pub fn serialize_value_preserving(
original_content: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
format: &FileFormat,
) -> anyhow::Result<String> {
match format {
FileFormat::Toml => {
let mut doc: toml_edit::DocumentMut = toml_source_for_parse(original_content)
.parse()
.map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("TOML re-parse for comment preservation: {e}"),
})
})?;
apply_value_diff(doc.as_item_mut(), old_value, new_value);
Ok(restore_toml_file_eol(original_content, doc.to_string()))
}
FileFormat::Yaml => {
if is_multi_document_yaml(original_content) {
return serialize_multi_document_yaml(original_content, old_value, new_value);
}
if let Some(result) = try_preserve_yaml(original_content, old_value, new_value)? {
return Ok(result);
}
if old_value == new_value {
Ok(original_content.to_string())
} else {
let body = serialize_value(new_value, format)?;
Ok(preserve::hoist_comments(original_content, &body))
}
}
_ => {
if old_value == new_value {
Ok(original_content.to_string())
} else {
serialize_value(new_value, format)
}
}
}
}
fn cleanup_yaml_cst_whitespace(text: &str) -> String {
let eol = crate::write::detect_eol(text);
let mut result: String = text
.lines()
.map(|line| line.trim_end())
.collect::<Vec<_>>()
.join(eol);
if result.is_empty() || !result.ends_with('\n') {
result.push_str(eol);
}
result
}
#[cfg(test)]
fn fix_yaml_block_indentation(text: &str) -> String {
fix_yaml_block_indentation_with_original(None, text)
}
fn fix_yaml_block_indentation_with_original(original: Option<&str>, text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut result: Vec<String> = Vec::with_capacity(lines.len());
let original_trim_ends: Option<HashSet<&str>> =
original.map(|orig| orig.lines().map(str::trim_end).collect());
for i in 0..lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.is_empty() {
result.push(line.to_string());
continue;
}
if trimmed.starts_with('#') {
let comment_indent = line.len() - trimmed.len();
if comment_indent > 0
&& let Some(next_indent) = next_entry_indent(&lines, i)
&& next_indent < comment_indent
{
result.push(format!("{}{}", " ".repeat(next_indent), trimmed));
continue;
}
result.push(line.to_string());
continue;
}
let indent = line.len() - trimmed.len();
if is_yaml_sequence_item(trimmed)
&& let Some(rest) = trimmed.strip_prefix('-')
{
let rest_trim = rest.trim_start();
if rest.len() - rest_trim.len() > 1
&& (rest_trim.contains(": ") || rest_trim.ends_with(':'))
&& !is_yaml_sequence_item(rest_trim)
&& original_trim_ends
.as_ref()
.is_some_and(|orig| !orig.contains(line.trim_end()))
{
result.push(format!("{}- {rest_trim}", " ".repeat(indent)));
continue;
}
}
let is_mapping_entry = indent > 0
&& !is_yaml_sequence_item(trimmed)
&& (trimmed.contains(": ") || trimmed.ends_with(':'));
if is_mapping_entry
&& let Some(expected) = expected_sibling_indent(&lines, i, indent)
&& expected < indent
{
result.push(format!("{}{}", " ".repeat(expected), trimmed));
continue;
}
result.push(line.to_string());
}
let eol = crate::write::detect_eol(text);
let mut out = result.join(eol);
if text.ends_with('\n') && !out.ends_with('\n') {
out.push_str(eol);
}
out
}
fn repair_glued_block_sequence_items(text: &str) -> String {
let eol = crate::write::detect_eol(text);
let mut lines: Vec<String> = Vec::new();
for line in text.lines() {
let mut remaining = line.to_string();
loop {
match split_glued_block_sequence_line(&remaining) {
Some((first, second)) => {
lines.push(first);
remaining = second;
}
None => {
lines.push(remaining);
break;
}
}
}
}
let mut out = lines.join(eol);
if text.ends_with('\n') && !out.ends_with('\n') {
out.push_str(eol);
}
out
}
fn split_glued_block_sequence_line(line: &str) -> Option<(String, String)> {
let indent_len = line.len() - line.trim_start().len();
let trimmed = &line[indent_len..];
let after_dash = trimmed.strip_prefix('-')?;
let pad_len = after_dash.len() - after_dash.trim_start().len();
if pad_len == 0 {
return None;
}
let after_pad = after_dash.trim_start();
let first_item = if after_pad.starts_with("{}") {
"{}"
} else if after_pad.starts_with("[]") {
"[]"
} else {
return None;
};
let rest = &after_pad[first_item.len()..];
let pad = &after_dash[..pad_len];
let first = format!("{}-{pad}{first_item}", &line[..indent_len]);
let rest_trim = rest.trim_start();
if is_yaml_sequence_item(rest_trim) {
let between = &rest[..rest.len() - rest_trim.len()];
if between.chars().all(|c| c == ' ' || c == '\t') {
let second = if between.is_empty() {
format!("{}{rest_trim}", &line[..indent_len])
} else {
format!("{between}{rest_trim}")
};
return Some((first, second));
}
}
if !rest.is_empty()
&& !rest_trim.starts_with('#')
&& !is_yaml_sequence_item(rest_trim)
&& (rest_trim.contains(": ") || rest_trim.ends_with(':'))
{
return Some((first, rest.to_string()));
}
None
}
fn next_entry_indent(lines: &[&str], i: usize) -> Option<usize> {
for line in &lines[i + 1..] {
let t = line.trim();
if !t.is_empty() && !t.starts_with('#') {
return Some(line.len() - t.len());
}
}
None
}
fn expected_sibling_indent(lines: &[&str], i: usize, current_indent: usize) -> Option<usize> {
let is_significant = |l: &&str| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#')
};
if let Some(next_line) = lines[i + 1..].iter().find(|l| is_significant(l)) {
let nt = next_line.trim_start();
let ni = next_line.len() - nt.len();
let next_is_entry = !is_yaml_sequence_item(nt) && (nt.contains(": ") || nt.ends_with(':'));
if ni < current_indent && ni > 0 && next_is_entry {
let prev_ok = lines[..i]
.iter()
.rev()
.find(|l| is_significant(l))
.is_some_and(|l| {
let t = l.trim_start();
if is_yaml_sequence_item(t) {
return false;
}
let pi = l.len() - t.len();
(pi < ni && l.trim_end().ends_with(':')) || pi == ni
});
if prev_ok {
return Some(ni);
}
}
}
if let Some(prev_line) = lines[..i].iter().rev().find(|l| is_significant(l)) {
let pt = prev_line.trim_start();
let pi = prev_line.len() - pt.len();
if pi < current_indent
&& pi > 0
&& pt.contains(": ")
&& !is_yaml_parent_line(pt)
&& !is_yaml_sequence_item(pt)
{
return Some(pi);
}
}
None
}
fn is_yaml_sequence_item(trimmed: &str) -> bool {
trimmed == "-" || trimmed.starts_with("- ")
}
fn is_yaml_parent_line(trimmed: &str) -> bool {
if trimmed.ends_with(':') {
return true;
}
if let Some(pos) = trimmed.find(": ") {
let after = trimmed[pos + 2..].trim();
return after.is_empty() || after.starts_with('#');
}
false
}
fn try_preserve_yaml(
original_content: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
use std::str::FromStr;
let file = yaml_edit::YamlFile::from_str(original_content).map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("YAML re-parse for comment preservation: {e}"),
})
})?;
let promoted =
yaml_cst::rewrite_yaml_alias_object_edits(original_content, &file, old_value, new_value)?;
if let Some(spliced) = promoted.as_deref()
&& yaml_semantic_eq(spliced, new_value)
{
return Ok(Some(cleanup_yaml_cst_whitespace(spliced)));
}
if let Some(spliced) = promoted.as_deref()
&& let Some(reparsed) = parse_yaml_semantic(spliced)
&& let Some(grown) = yaml_splice::splice_yaml_array_diffs(spliced, &reparsed, new_value)?
{
return Ok(Some(grown));
}
let (file, cst_old) = if let Some(spliced) = promoted.as_deref() {
match yaml_file_after_partial_alias_splice(spliced) {
Some(pair) => pair,
None => return Ok(None),
}
} else {
(file, old_value.clone())
};
if let Some(doc) = file.document() {
if let Some(mapping) = doc.as_mapping() {
if cst_old.is_object() && new_value.is_object() {
return try_preserve_yaml_object(
promoted.as_deref().unwrap_or(original_content),
&file,
&mapping,
&cst_old,
new_value,
);
}
} else if let Some(seq) = doc.as_sequence()
&& let (Some(old_arr), Some(new_arr)) = (cst_old.as_array(), new_value.as_array())
{
return try_preserve_yaml_array(
&file,
&seq,
promoted.as_deref().unwrap_or(original_content),
old_arr,
new_arr,
new_value,
);
}
}
Ok(None)
}
fn yaml_file_after_partial_alias_splice(
spliced: &str,
) -> Option<(yaml_edit::YamlFile, serde_json::Value)> {
use std::str::FromStr;
let reparsed = yaml_edit::YamlFile::from_str(spliced).ok()?;
let cst_old = parse_yaml_semantic(spliced)?;
Some((reparsed, cst_old))
}
fn try_preserve_yaml_object(
original: &str,
file: &yaml_edit::YamlFile,
mapping: &yaml_edit::Mapping,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
let all_cst_applied = apply_yaml_mapping_diff(mapping, old_value, new_value)?;
let result = finalize_yaml_cst_text(original, &file.to_string());
if yaml_semantic_eq(&result, new_value) {
return Ok(Some(result));
}
if !all_cst_applied
&& let Some(finished) = retry_yaml_cst_empties(original, &result, new_value)?
{
return Ok(Some(finished));
}
if let Some(reparsed) = parse_yaml_semantic(&result)
&& let Some(spliced) = yaml_splice::splice_yaml_array_diffs(&result, &reparsed, new_value)?
{
return Ok(Some(spliced));
}
Ok(None)
}
fn finalize_yaml_cst_text(original: &str, cst: &str) -> String {
fix_yaml_block_indentation_with_original(
Some(original),
&repair_glued_empty_flow_after_colon(&repair_glued_block_sequence_items(
&cleanup_yaml_cst_whitespace(cst),
)),
)
}
fn repair_glued_empty_flow_after_colon(text: &str) -> String {
let eol = crate::write::detect_eol(text);
let mut lines: Vec<String> = Vec::new();
for line in text.lines() {
lines.push(repair_glued_empty_flow_line(line));
}
let mut out = lines.join(eol);
if text.ends_with('\n') && !out.ends_with('\n') {
out.push_str(eol);
}
out
}
fn repair_glued_empty_flow_line(line: &str) -> String {
let indent_len = line.len() - line.trim_start().len();
let trimmed = &line[indent_len..];
if is_yaml_sequence_item(trimmed) {
return line.to_string();
}
let Some((key, rest)) = trimmed.split_once(':') else {
return line.to_string();
};
if key.is_empty() || key.contains('#') {
return line.to_string();
}
if rest.starts_with("{}") || rest.starts_with("[]") {
return format!("{}{key}: {rest}", &line[..indent_len]);
}
line.to_string()
}
fn retry_yaml_cst_empties(
original: &str,
start: &str,
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
use std::str::FromStr;
let mut text = start.to_string();
for _ in 0..32 {
let Some(current) = parse_yaml_semantic(&text) else {
return Ok(None);
};
if current == *new_value {
return Ok(Some(text));
}
let Ok(file) = yaml_edit::YamlFile::from_str(&text) else {
return Ok(None);
};
let Some(doc) = file.document() else {
return Ok(None);
};
if let Some(mapping) = doc.as_mapping() {
apply_yaml_mapping_diff(&mapping, ¤t, new_value)?;
} else if let Some(seq) = doc.as_sequence()
&& let (Some(old_arr), Some(new_arr)) = (current.as_array(), new_value.as_array())
{
apply_yaml_sequence_diff(&seq, old_arr, new_arr)?;
} else {
return Ok(None);
}
let next = finalize_yaml_cst_text(original, &file.to_string());
if yaml_semantic_eq(&next, new_value) {
return Ok(Some(next));
}
if next == text {
return Ok(None);
}
text = next;
}
Ok(None)
}
fn try_preserve_yaml_array(
file: &yaml_edit::YamlFile,
seq: &yaml_edit::Sequence,
original_content: &str,
old_arr: &[serde_json::Value],
new_arr: &[serde_json::Value],
new_value: &serde_json::Value,
) -> anyhow::Result<Option<String>> {
let applied = if old_arr.len() == new_arr.len() {
apply_yaml_sequence_diff(seq, old_arr, new_arr)?
} else if new_arr.len() < old_arr.len() {
try_remove_subsequence(seq, old_arr, new_arr)
} else {
false
};
if old_arr.len() == new_arr.len() || applied {
let result = finalize_yaml_cst_text(original_content, &file.to_string());
if yaml_semantic_eq(&result, new_value) {
return Ok(Some(result));
}
if !applied
&& let Some(finished) = retry_yaml_cst_empties(original_content, &result, new_value)?
{
return Ok(Some(finished));
}
}
if new_arr.len() > old_arr.len()
&& let Some(spliced) =
yaml_splice::splice_yaml_root_sequence(original_content, old_arr, new_arr)?
&& yaml_semantic_eq(&spliced, new_value)
&& spliced.parse::<yaml_edit::YamlFile>().is_ok()
{
return Ok(Some(spliced));
}
Ok(None)
}
fn parse_yaml_semantic(text: &str) -> Option<serde_json::Value> {
let mut value: serde_json::Value = serde_yaml_ng::from_str(text).ok()?;
resolve_yaml_merge_keys(&mut value);
Some(value)
}
pub(super) fn yaml_semantic_eq(text: &str, expected: &serde_json::Value) -> bool {
parse_yaml_semantic(text).is_some_and(|v| v == *expected)
}
fn toml_source_for_parse(content: &str) -> Cow<'_, str> {
let bytes = content.as_bytes();
let mut i = 0;
let mut lone = false;
while i < bytes.len() {
if bytes[i] == b'\r' {
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
i += 2;
} else {
lone = true;
break;
}
} else {
i += 1;
}
}
if !lone {
return Cow::Borrowed(content);
}
let mut out = Vec::with_capacity(bytes.len());
i = 0;
while i < bytes.len() {
if bytes[i] == b'\r' {
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
out.extend_from_slice(b"\r\n");
i += 2;
} else {
out.push(b'\n');
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
Cow::Owned(String::from_utf8(out).expect("input was UTF-8"))
}
fn restore_toml_file_eol(original: &str, rendered: String) -> String {
if crate::write::detect_eol(original) == "\r" {
crate::write::normalize_eol(&rendered, crate::write::EolMode::Cr).into_owned()
} else {
rendered
}
}
pub fn parse_doc(content: &str, format: &FileFormat) -> anyhow::Result<serde_json::Value> {
let content = crate::ops::file::strip_utf8_bom(content);
match format {
FileFormat::Json => {
if content.trim().is_empty() {
Ok(serde_json::json!({}))
} else {
serde_json::from_str(content).map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
})
}
}
FileFormat::Yaml => {
if is_multi_document_yaml(content) {
parse_multi_document_yaml(content).map_err(|e| {
if crate::exit::is_parse_error(&e) {
e
} else {
anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
}
})
} else {
let mut val: serde_json::Value = serde_yaml_ng::from_str(content).map_err(|e| {
anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })
})?;
resolve_yaml_merge_keys(&mut val);
Ok(val)
}
}
FileFormat::Toml => toml_edit::de::from_str(&toml_source_for_parse(content))
.map_err(|e| anyhow::Error::new(crate::exit::ParseErrorError { msg: e.to_string() })),
}
}
pub fn parse_doc_for_query(
content: &str,
format: &FileFormat,
) -> anyhow::Result<serde_json::Value> {
if matches!(format, FileFormat::Yaml) && crate::containment::is_blank_text(content) {
return Ok(serde_json::json!({}));
}
parse_doc(content, format)
}
pub fn load_for_query(path: &Path) -> anyhow::Result<serde_json::Value> {
let display = path.to_string_lossy();
let content = crate::files::load_text_strict(path, &display)?;
let format = detect_format(&display)?;
parse_doc_for_query(&content, &format).with_context(|| format!("parsing {display}"))
}
pub(crate) fn is_multi_document_yaml(content: &str) -> bool {
let rest = content.strip_prefix("---").map_or(content, |after| {
if is_after_yaml_marker(after) {
skip_to_next_line(after)
} else {
content
}
});
if rest.starts_with("---") && is_after_yaml_marker(&rest[3..]) {
return true;
}
for (i, _) in rest.match_indices("\n---") {
let after_marker = &rest[i + 4..];
if is_after_yaml_marker(after_marker) {
return true;
}
}
false
}
fn is_after_yaml_marker(after: &str) -> bool {
if after.is_empty() {
return true;
}
let b = after.as_bytes()[0];
if b == b'\n' || b == b'\r' {
return true;
}
if b == b' ' || b == b'\t' || b == b'#' {
let rest = after
.as_bytes()
.iter()
.skip_while(|&&c| c == b' ' || c == b'\t')
.copied()
.next();
return rest.is_none() || rest == Some(b'\n') || rest == Some(b'\r') || rest == Some(b'#');
}
false
}
fn skip_to_next_line(s: &str) -> &str {
match s.find('\n') {
Some(pos) => &s[pos + 1..],
None => "",
}
}
fn parse_multi_document_yaml(content: &str) -> anyhow::Result<serde_json::Value> {
let mut docs = Vec::new();
for de in serde_yaml_ng::Deserializer::from_str(content) {
let mut val: serde_json::Value = serde_json::Value::deserialize(de)?;
resolve_yaml_merge_keys(&mut val);
docs.push(val);
}
debug_assert!(!docs.is_empty(), "multi-doc YAML produced zero documents");
Ok(serde_json::Value::Array(docs))
}
fn is_yaml_document_separator_line(line: &str) -> bool {
line.strip_prefix("---").is_some_and(is_after_yaml_marker)
}
pub(crate) fn split_multi_document_yaml(content: &str) -> (bool, Vec<String>) {
let mut bodies: Vec<String> = Vec::new();
let mut current = String::new();
let mut leading_marker = false;
let mut first_line = true;
let mut saw_body = false;
for line in content.split_inclusive('\n') {
let without_nl = line.trim_end_matches(['\n', '\r']);
if is_yaml_document_separator_line(without_nl) {
if first_line && !saw_body {
leading_marker = true;
first_line = false;
continue;
}
bodies.push(std::mem::take(&mut current));
first_line = false;
continue;
}
first_line = false;
saw_body = true;
current.push_str(line);
}
bodies.push(current);
(leading_marker, bodies)
}
fn join_multi_document_yaml(leading_marker: bool, docs: &[String], eol: &str) -> String {
let mut out = String::new();
if leading_marker {
out.push_str("---");
out.push_str(eol);
}
for (i, doc) in docs.iter().enumerate() {
if i > 0 {
if !out.ends_with('\n') {
out.push_str(eol);
}
out.push_str("---");
out.push_str(eol);
}
let body = doc.trim_end_matches(['\n', '\r']);
if !body.is_empty() {
out.push_str(body);
out.push_str(eol);
}
}
if out.is_empty() {
out.push_str(eol);
}
out
}
fn serialize_single_yaml_document(
original_body: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<String> {
if old_value == new_value && !original_body.is_empty() {
return Ok(original_body.to_string());
}
if !original_body.trim().is_empty()
&& let Some(result) = try_preserve_yaml(original_body, old_value, new_value)?
{
return Ok(result);
}
let body = serialize_value(new_value, &FileFormat::Yaml)?;
if original_body.is_empty() {
Ok(body)
} else {
Ok(preserve::hoist_comments(original_body, &body))
}
}
fn serialize_multi_document_yaml(
original_content: &str,
old_value: &serde_json::Value,
new_value: &serde_json::Value,
) -> anyhow::Result<String> {
if old_value == new_value {
return Ok(original_content.to_string());
}
let (leading_marker, bodies) = split_multi_document_yaml(original_content);
let Some(new_docs) = new_value.as_array() else {
let body = serialize_value(new_value, &FileFormat::Yaml)?;
return Ok(preserve::hoist_comments(original_content, &body));
};
let old_docs = old_value.as_array().map(|a| a.as_slice()).unwrap_or(&[]);
let mut out_docs: Vec<String> = Vec::with_capacity(new_docs.len());
if old_docs.len() == new_docs.len() {
for (i, new_doc) in new_docs.iter().enumerate() {
let orig_body = bodies.get(i).map(String::as_str).unwrap_or("");
let old_doc = old_docs.get(i);
match old_doc {
Some(old_doc) if old_doc == new_doc && !orig_body.is_empty() => {
out_docs.push(orig_body.to_string());
}
Some(old_doc) => {
out_docs.push(serialize_single_yaml_document(orig_body, old_doc, new_doc)?);
}
None => {
out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
}
}
}
} else {
let mut used = vec![false; old_docs.len()];
for new_doc in new_docs {
let match_j = old_docs.iter().enumerate().find_map(|(j, old_doc)| {
if !used[j] && old_doc == new_doc {
Some(j)
} else {
None
}
});
match match_j {
Some(j) => {
used[j] = true;
let orig_body = bodies.get(j).map(String::as_str).unwrap_or("");
if !orig_body.is_empty() {
out_docs.push(orig_body.to_string());
} else {
out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
}
}
None => {
out_docs.push(serialize_value(new_doc, &FileFormat::Yaml)?);
}
}
}
}
let eol = crate::write::detect_eol(original_content);
Ok(join_multi_document_yaml(leading_marker, &out_docs, eol))
}
pub fn parse_value(s: &str) -> serde_json::Value {
if s.starts_with('"')
&& s.ends_with('"')
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
return v;
}
if (s.starts_with('{') || s.starts_with('['))
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
return v;
}
if s == "true" {
return serde_json::Value::Bool(true);
}
if s == "false" {
return serde_json::Value::Bool(false);
}
if s == "null" {
return serde_json::Value::Null;
}
if let Ok(n) = s.parse::<i64>() {
return serde_json::Value::Number(n.into());
}
if let Ok(n) = s.parse::<f64>()
&& let Some(num) = serde_json::Number::from_f64(n)
{
return serde_json::Value::Number(num);
}
serde_json::Value::String(s.to_string())
}
fn push_key_quoted(buf: &mut String, k: &str) {
if k.contains('.') || k.contains('[') || k.contains(']') || k.contains('"') {
buf.push('"');
buf.push_str(&k.replace('"', "\\\""));
buf.push('"');
} else {
buf.push_str(k);
}
}
pub fn flatten_value<'a>(
value: &'a serde_json::Value,
buf: &mut String,
out: &mut Vec<(String, &'a serde_json::Value)>,
) {
match value {
serde_json::Value::Object(map) if !map.is_empty() => {
for (k, v) in map {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
flatten_value(v, buf, out);
buf.truncate(restore);
}
}
serde_json::Value::Array(arr) if !arr.is_empty() => {
for (i, v) in arr.iter().enumerate() {
let restore = buf.len();
buf.push('[');
let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
buf.push(']');
flatten_value(v, buf, out);
buf.truncate(restore);
}
}
_ => {
out.push((buf.clone(), value));
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiffEntry {
pub path: String,
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_value: Option<serde_json::Value>,
}
pub fn diff_values(
a: &serde_json::Value,
b: &serde_json::Value,
buf: &mut String,
out: &mut Vec<DiffEntry>,
) {
match (a, b) {
(serde_json::Value::Object(ma), serde_json::Value::Object(mb)) => {
for (k, va) in ma {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
if let Some(vb) = mb.get(k) {
diff_values(va, vb, buf, out);
} else {
out.push(DiffEntry {
path: buf.clone(),
kind: "removed",
old_value: Some(va.clone()),
new_value: None,
});
}
buf.truncate(restore);
}
for (k, vb) in mb {
if !ma.contains_key(k) {
let restore = buf.len();
if !buf.is_empty() {
buf.push('.');
}
push_key_quoted(buf, k);
out.push(DiffEntry {
path: buf.clone(),
kind: "added",
old_value: None,
new_value: Some(vb.clone()),
});
buf.truncate(restore);
}
}
}
(serde_json::Value::Array(aa), serde_json::Value::Array(ab)) => {
let max_len = aa.len().max(ab.len());
for i in 0..max_len {
let restore = buf.len();
buf.push('[');
let _ = std::fmt::Write::write_fmt(buf, format_args!("{i}"));
buf.push(']');
match (aa.get(i), ab.get(i)) {
(Some(va), Some(vb)) => diff_values(va, vb, buf, out),
(Some(va), None) => out.push(DiffEntry {
path: buf.clone(),
kind: "removed",
old_value: Some(va.clone()),
new_value: None,
}),
(None, Some(vb)) => out.push(DiffEntry {
path: buf.clone(),
kind: "added",
old_value: None,
new_value: Some(vb.clone()),
}),
(None, None) => {}
}
buf.truncate(restore);
}
}
_ => {
if a != b {
out.push(DiffEntry {
path: buf.clone(),
kind: "changed",
old_value: Some(a.clone()),
new_value: Some(b.clone()),
});
}
}
}
}
fn resolve_yaml_merge_keys(value: &mut serde_json::Value) {
resolve_yaml_merge_keys_inner(value, 0);
}
fn resolve_yaml_merge_keys_inner(value: &mut serde_json::Value, depth: usize) {
if depth >= navigate::MAX_MERGE_DEPTH {
return;
}
match value {
serde_json::Value::Object(map) => {
for v in map.values_mut() {
resolve_yaml_merge_keys_inner(v, depth + 1);
}
if let Some(merge_val) = map.remove("<<") {
match merge_val {
serde_json::Value::Object(merged) => {
for (k, v) in merged {
map.entry(k).or_insert(v);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
if let serde_json::Value::Object(merged) = item {
for (k, v) in merged {
map.entry(k).or_insert(v);
}
}
}
}
_ => {
map.insert("<<".to_string(), merge_val);
}
}
}
}
serde_json::Value::Array(arr) => {
for v in arr {
resolve_yaml_merge_keys_inner(v, depth + 1);
}
}
_ => {}
}
}
#[derive(Debug)]
pub enum DocMutation {
Set {
selector: String,
value: serde_json::Value,
},
Delete {
selector: String,
},
Merge {
selector: Option<String>,
value: serde_json::Value,
},
Append {
selector: String,
value: serde_json::Value,
},
Prepend {
selector: String,
value: serde_json::Value,
},
Update {
selector: String,
value: serde_json::Value,
},
Move {
from: String,
to: String,
},
Ensure {
selector: String,
value: serde_json::Value,
},
DeleteWhere {
selector: String,
predicate: String,
},
}
#[derive(Debug)]
pub enum MutationResult {
Applied,
Removed(usize),
NoMatch,
AlreadyExists,
TypeError(String),
}
pub fn apply_doc_mutation(
root: &mut serde_json::Value,
mutation: DocMutation,
) -> anyhow::Result<MutationResult> {
match mutation {
DocMutation::Set { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
set_at_path(root, &sel, value)?;
Ok(MutationResult::Applied)
}
DocMutation::Delete { selector } => {
let sel = selector::parse_anyhow(&selector)?;
if delete_at_selector(root, &sel)? {
Ok(MutationResult::Removed(1))
} else {
Ok(MutationResult::NoMatch)
}
}
DocMutation::Merge { selector, value } => {
let target = if let Some(sel) = selector.as_deref().filter(|s| !s.is_empty()) {
let parsed = selector::parse_anyhow(sel)?;
navigate_mut(root, &parsed, false, "doc.merge")?
} else {
root
};
if target.is_array() {
return Ok(MutationResult::TypeError(
"doc merge: target is a top-level array (multi-document YAML or JSON \
array); deep-merge would replace the whole stream with the overlay. \
Pass a selector to an object document (e.g. `--selector 0` / plan \
`\"selector\": \"0\"`) or use doc.set under `0.` / `[0].`"
.into(),
));
}
if !target.is_object() && !value.is_object() {
}
reject_blank_merge_overlay(&value)?;
deep_merge(target, &value);
Ok(MutationResult::Applied)
}
DocMutation::Append { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
let target = navigate_mut(root, &sel, false, "doc.append")?;
match target.as_array_mut() {
Some(arr) => {
arr.push(value);
Ok(MutationResult::Applied)
}
None => Ok(MutationResult::TypeError(format!(
"doc append: target at '{selector}' is not an array"
))),
}
}
DocMutation::Prepend { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
let target = navigate_mut(root, &sel, false, "doc.prepend")?;
match target.as_array_mut() {
Some(arr) => {
arr.insert(0, value);
Ok(MutationResult::Applied)
}
None => Ok(MutationResult::TypeError(format!(
"doc prepend: target at '{selector}' is not an array"
))),
}
}
DocMutation::Update { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
if update_matching(root, &sel, &value)? == 0 {
if let Some(hint) = query::array_root_bare_key_hint(root, &sel) {
Ok(MutationResult::TypeError(hint))
} else {
Ok(MutationResult::NoMatch)
}
} else {
Ok(MutationResult::Applied)
}
}
DocMutation::Move { from, to } => {
let from_sel = selector::parse_anyhow(&from)?;
let to_sel = selector::parse_anyhow(&to)?;
move_at_path(root, &from_sel, &to_sel)?;
Ok(MutationResult::Applied)
}
DocMutation::Ensure { selector, value } => {
let sel = selector::parse_anyhow(&selector)?;
if !selector::eval_result(root, &sel)?.is_empty() {
Ok(MutationResult::AlreadyExists)
} else {
set_at_path(root, &sel, value)?;
Ok(MutationResult::Applied)
}
}
DocMutation::DeleteWhere {
selector,
predicate,
} => {
let sel = selector::parse_anyhow(&selector)?;
let removed = delete_where(root, &sel, &predicate)?;
if removed == 0 {
Ok(MutationResult::NoMatch)
} else {
Ok(MutationResult::Removed(removed))
}
}
}
}
#[cfg(test)]
mod tests;