pushkin_core/edits.rs
1//! F48 Phase B — applying an editor's edit operations to file content.
2//!
3//! A PURE function: (content, edits) → new content, or a typed error. No
4//! filesystem, no manifest, no gate. The gate layer reads the file and decides
5//! what to do with the result; this module only reconstructs.
6//!
7//! **Why it is pure, and why it is specified before it is used.** After Phase B
8//! the gate judges a RECONSTRUCTION of the post-edit file rather than refusing
9//! to judge at all. A reconstruction that is subtly wrong produces a confident
10//! verdict on a file that never existed — and a real violation in the true
11//! post-edit content passes, because it was never in the synthesized content.
12//! That inverts Phase A's posture silently, with everything still green. So the
13//! rules live here, pinned by `tests/edit_application.rs`, testable without a
14//! gate anywhere near them.
15//!
16//! **Every error is a refusal, never a fallback.** A caller that cannot
17//! reconstruct the file faithfully must deny, exactly as Phase A denied when
18//! there was no content at all. The failure direction does not move.
19
20/// A 1-based, INCLUSIVE line range: the region of the file a family claims its
21/// target sits in.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct LineSpan {
24 pub start: usize,
25 pub end: usize,
26}
27
28/// One replacement, as an editor's edit tool describes it.
29#[derive(Debug, Clone)]
30pub struct Replacement {
31 pub old: String,
32 pub new: String,
33 /// When false, the target must occur EXACTLY once or the edit is refused.
34 /// Ignored when `anchor` is set: an anchored edit is positional, so
35 /// "everywhere" has no meaning for it.
36 pub replace_all: bool,
37 /// F48 Phase B, auggie arm — where the family says the target is. When
38 /// present the search is CONFINED to these lines, which is what makes a
39 /// repeated target unambiguous: `body` may occur all over the file and
40 /// still occur exactly once inside its own anchor.
41 ///
42 /// `None` for families that locate by string search alone (Claude's
43 /// `Edit`/`MultiEdit`), where a repeated target genuinely is ambiguous.
44 pub anchor: Option<LineSpan>,
45}
46
47/// Why a reconstruction could not be produced. Each variant is a deny at the
48/// gate; none of them may be absorbed into a plausible-looking string.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EditError {
51 /// The target does not occur in the content the edit was applied to. With
52 /// `MultiEdit` this includes a target a PREVIOUS edit destroyed.
53 NotFound { old: String },
54 /// The target occurs more than once and `replace_all` was not set, so the
55 /// tool cannot know which was meant. Replacing the first is the plausible
56 /// guess and precisely the guess that fabricates a file.
57 NotUnique { old: String, count: usize },
58 /// `old` and `new` are identical — a caller mistake, not a no-op to absorb.
59 NoOp,
60 /// An empty target has no meaningful occurrence count; every position
61 /// matches.
62 EmptyTarget,
63 /// A mutation naming no edits is malformed, not a no-op.
64 NoEdits,
65 /// The anchor names lines the file does not have, or an inverted range. The
66 /// family is describing a file we are not looking at.
67 AnchorOutOfRange {
68 start: usize,
69 end: usize,
70 lines: usize,
71 },
72 /// An anchored edit follows one that changed the file's line count, so its
73 /// coordinates are ambiguous between two readings the capture does not
74 /// distinguish: original-file numbering, or numbering in the file as the
75 /// previous edits left it. Both agree until a line is added or removed, and
76 /// then they disagree silently. Refusing is the only honest answer until a
77 /// capture settles it.
78 AnchorShifted,
79}
80
81impl std::fmt::Display for EditError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::NotFound { old } => {
85 write!(f, "no occurrence of {old:?} in the file being edited")
86 }
87 Self::NotUnique { old, count } => write!(
88 f,
89 "{old:?} occurs {count} times and replace_all was not set; \
90 the intended occurrence is ambiguous"
91 ),
92 Self::NoOp => write!(f, "the replacement is identical to the target"),
93 Self::EmptyTarget => write!(f, "the target is empty and matches everywhere"),
94 Self::NoEdits => write!(f, "the mutation carries no edits"),
95 Self::AnchorOutOfRange { start, end, lines } => write!(
96 f,
97 "the edit is anchored to lines {start}-{end}, which the file \
98 (of {lines} lines) does not have"
99 ),
100 Self::AnchorShifted => write!(
101 f,
102 "an earlier edit changed the file's line count, so this edit's \
103 line anchor is ambiguous and cannot be honored"
104 ),
105 }
106 }
107}
108
109impl std::error::Error for EditError {}
110
111/// Applies `edits` to `content`, in order, each against the RESULT of the
112/// previous one — the semantics `MultiEdit` documents. Atomic: any failure
113/// discards the whole application rather than returning a partially-edited
114/// file, which would match no state the editor could produce.
115///
116/// # Errors
117/// Returns `EditError` when the reconstruction cannot be produced faithfully.
118pub fn apply_edits(content: &str, edits: &[Replacement]) -> Result<String, EditError> {
119 if edits.is_empty() {
120 return Err(EditError::NoEdits);
121 }
122 // Applied to a running copy, and only returned once every edit has
123 // succeeded — the atomicity the suite pins. A caller must never receive a
124 // partially-edited file.
125 let mut current = content.to_owned();
126 let mut line_count_changed = false;
127 for edit in edits {
128 // Anchors are coordinates, and once the line count moves we no longer
129 // know which file state they are coordinates IN. See `AnchorShifted`.
130 if line_count_changed && edit.anchor.is_some() {
131 return Err(EditError::AnchorShifted);
132 }
133 let before = current.lines().count();
134 current = apply_one(¤t, edit)?;
135 line_count_changed |= current.lines().count() != before;
136 }
137 Ok(current)
138}
139
140/// One replacement against the content as it stands at this point in the
141/// sequence. Occurrence counting therefore sees the result of every prior
142/// edit, not the original file.
143fn apply_one(content: &str, edit: &Replacement) -> Result<String, EditError> {
144 if edit.old.is_empty() {
145 return Err(EditError::EmptyTarget);
146 }
147 if edit.old == edit.new {
148 return Err(EditError::NoOp);
149 }
150 if let Some(anchor) = edit.anchor {
151 return apply_anchored(content, edit, anchor);
152 }
153 let count = content.matches(edit.old.as_str()).count();
154 if count == 0 {
155 return Err(EditError::NotFound {
156 old: edit.old.clone(),
157 });
158 }
159 if count > 1 && !edit.replace_all {
160 return Err(EditError::NotUnique {
161 old: edit.old.clone(),
162 count,
163 });
164 }
165 // `replace` is literal, never pattern-based — the property the suite pins
166 // with a target containing a regex metacharacter. `replacen(.., 1)` is the
167 // unique case, which by here is known to have exactly one occurrence.
168 Ok(if edit.replace_all {
169 content.replace(edit.old.as_str(), &edit.new)
170 } else {
171 content.replacen(edit.old.as_str(), &edit.new, 1)
172 })
173}
174
175/// One replacement confined to its anchored lines. The occurrence rules are the
176/// unanchored ones, applied to the window instead of the whole file — so an
177/// anchor never RELAXES a rule, it only narrows where the rule looks. A target
178/// absent from its window is `NotFound` even if it occurs elsewhere, which is
179/// the point: the family told us where it is, and it is not there.
180fn apply_anchored(
181 content: &str,
182 edit: &Replacement,
183 anchor: LineSpan,
184) -> Result<String, EditError> {
185 let Some((from, to)) = byte_range_of_lines(content, anchor) else {
186 return Err(EditError::AnchorOutOfRange {
187 start: anchor.start,
188 end: anchor.end,
189 lines: content.lines().count(),
190 });
191 };
192 let window = &content[from..to];
193 let count = window.matches(edit.old.as_str()).count();
194 if count == 0 {
195 return Err(EditError::NotFound {
196 old: edit.old.clone(),
197 });
198 }
199 if count > 1 {
200 return Err(EditError::NotUnique {
201 old: edit.old.clone(),
202 count,
203 });
204 }
205 let mut out = String::with_capacity(content.len());
206 out.push_str(&content[..from]);
207 out.push_str(&window.replacen(edit.old.as_str(), &edit.new, 1));
208 out.push_str(&content[to..]);
209 Ok(out)
210}
211
212/// Byte range covering lines `start..=end`, 1-based inclusive, with each line's
213/// terminator included. `None` for an inverted range, a zero start, or an end
214/// past the last line — every one of which means the anchor describes a file
215/// other than this one.
216fn byte_range_of_lines(content: &str, anchor: LineSpan) -> Option<(usize, usize)> {
217 if anchor.start == 0 || anchor.end < anchor.start {
218 return None;
219 }
220 let mut offset = 0;
221 let mut from = None;
222 let mut to = None;
223 for (index, line) in content.split_inclusive('\n').enumerate() {
224 let number = index + 1;
225 if number == anchor.start {
226 from = Some(offset);
227 }
228 offset += line.len();
229 if number == anchor.end {
230 to = Some(offset);
231 }
232 }
233 from.zip(to)
234}