1use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20
21use crate::compat::added_lines::{AddedLinesSubject, file_line};
22use crate::compat::call_sites::extract_qualified_calls_with_context;
23use crate::compat::indirect_calls::{
24 IndirectExtraction, extract_indirect_calls_elixir, extract_indirect_calls_with_context,
25};
26use crate::compat::source_attributes::{
27 ExportSurface, Presence, SpecTable, Surface, extract_exports, extract_function_signatures,
28 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::LineClass;
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 [LineClass],
51}
52
53#[derive(Debug, Default)]
64pub struct PatchProvided {
65 pub functions: BTreeMap<ModuleName, ExportSurface>,
66 pub specs: PerModuleFunctionSet,
67}
68
69fn patch_covers(
73 functions: &BTreeMap<ModuleName, ExportSurface>,
74 module: &ModuleName,
75 key: &(FunctionName, Arity),
76) -> bool {
77 functions
78 .get(module)
79 .is_some_and(|surface| !matches!(surface.lookup(key), Presence::Absent))
80}
81
82pub type TreeReader<'a> = &'a dyn Fn(&RelativePath) -> Option<String>;
85
86pub struct ReferenceContext<'a> {
90 pub covered_modules: &'a BTreeSet<ModuleName>,
91 pub patch_added: &'a PatchProvided,
92 pub resolve_module_path: &'a dyn Fn(&ModuleName) -> Option<RelativePath>,
93 pub read_target: TreeReader<'a>,
94 pub read_source: Option<TreeReader<'a>>,
95}
96
97impl fmt::Debug for ReferenceContext<'_> {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.debug_struct("ReferenceContext")
100 .field("covered_modules", self.covered_modules)
101 .field("patch_added", self.patch_added)
102 .finish_non_exhaustive()
103 }
104}
105
106#[derive(Debug, Default)]
110pub struct ReferenceCaches {
111 surfaces: BTreeMap<ModuleName, Option<TargetModuleSurface>>,
112 specs: BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
113 flagged: BTreeSet<(ModuleName, FunctionName, Arity)>,
114 shape_seen: BTreeSet<(ModuleName, FunctionName, Arity)>,
115}
116
117impl ReferenceCaches {
118 pub fn begin_subject(&mut self) {
122 self.flagged.clear();
123 self.shape_seen.clear();
124 }
125}
126
127#[allow(clippy::too_many_arguments)]
133pub fn resolve_qualified_reference(
134 subject: &AddedLinesSubject<'_>,
135 module: &ModuleName,
136 key: &(FunctionName, Arity),
137 call_line: u32,
138 ctx: &ReferenceContext<'_>,
139 caches: &mut ReferenceCaches,
140 reasons: &mut Vec<Reason>,
141 shape_checks: &mut ShapeCheckTally,
142) {
143 let surface = caches.surfaces.entry(module.clone()).or_insert_with(|| {
144 target_module_surface(
145 module,
146 ctx.covered_modules,
147 ctx.resolve_module_path,
148 ctx.read_target,
149 )
150 });
151 let Some(surface) = surface.as_ref() else {
152 return;
153 };
154 let presence = surface.exports.lookup(key);
155 let path = surface.path.clone();
156 if patch_covers(&ctx.patch_added.functions, module, key) {
158 return;
159 }
160 match presence {
161 Presence::Present => {
162 if ctx
164 .patch_added
165 .specs
166 .get(module)
167 .is_some_and(|s| s.contains(key))
168 {
169 return;
170 }
171 if !caches
172 .shape_seen
173 .insert((module.clone(), key.0.clone(), key.1))
174 {
175 return;
176 }
177 check_return_shape(
178 subject,
179 module,
180 &path,
181 key,
182 call_line,
183 ctx.read_target,
184 ctx.read_source,
185 &mut caches.specs,
186 reasons,
187 shape_checks,
188 );
189 }
190 Presence::Unknowable(_) => {}
192 Presence::Absent => {
193 if !caches
194 .flagged
195 .insert((module.clone(), key.0.clone(), key.1))
196 {
197 return;
198 }
199 reasons.push(Reason::QualifiedCallUndefinedOnTarget {
200 source_path: subject.source_path.clone(),
201 module: module.clone(),
202 function: key.0.clone(),
203 arity: key.1,
204 line: file_line(subject.line_map, call_line),
205 });
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum ModuleProvenance {
215 CoveredBySnapshot,
218 FirstParty { path: RelativePath },
221 Unknown,
224}
225
226pub fn classify_module(
230 module: &ModuleName,
231 covered_modules: &BTreeSet<ModuleName>,
232 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
233) -> ModuleProvenance {
234 if covered_modules.contains(module) {
235 return ModuleProvenance::CoveredBySnapshot;
236 }
237 match resolve_module_path(module) {
238 Some(path) => ModuleProvenance::FirstParty { path },
239 None => ModuleProvenance::Unknown,
240 }
241}
242
243pub fn patch_provided(per_file: &[(ModuleName, &str)]) -> PatchProvided {
247 let mut out = PatchProvided::default();
248 for (module, added_text) in per_file {
249 let mut defined = BTreeSet::new();
250 for sig in extract_function_signatures(added_text) {
251 if !sig.is_definition {
252 continue;
253 }
254 if let Ok(arity) = Arity::try_from(sig.arity) {
255 defined.insert((sig.name.clone(), arity));
256 }
257 }
258 let surface = out.functions.entry(module.clone()).or_default();
259 surface.merge(Surface::new(defined, None));
260 surface.merge(extract_exports(added_text));
261 let specs = extract_specs(added_text);
262 if !specs.is_empty() {
263 out.specs
264 .entry(module.clone())
265 .or_default()
266 .extend(specs.into_keys());
267 }
268 }
269 out
270}
271
272#[derive(Debug, Default)]
276pub struct QualifiedCallAnalysis {
277 pub reasons: Vec<Reason>,
278 pub shape_checks: ShapeCheckTally,
279 pub indirect_checks: IndirectCallTally,
280}
281
282#[derive(Debug)]
286struct TargetModuleSurface {
287 exports: ExportSurface,
288 defined: BTreeSet<(FunctionName, Arity)>,
289 path: RelativePath,
290}
291
292pub fn analyse_qualified_calls(
304 subjects: &[ContextAwareSubject<'_>],
305 covered_modules: &BTreeSet<ModuleName>,
306 patch_added: &PatchProvided,
307 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
308 read_target: TreeReader<'_>,
309 read_source: Option<TreeReader<'_>>,
310) -> QualifiedCallAnalysis {
311 let mut analysis = QualifiedCallAnalysis::default();
312 let ctx = ReferenceContext {
313 covered_modules,
314 patch_added,
315 resolve_module_path,
316 read_target,
317 read_source,
318 };
319 let mut caches = ReferenceCaches::default();
320 for subject in subjects {
321 let calls = extract_qualified_calls_with_context(
322 subject.subject.added_text,
323 subject.subject.line_map,
324 subject.line_context,
325 );
326 if calls.is_empty() {
327 continue;
328 }
329 caches.begin_subject();
330 for call in calls {
331 let key = (call.mfa.function.clone(), call.mfa.arity);
332 resolve_qualified_reference(
333 &subject.subject,
334 &call.mfa.module,
335 &key,
336 call.line,
337 &ctx,
338 &mut caches,
339 &mut analysis.reasons,
340 &mut analysis.shape_checks,
341 );
342 }
343 }
344 for subject in subjects {
345 resolve_indirect_calls(subject, &ctx, &mut caches, &mut analysis);
346 }
347 analysis
348}
349
350fn target_module_surface(
356 module: &ModuleName,
357 covered_modules: &BTreeSet<ModuleName>,
358 resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
359 read_target: TreeReader<'_>,
360) -> Option<TargetModuleSurface> {
361 match classify_module(module, covered_modules, resolve_module_path) {
362 ModuleProvenance::FirstParty { path } => {
363 let text = read_target(&path)?;
364 let exports = extract_exports(&text);
365 let defined = extract_function_signatures(&text)
366 .into_iter()
367 .filter(|sig| sig.is_definition)
368 .filter_map(|sig| Some((sig.name, Arity::try_from(sig.arity).ok()?)))
369 .collect();
370 Some(TargetModuleSurface {
371 exports,
372 defined,
373 path,
374 })
375 }
376 ModuleProvenance::CoveredBySnapshot | ModuleProvenance::Unknown => None,
377 }
378}
379
380pub fn analyse_indirect_elixir_calls(
386 subjects: &[AddedLinesSubject<'_>],
387 ctx: &ReferenceContext<'_>,
388 caches: &mut ReferenceCaches,
389) -> IndirectCallAnalysis {
390 let mut out = IndirectCallAnalysis::default();
391 for subject in subjects {
392 let extraction = extract_indirect_calls_elixir(subject.added_text, subject.line_map);
393 resolve_extraction(
394 &extraction,
395 subject,
396 ctx,
397 caches,
398 &mut out.reasons,
399 &mut out.tally,
400 );
401 }
402 out
403}
404
405#[derive(Debug, Default)]
408pub struct IndirectCallAnalysis {
409 pub reasons: Vec<Reason>,
410 pub tally: IndirectCallTally,
411}
412
413fn resolve_indirect_calls(
417 subject: &ContextAwareSubject<'_>,
418 ctx: &ReferenceContext<'_>,
419 caches: &mut ReferenceCaches,
420 analysis: &mut QualifiedCallAnalysis,
421) {
422 let extraction = extract_indirect_calls_with_context(
423 subject.subject.added_text,
424 subject.subject.line_map,
425 subject.line_context,
426 );
427 resolve_extraction(
428 &extraction,
429 &subject.subject,
430 ctx,
431 caches,
432 &mut analysis.reasons,
433 &mut analysis.indirect_checks,
434 );
435}
436
437fn resolve_extraction(
444 extraction: &IndirectExtraction,
445 subject: &AddedLinesSubject<'_>,
446 ctx: &ReferenceContext<'_>,
447 caches: &mut ReferenceCaches,
448 reasons: &mut Vec<Reason>,
449 tally: &mut IndirectCallTally,
450) {
451 tally.withheld_dynamic += extraction.withheld_dynamic.len();
452 let mut seen: BTreeSet<(ModuleName, FunctionName, Arity, IndirectCallForm)> = BTreeSet::new();
453 for site in &extraction.sites {
454 let (module, function, arity, via) = (
455 &site.mfa.module,
456 &site.mfa.function,
457 site.mfa.arity,
458 site.via,
459 );
460 let entry = caches.surfaces.entry(module.clone()).or_insert_with(|| {
461 target_module_surface(
462 module,
463 ctx.covered_modules,
464 ctx.resolve_module_path,
465 ctx.read_target,
466 )
467 });
468 let Some(surface) = entry.as_ref() else {
469 continue;
470 };
471 if !seen.insert((module.clone(), function.clone(), arity, via)) {
472 continue;
473 }
474 tally.checked += 1;
475 let key = (function.clone(), arity);
476 if matches!(surface.exports.lookup(&key), Presence::Present)
477 || surface.defined.contains(&key)
478 || patch_covers(&ctx.patch_added.functions, module, &key)
479 {
480 continue;
481 }
482 reasons.push(Reason::IndirectCallUndefinedOnTarget {
483 source_path: subject.source_path.clone(),
484 module: module.clone(),
485 function: function.clone(),
486 arity,
487 via,
488 line: file_line(subject.line_map, site.line),
489 });
490 }
491}
492
493#[derive(Debug, PartialEq, Eq)]
498pub enum ShapeComparison {
499 Same,
501 Drift {
503 source_signature: String,
504 target_signature: String,
505 },
506 NoSpec,
508 UnknownType,
511}
512
513pub fn compare_return_shapes(
514 target_specs: &SpecTable,
515 source_specs: &SpecTable,
516 key: &(FunctionName, Arity),
517) -> ShapeComparison {
518 let (Some(target_sig), Some(source_sig)) = (target_specs.get(key), source_specs.get(key))
519 else {
520 return ShapeComparison::NoSpec;
521 };
522 if target_sig == source_sig {
524 return ShapeComparison::Same;
525 }
526 let target_ast = parse_signature_return(target_sig);
527 let source_ast = parse_signature_return(source_sig);
528 if matches!(target_ast, SpecType::Unknown) || matches!(source_ast, SpecType::Unknown) {
529 return ShapeComparison::UnknownType;
530 }
531 if target_ast.matches(&source_ast) {
532 ShapeComparison::Same
533 } else {
534 ShapeComparison::Drift {
535 source_signature: source_sig.clone(),
536 target_signature: target_sig.clone(),
537 }
538 }
539}
540
541#[allow(clippy::too_many_arguments)]
544fn check_return_shape(
545 subject: &AddedLinesSubject<'_>,
546 module: &ModuleName,
547 path: &RelativePath,
548 key: &(FunctionName, Arity),
549 call_line: u32,
550 read_target: TreeReader<'_>,
551 read_source: Option<TreeReader<'_>>,
552 specs_by_module: &mut BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
553 reasons: &mut Vec<Reason>,
554 tally: &mut ShapeCheckTally,
555) {
556 let Some(read_source) = read_source else {
557 tally.withheld_no_source += 1;
558 return;
559 };
560 let (target_specs, source_specs) = specs_by_module.entry(module.clone()).or_insert_with(|| {
561 (
562 read_target(path)
563 .map(|t| extract_specs(&t))
564 .unwrap_or_default(),
565 read_source(path).map(|t| extract_specs(&t)),
566 )
567 });
568 let Some(source_specs) = source_specs.as_ref() else {
569 tally.withheld_no_source += 1;
570 return;
571 };
572 match compare_return_shapes(target_specs, source_specs, key) {
573 ShapeComparison::Same => tally.compared += 1,
574 ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
575 ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
576 ShapeComparison::Drift {
577 source_signature,
578 target_signature,
579 } => {
580 tally.compared += 1;
581 reasons.push(Reason::QualifiedCallReturnShapeDrift {
582 source_path: subject.source_path.clone(),
583 module: module.clone(),
584 function: key.0.clone(),
585 arity: key.1,
586 source_signature,
587 target_signature,
588 line: file_line(subject.line_map, call_line),
589 });
590 }
591 }
592}