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 {
35 find: Vec<String>,
36 replace: Vec<String>,
37 squeeze: bool,
38 },
39 Line {
41 re: Regex,
42 literal: bool,
43 replace: String,
44 },
45}
46
47pub struct EditSpec {
49 pub ordinal: usize,
51 pub line: usize,
53 pub expect: Expect,
54 pub expect_label: String,
55 pub mode_label: String,
56 pub op: Op,
57 pub file: Option<String>,
59}
60
61#[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 pub miss: Option<(String, NearestMiss)>,
72}
73
74const EDIT_ATTRS: [&str; 4] = ["expect", "mode", "file", "squeeze"];
76const EDIT_SECTIONS: [&str; 2] = ["find", "replace"];
77
78pub 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 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
162fn 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 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
210fn 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
228pub 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
267struct Splice {
269 file: usize,
270 start: usize,
271 len: usize,
272 replacement: Vec<String>,
273}
274
275pub 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(); 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 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 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 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
357fn 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 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 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 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"); assert_eq!(files[1].content, "y\n"); }
536}