1mod 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
69pub fn head_commit(root: &Path) -> Option<String> {
72 Git::open(root).ok()?.resolve("HEAD").ok().flatten()
73}
74
75#[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#[derive(Debug, Clone, Copy)]
86pub enum BaseRequest<'a> {
87 Given(&'a str),
88 Session(&'a str),
89 Head,
90}
91
92#[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#[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#[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#[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"];
152const 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
277pub 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 pub fn diff_hash(&self) -> &str {
328 &self.diff_hash
329 }
330
331 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
449fn 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
550struct 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 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 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
686struct 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 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
752fn 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
800fn 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
831fn 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
874fn 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(¤t[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
918fn 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
953struct 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 fixture: Option<String>,
976 condition: Option<String>,
978 },
979 Fields(FieldChange),
980 Variants {
981 added: Vec<String>,
982 removed: Vec<String>,
983 },
984}
985
986struct Overloads {
990 remaining: Option<Arity>,
991 removed: Option<Arity>,
992}
993
994enum 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#[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 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 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
1349struct 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 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
1380fn 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
1392fn 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
1402fn 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
1425fn 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 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 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 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 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 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 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 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 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 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 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 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
2159fn 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
2191fn 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
2205fn 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}