#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineSpan {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone)]
pub struct Replacement {
pub old: String,
pub new: String,
pub replace_all: bool,
pub anchor: Option<LineSpan>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditError {
NotFound { old: String },
NotUnique { old: String, count: usize },
NoOp,
EmptyTarget,
NoEdits,
AnchorOutOfRange {
start: usize,
end: usize,
lines: usize,
},
AnchorShifted,
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { old } => {
write!(f, "no occurrence of {old:?} in the file being edited")
}
Self::NotUnique { old, count } => write!(
f,
"{old:?} occurs {count} times and replace_all was not set; \
the intended occurrence is ambiguous"
),
Self::NoOp => write!(f, "the replacement is identical to the target"),
Self::EmptyTarget => write!(f, "the target is empty and matches everywhere"),
Self::NoEdits => write!(f, "the mutation carries no edits"),
Self::AnchorOutOfRange { start, end, lines } => write!(
f,
"the edit is anchored to lines {start}-{end}, which the file \
(of {lines} lines) does not have"
),
Self::AnchorShifted => write!(
f,
"an earlier edit changed the file's line count, so this edit's \
line anchor is ambiguous and cannot be honored"
),
}
}
}
impl std::error::Error for EditError {}
pub fn apply_edits(content: &str, edits: &[Replacement]) -> Result<String, EditError> {
if edits.is_empty() {
return Err(EditError::NoEdits);
}
let mut current = content.to_owned();
let mut line_count_changed = false;
for edit in edits {
if line_count_changed && edit.anchor.is_some() {
return Err(EditError::AnchorShifted);
}
let before = current.lines().count();
current = apply_one(¤t, edit)?;
line_count_changed |= current.lines().count() != before;
}
Ok(current)
}
fn apply_one(content: &str, edit: &Replacement) -> Result<String, EditError> {
if edit.old.is_empty() {
return Err(EditError::EmptyTarget);
}
if edit.old == edit.new {
return Err(EditError::NoOp);
}
if let Some(anchor) = edit.anchor {
return apply_anchored(content, edit, anchor);
}
let count = content.matches(edit.old.as_str()).count();
if count == 0 {
return Err(EditError::NotFound {
old: edit.old.clone(),
});
}
if count > 1 && !edit.replace_all {
return Err(EditError::NotUnique {
old: edit.old.clone(),
count,
});
}
Ok(if edit.replace_all {
content.replace(edit.old.as_str(), &edit.new)
} else {
content.replacen(edit.old.as_str(), &edit.new, 1)
})
}
fn apply_anchored(
content: &str,
edit: &Replacement,
anchor: LineSpan,
) -> Result<String, EditError> {
let Some((from, to)) = byte_range_of_lines(content, anchor) else {
return Err(EditError::AnchorOutOfRange {
start: anchor.start,
end: anchor.end,
lines: content.lines().count(),
});
};
let window = &content[from..to];
let count = window.matches(edit.old.as_str()).count();
if count == 0 {
return Err(EditError::NotFound {
old: edit.old.clone(),
});
}
if count > 1 {
return Err(EditError::NotUnique {
old: edit.old.clone(),
count,
});
}
let mut out = String::with_capacity(content.len());
out.push_str(&content[..from]);
out.push_str(&window.replacen(edit.old.as_str(), &edit.new, 1));
out.push_str(&content[to..]);
Ok(out)
}
fn byte_range_of_lines(content: &str, anchor: LineSpan) -> Option<(usize, usize)> {
if anchor.start == 0 || anchor.end < anchor.start {
return None;
}
let mut offset = 0;
let mut from = None;
let mut to = None;
for (index, line) in content.split_inclusive('\n').enumerate() {
let number = index + 1;
if number == anchor.start {
from = Some(offset);
}
offset += line.len();
if number == anchor.end {
to = Some(offset);
}
}
from.zip(to)
}