use super::model::{ApplyResult, Cursor, Edit, InsertMode, ParseWarning};
use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineOrigin {
Original,
Insert,
Replacement,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AppliedEdit {
Insert {
cursor: Cursor,
text: String,
line_num: usize,
index: usize,
mode: Option<InsertMode>,
block_start: Option<usize>,
},
Delete {
line: usize,
line_num: usize,
index: usize,
},
}
#[derive(Debug, Clone)]
struct IndexedEdit {
edit: AppliedEdit,
idx: usize,
}
#[derive(Debug, Clone)]
struct ReplacementGroup {
insert_indices: Vec<usize>,
delete_indices: Vec<usize>,
payload: Vec<String>,
start_line: usize,
end_line: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct DelimiterBalance {
paren: isize,
bracket: isize,
brace: isize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BoundaryEcho {
leading: usize,
trailing: usize,
}
#[derive(Debug, Clone)]
struct AfterInsertGroup {
anchor: usize,
members: Vec<usize>,
block_start: Option<usize>,
}
pub(crate) fn apply_edits(text: &str, edits: &[Edit]) -> Result<ApplyResult, String> {
if edits.is_empty() {
return Ok(ApplyResult {
text: text.to_string(),
first_changed_line: None,
warnings: Vec::new(),
block_resolutions: Vec::new(),
});
}
let mut applied = Vec::with_capacity(edits.len());
for (idx, edit) in edits.iter().enumerate() {
match edit {
Edit::Insert {
cursor,
text,
line_num,
index,
mode,
block_start,
} => applied.push(AppliedEdit::Insert {
cursor: cursor.clone(),
text: text.clone(),
line_num: *line_num,
index: *index.max(&idx),
mode: *mode,
block_start: *block_start,
}),
Edit::Delete {
anchor,
line_num,
index,
old_assertion: _,
} => applied.push(AppliedEdit::Delete {
line: anchor.line,
line_num: *line_num,
index: *index.max(&idx),
}),
Edit::Block { .. } => {
return Err(
"internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run)."
.to_string(),
);
}
}
}
let mut file_lines = text.split('\n').map(str::to_string).collect::<Vec<_>>();
let mut line_origins = vec![LineOrigin::Original; file_lines.len()];
let target_edits = drop_trailing_phantom_deletes(applied, &file_lines);
validate_line_bounds(&target_edits, &file_lines)?;
let (repaired, mut warnings) = repair_replacement_boundaries(&target_edits, &file_lines);
let (landed, landing_warnings) = repair_after_insert_landings(&repaired, &file_lines);
warnings.extend(landing_warnings);
let mut first_changed_line = None;
let mut track_first_changed = |line: usize| {
if first_changed_line.is_none_or(|current| line < current) {
first_changed_line = Some(line);
}
};
let mut bof_lines = Vec::new();
let mut eof_lines = Vec::new();
let mut anchor_edits = Vec::new();
for (idx, edit) in landed.into_iter().enumerate() {
match &edit {
AppliedEdit::Insert {
cursor: Cursor::Bof,
text,
..
} => bof_lines.push(text.clone()),
AppliedEdit::Insert {
cursor: Cursor::Eof,
text,
..
} => eof_lines.push(text.clone()),
_ => anchor_edits.push(IndexedEdit { edit, idx }),
}
}
let mut by_line: BTreeMap<usize, Vec<IndexedEdit>> = BTreeMap::new();
for entry in anchor_edits {
by_line
.entry(anchor_line(&entry.edit))
.or_default()
.push(entry);
}
for (line, mut bucket) in by_line.into_iter().rev() {
bucket.sort_by_key(|entry| entry.idx);
let idx = line - 1;
let current_line = file_lines.get(idx).cloned().unwrap_or_default();
let current_origin = *line_origins.get(idx).unwrap_or(&LineOrigin::Original);
let mut before_insert_lines = Vec::new();
let mut after_insert_lines = Vec::new();
let mut replacement_lines = Vec::new();
let mut delete_line = false;
for entry in bucket {
match entry.edit {
AppliedEdit::Insert {
cursor: Cursor::AfterAnchor { .. },
text,
mode: Some(InsertMode::Replacement),
..
} => {
replacement_lines.push(text);
}
AppliedEdit::Insert {
cursor: Cursor::AfterAnchor { .. },
text,
..
} => after_insert_lines.push(text),
AppliedEdit::Insert {
text,
mode: Some(InsertMode::Replacement),
..
} => replacement_lines.push(text),
AppliedEdit::Insert { text, .. } => before_insert_lines.push(text),
AppliedEdit::Delete { .. } => delete_line = true,
}
}
if before_insert_lines.is_empty()
&& replacement_lines.is_empty()
&& after_insert_lines.is_empty()
&& !delete_line
{
continue;
}
let mut replacement = Vec::new();
replacement.extend(before_insert_lines.iter().cloned());
replacement.extend(replacement_lines.iter().cloned());
if !delete_line {
replacement.push(current_line);
}
replacement.extend(after_insert_lines.iter().cloned());
let mut origins = Vec::new();
origins.extend(std::iter::repeat_n(
LineOrigin::Insert,
before_insert_lines.len(),
));
origins.extend(std::iter::repeat_n(
if delete_line {
LineOrigin::Replacement
} else {
LineOrigin::Insert
},
replacement_lines.len(),
));
if !delete_line {
origins.push(current_origin);
}
origins.extend(std::iter::repeat_n(
LineOrigin::Insert,
after_insert_lines.len(),
));
file_lines.splice(idx..idx + 1, replacement);
line_origins.splice(idx..idx + 1, origins);
track_first_changed(line);
}
if !bof_lines.is_empty() {
insert_at_start(&mut file_lines, &mut line_origins, bof_lines);
track_first_changed(1);
}
if !eof_lines.is_empty() {
let changed = insert_at_end(&mut file_lines, &mut line_origins, eof_lines);
if let Some(line) = changed {
track_first_changed(line);
}
}
Ok(ApplyResult {
text: file_lines.join("\n"),
first_changed_line,
warnings,
block_resolutions: Vec::new(),
})
}
fn anchor_line(edit: &AppliedEdit) -> usize {
match edit {
AppliedEdit::Delete { line, .. } => *line,
AppliedEdit::Insert { cursor, .. } => match cursor {
Cursor::BeforeAnchor { anchor } | Cursor::AfterAnchor { anchor } => anchor.line,
Cursor::Bof | Cursor::Eof => 0,
},
}
}
fn cursor_anchor(cursor: &Cursor) -> Option<usize> {
match cursor {
Cursor::BeforeAnchor { anchor } | Cursor::AfterAnchor { anchor } => Some(anchor.line),
Cursor::Bof | Cursor::Eof => None,
}
}
fn trailing_phantom_line(file_lines: &[String]) -> Option<usize> {
(file_lines.len() > 1 && file_lines.last().is_some_and(String::is_empty))
.then_some(file_lines.len())
}
fn drop_trailing_phantom_deletes(
edits: Vec<AppliedEdit>,
file_lines: &[String],
) -> Vec<AppliedEdit> {
let Some(phantom_line) = trailing_phantom_line(file_lines) else {
return edits;
};
edits
.into_iter()
.filter(|edit| !matches!(edit, AppliedEdit::Delete { line, .. } if *line == phantom_line))
.collect()
}
fn validate_line_bounds(edits: &[AppliedEdit], file_lines: &[String]) -> Result<(), String> {
for edit in edits {
let anchors: Vec<usize> = match edit {
AppliedEdit::Delete { line, .. } => vec![*line],
AppliedEdit::Insert { cursor, .. } => cursor_anchor(cursor).into_iter().collect(),
};
for line in anchors {
if line == 0 || line > file_lines.len() {
return Err(format!(
"Line {line} does not exist (file has {} lines)",
file_lines.len()
));
}
}
}
Ok(())
}
fn insert_at_start(
file_lines: &mut Vec<String>,
line_origins: &mut Vec<LineOrigin>,
lines: Vec<String>,
) {
if file_lines.len() == 1 && file_lines[0].is_empty() {
let origins = vec![LineOrigin::Insert; lines.len()];
file_lines.splice(0..1, lines);
line_origins.splice(0..1, origins);
return;
}
let origins = vec![LineOrigin::Insert; lines.len()];
file_lines.splice(0..0, lines);
line_origins.splice(0..0, origins);
}
fn insert_at_end(
file_lines: &mut Vec<String>,
line_origins: &mut Vec<LineOrigin>,
lines: Vec<String>,
) -> Option<usize> {
if lines.is_empty() {
return None;
}
if file_lines.len() == 1 && file_lines[0].is_empty() {
let origins = vec![LineOrigin::Insert; lines.len()];
file_lines.splice(0..1, lines);
line_origins.splice(0..1, origins);
return Some(1);
}
let insert_index = if file_lines.last().is_some_and(String::is_empty) {
file_lines.len() - 1
} else {
file_lines.len()
};
let origins = vec![LineOrigin::Insert; lines.len()];
file_lines.splice(insert_index..insert_index, lines);
line_origins.splice(insert_index..insert_index, origins);
Some(insert_index + 1)
}
fn find_replacement_group(edits: &[AppliedEdit], start: usize) -> Option<ReplacementGroup> {
let AppliedEdit::Insert {
cursor: Cursor::BeforeAnchor { anchor },
line_num,
mode: Some(InsertMode::Replacement),
..
} = edits.get(start)?
else {
return None;
};
let anchor_line = anchor.line;
let source_line_num = *line_num;
let mut insert_indices = Vec::new();
let mut payload = Vec::new();
let mut i = start;
while let Some(AppliedEdit::Insert {
cursor: Cursor::BeforeAnchor { anchor },
text,
line_num,
mode: Some(InsertMode::Replacement),
..
}) = edits.get(i)
{
if *line_num != source_line_num || anchor.line != anchor_line {
break;
}
insert_indices.push(i);
payload.push(text.clone());
i += 1;
}
let mut delete_indices = Vec::new();
let mut expected_line = anchor_line;
while let Some(AppliedEdit::Delete { line, line_num, .. }) = edits.get(i) {
if *line_num != source_line_num || *line != expected_line {
break;
}
delete_indices.push(i);
expected_line += 1;
i += 1;
}
if delete_indices.is_empty() {
return None;
}
Some(ReplacementGroup {
insert_indices,
delete_indices,
payload,
start_line: anchor_line,
end_line: expected_line - 1,
})
}
fn repair_replacement_boundaries(
edits: &[AppliedEdit],
file_lines: &[String],
) -> (Vec<AppliedEdit>, Vec<ParseWarning>) {
let mut out = Vec::new();
let mut warnings = Vec::new();
let mut i = 0;
while i < edits.len() {
let Some(group) = find_replacement_group(edits, i) else {
out.push(edits[i].clone());
i += 1;
continue;
};
let inserts = group
.insert_indices
.iter()
.map(|idx| edits[*idx].clone())
.collect::<Vec<_>>();
let deletes = group
.delete_indices
.iter()
.map(|idx| edits[*idx].clone())
.collect::<Vec<_>>();
i = group.delete_indices[group.delete_indices.len() - 1] + 1;
if let Some(echo) = find_boundary_echo(&group, file_lines) {
warnings.push(ParseWarning::ApplyRepair {
message: describe_boundary_echo_repair(&group, echo),
});
out.extend(
inserts[echo.leading..inserts.len() - echo.trailing]
.iter()
.cloned(),
);
out.extend(deletes);
continue;
}
let delta = balance_delta(
compute_delimiter_balance(&group.payload),
compute_delimiter_balance(&file_lines[group.start_line - 1..group.end_line]),
);
if balance_is_zero(delta) {
if let Some((side, count)) = find_one_sided_boundary_echo(&group, file_lines) {
warnings.push(ParseWarning::ApplyRepair {
message: describe_one_sided_echo_repair(&group, side, count),
});
match side {
"leading" => out.extend(inserts[count..].iter().cloned()),
_ => out.extend(inserts[..inserts.len() - count].iter().cloned()),
}
out.extend(deletes);
} else {
out.extend(inserts);
out.extend(deletes);
}
continue;
}
let dup_suffix = find_duplicate_suffix(&group, file_lines, delta);
if dup_suffix > 0 {
warnings.push(ParseWarning::ApplyRepair { message: describe_boundary_repair(&group, &format!("dropped {dup_suffix} duplicated trailing payload line(s) already present below the range")) });
out.extend(inserts[..inserts.len() - dup_suffix].iter().cloned());
out.extend(deletes);
continue;
}
let dup_prefix = find_duplicate_prefix(&group, file_lines, delta);
if dup_prefix > 0 {
warnings.push(ParseWarning::ApplyRepair { message: describe_boundary_repair(&group, &format!("dropped {dup_prefix} duplicated leading payload line(s) already present above the range")) });
out.extend(inserts[dup_prefix..].iter().cloned());
out.extend(deletes);
continue;
}
out.extend(inserts);
out.extend(deletes);
}
(out, warnings)
}
fn compute_delimiter_balance(lines: &[String]) -> DelimiterBalance {
let mut balance = DelimiterBalance::default();
let mut in_block_comment = false;
let mut quote = '\0';
for line in lines {
let chars = line.chars().collect::<Vec<_>>();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if in_block_comment {
if ch == '*' && chars.get(i + 1) == Some(&'/') {
in_block_comment = false;
i += 2;
continue;
}
i += 1;
continue;
}
if quote != '\0' {
if ch == '\\' {
i += 2;
continue;
}
if ch == quote {
quote = '\0';
}
i += 1;
continue;
}
if matches!(ch, '"' | '\'' | '`') {
quote = ch;
i += 1;
continue;
}
if ch == '/' && chars.get(i + 1) == Some(&'/') {
break;
}
if ch == '/' && chars.get(i + 1) == Some(&'*') {
in_block_comment = true;
i += 2;
continue;
}
match ch {
'(' => balance.paren += 1,
')' => balance.paren -= 1,
'[' => balance.bracket += 1,
']' => balance.bracket -= 1,
'{' => balance.brace += 1,
'}' => balance.brace -= 1,
_ => {}
}
i += 1;
}
if quote == '"' || quote == '\'' {
quote = '\0';
}
}
balance
}
fn balance_delta(a: DelimiterBalance, b: DelimiterBalance) -> DelimiterBalance {
DelimiterBalance {
paren: a.paren - b.paren,
bracket: a.bracket - b.bracket,
brace: a.brace - b.brace,
}
}
fn balance_negate(a: DelimiterBalance) -> DelimiterBalance {
DelimiterBalance {
paren: -a.paren,
bracket: -a.bracket,
brace: -a.brace,
}
}
fn balance_is_zero(a: DelimiterBalance) -> bool {
a == DelimiterBalance::default()
}
fn find_duplicate_suffix(
group: &ReplacementGroup,
file_lines: &[String],
delta: DelimiterBalance,
) -> usize {
if balance_is_zero(delta) {
return 0;
}
let max = group
.payload
.len()
.min(file_lines.len().saturating_sub(group.end_line));
for k in (1..=max).rev() {
if group.payload[group.payload.len() - k..]
== file_lines[group.end_line..group.end_line + k]
&& compute_delimiter_balance(&group.payload[group.payload.len() - k..]) == delta
{
return k;
}
}
0
}
fn find_duplicate_prefix(
group: &ReplacementGroup,
file_lines: &[String],
delta: DelimiterBalance,
) -> usize {
if balance_is_zero(delta) {
return 0;
}
let max = group.payload.len().min(group.start_line - 1);
for j in (1..=max).rev() {
if group.payload[..j] == file_lines[group.start_line - 1 - j..group.start_line - 1]
&& compute_delimiter_balance(&group.payload[..j]) == delta
{
return j;
}
}
0
}
fn has_non_whitespace(text: &str) -> bool {
text.chars().any(|ch| !ch.is_whitespace())
}
fn count_duplicate_leading_boundary_lines(
group: &ReplacementGroup,
file_lines: &[String],
) -> usize {
let max = group.payload.len().min(group.start_line - 1);
for count in (1..=max).rev() {
let payload = &group.payload[..count];
let original = &file_lines[group.start_line - 1 - count..group.start_line - 1];
if payload == original && payload.iter().any(|line| has_non_whitespace(line)) {
return count;
}
}
0
}
fn count_duplicate_trailing_boundary_lines(
group: &ReplacementGroup,
file_lines: &[String],
) -> usize {
let max = group
.payload
.len()
.min(file_lines.len().saturating_sub(group.end_line));
for count in (1..=max).rev() {
let payload = &group.payload[group.payload.len() - count..];
let original = &file_lines[group.end_line..group.end_line + count];
if payload == original && payload.iter().any(|line| has_non_whitespace(line)) {
return count;
}
}
0
}
fn find_boundary_echo(group: &ReplacementGroup, file_lines: &[String]) -> Option<BoundaryEcho> {
let leading = count_duplicate_leading_boundary_lines(group, file_lines);
if leading == 0 {
return None;
}
let trailing = count_duplicate_trailing_boundary_lines(group, file_lines);
if trailing == 0 || leading + trailing >= group.payload.len() {
return None;
}
let leading_balance = compute_delimiter_balance(&group.payload[..leading]);
let trailing_balance =
compute_delimiter_balance(&group.payload[group.payload.len() - trailing..]);
let dropped_balance = balance_delta(leading_balance, balance_negate(trailing_balance));
if !balance_is_zero(dropped_balance) {
let delta = balance_delta(
compute_delimiter_balance(&group.payload),
compute_delimiter_balance(&file_lines[group.start_line - 1..group.end_line]),
);
if dropped_balance != delta {
return None;
}
}
Some(BoundaryEcho { leading, trailing })
}
fn structural_closer_line(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let without_suffix = trimmed
.strip_suffix(';')
.or_else(|| trimmed.strip_suffix(','))
.unwrap_or(trimmed);
without_suffix
.chars()
.all(|ch| matches!(ch, ')' | ']' | '}'))
|| (trimmed.starts_with("</") && trimmed.ends_with('>'))
|| trimmed == "/>"
}
fn find_one_sided_boundary_echo(
group: &ReplacementGroup,
file_lines: &[String],
) -> Option<(&'static str, usize)> {
let leading = count_duplicate_leading_boundary_lines(group, file_lines);
let trailing = count_duplicate_trailing_boundary_lines(group, file_lines);
if (leading > 0) == (trailing > 0) {
return None;
}
let (side, count) = if leading > 0 {
("leading", leading)
} else {
("trailing", trailing)
};
if count >= group.payload.len() {
return None;
}
let echo_lines = if side == "leading" {
&group.payload[..count]
} else {
&group.payload[group.payload.len() - count..]
};
if !balance_is_zero(compute_delimiter_balance(echo_lines)) {
return None;
}
if group.delete_indices.len() <= 1
&& (side != "trailing" || !echo_lines.iter().all(|line| structural_closer_line(line)))
{
return None;
}
Some((side, count))
}
fn describe_boundary_echo_repair(group: &ReplacementGroup, echo: BoundaryEcho) -> String {
format!(
"Auto-repaired a replacement boundary echo at line {}: dropped {} leading and {} trailing payload line(s) already present outside the range. Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.",
group.start_line, echo.leading, echo.trailing
)
}
fn describe_boundary_repair(group: &ReplacementGroup, action: &str) -> String {
format!(
"Auto-repaired a delimiter-balance mismatch in the replacement at line {}: {action}. Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.",
group.start_line
)
}
fn describe_one_sided_echo_repair(group: &ReplacementGroup, side: &str, count: usize) -> String {
let where_text = if side == "leading" { "above" } else { "below" };
format!(
"Auto-repaired a replacement boundary echo at line {}: dropped {count} {side} payload line(s) identical to the surviving line(s) just {where_text} the range. The range was one line short of the content you retyped — issue the payload as the final content for the selected range only, and widen the range to consume any keeper you restate.",
group.start_line
)
}
fn repair_after_insert_landings(
edits: &[AppliedEdit],
file_lines: &[String],
) -> (Vec<AppliedEdit>, Vec<ParseWarning>) {
let mut groups: HashMap<(usize, usize), AfterInsertGroup> = HashMap::new();
for (idx, edit) in edits.iter().enumerate() {
let AppliedEdit::Insert {
cursor: Cursor::AfterAnchor { anchor },
line_num,
mode,
block_start,
..
} = edit
else {
continue;
};
if *mode == Some(InsertMode::Replacement) {
continue;
}
groups
.entry((anchor.line, *line_num))
.and_modify(|group| group.members.push(idx))
.or_insert_with(|| AfterInsertGroup {
anchor: anchor.line,
members: vec![idx],
block_start: *block_start,
});
}
if groups.is_empty() {
return (edits.to_vec(), Vec::new());
}
let mut targeted_lines = HashSet::new();
for edit in edits {
match edit {
AppliedEdit::Delete { line, .. } => {
targeted_lines.insert(*line);
}
AppliedEdit::Insert { cursor, .. } => {
if let Some(line) = cursor_anchor(cursor) {
targeted_lines.insert(line);
}
}
}
}
let mut out = edits.to_vec();
let mut changed = false;
let mut warnings = Vec::new();
for group in groups.values() {
let rows = group
.members
.iter()
.filter_map(|idx| match &edits[*idx] {
AppliedEdit::Insert { text, .. } => Some(text.clone()),
AppliedEdit::Delete { .. } => None,
})
.collect::<Vec<_>>();
let Some(target) = body_target_indent(&rows) else {
continue;
};
if let Some((line, crossed)) =
resolve_shifted_landing(group, &target, file_lines, &targeted_lines)
{
retarget_after_insert(&mut out, group, line);
changed = true;
warnings.push(ParseWarning::ApplyRepair {
message: after_insert_landing_shift_warning(group.anchor, line, crossed),
});
continue;
}
let Some(block_start) = group.block_start else {
continue;
};
if let Some(line) =
resolve_inward_landing(group, &target, block_start, file_lines, &targeted_lines)
{
retarget_after_insert(&mut out, group, line);
changed = true;
warnings.push(ParseWarning::ApplyRepair {
message: block_insert_landing_shift_warning(block_start, group.anchor, line),
});
}
}
if changed {
(out, warnings)
} else {
(edits.to_vec(), warnings)
}
}
fn leading_indent(line: &str) -> &str {
let end = line
.char_indices()
.find_map(|(idx, ch)| (!matches!(ch, ' ' | '\t')).then_some(idx))
.unwrap_or(line.len());
&line[..end]
}
fn is_indent_deeper(deeper: &str, shallower: &str) -> bool {
deeper.len() > shallower.len() && deeper.starts_with(shallower)
}
fn body_target_indent(rows: &[String]) -> Option<String> {
let non_blank = rows
.iter()
.filter(|row| has_non_whitespace(row))
.collect::<Vec<_>>();
if non_blank.is_empty() || non_blank.iter().all(|row| structural_closer_line(row)) {
return None;
}
let mut target = leading_indent(non_blank[0]).to_string();
for row in non_blank {
let indent = leading_indent(row);
if indent.starts_with(&target) {
continue;
}
if target.starts_with(indent) {
target = indent.to_string();
} else {
return None;
}
}
Some(target)
}
fn resolve_shifted_landing(
group: &AfterInsertGroup,
target: &str,
file_lines: &[String],
targeted_lines: &HashSet<usize>,
) -> Option<(usize, usize)> {
let anchor_text = file_lines.get(group.anchor - 1)?;
if !has_non_whitespace(anchor_text) || !is_indent_deeper(leading_indent(anchor_text), target) {
return None;
}
let mut landing = group.anchor;
let mut crossed = 0;
for line in group.anchor + 1..=file_lines.len() {
let text = &file_lines[line - 1];
if !has_non_whitespace(text) {
continue;
}
if !structural_closer_line(text) {
break;
}
let indent = leading_indent(text);
if !indent.starts_with(target) || targeted_lines.contains(&line) {
return None;
}
landing = line;
crossed += 1;
if indent.len() == target.len() {
break;
}
}
(landing != group.anchor).then_some((landing, crossed))
}
fn resolve_inward_landing(
group: &AfterInsertGroup,
target: &str,
block_start: usize,
file_lines: &[String],
targeted_lines: &HashSet<usize>,
) -> Option<usize> {
let anchor_text = file_lines.get(group.anchor - 1)?;
if !has_non_whitespace(anchor_text)
|| !structural_closer_line(anchor_text)
|| !is_indent_deeper(target, leading_indent(anchor_text))
{
return None;
}
let mut landing = group.anchor;
for line in (block_start + 1..=group.anchor).rev() {
let text = &file_lines[line - 1];
if !has_non_whitespace(text) {
landing = line - 1;
continue;
}
if !structural_closer_line(text) {
break;
}
let indent = leading_indent(text);
if !is_indent_deeper(target, indent) {
break;
}
if line != group.anchor && targeted_lines.contains(&line) {
return None;
}
landing = line - 1;
}
(landing != group.anchor).then_some(landing)
}
fn retarget_after_insert(edits: &mut [AppliedEdit], group: &AfterInsertGroup, line: usize) {
for idx in &group.members {
if let AppliedEdit::Insert { cursor, .. } = &mut edits[*idx] {
*cursor = Cursor::AfterAnchor {
anchor: super::model::Anchor { line },
};
}
}
}
fn after_insert_landing_shift_warning(
anchor_line: usize,
landing_line: usize,
crossed: usize,
) -> String {
format!(
"INS.POST {anchor_line}: body indented shallower than the anchor, so the landing moved past {crossed} closing line{} to after line {landing_line}. For the deeper position inside the block, re-issue with the body indented to match.",
if crossed == 1 { "" } else { "s" }
)
}
fn block_insert_landing_shift_warning(
block_start: usize,
closer_line: usize,
landing_line: usize,
) -> String {
format!(
"INS.BLK.POST {block_start}: body indented deeper than closing line {closer_line}, so it was placed inside the block, after line {landing_line}. `INS.BLK.POST` lands AFTER the block at sibling depth — if inside was intended, use plain `INS.POST {closer_line}:`."
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::hash_edit::{model::Anchor, parser::parse_patch};
fn apply(input: &str, patch: &str) -> ApplyResult {
let parsed = parse_patch(patch).unwrap();
apply_edits(input, &parsed.edits).unwrap()
}
#[test]
fn table_driven_concrete_ops() {
let cases = [
("a\nb\nc", "SWAP 2.=2:\n+x", "a\nx\nc", Some(2)),
("a\nb\nc", "DEL 2", "a\nc", Some(2)),
("a\nb\nc", "INS.PRE 2:\n+x", "a\nx\nb\nc", Some(2)),
("a\nb\nc", "INS.POST 2:\n+x", "a\nb\nx\nc", Some(2)),
("a\nb\nc", "INS.HEAD:\n+x", "x\na\nb\nc", Some(1)),
("a\nb\nc", "INS.TAIL:\n+x", "a\nb\nc\nx", Some(4)),
];
for (input, patch, expected, first) in cases {
let result = apply(input, patch);
assert_eq!(result.text, expected, "{patch}");
assert_eq!(result.first_changed_line, first, "{patch}");
}
}
#[test]
fn replacements_use_original_lines_independent_of_order() {
let result = apply("a\nb\nc\nd", "SWAP 3.=3:\n+C\nSWAP 1.=1:\n+A");
assert_eq!(result.text, "A\nb\nC\nd");
assert_eq!(result.first_changed_line, Some(1));
}
#[test]
fn mixed_hunks_apply_bottom_up() {
let result = apply(
"a\nb\nc\nd",
"INS.POST 1:\n+after-a\nDEL 3\nINS.PRE 4:\n+before-d",
);
assert_eq!(result.text, "a\nafter-a\nb\nbefore-d\nd");
}
#[test]
fn no_op_apply_returns_unchanged_text() {
let result = apply_edits("a\nb", &[]).unwrap();
assert_eq!(result.text, "a\nb");
assert_eq!(result.first_changed_line, None);
assert!(result.warnings.is_empty());
}
#[test]
fn validates_anchor_bounds() {
let parsed = parse_patch("DEL 2").unwrap();
let err = apply_edits("a", &parsed.edits).unwrap_err();
assert!(err.contains("Line 2 does not exist"));
}
#[test]
fn drops_trailing_phantom_newline_delete() {
let result = apply("a\nb\n", "DEL 2.=3");
assert_eq!(result.text, "a\n");
}
#[test]
fn replacement_boundary_echo_is_repaired() {
let input = "fn main() {\n old();\n}\nnext();";
let result = apply(input, "SWAP 2.=2:\n+fn main() {\n+ new();\n+}\n");
assert_eq!(result.text, "fn main() {\n new();\n}\nnext();");
assert!(result.warnings.iter().any(|warning| matches!(warning, ParseWarning::ApplyRepair { message } if message.contains("boundary echo"))));
}
#[test]
fn ins_post_landing_repair_moves_past_closer() {
let input = "if ok {\n call();\n}\nnext();";
let result = apply(input, "INS.POST 2:\n+sibling();");
assert_eq!(result.text, "if ok {\n call();\n}\nsibling();\nnext();");
assert!(result.warnings.iter().any(|warning| matches!(warning, ParseWarning::ApplyRepair { message } if message.contains("INS.POST 2"))));
}
#[test]
fn eof_insert_respects_trailing_newline_sentinel() {
let result = apply("a\n", "INS.TAIL:\n+b");
assert_eq!(result.text, "a\nb\n");
assert_eq!(result.first_changed_line, Some(2));
}
#[test]
fn block_edits_are_rejected_until_lowered() {
let err = apply_edits(
"a",
&[Edit::Block {
anchor: Anchor { line: 1 },
payloads: Vec::new(),
mode: None,
line_num: 1,
index: 0,
}],
)
.unwrap_err();
assert!(err.contains("unresolved"));
}
}