use std::borrow::Cow;
use line_index::{LineCol, TextRange, TextSize};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("YAML query error: {0}")]
Query(#[from] yamlpath::QueryError),
#[error("YAML serialization error: {0}")]
Serialization(#[from] serde_yaml::Error),
#[error("Invalid operation: {0}")]
InvalidOperation(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Style {
BlockMapping,
BlockSequence,
MultilineFlowMapping,
FlowMapping,
MultilineFlowSequence,
FlowSequence,
MultilineLiteralScalar,
MultilineFoldedScalar,
DoubleQuoted,
SingleQuoted,
PlainScalar,
}
impl Style {
pub fn from_feature(feature: &yamlpath::Feature, doc: &yamlpath::Document) -> Self {
let content = doc.extract(feature);
let trimmed = content.trim().as_bytes();
let multiline = trimmed.contains(&b'\n');
match feature.kind() {
yamlpath::FeatureKind::BlockMapping => Style::BlockMapping,
yamlpath::FeatureKind::BlockSequence => Style::BlockSequence,
yamlpath::FeatureKind::FlowMapping => {
if multiline {
Style::MultilineFlowMapping
} else {
Style::FlowMapping
}
}
yamlpath::FeatureKind::FlowSequence => {
if multiline {
Style::MultilineFlowSequence
} else {
Style::FlowSequence
}
}
yamlpath::FeatureKind::Scalar => match trimmed[0] {
b'|' => Style::MultilineLiteralScalar,
b'>' => Style::MultilineFoldedScalar,
b'"' => Style::DoubleQuoted,
b'\'' => Style::SingleQuoted,
_ => Style::PlainScalar,
},
}
}
}
#[derive(Debug, Clone)]
pub struct Patch<'doc> {
pub route: yamlpath::Route<'doc>,
pub operation: Op<'doc>,
}
#[derive(Debug, Clone)]
pub enum Op<'doc> {
RewriteFragment {
from: subfeature::Subfeature<'doc>,
to: Cow<'doc, str>,
},
ReplaceComment { new: Cow<'doc, str> },
EmplaceComment { new: Cow<'doc, str> },
Replace(serde_yaml::Value),
Add {
key: String,
value: serde_yaml::Value,
},
MergeInto {
key: String,
updates: indexmap::IndexMap<String, serde_yaml::Value>,
},
#[allow(dead_code)]
Remove,
Append { value: serde_yaml::Value },
}
pub fn apply_yaml_patches(
document: &yamlpath::Document,
patches: &[Patch],
) -> Result<yamlpath::Document, Error> {
let mut patches = patches.iter();
let mut next_document = {
let Some(patch) = patches.next() else {
return Err(Error::InvalidOperation("no patches provided".to_string()));
};
apply_single_patch(document, patch)?
};
for patch in patches {
next_document = apply_single_patch(&next_document, patch)?;
}
Ok(next_document)
}
fn apply_single_patch(
document: &yamlpath::Document,
patch: &Patch,
) -> Result<yamlpath::Document, Error> {
let content = document.source();
let mut patched_content = match &patch.operation {
Op::RewriteFragment { from, to } => {
let (extracted_feature, range) = if patch.route.is_empty() {
let source = document.source();
(source, 0..source.len())
} else {
let Some(feature) = route_to_feature_exact(&patch.route, document)? else {
return Err(Error::InvalidOperation(format!(
"no pre-existing value to patch at {route:?}",
route = patch.route
)));
};
(
document.extract(&feature),
feature.location.byte_span.0..feature.location.byte_span.1,
)
};
let bias = from.after;
if bias > extracted_feature.len() {
return Err(Error::InvalidOperation(format!(
"replacement scan index {bias} is out of bounds for feature",
)));
}
let Some(span) = from.locate_within(extracted_feature) else {
return Err(Error::InvalidOperation(format!(
"no match for '{from:?}' in feature",
)));
};
let mut patched_feature = extracted_feature.to_string();
patched_feature.replace_range(span.as_range(), to);
let mut patched_content = content.to_string();
patched_content.replace_range(range, &patched_feature);
patched_content
}
Op::ReplaceComment { new } => {
let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
Error::InvalidOperation(format!(
"no existing feature at {route:?}",
route = patch.route
))
})?;
let comment_features = document.feature_comments(&feature);
let comment_feature = match comment_features.len() {
0 => return Ok(document.clone()),
1 => &comment_features[0],
_ => {
return Err(Error::InvalidOperation(format!(
"multiple comments found at {route:?}",
route = patch.route
)));
}
};
let mut result = content.to_string();
result.replace_range(comment_feature, new);
result
}
Op::EmplaceComment { new } => {
let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
Error::InvalidOperation(format!(
"no existing feature at {route:?}",
route = patch.route
))
})?;
if matches!(
Style::from_feature(&feature, document),
Style::SingleQuoted | Style::DoubleQuoted
) && feature.is_multiline()
{
return Err(Error::InvalidOperation(format!(
"cannot emplace comment on non-block multi-line scalar at {route:?}",
route = patch.route
)));
}
let comment_features = document.feature_comments(&feature);
match comment_features.len() {
0 => {
let line_range = line_span(document, feature.location.byte_span.0);
let mut insert_pos = line_range.end;
if let Some(b'\n') = document.source().as_bytes().get(insert_pos - 1) {
insert_pos -= 1;
}
if let Some(b'\r') = document.source().as_bytes().get(insert_pos - 1) {
insert_pos -= 1;
}
let mut result = content.to_string();
result.insert_str(insert_pos, &format!(" {new}"));
result
}
1 => {
return apply_single_patch(
document,
&Patch {
route: patch.route.clone(),
operation: Op::ReplaceComment { new: new.clone() },
},
);
}
_ => {
return Err(Error::InvalidOperation(format!(
"multiple comments found at {route:?}",
route = patch.route
)));
}
}
}
Op::Replace(value) => {
let feature = route_to_feature_pretty(&patch.route, document)?;
let replacement = apply_value_replacement(&feature, document, value, true)?;
let current_content = document.extract(&feature);
let current_content_with_ws = document.extract_with_leading_whitespace(&feature);
let (start_span, end_span) = if current_content_with_ws.contains(':') {
let ws_start = feature.location.byte_span.0
- (current_content_with_ws.len() - current_content.len());
(ws_start, feature.location.byte_span.1)
} else {
(feature.location.byte_span.0, feature.location.byte_span.1)
};
let mut result = content.to_string();
result.replace_range(start_span..end_span, &replacement);
result
}
Op::Add { key, value } => {
let key_query = patch.route.with_key(key.as_str());
if document.query_exists(&key_query) {
return Err(Error::InvalidOperation(format!(
"key '{key}' already exists at {route:?}",
key = key,
route = patch.route
)));
}
let feature = if patch.route.is_empty() {
document.top_feature()?
} else {
route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
Error::InvalidOperation(format!(
"no existing mapping at {route:?}",
route = patch.route
))
})?
};
let style = Style::from_feature(&feature, document);
let feature_content = document.extract(&feature);
let updated_feature = match style {
Style::BlockMapping => {
handle_block_mapping_addition(feature_content, document, &feature, key, value)
}
Style::FlowMapping => handle_flow_mapping_addition(feature_content, key, value),
Style::MultilineFlowMapping => Err(Error::InvalidOperation(format!(
"add operation is not permitted against multiline flow mapping route: {:?}",
patch.route
))),
_ => Err(Error::InvalidOperation(format!(
"add operation is not permitted against non-mapping route: {:?}",
patch.route
))),
}?;
let mut result = content.to_string();
result.replace_range(&feature, &updated_feature);
result
}
Op::MergeInto { key, updates } => {
let existing_key_route = patch.route.with_key(key.as_str());
match route_to_feature_exact(&existing_key_route, document) {
Ok(Some(existing_feature)) => {
let style = Style::from_feature(&existing_feature, document);
if !matches!(style, Style::BlockMapping | Style::FlowMapping) {
return Err(Error::InvalidOperation(format!(
"can't perform merge against non-mapping at {existing_key_route:?}"
)));
}
let existing_content =
document.extract_with_leading_whitespace(&existing_feature);
let existing_mapping = serde_yaml::from_str::<serde_yaml::Mapping>(
existing_content,
)
.map_err(|e| {
Error::InvalidOperation(format!(
"MergeInto: failed to parse existing mapping at {existing_key_route:?}: {e}"
))
})?;
let mut current_document = document.clone();
for (k, v) in updates {
if existing_mapping.contains_key(k) {
current_document = apply_single_patch(
¤t_document,
&Patch {
route: existing_key_route.with_key(k.as_str()),
operation: Op::Replace(v.clone()),
},
)?;
} else {
current_document = apply_single_patch(
¤t_document,
&Patch {
route: existing_key_route.clone(),
operation: Op::Add {
key: k.into(),
value: v.clone(),
},
},
)?;
}
}
return Ok(current_document);
}
Ok(None) => {
return Err(Error::InvalidOperation(format!(
"MergeInto: cannot merge into empty key at {existing_key_route:?}"
)));
}
Err(Error::Query(yamlpath::QueryError::ExhaustedMapping(_))) => {
return apply_single_patch(
document,
&Patch {
route: patch.route.clone(),
operation: Op::Add {
key: key.clone(),
value: serde_yaml::to_value(updates.clone())?,
},
},
);
}
Err(e) => return Err(e),
}
}
Op::Remove => {
if patch.route.is_empty() {
return Err(Error::InvalidOperation(
"Cannot remove root document".to_string(),
));
}
let feature = route_to_feature_pretty(&patch.route, document)?;
let start_pos = {
let range = line_span(document, feature.location.byte_span.0);
range.start
};
let end_pos = {
let range = line_span(document, feature.location.byte_span.1);
range.end
};
let mut result = content.to_string();
result.replace_range(start_pos..end_pos, "");
result
}
Op::Append { value } => {
let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
Error::InvalidOperation(format!(
"no existing sequence at {route:?}",
route = patch.route
))
})?;
let style = Style::from_feature(&feature, document);
match style {
Style::BlockSequence => {
let updated_feature = handle_block_sequence_append(document, &feature, value)?;
let mut result = content.to_string();
result.replace_range(&feature, &updated_feature);
result
}
Style::FlowSequence => {
return Err(Error::InvalidOperation(format!(
"append operation is not permitted against flow sequence route: {:?}",
patch.route
)));
}
_ => {
return Err(Error::InvalidOperation(format!(
"append operation is only permitted against sequence routes: {:?}",
patch.route
)));
}
}
}
};
if !patched_content.ends_with('\n') {
patched_content.push('\n');
}
yamlpath::Document::new(patched_content).map_err(Error::from)
}
pub fn route_to_feature_pretty<'a>(
route: &yamlpath::Route<'_>,
doc: &'a yamlpath::Document,
) -> Result<yamlpath::Feature<'a>, Error> {
doc.query_pretty(route).map_err(Error::from)
}
pub fn route_to_feature_exact<'a>(
route: &yamlpath::Route<'_>,
doc: &'a yamlpath::Document,
) -> Result<Option<yamlpath::Feature<'a>>, Error> {
doc.query_exact(route).map_err(Error::from)
}
fn serialize_yaml_value(value: &serde_yaml::Value) -> Result<String, Error> {
let yaml_str = serde_yaml::to_string(value)?;
Ok(yaml_str.trim_end().to_string()) }
pub fn serialize_flow(value: &serde_yaml::Value) -> Result<String, Error> {
let mut buf = String::new();
fn serialize_inner(value: &serde_yaml::Value, buf: &mut String) -> Result<(), Error> {
match value {
serde_yaml::Value::Null => {
buf.push_str("null");
Ok(())
}
serde_yaml::Value::Bool(b) => {
buf.push_str(if *b { "true" } else { "false" });
Ok(())
}
serde_yaml::Value::Number(n) => {
buf.push_str(&n.to_string());
Ok(())
}
serde_yaml::Value::String(s) => {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
buf.push_str(s);
} else {
buf.push_str(
&serde_json::to_string(s)
.map_err(|e| Error::InvalidOperation(e.to_string()))?,
);
}
Ok(())
}
serde_yaml::Value::Sequence(values) => {
buf.push('[');
for (i, item) in values.iter().enumerate() {
if i > 0 {
buf.push_str(", ");
}
serialize_inner(item, buf)?;
}
buf.push(']');
Ok(())
}
serde_yaml::Value::Mapping(mapping) => {
buf.push_str("{ ");
for (i, (key, value)) in mapping.iter().enumerate() {
if i > 0 {
buf.push_str(", ");
}
if !matches!(key, serde_yaml::Value::String(_)) {
return Err(Error::InvalidOperation(format!(
"mapping keys must be strings, found: {key:?}"
)));
}
serialize_inner(key, buf)?;
buf.push_str(": ");
if !matches!(value, serde_yaml::Value::Null) {
serialize_inner(value, buf)?;
}
}
buf.push_str(" }");
Ok(())
}
serde_yaml::Value::Tagged(tagged_value) => Err(Error::InvalidOperation(format!(
"cannot serialize tagged value: {tagged_value:?}"
))),
}
}
serialize_inner(value, &mut buf)?;
Ok(buf)
}
fn line_span(doc: &yamlpath::Document, pos: usize) -> core::ops::Range<usize> {
let pos = TextSize::new(pos as u32);
let LineCol { line, .. } = doc.line_index().line_col(pos);
doc.line_index()
.line(line)
.expect("impossible: line index gave us an invalid line")
.into()
}
pub fn extract_leading_indentation_for_block_item(
doc: &yamlpath::Document,
feature: &yamlpath::Feature,
) -> usize {
let line_range = line_span(doc, feature.location.byte_span.0);
let line_content = &doc.source()[line_range].trim_end();
let mut accept_dash = true;
for (idx, b) in line_content.bytes().enumerate() {
match b {
b' ' => {
accept_dash = true;
}
b'-' => {
if accept_dash {
accept_dash = false;
} else {
return idx - 1;
}
}
_ => {
if !accept_dash {
return idx - 1;
} else {
return idx;
}
}
}
}
line_content.len() + 1
}
pub fn extract_leading_whitespace<'doc>(
doc: &'doc yamlpath::Document,
feature: &yamlpath::Feature,
) -> &'doc str {
let line_range = line_span(doc, feature.location.byte_span.0);
let line_content = &doc.source()[line_range];
let end = line_content
.bytes()
.position(|b| b != b' ')
.unwrap_or(line_content.len());
&line_content[..end]
}
fn indent_multiline_yaml(content: &str, base_indent: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= 1 {
return content.to_string();
}
let mut result = String::new();
for (i, line) in lines.iter().enumerate() {
if i == 0 {
result.push_str(line);
} else {
result.push('\n');
result.push_str(base_indent);
if !line.trim().is_empty() {
result.push_str(" "); result.push_str(line.trim_start());
}
}
}
result
}
fn handle_block_mapping_addition(
feature_content: &str,
doc: &yamlpath::Document,
feature: &yamlpath::Feature,
key: &str,
value: &serde_yaml::Value,
) -> Result<String, Error> {
let new_value_str = if matches!(value, serde_yaml::Value::Sequence(_)) {
serialize_flow(value)?
} else {
serialize_yaml_value(value)?
};
let new_value_str = new_value_str.trim_end();
let indent = " ".repeat(extract_leading_indentation_for_block_item(doc, feature));
let mut final_entry = if let serde_yaml::Value::Mapping(mapping) = &value {
if mapping.is_empty() {
format!("\n{indent}{key}: {new_value_str}")
} else {
let value_lines = new_value_str.lines();
let mut result = format!("\n{indent}{key}:");
for line in value_lines {
if !line.trim().is_empty() {
result.push('\n');
result.push_str(&indent);
result.push_str(" "); result.push_str(line.trim_start());
}
}
result
}
} else if new_value_str.contains('\n') {
let indented_value = indent_multiline_yaml(new_value_str, &indent);
format!("\n{indent}{key}: {indented_value}")
} else {
format!("\n{indent}{key}: {new_value_str}")
};
let insertion_point = find_content_end(feature, doc);
if insertion_point < feature.location.byte_span.1 {
final_entry.push('\n');
}
let needs_leading_newline = if insertion_point > 0 {
doc.source().as_bytes().get(insertion_point - 1) != Some(&b'\n')
} else {
true
};
let final_entry_to_insert = if needs_leading_newline {
final_entry
} else {
final_entry
.strip_prefix('\n')
.unwrap_or(&final_entry)
.to_string()
};
let bias = feature.location.byte_span.0;
let relative_insertion_point = insertion_point - bias;
let mut updated_feature = feature_content.to_string();
updated_feature.insert_str(relative_insertion_point, &final_entry_to_insert);
Ok(updated_feature)
}
fn handle_block_sequence_append(
doc: &yamlpath::Document,
feature: &yamlpath::Feature,
value: &serde_yaml::Value,
) -> Result<String, Error> {
let feature_content = doc.extract(feature);
let indent = extract_leading_whitespace(doc, feature);
let value_str = if matches!(value, serde_yaml::Value::Sequence(_)) {
serialize_flow(value)?
} else {
serialize_yaml_value(value)?
};
let insertion_point = find_content_end(feature, doc);
let bias = feature.location.byte_span.0;
let relative_insertion_point = insertion_point - bias;
let needs_leading_newline = if relative_insertion_point > 0 {
feature_content.chars().nth(relative_insertion_point - 1) != Some('\n')
} else {
!feature_content.is_empty()
};
let mut new_item = String::new();
if needs_leading_newline {
new_item.push('\n');
}
let mut lines = value_str.lines();
if let Some(first_line) = lines.next() {
new_item.push_str(&format!("{}- {}", indent, first_line));
let item_content_indent = format!("{} ", indent);
for line in lines {
new_item.push('\n');
new_item.push_str(&item_content_indent);
new_item.push_str(line);
}
} else {
new_item.push_str(&format!("{}- {}", indent, value_str));
}
let mut updated_feature = feature_content.to_string();
updated_feature.insert_str(relative_insertion_point, &new_item);
Ok(updated_feature)
}
fn handle_flow_mapping_addition(
feature_content: &str,
key: &str,
value: &serde_yaml::Value,
) -> Result<String, Error> {
let mut existing_mapping = serde_yaml::from_str::<serde_yaml::Mapping>(feature_content)
.map_err(Error::Serialization)?;
existing_mapping.insert(key.into(), value.clone());
let updated_content = serialize_flow(&serde_yaml::Value::Mapping(existing_mapping))?;
Ok(updated_content)
}
pub fn find_content_end(feature: &yamlpath::Feature, doc: &yamlpath::Document) -> usize {
let lines: Vec<_> = doc
.line_index()
.lines(TextRange::new(
(feature.location.byte_span.0 as u32).into(),
(feature.location.byte_span.1 as u32).into(),
))
.collect();
for line in lines.into_iter().rev() {
let line_content = &doc.source()[line];
let trimmed = line_content.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
return line.end().into();
}
}
feature.location.byte_span.1 }
fn apply_value_replacement(
feature: &yamlpath::Feature,
doc: &yamlpath::Document,
value: &serde_yaml::Value,
support_multiline_literals: bool,
) -> Result<String, Error> {
let current_content_with_ws = doc.extract_with_leading_whitespace(feature);
let start_byte = feature.location.byte_span.0;
let end_byte = feature.location.byte_span.1;
let trimmed_content = current_content_with_ws.trim();
let is_flow_mapping = trimmed_content.starts_with('{')
&& trimmed_content.ends_with('}')
&& !trimmed_content.contains('\n');
if is_flow_mapping {
return handle_flow_mapping_value_replacement(
doc.source(),
start_byte,
end_byte,
current_content_with_ws,
value,
);
}
let replacement = if let Some(colon_pos) = current_content_with_ws.find(':') {
let key_part = ¤t_content_with_ws[..colon_pos + 1];
let value_part = ¤t_content_with_ws[colon_pos + 1..];
if support_multiline_literals {
let is_multiline_literal = value_part.trim_start().starts_with('|');
if is_multiline_literal {
if let serde_yaml::Value::String(string_content) = value
&& string_content.contains('\n')
{
let leading_whitespace = extract_leading_whitespace(doc, feature);
let content_indent = format!("{leading_whitespace} ");
let indented_content = string_content
.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{}{}", content_indent, line.trim_start())
}
})
.collect::<Vec<_>>()
.join("\n");
let pipe_pos = value_part.find('|').expect("impossible");
let key_with_pipe = ¤t_content_with_ws
[..colon_pos + 1 + value_part[..pipe_pos].len() + 1];
return Ok(format!(
"{}\n{}",
key_with_pipe.trim_end(),
indented_content
));
}
}
}
let val_str = serialize_yaml_value(value)?;
format!("{} {}", key_part, val_str.trim())
} else {
serialize_yaml_value(value)?
};
Ok(replacement)
}
fn handle_flow_mapping_value_replacement(
_content: &str,
_start_byte: usize,
_end_byte: usize,
current_content: &str,
value: &serde_yaml::Value,
) -> Result<String, Error> {
let val_str = serialize_yaml_value(value)?;
let val_str = val_str.trim();
let trimmed = current_content.trim();
if let Some(colon_pos) = trimmed.find(':') {
let before_colon = &trimmed[..colon_pos];
let after_colon = &trimmed[colon_pos + 1..];
let value_part = after_colon.trim().trim_end_matches('}').trim();
if value_part.is_empty() {
let key_part = before_colon.trim_start_matches('{').trim();
Ok(format!("{{ {key_part}: {val_str} }}"))
} else {
let key_part = before_colon.trim_start_matches('{').trim();
Ok(format!("{{ {key_part}: {val_str} }}"))
}
} else {
let key_part = trimmed.trim_start_matches('{').trim_end_matches('}').trim();
Ok(format!("{{ {key_part}: {val_str} }}"))
}
}