Skip to main content

lvz_context/
anchor.rs

1//! Hash-anchored edits (§6.1): address a line by a short stable hash of its
2//! content instead of resending the whole file. The model reads anchored lines once, then
3//! targets edits by anchor — no full-file round-trips, and an edit that no longer matches is
4//! rejected rather than silently misapplied.
5
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8
9/// Compute the anchor for a single line: 8 hex chars over the line's trimmed content.
10///
11/// Trimming makes the anchor insensitive to surrounding indentation churn; identical content
12/// yields identical anchors (and is therefore ambiguous to target — by design).
13pub fn anchor_of(line: &str) -> String {
14    let mut hasher = DefaultHasher::new();
15    line.trim().hash(&mut hasher);
16    format!("{:08x}", hasher.finish() as u32)
17}
18
19/// A line paired with its anchor, as presented to the model.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct AnchoredLine {
22    pub anchor: String,
23    pub text: String,
24}
25
26/// Annotate every line of `source` with its anchor.
27pub fn anchored_lines(source: &str) -> Vec<AnchoredLine> {
28    source
29        .lines()
30        .map(|text| AnchoredLine {
31            anchor: anchor_of(text),
32            text: text.to_string(),
33        })
34        .collect()
35}
36
37/// Render `source` with a leading `anchor│ ` gutter on each line — the form the model reads
38/// before issuing [`Edit`]s.
39pub fn render_anchored(source: &str) -> String {
40    anchored_lines(source)
41        .iter()
42        .map(|l| format!("{}\u{2502} {}", l.anchor, l.text))
43        .collect::<Vec<_>>()
44        .join("\n")
45}
46
47/// What to do at a matched anchor.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum EditOp {
50    /// Replace the matched line with these lines.
51    Replace(String),
52    /// Insert these lines immediately after the matched line.
53    InsertAfter(String),
54    /// Insert these lines immediately before the matched line.
55    InsertBefore(String),
56    /// Delete the matched line.
57    Delete,
58}
59
60/// A single anchored edit.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Edit {
63    pub anchor: String,
64    pub op: EditOp,
65}
66
67impl Edit {
68    pub fn replace(anchor: impl Into<String>, text: impl Into<String>) -> Self {
69        Edit {
70            anchor: anchor.into(),
71            op: EditOp::Replace(text.into()),
72        }
73    }
74    pub fn insert_after(anchor: impl Into<String>, text: impl Into<String>) -> Self {
75        Edit {
76            anchor: anchor.into(),
77            op: EditOp::InsertAfter(text.into()),
78        }
79    }
80    pub fn insert_before(anchor: impl Into<String>, text: impl Into<String>) -> Self {
81        Edit {
82            anchor: anchor.into(),
83            op: EditOp::InsertBefore(text.into()),
84        }
85    }
86    pub fn delete(anchor: impl Into<String>) -> Self {
87        Edit {
88            anchor: anchor.into(),
89            op: EditOp::Delete,
90        }
91    }
92}
93
94/// Why an anchored edit could not be applied.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum AnchorError {
97    /// No line matched the anchor — the file changed under the edit.
98    NotFound(String),
99    /// More than one line matched the anchor — the target is ambiguous.
100    Ambiguous { anchor: String, count: usize },
101}
102
103impl std::fmt::Display for AnchorError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            AnchorError::NotFound(a) => write!(f, "no line matches anchor {a}"),
107            AnchorError::Ambiguous { anchor, count } => {
108                write!(f, "anchor {anchor} matches {count} lines (ambiguous)")
109            }
110        }
111    }
112}
113
114impl std::error::Error for AnchorError {}
115
116/// Apply a batch of anchored edits to `source`, preserving a trailing newline if present.
117///
118/// All anchors are resolved against the **original** line set, so edits don't interfere with
119/// each other's targeting. Any unmatched or ambiguous anchor fails the whole batch (atomic),
120/// so a stale edit never corrupts the file.
121pub fn apply_edits(source: &str, edits: &[Edit]) -> Result<String, AnchorError> {
122    let lines: Vec<&str> = source.lines().collect();
123
124    // Resolve every anchor to a unique line index first.
125    let mut resolved: Vec<(usize, &EditOp)> = Vec::with_capacity(edits.len());
126    for edit in edits {
127        let matches: Vec<usize> = lines
128            .iter()
129            .enumerate()
130            .filter(|(_, l)| anchor_of(l) == edit.anchor)
131            .map(|(i, _)| i)
132            .collect();
133        match matches.as_slice() {
134            [] => return Err(AnchorError::NotFound(edit.anchor.clone())),
135            [i] => resolved.push((*i, &edit.op)),
136            many => {
137                return Err(AnchorError::Ambiguous {
138                    anchor: edit.anchor.clone(),
139                    count: many.len(),
140                })
141            }
142        }
143    }
144
145    // Build the output line list, consulting edits keyed by original index.
146    let mut by_index: std::collections::HashMap<usize, Vec<&EditOp>> =
147        std::collections::HashMap::new();
148    for (i, op) in resolved {
149        by_index.entry(i).or_default().push(op);
150    }
151
152    let mut out: Vec<String> = Vec::with_capacity(lines.len());
153    for (i, line) in lines.iter().enumerate() {
154        let ops = by_index.get(&i);
155        // Inserts-before first.
156        if let Some(ops) = ops {
157            for op in ops {
158                if let EditOp::InsertBefore(text) = op {
159                    out.extend(text.lines().map(String::from));
160                }
161            }
162        }
163        // The line itself: replaced, deleted, or kept.
164        let mut emitted = false;
165        if let Some(ops) = ops {
166            for op in ops {
167                match op {
168                    EditOp::Replace(text) => {
169                        out.extend(text.lines().map(String::from));
170                        emitted = true;
171                    }
172                    EditOp::Delete => emitted = true,
173                    _ => {}
174                }
175            }
176        }
177        if !emitted {
178            out.push((*line).to_string());
179        }
180        // Inserts-after last.
181        if let Some(ops) = ops {
182            for op in ops {
183                if let EditOp::InsertAfter(text) = op {
184                    out.extend(text.lines().map(String::from));
185                }
186            }
187        }
188    }
189
190    let mut result = out.join("\n");
191    if source.ends_with('\n') {
192        result.push('\n');
193    }
194    Ok(result)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    const SRC: &str = "fn main() {\n    let x = 1;\n    println!(\"{x}\");\n}\n";
202
203    #[test]
204    fn anchor_is_indentation_insensitive_and_stable() {
205        assert_eq!(anchor_of("    let x = 1;"), anchor_of("let x = 1;"));
206        assert_eq!(anchor_of("let x = 1;").len(), 8);
207    }
208
209    #[test]
210    fn replace_targets_the_anchored_line() {
211        let anchor = anchor_of("    let x = 1;");
212        let out = apply_edits(SRC, &[Edit::replace(anchor, "    let x = 42;")]).unwrap();
213        assert!(out.contains("let x = 42;"));
214        assert!(!out.contains("let x = 1;"));
215        assert!(out.ends_with('\n'));
216    }
217
218    #[test]
219    fn insert_after_and_before() {
220        let anchor = anchor_of("    let x = 1;");
221        let out = apply_edits(SRC, &[Edit::insert_after(&anchor, "    let y = 2;")]).unwrap();
222        let lines: Vec<&str> = out.lines().collect();
223        let xi = lines.iter().position(|l| l.contains("let x = 1;")).unwrap();
224        assert!(lines[xi + 1].contains("let y = 2;"));
225    }
226
227    #[test]
228    fn delete_removes_the_line() {
229        let anchor = anchor_of("    println!(\"{x}\");");
230        let out = apply_edits(SRC, &[Edit::delete(anchor)]).unwrap();
231        assert!(!out.contains("println!"));
232    }
233
234    #[test]
235    fn unmatched_anchor_fails_the_batch() {
236        let err = apply_edits(SRC, &[Edit::replace("deadbeef", "x")]).unwrap_err();
237        assert!(matches!(err, AnchorError::NotFound(_)));
238    }
239
240    #[test]
241    fn ambiguous_anchor_is_rejected() {
242        let src = "dup\ndup\n";
243        let err = apply_edits(src, &[Edit::replace(anchor_of("dup"), "x")]).unwrap_err();
244        assert!(matches!(err, AnchorError::Ambiguous { count: 2, .. }));
245    }
246
247    #[test]
248    fn render_anchored_has_gutter() {
249        let rendered = render_anchored("hello");
250        assert!(rendered.starts_with(&anchor_of("hello")));
251        assert!(rendered.contains('\u{2502}'));
252    }
253}