Skip to main content

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(&current, 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}