Skip to main content

coding_tools/
editscript.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `ct-edit --script` engine: a batch of edits applied under the
5//! prepare/confirm/write standard.
6//!
7//! Phase 1 simulates the whole script in memory over [`FileBuf`]s — in script
8//! order under cascade (each edit matches the buffer as transformed by
9//! earlier edits), or against pristine content with an overlap check under
10//! `--no-cascade`. Every edit's expectation is judged in the simulation;
11//! the caller writes the final buffers only when every edit passed. Nothing
12//! here touches the filesystem.
13
14use crate::block::{self, NearestMiss};
15use crate::blockdoc::Item;
16use crate::edit::{Site, edit_content};
17use crate::pattern::{self, Mode};
18use crate::payload;
19use crate::verdict::{Expect, Verdict};
20use regex::Regex;
21
22/// One selected file, held in memory for the whole simulation.
23#[derive(Debug, Clone)]
24pub struct FileBuf {
25    pub path: String,
26    pub content: String,
27}
28
29/// A compiled edit operation.
30pub enum Op {
31    /// Line-anchored literal block find/replace (empty `replace` deletes).
32    Block {
33        find: Vec<String>,
34        replace: Vec<String>,
35    },
36    /// Single-line find/replace, as the argv form does it.
37    Line {
38        re: Regex,
39        literal: bool,
40        replace: String,
41    },
42}
43
44/// One edit from the script, compiled and ready to run.
45pub struct EditSpec {
46    /// 1-based position in the script.
47    pub ordinal: usize,
48    /// 1-based script line of the opening `edit` directive.
49    pub line: usize,
50    pub expect: Expect,
51    pub expect_label: String,
52    pub mode_label: String,
53    pub op: Op,
54    /// Optional `file=` narrowing within the invocation's selection.
55    pub file: Option<String>,
56}
57
58/// One edit's simulated outcome.
59#[derive(Debug)]
60pub struct EditOutcome {
61    pub ordinal: usize,
62    pub expect: String,
63    pub mode: String,
64    pub replacements: usize,
65    pub verdict: Verdict,
66    pub sites: Vec<Site>,
67    /// Best partial alignment when a literal block matched nothing: (path, miss).
68    pub miss: Option<(String, NearestMiss)>,
69}
70
71/// The attribute and section vocabulary of an `edit` item.
72const EDIT_ATTRS: [&str; 3] = ["expect", "mode", "file"];
73const EDIT_SECTIONS: [&str; 2] = ["find", "replace"];
74
75/// Compile one parsed `edit` item into an [`EditSpec`]. Defaults inside a
76/// script: `expect "=1"` (an anchored structural edit means *exactly here*,
77/// and the stricter default is the safer one inside an atomic batch) and
78/// `mode literal` (promotion is off in scripts; the author states intent).
79pub fn compile_item(item: &Item, ordinal: usize) -> Result<EditSpec, String> {
80    let at = |msg: String| format!("edit {ordinal} (script line {}): {msg}", item.line);
81
82    for (k, _) in &item.attrs {
83        if !EDIT_ATTRS.contains(&k.as_str()) {
84            return Err(at(format!("unknown attribute '{k}'")));
85        }
86    }
87    for (k, _) in &item.sections {
88        if !EDIT_SECTIONS.contains(&k.as_str()) {
89            return Err(at(format!("unknown section '{k}'")));
90        }
91    }
92
93    let expect_label = item.attr("expect").unwrap_or("=1").to_string();
94    let expect =
95        Expect::parse(&expect_label).map_err(|e| at(format!("invalid expect: {e}")))?;
96    let mode_label = item.attr("mode").unwrap_or("literal").to_string();
97    let mode = match mode_label.as_str() {
98        "literal" => Mode::Literal,
99        "glob" => Mode::Glob,
100        "regex" => Mode::Regex,
101        other => return Err(at(format!("invalid mode '{other}' (literal|glob|regex)"))),
102    };
103
104    let find_payload = item
105        .section("find")
106        .ok_or_else(|| at("missing 'find' section".to_string()))?;
107    let replace_payload = item
108        .section("replace")
109        .ok_or_else(|| at("missing 'replace' section".to_string()))?;
110    let find_lines = payload::to_lines(find_payload);
111    if find_lines.is_empty() {
112        return Err(at("empty 'find' section".to_string()));
113    }
114
115    let op = if find_lines.len() > 1 {
116        if mode != Mode::Literal {
117            return Err(at(
118                "a multi-line find matches as a literal block; mode glob/regex is reserved"
119                    .to_string(),
120            ));
121        }
122        Op::Block {
123            find: find_lines,
124            replace: payload::to_lines(replace_payload),
125        }
126    } else {
127        let single = find_lines.into_iter().next().unwrap();
128        let re = pattern::compile_with(&single, Some(mode))
129            .map_err(|e| at(format!("invalid find pattern: {e}")))?;
130        Op::Line {
131            re,
132            literal: mode != Mode::Regex,
133            replace: replace_payload
134                .strip_suffix('\n')
135                .unwrap_or(replace_payload)
136                .to_string(),
137        }
138    };
139
140    Ok(EditSpec {
141        ordinal,
142        line: item.line,
143        expect,
144        expect_label,
145        mode_label,
146        op,
147        file: item.attr("file").map(str::to_string),
148    })
149}
150
151/// The file indices an edit applies to, honouring its `file=` narrowing
152/// (exact path or whole-component suffix within the selection).
153fn candidates(spec: &EditSpec, files: &[FileBuf]) -> Result<Vec<usize>, String> {
154    let Some(f) = &spec.file else {
155        return Ok((0..files.len()).collect());
156    };
157    let suffix = format!("/{f}");
158    let cand: Vec<usize> = files
159        .iter()
160        .enumerate()
161        .filter(|(_, fb)| fb.path == *f || fb.path.ends_with(&suffix))
162        .map(|(i, _)| i)
163        .collect();
164    if cand.is_empty() {
165        return Err(format!(
166            "edit {} (script line {}): file={f} matches no selected file",
167            spec.ordinal, spec.line
168        ));
169    }
170    Ok(cand)
171}
172
173impl Op {
174    /// Apply this operation to one file's content: new content, occurrence
175    /// count, changed sites. Shared by the script engine and the argv form.
176    pub fn apply(&self, path: &str, content: &str) -> (String, usize, Vec<Site>) {
177        match self {
178            Op::Block { find, replace } => block::edit_blocks(path, content, find, replace),
179            Op::Line {
180                re,
181                literal,
182                replace,
183            } => edit_content(path, content, re, replace, *literal),
184        }
185    }
186}
187
188/// Track the deepest-diverging nearest miss across candidate files.
189fn track_miss(
190    best: &mut Option<(String, NearestMiss)>,
191    path: &str,
192    content: &str,
193    find: &[String],
194) {
195    let lines: Vec<&str> = content.lines().collect();
196    if let Some(m) = block::nearest_miss(&lines, find)
197        && best
198            .as_ref()
199            .is_none_or(|(_, b)| m.first_diverging_line > b.first_diverging_line)
200    {
201        *best = Some((path.to_string(), m));
202    }
203}
204
205/// Run the script with cascade: edits run in script order, each matching the
206/// buffers as already transformed by earlier edits, exactly as the final
207/// write would have it. Buffers are updated even past a failing edit so the
208/// remaining diagnostics stay meaningful; the caller writes nothing unless
209/// every outcome is `SUCCESS`.
210pub fn run_cascade(
211    specs: &[EditSpec],
212    files: &mut [FileBuf],
213) -> Result<Vec<EditOutcome>, String> {
214    let mut outcomes = Vec::with_capacity(specs.len());
215    for spec in specs {
216        let cand = candidates(spec, files)?;
217        let mut total = 0usize;
218        let mut sites: Vec<Site> = Vec::new();
219        let mut miss: Option<(String, NearestMiss)> = None;
220        for &i in &cand {
221            let f = &mut files[i];
222            let (new_content, hits, s) = spec.op.apply(&f.path, &f.content);
223            if hits > 0 {
224                f.content = new_content;
225                total += hits;
226                sites.extend(s);
227            } else if let Op::Block { find, .. } = &spec.op {
228                track_miss(&mut miss, &f.path, &f.content, find);
229            }
230        }
231        let verdict = spec.expect.eval(total as u64);
232        outcomes.push(EditOutcome {
233            ordinal: spec.ordinal,
234            expect: spec.expect_label.clone(),
235            mode: spec.mode_label.clone(),
236            replacements: total,
237            verdict,
238            sites,
239            miss: (verdict != Verdict::Success && total == 0)
240                .then_some(miss)
241                .flatten(),
242        });
243    }
244    Ok(outcomes)
245}
246
247/// A pending line-range replacement located against pristine content.
248struct Splice {
249    file: usize,
250    start: usize,
251    len: usize,
252    replacement: Vec<String>,
253}
254
255/// Run the script without cascade: every edit matches pristine content, any
256/// two edits touching the same line is a usage error, and the located
257/// splices are applied positionally so the result is exactly what was
258/// verified.
259pub fn run_no_cascade(
260    specs: &[EditSpec],
261    files: &mut [FileBuf],
262) -> Result<Vec<EditOutcome>, String> {
263    let pristine: Vec<String> = files.iter().map(|f| f.content.clone()).collect();
264    let mut outcomes = Vec::with_capacity(specs.len());
265    let mut splices: Vec<(usize, Splice)> = Vec::new(); // (ordinal, splice)
266
267    for spec in specs {
268        let cand = candidates(spec, files)?;
269        let mut total = 0usize;
270        let mut sites: Vec<Site> = Vec::new();
271        let mut miss: Option<(String, NearestMiss)> = None;
272        for &i in &cand {
273            let (_, hits, s) = spec.op.apply(&files[i].path, &pristine[i]);
274            if hits == 0 {
275                if let Op::Block { find, .. } = &spec.op {
276                    track_miss(&mut miss, &files[i].path, &pristine[i], find);
277                }
278                continue;
279            }
280            total += hits;
281            for site in &s {
282                let (len, replacement) = match &spec.op {
283                    Op::Block { find, replace } => (find.len(), replace.clone()),
284                    Op::Line { .. } => {
285                        (1, site.after.split('\n').map(str::to_string).collect())
286                    }
287                };
288                splices.push((
289                    spec.ordinal,
290                    Splice {
291                        file: i,
292                        start: site.line - 1,
293                        len,
294                        replacement,
295                    },
296                ));
297            }
298            sites.extend(s);
299        }
300        let verdict = spec.expect.eval(total as u64);
301        outcomes.push(EditOutcome {
302            ordinal: spec.ordinal,
303            expect: spec.expect_label.clone(),
304            mode: spec.mode_label.clone(),
305            replacements: total,
306            verdict,
307            sites,
308            miss: (verdict != Verdict::Success && total == 0)
309                .then_some(miss)
310                .flatten(),
311        });
312    }
313
314    // Overlap check: without cascade, two edits touching the same line are
315    // ambiguous by construction.
316    splices.sort_by_key(|(_, s)| (s.file, s.start));
317    for pair in splices.windows(2) {
318        let (ord_a, a) = &pair[0];
319        let (ord_b, b) = &pair[1];
320        if a.file == b.file && b.start < a.start + a.len && ord_a != ord_b {
321            return Err(format!(
322                "edits {ord_a} and {ord_b} overlap at {}:{} (no-cascade requires disjoint edits)",
323                files[a.file].path,
324                b.start + 1
325            ));
326        }
327    }
328
329    // Apply positionally, bottom-up per file, so earlier indices stay valid.
330    for (_, s) in splices.iter().rev() {
331        let f = &mut files[s.file];
332        f.content = splice_lines(&f.content, s.start, s.len, &s.replacement);
333    }
334    Ok(outcomes)
335}
336
337/// Replace `len` lines starting at 0-based `start` with `replacement` lines,
338/// preserving every untouched byte (including a missing final newline).
339fn splice_lines(content: &str, start: usize, len: usize, replacement: &[String]) -> String {
340    let segments: Vec<(&str, &str)> = content
341        .split_inclusive('\n')
342        .map(|seg| match seg.strip_suffix('\n') {
343            Some(b) => (b, "\n"),
344            None => (seg, ""),
345        })
346        .collect();
347    let mut out = String::with_capacity(content.len());
348    for (i, (body, term)) in segments.iter().enumerate() {
349        if i == start {
350            let last_term = segments[(start + len - 1).min(segments.len() - 1)].1;
351            for (r, rl) in replacement.iter().enumerate() {
352                out.push_str(rl);
353                out.push_str(if r + 1 == replacement.len() { last_term } else { "\n" });
354            }
355        }
356        if i < start || i >= start + len {
357            out.push_str(body);
358            out.push_str(term);
359        }
360    }
361    out
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::blockdoc::{DEFAULT_FENCE, parse};
368
369    fn bufs(files: &[(&str, &str)]) -> Vec<FileBuf> {
370        files
371            .iter()
372            .map(|(p, c)| FileBuf {
373                path: p.to_string(),
374                content: c.to_string(),
375            })
376            .collect()
377    }
378
379    fn specs(doc: &str) -> Vec<EditSpec> {
380        parse(doc, DEFAULT_FENCE, &["edit"])
381            .unwrap()
382            .iter()
383            .enumerate()
384            .map(|(i, it)| compile_item(it, i + 1).unwrap())
385            .collect()
386    }
387
388    #[test]
389    fn script_default_expect_is_exactly_one() {
390        let s = specs("#% edit\n#% find\nx\n#% replace\ny\n#% end\n");
391        assert_eq!(s[0].expect_label, "=1");
392        let mut files = bufs(&[("a", "x\nx\n")]);
393        let out = run_cascade(&s, &mut files).unwrap();
394        // Two sites against expect =1: the edit fails.
395        assert_eq!(out[0].replacements, 2);
396        assert_eq!(out[0].verdict, Verdict::Error);
397    }
398
399    #[test]
400    fn cascade_lets_a_later_edit_see_an_earlier_one() {
401        let doc = "\
402#% edit
403#% find
404base()
405#% replace
406base()
407added()
408#% edit
409#% find
410added()
411#% replace
412added(1)
413#% end
414";
415        let s = specs(doc);
416        let mut files = bufs(&[("a", "base()\n")]);
417        let out = run_cascade(&s, &mut files).unwrap();
418        assert!(out.iter().all(|o| o.verdict == Verdict::Success));
419        assert_eq!(files[0].content, "base()\nadded(1)\n");
420    }
421
422    #[test]
423    fn no_cascade_judges_pristine_and_rejects_overlap() {
424        let doc = "\
425#% edit
426#% find
427a
428b
429#% replace
430A
431#% edit
432#% find
433b
434c
435#% replace
436C
437#% end
438";
439        let s = specs(doc);
440        let mut files = bufs(&[("f", "a\nb\nc\n")]);
441        let err = run_no_cascade(&s, &mut files).unwrap_err();
442        assert!(err.contains("overlap"), "{err}");
443    }
444
445    #[test]
446    fn no_cascade_applies_disjoint_edits_positionally() {
447        let doc = "\
448#% edit
449#% find
450a
451#% replace
452A1
453A2
454#% edit
455#% find
456c
457#% replace
458#% end
459";
460        let s = specs(doc);
461        let mut files = bufs(&[("f", "a\nb\nc")]);
462        let out = run_no_cascade(&s, &mut files).unwrap();
463        assert!(out.iter().all(|o| o.verdict == Verdict::Success));
464        // Block growth above, deletion below, missing final newline preserved
465        // on the spliced tail.
466        assert_eq!(files[0].content, "A1\nA2\nb\n");
467    }
468
469    #[test]
470    fn failing_block_edit_carries_a_nearest_miss() {
471        let doc = "#% edit\n#% find\nfn a() {\n    three();\n#% replace\nx\n#% end\n";
472        let s = specs(doc);
473        let mut files = bufs(&[("f", "fn a() {\n    two();\n}\n")]);
474        let out = run_cascade(&s, &mut files).unwrap();
475        assert_eq!(out[0].verdict, Verdict::Error);
476        let (path, m) = out[0].miss.as_ref().unwrap();
477        assert_eq!(path, "f");
478        assert_eq!(m.first_diverging_line, 2);
479    }
480
481    #[test]
482    fn file_narrowing_limits_and_validates() {
483        let doc = "#% edit file=b.rs\n#% find\nx\n#% replace\ny\n#% end\n";
484        let s = specs(doc);
485        let mut files = bufs(&[("./src/a.rs", "x\n"), ("./src/b.rs", "x\n")]);
486        let out = run_cascade(&s, &mut files).unwrap();
487        assert_eq!(out[0].replacements, 1);
488        assert_eq!(files[0].content, "x\n");
489        assert_eq!(files[1].content, "y\n");
490
491        let missing = specs("#% edit file=zzz.rs\n#% find\nx\n#% replace\ny\n#% end\n");
492        let mut files = bufs(&[("./src/a.rs", "x\n")]);
493        assert!(run_cascade(&missing, &mut files).is_err());
494    }
495}