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::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    Signature,
969    Renamed {
970        renames: Vec<(String, String)>,
971    },
972    Removed {
973        overloads: Option<Overloads>,
974        /// The name tests take as a parameter when the symbol is a pytest fixture.
975        fixture: Option<String>,
976        /// The C# `#if` condition of the branch that julie kept the symbol from.
977        condition: Option<String>,
978    },
979    Fields(FieldChange),
980    Variants {
981        added: Vec<String>,
982        removed: Vec<String>,
983    },
984}
985
986/// The overloads that stay under the name of a callable that was removed or renamed, with the
987/// argument counts each group accepts. A group has no range when the index cannot tell, and the
988/// remaining group has none when an overload remains that the index does not list.
989struct Overloads {
990    remaining: Option<Arity>,
991    removed: Option<Arity>,
992}
993
994/// Whether a call can reach an overload that remains.
995enum Reach {
996    Fits,
997    Unknown,
998    OnlyRemoved(u32),
999}
1000
1001impl Overloads {
1002    fn judge(&self, remaining: &Arity, language: &str, arguments: &CallArguments) -> Reach {
1003        let removed_takes = |count| {
1004            self.removed
1005                .as_ref()
1006                .is_none_or(|removed| removed.accepts(language, count) != Some(false))
1007        };
1008        let mut unknown = arguments.spread || arguments.counts.is_empty();
1009        for &count in &arguments.counts {
1010            match remaining.accepts(language, count) {
1011                Some(true) => unknown |= removed_takes(count),
1012                Some(false) if removed_takes(count) => return Reach::OnlyRemoved(count),
1013                Some(false) => {}
1014                None => unknown = true,
1015            }
1016        }
1017        if unknown { Reach::Unknown } else { Reach::Fits }
1018    }
1019}
1020
1021/// The argument counts of the calls of one name on one line.
1022#[derive(Default)]
1023struct CallArguments {
1024    counts: Vec<u32>,
1025    spread: bool,
1026}
1027
1028impl CallArguments {
1029    fn rejected_by(&self, range: &Arity, language: &str) -> Option<u32> {
1030        self.counts
1031            .iter()
1032            .copied()
1033            .find(|&count| range.accepts(language, count) == Some(false))
1034    }
1035}
1036
1037struct OwnedMethod {
1038    name: String,
1039    path: String,
1040    first: usize,
1041    last: usize,
1042}
1043
1044struct Detector<'a> {
1045    conn: &'a Connection,
1046    root: &'a Path,
1047    base: &'a BaseIndex,
1048}
1049
1050impl Detector<'_> {
1051    fn detect(&self, files: &[ChangedFile]) -> Result<Vec<Candidate>, EditCheckError> {
1052        let mut candidates = Vec::new();
1053        for file in files {
1054            if file.base_path.is_none() {
1055                continue;
1056            }
1057            let before = rows_of(&self.base.conn, &file.path)?;
1058            if before.is_empty() {
1059                continue;
1060            }
1061            let after = rows_of(self.conn, &file.path)?;
1062            let unreadable = after.is_empty()
1063                && self.root.join(&file.path).exists()
1064                && !file_is_indexed(self.conn, &file.path)?;
1065            if unreadable {
1066                continue;
1067            }
1068            candidates.extend(self.compare(&before, &after)?);
1069        }
1070        Ok(candidates)
1071    }
1072
1073    fn compare(&self, before: &[Row], after: &[Row]) -> Result<Vec<Candidate>, EditCheckError> {
1074        let pairing = pair_rows(before, after);
1075        let mut candidates = Vec::new();
1076        for &(old, new) in &pairing.pairs {
1077            if let Some(candidate) = self.changed_pair(before, old, after, new)? {
1078                candidates.push(candidate);
1079            }
1080        }
1081        let renamed: HashMap<usize, usize> = renames(before, after, &pairing).into_iter().collect();
1082        let gone: HashSet<&str> = pairing
1083            .removed
1084            .iter()
1085            .map(|&index| before[index].symbol.symbol_id.as_str())
1086            .collect();
1087        for (position, &index) in pairing.removed.iter().enumerate() {
1088            let row = &before[index];
1089            let inside_removed = row
1090                .symbol
1091                .parent_symbol_id
1092                .as_deref()
1093                .is_some_and(|parent| gone.contains(parent));
1094            let value_beside = row.symbol.kind == "type"
1095                && pairing.removed.iter().any(|&other| {
1096                    let other = &before[other];
1097                    matches!(other.symbol.kind.as_str(), "constant" | "variable")
1098                        && other.symbol.name == row.symbol.name
1099                        && other.parents == row.parents
1100                });
1101            let repeated = BINDING_KINDS.contains(&row.symbol.kind.as_str())
1102                && pairing.removed[..position].iter().any(|&other| {
1103                    let other = &before[other];
1104                    other.symbol.kind == row.symbol.kind
1105                        && other.symbol.name == row.symbol.name
1106                        && other.parents == row.parents
1107                });
1108            if inside_removed
1109                || value_beside
1110                || repeated
1111                || after.iter().any(|other| row.changed_kind_to(other))
1112            {
1113                continue;
1114            }
1115            let renamed_to = renamed
1116                .get(&index)
1117                .map(|&new| after[new].symbol.name.as_str());
1118            candidates.push(self.removed(row, after, renamed_to)?);
1119        }
1120        Ok(candidates)
1121    }
1122
1123    /// A C# namespace of the index with the name of the namespace that held a removed top-level
1124    /// type, when its own file no longer declares it: the report reads the type's namespace from
1125    /// its owner.
1126    fn namespace_elsewhere(&self, row: &Row) -> Result<Option<String>, EditCheckError> {
1127        let [namespace] = row.parents.as_slice() else {
1128            return Ok(None);
1129        };
1130        if row.symbol.language != "csharp" {
1131            return Ok(None);
1132        }
1133        Ok(self
1134            .conn
1135            .query_row(
1136                "SELECT symbol_id FROM symbols
1137                 WHERE kind = 'namespace' AND language = 'csharp' AND name = ?1 LIMIT 1",
1138                [namespace],
1139                |r| r.get(0),
1140            )
1141            .optional()
1142            .map_err(QueryError::from)?)
1143    }
1144
1145    fn removed(
1146        &self,
1147        row: &Row,
1148        after: &[Row],
1149        renamed_to: Option<&str>,
1150    ) -> Result<Candidate, EditCheckError> {
1151        let owner = row.parents.split_last().and_then(|(name, above)| {
1152            after
1153                .iter()
1154                .find(|other| other.symbol.name == *name && other.parents == above)
1155        });
1156        let mut subject = row.symbol.clone();
1157        subject.symbol_id = format!("removed:{}", subject.symbol_id);
1158        subject.parent_symbol_id = match owner {
1159            Some(owner) => Some(owner.symbol.symbol_id.clone()),
1160            None => self.namespace_elsewhere(row)?,
1161        };
1162        Ok(Candidate {
1163            kind: row.symbol.kind.clone(),
1164            display: row.display(),
1165            path: row.symbol.path.clone(),
1166            line: row.symbol.start_line,
1167            change: match renamed_to {
1168                Some(new) => format!("renamed to `{new}`"),
1169                None => "removed".to_string(),
1170            },
1171            rank_name: row.symbol.name.clone(),
1172            uses: 0,
1173            subject,
1174            what: What::Removed {
1175                overloads: self.remaining_overloads(row, after)?,
1176                fixture: pytest_fixture_name(&row.symbol),
1177                condition: preprocessor_condition(&self.base.conn, &row.symbol.symbol_id)?,
1178            },
1179        })
1180    }
1181
1182    /// The overloads of a removed callable that remain under its name in the same owner. A method
1183    /// that the index gives no owner (a Go method) shares its key with the methods of other
1184    /// types, so it has none. A constructor of a class that has one the index lists no symbol for
1185    /// keeps an overload with no range.
1186    fn remaining_overloads(
1187        &self,
1188        row: &Row,
1189        after: &[Row],
1190    ) -> Result<Option<Overloads>, EditCheckError> {
1191        let ownerless = row.symbol.kind == "method" && row.parents.is_empty();
1192        if !CALLABLE_KINDS.contains(&row.symbol.kind.as_str()) || ownerless {
1193            return Ok(None);
1194        }
1195        let key = row.key();
1196        let remaining: Vec<Symbol> = after
1197            .iter()
1198            .filter(|other| other.key() == key)
1199            .map(|other| other.symbol.clone())
1200            .collect();
1201        let hidden = row.symbol.kind == "constructor"
1202            && arity::has_unlisted_constructor(&self.base.conn, &row.symbol)?;
1203        if remaining.is_empty() && !hidden {
1204            return Ok(None);
1205        }
1206        Ok(Some(Overloads {
1207            remaining: if hidden {
1208                None
1209            } else {
1210                arity::declared_group_arity(self.conn, self.root, &remaining)?
1211            },
1212            removed: arity::declared_group_arity(
1213                &self.base.conn,
1214                &self.base.tree,
1215                std::slice::from_ref(&row.symbol),
1216            )?,
1217        }))
1218    }
1219
1220    fn changed_pair(
1221        &self,
1222        before: &[Row],
1223        old: usize,
1224        after: &[Row],
1225        new: usize,
1226    ) -> Result<Option<Candidate>, EditCheckError> {
1227        let (was, is) = (&before[old], &after[new]);
1228        let kind = is.symbol.kind.as_str();
1229        let (change, what) = if CALLABLE_KINDS.contains(&kind) {
1230            match self.callable_change(&was.symbol, &is.symbol)? {
1231                Some(found) => found,
1232                None => return Ok(None),
1233            }
1234        } else if FIELD_OWNER_KINDS.contains(&kind) {
1235            let dataclass = is
1236                .symbol
1237                .signature
1238                .as_deref()
1239                .is_some_and(|sig| sig.starts_with("@dataclass"));
1240            let old_components = was
1241                .symbol
1242                .signature
1243                .as_deref()
1244                .filter(|_| is.symbol.language == "java")
1245                .and_then(|sig| fields::record_components(sig, &was.symbol.name));
1246            let is_field = |row: &Row| {
1247                (FIELD_KINDS.contains(&row.symbol.kind.as_str())
1248                    || dataclass && is_annotated_variable(&row.symbol))
1249                    && !row.symbol.signature.as_deref().is_some_and(|sig| {
1250                        ATTRIBUTE_WRITE_PREFIXES.iter().any(|p| sig.starts_with(p))
1251                    })
1252            };
1253            let (added, removed) = member_changes(before, &was.symbol, after, &is.symbol, is_field);
1254            if added.is_empty() && removed.is_empty() {
1255                return Ok(None);
1256            }
1257            let required = after.iter().any(|row| {
1258                row.symbol.parent_symbol_id.as_deref() == Some(&is.symbol.symbol_id)
1259                    && is_field(row)
1260                    && added.contains(&row.symbol.name)
1261                    && fields::needs_argument(row.symbol.signature.as_deref())
1262            });
1263            (
1264                format!("fields {}", member_text(&added, &removed)),
1265                What::Fields(FieldChange {
1266                    added,
1267                    removed,
1268                    required,
1269                    old_components,
1270                }),
1271            )
1272        } else if kind == "enum" {
1273            let c_enum = is.symbol.language == "c";
1274            let (added, removed) = member_changes(before, &was.symbol, after, &is.symbol, |row| {
1275                row.symbol.kind == "enum_member" || (c_enum && row.symbol.kind == "constant")
1276            });
1277            if added.is_empty() && removed.is_empty() {
1278                return Ok(None);
1279            }
1280            (
1281                format!("variants {}", member_text(&added, &removed)),
1282                What::Variants { added, removed },
1283            )
1284        } else {
1285            return Ok(None);
1286        };
1287        Ok(Some(Candidate {
1288            kind: is.symbol.kind.clone(),
1289            display: is.display(),
1290            path: is.symbol.path.clone(),
1291            line: is.symbol.start_line,
1292            change,
1293            rank_name: is.symbol.name.clone(),
1294            uses: 0,
1295            subject: is.symbol.clone(),
1296            what,
1297        }))
1298    }
1299
1300    fn callable_change(
1301        &self,
1302        was: &Symbol,
1303        is: &Symbol,
1304    ) -> Result<Option<(String, What)>, EditCheckError> {
1305        let (before, after) = (
1306            self.parameter_shape(&self.base.conn, &self.base.tree, was)?,
1307            self.parameter_shape(self.conn, self.root, is)?,
1308        );
1309        if before.differs(&after) {
1310            return Ok(Some((before.change_text(&after), What::Parameters)));
1311        }
1312        let shapes = (
1313            Shape::of(&self.base.conn, was, &self.base.tree)?,
1314            Shape::of(self.conn, is, self.root)?,
1315        );
1316        Ok(match shapes.0.compare(&shapes.1, &is.language) {
1317            Difference::None => None,
1318            Difference::Signature => Some(("signature changed".to_string(), What::Signature)),
1319            Difference::Modifiers(words) => Some((
1320                format!("signature changed: {}", words.join(", ")),
1321                What::Signature,
1322            )),
1323            Difference::Renamed(renames) => {
1324                let pairs: Vec<String> = renames
1325                    .iter()
1326                    .map(|(old, new)| format!("{old} → {new}"))
1327                    .collect();
1328                Some((
1329                    format!("parameter renamed: {}", pairs.join(", ")),
1330                    What::Renamed { renames },
1331                ))
1332            }
1333        })
1334    }
1335
1336    fn parameter_shape(
1337        &self,
1338        conn: &Connection,
1339        root: &Path,
1340        symbol: &Symbol,
1341    ) -> Result<ParameterShape, EditCheckError> {
1342        Ok(ParameterShape {
1343            range: arity::group_arity(conn, root, std::slice::from_ref(symbol), false)?,
1344            count: arity::parameter_count(conn, symbol)? + signatures::ignored_parameters(symbol),
1345        })
1346    }
1347}
1348
1349/// What a caller must pass to a callable: the accepted range when the index knows it, else the
1350/// number of parameter rows.
1351struct ParameterShape {
1352    range: Option<Arity>,
1353    count: usize,
1354}
1355
1356impl ParameterShape {
1357    fn differs(&self, other: &ParameterShape) -> bool {
1358        match (&self.range, &other.range) {
1359            (Some(a), Some(b)) => a != b,
1360            _ => self.count != other.count,
1361        }
1362    }
1363
1364    /// The range when both sides have one, else the row count, so the two texts compare alike.
1365    fn text(&self, other: &ParameterShape) -> String {
1366        match (&self.range, &other.range) {
1367            (Some(range), Some(_)) => range.range_text(),
1368            _ => self.count.to_string(),
1369        }
1370    }
1371
1372    fn change_text(&self, other: &ParameterShape) -> String {
1373        parameters_change(
1374            (self.text(other), other.text(self)),
1375            (self.count, other.count),
1376        )
1377    }
1378}
1379
1380/// `parameters 2 → 3` from the two range texts. When they read alike (a `self` parameter that came
1381/// or went), the row counts say it, and `parameters changed` when those are equal too.
1382fn parameters_change((was, is): (String, String), (was_rows, is_rows): (usize, usize)) -> String {
1383    if was != is {
1384        return format!("parameters {was} → {is}");
1385    }
1386    if was_rows != is_rows {
1387        return format!("parameters {was_rows} → {is_rows}");
1388    }
1389    "parameters changed".to_string()
1390}
1391
1392/// A class-body variable with a type annotation that is no `ClassVar`, which is a field of a
1393/// Python dataclass.
1394fn is_annotated_variable(symbol: &Symbol) -> bool {
1395    symbol.kind == "variable"
1396        && symbol
1397            .signature
1398            .as_deref()
1399            .is_some_and(fields::is_dataclass_field)
1400}
1401
1402/// The names of the members of `was` that `is` lacks and the names it gains, among the children
1403/// of each that `member` accepts.
1404fn member_changes(
1405    before: &[Row],
1406    was: &Symbol,
1407    after: &[Row],
1408    is: &Symbol,
1409    member: impl Fn(&Row) -> bool,
1410) -> (Vec<String>, Vec<String>) {
1411    let names = |rows: &[Row], owner: &Symbol| -> BTreeSet<String> {
1412        rows.iter()
1413            .filter(|row| row.symbol.parent_symbol_id.as_deref() == Some(&owner.symbol_id))
1414            .filter(|row| member(row))
1415            .map(|row| row.symbol.name.clone())
1416            .collect()
1417    };
1418    let (old, new) = (names(before, was), names(after, is));
1419    (
1420        new.difference(&old).cloned().collect(),
1421        old.difference(&new).cloned().collect(),
1422    )
1423}
1424
1425/// `+1 (z), -0`: how many members a type gained and lost, and the names of each.
1426fn member_text(added: &[String], removed: &[String]) -> String {
1427    let part = |sign: char, names: &[String]| {
1428        if names.is_empty() {
1429            format!("{sign}0")
1430        } else {
1431            format!("{sign}{} ({})", names.len(), names.join(", "))
1432        }
1433    };
1434    format!("{}, {}", part('+', added), part('-', removed))
1435}
1436
1437fn name_uses(conn: &Connection, name: &str) -> Result<usize, EditCheckError> {
1438    let count: i64 = conn
1439        .query_row(
1440            "SELECT (SELECT COUNT(*) FROM identifiers WHERE name = ?1)
1441                  + (SELECT COUNT(*) FROM pending_relationships WHERE target_terminal_name = ?1)",
1442            [name],
1443            |row| row.get(0),
1444        )
1445        .map_err(QueryError::from)?;
1446    Ok(count.max(0) as usize)
1447}
1448
1449#[derive(Default)]
1450struct Placed {
1451    in_diff: Vec<EditSite>,
1452    proven: Vec<EditSite>,
1453    likely: Vec<EditSite>,
1454    unresolved: usize,
1455}
1456
1457impl Placed {
1458    fn add(&mut self, label: SiteLabel, reached: bool, site: EditSite) {
1459        if reached {
1460            self.in_diff.push(site);
1461            return;
1462        }
1463        match label {
1464            SiteLabel::Proven => self.proven.push(site),
1465            SiteLabel::Likely => self.likely.push(site),
1466            SiteLabel::Unresolved => self.unresolved += 1,
1467            SiteLabel::Excluded => {}
1468        }
1469    }
1470}
1471
1472struct Listed {
1473    placed: Placed,
1474    tests: Vec<String>,
1475}
1476
1477struct SiteLister<'a> {
1478    conn: &'a Connection,
1479    root: &'a Path,
1480    hunks: &'a Hunks,
1481    sources: HashMap<String, Option<Vec<String>>>,
1482    languages: HashMap<String, String>,
1483}
1484
1485fn is_call(kind: &str) -> bool {
1486    matches!(kind, "calls" | "call" | "instantiates")
1487}
1488
1489fn site_span(labeled: &LabeledSite) -> (usize, usize) {
1490    let first = labeled.site.start_line.unwrap_or(0);
1491    (first, labeled.end_line.unwrap_or(first).max(first))
1492}
1493
1494fn args_noun(count: u32) -> &'static str {
1495    if count == 1 { "arg" } else { "args" }
1496}
1497
1498fn edit_site(labeled: &LabeledSite, line: usize, note: Option<String>) -> EditSite {
1499    EditSite {
1500        path: labeled.site.path.clone(),
1501        line,
1502        caller: displayed_symbol_name(
1503            &labeled.site.from_symbol_name,
1504            labeled.site.enclosing_symbol_name.as_deref(),
1505        ),
1506        note,
1507    }
1508}
1509
1510impl<'a> SiteLister<'a> {
1511    fn new(conn: &'a Connection, root: &'a Path, hunks: &'a Hunks) -> Self {
1512        Self {
1513            conn,
1514            root,
1515            hunks,
1516            sources: HashMap::new(),
1517            languages: HashMap::new(),
1518        }
1519    }
1520
1521    fn list(&mut self, candidate: &Candidate) -> Result<Listed, EditCheckError> {
1522        let condition = match &candidate.what {
1523            What::Removed { condition, .. } => condition.as_deref(),
1524            _ => None,
1525        };
1526        let report =
1527            reference_report_for_edit(self.conn, self.root, &candidate.subject, condition)?;
1528        let placed = match &candidate.what {
1529            What::Parameters => {
1530                let range = inherited_group_arity(self.conn, self.root, &candidate.subject)?;
1531                let typed = TypedOverloads::of(self.conn, &candidate.subject)?;
1532                let mut placed = self.calls(&report, range.as_ref(), typed.as_ref());
1533                self.overrides(&report, &mut placed)?;
1534                placed
1535            }
1536            What::Signature => {
1537                let typed = TypedOverloads::of(self.conn, &candidate.subject)?;
1538                let mut placed = self.calls(&report, None, typed.as_ref());
1539                self.overrides(&report, &mut placed)?;
1540                placed
1541            }
1542            What::Renamed { renames } => self.named_calls(&report, renames),
1543            What::Removed {
1544                overloads: Some(overloads),
1545                ..
1546            } => self.removed_overloads(&report, overloads),
1547            What::Removed {
1548                overloads: None,
1549                fixture,
1550                ..
1551            } => {
1552                let mut placed = self.every_site(&report);
1553                self.imports(&report, &mut placed);
1554                if let Some(name) = fixture {
1555                    self.fixture_users(name, &candidate.subject.path, &mut placed)?;
1556                }
1557                placed
1558            }
1559            What::Fields(change) => self.constructions(&report, &candidate.subject, change)?,
1560            What::Variants { added, removed } => {
1561                self.variant_uses(&report, &candidate.subject, added, removed)?
1562            }
1563        };
1564        let tests = report
1565            .tests
1566            .iter()
1567            .filter_map(|test| test.command.clone())
1568            .collect();
1569        Ok(Listed { placed, tests })
1570    }
1571
1572    fn calls(
1573        &mut self,
1574        report: &ReferenceReport,
1575        range: Option<&Arity>,
1576        typed: Option<&TypedOverloads>,
1577    ) -> Placed {
1578        let mut placed = Placed::default();
1579        for labeled in report
1580            .sites
1581            .iter()
1582            .filter(|site| site.label != SiteLabel::Excluded && is_call(&site.site.kind))
1583        {
1584            let (first, last) = site_span(labeled);
1585            let path = labeled.site.path.as_str();
1586            let touched = self.touches(path, first, last);
1587            if !touched
1588                && typed.is_some_and(|typed| {
1589                    typed.binds_another(self.conn, &labeled.site.to_symbol_name, path, first)
1590                })
1591            {
1592                continue;
1593            }
1594            let language = self.language_of(path);
1595            let arguments = range.map(|_| self.arguments(labeled)).unwrap_or_default();
1596            let count = range
1597                .and_then(|range| arguments.rejected_by(range, &language))
1598                .or_else(|| arguments.counts.first().copied());
1599            let verdict = range
1600                .zip(count)
1601                .and_then(|(range, count)| Some((range, count, range.accepts(&language, count)?)));
1602            if !touched && verdict.is_some_and(|(_, _, fits)| fits) {
1603                continue;
1604            }
1605            if !touched && verdict.is_none() && arguments.spread {
1606                placed.unresolved += 1;
1607                continue;
1608            }
1609            let rejected = verdict.filter(|(_, _, fits)| !fits);
1610            let note = rejected.map(|(range, count, _)| {
1611                format!(
1612                    "passes {count} {}; takes {}",
1613                    args_noun(count),
1614                    range.range_text()
1615                )
1616            });
1617            placed.add(
1618                labeled.label,
1619                touched && rejected.is_none(),
1620                edit_site(labeled, first, note),
1621            );
1622        }
1623        placed
1624    }
1625
1626    /// The argument counts of the call at the site: the count the report gives, else the counts of
1627    /// the calls of the name on its line. A line with two calls of one name gets no count in the
1628    /// report, so the call rows are read.
1629    fn arguments(&mut self, labeled: &LabeledSite) -> CallArguments {
1630        if let Some(count) = labeled.arg_count {
1631            return CallArguments {
1632                counts: vec![count],
1633                spread: false,
1634            };
1635        }
1636        if !is_call(&labeled.site.kind) {
1637            return CallArguments::default();
1638        }
1639        let mut arguments = self.line_arguments(labeled).unwrap_or_default();
1640        if arguments.counts.is_empty() && !arguments.spread {
1641            arguments.counts = self.written_counts(labeled);
1642        }
1643        arguments
1644    }
1645
1646    /// The argument counts of the Java calls of the name on the line of the site, read from the
1647    /// source, because julie writes no count for `new Foo(a)`.
1648    fn written_counts(&mut self, labeled: &LabeledSite) -> Vec<u32> {
1649        let path = labeled.site.path.as_str();
1650        if self.language_of(path) != "java" {
1651            return Vec::new();
1652        }
1653        let line = labeled.site.start_line.unwrap_or(0);
1654        self.source(path)
1655            .map(|lines| fields::call_counts(lines, line, &labeled.site.to_symbol_name))
1656            .unwrap_or_default()
1657            .into_iter()
1658            .filter_map(|count| u32::try_from(count).ok())
1659            .collect()
1660    }
1661
1662    fn line_arguments(&self, labeled: &LabeledSite) -> Option<CallArguments> {
1663        let path = labeled.site.path.as_str();
1664        let mut stmt = self.conn.prepare(CALL_ARGUMENTS_SQL).ok()?;
1665        let rows = stmt
1666            .query_map(
1667                params![
1668                    labeled.site.to_symbol_name,
1669                    path,
1670                    path.replace('/', "\\"),
1671                    labeled.site.start_line.unwrap_or(0) as i64
1672                ],
1673                |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, bool>(1)?)),
1674            )
1675            .ok()?;
1676        let mut arguments = CallArguments::default();
1677        for (count, spread) in rows.filter_map(Result::ok) {
1678            arguments.spread |= spread;
1679            if let Some(count) = count
1680                .filter(|_| !spread)
1681                .and_then(|c| u32::try_from(c).ok())
1682            {
1683                arguments.counts.push(count);
1684            }
1685        }
1686        Some(arguments)
1687    }
1688
1689    /// The sites of a removed or renamed overload when other overloads of the name remain. A call
1690    /// that only a remaining overload takes is no site, a call that only the removed one took is,
1691    /// and a call that both take, a call with no known count, and every call while the remaining
1692    /// overloads have no range are counted as unresolved.
1693    fn removed_overloads(&mut self, report: &ReferenceReport, overloads: &Overloads) -> Placed {
1694        let mut placed = Placed::default();
1695        for labeled in report
1696            .sites
1697            .iter()
1698            .filter(|site| site.label != SiteLabel::Excluded)
1699        {
1700            let (first, last) = site_span(labeled);
1701            let path = labeled.site.path.as_str();
1702            let language = self.language_of(path);
1703            let reach = match &overloads.remaining {
1704                Some(remaining) => overloads.judge(remaining, &language, &self.arguments(labeled)),
1705                None => Reach::Unknown,
1706            };
1707            match reach {
1708                Reach::Fits => {}
1709                Reach::Unknown => {
1710                    placed.unresolved += usize::from(!self.touches(path, first, last));
1711                }
1712                Reach::OnlyRemoved(count) => {
1713                    let note = format!(
1714                        "passes {count} {}; no remaining overload takes it",
1715                        args_noun(count)
1716                    );
1717                    placed.add(labeled.label, false, edit_site(labeled, first, Some(note)));
1718                }
1719            }
1720        }
1721        placed
1722    }
1723
1724    /// The classes that implement or extend the owner of a changed method, each with its own
1725    /// version of the method. The section gives no label, so a row is `likely`.
1726    fn overrides(
1727        &self,
1728        report: &ReferenceReport,
1729        placed: &mut Placed,
1730    ) -> Result<(), EditCheckError> {
1731        for row in &report.overrides {
1732            let (first, last) = self
1733                .symbol_span(&row.symbol_id)?
1734                .unwrap_or((row.line, row.line));
1735            let reached = self.touches(&row.path, first, last);
1736            placed.add(
1737                SiteLabel::Likely,
1738                reached,
1739                EditSite {
1740                    path: row.path.clone(),
1741                    line: row.line,
1742                    caller: format!("{}.{}", row.owner, row.name),
1743                    note: None,
1744                },
1745            );
1746        }
1747        Ok(())
1748    }
1749
1750    /// The import lines of a removed symbol's name that name its file, each `likely`.
1751    fn imports(&self, report: &ReferenceReport, placed: &mut Placed) {
1752        for (path, line) in &report.imports {
1753            let reached = self.touches(path, *line, *line);
1754            placed.add(
1755                SiteLabel::Likely,
1756                reached,
1757                EditSite {
1758                    path: path.clone(),
1759                    line: *line,
1760                    caller: String::new(),
1761                    note: Some(format!("imports `{}`", report.target.name)),
1762                },
1763            );
1764        }
1765    }
1766
1767    /// The calls that pass an old parameter name as a named argument. A call that still does so
1768    /// is stale even when the diff touched its line.
1769    fn named_calls(&mut self, report: &ReferenceReport, renames: &[(String, String)]) -> Placed {
1770        let mut placed = Placed::default();
1771        for labeled in report
1772            .sites
1773            .iter()
1774            .filter(|site| site.label != SiteLabel::Excluded && is_call(&site.site.kind))
1775        {
1776            let (first, last) = site_span(labeled);
1777            let path = labeled.site.path.as_str();
1778            let callee = labeled.site.to_symbol_name.as_str();
1779            let language = self.language_of(path);
1780            let Some(lines) = self.source(path) else {
1781                continue;
1782            };
1783            let last = last.max(construction_end(lines, first, callee));
1784            let span: Vec<&str> = lines
1785                .get(first.saturating_sub(1)..last.min(lines.len()))
1786                .unwrap_or_default()
1787                .iter()
1788                .map(String::as_str)
1789                .collect();
1790            let named = renames
1791                .iter()
1792                .find(|(old, _)| signatures::names_parameter(&span, callee, old, &language));
1793            if let Some((old, _)) = named {
1794                let note = Some(format!("names parameter `{old}`"));
1795                placed.add(labeled.label, false, edit_site(labeled, first, note));
1796            }
1797        }
1798        placed
1799    }
1800
1801    /// The tests and fixtures in the scope of a pytest fixture that take it as a parameter, and
1802    /// the reads of that parameter in their bodies. A user that a fixture of the same name in its
1803    /// own file or in a nearer `conftest.py` serves is left out.
1804    fn fixture_users(
1805        &self,
1806        name: &str,
1807        fixture_path: &str,
1808        placed: &mut Placed,
1809    ) -> Result<(), EditCheckError> {
1810        let fixture_path = fixture_path.replace('\\', "/");
1811        let fixture_dir = fixture_path
1812            .strip_suffix("conftest.py")
1813            .map(|dir| dir.trim_end_matches('/'));
1814        let scope = match fixture_dir {
1815            Some("") => "%".to_string(),
1816            Some(dir) => format!("{}/%", escape_like(dir)),
1817            None => escape_like(&fixture_path),
1818        };
1819        let (overrides, users) = fixture_rows(self.conn, name, &scope).map_err(QueryError::from)?;
1820        let depth = fixture_dir.map_or(usize::MAX, str::len);
1821        let served_elsewhere = |path: &str| {
1822            overrides.iter().any(|other| {
1823                other == path
1824                    || other.strip_suffix("conftest.py").is_some_and(|dir| {
1825                        let dir = dir.trim_end_matches('/');
1826                        dir.len() > depth && path.starts_with(&format!("{dir}/"))
1827                    })
1828            })
1829        };
1830        let mut listed: HashSet<(String, usize)> =
1831            [&placed.in_diff, &placed.proven, &placed.likely]
1832                .into_iter()
1833                .flatten()
1834                .map(|site| (site.path.clone(), site.line))
1835                .collect();
1836        let mut reads = self
1837            .conn
1838            .prepare(PARAMETER_READS_SQL)
1839            .map_err(QueryError::from)?;
1840        for (path, line, caller, first, last) in users {
1841            let shown = path.replace('\\', "/");
1842            if served_elsewhere(&shown) {
1843                continue;
1844            }
1845            let read_lines: Vec<i64> = reads
1846                .query_map(params![name, path, first as i64, last as i64], |row| {
1847                    row.get(0)
1848                })
1849                .and_then(Iterator::collect)
1850                .map_err(QueryError::from)?;
1851            let note = format!("takes the fixture `{name}`");
1852            let sites = std::iter::once((line, Some(note)))
1853                .chain(read_lines.into_iter().map(|read| (read as usize, None)));
1854            for (line, note) in sites {
1855                if !listed.insert((shown.clone(), line)) {
1856                    continue;
1857                }
1858                let reached = self.touches(&shown, line, line);
1859                placed.add(
1860                    SiteLabel::Proven,
1861                    reached,
1862                    EditSite {
1863                        path: shown.clone(),
1864                        line,
1865                        caller: caller.clone(),
1866                        note,
1867                    },
1868                );
1869            }
1870        }
1871        Ok(())
1872    }
1873
1874    fn every_site(&mut self, report: &ReferenceReport) -> Placed {
1875        let mut placed = Placed::default();
1876        for labeled in report
1877            .sites
1878            .iter()
1879            .filter(|site| site.label != SiteLabel::Excluded)
1880        {
1881            let (first, last) = site_span(labeled);
1882            let reached = self.touches(&labeled.site.path, first, last);
1883            placed.add(labeled.label, reached, edit_site(labeled, first, None));
1884        }
1885        placed
1886    }
1887
1888    fn constructions(
1889        &mut self,
1890        report: &ReferenceReport,
1891        subject: &Symbol,
1892        change: &FieldChange,
1893    ) -> Result<Placed, EditCheckError> {
1894        let rust_struct = subject.language == "rust" && subject.kind == "struct";
1895        let mut rows: Vec<ConstructionSite> = report.constructions.clone();
1896        if rust_struct {
1897            for pattern in destructurings(self.root, &report.target.name, &report.sites) {
1898                if !rows
1899                    .iter()
1900                    .any(|row| row.path == pattern.path && row.line == pattern.line)
1901                {
1902                    rows.push(pattern);
1903                }
1904            }
1905        }
1906        let mut placed = Placed::default();
1907        for construction in &rows {
1908            let site = report
1909                .sites
1910                .iter()
1911                .filter(|site| {
1912                    site.label != SiteLabel::Excluded
1913                        && site.site.path == construction.path
1914                        && site.site.start_line == Some(construction.line)
1915                })
1916                .min_by_key(|site| site.label.rank());
1917            let lines = self.source(&construction.path).map(Vec::as_slice);
1918            if !change.breaks(subject, lines, construction.line) {
1919                continue;
1920            }
1921            let last = self
1922                .source(&construction.path)
1923                .map_or(construction.line, |lines| {
1924                    construction_end(lines, construction.line, &report.target.name)
1925                });
1926            let reached = self.touches(&construction.path, construction.line, last);
1927            let caller = site
1928                .map(|site| {
1929                    displayed_symbol_name(
1930                        &site.site.from_symbol_name,
1931                        site.site.enclosing_symbol_name.as_deref(),
1932                    )
1933                })
1934                .unwrap_or_default();
1935            placed.add(
1936                site.map_or(SiteLabel::Likely, |site| site.label),
1937                reached,
1938                EditSite {
1939                    path: construction.path.clone(),
1940                    line: construction.line,
1941                    caller,
1942                    note: None,
1943                },
1944            );
1945        }
1946        if rust_struct {
1947            self.self_literals(subject, change, &rows, &mut placed)?;
1948        }
1949        Ok(placed)
1950    }
1951
1952    /// The `Self { … }` literals and patterns in the methods of a Rust struct, which no
1953    /// `Constructions` row holds, because they do not name the struct.
1954    fn self_literals(
1955        &mut self,
1956        subject: &Symbol,
1957        change: &FieldChange,
1958        listed: &[ConstructionSite],
1959        placed: &mut Placed,
1960    ) -> Result<(), EditCheckError> {
1961        for method in self.owned_methods(&subject.symbol_id)? {
1962            for line in method.first..=method.last {
1963                let Some(lines) = self.source(&method.path) else {
1964                    break;
1965                };
1966                let names_self = lines
1967                    .get(line - 1)
1968                    .is_some_and(|text| word_position(text, "Self").is_some());
1969                let already = listed
1970                    .iter()
1971                    .any(|row| row.path == method.path && row.line == line);
1972                if !names_self || already || !change.breaks_self(lines, line) {
1973                    continue;
1974                }
1975                let last = construction_end(lines, line, "Self");
1976                let reached = self.touches(&method.path, line, last);
1977                placed.add(
1978                    SiteLabel::Proven,
1979                    reached,
1980                    EditSite {
1981                        path: method.path.clone(),
1982                        line,
1983                        caller: format!("{}.{}", subject.name, method.name),
1984                        note: None,
1985                    },
1986                );
1987            }
1988        }
1989        Ok(())
1990    }
1991
1992    fn owned_methods(&self, owner_id: &str) -> Result<Vec<OwnedMethod>, EditCheckError> {
1993        let mut stmt = self
1994            .conn
1995            .prepare(OWNED_METHODS_SQL)
1996            .map_err(QueryError::from)?;
1997        let methods = stmt
1998            .query_map([owner_id], |row| {
1999                Ok(OwnedMethod {
2000                    name: row.get(0)?,
2001                    path: row.get::<_, String>(1)?.replace('\\', "/"),
2002                    first: row.get::<_, i64>(2)?.max(1) as usize,
2003                    last: row.get::<_, i64>(3)?.max(1) as usize,
2004                })
2005            })
2006            .map_err(QueryError::from)?
2007            .collect::<Result<Vec<_>, _>>()
2008            .map_err(QueryError::from)?;
2009        Ok(methods)
2010    }
2011
2012    /// The functions that name a removed variant, each at its first use of one and never in the
2013    /// diff, then the functions that need an arm for an added variant.
2014    fn variant_uses(
2015        &mut self,
2016        report: &ReferenceReport,
2017        subject: &Symbol,
2018        added: &[String],
2019        removed: &[String],
2020    ) -> Result<Placed, EditCheckError> {
2021        let mut placed = Placed::default();
2022        let mut listed = HashSet::new();
2023        for used in uses_of_variants(self.conn, self.root, subject, &report.sites, removed)? {
2024            let span = self.symbol_span(&used.caller_id)?;
2025            let label = variant_label(report, &used, span);
2026            listed.insert((used.path.clone(), used.caller_id.clone()));
2027            placed.add(label, false, variant_site(&used));
2028        }
2029        if added.is_empty() {
2030            return Ok(placed);
2031        }
2032        let variants = enum_variant_names(self.conn, subject)?;
2033        for used in &report.variant_uses {
2034            if listed.contains(&(used.path.clone(), used.caller_id.clone())) {
2035                continue;
2036            }
2037            let span = self.symbol_span(&used.caller_id)?;
2038            let (first, last) = span.unwrap_or((used.line, used.line));
2039            let reached = self.names_in_diff(&used.path, first, last, added);
2040            match self.needs_arm(&used.path, span, &report.target.name, &variants) {
2041                Verdict::Unresolved => placed.unresolved += usize::from(!reached),
2042                Verdict::Site => {
2043                    let label = variant_label(report, used, span);
2044                    placed.add(label, reached, variant_site(used));
2045                }
2046            }
2047        }
2048        Ok(placed)
2049    }
2050
2051    /// Whether the function that spans `span` of `path` needs a new arm for a new variant of
2052    /// the enum `enum_name`, judged from its lines.
2053    fn needs_arm(
2054        &mut self,
2055        path: &str,
2056        span: Option<(usize, usize)>,
2057        enum_name: &str,
2058        variants: &[String],
2059    ) -> Verdict {
2060        let Some((first, last)) = span else {
2061            return Verdict::Unresolved;
2062        };
2063        let language = self.language_of(path);
2064        let Some(lines) = self.source(path) else {
2065            return Verdict::Unresolved;
2066        };
2067        let Some(function) = lines.get(first.saturating_sub(1)..last.min(lines.len())) else {
2068            return Verdict::Unresolved;
2069        };
2070        let bare = match language.as_str() {
2071            "java" | "c" | "cpp" => true,
2072            "rust" => lines.iter().any(|line| imports_variants(line, enum_name)),
2073            _ => false,
2074        };
2075        arms::judge(
2076            &language,
2077            function,
2078            &arms::EnumNames {
2079                enum_name,
2080                variants,
2081                bare,
2082            },
2083        )
2084    }
2085
2086    fn touches(&self, path: &str, first: usize, last: usize) -> bool {
2087        self.hunks
2088            .get(path)
2089            .is_some_and(|ranges| ranges.iter().any(|&(a, b)| a <= last && first <= b))
2090    }
2091
2092    /// Whether the diff changed a line of `first..=last` that names one of `added`.
2093    fn names_in_diff(&mut self, path: &str, first: usize, last: usize, added: &[String]) -> bool {
2094        let ranges = self.hunks.get(path).cloned().unwrap_or_default();
2095        let Some(lines) = self.source(path) else {
2096            return false;
2097        };
2098        ranges.iter().any(|&(a, b)| {
2099            (a.max(first)..=b.min(last)).any(|line| {
2100                lines.get(line.wrapping_sub(1)).is_some_and(|text| {
2101                    added.iter().any(|name| word_position(text, name).is_some())
2102                })
2103            })
2104        })
2105    }
2106
2107    fn source(&mut self, path: &str) -> Option<&Vec<String>> {
2108        let root = self.root;
2109        self.sources
2110            .entry(path.to_string())
2111            .or_insert_with(|| {
2112                std::fs::read_to_string(root.join(path))
2113                    .ok()
2114                    .map(|text| text.lines().map(str::to_string).collect())
2115            })
2116            .as_ref()
2117    }
2118
2119    fn language_of(&mut self, path: &str) -> String {
2120        if let Some(language) = self.languages.get(path) {
2121            return language.clone();
2122        }
2123        let language = self
2124            .conn
2125            .query_row(
2126                "SELECT language FROM files WHERE path = ?1 OR path = ?2",
2127                params![path, path.replace('/', "\\")],
2128                |row| row.get::<_, String>(0),
2129            )
2130            .optional()
2131            .ok()
2132            .flatten()
2133            .unwrap_or_default();
2134        self.languages.insert(path.to_string(), language.clone());
2135        language
2136    }
2137
2138    fn symbol_span(&self, symbol_id: &str) -> Result<Option<(usize, usize)>, EditCheckError> {
2139        if symbol_id.is_empty() {
2140            return Ok(None);
2141        }
2142        Ok(self
2143            .conn
2144            .query_row(
2145                "SELECT start_line, end_line FROM symbols WHERE symbol_id = ?1",
2146                [symbol_id],
2147                |row| {
2148                    Ok((
2149                        row.get::<_, i64>(0)? as usize,
2150                        row.get::<_, i64>(1)? as usize,
2151                    ))
2152                },
2153            )
2154            .optional()
2155            .map_err(QueryError::from)?)
2156    }
2157}
2158
2159/// The best label among the sites of the enum's name inside the function of `used`.
2160fn variant_label(
2161    report: &ReferenceReport,
2162    used: &VariantUse,
2163    span: Option<(usize, usize)>,
2164) -> SiteLabel {
2165    let (first, last) = span.unwrap_or((used.line, used.line));
2166    report
2167        .sites
2168        .iter()
2169        .filter(|site| {
2170            site.label != SiteLabel::Excluded
2171                && site.site.path == used.path
2172                && site
2173                    .site
2174                    .start_line
2175                    .is_some_and(|line| (first..=last).contains(&line))
2176        })
2177        .map(|site| site.label)
2178        .min_by_key(|label| label.rank())
2179        .unwrap_or(SiteLabel::Likely)
2180}
2181
2182fn variant_site(used: &VariantUse) -> EditSite {
2183    EditSite {
2184        path: used.path.clone(),
2185        line: used.line,
2186        caller: used.caller.clone(),
2187        note: None,
2188    }
2189}
2190
2191/// Whether `line` is a `use` item that brings in variants of `enum_name` (`use a::Kind::*;`,
2192/// `use a::Kind::{A, B};`, `use a::Kind::A;`).
2193fn imports_variants(line: &str, enum_name: &str) -> bool {
2194    let item = line.trim_start();
2195    let item = item
2196        .strip_prefix("pub")
2197        .map_or(item, |rest| match rest.trim_start().strip_prefix('(') {
2198            Some(group) => group.split_once(')').map_or(rest, |(_, after)| after),
2199            None => rest,
2200        })
2201        .trim_start();
2202    item.starts_with("use ") && item.contains(&format!("{enum_name}::"))
2203}
2204
2205/// The last line of the literal or call that starts at `line` and names `name`: the line of the
2206/// bracket that closes the first one opened after the name. `line` when the line opens none.
2207fn construction_end(lines: &[String], line: usize, name: &str) -> usize {
2208    let Some(first) = line.checked_sub(1).and_then(|index| lines.get(index)) else {
2209        return line;
2210    };
2211    let from = word_position(first, name).unwrap_or(0);
2212    let mut depth = 0usize;
2213    let mut quote: Option<char> = None;
2214    let mut escaped = false;
2215    for (index, text) in lines
2216        .iter()
2217        .enumerate()
2218        .skip(line - 1)
2219        .take(MAX_EXTENT_LINES)
2220    {
2221        let text = if index == line - 1 {
2222            &first[from..]
2223        } else {
2224            text
2225        };
2226        for c in text.chars() {
2227            if let Some(open) = quote {
2228                match (escaped, c) {
2229                    (true, _) => escaped = false,
2230                    (false, '\\') => escaped = true,
2231                    (false, c) if c == open => quote = None,
2232                    _ => {}
2233                }
2234                continue;
2235            }
2236            match c {
2237                '"' | '\'' if depth > 0 => quote = Some(c),
2238                '{' | '(' | '[' => depth += 1,
2239                '}' | ')' | ']' if depth > 0 => {
2240                    depth -= 1;
2241                    if depth == 0 {
2242                        return index + 1;
2243                    }
2244                }
2245                _ => {}
2246            }
2247        }
2248        if depth == 0 {
2249            return line;
2250        }
2251    }
2252    line
2253}
2254
2255#[cfg(test)]
2256fn test_symbol(language: &str, kind: &str, name: &str, signature: &str) -> Symbol {
2257    Symbol {
2258        symbol_id: name.to_string(),
2259        file_id: String::new(),
2260        path: "a.py".to_string(),
2261        language: language.to_string(),
2262        name: name.to_string(),
2263        kind: kind.to_string(),
2264        signature: Some(signature.to_string()),
2265        doc_comment: None,
2266        visibility: None,
2267        parent_symbol_id: None,
2268        start_line: 1,
2269        start_column: 0,
2270        end_line: 1,
2271        end_column: 0,
2272        start_byte: 0,
2273        end_byte: 0,
2274        body_start_line: None,
2275        body_start_column: None,
2276        body_end_line: None,
2277        body_end_column: None,
2278        body_start_byte: None,
2279        body_end_byte: None,
2280        body_hash: None,
2281        semantic_group: None,
2282        is_test: false,
2283        test_container: false,
2284    }
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    use super::*;
2290
2291    fn row(
2292        name: &str,
2293        kind: &str,
2294        signature: &str,
2295        hash: Option<&str>,
2296        parents: &[&str],
2297        start: usize,
2298    ) -> Row {
2299        Row {
2300            symbol: Symbol {
2301                symbol_id: format!("{name}{start}"),
2302                start_line: start,
2303                end_line: start,
2304                start_byte: start,
2305                end_byte: start,
2306                body_hash: hash.map(str::to_string),
2307                ..test_symbol("python", kind, name, signature)
2308            },
2309            parents: parents.iter().map(|p| p.to_string()).collect(),
2310        }
2311    }
2312
2313    fn file(path: &str) -> ChangedFile {
2314        ChangedFile {
2315            path: path.to_string(),
2316            base_path: Some(path.to_string()),
2317            untracked: false,
2318        }
2319    }
2320
2321    #[test]
2322    fn more_files_than_the_limit_are_cut_and_the_cut_is_stated() {
2323        let files: Vec<ChangedFile> = (0..250).map(|n| file(&format!("f{n}.py"))).collect();
2324        let (kept, cut) = cap_files(files, 200);
2325        assert_eq!(kept.len(), 200);
2326        assert_eq!(cut.as_deref(), Some("Checked 200 of 250 changed files."));
2327        let (kept, cut) = cap_files(vec![file("a.py")], 200);
2328        assert_eq!((kept.len(), cut), (1, None));
2329    }
2330
2331    #[test]
2332    fn a_signature_is_compared_from_its_name_and_without_modifiers() {
2333        let plain = row("add", "method", "int add(int a)", None, &[], 1);
2334        let modified = row(
2335            "add",
2336            "method",
2337            "@Override  public   final int add(int a)",
2338            None,
2339            &[],
2340            1,
2341        );
2342        assert_eq!(
2343            signature_tail(&plain.symbol),
2344            signature_tail(&modified.symbol)
2345        );
2346        let wider = row("add", "method", "long add(int a)", None, &[], 1);
2347        assert_ne!(signature_tail(&plain.symbol), signature_tail(&wider.symbol));
2348        let longer = row("add", "method", "int add(int a, int b)", None, &[], 1);
2349        assert_ne!(
2350            signature_tail(&plain.symbol),
2351            signature_tail(&longer.symbol)
2352        );
2353    }
2354
2355    #[test]
2356    fn a_name_inside_another_word_is_not_the_signature_name() {
2357        let symbol = row("add", "function", "def padd(x)", None, &[], 1).symbol;
2358        assert_eq!(signature_tail(&symbol), "def padd(x)");
2359    }
2360
2361    #[test]
2362    fn a_new_overload_does_not_shift_the_others() {
2363        let before = vec![
2364            row("f", "method", "f(int a)", None, &["K"], 1),
2365            row("f", "method", "f(int a, int b)", None, &["K"], 5),
2366        ];
2367        let after = vec![
2368            row("f", "method", "f(String s)", None, &["K"], 1),
2369            row("f", "method", "f(int a)", None, &["K"], 5),
2370            row("f", "method", "f(int a, int b)", None, &["K"], 9),
2371        ];
2372        let pairing = pair_rows(&before, &after);
2373        assert_eq!(pairing.pairs, vec![(0, 1), (1, 2)]);
2374        assert_eq!(pairing.added, vec![0]);
2375        assert!(pairing.removed.is_empty());
2376    }
2377
2378    #[test]
2379    fn symbols_with_other_parents_or_kinds_do_not_pair() {
2380        let before = vec![row("f", "function", "f()", None, &[], 1)];
2381        let after = vec![
2382            row("f", "method", "f()", None, &["K"], 1),
2383            row("f", "class", "class f", None, &[], 4),
2384        ];
2385        let pairing = pair_rows(&before, &after);
2386        assert!(pairing.pairs.is_empty());
2387        assert_eq!(pairing.removed, vec![0]);
2388        assert_eq!(pairing.added.len(), 2);
2389    }
2390
2391    #[test]
2392    fn a_unique_body_hash_names_a_rename_and_a_shared_one_does_not() {
2393        let before = vec![
2394            row("old", "function", "old()", Some("h1"), &[], 1),
2395            row("twin_a", "function", "twin_a()", Some("h2"), &[], 5),
2396        ];
2397        let after = vec![
2398            row("new", "function", "new()", Some("h1"), &[], 1),
2399            row("twin_b", "function", "twin_b()", Some("h2"), &[], 5),
2400            row("twin_c", "function", "twin_c()", Some("h2"), &[], 9),
2401        ];
2402        let pairing = pair_rows(&before, &after);
2403        assert_eq!(renames(&before, &after, &pairing), vec![(0, 0)]);
2404    }
2405
2406    #[test]
2407    fn a_rename_needs_the_same_parents_kind_and_a_body_hash() {
2408        let before = vec![
2409            row("old", "function", "old()", Some("h1"), &["K"], 1),
2410            row("bare", "function", "bare()", None, &[], 5),
2411        ];
2412        let after = vec![
2413            row("new", "function", "new()", Some("h1"), &[], 1),
2414            row("fresh", "function", "fresh()", None, &[], 5),
2415        ];
2416        let pairing = pair_rows(&before, &after);
2417        assert!(renames(&before, &after, &pairing).is_empty());
2418    }
2419
2420    #[test]
2421    fn parameters_with_different_ranges_show_the_ranges() {
2422        let change = parameters_change(("1".to_string(), "1-2".to_string()), (1, 2));
2423        assert_eq!(change, "parameters 1 → 1-2");
2424    }
2425
2426    #[test]
2427    fn parameters_with_equal_ranges_and_different_row_counts_show_the_row_counts() {
2428        let change = parameters_change(("1".to_string(), "1".to_string()), (2, 1));
2429        assert_eq!(change, "parameters 2 → 1");
2430    }
2431
2432    #[test]
2433    fn parameters_with_equal_ranges_and_equal_row_counts_say_they_changed() {
2434        let change = parameters_change(("1".to_string(), "1".to_string()), (1, 1));
2435        assert_eq!(change, "parameters changed");
2436    }
2437
2438    #[test]
2439    fn member_text_counts_and_names_both_sides() {
2440        let none: Vec<String> = Vec::new();
2441        let names = vec!["b".to_string(), "c".to_string()];
2442        assert_eq!(member_text(&names, &none), "+2 (b, c), -0");
2443        assert_eq!(member_text(&none, &names[..1]), "+0, -1 (b)");
2444    }
2445
2446    fn lines(text: &str) -> Vec<String> {
2447        text.lines().map(str::to_string).collect()
2448    }
2449
2450    #[test]
2451    fn a_literal_ends_where_its_first_bracket_closes() {
2452        let source =
2453            lines("let a = Cfg {\n    x: f(1, 2),\n    y: \"}\",\n};\nlet b = Cfg { x: 1 };\n");
2454        assert_eq!(construction_end(&source, 1, "Cfg"), 4);
2455        assert_eq!(construction_end(&source, 5, "Cfg"), 5);
2456    }
2457
2458    #[test]
2459    fn a_call_across_lines_ends_at_its_closing_parenthesis() {
2460        let source = lines("total = area(\n    1,\n    2,\n)\nrest()\n");
2461        assert_eq!(construction_end(&source, 1, "area"), 4);
2462    }
2463
2464    #[test]
2465    fn a_name_with_no_bracket_on_its_line_ends_on_that_line() {
2466        let source = lines("x = Foo\ny = bar(\n  1)\n");
2467        assert_eq!(construction_end(&source, 1, "Foo"), 1);
2468        assert_eq!(construction_end(&source, 9, "Foo"), 9);
2469    }
2470}