1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use sem_core::model::entity::SemanticEntity;
23
24use crate::parsers::REGISTRY;
25
26#[derive(Debug, Clone, Default, Deserialize, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct Edit {
29 pub file: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub entity: Option<EntityRef>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub op: Option<String>,
41 #[serde(
43 default,
44 alias = "entity_type",
45 skip_serializing_if = "Option::is_none"
46 )]
47 pub kind: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub ordinal: Option<usize>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub content: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub old: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub new: Option<String>,
63 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
65 pub all: bool,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub after: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub before: Option<String>,
72 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
74 pub append: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub into: Option<String>,
79 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
81 pub delete: bool,
82 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
86 pub allow_signature_change: bool,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub rename: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub expect: Option<String>,
97 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 pub everywhere: bool,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub scope: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub revert: Option<String>,
112}
113
114#[derive(Debug, Clone, Deserialize, Serialize)]
115#[serde(untagged)]
116pub enum EntityRef {
117 Name(String),
118 Ref {
119 name: String,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 entity_type: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 parent_name: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 ordinal: Option<usize>,
126 },
127}
128
129impl From<&str> for EntityRef {
130 fn from(s: &str) -> Self {
131 EntityRef::Name(s.to_string())
132 }
133}
134
135#[derive(Debug, Clone, Default)]
138struct Plain {
139 entity: Option<String>,
140 kind: Option<String>,
141 ordinal: Option<usize>,
142 content: Option<String>,
143 old: Option<String>,
144 new: Option<String>,
145 all: bool,
146 after: Option<String>,
147 before: Option<String>,
148 append: bool,
149 into: Option<String>,
150 delete: bool,
151 allow_signature_change: bool,
152 rename_ident: Option<(String, String)>,
154 everywhere: bool,
156 member: bool,
159 home: Option<String>,
161 expect: Option<String>,
162}
163
164impl Edit {
165 fn plain(&self) -> Result<Plain, String> {
166 let (mut name, mut kind, mut ordinal) = (None, self.kind.clone(), self.ordinal);
167 match &self.entity {
168 Some(EntityRef::Name(n)) => name = Some(n.clone()),
169 Some(EntityRef::Ref {
170 name: n,
171 entity_type,
172 parent_name,
173 ordinal: o,
174 }) => {
175 name = Some(match parent_name {
176 Some(p) => format!("{p}.{n}"),
177 None => n.clone(),
178 });
179 kind = kind.or_else(|| entity_type.clone());
180 ordinal = ordinal.or(*o);
181 }
182 None => {}
183 }
184 let mut p = Plain {
185 entity: name,
186 kind,
187 ordinal,
188 content: self.content.clone(),
189 old: self.old.clone(),
190 new: self.new.clone(),
191 all: self.all,
192 after: self.after.clone(),
193 before: self.before.clone(),
194 append: self.append,
195 into: self.into.clone(),
196 delete: self.delete,
197 allow_signature_change: self.allow_signature_change,
198 rename_ident: None,
199 everywhere: false,
200 member: false,
201 home: None,
202 expect: self.expect.clone(),
203 };
204 match self.op.as_deref() {
205 None | Some("replace") => {}
206 Some("insert_after") => {
207 p.after = Some(
208 p.entity
209 .take()
210 .ok_or("insert_after needs the entity to insert after")?,
211 )
212 }
213 Some("insert_before") => {
214 p.before = Some(
215 p.entity
216 .take()
217 .ok_or("insert_before needs the entity to insert before")?,
218 )
219 }
220 Some("delete") => p.delete = true,
221 Some(other) => {
222 return Err(format!(
223 "unknown op {other:?}: use replace, insert_after, insert_before or delete"
224 ))
225 }
226 }
227 Ok(p)
228 }
229}
230
231#[derive(Debug, Clone, Serialize, PartialEq)]
233pub struct FileReport {
234 pub file: String,
235 #[serde(skip_serializing_if = "std::ops::Not::not")]
236 pub created: bool,
237 pub added: Vec<String>,
238 pub modified: Vec<String>,
239 pub deleted: Vec<String>,
240}
241
242#[derive(Debug, Clone, Serialize)]
243pub struct Report {
244 pub written: bool,
245 pub files: Vec<FileReport>,
246 #[serde(skip_serializing_if = "Vec::is_empty")]
249 pub notes: Vec<String>,
250 #[serde(skip_serializing_if = "Vec::is_empty")]
254 pub review: Vec<String>,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub then: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, thiserror::Error)]
262#[error("edit {index} ({file}): {message}")]
263pub struct EditError {
264 pub index: usize,
266 pub file: String,
267 pub message: String,
268 #[serde(skip_serializing_if = "Vec::is_empty")]
270 pub candidates: Vec<String>,
271}
272
273pub fn run(root: &Path, edits: &[Edit], dry_run: bool) -> Result<Report, EditError> {
279 let _lock = if dry_run { None } else { lock(root) };
283 let mut by_file: BTreeMap<String, Vec<(usize, Plain)>> = BTreeMap::new();
284 let mut notes = Vec::new();
285 let mut renamed: Vec<String> = Vec::new();
286 for (index, e) in edits.iter().enumerate() {
287 let fail = |message: String| EditError {
288 index,
289 file: e.file.clone(),
290 message,
291 candidates: vec![],
292 };
293 let path = normalize(root, &e.file).map_err(fail)?;
294 let mut plain = e.plain().map_err(fail)?;
295 if let Some(rev) = &e.revert {
296 plain.content =
297 Some(
298 reverted(root, &path, rev, &plain).map_err(|(m, c)| EditError {
299 index,
300 file: e.file.clone(),
301 message: m,
302 candidates: c,
303 })?,
304 );
305 }
306 if let Some(to) = &e.rename {
307 let entity = plain
308 .entity
309 .clone()
310 .ok_or_else(|| fail("rename needs the entity to rename".into()))?;
311 let from = entity
312 .rsplit(['.', ':'])
313 .next()
314 .unwrap_or(&entity)
315 .to_string();
316 if !is_identifier(to) {
317 return Err(fail(format!("{to:?} is not an identifier")));
318 }
319 renamed.push(from.clone());
320 let home_src = std::fs::read_to_string(root.join(&path)).unwrap_or_default();
321 let target = find(
322 &path,
323 &home_src,
324 &entity,
325 plain.kind.as_deref(),
326 plain.ordinal,
327 )
328 .map_err(|(m, c)| EditError {
329 index,
330 file: e.file.clone(),
331 message: m,
332 candidates: c,
333 })?;
334 let member = target.parent_id.is_some() || target.entity_type == "method";
335 let (code, other) = mentions(root, &from, &e.scope);
336 let mut files: Vec<String> = code;
337 if !files.contains(&path) {
338 files.insert(0, path.clone());
339 }
340 for f in files {
341 let p = Plain {
342 rename_ident: Some((from.clone(), to.clone())),
343 entity: (f == path).then(|| entity.clone()),
344 kind: plain.kind.clone(),
345 home: Some(path.clone()),
346 everywhere: e.everywhere,
347 member,
348 ..Default::default()
349 };
350 by_file.entry(f).or_default().push((index, p));
351 }
352 let other: Vec<String> = if e.everywhere {
353 let (text, rest): (Vec<String>, Vec<String>) =
354 other.into_iter().partition(|f| is_text_doc(f));
355 for f in text {
356 let p = Plain {
357 rename_ident: Some((from.clone(), to.clone())),
358 everywhere: true,
359 ..Default::default()
360 };
361 by_file.entry(f).or_default().push((index, p));
362 }
363 rest
364 } else {
365 other
366 };
367 if member {
368 let twins: Vec<String> = code_twins(root, &from, &path, target.start_line);
369 if !twins.is_empty() {
370 notes.push(format!("{from} is also defined at {}: calls like x.{from}() were renamed whatever x is, so check the ones on those types", twins.join(", ")));
371 }
372 }
373 if !other.is_empty() {
374 notes.push(format!(
375 "{} also appears in files weave cannot parse, left as they are: {}",
376 from,
377 other.join(", ")
378 ));
379 }
380 continue;
381 }
382 by_file.entry(path).or_default().push((index, plain));
383 }
384
385 type Planned = Result<(String, Option<String>, String), EditError>;
386 let planned: Vec<Planned> = std::thread::scope(|scope| {
387 let jobs: Vec<_> = by_file
388 .iter()
389 .map(|(path, list)| scope.spawn(move || plan_file(root, path, list)))
390 .collect();
391 jobs.into_iter()
392 .map(|j| {
393 j.join().unwrap_or_else(|_| {
394 Err(EditError {
395 index: 0,
396 file: String::new(),
397 message: "planning panicked".into(),
398 candidates: vec![],
399 })
400 })
401 })
402 .collect()
403 });
404 if let Some(e) = planned
406 .iter()
407 .filter_map(|r| r.as_ref().err())
408 .min_by_key(|e| e.index)
409 {
410 return Err(e.clone());
411 }
412 let files: Vec<(String, Option<String>, String)> = planned
413 .into_iter()
414 .filter_map(Result::ok)
415 .filter(|(_, disk, now)| disk.as_deref() != Some(now.as_str()))
416 .collect();
417
418 let reports: Vec<FileReport> = files
419 .iter()
420 .map(|(path, disk, now)| report(path, disk.as_deref(), now))
421 .collect();
422 for from in &renamed {
425 let word = |line: &str| {
426 line.match_indices(from.as_str()).any(|(i, _)| {
427 let before = line[..i].chars().next_back();
428 let after = line[i + from.len()..].chars().next();
429 !before.is_some_and(|c| c.is_alphanumeric() || c == '_')
430 && !after.is_some_and(|c| c.is_alphanumeric() || c == '_')
431 })
432 };
433 let mut left: Vec<String> = Vec::new();
434 for (path, _, now) in &files {
435 for (i, line) in now.lines().enumerate() {
436 if word(line) {
437 left.push(format!("{path}:{}: {}", i + 1, line.trim()));
438 }
439 }
440 }
441 if !left.is_empty() {
442 let more = left.len().saturating_sub(12);
443 left.truncate(12);
444 let tail = if more > 0 {
445 format!("\n ... and {more} more")
446 } else {
447 String::new()
448 };
449 notes.push(format!("{from} is still spelled out here, in strings, comments, docs or as a different entity's name (left as they are):\n {}{tail}", left.join("\n ")));
450 }
451 }
452 if !dry_run {
453 for (path, _, now) in &files {
454 let abs = root.join(path);
455 if let Some(dir) = abs.parent() {
456 std::fs::create_dir_all(dir).map_err(|e| io_error(path, e))?;
457 }
458 std::fs::write(&abs, now).map_err(|e| io_error(path, e))?;
459 }
460 }
461 let review = if dry_run {
462 Vec::new()
463 } else {
464 review(root, &files, &renamed)
465 };
466 let written = !dry_run && !reports.is_empty();
467 Ok(Report {
468 written,
469 files: reports,
470 notes,
471 review,
472 then: None,
473 })
474}
475
476fn plan_file(
478 root: &Path,
479 path: &str,
480 list: &[(usize, Plain)],
481) -> Result<(String, Option<String>, String), EditError> {
482 let first = list.first().map(|(i, _)| *i).unwrap_or(0);
483 let fail = |index: usize, message: String, candidates: Vec<String>| EditError {
484 index,
485 file: path.to_string(),
486 message,
487 candidates,
488 };
489 let disk = match std::fs::read(root.join(path)) {
490 Ok(b) => Some(
491 String::from_utf8(b)
492 .map_err(|_| fail(first, "not a UTF-8 text file".into(), vec![]))?,
493 ),
494 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
495 Err(err) => return Err(fail(first, err.to_string(), vec![])),
496 };
497 let mut now = disk.clone().unwrap_or_default();
498 for (index, e) in list {
499 let exists = disk.is_some() || !now.is_empty();
500 now = apply_one(path, &now, exists, e).map_err(|(m, c)| fail(*index, m, c))?;
501 }
502 if disk.as_deref() != Some(now.as_str()) {
503 if let Some(why) = parses_worse(path, disk.as_deref().unwrap_or(""), &now) {
504 let last = list.last().map(|(i, _)| *i).unwrap_or(first);
505 return Err(fail(last, why, vec![]));
506 }
507 }
508 Ok((path.to_string(), disk, now))
509}
510
511fn parses_worse(path: &str, before: &str, after: &str) -> Option<String> {
514 use sem_core::parser::plugins::code::{language_config_for_content, parse_tree};
515 let config = language_config_for_content(after, path)?;
516 let errors = |src: &str| -> Vec<usize> {
517 let Some(tree) = parse_tree(config, src) else {
518 return vec![];
519 };
520 let mut lines = Vec::new();
521 let mut cursor = tree.walk();
522 let mut stack = vec![tree.root_node()];
523 while let Some(n) = stack.pop() {
524 if !n.has_error() && !n.is_missing() {
525 continue;
526 }
527 if n.is_error() || n.is_missing() {
528 lines.push(n.start_position().row + 1);
529 continue;
530 }
531 stack.extend(n.children(&mut cursor));
532 }
533 lines.sort_unstable();
534 lines
535 };
536 let (b, a) = (errors(before), errors(after));
537 if a.len() <= b.len() {
538 return None;
539 }
540 let line = a
541 .iter()
542 .find(|l| !b.contains(l))
543 .or(a.first())
544 .copied()
545 .unwrap_or(0);
546 Some(format!(
547 "the edits leave {path} unparseable near line {line}; nothing was written"
548 ))
549}
550
551fn sem_bin() -> String {
553 std::env::var("SEM_BIN")
554 .ok()
555 .filter(|s| !s.is_empty())
556 .unwrap_or_else(|| "sem".to_string())
557}
558
559fn last_command(root: &Path) -> Option<(String, String)> {
562 let o = std::process::Command::new("git")
563 .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
564 .current_dir(root)
565 .output()
566 .ok()?;
567 let dir = std::path::PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()).join("sem-run");
568 let text = std::fs::read_to_string(dir.join("last")).ok()?;
569 let (rel, cmd) = text.split_once('\n')?;
570 (!cmd.trim().is_empty()).then(|| (rel.to_string(), cmd.to_string()))
571}
572
573pub fn speculate(root: &Path) {
578 if std::env::var("WEAVE_SPECULATE").is_ok_and(|v| v == "0") {
579 return;
580 }
581 let Some((rel, cmd)) = last_command(root) else {
582 return;
583 };
584 let cwd = root.join(rel);
585 let quoted = |s: &str| format!("'{}'", s.replace('\'', "'\\''"));
586 let line = format!(
587 "{} check --run {} --quiet >/dev/null 2>&1 &",
588 quoted(&sem_bin()),
589 quoted(&cmd)
590 );
591 let _ = std::process::Command::new("sh")
592 .arg("-c")
593 .arg(line)
594 .current_dir(cwd)
595 .stdin(std::process::Stdio::null())
596 .stdout(std::process::Stdio::null())
597 .stderr(std::process::Stdio::null())
598 .status();
599}
600
601pub fn then(root: &Path, cmd: &str) -> String {
604 match std::process::Command::new(sem_bin())
605 .args(["check", "--run", cmd])
606 .current_dir(root)
607 .stdin(std::process::Stdio::null())
608 .output()
609 {
610 Ok(o) => format!(
611 "{}{}",
612 String::from_utf8_lossy(&o.stdout),
613 String::from_utf8_lossy(&o.stderr)
614 ),
615 Err(e) => format!("could not run sem check --run: {e} (set SEM_BIN)"),
616 }
617}
618
619fn lock(root: &Path) -> Option<std::fs::File> {
620 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
621 let mut h: u64 = 0xcbf29ce484222325;
622 for b in root.to_string_lossy().bytes() {
623 h = (h ^ b as u64).wrapping_mul(0x100000001b3);
624 }
625 let f = std::fs::OpenOptions::new()
626 .create(true)
627 .truncate(false)
628 .write(true)
629 .open(std::env::temp_dir().join(format!("weave-edit-{h:016x}.lock")))
630 .ok()?;
631 f.lock().ok()?;
632 Some(f)
633}
634
635fn io_error(path: &str, e: std::io::Error) -> EditError {
636 EditError {
637 index: 0,
638 file: path.to_string(),
639 message: format!("writing: {e}"),
640 candidates: vec![],
641 }
642}
643
644fn normalize(root: &Path, file: &str) -> Result<String, String> {
646 let p = Path::new(file);
647 let rel: PathBuf = if p.is_absolute() {
648 p.strip_prefix(root)
649 .map_err(|_| format!("{file} is outside the repository"))?
650 .to_path_buf()
651 } else {
652 p.to_path_buf()
653 };
654 let mut out: Vec<String> = Vec::new();
655 for c in rel.components() {
656 match c {
657 std::path::Component::Normal(s) => out.push(s.to_string_lossy().to_string()),
658 std::path::Component::CurDir => {}
659 std::path::Component::ParentDir => {
660 if out.pop().is_none() {
661 return Err(format!("{file} is outside the repository"));
662 }
663 }
664 _ => return Err(format!("{file} is not a relative path")),
665 }
666 }
667 if out.is_empty() {
668 return Err("no file named".into());
669 }
670 Ok(out.join("/"))
671}
672
673type Fail = (String, Vec<String>);
674
675fn apply_one(path: &str, src: &str, exists: bool, e: &Plain) -> Result<String, Fail> {
676 if let Some((from, to)) = &e.rename_ident {
677 let target = match &e.entity {
680 Some(name) => Some(find(path, src, name, e.kind.as_deref(), None)?),
681 None => None,
682 };
683 if e.home.is_none() && is_text_doc(path) {
684 return Ok(replace_words(src, from, to));
685 }
686 let home = e.home.as_deref().unwrap_or(path);
687 let module = home
688 .rsplit('/')
689 .next()
690 .unwrap_or(home)
691 .split('.')
692 .next()
693 .unwrap_or("");
694 return Ok(rename_identifiers(
695 path,
696 src,
697 from,
698 to,
699 target.as_ref(),
700 module,
701 e.everywhere,
702 e.member,
703 ));
704 }
705 let anchors = [
706 e.after.is_some(),
707 e.before.is_some(),
708 e.append,
709 e.into.is_some(),
710 ]
711 .iter()
712 .filter(|b| **b)
713 .count();
714 if anchors > 1 {
715 return Err((
716 "use only one of after, before, append and into".into(),
717 vec![],
718 ));
719 }
720 if let Some(container) = &e.into {
721 let content = e
722 .content
723 .as_deref()
724 .ok_or_else(|| ("into needs content: the member to add".to_string(), vec![]))?;
725 let parent = find(path, src, container, None, None)?;
726 let all = entities(path, src);
727 let last = all
728 .iter()
729 .filter(|m| m.parent_id.as_deref() == Some(parent.id.as_str()))
730 .max_by_key(|m| m.end_line);
731 let Some(last) = last else {
732 return Err((
733 format!("{container} has no members to add after; use after or old/new"),
734 vec![],
735 ));
736 };
737 let (start, end) = span(src, last);
738 let indent = indent_at(src, start);
739 let gap = gap_before(src, start);
740 let end = line_end(src, end);
741 let body = fit(content, &indent);
742 let body = if uses_tabs(src) { tabify(&body) } else { body };
743 let out = format!("{}{gap}{indent}{}{}", &src[..end], body, &src[end..]);
744 placed_under(path, src, &out, Some(&parent.name))?;
745 return Ok(out);
746 }
747 if e.old.is_some() != e.new.is_some() {
748 return Err(("old and new go together".into(), vec![]));
749 }
750 if e.old.is_some() && (e.content.is_some() || e.delete || anchors > 0) {
751 return Err((
752 "a text edit (old/new) takes no content, delete, after, before or append".into(),
753 vec![],
754 ));
755 }
756
757 if let (Some(old), Some(new)) = (&e.old, &e.new) {
758 if !exists {
759 return Err((
760 "the file does not exist; create it with content alone".into(),
761 vec![],
762 ));
763 }
764 let (start, end) = match &e.entity {
765 Some(name) => {
766 let (s, t) = span(src, &find(path, src, name, e.kind.as_deref(), e.ordinal)?);
767 check_version(name, &src[s..t], e.expect.as_deref())?;
768 (s, t)
769 }
770 None => (0, src.len()),
771 };
772 let new = if uses_tabs(src)
773 && new.lines().any(|l| l.starts_with(" "))
774 && !old.lines().any(|l| l.starts_with(" "))
775 {
776 tabify(new)
777 } else {
778 new.clone()
779 };
780 return replace_text(src, start, end, old, &new, e.all, e.entity.as_deref());
781 }
782
783 if let Some(anchor) = e.after.as_ref().or(e.before.as_ref()) {
784 let content = e.content.as_deref().ok_or_else(|| {
785 (
786 "after and before need content: the entity to add".to_string(),
787 vec![],
788 )
789 })?;
790 if !exists {
791 return Err((
792 "the file does not exist; create it with content alone".into(),
793 vec![],
794 ));
795 }
796 let target = find(path, src, anchor, e.kind.as_deref(), e.ordinal)?;
797 let (start, end) = span(src, &target);
798 let indent = indent_at(src, start);
799 let body = fit(content, &indent);
800 let body = if uses_tabs(src) { tabify(&body) } else { body };
801 let gap = gap_before(src, start);
802 let out = if e.after.is_some() {
803 let end = line_end(src, end);
804 format!("{}{gap}{indent}{body}{}", &src[..end], &src[end..])
805 } else {
806 let lead = leading_start(src, start);
809 let at = if lead < start {
810 lead + (src[lead..].len() - src[lead..].trim_start_matches([' ', '\t']).len())
811 } else {
812 start
813 };
814 format!("{}{body}{gap}{indent}{}", &src[..at], &src[at..])
815 };
816 let all = entities(path, src);
817 let parent = target
818 .parent_id
819 .as_ref()
820 .and_then(|id| all.iter().find(|p| &p.id == id))
821 .map(|p| p.name.clone());
822 placed_under(path, src, &out, parent.as_deref())?;
823 return Ok(out);
824 }
825
826 if e.append {
827 let content = e
828 .content
829 .as_deref()
830 .ok_or_else(|| ("append needs content".to_string(), vec![]))?;
831 let body = content.trim_matches('\n');
832 if src.trim().is_empty() {
833 return Ok(format!("{body}\n"));
834 }
835 let head = src.trim_end_matches('\n');
836 return Ok(format!("{head}\n\n{body}\n"));
837 }
838
839 if e.delete {
840 let name = e
841 .entity
842 .as_ref()
843 .ok_or_else(|| ("delete needs the entity to delete".to_string(), vec![]))?;
844 let target = find(path, src, name, e.kind.as_deref(), e.ordinal)?;
845 let (start, end) = span(src, &target);
846 check_version(name, &src[start..end], e.expect.as_deref())?;
847 let lead = leading_start(src, start);
848 let mut from = line_start(src, lead);
849 if src[from..lead].trim().is_empty() {
850 let mut to = line_end(src, end);
852 if to < src.len() {
853 to += 1;
854 }
855 while src[to..].starts_with('\n') && (from == 0 || src[..from].ends_with("\n\n")) {
856 to += 1;
857 }
858 if to >= src.len() && from > 0 {
859 while from > 0 && src[..from].ends_with("\n\n") {
860 from -= 1;
861 }
862 }
863 return Ok(format!("{}{}", &src[..from], &src[to..]));
864 }
865 from = start;
866 return Ok(format!("{}{}", &src[..from], &src[end..]));
867 }
868
869 let content = e.content.as_deref().ok_or_else(|| {
870 (
871 "nothing to do: give content, old/new, or delete".to_string(),
872 vec![],
873 )
874 })?;
875 match &e.entity {
876 Some(name) => {
877 if !exists {
878 return Err(("the file does not exist; create it with content alone".into(), vec![]));
879 }
880 let target = find(path, src, name, e.kind.as_deref(), e.ordinal)?;
881 let (start, end) = span(src, &target);
882 check_version(name, &src[start..end], e.expect.as_deref())?;
883 let body = if src[..start].ends_with([' ', '\t']) { fit(content, &indent_at(src, start)) } else { content.trim_matches('\n').to_string() };
884 let body = if uses_tabs(src) { tabify(&body) } else { body };
885 let out = format!("{}{body}{}", &src[..start], &src[end..]);
886 if !e.allow_signature_change {
887 identity_kept(path, &target, &src[start..end], &out, start, body.len())?;
888 }
889 Ok(out)
890 }
891 None if !exists => Ok(content.to_string()),
892 None => Err((
893 "the file exists: name the entity to replace, use old/new, or after/before/append to add".into(),
894 vec![],
895 )),
896 }
897}
898
899fn uses_tabs(src: &str) -> bool {
906 let (tabs, spaces) = src.lines().fold((0, 0), |(t, sp), l| {
907 (
908 t + l.starts_with('\t') as usize,
909 sp + l.starts_with(" ") as usize,
910 )
911 });
912 tabs > spaces
913}
914
915fn tabify(text: &str) -> String {
917 text.lines()
918 .map(|l| {
919 let lead = l.len() - l.trim_start_matches(' ').len();
920 if lead == 0 {
921 l.to_string()
922 } else {
923 format!(
924 "{}{}{}",
925 "\t".repeat(lead / 4),
926 " ".repeat(lead % 4),
927 &l[lead..]
928 )
929 }
930 })
931 .collect::<Vec<_>>()
932 .join("\n")
933}
934
935fn fit(content: &str, indent: &str) -> String {
936 let content = content.trim_matches('\n');
937 let lines: Vec<&str> = content.lines().collect();
938 let width = |l: &str| l.len() - l.trim_start_matches([' ', '\t']).len();
939 let Some(first) = lines.iter().position(|l| !l.trim().is_empty()) else {
940 return String::new();
941 };
942 let base = width(lines[first]);
943 let target = indent.len();
944 let unit = if indent.contains('\t') { "\t" } else { " " };
945 let last = lines
950 .iter()
951 .rev()
952 .find(|l| !l.trim().is_empty())
953 .copied()
954 .unwrap_or("");
955 let closing = matches!(
956 last.trim(),
957 "}" | "};" | "})" | ")" | "]" | "end" | "});" | "},"
958 );
959 let relative = base == 0
960 && target > 0
961 && lines.len() > first + 1
962 && if closing {
963 width(last) < target
964 } else {
965 width(last) <= target
966 };
967 let final_form = base == 0 && !relative;
971 let mut out = Vec::with_capacity(lines.len());
972 for (i, l) in lines.iter().enumerate() {
973 if i == first {
974 out.push(l.trim_start().to_string());
975 } else if l.trim().is_empty() {
976 out.push(String::new());
977 } else if final_form && width(l) == 0 {
978 out.push(format!("{indent}{}", l.trim_start()));
979 } else {
980 let w = width(l) as isize;
981 let shift = if relative {
984 target as isize
985 } else if base > 0 {
986 target as isize - base as isize
987 } else {
988 0
989 };
990 let n = (w + shift).max(0) as usize;
991 out.push(format!("{}{}", unit.repeat(n), l.trim_start()));
992 }
993 }
994 out.join("\n")
995}
996
997fn placed_under(path: &str, before: &str, after: &str, parent: Option<&str>) -> Result<(), Fail> {
1000 let keys = |src: &str| {
1001 let all = entities(path, src);
1002 all.iter()
1003 .filter(|e| !e.name.is_empty())
1004 .map(|e| {
1005 let p = e
1006 .parent_id
1007 .as_ref()
1008 .and_then(|id| all.iter().find(|x| &x.id == id))
1009 .map(|x| x.name.clone());
1010 (p, e.name.clone())
1011 })
1012 .collect::<Vec<_>>()
1013 };
1014 let old = keys(before);
1015 let mut new = keys(after);
1016 for k in &old {
1017 if let Some(i) = new.iter().position(|n| n == k) {
1018 new.remove(i);
1019 }
1020 }
1021 let added_names: Vec<&String> = new.iter().map(|(_, n)| n).collect();
1022 for (p, name) in &new {
1024 if p.as_ref().is_some_and(|p| added_names.contains(&p)) {
1025 continue;
1026 }
1027 if p.as_deref() != parent {
1028 let wanted = parent
1029 .map(|p| format!("in {p}"))
1030 .unwrap_or_else(|| "at the top level".into());
1031 let got = p
1032 .as_ref()
1033 .map(|p| format!("inside {p}"))
1034 .unwrap_or_else(|| "at the top level".into());
1035 return Err((
1036 format!("{name} would land {got}, not {wanted}: check the content's indentation"),
1037 vec![],
1038 ));
1039 }
1040 }
1041 Ok(())
1042}
1043
1044fn identity_kept(
1047 path: &str,
1048 old: &SemanticEntity,
1049 old_text: &str,
1050 out: &str,
1051 start: usize,
1052 len: usize,
1053) -> Result<(), Fail> {
1054 let all = entities(path, out);
1055 let here = all
1056 .iter()
1057 .filter(|n| {
1058 let (s, t) = span(out, n);
1059 s >= start.saturating_sub(1) && s <= start + 1 && t <= start + len + 1
1060 })
1061 .find(|n| n.parent_id.is_some() == old.parent_id.is_some());
1062 let advice = "set allow_signature_change if that is intended";
1063 match here {
1064 None if !all
1065 .iter()
1066 .any(|n| n.name == old.name && n.entity_type == old.entity_type) =>
1067 {
1068 Err((
1069 format!(
1070 "the new content no longer defines {} {}; {advice}",
1071 old.entity_type, old.name
1072 ),
1073 vec![],
1074 ))
1075 }
1076 Some(n) if n.name != old.name || n.entity_type != old.entity_type => Err((
1077 format!(
1078 "the replace turns {} {} into {} {}; {advice}",
1079 old.entity_type, old.name, n.entity_type, n.name
1080 ),
1081 vec![],
1082 )),
1083 Some(n) if visibility(old_text) != visibility(&n.content) => Err((
1084 format!(
1085 "the replace changes {}'s visibility ({:?} to {:?}); {advice}",
1086 old.name,
1087 visibility(old_text),
1088 visibility(&n.content)
1089 ),
1090 vec![],
1091 )),
1092 _ => Ok(()),
1093 }
1094}
1095
1096fn visibility(text: &str) -> Vec<&str> {
1098 const WORDS: &[&str] = &[
1099 "export",
1100 "pub",
1101 "pub(crate)",
1102 "public",
1103 "private",
1104 "protected",
1105 "internal",
1106 ];
1107 let head = text
1108 .lines()
1109 .find(|l| {
1110 let t = l.trim_start();
1111 !t.is_empty()
1112 && !t.starts_with('@')
1113 && !t.starts_with("#[")
1114 && !t.starts_with("//")
1115 && !t.starts_with('#')
1116 })
1117 .unwrap_or("");
1118 head.split_whitespace()
1119 .take_while(|w| !w.contains('(') || w.starts_with("pub("))
1120 .filter(|w| WORDS.contains(w))
1121 .collect()
1122}
1123
1124pub fn version(source: &str) -> String {
1127 let mut h: u32 = 0x811c9dc5;
1128 for b in source.trim_end_matches('\n').bytes() {
1129 h = (h ^ b as u32).wrapping_mul(0x01000193);
1130 }
1131 format!("v:{h:08x}")
1132}
1133
1134fn check_version(name: &str, source: &str, expect: Option<&str>) -> Result<(), Fail> {
1135 let Some(want) = expect.map(str::trim).filter(|w| !w.is_empty()) else {
1136 return Ok(());
1137 };
1138 let want = if want.starts_with("v:") {
1139 want.to_string()
1140 } else {
1141 format!("v:{want}")
1142 };
1143 let now = version(source);
1144 if now == want {
1145 return Ok(());
1146 }
1147 Err((format!("{name} changed since you read it ({want}, now {now}): read it again and redo the edit on the current version"), vec![]))
1148}
1149
1150fn replace_text(
1151 src: &str,
1152 start: usize,
1153 end: usize,
1154 old: &str,
1155 new: &str,
1156 all: bool,
1157 entity: Option<&str>,
1158) -> Result<String, Fail> {
1159 if old.is_empty() {
1160 return Err(("old is empty".into(), vec![]));
1161 }
1162 let scope = &src[start..end];
1163 let n = scope.matches(old).count();
1164 let place = entity.map(|e| format!(" in {e}")).unwrap_or_default();
1165 if n == 0 {
1166 let hint = if scope.contains(old.trim()) {
1167 " (it matches with different surrounding whitespace)"
1168 } else {
1169 ""
1170 };
1171 return Err((format!("old text not found{place}{hint}"), vec![]));
1172 }
1173 if n > 1 && !all {
1174 return Err((
1175 format!("old text matches {n} times{place}: add context to make it unique, or set all"),
1176 vec![],
1177 ));
1178 }
1179 let replaced = if all {
1180 scope.replace(old, new)
1181 } else {
1182 scope.replacen(old, new, 1)
1183 };
1184 Ok(format!("{}{replaced}{}", &src[..start], &src[end..]))
1185}
1186
1187fn code_twins(root: &Path, name: &str, home: &str, line: usize) -> Vec<String> {
1190 let (code, _) = mentions(root, name, &[]);
1191 let mut out = Vec::new();
1192 for f in code {
1193 let Ok(src) = std::fs::read_to_string(root.join(&f)) else {
1194 continue;
1195 };
1196 for e in entities(&f, &src) {
1197 if e.name == name && !(f == home && e.start_line == line) {
1198 out.push(format!("{f}:{}", e.start_line));
1199 }
1200 }
1201 }
1202 out.truncate(8);
1203 out
1204}
1205
1206fn is_text_doc(path: &str) -> bool {
1208 let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
1209 matches!(
1210 ext.as_str(),
1211 "md" | "mdx" | "markdown" | "rst" | "txt" | "adoc"
1212 )
1213}
1214
1215fn word_hits<'a>(text: &'a str, word: &'a str) -> impl Iterator<Item = (usize, &'a str)> + 'a {
1217 let is_word = |c: char| c.is_alphanumeric() || c == '_';
1218 text.match_indices(word).filter(move |(i, _)| {
1219 !text[..*i].chars().next_back().is_some_and(is_word)
1220 && !text[i + word.len()..].chars().next().is_some_and(is_word)
1221 })
1222}
1223
1224fn replace_words(text: &str, from: &str, to: &str) -> String {
1225 let mut out = String::with_capacity(text.len());
1226 let mut at = 0;
1227 for (i, _) in word_hits(text, from).collect::<Vec<_>>() {
1228 out.push_str(&text[at..i]);
1229 out.push_str(to);
1230 at = i + from.len();
1231 }
1232 out.push_str(&text[at..]);
1233 out
1234}
1235
1236fn is_identifier(s: &str) -> bool {
1237 let mut c = s.chars();
1238 c.next()
1239 .is_some_and(|f| f.is_alphabetic() || f == '_' || f == '$')
1240 && c.all(|x| x.is_alphanumeric() || x == '_' || x == '$')
1241}
1242
1243fn mentions(root: &Path, word: &str, scope: &[String]) -> (Vec<String>, Vec<String>) {
1245 let mut cmd = std::process::Command::new("git");
1246 cmd.arg("-C").arg(root).args([
1247 "grep",
1248 "-l",
1249 "-w",
1250 "-I",
1251 "--untracked",
1252 "-F",
1253 "-e",
1254 word,
1255 "--",
1256 ]);
1257 for g in scope {
1258 cmd.arg(format!(":(glob){g}"));
1259 }
1260 let Ok(out) = cmd.output() else {
1261 return (vec![], vec![]);
1262 };
1263 let (mut code, mut other) = (Vec::new(), Vec::new());
1264 for f in String::from_utf8_lossy(&out.stdout).lines() {
1265 let f = f.to_string();
1266 if sem_core::parser::plugins::code::language_config_for_content("", &f).is_some() {
1267 code.push(f);
1268 } else {
1269 other.push(f);
1270 }
1271 }
1272 (code, other)
1273}
1274
1275#[allow(clippy::too_many_arguments)]
1283fn rename_identifiers(
1284 path: &str,
1285 src: &str,
1286 from: &str,
1287 to: &str,
1288 target: Option<&SemanticEntity>,
1289 module: &str,
1290 comments: bool,
1291 member: bool,
1292) -> String {
1293 use sem_core::parser::plugins::code::{language_config_for_content, parse_tree};
1294 let Some(tree) = language_config_for_content(src, path).and_then(|c| parse_tree(c, src)) else {
1295 return src.to_string();
1296 };
1297 let others: Vec<(usize, usize)> = entities(path, src)
1299 .iter()
1300 .filter(|e| e.name == from && target.is_none_or(|t| t.start_line != e.start_line))
1301 .map(|e| span(src, e))
1302 .collect();
1303 let target_span = target.map(|t| span(src, t));
1304 const MEMBER: &[&str] = &[
1305 "attribute",
1306 "member_expression",
1307 "field_expression",
1308 "selector_expression",
1309 "navigation_expression",
1310 "member_access_expression",
1311 "field_access",
1312 "property_access_expression",
1313 ];
1314 let mut hits = Vec::new();
1315 let mut cursor = tree.walk();
1316 let mut stack = vec![tree.root_node()];
1317 while let Some(n) = stack.pop() {
1318 if n.child_count() > 0 {
1319 stack.extend(n.children(&mut cursor));
1320 continue;
1321 }
1322 let k = n.kind();
1323 if comments && k.contains("comment") {
1324 for (i, _) in word_hits(&src[n.byte_range()], from) {
1325 let at = n.start_byte() + i;
1326 hits.push(at..at + from.len());
1327 }
1328 continue;
1329 }
1330 if !(k.contains("identifier") || k == "name" || k == "constant")
1331 || &src[n.byte_range()] != from
1332 {
1333 continue;
1334 }
1335 if let Some(p) = n.parent() {
1336 if !member && MEMBER.contains(&p.kind()) && p.child(0).is_some_and(|o| o.id() != n.id())
1339 {
1340 let object = p.child(0).map(|o| &src[o.byte_range()]).unwrap_or("");
1341 if object != module {
1342 continue;
1343 }
1344 }
1345 }
1346 let at = n.start_byte();
1348 let defines_other = others.iter().any(|&(s, t)| {
1349 at >= s
1350 && at <= t
1351 && src[s..at].lines().count() <= 3
1352 && !target_span.is_some_and(|(ts, tt)| at >= ts && at <= tt && ts >= s && tt <= t)
1353 });
1354 if defines_other && is_definition_name(&n) {
1355 continue;
1356 }
1357 hits.push(n.byte_range());
1358 }
1359 hits.sort_by_key(|r| r.start);
1360 let mut out = String::with_capacity(src.len());
1361 let mut at = 0;
1362 for r in hits {
1363 out.push_str(&src[at..r.start]);
1364 out.push_str(to);
1365 at = r.end;
1366 }
1367 out.push_str(&src[at..]);
1368 out
1369}
1370
1371fn is_definition_name(n: &tree_sitter::Node) -> bool {
1374 let Some(p) = n.parent() else { return false };
1375 let k = p.kind();
1376 let definition = k.contains("definition")
1377 || k.contains("declaration")
1378 || k.contains("function_item")
1379 || k.contains("method")
1380 || k.ends_with("_item")
1381 || k == "property_signature"
1382 || k == "pair";
1383 definition
1384 && p.child_by_field_name("name")
1385 .is_some_and(|c| c.id() == n.id())
1386}
1387
1388fn entities(path: &str, src: &str) -> Vec<SemanticEntity> {
1390 REGISTRY
1391 .get_explicit_plugin(path)
1392 .map(|p| p.extract_entities(src, path))
1393 .unwrap_or_default()
1394}
1395
1396fn label(e: &SemanticEntity, all: &[SemanticEntity]) -> String {
1397 let parent = e
1398 .parent_id
1399 .as_ref()
1400 .and_then(|id| all.iter().find(|p| &p.id == id))
1401 .map(|p| format!("{}.", p.name))
1402 .unwrap_or_default();
1403 format!(
1404 "{} {parent}{} (line {})",
1405 e.entity_type, e.name, e.start_line
1406 )
1407}
1408
1409fn reverted(root: &Path, path: &str, rev: &str, plain: &Plain) -> Result<String, Fail> {
1419 let name = plain
1420 .entity
1421 .as_deref()
1422 .ok_or_else(|| ("revert needs the entity to put back".to_string(), vec![]))?;
1423 if plain.content.is_some()
1424 || plain.old.is_some()
1425 || plain.delete
1426 || plain.after.is_some()
1427 || plain.before.is_some()
1428 || plain.into.is_some()
1429 || plain.append
1430 {
1431 return Err(("revert replaces the entity with its text at that ref: drop content, old/new, delete and the insert options".to_string(), vec![]));
1432 }
1433 let out = std::process::Command::new("git")
1434 .arg("-C")
1435 .arg(root)
1436 .args(["show", &format!("{rev}:./{path}")])
1437 .output()
1438 .map_err(|e| {
1439 (
1440 format!("could not run git to read {path} at {rev}: {e}"),
1441 vec![],
1442 )
1443 })?;
1444 if !out.status.success() {
1445 let why = String::from_utf8_lossy(&out.stderr);
1446 return Err((
1447 format!("{path} does not exist at {rev}: {}", why.trim()),
1448 vec![],
1449 ));
1450 }
1451 let then = String::from_utf8_lossy(&out.stdout);
1452 find(path, &then, name, plain.kind.as_deref(), plain.ordinal)
1453 .map(|e| e.content)
1454 .map_err(|(m, c)| (format!("{name} did not exist in {path} at {rev} ({m})"), c))
1455}
1456
1457fn find(
1458 path: &str,
1459 src: &str,
1460 name: &str,
1461 kind: Option<&str>,
1462 ordinal: Option<usize>,
1463) -> Result<SemanticEntity, Fail> {
1464 let all = entities(path, src);
1465 if all.is_empty() {
1466 let why = if REGISTRY.get_explicit_plugin(path).is_some() {
1467 "has no entities"
1468 } else {
1469 "is not a language weave parses"
1470 };
1471 return Err((format!("{path} {why}: edit it with old/new"), vec![]));
1472 }
1473 let parent_of = |e: &SemanticEntity| {
1474 e.parent_id
1475 .as_ref()
1476 .and_then(|id| all.iter().find(|p| &p.id == id))
1477 .map(|p| p.name.clone())
1478 };
1479 let mut matches: Vec<&SemanticEntity> = all.iter().filter(|e| e.name == name).collect();
1480 if matches.is_empty() {
1481 if let Some((parent, member)) = name.rsplit_once("::").or_else(|| name.rsplit_once('.')) {
1482 matches = all
1483 .iter()
1484 .filter(|e| e.name == member && parent_of(e).as_deref() == Some(parent))
1485 .collect();
1486 }
1487 }
1488 if let Some(k) = kind {
1489 matches.retain(|e| e.entity_type == k);
1490 }
1491 let pick = match (matches.len(), ordinal) {
1492 (0, _) => {
1493 let leaf = name
1494 .rsplit(['.', ':'])
1495 .next()
1496 .unwrap_or(name)
1497 .to_lowercase();
1498 let mut near: Vec<String> = all
1499 .iter()
1500 .filter(|e| {
1501 e.name.to_lowercase().contains(&leaf) || leaf.contains(&e.name.to_lowercase())
1502 })
1503 .map(|e| label(e, &all))
1504 .collect();
1505 near.truncate(10);
1506 return Err((format!("no entity named {name} in {path}"), near));
1507 }
1508 (_, Some(i)) => *matches.get(i).ok_or_else(|| {
1509 (
1510 format!("{name}: ordinal {i} but only {} match", matches.len()),
1511 vec![],
1512 )
1513 })?,
1514 (1, None) => matches[0],
1515 (n, None) => {
1516 const PICK: &str = "pick one with kind, Parent.name or ordinal";
1517 let why = format!("{name} matches {n} entities in {path}: {PICK}");
1518 return Err((why, matches.iter().map(|e| label(e, &all)).collect()));
1519 }
1520 };
1521 Ok(pick.clone())
1522}
1523
1524fn span(src: &str, e: &SemanticEntity) -> (usize, usize) {
1526 if let (Some(s), Some(t)) = (e.start_byte, e.end_byte) {
1527 if s <= t && t <= src.len() && src.is_char_boundary(s) && src.is_char_boundary(t) {
1528 return (s, t);
1529 }
1530 }
1531 let starts: Vec<usize> = std::iter::once(0)
1532 .chain(src.match_indices('\n').map(|(i, _)| i + 1))
1533 .collect();
1534 let s = starts
1535 .get(e.start_line.saturating_sub(1))
1536 .copied()
1537 .unwrap_or(src.len());
1538 let s = s + (src[s..].len() - src[s..].trim_start_matches([' ', '\t']).len());
1539 let t = starts.get(e.end_line).map(|i| i - 1).unwrap_or(src.len());
1540 (s, t.max(s))
1541}
1542
1543fn leading_start(src: &str, start: usize) -> usize {
1547 let mut at = line_start(src, start);
1548 if !src[at..start].trim().is_empty() {
1549 return start;
1550 }
1551 while at > 0 {
1552 let prev = line_start(src, at - 1);
1553 let t = src[prev..at].trim();
1554 let owned = t.starts_with("///")
1555 || t.starts_with("//!")
1556 || t.starts_with("#[")
1557 || t.starts_with("#![")
1558 || t.starts_with('@')
1559 || t.starts_with("/**")
1560 || t.starts_with("* ")
1561 || t == "*"
1562 || t.starts_with("*/")
1563 || t.starts_with("//")
1564 || t.starts_with("# ") && false;
1565 if t.is_empty() || !owned {
1566 break;
1567 }
1568 at = prev;
1569 }
1570 at
1571}
1572
1573fn line_start(src: &str, at: usize) -> usize {
1574 src[..at].rfind('\n').map(|i| i + 1).unwrap_or(0)
1575}
1576
1577fn line_end(src: &str, at: usize) -> usize {
1578 src[at..].find('\n').map(|i| at + i).unwrap_or(src.len())
1579}
1580
1581fn indent_at(src: &str, at: usize) -> String {
1582 let ls = line_start(src, at);
1583 let lead = &src[ls..at];
1584 if lead.trim().is_empty() {
1585 lead.to_string()
1586 } else {
1587 String::new()
1588 }
1589}
1590
1591fn gap_before(src: &str, start: usize) -> String {
1595 let before = &src[..line_start(src, start)];
1596 let newlines = before.len() - before.trim_end_matches('\n').len();
1597 "\n".repeat(newlines.clamp(2, 3))
1598}
1599
1600pub fn review_external(root: &Path, files: &[(String, Option<String>)]) -> Vec<String> {
1606 let mut planned = Vec::new();
1607 let mut out = Vec::new();
1608 for (file, before) in files {
1609 let Ok(path) = normalize(root, file) else {
1610 continue;
1611 };
1612 let Ok(after) = std::fs::read_to_string(root.join(&path)) else {
1613 continue;
1614 };
1615 let before = before.clone().or_else(|| {
1616 std::process::Command::new("git")
1617 .args(["show", &format!("HEAD:{path}")])
1618 .current_dir(root)
1619 .output()
1620 .ok()
1621 .filter(|o| o.status.success())
1622 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
1623 });
1624 if let Some(why) = parses_worse(&path, before.as_deref().unwrap_or(""), &after) {
1625 out.push(why.replace("; nothing was written", ""));
1626 }
1627 planned.push((path, before, after));
1628 }
1629 out.extend(review(root, &planned, &[]));
1630 speculate(root);
1631 out
1632}
1633
1634fn signature(content: &str) -> String {
1637 let lines: Vec<&str> = content
1638 .lines()
1639 .skip_while(|l| {
1640 let t = l.trim_start();
1641 t.starts_with('@') || t.starts_with("#[")
1642 })
1643 .collect();
1644 let text = lines.join("\n");
1645 let cut = ["{", ":\n", "=>", " where "]
1646 .iter()
1647 .filter_map(|m| text.find(m))
1648 .min()
1649 .unwrap_or(text.len().min(400));
1650 text[..cut].split_whitespace().collect::<Vec<_>>().join(" ")
1651}
1652
1653fn review(
1656 root: &Path,
1657 files: &[(String, Option<String>, String)],
1658 renamed: &[String],
1659) -> Vec<String> {
1660 let mut changed: Vec<(String, String, &'static str)> = Vec::new(); for (path, disk, now) in files {
1662 let Some(disk) = disk else { continue };
1663 let before = entities(path, disk);
1664 let after = entities(path, now);
1665 for b in &before {
1666 if b.name.len() < 3
1667 || renamed.contains(&b.name)
1668 || !matches!(
1669 b.entity_type.as_str(),
1670 "function"
1671 | "method"
1672 | "class"
1673 | "struct"
1674 | "interface"
1675 | "trait"
1676 | "type"
1677 | "enum"
1678 | "constructor"
1679 )
1680 {
1681 continue;
1682 }
1683 match after
1684 .iter()
1685 .find(|a| a.name == b.name && a.entity_type == b.entity_type)
1686 {
1687 None => changed.push((b.name.clone(), path.clone(), "was deleted")),
1688 Some(a) if signature(&a.content) != signature(&b.content) => {
1689 changed.push((b.name.clone(), path.clone(), "changed its signature"))
1690 }
1691 _ => {}
1692 }
1693 }
1694 }
1695 let edited: Vec<&str> = files.iter().map(|(p, _, _)| p.as_str()).collect();
1696 let mut out = Vec::new();
1697 for (name, home, why) in changed {
1698 let Ok(o) = std::process::Command::new("git")
1699 .arg("-C")
1700 .arg(root)
1701 .args(["grep", "-n", "-w", "-I", "--untracked", "-F", "-e", &name])
1702 .output()
1703 else {
1704 continue;
1705 };
1706 let sites: Vec<String> = String::from_utf8_lossy(&o.stdout)
1707 .lines()
1708 .filter(|l| {
1709 sem_core::parser::plugins::code::language_config_for_content(
1710 "",
1711 l.split(':').next().unwrap_or(""),
1712 )
1713 .is_some()
1714 })
1715 .filter(|l| {
1716 let text = l.splitn(3, ':').nth(2).unwrap_or("").trim_start();
1717 let defines = [
1719 format!("def {name}"),
1720 format!("fn {name}"),
1721 format!("function {name}"),
1722 format!("class {name}"),
1723 format!("func {name}("),
1724 format!(") {name}("),
1725 ]
1726 .iter()
1727 .any(|d| {
1728 text.contains(d.as_str())
1729 && (text.starts_with("def ")
1730 || text.starts_with("fn ")
1731 || text.starts_with("pub ")
1732 || text.starts_with("func ")
1733 || text.starts_with("function ")
1734 || text.starts_with("class ")
1735 || text.starts_with("export ")
1736 || text.starts_with("async "))
1737 });
1738 let comment = ["//", "#", "*", "/*", "--"]
1739 .iter()
1740 .any(|c| text.starts_with(c));
1741 !defines && !comment
1742 })
1743 .map(|l| {
1744 let mut parts = l.splitn(3, ':');
1745 let (f, n, t) = (
1746 parts.next().unwrap_or(""),
1747 parts.next().unwrap_or(""),
1748 parts.next().unwrap_or("").trim(),
1749 );
1750 let mark = if edited.contains(&f) {
1751 " (file edited in this batch)"
1752 } else {
1753 ""
1754 };
1755 format!("{f}:{n}: {t}{mark}")
1756 })
1757 .collect();
1758 if sites.is_empty() {
1759 continue;
1760 }
1761 let more = sites.len().saturating_sub(20);
1762 let tail = if more > 0 {
1763 format!("\n ... and {more} more")
1764 } else {
1765 String::new()
1766 };
1767 out.push(format!(
1768 "{name} ({home}) {why}; still used at:\n {}{tail}",
1769 sites.into_iter().take(20).collect::<Vec<_>>().join("\n ")
1770 ));
1771 }
1772 out
1773}
1774
1775fn report(path: &str, before: Option<&str>, after: &str) -> FileReport {
1776 let key = |e: &SemanticEntity, all: &[SemanticEntity]| {
1777 let parent = e
1778 .parent_id
1779 .as_ref()
1780 .and_then(|id| all.iter().find(|p| &p.id == id))
1781 .map(|p| format!("{}.", p.name))
1782 .unwrap_or_default();
1783 format!("{parent}{}", e.name)
1784 };
1785 let index = |src: &str| {
1786 let all = entities(path, src);
1787 let mut m: BTreeMap<String, String> = BTreeMap::new();
1788 for e in &all {
1789 if !e.name.is_empty() {
1790 m.insert(key(e, &all), e.content.clone());
1791 }
1792 }
1793 m
1794 };
1795 let a = before.map(index).unwrap_or_default();
1796 let b = index(after);
1797 let mut r = FileReport {
1798 file: path.to_string(),
1799 created: before.is_none(),
1800 added: vec![],
1801 modified: vec![],
1802 deleted: vec![],
1803 };
1804 for (k, v) in &b {
1805 match a.get(k) {
1806 None => r.added.push(k.clone()),
1807 Some(old) if old != v => r.modified.push(k.clone()),
1808 _ => {}
1809 }
1810 }
1811 r.deleted = a.keys().filter(|k| !b.contains_key(*k)).cloned().collect();
1812 let touched: Vec<String> = r
1814 .added
1815 .iter()
1816 .chain(&r.modified)
1817 .chain(&r.deleted)
1818 .cloned()
1819 .collect();
1820 r.modified
1821 .retain(|k| !touched.iter().any(|t| t.starts_with(&format!("{k}."))));
1822 r
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828
1829 const PY: &str = "import os\n\n\ndef a():\n return 1\n\n\nclass K:\n def m(self):\n return 2\n\n def n(self):\n return 3\n\n\ndef b():\n return a()\n";
1830
1831 fn scratch() -> PathBuf {
1832 static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1833 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1834 let d = std::env::temp_dir().join(format!("weave-edit-test-{}-{n}", std::process::id()));
1835 let _ = std::fs::remove_dir_all(&d);
1836 std::fs::create_dir_all(&d).unwrap();
1837 d
1838 }
1839
1840 fn edit(f: impl FnOnce(&mut Edit)) -> Edit {
1841 let mut e = Edit {
1842 file: "x.py".into(),
1843 ..Default::default()
1844 };
1845 f(&mut e);
1846 e
1847 }
1848
1849 fn go(src: &str, edits: Vec<Edit>) -> Result<(String, Report), EditError> {
1850 let d = scratch();
1851 if !src.is_empty() {
1852 std::fs::write(d.join("x.py"), src).unwrap();
1853 }
1854 let r = run(&d, &edits, false)?;
1855 Ok((
1856 std::fs::read_to_string(d.join("x.py")).unwrap_or_default(),
1857 r,
1858 ))
1859 }
1860
1861 #[test]
1864 fn revert_restores_one_entity_from_history() {
1865 let d = scratch();
1866 let git = |args: &[&str]| {
1867 let ok = std::process::Command::new("git")
1868 .arg("-C")
1869 .arg(&d)
1870 .args(args)
1871 .output()
1872 .unwrap()
1873 .status
1874 .success();
1875 assert!(ok, "git {args:?}");
1876 };
1877 git(&["init", "-q"]);
1878 git(&["config", "user.email", "t@t"]);
1879 git(&["config", "user.name", "t"]);
1880 std::fs::write(d.join("x.py"), PY).unwrap();
1881 git(&["add", "."]);
1882 git(&["commit", "-qm", "base"]);
1883 let now = PY
1884 .replace("return 1\n", "return 10\n")
1885 .replace("return a()", "return a() + 1");
1886 std::fs::write(d.join("x.py"), &now).unwrap();
1887 let r = run(
1888 &d,
1889 &[edit(|e| {
1890 e.entity = Some(EntityRef::from("a"));
1891 e.revert = Some("HEAD".into());
1892 })],
1893 false,
1894 )
1895 .unwrap();
1896 let out = std::fs::read_to_string(d.join("x.py")).unwrap();
1897 assert_eq!(
1898 out,
1899 PY.replace("return a()", "return a() + 1"),
1900 "only `a` goes back"
1901 );
1902 assert_eq!(r.files[0].modified, vec!["a"]);
1903 let err = run(
1904 &d,
1905 &[edit(|e| {
1906 e.entity = Some(EntityRef::from("nope"));
1907 e.revert = Some("HEAD".into());
1908 })],
1909 false,
1910 )
1911 .unwrap_err();
1912 assert!(
1913 err.message.contains("did not exist in x.py at HEAD"),
1914 "{}",
1915 err.message
1916 );
1917 }
1918
1919 #[test]
1920 fn replaces_entities_and_members_by_name() {
1921 let (out, r) = go(
1922 PY,
1923 vec![
1924 edit(|e| {
1925 e.entity = Some(EntityRef::from("a"));
1926 e.content = Some("def a():\n return 10\n".into());
1927 }),
1928 edit(|e| {
1929 e.entity = Some(EntityRef::from("K.n"));
1930 e.content = Some("def n(self):\n return 30".into());
1931 }),
1932 ],
1933 )
1934 .unwrap();
1935 assert_eq!(
1936 out,
1937 PY.replace("return 1\n", "return 10\n")
1938 .replace("return 3", "return 30")
1939 );
1940 assert_eq!(r.files[0].modified, vec!["K.n", "a"]);
1941 }
1942
1943 #[test]
1944 fn text_edits_are_scoped_and_must_be_unique() {
1945 let (out, _) = go(
1946 PY,
1947 vec![edit(|e| {
1948 e.entity = Some(EntityRef::from("m"));
1949 e.old = Some("return".into());
1950 e.new = Some("yield".into());
1951 })],
1952 )
1953 .unwrap();
1954 assert!(out.contains("yield 2") && out.contains("return 3"));
1955 let err = go(
1956 PY,
1957 vec![edit(|e| {
1958 e.old = Some("return".into());
1959 e.new = Some("x".into());
1960 })],
1961 )
1962 .unwrap_err();
1963 assert!(err.message.contains("4 times"), "{}", err.message);
1964 }
1965
1966 #[test]
1967 fn adds_deletes_and_creates() {
1968 let (out, r) = go(
1969 PY,
1970 vec![
1971 edit(|e| {
1972 e.after = Some("a".into());
1973 e.content = Some("def c():\n return 0".into());
1974 }),
1975 edit(|e| {
1976 e.after = Some("m".into());
1977 e.content = Some("def p(self):\n return 5".into());
1978 }),
1979 edit(|e| {
1980 e.entity = Some(EntityRef::from("b"));
1981 e.delete = true;
1982 }),
1983 ],
1984 )
1985 .unwrap();
1986 assert!(
1987 out.contains(" return 1\n\n\ndef c():\n return 0\n\n\nclass K:"),
1988 "{out}"
1989 );
1990 assert!(
1991 out.contains(" return 2\n\n def p(self):\n return 5\n\n def n"),
1992 "{out}"
1993 );
1994 assert!(out.ends_with(" return 3\n"), "{out:?}");
1995 assert_eq!(r.files[0].added, vec!["K.p", "c"]);
1996 assert_eq!(r.files[0].deleted, vec!["b"]);
1997 let (out, r) = go("", vec![edit(|e| e.content = Some("x = 1\n".into()))]).unwrap();
1998 assert_eq!(out, "x = 1\n");
1999 assert!(r.files[0].created);
2000 }
2001
2002 #[test]
2003 fn a_failing_edit_writes_nothing() {
2004 let d = scratch();
2005 std::fs::write(d.join("x.py"), PY).unwrap();
2006 let edits = vec![
2007 edit(|e| {
2008 e.entity = Some(EntityRef::from("a"));
2009 e.content = Some("def a():\n pass".into());
2010 }),
2011 edit(|e| {
2012 e.entity = Some(EntityRef::from("nope"));
2013 e.delete = true;
2014 }),
2015 ];
2016 let err = run(&d, &edits, false).unwrap_err();
2017 assert_eq!(err.index, 1);
2018 assert_eq!(std::fs::read_to_string(d.join("x.py")).unwrap(), PY);
2019 assert!(normalize(&d, "../etc/passwd").is_err());
2020 }
2021
2022 #[test]
2023 fn guards_refuse_broken_or_renamed_entities() {
2024 let pi: Vec<Edit> = serde_json::from_str(r#"[{"file":"x.py","op":"insert_after","entity":{"name":"m","parent_name":"K"},"content":"def q(self):\n return 9"}]"#).unwrap();
2025 let (out, _) = go(PY, pi).unwrap();
2026 assert!(
2027 out.contains(" def q(self):\n return 9\n"),
2028 "{out}"
2029 );
2030 let broken = go(
2031 PY,
2032 vec![edit(|e| {
2033 e.entity = Some(EntityRef::from("a"));
2034 e.content = Some("def a(:\n return 1".into());
2035 })],
2036 )
2037 .unwrap_err();
2038 assert!(broken.message.contains("unparseable"), "{}", broken.message);
2039 let renamed = go(
2040 PY,
2041 vec![edit(|e| {
2042 e.entity = Some(EntityRef::from("a"));
2043 e.content = Some("def z():\n return 1".into());
2044 })],
2045 )
2046 .unwrap_err();
2047 assert!(
2048 renamed.message.contains("allow_signature_change"),
2049 "{}",
2050 renamed.message
2051 );
2052 let (out, _) = go(
2053 PY,
2054 vec![edit(|e| {
2055 e.entity = Some(EntityRef::from("a"));
2056 e.content = Some("def z():\n return 1".into());
2057 e.allow_signature_change = true;
2058 })],
2059 )
2060 .unwrap();
2061 assert!(out.contains("def z():"));
2062 }
2063
2064 #[test]
2065 fn rename_touches_identifiers_not_strings() {
2066 let d = scratch();
2067 let git = |a: &[&str]| {
2068 std::process::Command::new("git")
2069 .arg("-C")
2070 .arg(&d)
2071 .args(a)
2072 .output()
2073 .unwrap()
2074 };
2075 git(&["init", "-q"]);
2076 std::fs::write(d.join("x.py"), PY).unwrap();
2077 std::fs::write(
2078 d.join("y.py"),
2079 "from x import a\n\nprint(a(), \"a\", aa) # a\n",
2080 )
2081 .unwrap();
2082 std::fs::write(d.join("notes.md"), "call a() first\n").unwrap();
2083 git(&["add", "."]);
2084 let e: Vec<Edit> =
2085 serde_json::from_str(r#"[{"file":"x.py","entity":"a","rename":"first"}]"#).unwrap();
2086 let r = run(&d, &e, false).unwrap();
2087 assert!(
2088 std::fs::read_to_string(d.join("x.py"))
2089 .unwrap()
2090 .contains("def first():")
2091 && std::fs::read_to_string(d.join("x.py"))
2092 .unwrap()
2093 .contains("return first()")
2094 );
2095 assert_eq!(
2096 std::fs::read_to_string(d.join("y.py")).unwrap(),
2097 "from x import first\n\nprint(first(), \"a\", aa) # a\n"
2098 );
2099 assert!(r.notes[0].contains("notes.md"), "{:?}", r.notes);
2100 }
2101
2102 #[test]
2103 fn into_adds_a_last_member() {
2104 let (out, r) = go(
2105 PY,
2106 vec![edit(|e| {
2107 e.into = Some("K".into());
2108 e.content = Some("def z(self):\n return 0".into());
2109 })],
2110 )
2111 .unwrap();
2112 assert!(
2113 out.contains(" return 3\n\n def z(self):\n return 0\n\n\ndef b"),
2114 "{out}"
2115 );
2116 assert_eq!(r.files[0].added, vec!["K.z"]);
2117 }
2118
2119 #[test]
2120 fn inserted_content_is_reindented_whichever_way_it_is_written() {
2121 for content in [
2122 " def z(self):\n return 0",
2123 "def z(self):\n return 0",
2124 "def z(self):\n return 0",
2125 ] {
2126 let (out, _) = go(
2127 PY,
2128 vec![edit(|e| {
2129 e.after = Some("K.m".into());
2130 e.content = Some(content.into());
2131 })],
2132 )
2133 .unwrap();
2134 assert!(
2135 out.contains(" return 2\n\n def z(self):\n return 0\n\n def n"),
2136 "{content:?}\n{out}"
2137 );
2138 }
2139 }
2140
2141 #[test]
2142 fn expect_refuses_an_entity_changed_since_it_was_read() {
2143 let v = version("def a():\n return 1");
2144 go(
2145 PY,
2146 vec![edit(|e| {
2147 e.entity = Some(EntityRef::from("a"));
2148 e.old = Some("1".into());
2149 e.new = Some("2".into());
2150 e.expect = Some(v.clone());
2151 })],
2152 )
2153 .unwrap();
2154 let err = go(
2155 PY,
2156 vec![edit(|e| {
2157 e.entity = Some(EntityRef::from("a"));
2158 e.old = Some("1".into());
2159 e.new = Some("2".into());
2160 e.expect = Some("v:00000000".into());
2161 })],
2162 )
2163 .unwrap_err();
2164 assert!(
2165 err.message.contains("changed since you read it"),
2166 "{}",
2167 err.message
2168 );
2169 }
2170
2171 #[test]
2172 fn rename_leaves_members_and_other_definitions_alone() {
2173 let d = scratch();
2174 let git = |a: &[&str]| {
2175 std::process::Command::new("git")
2176 .arg("-C")
2177 .arg(&d)
2178 .args(a)
2179 .output()
2180 .unwrap()
2181 };
2182 git(&["init", "-q"]);
2183 std::fs::write(d.join("cells.py"), "def cell_len(s):\n return len(s)\n").unwrap();
2184 std::fs::write(d.join("text.py"), "from cells import cell_len\nimport cells\n\n\nclass Text:\n @property\n def cell_len(self):\n return cell_len(self.s)\n\n\ndef w(t):\n return t.cell_len + cells.cell_len(\"x\")\n").unwrap();
2185 git(&["add", "."]);
2186 let e: Vec<Edit> = serde_json::from_str(
2187 r#"[{"file":"cells.py","entity":"cell_len","rename":"cell_width"}]"#,
2188 )
2189 .unwrap();
2190 run(&d, &e, false).unwrap();
2191 assert_eq!(
2192 std::fs::read_to_string(d.join("cells.py")).unwrap(),
2193 "def cell_width(s):\n return len(s)\n"
2194 );
2195 assert_eq!(
2196 std::fs::read_to_string(d.join("text.py")).unwrap(),
2197 "from cells import cell_width\nimport cells\n\n\nclass Text:\n @property\n def cell_len(self):\n return cell_width(self.s)\n\n\ndef w(t):\n return t.cell_len + cells.cell_width(\"x\")\n"
2198 );
2199 }
2200
2201 #[test]
2202 fn signature_changes_list_the_call_sites_left() {
2203 let d = scratch();
2204 let git = |a: &[&str]| {
2205 std::process::Command::new("git")
2206 .arg("-C")
2207 .arg(&d)
2208 .args(a)
2209 .output()
2210 .unwrap()
2211 };
2212 git(&["init", "-q"]);
2213 std::fs::write(
2214 d.join("x.py"),
2215 "def area():\n return 1\n\n\ndef total():\n return area()\n",
2216 )
2217 .unwrap();
2218 std::fs::write(d.join("y.py"), "from x import area\n\nprint(area())\n").unwrap();
2219 git(&["add", "."]);
2220 let e: Vec<Edit> = serde_json::from_str(
2221 r#"[{"file":"x.py","entity":"area","content":"def area(n):\n return n"}]"#,
2222 )
2223 .unwrap();
2224 let r = run(&d, &e, false).unwrap();
2225 assert!(
2226 r.review.len() == 1
2227 && r.review[0].contains("y.py:3: print(area())")
2228 && r.review[0].contains("x.py:6: return area() (file edited in this batch)"),
2229 "{:?}",
2230 r.review
2231 );
2232 }
2233
2234 #[test]
2235 fn insert_before_and_delete_keep_doc_comments_with_their_entity() {
2236 let rs = "impl V {\n /// Is it an array?\n #[inline]\n pub fn is_array(&self) -> bool {\n true\n }\n}\n";
2237 let d = scratch();
2238 std::fs::write(d.join("v.rs"), rs).unwrap();
2239 let e: Vec<Edit> = serde_json::from_str(r#"[{"file":"v.rs","before":"is_array","content":"/// Its length.\npub fn len(&self) -> usize {\n 0\n }"}]"#).unwrap();
2240 run(&d, &e, false).unwrap();
2241 let out = std::fs::read_to_string(d.join("v.rs")).unwrap();
2242 assert!(out.contains(" /// Its length.\n pub fn len(&self) -> usize {\n 0\n }\n\n /// Is it an array?\n #[inline]\n pub fn is_array"), "{out}");
2243 let e: Vec<Edit> =
2244 serde_json::from_str(r#"[{"file":"v.rs","entity":"is_array","delete":true}]"#).unwrap();
2245 run(&d, &e, false).unwrap();
2246 let out = std::fs::read_to_string(d.join("v.rs")).unwrap();
2247 assert!(
2248 !out.contains("Is it an array")
2249 && !out.contains("#[inline]")
2250 && out.contains("pub fn len"),
2251 "{out}"
2252 );
2253 }
2254
2255 #[test]
2256 fn rename_everywhere_also_updates_comments_and_docs() {
2257 let d = scratch();
2258 let git = |a: &[&str]| {
2259 std::process::Command::new("git")
2260 .arg("-C")
2261 .arg(&d)
2262 .args(a)
2263 .output()
2264 .unwrap()
2265 };
2266 git(&["init", "-q"]);
2267 std::fs::write(
2268 d.join("x.py"),
2269 "def area():\n # area is cached\n return \"area\"\n",
2270 )
2271 .unwrap();
2272 std::fs::write(d.join("README.md"), "Call `area()` (not areas).\n").unwrap();
2273 git(&["add", "."]);
2274 let e: Vec<Edit> = serde_json::from_str(
2275 r#"[{"file":"x.py","entity":"area","rename":"size","everywhere":true}]"#,
2276 )
2277 .unwrap();
2278 run(&d, &e, false).unwrap();
2279 assert_eq!(
2280 std::fs::read_to_string(d.join("x.py")).unwrap(),
2281 "def size():\n # size is cached\n return \"area\"\n"
2282 );
2283 assert_eq!(
2284 std::fs::read_to_string(d.join("README.md")).unwrap(),
2285 "Call `size()` (not areas).\n"
2286 );
2287 }
2288
2289 #[test]
2290 fn method_renames_follow_calls_and_tabbed_files_stay_tabbed() {
2291 let d = scratch();
2292 let git = |a: &[&str]| {
2293 std::process::Command::new("git")
2294 .arg("-C")
2295 .arg(&d)
2296 .args(a)
2297 .output()
2298 .unwrap()
2299 };
2300 git(&["init", "-q"]);
2301 std::fs::write(d.join("c.go"), "package c\n\ntype C struct{}\n\nfunc (c *C) IsAvail() bool {\n\treturn true\n}\n\nfunc use(c *C) bool {\n\treturn c.IsAvail()\n}\n").unwrap();
2302 git(&["add", "."]);
2303 let e: Vec<Edit> = serde_json::from_str(r#"[{"file":"c.go","entity":"IsAvail","rename":"IsAvailable"},{"file":"c.go","after":"IsAvailable","content":"// Deprecated: use IsAvailable.\nfunc (c *C) IsAvail() bool {\n return c.IsAvailable()\n}"}]"#).unwrap();
2304 run(&d, &e, false).unwrap();
2305 let out = std::fs::read_to_string(d.join("c.go")).unwrap();
2306 assert!(
2307 out.contains("\treturn c.IsAvailable()\n}\n\nfunc use"),
2308 "{out}"
2309 );
2310 assert!(
2311 out.contains("func (c *C) IsAvail() bool {\n\treturn c.IsAvailable()\n}"),
2312 "{out}"
2313 );
2314 }
2315}