Skip to main content

weave_cli/
edit.rs

1//! `weave edit`: how an agent changes code, addressed by entity.
2//!
3//! An edit names a function, class or method and says what becomes of it:
4//! replace it, change one piece of text inside it, delete it, or add a new
5//! entity before or after it. Text edits without an entity cover everything
6//! else (imports, module-level statements, files weave has no grammar for),
7//! and an edit to a file that does not exist creates it.
8//!
9//! Every edit is resolved against the file as it is on disk now, so nothing
10//! has to be read first and nothing another agent changed elsewhere in the
11//! file is overwritten. A text edit's `old` must match exactly once (or set
12//! `all`), which is what keeps a stale edit from landing somewhere else.
13//!
14//! A batch is all or nothing: every edit is planned in memory, in order, and
15//! files are written only when all of them resolved.
16
17use 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    /// The file, relative to the repository root.
30    pub file: String,
31    /// The entity to change: a function, class, method or key, by name.
32    /// `Parent.name` (or `Parent::name`) picks a member of one class. Also
33    /// takes pi-sem's form, `{name, entity_type?, parent_name?, ordinal?}`.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub entity: Option<EntityRef>,
36    /// pi-sem's verb: `replace`, `insert_after`, `insert_before` or `delete`,
37    /// each about `entity`. The same edits are also spelled with `content`,
38    /// `after`, `before` and `delete` directly.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub op: Option<String>,
41    /// The entity's kind (function, method, class...) when names collide.
42    #[serde(
43        default,
44        alias = "entity_type",
45        skip_serializing_if = "Option::is_none"
46    )]
47    pub kind: Option<String>,
48    /// 0-based occurrence among entities that still match, in file order.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub ordinal: Option<usize>,
51    /// The new source. With `entity`: the entity's whole new text. With
52    /// `after`, `before` or `append`: the entity to add. Alone, for a file that
53    /// does not exist: the new file.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub content: Option<String>,
56    /// Text to replace, inside `entity` when one is named, else in the whole
57    /// file. Must match exactly once unless `all` is set.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub old: Option<String>,
60    /// What `old` becomes.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub new: Option<String>,
63    /// Replace every occurrence of `old`, not exactly one.
64    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
65    pub all: bool,
66    /// Add `content` as a new entity right after this one.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub after: Option<String>,
69    /// Add `content` as a new entity right before this one.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub before: Option<String>,
72    /// Add `content` at the end of the file.
73    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
74    pub append: bool,
75    /// Add `content` as the last member of this class (or other container),
76    /// so a new method needs no anchor and nothing read first.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub into: Option<String>,
79    /// Delete `entity`.
80    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
81    pub delete: bool,
82    /// Let a replace rename the entity, change its kind or its visibility
83    /// (`export`, `pub`, `public`...). Refused otherwise, since that is how an
84    /// edit meant for one entity silently becomes a different one.
85    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
86    pub allow_signature_change: bool,
87    /// Rename `entity` (defined in `file`) to this name, and every identifier
88    /// spelled like it in the code files of `scope`. Strings and comments are
89    /// left alone; files without a grammar that mention it are listed.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub rename: Option<String>,
92    /// The entity's version as sem showed it (`v:…`): the edit is refused if
93    /// the entity changed since, so two agents never overwrite each other's
94    /// version of one function.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub expect: Option<String>,
97    /// For `rename`: also rename the name where it appears as a whole word in
98    /// comments and in docs and other text files (Markdown, reStructuredText,
99    /// plain text). String literals are still left alone.
100    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101    pub everywhere: bool,
102    /// For `rename`: the files to rename in, as git pathspec globs
103    /// (`src/**`, `*.py`). Default: the whole repository.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub scope: Vec<String>,
106    /// Put `entity` back the way it was at this git ref (`HEAD~3`, a branch,
107    /// a commit): its text at that ref replaces its text now, and nothing else
108    /// in the file moves. A revert is a replace whose content comes from
109    /// history, so every replace guard still applies.
110    #[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/// An edit in one spelling: the entity as `Parent.name`, `op` folded into
136/// `content`/`after`/`before`/`delete`.
137#[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 every identifier `.0` to `.1` in this file.
153    rename_ident: Option<(String, String)>,
154    /// For a rename: also comments, and whole words in a text file.
155    everywhere: bool,
156    /// For a rename: the entity is a member (a method or field), so `x.name`
157    /// is a use of it rather than of something else spelled the same.
158    member: bool,
159    /// For a rename: the file that defines the renamed entity.
160    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/// What a batch did to one file, entity by entity.
232#[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    /// Things the edits did not do that the agent may want to: e.g. files
247    /// without a grammar that still mention a renamed name.
248    #[serde(skip_serializing_if = "Vec::is_empty")]
249    pub notes: Vec<String>,
250    /// Entities whose signature changed or that were deleted, with every
251    /// place their name is still used: the call sites to update in the next
252    /// edit rather than discover from a failing test.
253    #[serde(skip_serializing_if = "Vec::is_empty")]
254    pub review: Vec<String>,
255    /// The output of the `then` command, run once the edits were written.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub then: Option<String>,
258}
259
260/// Why a batch was refused. Nothing was written.
261#[derive(Debug, Clone, Serialize, thiserror::Error)]
262#[error("edit {index} ({file}): {message}")]
263pub struct EditError {
264    /// Which edit of the batch, 0-based.
265    pub index: usize,
266    pub file: String,
267    pub message: String,
268    /// When a name was ambiguous or missing: the entities that could be meant.
269    #[serde(skip_serializing_if = "Vec::is_empty")]
270    pub candidates: Vec<String>,
271}
272
273/// Apply `edits` under `root`. With `dry_run`, plan and report only.
274///
275/// Edits to one file apply in order; different files are planned in
276/// parallel. Nothing is written unless every edit resolved and no edited
277/// file parses worse than it did before.
278pub fn run(root: &Path, edits: &[Edit], dry_run: bool) -> Result<Report, EditError> {
279    // Agents editing one checkout at once: each batch reads, plans and
280    // writes under one lock per repository, so a batch always applies to
281    // the files as the previous batch left them and no edit is lost.
282    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    // the earliest failing edit is the one to report
405    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    // what a rename left behind, so the agent need not grep to find out:
423    // the old name in strings, comments and docs, or another entity's name
424    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
476/// One file's edits, in order: (path, content on disk, content after).
477fn 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
511/// Does `after` have parse errors `before` did not? Only for languages with
512/// a grammar; the answer names the first new error's line.
513fn 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
551/// The sem binary: `SEM_BIN`, else `sem` on PATH.
552fn 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
559/// `<git common dir>/sem-run/last`: the build or test command the agent ran
560/// last through `sem check --run`, and the directory it ran it in.
561fn 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
573/// Start the agent's last test command on the tree just written, in the
574/// background. Agents follow an edit by rerunning the same command after
575/// thinking for some seconds; by then this run is done or under way, and
576/// `sem check --run` joins it instead of starting over.
577pub 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
601/// Run `cmd` through `sem check --run` once the edits are written, so an edit
602/// and its check take one call; it joins the speculative run if there is one.
603pub 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
644/// `file` as a path relative to `root`, refusing anything outside it.
645fn 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        // in the defining file, the target's own definition is renamed and
678        // every other entity spelled the same is left alone
679        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            // before the anchor's doc comments and attributes, not between
807            // them and the definition they document
808            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            // whole lines: drop the entity's lines and one blank line around it
851            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
899/// `content` as text to place where `indent` already stands: the first line
900/// without its indentation, the rest at the depth the file needs. Accepts the
901/// three ways agents write a nested entity: every line at its final depth,
902/// only the body at its final depth (sem's form), or every line relative to
903/// the entity.
904/// Does this file indent with tabs?
905fn 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
915/// Leading runs of spaces as tabs, four to a tab, for a file indented with tabs.
916fn 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    // Which form, when the first line is not indented: the last line tells.
946    // A closing line (`}`, `)`, `end`) stands at the entity's own depth, so
947    // at the target it is the final form and at zero the relative one; a
948    // body line is in its final form only when deeper than the target.
949    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    // in the final form, lines written at column zero after the first are the
968    // entity's own header (a signature after its doc comment): bring them to
969    // the target depth
970    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            // relative: shift by the depth; every line at its final depth:
982            // re-base on the target; sem's form (base 0, body final): as is
983            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
997/// An added entity must land where it was meant to: a sibling of its anchor,
998/// or a member of the class it was added into.
999fn 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    // the outermost new entities: those whose parent is not itself new
1023    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
1044/// A replace must leave an entity of the same name, kind and visibility
1045/// where the old one was.
1046fn 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
1096/// The visibility words in front of a definition.
1097fn 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
1124/// sem's entity version: `v:` + FNV-1a (32 bits) of the source without
1125/// trailing newlines.
1126pub 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
1187/// Other definitions spelled `name` in the repository's code (not the one at
1188/// `home:line`), as `file:line`.
1189fn 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
1206/// Docs and other prose files a rename with `everywhere` also updates.
1207fn 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
1215/// Byte offsets of `word` in `text` where it stands as a whole word.
1216fn 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
1243/// Files that mention `word`: (code files weave parses, other files).
1244fn 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/// Rename identifier tokens spelled `from`: tree-sitter leaves whose kind is
1276/// an identifier, so strings, comments and longer names are never touched.
1277/// Two kinds of same-spelled identifiers are someone else's and are skipped:
1278/// a member access (`obj.name`, unless `obj` is the defining module, as in
1279/// `cells.cell_len`), and the name of another definition (a method of the
1280/// same name in some class). `target` is the renamed entity when `src` is
1281/// the file that defines it.
1282#[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    // other definitions spelled `from`: their name tokens are not renamed
1298    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            // obj.name: someone else's member unless obj is the module, or the
1337            // renamed entity is itself a member (then obj.name is its call)
1338            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        // the name token of another definition spelled the same
1347        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
1371/// Is this identifier the name a definition introduces (`def name`, `fn
1372/// name`, `class name`, a method or property name)?
1373fn 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
1388/// Every entity in the file, at every depth.
1389fn 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
1409/// The text `plain`'s entity had at git ref `rev`: what a revert writes back.
1410///
1411/// History is read, never trusted to still line up with the file: the entity
1412/// is found by name in the file as it was at `rev`, so a function that moved
1413/// within the file since is still reverted to the right text. The result goes
1414/// through the ordinary replace path, so the identity guard (same name, kind
1415/// and visibility unless `allow_signature_change`), `expect`, the parse guard
1416/// and the review of stranded callers all apply to a revert as they would to
1417/// a hand-written replace.
1418fn 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
1524/// The entity's byte range in `src`.
1525fn 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
1543/// Where an entity's leading doc comments, attributes and decorators begin:
1544/// the lines directly above `start` (no blank line between) that belong to
1545/// it. Inserting before an entity, or deleting it, must not split them off.
1546fn 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
1591/// The line breaks to put between two neighbouring entities: as many blank
1592/// lines as already sit in front of `start` (one or two), plus the newline
1593/// that ends the previous line.
1594fn 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
1600/// Review an edit another tool made (an agent's own edit or patch tool):
1601/// for each `(file, content before the edit)` (`None` before: the file is new
1602/// or its content at HEAD stands in), what the edit broke in the parse and
1603/// which callers a signature change or deletion left behind. Then start the
1604/// agent's last test command on the new tree, as after a weave edit.
1605pub 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
1634/// A definition's signature: its text up to where the body starts, with
1635/// decorators and attributes dropped and whitespace collapsed.
1636fn 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
1653/// For each entity the batch deleted or gave a new signature: the places its
1654/// name is still used, from `git grep`, after the batch was written.
1655fn 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(); // (name, file, why)
1661    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                // the definition itself, and mentions in comments, are not uses
1718                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    // a changed member changes its class's text too: name the member only
1813    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    /// A revert puts one entity back as it was at a ref and leaves every
1862    /// other entity — including ones edited since — as it is now.
1863    #[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}