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