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