use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use crate::compat::added_lines::{AddedLinesSubject, file_line};
use crate::compat::call_sites::extract_qualified_calls;
use crate::compat::indirect_calls::{
IndirectExtraction, extract_indirect_calls, extract_indirect_calls_elixir,
};
use crate::compat::source_attributes::{
ExportSet, SpecTable, extract_exports, extract_function_signatures, extract_specs,
};
use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath};
use crate::model::spec_ast::SpecType;
use crate::model::spec_parser::parse_signature_return;
use crate::model::verdict::{IndirectCallForm, IndirectCallTally, Reason, ShapeCheckTally};
pub type PerModuleFunctionSet = BTreeMap<ModuleName, BTreeSet<(FunctionName, Arity)>>;
#[derive(Debug, Default)]
pub struct PatchProvided {
pub functions: PerModuleFunctionSet,
pub specs: PerModuleFunctionSet,
}
pub type TreeReader<'a> = &'a dyn Fn(&RelativePath) -> Option<String>;
pub struct ReferenceContext<'a> {
pub covered_modules: &'a BTreeSet<ModuleName>,
pub patch_added: &'a PatchProvided,
pub resolve_module_path: &'a dyn Fn(&ModuleName) -> Option<RelativePath>,
pub read_target: TreeReader<'a>,
pub read_source: Option<TreeReader<'a>>,
}
impl fmt::Debug for ReferenceContext<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReferenceContext")
.field("covered_modules", self.covered_modules)
.field("patch_added", self.patch_added)
.finish_non_exhaustive()
}
}
#[derive(Debug, Default)]
pub struct ReferenceCaches {
surfaces: BTreeMap<ModuleName, Option<TargetModuleSurface>>,
specs: BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
flagged: BTreeSet<(ModuleName, FunctionName, Arity)>,
shape_seen: BTreeSet<(ModuleName, FunctionName, Arity)>,
}
impl ReferenceCaches {
pub fn begin_subject(&mut self) {
self.flagged.clear();
self.shape_seen.clear();
}
}
#[allow(clippy::too_many_arguments)]
pub fn resolve_qualified_reference(
subject: &AddedLinesSubject<'_>,
module: &ModuleName,
key: &(FunctionName, Arity),
call_line: u32,
ctx: &ReferenceContext<'_>,
caches: &mut ReferenceCaches,
reasons: &mut Vec<Reason>,
shape_checks: &mut ShapeCheckTally,
) {
let surface = caches.surfaces.entry(module.clone()).or_insert_with(|| {
target_module_surface(
module,
ctx.covered_modules,
ctx.resolve_module_path,
ctx.read_target,
)
});
let Some(surface) = surface.as_ref() else {
return;
};
let is_exported = surface.exports.exports.contains(key);
let path = surface.path.clone();
if ctx
.patch_added
.functions
.get(module)
.is_some_and(|s| s.contains(key))
{
return;
}
if is_exported {
if ctx
.patch_added
.specs
.get(module)
.is_some_and(|s| s.contains(key))
{
return;
}
if !caches
.shape_seen
.insert((module.clone(), key.0.clone(), key.1))
{
return;
}
check_return_shape(
subject,
module,
&path,
key,
call_line,
ctx.read_target,
ctx.read_source,
&mut caches.specs,
reasons,
shape_checks,
);
return;
}
if !caches
.flagged
.insert((module.clone(), key.0.clone(), key.1))
{
return;
}
reasons.push(Reason::QualifiedCallUndefinedOnTarget {
source_path: subject.source_path.clone(),
module: module.clone(),
function: key.0.clone(),
arity: key.1,
line: file_line(subject.line_map, call_line),
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModuleProvenance {
CoveredBySnapshot,
FirstParty { path: RelativePath },
Unknown,
}
pub fn classify_module(
module: &ModuleName,
covered_modules: &BTreeSet<ModuleName>,
resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
) -> ModuleProvenance {
if covered_modules.contains(module) {
return ModuleProvenance::CoveredBySnapshot;
}
match resolve_module_path(module) {
Some(path) => ModuleProvenance::FirstParty { path },
None => ModuleProvenance::Unknown,
}
}
pub fn patch_provided(per_file: &[(ModuleName, &str)]) -> PatchProvided {
let mut out = PatchProvided::default();
for (module, added_text) in per_file {
let functions = out.functions.entry(module.clone()).or_default();
for sig in extract_function_signatures(added_text) {
if !sig.is_definition {
continue;
}
if let (Ok(function), Ok(arity)) = (
FunctionName::from_str(&sig.name),
Arity::try_from(sig.arity),
) {
functions.insert((function, arity));
}
}
functions.extend(extract_exports(added_text).exports);
let specs = extract_specs(added_text);
if !specs.is_empty() {
out.specs
.entry(module.clone())
.or_default()
.extend(specs.into_keys());
}
}
out
}
#[derive(Debug, Default)]
pub struct QualifiedCallAnalysis {
pub reasons: Vec<Reason>,
pub shape_checks: ShapeCheckTally,
pub indirect_checks: IndirectCallTally,
}
#[derive(Debug)]
struct TargetModuleSurface {
exports: ExportSet,
defined: BTreeSet<(FunctionName, Arity)>,
path: RelativePath,
}
pub fn analyse_qualified_calls(
subjects: &[AddedLinesSubject<'_>],
covered_modules: &BTreeSet<ModuleName>,
patch_added: &PatchProvided,
resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
read_target: TreeReader<'_>,
read_source: Option<TreeReader<'_>>,
) -> QualifiedCallAnalysis {
let mut analysis = QualifiedCallAnalysis::default();
let ctx = ReferenceContext {
covered_modules,
patch_added,
resolve_module_path,
read_target,
read_source,
};
let mut caches = ReferenceCaches::default();
for subject in subjects {
let calls = extract_qualified_calls(subject.added_text, subject.line_map);
if calls.is_empty() {
continue;
}
caches.begin_subject();
for call in calls {
let key = (call.mfa.function.clone(), call.mfa.arity);
resolve_qualified_reference(
subject,
&call.mfa.module,
&key,
call.line,
&ctx,
&mut caches,
&mut analysis.reasons,
&mut analysis.shape_checks,
);
}
}
for subject in subjects {
resolve_indirect_calls(subject, &ctx, &mut caches, &mut analysis);
}
analysis
}
fn target_module_surface(
module: &ModuleName,
covered_modules: &BTreeSet<ModuleName>,
resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
read_target: TreeReader<'_>,
) -> Option<TargetModuleSurface> {
match classify_module(module, covered_modules, resolve_module_path) {
ModuleProvenance::FirstParty { path } => {
let text = read_target(&path)?;
let exports = extract_exports(&text);
if !exports.complete {
return None;
}
let defined = extract_function_signatures(&text)
.into_iter()
.filter(|sig| sig.is_definition)
.filter_map(|sig| {
match (
FunctionName::from_str(&sig.name),
Arity::try_from(sig.arity),
) {
(Ok(function), Ok(arity)) => Some((function, arity)),
_ => None,
}
})
.collect();
Some(TargetModuleSurface {
exports,
defined,
path,
})
}
ModuleProvenance::CoveredBySnapshot | ModuleProvenance::Unknown => None,
}
}
pub fn analyse_indirect_elixir_calls(
subjects: &[AddedLinesSubject<'_>],
ctx: &ReferenceContext<'_>,
caches: &mut ReferenceCaches,
) -> IndirectCallAnalysis {
let mut out = IndirectCallAnalysis::default();
for subject in subjects {
let extraction = extract_indirect_calls_elixir(subject.added_text, subject.line_map);
resolve_extraction(
&extraction,
subject,
ctx,
caches,
&mut out.reasons,
&mut out.tally,
);
}
out
}
#[derive(Debug, Default)]
pub struct IndirectCallAnalysis {
pub reasons: Vec<Reason>,
pub tally: IndirectCallTally,
}
fn resolve_indirect_calls(
subject: &AddedLinesSubject<'_>,
ctx: &ReferenceContext<'_>,
caches: &mut ReferenceCaches,
analysis: &mut QualifiedCallAnalysis,
) {
let extraction = extract_indirect_calls(subject.added_text, subject.line_map);
resolve_extraction(
&extraction,
subject,
ctx,
caches,
&mut analysis.reasons,
&mut analysis.indirect_checks,
);
}
fn resolve_extraction(
extraction: &IndirectExtraction,
subject: &AddedLinesSubject<'_>,
ctx: &ReferenceContext<'_>,
caches: &mut ReferenceCaches,
reasons: &mut Vec<Reason>,
tally: &mut IndirectCallTally,
) {
tally.withheld_dynamic += extraction.withheld_dynamic;
let mut seen: BTreeSet<(ModuleName, FunctionName, Arity, IndirectCallForm)> = BTreeSet::new();
for site in &extraction.sites {
let (module, function, arity, via) = (
&site.mfa.module,
&site.mfa.function,
site.mfa.arity,
site.via,
);
let entry = caches.surfaces.entry(module.clone()).or_insert_with(|| {
target_module_surface(
module,
ctx.covered_modules,
ctx.resolve_module_path,
ctx.read_target,
)
});
let Some(surface) = entry.as_ref() else {
continue;
};
if !seen.insert((module.clone(), function.clone(), arity, via)) {
continue;
}
tally.checked += 1;
let key = (function.clone(), arity);
if surface.exports.exports.contains(&key)
|| surface.defined.contains(&key)
|| ctx
.patch_added
.functions
.get(module)
.is_some_and(|s| s.contains(&key))
{
continue;
}
reasons.push(Reason::IndirectCallUndefinedOnTarget {
source_path: subject.source_path.clone(),
module: module.clone(),
function: function.clone(),
arity,
via,
line: file_line(subject.line_map, site.line),
});
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum ShapeComparison {
Same,
Drift {
source_signature: String,
target_signature: String,
},
NoSpec,
UnknownType,
}
pub fn compare_return_shapes(
target_specs: &SpecTable,
source_specs: &SpecTable,
key: &(FunctionName, Arity),
) -> ShapeComparison {
let (Some(target_sig), Some(source_sig)) = (target_specs.get(key), source_specs.get(key))
else {
return ShapeComparison::NoSpec;
};
if target_sig == source_sig {
return ShapeComparison::Same;
}
let target_ast = parse_signature_return(target_sig);
let source_ast = parse_signature_return(source_sig);
if matches!(target_ast, SpecType::Unknown) || matches!(source_ast, SpecType::Unknown) {
return ShapeComparison::UnknownType;
}
if target_ast.matches(&source_ast) {
ShapeComparison::Same
} else {
ShapeComparison::Drift {
source_signature: source_sig.clone(),
target_signature: target_sig.clone(),
}
}
}
#[allow(clippy::too_many_arguments)]
fn check_return_shape(
subject: &AddedLinesSubject<'_>,
module: &ModuleName,
path: &RelativePath,
key: &(FunctionName, Arity),
call_line: u32,
read_target: TreeReader<'_>,
read_source: Option<TreeReader<'_>>,
specs_by_module: &mut BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
reasons: &mut Vec<Reason>,
tally: &mut ShapeCheckTally,
) {
let Some(read_source) = read_source else {
tally.withheld_no_source += 1;
return;
};
let (target_specs, source_specs) = specs_by_module.entry(module.clone()).or_insert_with(|| {
(
read_target(path)
.map(|t| extract_specs(&t))
.unwrap_or_default(),
read_source(path).map(|t| extract_specs(&t)),
)
});
let Some(source_specs) = source_specs.as_ref() else {
tally.withheld_no_source += 1;
return;
};
match compare_return_shapes(target_specs, source_specs, key) {
ShapeComparison::Same => tally.compared += 1,
ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
ShapeComparison::Drift {
source_signature,
target_signature,
} => {
tally.compared += 1;
reasons.push(Reason::QualifiedCallReturnShapeDrift {
source_path: subject.source_path.clone(),
module: module.clone(),
function: key.0.clone(),
arity: key.1,
source_signature,
target_signature,
line: file_line(subject.line_map, call_line),
});
}
}
}