use crate::hashline::scan::TerminatorKind;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LineEdit {
Insert {
anchor: usize,
place: InsertPlace,
text: String,
mode: InsertMode,
op_index: usize,
},
Delete {
line: usize,
op_index: usize,
},
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum InsertPlace {
Before,
After,
Bof,
Eof,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum InsertMode {
Plain,
Replacement,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReplacementGroup {
pub insert_indices: Vec<usize>,
pub delete_indices: Vec<usize>,
pub payload: Vec<String>,
pub start_line: usize,
pub end_line: usize,
pub op_index: usize,
}
pub fn find_replacement_group(edits: &[LineEdit], start: usize) -> Option<ReplacementGroup> {
let LineEdit::Insert {
anchor,
place: InsertPlace::Before,
mode: InsertMode::Replacement,
op_index,
..
} = edits.get(start)?
else {
return None;
};
let anchor_line = *anchor;
let op_index = *op_index;
let mut insert_indices = Vec::new();
let mut payload = Vec::new();
let mut i = start;
while i < edits.len() {
match &edits[i] {
LineEdit::Insert {
anchor,
place: InsertPlace::Before,
text,
mode: InsertMode::Replacement,
op_index: edit_op,
} if *anchor == anchor_line && *edit_op == op_index => {
insert_indices.push(i);
payload.push(text.clone());
i += 1;
}
_ => break,
}
}
let mut delete_indices = Vec::new();
let mut expected = anchor_line;
while i < edits.len() {
match &edits[i] {
LineEdit::Delete {
line,
op_index: edit_op,
} if *line == expected && *edit_op == op_index => {
delete_indices.push(i);
expected += 1;
i += 1;
}
_ => break,
}
}
if delete_indices.is_empty() {
return None;
}
Some(ReplacementGroup {
insert_indices,
delete_indices,
payload,
start_line: anchor_line,
end_line: expected - 1,
op_index,
})
}
pub fn coalesce_replacement_edits(edits: &[LineEdit]) -> Vec<LineEdit> {
if edits.is_empty() {
return Vec::new();
}
let mut op_order: Vec<usize> = Vec::new();
let mut by_op: std::collections::BTreeMap<usize, Vec<LineEdit>> =
std::collections::BTreeMap::new();
for edit in edits {
let op = match edit {
LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. } => *op_index,
};
if !by_op.contains_key(&op) {
op_order.push(op);
}
by_op.entry(op).or_default().push(edit.clone());
}
let mut out = Vec::with_capacity(edits.len());
for op in op_order {
let group = by_op.remove(&op).unwrap_or_default();
out.extend(coalesce_one_op(group));
}
out
}
fn coalesce_one_op(edits: Vec<LineEdit>) -> Vec<LineEdit> {
let mut deletes: Vec<usize> = edits
.iter()
.filter_map(|edit| match edit {
LineEdit::Delete { line, .. } => Some(*line),
_ => None,
})
.collect();
let replacements: Vec<String> = edits
.iter()
.filter_map(|edit| match edit {
LineEdit::Insert {
text,
mode: InsertMode::Replacement,
..
} => Some(text.clone()),
_ => None,
})
.collect();
let plain: Vec<LineEdit> = edits
.iter()
.filter(|edit| {
!matches!(
edit,
LineEdit::Delete { .. }
| LineEdit::Insert {
mode: InsertMode::Replacement,
..
}
)
})
.cloned()
.collect();
if deletes.is_empty() || replacements.is_empty() {
return edits;
}
deletes.sort_unstable();
deletes.dedup();
let contiguous = deletes
.windows(2)
.all(|pair| pair[1] == pair[0].saturating_add(1));
if !contiguous {
return edits;
}
let start = deletes[0];
let end = *deletes.last().unwrap();
let op_index = match edits.first() {
Some(LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. }) => *op_index,
None => 0,
};
let mut coalesced = plain;
for text in replacements {
coalesced.push(LineEdit::Insert {
anchor: start,
place: InsertPlace::Before,
text,
mode: InsertMode::Replacement,
op_index,
});
}
for line in start..=end {
coalesced.push(LineEdit::Delete { line, op_index });
}
coalesced
}
pub fn materialize_edits(original_lines: &[String], edits: &[LineEdit]) -> Vec<String> {
let mut file_lines = original_lines.to_vec();
let mut bof: Vec<String> = Vec::new();
let mut eof: Vec<String> = Vec::new();
let mut by_line: std::collections::BTreeMap<usize, Vec<&LineEdit>> =
std::collections::BTreeMap::new();
for edit in edits {
match edit {
LineEdit::Insert {
place: InsertPlace::Bof,
text,
..
} => bof.push(text.clone()),
LineEdit::Insert {
place: InsertPlace::Eof,
text,
..
} => eof.push(text.clone()),
LineEdit::Insert { anchor, .. } => {
by_line.entry(*anchor).or_default().push(edit);
}
LineEdit::Delete { line, .. } => {
by_line.entry(*line).or_default().push(edit);
}
}
}
let lines: Vec<usize> = by_line.keys().copied().collect();
for line in lines.into_iter().rev() {
let Some(bucket) = by_line.get(&line) else {
continue;
};
let idx = line.saturating_sub(1);
if idx > file_lines.len() {
continue;
}
let current = file_lines.get(idx).cloned().unwrap_or_default();
let mut before = Vec::new();
let mut after = Vec::new();
let mut replacement = Vec::new();
let mut delete_line = false;
for edit in bucket {
match edit {
LineEdit::Insert {
place: InsertPlace::Before,
text,
mode: InsertMode::Replacement,
..
} => replacement.push(text.clone()),
LineEdit::Insert {
place: InsertPlace::Before,
text,
..
} => before.push(text.clone()),
LineEdit::Insert {
place: InsertPlace::After,
text,
..
} => after.push(text.clone()),
LineEdit::Delete { .. } => delete_line = true,
LineEdit::Insert {
place: InsertPlace::Bof | InsertPlace::Eof,
..
} => {}
}
}
if before.is_empty() && replacement.is_empty() && after.is_empty() && !delete_line {
continue;
}
let spliced = if delete_line {
let mut rows = before;
rows.extend(replacement);
rows.extend(after);
rows
} else {
let mut rows = before;
rows.extend(replacement);
if idx < file_lines.len() {
rows.push(current);
}
rows.extend(after);
rows
};
if idx < file_lines.len() {
file_lines.splice(idx..=idx, spliced);
} else {
file_lines.extend(spliced);
}
}
if !bof.is_empty() {
let mut rows = bof;
rows.append(&mut file_lines);
file_lines = rows;
}
file_lines.extend(eof);
file_lines
}
pub fn join_lines(
lines: &[String],
default_terminator: TerminatorKind,
trailing_terminator: bool,
) -> Vec<u8> {
if lines.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for (index, line) in lines.iter().enumerate() {
out.extend_from_slice(line.as_bytes());
let is_last = index + 1 == lines.len();
let term = if is_last && !trailing_terminator {
TerminatorKind::None
} else if default_terminator == TerminatorKind::None {
TerminatorKind::Lf
} else {
default_terminator
};
match term {
TerminatorKind::Lf => out.push(b'\n'),
TerminatorKind::CrLf => out.extend_from_slice(b"\r\n"),
TerminatorKind::None => {}
}
}
out
}
pub fn terminator_policy(
records: &std::collections::BTreeMap<usize, crate::hashline::scan::RawLineRecord>,
) -> (TerminatorKind, bool) {
let mut lf = 0usize;
let mut crlf = 0usize;
let mut trailing = false;
let last = records.keys().next_back().copied();
for (&line, record) in records {
match record.terminator {
TerminatorKind::Lf => lf += 1,
TerminatorKind::CrLf => crlf += 1,
TerminatorKind::None => {}
}
if Some(line) == last {
trailing = record.terminator != TerminatorKind::None;
}
}
let default = if crlf > lf {
TerminatorKind::CrLf
} else if lf > 0 || crlf > 0 {
TerminatorKind::Lf
} else {
TerminatorKind::Lf
};
(default, trailing || records.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn materialize_replaces_a_span() {
let original = vec!["a".into(), "b".into(), "c".into()];
let edits = vec![
LineEdit::Insert {
anchor: 2,
place: InsertPlace::Before,
text: "B".into(),
mode: InsertMode::Replacement,
op_index: 0,
},
LineEdit::Delete {
line: 2,
op_index: 0,
},
];
assert_eq!(
materialize_edits(&original, &edits),
vec!["a".to_string(), "B".into(), "c".into()]
);
}
#[test]
fn coalesce_merges_contiguous_single_line_replacements() {
let edits = vec![
LineEdit::Insert {
anchor: 1,
place: InsertPlace::Before,
text: "A".into(),
mode: InsertMode::Replacement,
op_index: 0,
},
LineEdit::Delete {
line: 1,
op_index: 0,
},
LineEdit::Insert {
anchor: 2,
place: InsertPlace::Before,
text: "B".into(),
mode: InsertMode::Replacement,
op_index: 0,
},
LineEdit::Delete {
line: 2,
op_index: 0,
},
LineEdit::Insert {
anchor: 3,
place: InsertPlace::Before,
text: "C".into(),
mode: InsertMode::Replacement,
op_index: 0,
},
LineEdit::Delete {
line: 3,
op_index: 0,
},
];
let coalesced = coalesce_replacement_edits(&edits);
let group = find_replacement_group(&coalesced, 0).expect("one group");
assert_eq!(group.start_line, 1);
assert_eq!(group.end_line, 3);
assert_eq!(group.payload, vec!["A", "B", "C"]);
}
}