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;
24use crate::compat::indirect_calls::{
25 IndirectExtraction, extract_indirect_calls, extract_indirect_calls_elixir,
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::verdict::{IndirectCallForm, IndirectCallTally, Reason, ShapeCheckTally};
34
35pub type PerModuleFunctionSet = BTreeMap<ModuleName, BTreeSet<(FunctionName, Arity)>>;
37
38#[derive(Debug, Default)]
45pub struct PatchProvided {
46 pub functions: PerModuleFunctionSet,
47 pub specs: PerModuleFunctionSet,
48}
49
50pub type TreeReader<'a> = &'a dyn Fn(&RelativePath) -> Option<String>;
53
54pub struct ReferenceContext<'a> {
58 pub covered_modules: &'a BTreeSet<ModuleName>,
59 pub patch_added: &'a PatchProvided,
60 pub resolve_module_path: &'a dyn Fn(&ModuleName) -> Option<RelativePath>,
61 pub read_target: TreeReader<'a>,
62 pub read_source: Option<TreeReader<'a>>,
63}
64
65impl fmt::Debug for ReferenceContext<'_> {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.debug_struct("ReferenceContext")
68 .field("covered_modules", self.covered_modules)
69 .field("patch_added", self.patch_added)
70 .finish_non_exhaustive()
71 }
72}
73
74#[derive(Debug, Default)]
78pub struct ReferenceCaches {
79 surfaces: BTreeMap<ModuleName, Option<TargetModuleSurface>>,
80 specs: BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
81 flagged: BTreeSet<(ModuleName, FunctionName, Arity)>,
82 shape_seen: BTreeSet<(ModuleName, FunctionName, Arity)>,
83}
84
85impl ReferenceCaches {
86 pub fn begin_subject(&mut self) {
90 self.flagged.clear();
91 self.shape_seen.clear();
92 }
93}
94
95#[allow(clippy::too_many_arguments)]
101pub fn resolve_qualified_reference(
102 subject: &AddedLinesSubject<'_>,
103 module: &ModuleName,
104 key: &(FunctionName, Arity),
105 call_line: u32,
106 ctx: &ReferenceContext<'_>,
107 caches: &mut ReferenceCaches,
108 reasons: &mut Vec<Reason>,
109 shape_checks: &mut ShapeCheckTally,
110) {
111 let surface = caches.surfaces.entry(module.clone()).or_insert_with(|| {
112 target_module_surface(
113 module,
114 ctx.covered_modules,
115 ctx.resolve_module_path,
116 ctx.read_target,
117 )
118 });
119 let Some(surface) = surface.as_ref() else {
120 return;
121 };
122 let is_exported = surface.exports.exports.contains(key);
123 let path = surface.path.clone();
124 if ctx
127 .patch_added
128 .functions
129 .get(module)
130 .is_some_and(|s| s.contains(key))
131 {
132 return;
133 }
134 if is_exported {
135 if ctx
138 .patch_added
139 .specs
140 .get(module)
141 .is_some_and(|s| s.contains(key))
142 {
143 return;
144 }
145 if !caches
146 .shape_seen
147 .insert((module.clone(), key.0.clone(), key.1))
148 {
149 return;
150 }
151 check_return_shape(
152 subject,
153 module,
154 &path,
155 key,
156 call_line,
157 ctx.read_target,
158 ctx.read_source,
159 &mut caches.specs,
160 reasons,
161 shape_checks,
162 );
163 return;
164 }
165 if !caches
166 .flagged
167 .insert((module.clone(), key.0.clone(), key.1))
168 {
169 return;
170 }
171 reasons.push(Reason::QualifiedCallUndefinedOnTarget {
172 source_path: subject.source_path.clone(),
173 module: module.clone(),
174 function: key.0.clone(),
175 arity: key.1,
176 line: file_line(subject.line_map, call_line),
177 });
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum ModuleProvenance {
185 CoveredBySnapshot,
188 FirstParty { path: RelativePath },
191 Unknown,
194}
195
196pub fn classify_module(
200 module: &ModuleName,
201 covered_modules: &BTreeSet<ModuleName>,
202 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
203) -> ModuleProvenance {
204 if covered_modules.contains(module) {
205 return ModuleProvenance::CoveredBySnapshot;
206 }
207 match resolve_module_path(module) {
208 Some(path) => ModuleProvenance::FirstParty { path },
209 None => ModuleProvenance::Unknown,
210 }
211}
212
213pub fn patch_provided(per_file: &[(ModuleName, &str)]) -> PatchProvided {
217 let mut out = PatchProvided::default();
218 for (module, added_text) in per_file {
219 let functions = out.functions.entry(module.clone()).or_default();
220 for sig in extract_function_signatures(added_text) {
221 if !sig.is_definition {
222 continue;
223 }
224 if let (Ok(function), Ok(arity)) = (
225 FunctionName::from_str(&sig.name),
226 Arity::try_from(sig.arity),
227 ) {
228 functions.insert((function, arity));
229 }
230 }
231 functions.extend(extract_exports(added_text).exports);
232 let specs = extract_specs(added_text);
233 if !specs.is_empty() {
234 out.specs
235 .entry(module.clone())
236 .or_default()
237 .extend(specs.into_keys());
238 }
239 }
240 out
241}
242
243#[derive(Debug, Default)]
247pub struct QualifiedCallAnalysis {
248 pub reasons: Vec<Reason>,
249 pub shape_checks: ShapeCheckTally,
250 pub indirect_checks: IndirectCallTally,
251}
252
253#[derive(Debug)]
257struct TargetModuleSurface {
258 exports: ExportSet,
259 defined: BTreeSet<(FunctionName, Arity)>,
260 path: RelativePath,
261}
262
263pub fn analyse_qualified_calls(
275 subjects: &[AddedLinesSubject<'_>],
276 covered_modules: &BTreeSet<ModuleName>,
277 patch_added: &PatchProvided,
278 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
279 read_target: TreeReader<'_>,
280 read_source: Option<TreeReader<'_>>,
281) -> QualifiedCallAnalysis {
282 let mut analysis = QualifiedCallAnalysis::default();
283 let ctx = ReferenceContext {
284 covered_modules,
285 patch_added,
286 resolve_module_path,
287 read_target,
288 read_source,
289 };
290 let mut caches = ReferenceCaches::default();
291 for subject in subjects {
292 let calls = extract_qualified_calls(subject.added_text, subject.line_map);
293 if calls.is_empty() {
294 continue;
295 }
296 caches.begin_subject();
297 for call in calls {
298 let key = (call.mfa.function.clone(), call.mfa.arity);
299 resolve_qualified_reference(
300 subject,
301 &call.mfa.module,
302 &key,
303 call.line,
304 &ctx,
305 &mut caches,
306 &mut analysis.reasons,
307 &mut analysis.shape_checks,
308 );
309 }
310 }
311 for subject in subjects {
312 resolve_indirect_calls(subject, &ctx, &mut caches, &mut analysis);
313 }
314 analysis
315}
316
317fn target_module_surface(
321 module: &ModuleName,
322 covered_modules: &BTreeSet<ModuleName>,
323 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
324 read_target: TreeReader<'_>,
325) -> Option<TargetModuleSurface> {
326 match classify_module(module, covered_modules, resolve_module_path) {
327 ModuleProvenance::FirstParty { path } => {
328 let text = read_target(&path)?;
329 let exports = extract_exports(&text);
330 if !exports.complete {
331 return None;
332 }
333 let defined = extract_function_signatures(&text)
334 .into_iter()
335 .filter(|sig| sig.is_definition)
336 .filter_map(|sig| {
337 match (
338 FunctionName::from_str(&sig.name),
339 Arity::try_from(sig.arity),
340 ) {
341 (Ok(function), Ok(arity)) => Some((function, arity)),
342 _ => None,
343 }
344 })
345 .collect();
346 Some(TargetModuleSurface {
347 exports,
348 defined,
349 path,
350 })
351 }
352 ModuleProvenance::CoveredBySnapshot | ModuleProvenance::Unknown => None,
353 }
354}
355
356pub fn analyse_indirect_elixir_calls(
362 subjects: &[AddedLinesSubject<'_>],
363 ctx: &ReferenceContext<'_>,
364 caches: &mut ReferenceCaches,
365) -> IndirectCallAnalysis {
366 let mut out = IndirectCallAnalysis::default();
367 for subject in subjects {
368 let extraction = extract_indirect_calls_elixir(subject.added_text, subject.line_map);
369 resolve_extraction(
370 &extraction,
371 subject,
372 ctx,
373 caches,
374 &mut out.reasons,
375 &mut out.tally,
376 );
377 }
378 out
379}
380
381#[derive(Debug, Default)]
384pub struct IndirectCallAnalysis {
385 pub reasons: Vec<Reason>,
386 pub tally: IndirectCallTally,
387}
388
389fn resolve_indirect_calls(
393 subject: &AddedLinesSubject<'_>,
394 ctx: &ReferenceContext<'_>,
395 caches: &mut ReferenceCaches,
396 analysis: &mut QualifiedCallAnalysis,
397) {
398 let extraction = extract_indirect_calls(subject.added_text, subject.line_map);
399 resolve_extraction(
400 &extraction,
401 subject,
402 ctx,
403 caches,
404 &mut analysis.reasons,
405 &mut analysis.indirect_checks,
406 );
407}
408
409fn resolve_extraction(
416 extraction: &IndirectExtraction,
417 subject: &AddedLinesSubject<'_>,
418 ctx: &ReferenceContext<'_>,
419 caches: &mut ReferenceCaches,
420 reasons: &mut Vec<Reason>,
421 tally: &mut IndirectCallTally,
422) {
423 tally.withheld_dynamic += extraction.withheld_dynamic;
424 let mut seen: BTreeSet<(ModuleName, FunctionName, Arity, IndirectCallForm)> = BTreeSet::new();
425 for site in &extraction.sites {
426 let (module, function, arity, via) = (
427 &site.mfa.module,
428 &site.mfa.function,
429 site.mfa.arity,
430 site.via,
431 );
432 let entry = caches.surfaces.entry(module.clone()).or_insert_with(|| {
433 target_module_surface(
434 module,
435 ctx.covered_modules,
436 ctx.resolve_module_path,
437 ctx.read_target,
438 )
439 });
440 let Some(surface) = entry.as_ref() else {
441 continue;
442 };
443 if !seen.insert((module.clone(), function.clone(), arity, via)) {
444 continue;
445 }
446 tally.checked += 1;
447 let key = (function.clone(), arity);
448 if surface.exports.exports.contains(&key)
449 || surface.defined.contains(&key)
450 || ctx
451 .patch_added
452 .functions
453 .get(module)
454 .is_some_and(|s| s.contains(&key))
455 {
456 continue;
457 }
458 reasons.push(Reason::IndirectCallUndefinedOnTarget {
459 source_path: subject.source_path.clone(),
460 module: module.clone(),
461 function: function.clone(),
462 arity,
463 via,
464 line: file_line(subject.line_map, site.line),
465 });
466 }
467}
468
469#[derive(Debug, PartialEq, Eq)]
474pub enum ShapeComparison {
475 Same,
477 Drift {
479 source_signature: String,
480 target_signature: String,
481 },
482 NoSpec,
484 UnknownType,
487}
488
489pub fn compare_return_shapes(
490 target_specs: &SpecTable,
491 source_specs: &SpecTable,
492 key: &(FunctionName, Arity),
493) -> ShapeComparison {
494 let (Some(target_sig), Some(source_sig)) = (target_specs.get(key), source_specs.get(key))
495 else {
496 return ShapeComparison::NoSpec;
497 };
498 if target_sig == source_sig {
500 return ShapeComparison::Same;
501 }
502 let target_ast = parse_signature_return(target_sig);
503 let source_ast = parse_signature_return(source_sig);
504 if matches!(target_ast, SpecType::Unknown) || matches!(source_ast, SpecType::Unknown) {
505 return ShapeComparison::UnknownType;
506 }
507 if target_ast.matches(&source_ast) {
508 ShapeComparison::Same
509 } else {
510 ShapeComparison::Drift {
511 source_signature: source_sig.clone(),
512 target_signature: target_sig.clone(),
513 }
514 }
515}
516
517#[allow(clippy::too_many_arguments)]
520fn check_return_shape(
521 subject: &AddedLinesSubject<'_>,
522 module: &ModuleName,
523 path: &RelativePath,
524 key: &(FunctionName, Arity),
525 call_line: u32,
526 read_target: TreeReader<'_>,
527 read_source: Option<TreeReader<'_>>,
528 specs_by_module: &mut BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
529 reasons: &mut Vec<Reason>,
530 tally: &mut ShapeCheckTally,
531) {
532 let Some(read_source) = read_source else {
533 tally.withheld_no_source += 1;
534 return;
535 };
536 let (target_specs, source_specs) = specs_by_module.entry(module.clone()).or_insert_with(|| {
537 (
538 read_target(path)
539 .map(|t| extract_specs(&t))
540 .unwrap_or_default(),
541 read_source(path).map(|t| extract_specs(&t)),
542 )
543 });
544 let Some(source_specs) = source_specs.as_ref() else {
545 tally.withheld_no_source += 1;
546 return;
547 };
548 match compare_return_shapes(target_specs, source_specs, key) {
549 ShapeComparison::Same => tally.compared += 1,
550 ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
551 ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
552 ShapeComparison::Drift {
553 source_signature,
554 target_signature,
555 } => {
556 tally.compared += 1;
557 reasons.push(Reason::QualifiedCallReturnShapeDrift {
558 source_path: subject.source_path.clone(),
559 module: module.clone(),
560 function: key.0.clone(),
561 arity: key.1,
562 source_signature,
563 target_signature,
564 line: file_line(subject.line_map, call_line),
565 });
566 }
567 }
568}