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 result of the `then` command, run once the edits were written.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub then: Option<Then>,
258}
259
260/// What the `then` command did.
261#[derive(Debug, Clone, Serialize)]
262pub struct Then {
263    /// Its exit code; absent when it could not be run or did not finish.
264    pub exit_code: Option<i32>,
265    /// Its stdout then stderr, or why it did not run: the last
266    /// [`THEN_OUTPUT_CAP`] bytes, since a failure is reported at the end.
267    pub output: String,
268    /// Whether `output` was cut to its tail.
269    #[serde(skip_serializing_if = "std::ops::Not::not")]
270    pub truncated: bool,
271}
272
273/// How much of a `then` command's output comes back.
274pub const THEN_OUTPUT_CAP: usize = 16 * 1024;
275
276/// How long a `then` command may run before it is stopped.
277const THEN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(600);
278
279/// Why a batch was refused. Nothing was written.
280#[derive(Debug, Clone, Serialize, thiserror::Error)]
281#[error("edit {index} ({file}): {message}")]
282pub struct EditError {
283    /// Which edit of the batch, 0-based.
284    pub index: usize,
285    pub file: String,
286    pub message: String,
287    /// When a name was ambiguous or missing: the entities that could be meant.
288    #[serde(skip_serializing_if = "Vec::is_empty")]
289    pub candidates: Vec<String>,
290}
291
292/// Apply `edits` under `root`. With `dry_run`, plan and report only.
293///
294/// Edits to one file apply in order; different files are planned in
295/// parallel. Nothing is written unless every edit resolved and no edited
296/// file parses worse than it did before.
297pub fn run(root: &Path, edits: &[Edit], dry_run: bool) -> Result<Report, EditError> {
298    // Agents editing one checkout at once: each batch reads, plans and
299    // writes under one lock per repository, so a batch always applies to
300    // the files as the previous batch left them and no edit is lost.
301    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    // the earliest failing edit is the one to report
424    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    // what a rename left behind, so the agent need not grep to find out:
442    // the old name in strings, comments and docs, or another entity's name
443    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
495/// One file's edits, in order: (path, content on disk, content after).
496fn 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
530/// Does `after` have parse errors `before` did not? Only for languages with
531/// a grammar; the answer names the first new error's line.
532fn 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    // A clean result cannot introduce more parse errors, regardless of the
556    // original file. Avoid parsing the old source on the common clean path.
557    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
576/// The sem binary: `SEM_BIN`, else `sem` on PATH.
577fn 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
584/// `<git common dir>/sem-run/last`: the build or test command the agent ran
585/// last through `sem check --run`, and the directory it ran it in.
586fn 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
598/// Start the agent's last test command on the tree just written, in the
599/// background. Agents follow an edit by rerunning the same command after
600/// thinking for some seconds; by then this run is done or under way, and
601/// `sem check --run` joins it instead of starting over.
602pub 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
626/// Whether `bin` names a program that exists: a path as given, else a
627/// lookup on `PATH`.
628fn 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
636/// Run `cmd` once the edits are written, so an edit and its check take one
637/// call: through `sem check --run` when sem is installed (it joins the
638/// speculative run if there is one), else with `sh -c` in the repo root.
639/// Bounded by [`THEN_DEADLINE`]; output is kept to its last
640/// [`THEN_OUTPUT_CAP`] bytes.
641pub 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
706/// `file` as a path relative to `root`, refusing anything outside it.
707fn 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        // in the defining file, the target's own definition is renamed and
740        // every other entity spelled the same is left alone
741        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            // before the anchor's doc comments and attributes, not between
869            // them and the definition they document
870            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            // whole lines: drop the entity's lines and one blank line around it
913            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
961/// `content` as text to place where `indent` already stands: the first line
962/// without its indentation, the rest at the depth the file needs. Accepts the
963/// three ways agents write a nested entity: every line at its final depth,
964/// only the body at its final depth (sem's form), or every line relative to
965/// the entity.
966/// Does this file indent with tabs?
967fn 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
977/// Leading runs of spaces as tabs, four to a tab, for a file indented with tabs.
978fn 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    // Which form, when the first line is not indented: the last line tells.
1008    // A closing line (`}`, `)`, `end`) stands at the entity's own depth, so
1009    // at the target it is the final form and at zero the relative one; a
1010    // body line is in its final form only when deeper than the target.
1011    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    // in the final form, lines written at column zero after the first are the
1030    // entity's own header (a signature after its doc comment): bring them to
1031    // the target depth
1032    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            // relative: shift by the depth; every line at its final depth:
1044            // re-base on the target; sem's form (base 0, body final): as is
1045            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
1059/// An added entity must land where it was meant to: a sibling of its anchor,
1060/// or a member of the class it was added into.
1061fn 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    // the outermost new entities: those whose parent is not itself new
1085    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
1106/// A replace must leave an entity of the same name, kind and visibility
1107/// where the old one was.
1108fn 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
1158/// The visibility words in front of a definition.
1159fn 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
1186/// sem's entity version: `v:` + FNV-1a (32 bits) of the source without
1187/// trailing newlines.
1188pub 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
1249/// Other definitions spelled `name` in the repository's code (not the one at
1250/// `home:line`), as `file:line`.
1251fn 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
1268/// Docs and other prose files a rename with `everywhere` also updates.
1269fn 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
1277/// Byte offsets of `word` in `text` where it stands as a whole word.
1278fn 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
1305/// Files that mention `word`: (code files weave parses, other files).
1306fn 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/// Rename identifier tokens spelled `from`: tree-sitter leaves whose kind is
1338/// an identifier, so strings, comments and longer names are never touched.
1339/// Two kinds of same-spelled identifiers are someone else's and are skipped:
1340/// a member access (`obj.name`, unless `obj` is the defining module, as in
1341/// `cells.cell_len`), and the name of another definition (a method of the
1342/// same name in some class). `target` is the renamed entity when `src` is
1343/// the file that defines it.
1344#[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    // other definitions spelled `from`: their name tokens are not renamed
1360    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            // obj.name: someone else's member unless obj is the module, or the
1399            // renamed entity is itself a member (then obj.name is its call)
1400            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        // the name token of another definition spelled the same
1409        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
1433/// Is this identifier the name a definition introduces (`def name`, `fn
1434/// name`, `class name`, a method or property name)?
1435fn 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
1450/// Every entity in the file, at every depth.
1451fn 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
1471/// The text `plain`'s entity had at git ref `rev`: what a revert writes back.
1472///
1473/// History is read, never trusted to still line up with the file: the entity
1474/// is found by name in the file as it was at `rev`, so a function that moved
1475/// within the file since is still reverted to the right text. The result goes
1476/// through the ordinary replace path, so the identity guard (same name, kind
1477/// and visibility unless `allow_signature_change`), `expect`, the parse guard
1478/// and the review of stranded callers all apply to a revert as they would to
1479/// a hand-written replace.
1480fn 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
1586/// The entity's byte range in `src`.
1587fn 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
1605/// Where an entity's leading doc comments, attributes and decorators begin:
1606/// the lines directly above `start` (no blank line between) that belong to
1607/// it. Inserting before an entity, or deleting it, must not split them off.
1608fn 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
1653/// The line breaks to put between two neighbouring entities: as many blank
1654/// lines as already sit in front of `start` (one or two), plus the newline
1655/// that ends the previous line.
1656fn 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
1662/// Review an edit another tool made (an agent's own edit or patch tool):
1663/// for each `(file, content before the edit)` (`None` before: the file is new
1664/// or its content at HEAD stands in), what the edit broke in the parse and
1665/// which callers a signature change or deletion left behind. Then start the
1666/// agent's last test command on the new tree, as after a weave edit.
1667pub 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
1696/// A definition's signature: its text up to where the body starts, with
1697/// decorators and attributes dropped and whitespace collapsed.
1698fn 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
1715/// For each entity the batch deleted or gave a new signature: the places its
1716/// name is still used, from `git grep`, after the batch was written.
1717fn 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(); // (name, file, why)
1723    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                // the definition itself, and mentions in comments, are not uses
1780                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    // a changed member changes its class's text too: name the member only
1875    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    /// A revert puts one entity back as it was at a ref and leaves every
1934    /// other entity — including ones edited since — as it is now.
1935    #[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}