use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
pub fn anchor_of(line: &str) -> String {
let mut hasher = DefaultHasher::new();
line.trim().hash(&mut hasher);
format!("{:08x}", hasher.finish() as u32)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnchoredLine {
pub anchor: String,
pub text: String,
}
pub fn anchored_lines(source: &str) -> Vec<AnchoredLine> {
source
.lines()
.map(|text| AnchoredLine {
anchor: anchor_of(text),
text: text.to_string(),
})
.collect()
}
pub fn render_anchored(source: &str) -> String {
anchored_lines(source)
.iter()
.map(|l| format!("{}\u{2502} {}", l.anchor, l.text))
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditOp {
Replace(String),
InsertAfter(String),
InsertBefore(String),
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub anchor: String,
pub op: EditOp,
}
impl Edit {
pub fn replace(anchor: impl Into<String>, text: impl Into<String>) -> Self {
Edit {
anchor: anchor.into(),
op: EditOp::Replace(text.into()),
}
}
pub fn insert_after(anchor: impl Into<String>, text: impl Into<String>) -> Self {
Edit {
anchor: anchor.into(),
op: EditOp::InsertAfter(text.into()),
}
}
pub fn insert_before(anchor: impl Into<String>, text: impl Into<String>) -> Self {
Edit {
anchor: anchor.into(),
op: EditOp::InsertBefore(text.into()),
}
}
pub fn delete(anchor: impl Into<String>) -> Self {
Edit {
anchor: anchor.into(),
op: EditOp::Delete,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnchorError {
NotFound(String),
Ambiguous { anchor: String, count: usize },
}
impl std::fmt::Display for AnchorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AnchorError::NotFound(a) => write!(f, "no line matches anchor {a}"),
AnchorError::Ambiguous { anchor, count } => {
write!(f, "anchor {anchor} matches {count} lines (ambiguous)")
}
}
}
}
impl std::error::Error for AnchorError {}
pub fn apply_edits(source: &str, edits: &[Edit]) -> Result<String, AnchorError> {
let lines: Vec<&str> = source.lines().collect();
let mut resolved: Vec<(usize, &EditOp)> = Vec::with_capacity(edits.len());
for edit in edits {
let matches: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| anchor_of(l) == edit.anchor)
.map(|(i, _)| i)
.collect();
match matches.as_slice() {
[] => return Err(AnchorError::NotFound(edit.anchor.clone())),
[i] => resolved.push((*i, &edit.op)),
many => {
return Err(AnchorError::Ambiguous {
anchor: edit.anchor.clone(),
count: many.len(),
})
}
}
}
let mut by_index: std::collections::HashMap<usize, Vec<&EditOp>> =
std::collections::HashMap::new();
for (i, op) in resolved {
by_index.entry(i).or_default().push(op);
}
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
let ops = by_index.get(&i);
if let Some(ops) = ops {
for op in ops {
if let EditOp::InsertBefore(text) = op {
out.extend(text.lines().map(String::from));
}
}
}
let mut emitted = false;
if let Some(ops) = ops {
for op in ops {
match op {
EditOp::Replace(text) => {
out.extend(text.lines().map(String::from));
emitted = true;
}
EditOp::Delete => emitted = true,
_ => {}
}
}
}
if !emitted {
out.push((*line).to_string());
}
if let Some(ops) = ops {
for op in ops {
if let EditOp::InsertAfter(text) = op {
out.extend(text.lines().map(String::from));
}
}
}
}
let mut result = out.join("\n");
if source.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
const SRC: &str = "fn main() {\n let x = 1;\n println!(\"{x}\");\n}\n";
#[test]
fn anchor_is_indentation_insensitive_and_stable() {
assert_eq!(anchor_of(" let x = 1;"), anchor_of("let x = 1;"));
assert_eq!(anchor_of("let x = 1;").len(), 8);
}
#[test]
fn replace_targets_the_anchored_line() {
let anchor = anchor_of(" let x = 1;");
let out = apply_edits(SRC, &[Edit::replace(anchor, " let x = 42;")]).unwrap();
assert!(out.contains("let x = 42;"));
assert!(!out.contains("let x = 1;"));
assert!(out.ends_with('\n'));
}
#[test]
fn insert_after_and_before() {
let anchor = anchor_of(" let x = 1;");
let out = apply_edits(SRC, &[Edit::insert_after(&anchor, " let y = 2;")]).unwrap();
let lines: Vec<&str> = out.lines().collect();
let xi = lines.iter().position(|l| l.contains("let x = 1;")).unwrap();
assert!(lines[xi + 1].contains("let y = 2;"));
}
#[test]
fn delete_removes_the_line() {
let anchor = anchor_of(" println!(\"{x}\");");
let out = apply_edits(SRC, &[Edit::delete(anchor)]).unwrap();
assert!(!out.contains("println!"));
}
#[test]
fn unmatched_anchor_fails_the_batch() {
let err = apply_edits(SRC, &[Edit::replace("deadbeef", "x")]).unwrap_err();
assert!(matches!(err, AnchorError::NotFound(_)));
}
#[test]
fn ambiguous_anchor_is_rejected() {
let src = "dup\ndup\n";
let err = apply_edits(src, &[Edit::replace(anchor_of("dup"), "x")]).unwrap_err();
assert!(matches!(err, AnchorError::Ambiguous { count: 2, .. }));
}
#[test]
fn render_anchored_has_gutter() {
let rendered = render_anchored("hello");
assert!(rendered.starts_with(&anchor_of("hello")));
assert!(rendered.contains('\u{2502}'));
}
}