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