1use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20use std::str::FromStr;
21
22use crate::compat::added_lines::{AddedLinesSubject, file_line};
23use crate::compat::call_sites::extract_qualified_calls_with_context;
24use crate::compat::indirect_calls::{
25 IndirectExtraction, extract_indirect_calls_elixir, extract_indirect_calls_with_context,
26};
27use crate::compat::source_attributes::{
28 ExportSet, SpecTable, extract_exports, extract_function_signatures, extract_specs,
29};
30use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath};
31use crate::model::spec_ast::SpecType;
32use crate::model::spec_parser::parse_signature_return;
33use crate::model::symbol::RefContext;
34use crate::model::verdict::{IndirectCallForm, IndirectCallTally, Reason, ShapeCheckTally};
35
36pub type PerModuleFunctionSet = BTreeMap<ModuleName, BTreeSet<(FunctionName, Arity)>>;
38
39#[derive(Debug, Clone, Copy)]
48pub struct ContextAwareSubject<'a> {
49 pub subject: AddedLinesSubject<'a>,
50 pub line_context: &'a [RefContext],
51}
52
53#[derive(Debug, Default)]
60pub struct PatchProvided {
61 pub functions: PerModuleFunctionSet,
62 pub specs: PerModuleFunctionSet,
63}
64
65pub type TreeReader<'a> = &'a dyn Fn(&RelativePath) -> Option<String>;
68
69pub struct ReferenceContext<'a> {
73 pub covered_modules: &'a BTreeSet<ModuleName>,
74 pub patch_added: &'a PatchProvided,
75 pub resolve_module_path: &'a dyn Fn(&ModuleName) -> Option<RelativePath>,
76 pub read_target: TreeReader<'a>,
77 pub read_source: Option<TreeReader<'a>>,
78}
79
80impl fmt::Debug for ReferenceContext<'_> {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.debug_struct("ReferenceContext")
83 .field("covered_modules", self.covered_modules)
84 .field("patch_added", self.patch_added)
85 .finish_non_exhaustive()
86 }
87}
88
89#[derive(Debug, Default)]
93pub struct ReferenceCaches {
94 surfaces: BTreeMap<ModuleName, Option<TargetModuleSurface>>,
95 specs: BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
96 flagged: BTreeSet<(ModuleName, FunctionName, Arity)>,
97 shape_seen: BTreeSet<(ModuleName, FunctionName, Arity)>,
98}
99
100impl ReferenceCaches {
101 pub fn begin_subject(&mut self) {
105 self.flagged.clear();
106 self.shape_seen.clear();
107 }
108}
109
110#[allow(clippy::too_many_arguments)]
116pub fn resolve_qualified_reference(
117 subject: &AddedLinesSubject<'_>,
118 module: &ModuleName,
119 key: &(FunctionName, Arity),
120 call_line: u32,
121 ctx: &ReferenceContext<'_>,
122 caches: &mut ReferenceCaches,
123 reasons: &mut Vec<Reason>,
124 shape_checks: &mut ShapeCheckTally,
125) {
126 let surface = caches.surfaces.entry(module.clone()).or_insert_with(|| {
127 target_module_surface(
128 module,
129 ctx.covered_modules,
130 ctx.resolve_module_path,
131 ctx.read_target,
132 )
133 });
134 let Some(surface) = surface.as_ref() else {
135 return;
136 };
137 let is_exported = surface.exports.exports.contains(key);
138 let path = surface.path.clone();
139 if ctx
141 .patch_added
142 .functions
143 .get(module)
144 .is_some_and(|s| s.contains(key))
145 {
146 return;
147 }
148 if is_exported {
149 if ctx
151 .patch_added
152 .specs
153 .get(module)
154 .is_some_and(|s| s.contains(key))
155 {
156 return;
157 }
158 if !caches
159 .shape_seen
160 .insert((module.clone(), key.0.clone(), key.1))
161 {
162 return;
163 }
164 check_return_shape(
165 subject,
166 module,
167 &path,
168 key,
169 call_line,
170 ctx.read_target,
171 ctx.read_source,
172 &mut caches.specs,
173 reasons,
174 shape_checks,
175 );
176 return;
177 }
178 if !caches
179 .flagged
180 .insert((module.clone(), key.0.clone(), key.1))
181 {
182 return;
183 }
184 reasons.push(Reason::QualifiedCallUndefinedOnTarget {
185 source_path: subject.source_path.clone(),
186 module: module.clone(),
187 function: key.0.clone(),
188 arity: key.1,
189 line: file_line(subject.line_map, call_line),
190 });
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum ModuleProvenance {
198 CoveredBySnapshot,
201 FirstParty { path: RelativePath },
204 Unknown,
207}
208
209pub fn classify_module(
213 module: &ModuleName,
214 covered_modules: &BTreeSet<ModuleName>,
215 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
216) -> ModuleProvenance {
217 if covered_modules.contains(module) {
218 return ModuleProvenance::CoveredBySnapshot;
219 }
220 match resolve_module_path(module) {
221 Some(path) => ModuleProvenance::FirstParty { path },
222 None => ModuleProvenance::Unknown,
223 }
224}
225
226pub fn patch_provided(per_file: &[(ModuleName, &str)]) -> PatchProvided {
230 let mut out = PatchProvided::default();
231 for (module, added_text) in per_file {
232 let functions = out.functions.entry(module.clone()).or_default();
233 for sig in extract_function_signatures(added_text) {
234 if !sig.is_definition {
235 continue;
236 }
237 if let (Ok(function), Ok(arity)) = (
238 FunctionName::from_str(&sig.name),
239 Arity::try_from(sig.arity),
240 ) {
241 functions.insert((function, arity));
242 }
243 }
244 functions.extend(extract_exports(added_text).exports);
245 let specs = extract_specs(added_text);
246 if !specs.is_empty() {
247 out.specs
248 .entry(module.clone())
249 .or_default()
250 .extend(specs.into_keys());
251 }
252 }
253 out
254}
255
256#[derive(Debug, Default)]
260pub struct QualifiedCallAnalysis {
261 pub reasons: Vec<Reason>,
262 pub shape_checks: ShapeCheckTally,
263 pub indirect_checks: IndirectCallTally,
264}
265
266#[derive(Debug)]
270struct TargetModuleSurface {
271 exports: ExportSet,
272 defined: BTreeSet<(FunctionName, Arity)>,
273 path: RelativePath,
274}
275
276pub fn analyse_qualified_calls(
288 subjects: &[ContextAwareSubject<'_>],
289 covered_modules: &BTreeSet<ModuleName>,
290 patch_added: &PatchProvided,
291 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
292 read_target: TreeReader<'_>,
293 read_source: Option<TreeReader<'_>>,
294) -> QualifiedCallAnalysis {
295 let mut analysis = QualifiedCallAnalysis::default();
296 let ctx = ReferenceContext {
297 covered_modules,
298 patch_added,
299 resolve_module_path,
300 read_target,
301 read_source,
302 };
303 let mut caches = ReferenceCaches::default();
304 for subject in subjects {
305 let calls = extract_qualified_calls_with_context(
306 subject.subject.added_text,
307 subject.subject.line_map,
308 subject.line_context,
309 );
310 if calls.is_empty() {
311 continue;
312 }
313 caches.begin_subject();
314 for call in calls {
315 let key = (call.mfa.function.clone(), call.mfa.arity);
316 resolve_qualified_reference(
317 &subject.subject,
318 &call.mfa.module,
319 &key,
320 call.line,
321 &ctx,
322 &mut caches,
323 &mut analysis.reasons,
324 &mut analysis.shape_checks,
325 );
326 }
327 }
328 for subject in subjects {
329 resolve_indirect_calls(subject, &ctx, &mut caches, &mut analysis);
330 }
331 analysis
332}
333
334fn target_module_surface(
338 module: &ModuleName,
339 covered_modules: &BTreeSet<ModuleName>,
340 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
341 read_target: TreeReader<'_>,
342) -> Option<TargetModuleSurface> {
343 match classify_module(module, covered_modules, resolve_module_path) {
344 ModuleProvenance::FirstParty { path } => {
345 let text = read_target(&path)?;
346 let exports = extract_exports(&text);
347 if !exports.complete {
348 return None;
349 }
350 let defined = extract_function_signatures(&text)
351 .into_iter()
352 .filter(|sig| sig.is_definition)
353 .filter_map(|sig| {
354 match (
355 FunctionName::from_str(&sig.name),
356 Arity::try_from(sig.arity),
357 ) {
358 (Ok(function), Ok(arity)) => Some((function, arity)),
359 _ => None,
360 }
361 })
362 .collect();
363 Some(TargetModuleSurface {
364 exports,
365 defined,
366 path,
367 })
368 }
369 ModuleProvenance::CoveredBySnapshot | ModuleProvenance::Unknown => None,
370 }
371}
372
373pub fn analyse_indirect_elixir_calls(
379 subjects: &[AddedLinesSubject<'_>],
380 ctx: &ReferenceContext<'_>,
381 caches: &mut ReferenceCaches,
382) -> IndirectCallAnalysis {
383 let mut out = IndirectCallAnalysis::default();
384 for subject in subjects {
385 let extraction = extract_indirect_calls_elixir(subject.added_text, subject.line_map);
386 resolve_extraction(
387 &extraction,
388 subject,
389 ctx,
390 caches,
391 &mut out.reasons,
392 &mut out.tally,
393 );
394 }
395 out
396}
397
398#[derive(Debug, Default)]
401pub struct IndirectCallAnalysis {
402 pub reasons: Vec<Reason>,
403 pub tally: IndirectCallTally,
404}
405
406fn resolve_indirect_calls(
410 subject: &ContextAwareSubject<'_>,
411 ctx: &ReferenceContext<'_>,
412 caches: &mut ReferenceCaches,
413 analysis: &mut QualifiedCallAnalysis,
414) {
415 let extraction = extract_indirect_calls_with_context(
416 subject.subject.added_text,
417 subject.subject.line_map,
418 subject.line_context,
419 );
420 resolve_extraction(
421 &extraction,
422 &subject.subject,
423 ctx,
424 caches,
425 &mut analysis.reasons,
426 &mut analysis.indirect_checks,
427 );
428}
429
430fn resolve_extraction(
437 extraction: &IndirectExtraction,
438 subject: &AddedLinesSubject<'_>,
439 ctx: &ReferenceContext<'_>,
440 caches: &mut ReferenceCaches,
441 reasons: &mut Vec<Reason>,
442 tally: &mut IndirectCallTally,
443) {
444 tally.withheld_dynamic += extraction.withheld_dynamic;
445 let mut seen: BTreeSet<(ModuleName, FunctionName, Arity, IndirectCallForm)> = BTreeSet::new();
446 for site in &extraction.sites {
447 let (module, function, arity, via) = (
448 &site.mfa.module,
449 &site.mfa.function,
450 site.mfa.arity,
451 site.via,
452 );
453 let entry = caches.surfaces.entry(module.clone()).or_insert_with(|| {
454 target_module_surface(
455 module,
456 ctx.covered_modules,
457 ctx.resolve_module_path,
458 ctx.read_target,
459 )
460 });
461 let Some(surface) = entry.as_ref() else {
462 continue;
463 };
464 if !seen.insert((module.clone(), function.clone(), arity, via)) {
465 continue;
466 }
467 tally.checked += 1;
468 let key = (function.clone(), arity);
469 if surface.exports.exports.contains(&key)
470 || surface.defined.contains(&key)
471 || ctx
472 .patch_added
473 .functions
474 .get(module)
475 .is_some_and(|s| s.contains(&key))
476 {
477 continue;
478 }
479 reasons.push(Reason::IndirectCallUndefinedOnTarget {
480 source_path: subject.source_path.clone(),
481 module: module.clone(),
482 function: function.clone(),
483 arity,
484 via,
485 line: file_line(subject.line_map, site.line),
486 });
487 }
488}
489
490#[derive(Debug, PartialEq, Eq)]
495pub enum ShapeComparison {
496 Same,
498 Drift {
500 source_signature: String,
501 target_signature: String,
502 },
503 NoSpec,
505 UnknownType,
508}
509
510pub fn compare_return_shapes(
511 target_specs: &SpecTable,
512 source_specs: &SpecTable,
513 key: &(FunctionName, Arity),
514) -> ShapeComparison {
515 let (Some(target_sig), Some(source_sig)) = (target_specs.get(key), source_specs.get(key))
516 else {
517 return ShapeComparison::NoSpec;
518 };
519 if target_sig == source_sig {
521 return ShapeComparison::Same;
522 }
523 let target_ast = parse_signature_return(target_sig);
524 let source_ast = parse_signature_return(source_sig);
525 if matches!(target_ast, SpecType::Unknown) || matches!(source_ast, SpecType::Unknown) {
526 return ShapeComparison::UnknownType;
527 }
528 if target_ast.matches(&source_ast) {
529 ShapeComparison::Same
530 } else {
531 ShapeComparison::Drift {
532 source_signature: source_sig.clone(),
533 target_signature: target_sig.clone(),
534 }
535 }
536}
537
538#[allow(clippy::too_many_arguments)]
541fn check_return_shape(
542 subject: &AddedLinesSubject<'_>,
543 module: &ModuleName,
544 path: &RelativePath,
545 key: &(FunctionName, Arity),
546 call_line: u32,
547 read_target: TreeReader<'_>,
548 read_source: Option<TreeReader<'_>>,
549 specs_by_module: &mut BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
550 reasons: &mut Vec<Reason>,
551 tally: &mut ShapeCheckTally,
552) {
553 let Some(read_source) = read_source else {
554 tally.withheld_no_source += 1;
555 return;
556 };
557 let (target_specs, source_specs) = specs_by_module.entry(module.clone()).or_insert_with(|| {
558 (
559 read_target(path)
560 .map(|t| extract_specs(&t))
561 .unwrap_or_default(),
562 read_source(path).map(|t| extract_specs(&t)),
563 )
564 });
565 let Some(source_specs) = source_specs.as_ref() else {
566 tally.withheld_no_source += 1;
567 return;
568 };
569 match compare_return_shapes(target_specs, source_specs, key) {
570 ShapeComparison::Same => tally.compared += 1,
571 ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
572 ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
573 ShapeComparison::Drift {
574 source_signature,
575 target_signature,
576 } => {
577 tally.compared += 1;
578 reasons.push(Reason::QualifiedCallReturnShapeDrift {
579 source_path: subject.source_path.clone(),
580 module: module.clone(),
581 function: key.0.clone(),
582 arity: key.1,
583 source_signature,
584 target_signature,
585 line: file_line(subject.line_map, call_line),
586 });
587 }
588 }
589}