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 =
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
151fn 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 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
188fn 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
205pub 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
247struct Splice {
249 file: usize,
250 start: usize,
251 len: usize,
252 replacement: Vec<String>,
253}
254
255pub 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(); 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 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 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
337fn 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 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 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}