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