Skip to main content

code_kb_core/
edit_check.rs

1//! The post-edit check: which symbols changed shape against a base revision, and which sites of
2//! each the diff did not reach.
3//!
4//! The base symbols come from a scan of the base revision of the changed files into a side index
5//! under `<root>/.code-kb/check/<pid>/`, which is removed when the check ends. The current symbols
6//! and the sites come from the main index, after the changed files are refreshed in it.
7
8mod arms;
9mod fields;
10mod git;
11mod session;
12mod signatures;
13
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::path::{Path, PathBuf};
16use std::sync::{Mutex, PoisonError};
17use std::time::{Duration, Instant, SystemTime};
18
19use rusqlite::{Connection, OptionalExtension, params};
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use tracing::warn;
23
24use crate::db::{DbError, ensure_fts_index_path, open_read_only};
25use crate::formatters::displayed_symbol_name;
26use crate::models::{
27    ConstructionSite, LabeledSite, ReferenceReport, SiteLabel, Symbol, VariantUse,
28    is_generated_lambda_name,
29};
30use crate::queries::{QueryError, escape_like, load_file_symbols};
31use crate::reference_report::arity::{self, Arity};
32use crate::reference_report::{
33    destructurings, enum_variant_names, inherited_group_arity, reference_report_for_edit,
34    uses_of_variants,
35};
36use crate::sync::{self, SyncError, ensure_fresh_file};
37use crate::workspace::{Workspace, is_hard_excluded};
38use arms::Verdict;
39use fields::FieldChange;
40use git::{ChangedFile, Git, Hunks};
41pub use session::{
42    SessionLock, SessionState, is_session_id, latest_session_base, lock_session, prune_sessions,
43    read_session, record_session_base, write_session,
44};
45use signatures::{Difference, Shape};
46
47#[derive(Debug, Error)]
48pub enum EditCheckError {
49    #[error("not a git repository: {0}")]
50    NotARepository(String),
51    #[error("git revision `{0}` does not exist in this repository")]
52    UnknownRevision(String),
53    #[error("{0}")]
54    Git(String),
55    #[error(transparent)]
56    Io(#[from] std::io::Error),
57    #[error(transparent)]
58    Sync(#[from] SyncError),
59    #[error(transparent)]
60    Db(#[from] DbError),
61    #[error(transparent)]
62    Query(#[from] QueryError),
63}
64
65/// The commit `HEAD` names in the repository that holds `root`: `None` outside a repository and
66/// before the first commit.
67pub fn head_commit(root: &Path) -> Option<String> {
68    Git::open(root).ok()?.resolve("HEAD").ok().flatten()
69}
70
71/// Where the base revision of a check comes from.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum BaseSource {
75    Given,
76    Session,
77    Head,
78}
79
80/// The revision a check compares the working tree with.
81#[derive(Debug, Clone, Copy)]
82pub enum BaseRequest<'a> {
83    Given(&'a str),
84    Session(&'a str),
85    Head,
86}
87
88/// The cuts that bound one check.
89#[derive(Debug, Clone, Copy)]
90pub struct EditLimits {
91    pub files: usize,
92    pub symbols: usize,
93    pub time: Duration,
94}
95
96impl Default for EditLimits {
97    fn default() -> Self {
98        Self {
99            files: 200,
100            symbols: 10,
101            time: Duration::from_secs(20),
102        }
103    }
104}
105
106/// One site of a changed symbol: where it is, the function that holds it, and a note on the
107/// argument count it passes.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct EditSite {
110    pub path: String,
111    pub line: usize,
112    pub caller: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub note: Option<String>,
115}
116
117/// A symbol whose shape changed, with its sites sorted by whether the diff reached them.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ShapeChange {
120    pub kind: String,
121    pub display: String,
122    pub path: String,
123    pub line: usize,
124    pub change: String,
125    pub in_diff: Vec<EditSite>,
126    pub proven: Vec<EditSite>,
127    pub likely: Vec<EditSite>,
128    pub unresolved: usize,
129}
130
131/// The answer of one check. `diff_hash` names the state of the changed files, so a caller can
132/// tell a repeated check of the same diff.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct EditCheck {
135    pub rev: String,
136    pub source: BaseSource,
137    pub files_changed: usize,
138    pub changes: Vec<ShapeChange>,
139    pub tests: Vec<String>,
140    pub cuts: Vec<String>,
141    pub diff_hash: String,
142}
143
144const CALLABLE_KINDS: [&str; 3] = ["function", "method", "constructor"];
145const TYPE_KINDS: [&str; 6] = ["class", "struct", "interface", "trait", "enum", "record"];
146const FIELD_OWNER_KINDS: [&str; 3] = ["class", "struct", "record"];
147const FIELD_KINDS: [&str; 2] = ["field", "property"];
148const ATTRIBUTE_WRITE_PREFIXES: [&str; 3] = ["self.", "this.", "cls."];
149const SIGNATURE_MODIFIERS: [&str; 30] = [
150    "pub",
151    "public",
152    "private",
153    "protected",
154    "internal",
155    "static",
156    "final",
157    "abstract",
158    "async",
159    "override",
160    "virtual",
161    "sealed",
162    "open",
163    "export",
164    "default",
165    "unsafe",
166    "extern",
167    "inline",
168    "const",
169    "readonly",
170    "synchronized",
171    "native",
172    "partial",
173    "new",
174    "suspend",
175    "tailrec",
176    "operator",
177    "infix",
178    "external",
179    "mutating",
180];
181const CALL_ARGUMENTS_SQL: &str = "SELECT json_extract(metadata_json, '$.arg_count'),
182            json_extract(metadata_json, '$.arg_spread') IS NOT NULL
183     FROM identifiers
184     WHERE name = ?1 AND kind = 'call' AND (path = ?2 OR path = ?3) AND start_line = ?4
185       AND json_valid(metadata_json)";
186const OWNED_METHODS_SQL: &str = "SELECT name, path, start_line, end_line FROM symbols
187     WHERE parent_symbol_id = ?1 AND kind IN ('method', 'function', 'constructor')
188     ORDER BY path, start_line";
189const STALE_CHECK_AGE: Duration = Duration::from_secs(3600);
190const MAX_TESTS: usize = 50;
191const MAX_EXTENT_LINES: usize = 80;
192
193static CHECK_LOCK: Mutex<()> = Mutex::new(());
194
195struct Budget {
196    started: Instant,
197    limit: Duration,
198}
199
200impl Budget {
201    fn spent(&self) -> bool {
202        self.started.elapsed() >= self.limit
203    }
204
205    fn cut(&self, before: &str) -> String {
206        format!(
207            "Stopped at the {} s time budget before {before}.",
208            self.limit.as_secs()
209        )
210    }
211}
212
213struct Base {
214    diff_base: String,
215    commit: Option<String>,
216    rev: String,
217    source: BaseSource,
218}
219
220fn short(sha: &str) -> String {
221    sha.chars().take(10).collect()
222}
223
224fn resolve_base(git: &Git, request: BaseRequest<'_>) -> Result<Base, EditCheckError> {
225    let named = |rev: &str, source: BaseSource, shown: String| {
226        let sha = (!rev.is_empty() && !rev.starts_with('-'))
227            .then(|| git.resolve(rev))
228            .transpose()?
229            .flatten()
230            .ok_or_else(|| EditCheckError::UnknownRevision(rev.to_string()))?;
231        Ok(Base {
232            diff_base: sha.clone(),
233            commit: Some(sha),
234            rev: shown,
235            source,
236        })
237    };
238    match request {
239        BaseRequest::Given(rev) => named(rev, BaseSource::Given, rev.to_string()),
240        BaseRequest::Session(rev) => named(rev, BaseSource::Session, short(rev)),
241        BaseRequest::Head => match git.resolve("HEAD")? {
242            Some(sha) => Ok(Base {
243                rev: short(&sha),
244                diff_base: sha.clone(),
245                commit: Some(sha),
246                source: BaseSource::Head,
247            }),
248            None => Ok(Base {
249                diff_base: git.empty_tree()?,
250                commit: None,
251                rev: "no commits".to_string(),
252                source: BaseSource::Head,
253            }),
254        },
255    }
256}
257
258/// A check up to its diff hash: the base, the changed files, and the hash of their content, with
259/// no scan of the base. A caller that remembers the hash of an earlier check can drop a repeat
260/// before [`PendingCheck::run`] pays for the scan and the report.
261pub struct PendingCheck {
262    git: Git,
263    base: Base,
264    files: Vec<ChangedFile>,
265    files_cut: Option<String>,
266    diff_hash: String,
267    limits: EditLimits,
268    started: Instant,
269}
270
271pub(crate) fn run(
272    workspace: &Workspace,
273    db_path: &Path,
274    conn: &Connection,
275    request: BaseRequest<'_>,
276    limits: EditLimits,
277) -> Result<EditCheck, EditCheckError> {
278    prepare(workspace, conn, request, limits)?.run(workspace, db_path, conn)
279}
280
281pub(crate) fn prepare(
282    workspace: &Workspace,
283    conn: &Connection,
284    request: BaseRequest<'_>,
285    limits: EditLimits,
286) -> Result<PendingCheck, EditCheckError> {
287    let started = Instant::now();
288    let root = workspace.canonical_root.as_path();
289    let git = Git::open(root)?;
290    let base = resolve_base(&git, request)?;
291    let listed = git.changed_files(&base.diff_base)?;
292    let candidates = indexed_candidates(conn, root, listed)?;
293    let (files, files_cut) = cap_files(candidates, limits.files);
294    let diff_hash = diff_hash(root, &base, &files);
295    Ok(PendingCheck {
296        git,
297        base,
298        files,
299        files_cut,
300        diff_hash,
301        limits,
302        started,
303    })
304}
305
306impl PendingCheck {
307    /// The hash of the base commit and of the content of each changed file.
308    pub fn diff_hash(&self) -> &str {
309        &self.diff_hash
310    }
311
312    /// Runs the rest of the check. `workspace` and `conn` are those that prepared it.
313    pub fn run(
314        self,
315        workspace: &Workspace,
316        db_path: &Path,
317        conn: &Connection,
318    ) -> Result<EditCheck, EditCheckError> {
319        let budget = Budget {
320            started: self.started,
321            limit: self.limits.time,
322        };
323        let _serial = CHECK_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
324        let root = workspace.canonical_root.as_path();
325        let PendingCheck {
326            git,
327            base,
328            files,
329            files_cut,
330            diff_hash,
331            limits,
332            ..
333        } = self;
334        let mut check = EditCheck {
335            rev: base.rev.clone(),
336            source: base.source,
337            files_changed: files.len(),
338            changes: Vec::new(),
339            tests: Vec::new(),
340            cuts: files_cut.into_iter().collect(),
341            diff_hash,
342        };
343        if files.is_empty() {
344            return Ok(check);
345        }
346        if budget.spent() {
347            check.cuts.push(budget.cut("the changed files were read"));
348            return Ok(check);
349        }
350
351        let hunks = git.new_side_hunks(&base.diff_base, &files)?;
352        refresh_files(workspace, db_path, conn, &files, &budget);
353        let Some(base_index) = BaseIndex::build(&git, workspace, &base, &files)? else {
354            return Ok(check);
355        };
356        if budget.spent() {
357            check.cuts.push(budget.cut("the shapes were compared"));
358            return Ok(check);
359        }
360
361        let mut candidates = Detector {
362            conn,
363            root,
364            base: &base_index,
365        }
366        .detect(&files)?;
367        if candidates.len() > limits.symbols {
368            for candidate in &mut candidates {
369                candidate.uses = name_uses(conn, &candidate.rank_name)?;
370            }
371            candidates.sort_by(|a, b| {
372                b.uses
373                    .cmp(&a.uses)
374                    .then_with(|| a.path.cmp(&b.path))
375                    .then_with(|| a.line.cmp(&b.line))
376            });
377            check.cuts.push(format!(
378                "Checked {} of {} changed symbols, those with the most uses of their name first.",
379                limits.symbols,
380                candidates.len()
381            ));
382            candidates.truncate(limits.symbols);
383        }
384
385        let mut lister = SiteLister::new(conn, root, &hunks);
386        let total = candidates.len();
387        for (checked, candidate) in candidates.into_iter().enumerate() {
388            if budget.spent() {
389                check.cuts.push(format!(
390                    "Stopped at the {} s time budget after {checked} of {total} changed symbols.",
391                    limits.time.as_secs()
392                ));
393                break;
394            }
395            match lister.list(&candidate) {
396                Ok(listed) => {
397                    for command in listed.tests {
398                        if !check.tests.contains(&command) && check.tests.len() < MAX_TESTS {
399                            check.tests.push(command);
400                        }
401                    }
402                    check.changes.push(ShapeChange {
403                        kind: candidate.kind,
404                        display: candidate.display,
405                        path: candidate.path,
406                        line: candidate.line,
407                        change: candidate.change,
408                        in_diff: listed.placed.in_diff,
409                        proven: listed.placed.proven,
410                        likely: listed.placed.likely,
411                        unresolved: listed.placed.unresolved,
412                    });
413                }
414                Err(error) => check.cuts.push(format!(
415                    "The sites of `{}` could not be listed: {error}.",
416                    candidate.display
417                )),
418            }
419        }
420        check.changes.sort_by(|a, b| {
421            a.path
422                .cmp(&b.path)
423                .then_with(|| a.line.cmp(&b.line))
424                .then_with(|| a.display.cmp(&b.display))
425        });
426        Ok(check)
427    }
428}
429
430/// The changed files the check can say anything about: not excluded from the index, not a folder
431/// (a submodule), and either in the index or of a kind the index holds.
432fn indexed_candidates(
433    conn: &Connection,
434    root: &Path,
435    files: Vec<ChangedFile>,
436) -> Result<Vec<ChangedFile>, EditCheckError> {
437    let mut kept = Vec::new();
438    for file in files {
439        let excluded = is_hard_excluded(&file.path)
440            || file.base_path.as_deref().is_some_and(is_hard_excluded)
441            || root.join(&file.path).is_dir();
442        if excluded {
443            continue;
444        }
445        let mut known = false;
446        for path in std::iter::once(file.path.as_str()).chain(file.base_path.as_deref()) {
447            known = known || file_is_indexed(conn, path)?;
448        }
449        if known || extension_is_indexed(conn, &file.path)? {
450            kept.push(file);
451        }
452    }
453    Ok(kept)
454}
455
456fn file_is_indexed(conn: &Connection, path: &str) -> Result<bool, EditCheckError> {
457    Ok(conn
458        .query_row(
459            "SELECT 1 FROM files WHERE path = ?1 OR path = ?2 LIMIT 1",
460            params![path, path.replace('/', "\\")],
461            |_| Ok(()),
462        )
463        .optional()
464        .map_err(QueryError::from)?
465        .is_some())
466}
467
468fn extension_is_indexed(conn: &Connection, path: &str) -> Result<bool, EditCheckError> {
469    let Some(extension) = Path::new(path).extension().and_then(|e| e.to_str()) else {
470        return Ok(false);
471    };
472    let pattern = format!("%.{}", escape_like(extension));
473    Ok(conn
474        .query_row(
475            "SELECT 1 FROM files WHERE path LIKE ?1 ESCAPE '\\' LIMIT 1",
476            [pattern],
477            |_| Ok(()),
478        )
479        .optional()
480        .map_err(QueryError::from)?
481        .is_some())
482}
483
484fn cap_files(mut files: Vec<ChangedFile>, limit: usize) -> (Vec<ChangedFile>, Option<String>) {
485    if files.len() <= limit {
486        return (files, None);
487    }
488    let cut = format!("Checked {limit} of {} changed files.", files.len());
489    files.truncate(limit);
490    (files, Some(cut))
491}
492
493fn diff_hash(root: &Path, base: &Base, files: &[ChangedFile]) -> String {
494    let mut hasher = blake3::Hasher::new();
495    hasher.update(base.diff_base.as_bytes());
496    for file in files {
497        hasher.update(file.path.as_bytes());
498        hasher.update(b"\0");
499        match std::fs::read(root.join(&file.path)) {
500            Ok(bytes) => hasher.update(blake3::hash(&bytes).as_bytes()),
501            Err(_) => hasher.update(b"deleted"),
502        };
503        hasher.update(b"\0");
504    }
505    hasher.finalize().to_hex().to_string()
506}
507
508fn refresh_files(
509    workspace: &Workspace,
510    db_path: &Path,
511    conn: &Connection,
512    files: &[ChangedFile],
513    budget: &Budget,
514) {
515    for file in files {
516        let moved_from = file.base_path.as_deref().filter(|old| *old != file.path);
517        for path in std::iter::once(file.path.as_str()).chain(moved_from) {
518            if budget.spent() {
519                return;
520            }
521            if let Err(error) = ensure_fresh_file(workspace, db_path, conn, path) {
522                warn!(
523                    path,
524                    "could not refresh a changed file in the index: {error}"
525                );
526            }
527        }
528    }
529}
530
531/// The index of the changed files as they were at the base revision. It lives in a folder under
532/// `.code-kb/check`, which `Drop` removes after the connection is closed.
533struct BaseIndex {
534    conn: Connection,
535    tree: PathBuf,
536    _folder: CheckFolder,
537}
538
539struct CheckFolder {
540    path: PathBuf,
541}
542
543impl Drop for CheckFolder {
544    fn drop(&mut self) {
545        let _ = std::fs::remove_dir_all(&self.path);
546        if let Some(parent) = self.path.parent() {
547            let _ = std::fs::remove_dir(parent);
548        }
549    }
550}
551
552impl BaseIndex {
553    /// Scans the base content of `files` into a side index. `None` when the base has no file the
554    /// extractor can read, so no symbol can have changed shape.
555    fn build(
556        git: &Git,
557        workspace: &Workspace,
558        base: &Base,
559        files: &[ChangedFile],
560    ) -> Result<Option<Self>, EditCheckError> {
561        let Some(commit) = base.commit.as_deref() else {
562            return Ok(None);
563        };
564        let folder = CheckFolder::create(&workspace.canonical_root)?;
565        let tree = folder.path.join("tree");
566        let mut written = 0;
567        for file in files {
568            let Some(base_path) = file.base_path.as_deref() else {
569                continue;
570            };
571            let Some(content) = git.base_blob(commit, base_path) else {
572                continue;
573            };
574            if content.iter().take(8000).any(|&byte| byte == 0) || !is_plain_relative(&file.path) {
575                continue;
576            }
577            let target = tree.join(&file.path);
578            if let Some(parent) = target.parent() {
579                std::fs::create_dir_all(parent)?;
580            }
581            std::fs::write(target, content)?;
582            written += 1;
583        }
584        if written == 0 {
585            return Ok(None);
586        }
587        let db = folder.path.join("base.db");
588        let allow_all = folder.path.join("allow.ignore");
589        let spool = folder.path.join("spool");
590        std::fs::write(&allow_all, "!*\n")?;
591        std::fs::create_dir_all(&spool)?;
592        let (tree_arg, db_arg) = (tree.to_string_lossy(), db.to_string_lossy());
593        let (allow_arg, spool_arg) = (allow_all.to_string_lossy(), spool.to_string_lossy());
594        let pid = std::process::id().to_string();
595        let mut args = vec![
596            "scan",
597            "--root",
598            &tree_arg,
599            "--db",
600            &db_arg,
601            "--level",
602            sync::EXTRACTION_LEVEL,
603            "--ignore-file",
604            &allow_arg,
605            "--spool-dir",
606            &spool_arg,
607        ];
608        if cfg!(unix) {
609            args.extend(["--parent-pid", &pid]);
610        }
611        match sync::execute_julie_extract(&args) {
612            Ok(_) => {}
613            Err(SyncError::ExtractionFailed(1, stderr))
614                if stderr.starts_with("partial") && db.exists() => {}
615            Err(error) => return Err(error.into()),
616        }
617        ensure_fts_index_path(&db)?;
618        Ok(Some(BaseIndex {
619            conn: open_read_only(&db)?,
620            tree,
621            _folder: folder,
622        }))
623    }
624}
625
626impl CheckFolder {
627    /// Creates `<root>/.code-kb/check/<pid>` empty, after removing the folders that processes
628    /// which died left behind.
629    fn create(root: &Path) -> Result<Self, EditCheckError> {
630        sync::ensure_index_dir(&root.join(".code-kb").join("check"))?;
631        let checks = root.join(".code-kb").join("check");
632        sweep_stale(&checks);
633        let path = checks.join(std::process::id().to_string());
634        if path.exists() {
635            std::fs::remove_dir_all(&path)?;
636        }
637        std::fs::create_dir_all(&path)?;
638        Ok(CheckFolder { path })
639    }
640}
641
642fn sweep_stale(checks: &Path) {
643    let Ok(entries) = std::fs::read_dir(checks) else {
644        return;
645    };
646    for entry in entries.flatten() {
647        let old = entry
648            .metadata()
649            .and_then(|meta| meta.modified())
650            .ok()
651            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
652            .is_some_and(|age| age > STALE_CHECK_AGE);
653        if old {
654            let _ = std::fs::remove_dir_all(entry.path());
655        }
656    }
657}
658
659fn is_plain_relative(path: &str) -> bool {
660    let path = Path::new(path);
661    path.is_relative()
662        && path
663            .components()
664            .all(|part| matches!(part, std::path::Component::Normal(_)))
665}
666
667/// A symbol of one side of the comparison with the names of the symbols that hold it.
668struct Row {
669    symbol: Symbol,
670    parents: Vec<String>,
671}
672
673#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
674struct Key {
675    parents: Vec<String>,
676    kind: String,
677    name: String,
678}
679
680impl Row {
681    fn key(&self) -> Key {
682        Key {
683            parents: self.parents.clone(),
684            kind: self.symbol.kind.clone(),
685            name: self.symbol.name.clone(),
686        }
687    }
688
689    /// A julie lambda is named after its line, so a line shift would read as a rename.
690    fn is_shape(&self) -> bool {
691        let symbol = &self.symbol;
692        let kind = symbol.kind.as_str();
693        (CALLABLE_KINDS.contains(&kind) || TYPE_KINDS.contains(&kind))
694            && !is_generated_lambda_name(
695                &symbol.name,
696                &symbol.language,
697                kind,
698                symbol.signature.as_deref(),
699            )
700    }
701
702    fn display(&self) -> String {
703        match self.parents.last() {
704            Some(owner) => format!("{owner}.{}", self.symbol.name),
705            None => self.symbol.name.clone(),
706        }
707    }
708}
709
710const IMPL_OWNERS_SQL: &str = "SELECT symbol_id,
711            CASE WHEN json_valid(metadata_json)
712                 THEN json_extract(metadata_json, '$.impl_type_name') END
713     FROM symbols
714     WHERE path = ?1 OR path = ?2";
715
716/// The symbols of `path` with their parent names. A Rust method whose `impl` block is in another
717/// file than its type keeps the type named in its metadata, linked to a parent row or not.
718fn rows_of(conn: &Connection, path: &str) -> Result<Vec<Row>, QueryError> {
719    let symbols = load_file_symbols(conn, path)?;
720    let mut stmt = conn.prepare(IMPL_OWNERS_SQL)?;
721    let owners: HashMap<String, String> = stmt
722        .query_map(params![path, path.replace('/', "\\")], |row| {
723            Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
724        })?
725        .collect::<Result<Vec<_>, _>>()?
726        .into_iter()
727        .filter_map(|(id, owner)| owner.map(|owner| (id, owner)))
728        .collect();
729    let names: HashMap<&str, (&str, Option<&str>)> = symbols
730        .iter()
731        .map(|s| {
732            (
733                s.symbol_id.as_str(),
734                (s.name.as_str(), s.parent_symbol_id.as_deref()),
735            )
736        })
737        .collect();
738    let mut rows = Vec::with_capacity(symbols.len());
739    for symbol in &symbols {
740        let mut parents = Vec::new();
741        let mut next = symbol.parent_symbol_id.as_deref();
742        while let Some(id) = next.filter(|_| parents.len() < 32) {
743            let Some((name, parent)) = names.get(id) else {
744                break;
745            };
746            parents.push((*name).to_string());
747            next = *parent;
748        }
749        if parents.is_empty()
750            && let Some(owner) = owners.get(&symbol.symbol_id)
751        {
752            parents.push(owner.clone());
753        }
754        parents.reverse();
755        rows.push(Row {
756            symbol: symbol.clone(),
757            parents,
758        });
759    }
760    rows.sort_by_key(|row| row.symbol.start_byte);
761    Ok(rows)
762}
763
764fn signature_tail(symbol: &Symbol) -> String {
765    signatures::tail_of(&signatures::signature_of(symbol), &symbol.name)
766}
767
768fn word_position(text: &str, word: &str) -> Option<usize> {
769    let is_word = |c: char| c.is_alphanumeric() || c == '_';
770    text.match_indices(word)
771        .map(|(at, _)| at)
772        .find(|&at| !text[..at].ends_with(is_word) && !text[at + word.len()..].starts_with(is_word))
773}
774
775struct Pairing {
776    pairs: Vec<(usize, usize)>,
777    removed: Vec<usize>,
778    added: Vec<usize>,
779}
780
781/// Matches the symbols of `base` with those of `current` that share a key. Equal counts pair in
782/// source order; otherwise equal signatures pair first, so a new overload does not shift the
783/// others. What stays unpaired is removed or added.
784fn pair_rows(base: &[Row], current: &[Row]) -> Pairing {
785    let group = |rows: &[Row]| {
786        let mut groups: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
787        for (index, row) in rows.iter().enumerate().filter(|(_, row)| row.is_shape()) {
788            groups.entry(row.key()).or_default().push(index);
789        }
790        groups
791    };
792    let (base_groups, current_groups) = (group(base), group(current));
793    let mut pairing = Pairing {
794        pairs: Vec::new(),
795        removed: Vec::new(),
796        added: Vec::new(),
797    };
798    let keys: BTreeSet<&Key> = base_groups.keys().chain(current_groups.keys()).collect();
799    for key in keys {
800        let mut before: Vec<usize> = base_groups.get(key).cloned().unwrap_or_default();
801        let mut after: Vec<usize> = current_groups.get(key).cloned().unwrap_or_default();
802        if before.len() != after.len() {
803            let mut unmatched = Vec::new();
804            for &old in &before {
805                let same = after.iter().position(|&new| {
806                    signature_tail(&current[new].symbol) == signature_tail(&base[old].symbol)
807                });
808                match same {
809                    Some(position) => pairing.pairs.push((old, after.remove(position))),
810                    None => unmatched.push(old),
811                }
812            }
813            before = unmatched;
814        }
815        let common = before.len().min(after.len());
816        pairing
817            .pairs
818            .extend(before.iter().copied().zip(after.iter().copied()));
819        pairing.removed.extend(&before[common..]);
820        pairing.added.extend(&after[common..]);
821    }
822    pairing
823}
824
825/// The unpaired base symbols that an unpaired current symbol of the same parents, kind, and body
826/// hash replaces, as `(base index, current index)`. A hash that more than one symbol on either
827/// side has names no rename.
828fn renames(base: &[Row], current: &[Row], pairing: &Pairing) -> Vec<(usize, usize)> {
829    let side = |rows: &[Row], indexes: &[usize]| {
830        let mut by_hash: HashMap<(Vec<String>, String, String), Vec<usize>> = HashMap::new();
831        for &index in indexes {
832            let symbol = &rows[index].symbol;
833            if let Some(hash) = symbol.body_hash.as_deref().filter(|hash| !hash.is_empty()) {
834                by_hash
835                    .entry((
836                        rows[index].parents.clone(),
837                        symbol.kind.clone(),
838                        hash.to_string(),
839                    ))
840                    .or_default()
841                    .push(index);
842            }
843        }
844        by_hash
845    };
846    let (removed, added) = (side(base, &pairing.removed), side(current, &pairing.added));
847    let mut found: Vec<(usize, usize)> = removed
848        .iter()
849        .filter_map(
850            |(key, old)| match (old.as_slice(), added.get(key)?.as_slice()) {
851                ([old], [new]) => Some((*old, *new)),
852                _ => None,
853            },
854        )
855        .collect();
856    found.sort_unstable();
857    found
858}
859
860/// One symbol whose shape changed, before its sites are listed.
861struct Candidate {
862    kind: String,
863    display: String,
864    path: String,
865    line: usize,
866    change: String,
867    subject: Symbol,
868    what: What,
869    rank_name: String,
870    uses: usize,
871}
872
873enum What {
874    Parameters,
875    Signature,
876    Renamed {
877        renames: Vec<(String, String)>,
878    },
879    Removed {
880        overloads: Option<Overloads>,
881    },
882    Fields(FieldChange),
883    Variants {
884        added: Vec<String>,
885        removed: Vec<String>,
886    },
887}
888
889/// The overloads that stay under the name of a callable that was removed or renamed, with the
890/// argument counts each group accepts. A group has no range when the index cannot tell, and the
891/// remaining group has none when an overload remains that the index does not list.
892struct Overloads {
893    remaining: Option<Arity>,
894    removed: Option<Arity>,
895}
896
897/// Whether a call can reach an overload that remains.
898enum Reach {
899    Fits,
900    Unknown,
901    OnlyRemoved(u32),
902}
903
904impl Overloads {
905    fn judge(&self, remaining: &Arity, language: &str, arguments: &CallArguments) -> Reach {
906        let removed_takes = |count| {
907            self.removed
908                .as_ref()
909                .is_none_or(|removed| removed.accepts(language, count) != Some(false))
910        };
911        let mut unknown = arguments.spread || arguments.counts.is_empty();
912        for &count in &arguments.counts {
913            match remaining.accepts(language, count) {
914                Some(true) => unknown |= removed_takes(count),
915                Some(false) if removed_takes(count) => return Reach::OnlyRemoved(count),
916                Some(false) => {}
917                None => unknown = true,
918            }
919        }
920        if unknown { Reach::Unknown } else { Reach::Fits }
921    }
922}
923
924/// The argument counts of the calls of one name on one line.
925#[derive(Default)]
926struct CallArguments {
927    counts: Vec<u32>,
928    spread: bool,
929}
930
931impl CallArguments {
932    fn rejected_by(&self, range: &Arity, language: &str) -> Option<u32> {
933        self.counts
934            .iter()
935            .copied()
936            .find(|&count| range.accepts(language, count) == Some(false))
937    }
938}
939
940struct OwnedMethod {
941    name: String,
942    path: String,
943    first: usize,
944    last: usize,
945}
946
947struct Detector<'a> {
948    conn: &'a Connection,
949    root: &'a Path,
950    base: &'a BaseIndex,
951}
952
953impl Detector<'_> {
954    fn detect(&self, files: &[ChangedFile]) -> Result<Vec<Candidate>, EditCheckError> {
955        let mut candidates = Vec::new();
956        for file in files {
957            if file.base_path.is_none() {
958                continue;
959            }
960            let before = rows_of(&self.base.conn, &file.path)?;
961            if before.is_empty() {
962                continue;
963            }
964            let after = rows_of(self.conn, &file.path)?;
965            let unreadable = after.is_empty()
966                && self.root.join(&file.path).exists()
967                && !file_is_indexed(self.conn, &file.path)?;
968            if unreadable {
969                continue;
970            }
971            candidates.extend(self.compare(&before, &after)?);
972        }
973        Ok(candidates)
974    }
975
976    fn compare(&self, before: &[Row], after: &[Row]) -> Result<Vec<Candidate>, EditCheckError> {
977        let pairing = pair_rows(before, after);
978        let mut candidates = Vec::new();
979        for &(old, new) in &pairing.pairs {
980            if let Some(candidate) = self.changed_pair(before, old, after, new)? {
981                candidates.push(candidate);
982            }
983        }
984        let renamed: HashMap<usize, usize> = renames(before, after, &pairing).into_iter().collect();
985        let gone: HashSet<&str> = pairing
986            .removed
987            .iter()
988            .map(|&index| before[index].symbol.symbol_id.as_str())
989            .collect();
990        for &index in &pairing.removed {
991            let row = &before[index];
992            let inside_removed = row
993                .symbol
994                .parent_symbol_id
995                .as_deref()
996                .is_some_and(|parent| gone.contains(parent));
997            if inside_removed {
998                continue;
999            }
1000            let renamed_to = renamed
1001                .get(&index)
1002                .map(|&new| after[new].symbol.name.as_str());
1003            candidates.push(self.removed(row, after, renamed_to)?);
1004        }
1005        Ok(candidates)
1006    }
1007
1008    fn removed(
1009        &self,
1010        row: &Row,
1011        after: &[Row],
1012        renamed_to: Option<&str>,
1013    ) -> Result<Candidate, EditCheckError> {
1014        let owner = row.parents.split_last().and_then(|(name, above)| {
1015            after
1016                .iter()
1017                .find(|other| other.symbol.name == *name && other.parents == above)
1018        });
1019        let mut subject = row.symbol.clone();
1020        subject.symbol_id = format!("removed:{}", subject.symbol_id);
1021        subject.parent_symbol_id = owner.map(|owner| owner.symbol.symbol_id.clone());
1022        Ok(Candidate {
1023            kind: row.symbol.kind.clone(),
1024            display: row.display(),
1025            path: row.symbol.path.clone(),
1026            line: row.symbol.start_line,
1027            change: match renamed_to {
1028                Some(new) => format!("renamed to `{new}`"),
1029                None => "removed".to_string(),
1030            },
1031            rank_name: row.symbol.name.clone(),
1032            uses: 0,
1033            subject,
1034            what: What::Removed {
1035                overloads: self.remaining_overloads(row, after)?,
1036            },
1037        })
1038    }
1039
1040    /// The overloads of a removed callable that remain under its name in the same owner. A method
1041    /// that the index gives no owner (a Go method) shares its key with the methods of other
1042    /// types, so it has none. A constructor of a class that has one the index lists no symbol for
1043    /// keeps an overload with no range.
1044    fn remaining_overloads(
1045        &self,
1046        row: &Row,
1047        after: &[Row],
1048    ) -> Result<Option<Overloads>, EditCheckError> {
1049        let ownerless = row.symbol.kind == "method" && row.parents.is_empty();
1050        if !CALLABLE_KINDS.contains(&row.symbol.kind.as_str()) || ownerless {
1051            return Ok(None);
1052        }
1053        let key = row.key();
1054        let remaining: Vec<Symbol> = after
1055            .iter()
1056            .filter(|other| other.key() == key)
1057            .map(|other| other.symbol.clone())
1058            .collect();
1059        let hidden = row.symbol.kind == "constructor"
1060            && arity::has_unlisted_constructor(&self.base.conn, &row.symbol)?;
1061        if remaining.is_empty() && !hidden {
1062            return Ok(None);
1063        }
1064        Ok(Some(Overloads {
1065            remaining: if hidden {
1066                None
1067            } else {
1068                arity::declared_group_arity(self.conn, self.root, &remaining)?
1069            },
1070            removed: arity::declared_group_arity(
1071                &self.base.conn,
1072                &self.base.tree,
1073                std::slice::from_ref(&row.symbol),
1074            )?,
1075        }))
1076    }
1077
1078    fn changed_pair(
1079        &self,
1080        before: &[Row],
1081        old: usize,
1082        after: &[Row],
1083        new: usize,
1084    ) -> Result<Option<Candidate>, EditCheckError> {
1085        let (was, is) = (&before[old], &after[new]);
1086        let kind = is.symbol.kind.as_str();
1087        let (change, what) = if CALLABLE_KINDS.contains(&kind) {
1088            match self.callable_change(&was.symbol, &is.symbol)? {
1089                Some(found) => found,
1090                None => return Ok(None),
1091            }
1092        } else if FIELD_OWNER_KINDS.contains(&kind) {
1093            let dataclass = is
1094                .symbol
1095                .signature
1096                .as_deref()
1097                .is_some_and(|sig| sig.starts_with("@dataclass"));
1098            let old_components = was
1099                .symbol
1100                .signature
1101                .as_deref()
1102                .filter(|_| is.symbol.language == "java")
1103                .and_then(|sig| fields::record_components(sig, &was.symbol.name));
1104            let is_field = |row: &Row| {
1105                (FIELD_KINDS.contains(&row.symbol.kind.as_str())
1106                    || dataclass && is_annotated_variable(&row.symbol))
1107                    && !row.symbol.signature.as_deref().is_some_and(|sig| {
1108                        ATTRIBUTE_WRITE_PREFIXES.iter().any(|p| sig.starts_with(p))
1109                    })
1110            };
1111            let (added, removed) = member_changes(before, &was.symbol, after, &is.symbol, is_field);
1112            if added.is_empty() && removed.is_empty() {
1113                return Ok(None);
1114            }
1115            let required = after.iter().any(|row| {
1116                row.symbol.parent_symbol_id.as_deref() == Some(&is.symbol.symbol_id)
1117                    && is_field(row)
1118                    && added.contains(&row.symbol.name)
1119                    && fields::needs_argument(row.symbol.signature.as_deref())
1120            });
1121            (
1122                format!("fields {}", member_text(&added, &removed)),
1123                What::Fields(FieldChange {
1124                    added,
1125                    removed,
1126                    required,
1127                    old_components,
1128                }),
1129            )
1130        } else if kind == "enum" {
1131            let c_enum = is.symbol.language == "c";
1132            let (added, removed) = member_changes(before, &was.symbol, after, &is.symbol, |row| {
1133                row.symbol.kind == "enum_member" || (c_enum && row.symbol.kind == "constant")
1134            });
1135            if added.is_empty() && removed.is_empty() {
1136                return Ok(None);
1137            }
1138            (
1139                format!("variants {}", member_text(&added, &removed)),
1140                What::Variants { added, removed },
1141            )
1142        } else {
1143            return Ok(None);
1144        };
1145        Ok(Some(Candidate {
1146            kind: is.symbol.kind.clone(),
1147            display: is.display(),
1148            path: is.symbol.path.clone(),
1149            line: is.symbol.start_line,
1150            change,
1151            rank_name: is.symbol.name.clone(),
1152            uses: 0,
1153            subject: is.symbol.clone(),
1154            what,
1155        }))
1156    }
1157
1158    fn callable_change(
1159        &self,
1160        was: &Symbol,
1161        is: &Symbol,
1162    ) -> Result<Option<(String, What)>, EditCheckError> {
1163        let (before, after) = (
1164            self.parameter_shape(&self.base.conn, &self.base.tree, was)?,
1165            self.parameter_shape(self.conn, self.root, is)?,
1166        );
1167        if before.differs(&after) {
1168            return Ok(Some((before.change_text(&after), What::Parameters)));
1169        }
1170        let shapes = (
1171            Shape::of(&self.base.conn, was, &self.base.tree)?,
1172            Shape::of(self.conn, is, self.root)?,
1173        );
1174        Ok(match shapes.0.compare(&shapes.1, &is.language) {
1175            Difference::None => None,
1176            Difference::Signature => Some(("signature changed".to_string(), What::Signature)),
1177            Difference::Modifiers(words) => Some((
1178                format!("signature changed: {}", words.join(", ")),
1179                What::Signature,
1180            )),
1181            Difference::Renamed(renames) => {
1182                let pairs: Vec<String> = renames
1183                    .iter()
1184                    .map(|(old, new)| format!("{old} → {new}"))
1185                    .collect();
1186                Some((
1187                    format!("parameter renamed: {}", pairs.join(", ")),
1188                    What::Renamed { renames },
1189                ))
1190            }
1191        })
1192    }
1193
1194    fn parameter_shape(
1195        &self,
1196        conn: &Connection,
1197        root: &Path,
1198        symbol: &Symbol,
1199    ) -> Result<ParameterShape, EditCheckError> {
1200        Ok(ParameterShape {
1201            range: arity::group_arity(conn, root, std::slice::from_ref(symbol), false)?,
1202            count: arity::parameter_count(conn, symbol)? + signatures::ignored_parameters(symbol),
1203        })
1204    }
1205}
1206
1207/// What a caller must pass to a callable: the accepted range when the index knows it, else the
1208/// number of parameter rows.
1209struct ParameterShape {
1210    range: Option<Arity>,
1211    count: usize,
1212}
1213
1214impl ParameterShape {
1215    fn differs(&self, other: &ParameterShape) -> bool {
1216        match (&self.range, &other.range) {
1217            (Some(a), Some(b)) => a != b,
1218            _ => self.count != other.count,
1219        }
1220    }
1221
1222    /// The range when both sides have one, else the row count, so the two texts compare alike.
1223    fn text(&self, other: &ParameterShape) -> String {
1224        match (&self.range, &other.range) {
1225            (Some(range), Some(_)) => range.range_text(),
1226            _ => self.count.to_string(),
1227        }
1228    }
1229
1230    fn change_text(&self, other: &ParameterShape) -> String {
1231        parameters_change(
1232            (self.text(other), other.text(self)),
1233            (self.count, other.count),
1234        )
1235    }
1236}
1237
1238/// `parameters 2 → 3` from the two range texts. When they read alike (a `self` parameter that came
1239/// or went), the row counts say it, and `parameters changed` when those are equal too.
1240fn parameters_change((was, is): (String, String), (was_rows, is_rows): (usize, usize)) -> String {
1241    if was != is {
1242        return format!("parameters {was} → {is}");
1243    }
1244    if was_rows != is_rows {
1245        return format!("parameters {was_rows} → {is_rows}");
1246    }
1247    "parameters changed".to_string()
1248}
1249
1250/// A class-body variable with a type annotation that is no `ClassVar`, which is a field of a
1251/// Python dataclass.
1252fn is_annotated_variable(symbol: &Symbol) -> bool {
1253    symbol.kind == "variable"
1254        && symbol
1255            .signature
1256            .as_deref()
1257            .is_some_and(fields::is_dataclass_field)
1258}
1259
1260/// The names of the members of `was` that `is` lacks and the names it gains, among the children
1261/// of each that `member` accepts.
1262fn member_changes(
1263    before: &[Row],
1264    was: &Symbol,
1265    after: &[Row],
1266    is: &Symbol,
1267    member: impl Fn(&Row) -> bool,
1268) -> (Vec<String>, Vec<String>) {
1269    let names = |rows: &[Row], owner: &Symbol| -> BTreeSet<String> {
1270        rows.iter()
1271            .filter(|row| row.symbol.parent_symbol_id.as_deref() == Some(&owner.symbol_id))
1272            .filter(|row| member(row))
1273            .map(|row| row.symbol.name.clone())
1274            .collect()
1275    };
1276    let (old, new) = (names(before, was), names(after, is));
1277    (
1278        new.difference(&old).cloned().collect(),
1279        old.difference(&new).cloned().collect(),
1280    )
1281}
1282
1283/// `+1 (z), -0`: how many members a type gained and lost, and the names of each.
1284fn member_text(added: &[String], removed: &[String]) -> String {
1285    let part = |sign: char, names: &[String]| {
1286        if names.is_empty() {
1287            format!("{sign}0")
1288        } else {
1289            format!("{sign}{} ({})", names.len(), names.join(", "))
1290        }
1291    };
1292    format!("{}, {}", part('+', added), part('-', removed))
1293}
1294
1295fn name_uses(conn: &Connection, name: &str) -> Result<usize, EditCheckError> {
1296    let count: i64 = conn
1297        .query_row(
1298            "SELECT (SELECT COUNT(*) FROM identifiers WHERE name = ?1)
1299                  + (SELECT COUNT(*) FROM pending_relationships WHERE target_terminal_name = ?1)",
1300            [name],
1301            |row| row.get(0),
1302        )
1303        .map_err(QueryError::from)?;
1304    Ok(count.max(0) as usize)
1305}
1306
1307#[derive(Default)]
1308struct Placed {
1309    in_diff: Vec<EditSite>,
1310    proven: Vec<EditSite>,
1311    likely: Vec<EditSite>,
1312    unresolved: usize,
1313}
1314
1315impl Placed {
1316    fn add(&mut self, label: SiteLabel, reached: bool, site: EditSite) {
1317        if reached {
1318            self.in_diff.push(site);
1319            return;
1320        }
1321        match label {
1322            SiteLabel::Proven => self.proven.push(site),
1323            SiteLabel::Likely => self.likely.push(site),
1324            SiteLabel::Unresolved => self.unresolved += 1,
1325            SiteLabel::Excluded => {}
1326        }
1327    }
1328}
1329
1330struct Listed {
1331    placed: Placed,
1332    tests: Vec<String>,
1333}
1334
1335struct SiteLister<'a> {
1336    conn: &'a Connection,
1337    root: &'a Path,
1338    hunks: &'a Hunks,
1339    sources: HashMap<String, Option<Vec<String>>>,
1340    languages: HashMap<String, String>,
1341}
1342
1343fn is_call(kind: &str) -> bool {
1344    matches!(kind, "calls" | "call" | "instantiates")
1345}
1346
1347fn site_span(labeled: &LabeledSite) -> (usize, usize) {
1348    let first = labeled.site.start_line.unwrap_or(0);
1349    (first, labeled.end_line.unwrap_or(first).max(first))
1350}
1351
1352fn args_noun(count: u32) -> &'static str {
1353    if count == 1 { "arg" } else { "args" }
1354}
1355
1356fn edit_site(labeled: &LabeledSite, line: usize, note: Option<String>) -> EditSite {
1357    EditSite {
1358        path: labeled.site.path.clone(),
1359        line,
1360        caller: displayed_symbol_name(
1361            &labeled.site.from_symbol_name,
1362            labeled.site.enclosing_symbol_name.as_deref(),
1363        ),
1364        note,
1365    }
1366}
1367
1368impl<'a> SiteLister<'a> {
1369    fn new(conn: &'a Connection, root: &'a Path, hunks: &'a Hunks) -> Self {
1370        Self {
1371            conn,
1372            root,
1373            hunks,
1374            sources: HashMap::new(),
1375            languages: HashMap::new(),
1376        }
1377    }
1378
1379    fn list(&mut self, candidate: &Candidate) -> Result<Listed, EditCheckError> {
1380        let report = reference_report_for_edit(self.conn, self.root, &candidate.subject)?;
1381        let placed = match &candidate.what {
1382            What::Parameters => {
1383                let range = inherited_group_arity(self.conn, self.root, &candidate.subject)?;
1384                let mut placed = self.calls(&report, range.as_ref());
1385                self.overrides(&report, &mut placed)?;
1386                placed
1387            }
1388            What::Signature => {
1389                let mut placed = self.calls(&report, None);
1390                self.overrides(&report, &mut placed)?;
1391                placed
1392            }
1393            What::Renamed { renames } => self.named_calls(&report, renames),
1394            What::Removed {
1395                overloads: Some(overloads),
1396            } => self.removed_overloads(&report, overloads),
1397            What::Removed { overloads: None } => {
1398                let mut placed = self.every_site(&report);
1399                self.imports(&report, &mut placed);
1400                placed
1401            }
1402            What::Fields(change) => self.constructions(&report, &candidate.subject, change)?,
1403            What::Variants { added, removed } => {
1404                self.variant_uses(&report, &candidate.subject, added, removed)?
1405            }
1406        };
1407        let tests = report
1408            .tests
1409            .iter()
1410            .filter_map(|test| test.command.clone())
1411            .collect();
1412        Ok(Listed { placed, tests })
1413    }
1414
1415    fn calls(&mut self, report: &ReferenceReport, range: Option<&Arity>) -> Placed {
1416        let mut placed = Placed::default();
1417        for labeled in report
1418            .sites
1419            .iter()
1420            .filter(|site| site.label != SiteLabel::Excluded && is_call(&site.site.kind))
1421        {
1422            let (first, last) = site_span(labeled);
1423            let path = labeled.site.path.as_str();
1424            let touched = self.touches(path, first, last);
1425            let language = self.language_of(path);
1426            let arguments = range.map(|_| self.arguments(labeled)).unwrap_or_default();
1427            let count = range
1428                .and_then(|range| arguments.rejected_by(range, &language))
1429                .or_else(|| arguments.counts.first().copied());
1430            let verdict = range
1431                .zip(count)
1432                .and_then(|(range, count)| Some((range, count, range.accepts(&language, count)?)));
1433            if !touched && verdict.is_some_and(|(_, _, fits)| fits) {
1434                continue;
1435            }
1436            if !touched && verdict.is_none() && arguments.spread {
1437                placed.unresolved += 1;
1438                continue;
1439            }
1440            let rejected = verdict.filter(|(_, _, fits)| !fits);
1441            let note = rejected.map(|(range, count, _)| {
1442                format!(
1443                    "passes {count} {}; takes {}",
1444                    args_noun(count),
1445                    range.range_text()
1446                )
1447            });
1448            placed.add(
1449                labeled.label,
1450                touched && rejected.is_none(),
1451                edit_site(labeled, first, note),
1452            );
1453        }
1454        placed
1455    }
1456
1457    /// The argument counts of the call at the site: the count the report gives, else the counts of
1458    /// the calls of the name on its line. A line with two calls of one name gets no count in the
1459    /// report, so the call rows are read.
1460    fn arguments(&mut self, labeled: &LabeledSite) -> CallArguments {
1461        if let Some(count) = labeled.arg_count {
1462            return CallArguments {
1463                counts: vec![count],
1464                spread: false,
1465            };
1466        }
1467        if !is_call(&labeled.site.kind) {
1468            return CallArguments::default();
1469        }
1470        let mut arguments = self.line_arguments(labeled).unwrap_or_default();
1471        if arguments.counts.is_empty() && !arguments.spread {
1472            arguments.counts = self.written_counts(labeled);
1473        }
1474        arguments
1475    }
1476
1477    /// The argument counts of the Java calls of the name on the line of the site, read from the
1478    /// source, because julie writes no count for `new Foo(a)`.
1479    fn written_counts(&mut self, labeled: &LabeledSite) -> Vec<u32> {
1480        let path = labeled.site.path.as_str();
1481        if self.language_of(path) != "java" {
1482            return Vec::new();
1483        }
1484        let line = labeled.site.start_line.unwrap_or(0);
1485        self.source(path)
1486            .map(|lines| fields::call_counts(lines, line, &labeled.site.to_symbol_name))
1487            .unwrap_or_default()
1488            .into_iter()
1489            .filter_map(|count| u32::try_from(count).ok())
1490            .collect()
1491    }
1492
1493    fn line_arguments(&self, labeled: &LabeledSite) -> Option<CallArguments> {
1494        let path = labeled.site.path.as_str();
1495        let mut stmt = self.conn.prepare(CALL_ARGUMENTS_SQL).ok()?;
1496        let rows = stmt
1497            .query_map(
1498                params![
1499                    labeled.site.to_symbol_name,
1500                    path,
1501                    path.replace('/', "\\"),
1502                    labeled.site.start_line.unwrap_or(0) as i64
1503                ],
1504                |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, bool>(1)?)),
1505            )
1506            .ok()?;
1507        let mut arguments = CallArguments::default();
1508        for (count, spread) in rows.filter_map(Result::ok) {
1509            arguments.spread |= spread;
1510            if let Some(count) = count
1511                .filter(|_| !spread)
1512                .and_then(|c| u32::try_from(c).ok())
1513            {
1514                arguments.counts.push(count);
1515            }
1516        }
1517        Some(arguments)
1518    }
1519
1520    /// The sites of a removed or renamed overload when other overloads of the name remain. A call
1521    /// that only a remaining overload takes is no site, a call that only the removed one took is,
1522    /// and a call that both take, a call with no known count, and every call while the remaining
1523    /// overloads have no range are counted as unresolved.
1524    fn removed_overloads(&mut self, report: &ReferenceReport, overloads: &Overloads) -> Placed {
1525        let mut placed = Placed::default();
1526        for labeled in report
1527            .sites
1528            .iter()
1529            .filter(|site| site.label != SiteLabel::Excluded)
1530        {
1531            let (first, last) = site_span(labeled);
1532            let path = labeled.site.path.as_str();
1533            let language = self.language_of(path);
1534            let reach = match &overloads.remaining {
1535                Some(remaining) => overloads.judge(remaining, &language, &self.arguments(labeled)),
1536                None => Reach::Unknown,
1537            };
1538            match reach {
1539                Reach::Fits => {}
1540                Reach::Unknown => {
1541                    placed.unresolved += usize::from(!self.touches(path, first, last));
1542                }
1543                Reach::OnlyRemoved(count) => {
1544                    let note = format!(
1545                        "passes {count} {}; no remaining overload takes it",
1546                        args_noun(count)
1547                    );
1548                    placed.add(labeled.label, false, edit_site(labeled, first, Some(note)));
1549                }
1550            }
1551        }
1552        placed
1553    }
1554
1555    /// The classes that implement or extend the owner of a changed method, each with its own
1556    /// version of the method. The section gives no label, so a row is `likely`.
1557    fn overrides(
1558        &self,
1559        report: &ReferenceReport,
1560        placed: &mut Placed,
1561    ) -> Result<(), EditCheckError> {
1562        for row in &report.overrides {
1563            let (first, last) = self
1564                .symbol_span(&row.symbol_id)?
1565                .unwrap_or((row.line, row.line));
1566            let reached = self.touches(&row.path, first, last);
1567            placed.add(
1568                SiteLabel::Likely,
1569                reached,
1570                EditSite {
1571                    path: row.path.clone(),
1572                    line: row.line,
1573                    caller: format!("{}.{}", row.owner, row.name),
1574                    note: None,
1575                },
1576            );
1577        }
1578        Ok(())
1579    }
1580
1581    /// The import lines of a removed symbol's name that name its file, each `likely`.
1582    fn imports(&self, report: &ReferenceReport, placed: &mut Placed) {
1583        for (path, line) in &report.imports {
1584            let reached = self.touches(path, *line, *line);
1585            placed.add(
1586                SiteLabel::Likely,
1587                reached,
1588                EditSite {
1589                    path: path.clone(),
1590                    line: *line,
1591                    caller: String::new(),
1592                    note: Some(format!("imports `{}`", report.target.name)),
1593                },
1594            );
1595        }
1596    }
1597
1598    /// The calls that pass an old parameter name as a named argument. A call that still does so
1599    /// is stale even when the diff touched its line.
1600    fn named_calls(&mut self, report: &ReferenceReport, renames: &[(String, String)]) -> Placed {
1601        let mut placed = Placed::default();
1602        for labeled in report
1603            .sites
1604            .iter()
1605            .filter(|site| site.label != SiteLabel::Excluded && is_call(&site.site.kind))
1606        {
1607            let (first, last) = site_span(labeled);
1608            let path = labeled.site.path.as_str();
1609            let callee = labeled.site.to_symbol_name.as_str();
1610            let language = self.language_of(path);
1611            let Some(lines) = self.source(path) else {
1612                continue;
1613            };
1614            let last = last.max(construction_end(lines, first, callee));
1615            let span: Vec<&str> = lines
1616                .get(first.saturating_sub(1)..last.min(lines.len()))
1617                .unwrap_or_default()
1618                .iter()
1619                .map(String::as_str)
1620                .collect();
1621            let named = renames
1622                .iter()
1623                .find(|(old, _)| signatures::names_parameter(&span, callee, old, &language));
1624            if let Some((old, _)) = named {
1625                let note = Some(format!("names parameter `{old}`"));
1626                placed.add(labeled.label, false, edit_site(labeled, first, note));
1627            }
1628        }
1629        placed
1630    }
1631
1632    fn every_site(&mut self, report: &ReferenceReport) -> Placed {
1633        let mut placed = Placed::default();
1634        for labeled in report
1635            .sites
1636            .iter()
1637            .filter(|site| site.label != SiteLabel::Excluded)
1638        {
1639            let (first, last) = site_span(labeled);
1640            let reached = self.touches(&labeled.site.path, first, last);
1641            placed.add(labeled.label, reached, edit_site(labeled, first, None));
1642        }
1643        placed
1644    }
1645
1646    fn constructions(
1647        &mut self,
1648        report: &ReferenceReport,
1649        subject: &Symbol,
1650        change: &FieldChange,
1651    ) -> Result<Placed, EditCheckError> {
1652        let rust_struct = subject.language == "rust" && subject.kind == "struct";
1653        let mut rows: Vec<ConstructionSite> = report.constructions.clone();
1654        if rust_struct {
1655            for pattern in destructurings(self.root, &report.target.name, &report.sites) {
1656                if !rows
1657                    .iter()
1658                    .any(|row| row.path == pattern.path && row.line == pattern.line)
1659                {
1660                    rows.push(pattern);
1661                }
1662            }
1663        }
1664        let mut placed = Placed::default();
1665        for construction in &rows {
1666            let site = report
1667                .sites
1668                .iter()
1669                .filter(|site| {
1670                    site.label != SiteLabel::Excluded
1671                        && site.site.path == construction.path
1672                        && site.site.start_line == Some(construction.line)
1673                })
1674                .min_by_key(|site| site.label.rank());
1675            let lines = self.source(&construction.path).map(Vec::as_slice);
1676            if !change.breaks(subject, lines, construction.line) {
1677                continue;
1678            }
1679            let last = self
1680                .source(&construction.path)
1681                .map_or(construction.line, |lines| {
1682                    construction_end(lines, construction.line, &report.target.name)
1683                });
1684            let reached = self.touches(&construction.path, construction.line, last);
1685            let caller = site
1686                .map(|site| {
1687                    displayed_symbol_name(
1688                        &site.site.from_symbol_name,
1689                        site.site.enclosing_symbol_name.as_deref(),
1690                    )
1691                })
1692                .unwrap_or_default();
1693            placed.add(
1694                site.map_or(SiteLabel::Likely, |site| site.label),
1695                reached,
1696                EditSite {
1697                    path: construction.path.clone(),
1698                    line: construction.line,
1699                    caller,
1700                    note: None,
1701                },
1702            );
1703        }
1704        if rust_struct {
1705            self.self_literals(subject, change, &rows, &mut placed)?;
1706        }
1707        Ok(placed)
1708    }
1709
1710    /// The `Self { … }` literals and patterns in the methods of a Rust struct, which no
1711    /// `Constructions` row holds, because they do not name the struct.
1712    fn self_literals(
1713        &mut self,
1714        subject: &Symbol,
1715        change: &FieldChange,
1716        listed: &[ConstructionSite],
1717        placed: &mut Placed,
1718    ) -> Result<(), EditCheckError> {
1719        for method in self.owned_methods(&subject.symbol_id)? {
1720            for line in method.first..=method.last {
1721                let Some(lines) = self.source(&method.path) else {
1722                    break;
1723                };
1724                let names_self = lines
1725                    .get(line - 1)
1726                    .is_some_and(|text| word_position(text, "Self").is_some());
1727                let already = listed
1728                    .iter()
1729                    .any(|row| row.path == method.path && row.line == line);
1730                if !names_self || already || !change.breaks_self(lines, line) {
1731                    continue;
1732                }
1733                let last = construction_end(lines, line, "Self");
1734                let reached = self.touches(&method.path, line, last);
1735                placed.add(
1736                    SiteLabel::Proven,
1737                    reached,
1738                    EditSite {
1739                        path: method.path.clone(),
1740                        line,
1741                        caller: format!("{}.{}", subject.name, method.name),
1742                        note: None,
1743                    },
1744                );
1745            }
1746        }
1747        Ok(())
1748    }
1749
1750    fn owned_methods(&self, owner_id: &str) -> Result<Vec<OwnedMethod>, EditCheckError> {
1751        let mut stmt = self
1752            .conn
1753            .prepare(OWNED_METHODS_SQL)
1754            .map_err(QueryError::from)?;
1755        let methods = stmt
1756            .query_map([owner_id], |row| {
1757                Ok(OwnedMethod {
1758                    name: row.get(0)?,
1759                    path: row.get::<_, String>(1)?.replace('\\', "/"),
1760                    first: row.get::<_, i64>(2)?.max(1) as usize,
1761                    last: row.get::<_, i64>(3)?.max(1) as usize,
1762                })
1763            })
1764            .map_err(QueryError::from)?
1765            .collect::<Result<Vec<_>, _>>()
1766            .map_err(QueryError::from)?;
1767        Ok(methods)
1768    }
1769
1770    /// The functions that name a removed variant, each at its first use of one and never in the
1771    /// diff, then the functions that need an arm for an added variant.
1772    fn variant_uses(
1773        &mut self,
1774        report: &ReferenceReport,
1775        subject: &Symbol,
1776        added: &[String],
1777        removed: &[String],
1778    ) -> Result<Placed, EditCheckError> {
1779        let mut placed = Placed::default();
1780        let mut listed = HashSet::new();
1781        for used in uses_of_variants(self.conn, self.root, subject, &report.sites, removed)? {
1782            let span = self.symbol_span(&used.caller_id)?;
1783            let label = variant_label(report, &used, span);
1784            listed.insert((used.path.clone(), used.caller_id.clone()));
1785            placed.add(label, false, variant_site(&used));
1786        }
1787        if added.is_empty() {
1788            return Ok(placed);
1789        }
1790        let variants = enum_variant_names(self.conn, subject)?;
1791        for used in &report.variant_uses {
1792            if listed.contains(&(used.path.clone(), used.caller_id.clone())) {
1793                continue;
1794            }
1795            let span = self.symbol_span(&used.caller_id)?;
1796            let (first, last) = span.unwrap_or((used.line, used.line));
1797            let reached = self.names_in_diff(&used.path, first, last, added);
1798            match self.needs_arm(&used.path, span, &report.target.name, &variants) {
1799                Verdict::Unresolved => placed.unresolved += usize::from(!reached),
1800                Verdict::Site => {
1801                    let label = variant_label(report, used, span);
1802                    placed.add(label, reached, variant_site(used));
1803                }
1804            }
1805        }
1806        Ok(placed)
1807    }
1808
1809    /// Whether the function that spans `span` of `path` needs a new arm for a new variant of
1810    /// the enum `enum_name`, judged from its lines.
1811    fn needs_arm(
1812        &mut self,
1813        path: &str,
1814        span: Option<(usize, usize)>,
1815        enum_name: &str,
1816        variants: &[String],
1817    ) -> Verdict {
1818        let Some((first, last)) = span else {
1819            return Verdict::Unresolved;
1820        };
1821        let language = self.language_of(path);
1822        let Some(lines) = self.source(path) else {
1823            return Verdict::Unresolved;
1824        };
1825        let Some(function) = lines.get(first.saturating_sub(1)..last.min(lines.len())) else {
1826            return Verdict::Unresolved;
1827        };
1828        let bare = match language.as_str() {
1829            "java" | "c" | "cpp" => true,
1830            "rust" => lines.iter().any(|line| imports_variants(line, enum_name)),
1831            _ => false,
1832        };
1833        arms::judge(
1834            &language,
1835            function,
1836            &arms::EnumNames {
1837                enum_name,
1838                variants,
1839                bare,
1840            },
1841        )
1842    }
1843
1844    fn touches(&self, path: &str, first: usize, last: usize) -> bool {
1845        self.hunks
1846            .get(path)
1847            .is_some_and(|ranges| ranges.iter().any(|&(a, b)| a <= last && first <= b))
1848    }
1849
1850    /// Whether the diff changed a line of `first..=last` that names one of `added`.
1851    fn names_in_diff(&mut self, path: &str, first: usize, last: usize, added: &[String]) -> bool {
1852        let ranges = self.hunks.get(path).cloned().unwrap_or_default();
1853        let Some(lines) = self.source(path) else {
1854            return false;
1855        };
1856        ranges.iter().any(|&(a, b)| {
1857            (a.max(first)..=b.min(last)).any(|line| {
1858                lines.get(line.wrapping_sub(1)).is_some_and(|text| {
1859                    added.iter().any(|name| word_position(text, name).is_some())
1860                })
1861            })
1862        })
1863    }
1864
1865    fn source(&mut self, path: &str) -> Option<&Vec<String>> {
1866        let root = self.root;
1867        self.sources
1868            .entry(path.to_string())
1869            .or_insert_with(|| {
1870                std::fs::read_to_string(root.join(path))
1871                    .ok()
1872                    .map(|text| text.lines().map(str::to_string).collect())
1873            })
1874            .as_ref()
1875    }
1876
1877    fn language_of(&mut self, path: &str) -> String {
1878        if let Some(language) = self.languages.get(path) {
1879            return language.clone();
1880        }
1881        let language = self
1882            .conn
1883            .query_row(
1884                "SELECT language FROM files WHERE path = ?1 OR path = ?2",
1885                params![path, path.replace('/', "\\")],
1886                |row| row.get::<_, String>(0),
1887            )
1888            .optional()
1889            .ok()
1890            .flatten()
1891            .unwrap_or_default();
1892        self.languages.insert(path.to_string(), language.clone());
1893        language
1894    }
1895
1896    fn symbol_span(&self, symbol_id: &str) -> Result<Option<(usize, usize)>, EditCheckError> {
1897        if symbol_id.is_empty() {
1898            return Ok(None);
1899        }
1900        Ok(self
1901            .conn
1902            .query_row(
1903                "SELECT start_line, end_line FROM symbols WHERE symbol_id = ?1",
1904                [symbol_id],
1905                |row| {
1906                    Ok((
1907                        row.get::<_, i64>(0)? as usize,
1908                        row.get::<_, i64>(1)? as usize,
1909                    ))
1910                },
1911            )
1912            .optional()
1913            .map_err(QueryError::from)?)
1914    }
1915}
1916
1917/// The best label among the sites of the enum's name inside the function of `used`.
1918fn variant_label(
1919    report: &ReferenceReport,
1920    used: &VariantUse,
1921    span: Option<(usize, usize)>,
1922) -> SiteLabel {
1923    let (first, last) = span.unwrap_or((used.line, used.line));
1924    report
1925        .sites
1926        .iter()
1927        .filter(|site| {
1928            site.label != SiteLabel::Excluded
1929                && site.site.path == used.path
1930                && site
1931                    .site
1932                    .start_line
1933                    .is_some_and(|line| (first..=last).contains(&line))
1934        })
1935        .map(|site| site.label)
1936        .min_by_key(|label| label.rank())
1937        .unwrap_or(SiteLabel::Likely)
1938}
1939
1940fn variant_site(used: &VariantUse) -> EditSite {
1941    EditSite {
1942        path: used.path.clone(),
1943        line: used.line,
1944        caller: used.caller.clone(),
1945        note: None,
1946    }
1947}
1948
1949/// Whether `line` is a `use` item that brings in variants of `enum_name` (`use a::Kind::*;`,
1950/// `use a::Kind::{A, B};`, `use a::Kind::A;`).
1951fn imports_variants(line: &str, enum_name: &str) -> bool {
1952    let item = line.trim_start();
1953    let item = item
1954        .strip_prefix("pub")
1955        .map_or(item, |rest| match rest.trim_start().strip_prefix('(') {
1956            Some(group) => group.split_once(')').map_or(rest, |(_, after)| after),
1957            None => rest,
1958        })
1959        .trim_start();
1960    item.starts_with("use ") && item.contains(&format!("{enum_name}::"))
1961}
1962
1963/// The last line of the literal or call that starts at `line` and names `name`: the line of the
1964/// bracket that closes the first one opened after the name. `line` when the line opens none.
1965fn construction_end(lines: &[String], line: usize, name: &str) -> usize {
1966    let Some(first) = line.checked_sub(1).and_then(|index| lines.get(index)) else {
1967        return line;
1968    };
1969    let from = word_position(first, name).unwrap_or(0);
1970    let mut depth = 0usize;
1971    let mut quote: Option<char> = None;
1972    let mut escaped = false;
1973    for (index, text) in lines
1974        .iter()
1975        .enumerate()
1976        .skip(line - 1)
1977        .take(MAX_EXTENT_LINES)
1978    {
1979        let text = if index == line - 1 {
1980            &first[from..]
1981        } else {
1982            text
1983        };
1984        for c in text.chars() {
1985            if let Some(open) = quote {
1986                match (escaped, c) {
1987                    (true, _) => escaped = false,
1988                    (false, '\\') => escaped = true,
1989                    (false, c) if c == open => quote = None,
1990                    _ => {}
1991                }
1992                continue;
1993            }
1994            match c {
1995                '"' | '\'' if depth > 0 => quote = Some(c),
1996                '{' | '(' | '[' => depth += 1,
1997                '}' | ')' | ']' if depth > 0 => {
1998                    depth -= 1;
1999                    if depth == 0 {
2000                        return index + 1;
2001                    }
2002                }
2003                _ => {}
2004            }
2005        }
2006        if depth == 0 {
2007            return line;
2008        }
2009    }
2010    line
2011}
2012
2013#[cfg(test)]
2014fn test_symbol(language: &str, kind: &str, name: &str, signature: &str) -> Symbol {
2015    Symbol {
2016        symbol_id: name.to_string(),
2017        file_id: String::new(),
2018        path: "a.py".to_string(),
2019        language: language.to_string(),
2020        name: name.to_string(),
2021        kind: kind.to_string(),
2022        signature: Some(signature.to_string()),
2023        doc_comment: None,
2024        visibility: None,
2025        parent_symbol_id: None,
2026        start_line: 1,
2027        start_column: 0,
2028        end_line: 1,
2029        end_column: 0,
2030        start_byte: 0,
2031        end_byte: 0,
2032        body_start_line: None,
2033        body_start_column: None,
2034        body_end_line: None,
2035        body_end_column: None,
2036        body_start_byte: None,
2037        body_end_byte: None,
2038        body_hash: None,
2039        semantic_group: None,
2040        is_test: false,
2041        test_container: false,
2042    }
2043}
2044
2045#[cfg(test)]
2046mod tests {
2047    use super::*;
2048
2049    fn row(
2050        name: &str,
2051        kind: &str,
2052        signature: &str,
2053        hash: Option<&str>,
2054        parents: &[&str],
2055        start: usize,
2056    ) -> Row {
2057        Row {
2058            symbol: Symbol {
2059                symbol_id: format!("{name}{start}"),
2060                start_line: start,
2061                end_line: start,
2062                start_byte: start,
2063                end_byte: start,
2064                body_hash: hash.map(str::to_string),
2065                ..test_symbol("python", kind, name, signature)
2066            },
2067            parents: parents.iter().map(|p| p.to_string()).collect(),
2068        }
2069    }
2070
2071    fn file(path: &str) -> ChangedFile {
2072        ChangedFile {
2073            path: path.to_string(),
2074            base_path: Some(path.to_string()),
2075            untracked: false,
2076        }
2077    }
2078
2079    #[test]
2080    fn more_files_than_the_limit_are_cut_and_the_cut_is_stated() {
2081        let files: Vec<ChangedFile> = (0..250).map(|n| file(&format!("f{n}.py"))).collect();
2082        let (kept, cut) = cap_files(files, 200);
2083        assert_eq!(kept.len(), 200);
2084        assert_eq!(cut.as_deref(), Some("Checked 200 of 250 changed files."));
2085        let (kept, cut) = cap_files(vec![file("a.py")], 200);
2086        assert_eq!((kept.len(), cut), (1, None));
2087    }
2088
2089    #[test]
2090    fn a_signature_is_compared_from_its_name_and_without_modifiers() {
2091        let plain = row("add", "method", "int add(int a)", None, &[], 1);
2092        let modified = row(
2093            "add",
2094            "method",
2095            "@Override  public   final int add(int a)",
2096            None,
2097            &[],
2098            1,
2099        );
2100        assert_eq!(
2101            signature_tail(&plain.symbol),
2102            signature_tail(&modified.symbol)
2103        );
2104        let wider = row("add", "method", "long add(int a)", None, &[], 1);
2105        assert_ne!(signature_tail(&plain.symbol), signature_tail(&wider.symbol));
2106        let longer = row("add", "method", "int add(int a, int b)", None, &[], 1);
2107        assert_ne!(
2108            signature_tail(&plain.symbol),
2109            signature_tail(&longer.symbol)
2110        );
2111    }
2112
2113    #[test]
2114    fn a_name_inside_another_word_is_not_the_signature_name() {
2115        let symbol = row("add", "function", "def padd(x)", None, &[], 1).symbol;
2116        assert_eq!(signature_tail(&symbol), "def padd(x)");
2117    }
2118
2119    #[test]
2120    fn a_new_overload_does_not_shift_the_others() {
2121        let before = vec![
2122            row("f", "method", "f(int a)", None, &["K"], 1),
2123            row("f", "method", "f(int a, int b)", None, &["K"], 5),
2124        ];
2125        let after = vec![
2126            row("f", "method", "f(String s)", None, &["K"], 1),
2127            row("f", "method", "f(int a)", None, &["K"], 5),
2128            row("f", "method", "f(int a, int b)", None, &["K"], 9),
2129        ];
2130        let pairing = pair_rows(&before, &after);
2131        assert_eq!(pairing.pairs, vec![(0, 1), (1, 2)]);
2132        assert_eq!(pairing.added, vec![0]);
2133        assert!(pairing.removed.is_empty());
2134    }
2135
2136    #[test]
2137    fn symbols_with_other_parents_or_kinds_do_not_pair() {
2138        let before = vec![row("f", "function", "f()", None, &[], 1)];
2139        let after = vec![
2140            row("f", "method", "f()", None, &["K"], 1),
2141            row("f", "class", "class f", None, &[], 4),
2142        ];
2143        let pairing = pair_rows(&before, &after);
2144        assert!(pairing.pairs.is_empty());
2145        assert_eq!(pairing.removed, vec![0]);
2146        assert_eq!(pairing.added.len(), 2);
2147    }
2148
2149    #[test]
2150    fn a_unique_body_hash_names_a_rename_and_a_shared_one_does_not() {
2151        let before = vec![
2152            row("old", "function", "old()", Some("h1"), &[], 1),
2153            row("twin_a", "function", "twin_a()", Some("h2"), &[], 5),
2154        ];
2155        let after = vec![
2156            row("new", "function", "new()", Some("h1"), &[], 1),
2157            row("twin_b", "function", "twin_b()", Some("h2"), &[], 5),
2158            row("twin_c", "function", "twin_c()", Some("h2"), &[], 9),
2159        ];
2160        let pairing = pair_rows(&before, &after);
2161        assert_eq!(renames(&before, &after, &pairing), vec![(0, 0)]);
2162    }
2163
2164    #[test]
2165    fn a_rename_needs_the_same_parents_kind_and_a_body_hash() {
2166        let before = vec![
2167            row("old", "function", "old()", Some("h1"), &["K"], 1),
2168            row("bare", "function", "bare()", None, &[], 5),
2169        ];
2170        let after = vec![
2171            row("new", "function", "new()", Some("h1"), &[], 1),
2172            row("fresh", "function", "fresh()", None, &[], 5),
2173        ];
2174        let pairing = pair_rows(&before, &after);
2175        assert!(renames(&before, &after, &pairing).is_empty());
2176    }
2177
2178    #[test]
2179    fn parameters_with_different_ranges_show_the_ranges() {
2180        let change = parameters_change(("1".to_string(), "1-2".to_string()), (1, 2));
2181        assert_eq!(change, "parameters 1 → 1-2");
2182    }
2183
2184    #[test]
2185    fn parameters_with_equal_ranges_and_different_row_counts_show_the_row_counts() {
2186        let change = parameters_change(("1".to_string(), "1".to_string()), (2, 1));
2187        assert_eq!(change, "parameters 2 → 1");
2188    }
2189
2190    #[test]
2191    fn parameters_with_equal_ranges_and_equal_row_counts_say_they_changed() {
2192        let change = parameters_change(("1".to_string(), "1".to_string()), (1, 1));
2193        assert_eq!(change, "parameters changed");
2194    }
2195
2196    #[test]
2197    fn member_text_counts_and_names_both_sides() {
2198        let none: Vec<String> = Vec::new();
2199        let names = vec!["b".to_string(), "c".to_string()];
2200        assert_eq!(member_text(&names, &none), "+2 (b, c), -0");
2201        assert_eq!(member_text(&none, &names[..1]), "+0, -1 (b)");
2202    }
2203
2204    fn lines(text: &str) -> Vec<String> {
2205        text.lines().map(str::to_string).collect()
2206    }
2207
2208    #[test]
2209    fn a_literal_ends_where_its_first_bracket_closes() {
2210        let source =
2211            lines("let a = Cfg {\n    x: f(1, 2),\n    y: \"}\",\n};\nlet b = Cfg { x: 1 };\n");
2212        assert_eq!(construction_end(&source, 1, "Cfg"), 4);
2213        assert_eq!(construction_end(&source, 5, "Cfg"), 5);
2214    }
2215
2216    #[test]
2217    fn a_call_across_lines_ends_at_its_closing_parenthesis() {
2218        let source = lines("total = area(\n    1,\n    2,\n)\nrest()\n");
2219        assert_eq!(construction_end(&source, 1, "area"), 4);
2220    }
2221
2222    #[test]
2223    fn a_name_with_no_bracket_on_its_line_ends_on_that_line() {
2224        let source = lines("x = Foo\ny = bar(\n  1)\n");
2225        assert_eq!(construction_end(&source, 1, "Foo"), 1);
2226        assert_eq!(construction_end(&source, 9, "Foo"), 9);
2227    }
2228}