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