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