1use 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#[derive(Debug, Clone)]
24pub struct FileBuf {
25 pub path: String,
26 pub content: String,
27}
28
29pub enum Op {
31 Block {
33 find: Vec<String>,
34 replace: Vec<String>,
35 },
36 Line {
38 re: Regex,
39 literal: bool,
40 replace: String,
41 },
42}
43
44pub struct EditSpec {
46 pub ordinal: usize,
48 pub line: usize,
50 pub expect: Expect,
51 pub expect_label: String,
52 pub mode_label: String,
53 pub op: Op,
54 pub file: Option<String>,
56}
57
58#[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 pub miss: Option<(String, NearestMiss)>,
69}
70
71const EDIT_ATTRS: [&str; 3] = ["expect", "mode", "file"];
73const EDIT_SECTIONS: [&str; 2] = ["find", "replace"];
74
75pub 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
150fn 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 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
194fn 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
211pub 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
250struct Splice {
252 file: usize,
253 start: usize,
254 len: usize,
255 replacement: Vec<String>,
256}
257
258pub 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(); 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 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 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
338fn 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 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 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 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"); assert_eq!(files[1].content, "y\n"); }
517}