use fxrank_core::score::BoundaryCoverage;
use libcst_native::{
CompoundStatement, Decorator, Expression, OrElse, Param, SmallStatement, StarArg, Statement,
Suite,
};
use crate::detect::expr::render_expr;
use crate::functions::{FnBody, FnUnit};
use crate::imports::Imports;
pub struct Coverage {
pub boundary: BoundaryCoverage,
pub any_in_signature: bool,
pub any_in_body: bool,
pub unknown_decorator: bool,
}
pub fn of(unit: &FnUnit, imports: &Imports) -> Coverage {
let mut typed = 0usize;
let mut total = 0usize;
let mut any_in_signature = false;
let mut visit_param = |p: &Param, total: &mut usize, typed: &mut usize| {
if is_receiver(p.name.value) {
return;
}
*total += 1;
match classify_annotation(p.annotation.as_ref().map(|a| &a.annotation), imports) {
SlotKind::Typed => *typed += 1,
SlotKind::Any => any_in_signature = true,
SlotKind::Untyped => {}
}
};
for p in unit
.params
.posonly_params
.iter()
.chain(&unit.params.params)
.chain(&unit.params.kwonly_params)
{
visit_param(p, &mut total, &mut typed);
}
if let Some(StarArg::Param(p)) = &unit.params.star_arg {
visit_param(p, &mut total, &mut typed);
}
if let Some(p) = &unit.params.star_kwarg {
visit_param(p, &mut total, &mut typed);
}
total += 1;
match classify_annotation(unit.returns.map(|a| &a.annotation), imports) {
SlotKind::Typed => typed += 1,
SlotKind::Any => any_in_signature = true,
SlotKind::Untyped => {}
}
let boundary = if total > 0 && typed == total {
BoundaryCoverage::Full
} else if typed > 0 {
BoundaryCoverage::Partial
} else {
BoundaryCoverage::None
};
Coverage {
boundary,
any_in_signature,
any_in_body: body_has_any(&unit.body, imports),
unknown_decorator: unit.decorators.iter().any(|d| !is_pure_decorator(d)),
}
}
fn is_receiver(name: &str) -> bool {
name == "self" || name == "cls"
}
enum SlotKind {
Typed,
Any,
Untyped,
}
fn classify_annotation(ann: Option<&Expression>, imports: &Imports) -> SlotKind {
match ann {
None => SlotKind::Untyped,
Some(expr) if is_any_type(expr, imports) => SlotKind::Any,
Some(_) => SlotKind::Typed,
}
}
fn is_any_type(expr: &Expression, imports: &Imports) -> bool {
match expr {
Expression::Name(n) => n.value == "Any",
Expression::Attribute(a) => a.attr.value == "Any" && base_is_typing(&a.value, imports),
_ => false,
}
}
fn base_is_typing(base: &Expression, imports: &Imports) -> bool {
let Some(rendered) = render_expr(base) else {
return false;
};
let root = rendered.split('.').next().unwrap_or(&rendered);
imports.resolve(root) == Some("typing")
}
fn is_pure_decorator(dec: &Decorator) -> bool {
let callee = match &dec.decorator {
Expression::Call(c) => c.func.as_ref(),
other => other,
};
match callee {
Expression::Name(n) => matches!(
n.value,
"property" | "staticmethod" | "classmethod" | "dataclass" | "abstractmethod"
),
Expression::Attribute(a) => {
if matches!(a.attr.value, "wraps" | "cached_property") {
return true;
}
if a.attr.value == "dataclass" {
return true;
}
if a.attr.value == "abstractmethod" {
return true;
}
is_route_method(a.attr.value)
}
_ => false,
}
}
fn is_route_method(name: &str) -> bool {
matches!(
name,
"route" | "get" | "post" | "put" | "delete" | "patch" | "head" | "options"
)
}
fn body_has_any(body: &FnBody, imports: &Imports) -> bool {
match body {
FnBody::Suite(suite) => suite_has_any(suite, imports),
FnBody::Expr(e) => expr_has_any(e, imports),
}
}
fn suite_has_any(suite: &Suite, imports: &Imports) -> bool {
match suite {
Suite::IndentedBlock(b) => b.body.iter().any(|s| stmt_has_any(s, imports)),
Suite::SimpleStatementSuite(s) => s.body.iter().any(|s| small_has_any(s, imports)),
}
}
fn stmt_has_any(stmt: &Statement, imports: &Imports) -> bool {
match stmt {
Statement::Simple(line) => line.body.iter().any(|s| small_has_any(s, imports)),
Statement::Compound(c) => compound_has_any(c, imports),
}
}
fn compound_has_any(c: &CompoundStatement, imports: &Imports) -> bool {
match c {
CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => false,
CompoundStatement::If(i) => {
expr_has_any(&i.test, imports)
|| suite_has_any(&i.body, imports)
|| i.orelse
.as_ref()
.is_some_and(|o| orelse_has_any(o, imports))
}
CompoundStatement::For(f) => {
expr_has_any(&f.iter, imports)
|| suite_has_any(&f.body, imports)
|| f.orelse
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
}
CompoundStatement::While(w) => {
expr_has_any(&w.test, imports)
|| suite_has_any(&w.body, imports)
|| w.orelse
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
}
CompoundStatement::Try(t) => {
suite_has_any(&t.body, imports)
|| t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
|| t.orelse
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
|| t.finalbody
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
}
CompoundStatement::TryStar(t) => {
suite_has_any(&t.body, imports)
|| t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
|| t.orelse
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
|| t.finalbody
.as_ref()
.is_some_and(|e| suite_has_any(&e.body, imports))
}
CompoundStatement::With(w) => {
w.items.iter().any(|item| expr_has_any(&item.item, imports))
|| suite_has_any(&w.body, imports)
}
CompoundStatement::Match(m) => {
expr_has_any(&m.subject, imports)
|| m.cases
.iter()
.any(|case| suite_has_any(&case.body, imports))
}
}
}
fn orelse_has_any(orelse: &OrElse, imports: &Imports) -> bool {
match orelse {
OrElse::Elif(elif) => {
expr_has_any(&elif.test, imports)
|| suite_has_any(&elif.body, imports)
|| elif
.orelse
.as_ref()
.is_some_and(|o| orelse_has_any(o, imports))
}
OrElse::Else(e) => suite_has_any(&e.body, imports),
}
}
fn small_has_any(small: &SmallStatement, imports: &Imports) -> bool {
match small {
SmallStatement::AnnAssign(a) => {
if is_any_type(&a.annotation.annotation, imports) {
return true;
}
a.value.as_ref().is_some_and(|v| expr_has_any(v, imports))
}
SmallStatement::Assign(a) => expr_has_any(&a.value, imports),
SmallStatement::AugAssign(a) => expr_has_any(&a.value, imports),
SmallStatement::Expr(e) => expr_has_any(&e.value, imports),
SmallStatement::Return(r) => r.value.as_ref().is_some_and(|v| expr_has_any(v, imports)),
SmallStatement::Raise(r) => r.exc.as_ref().is_some_and(|e| expr_has_any(e, imports)),
_ => false,
}
}
fn expr_has_any(expr: &Expression, imports: &Imports) -> bool {
match expr {
Expression::Call(c) => {
if is_cast_any(c, imports) {
return true;
}
expr_has_any(&c.func, imports) || c.args.iter().any(|a| expr_has_any(&a.value, imports))
}
Expression::Attribute(a) => expr_has_any(&a.value, imports),
Expression::Subscript(s) => {
expr_has_any(&s.value, imports)
|| s.slice
.iter()
.any(|el| base_slice_has_any(&el.slice, imports))
}
Expression::BinaryOperation(b) => {
expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
}
Expression::BooleanOperation(b) => {
expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
}
Expression::UnaryOperation(u) => expr_has_any(&u.expression, imports),
Expression::Comparison(c) => {
expr_has_any(&c.left, imports)
|| c.comparisons
.iter()
.any(|cmp| expr_has_any(&cmp.comparator, imports))
}
Expression::IfExp(i) => {
expr_has_any(&i.test, imports)
|| expr_has_any(&i.body, imports)
|| expr_has_any(&i.orelse, imports)
}
Expression::List(l) => l.elements.iter().any(|e| element_has_any(e, imports)),
Expression::Set(s) => s.elements.iter().any(|e| element_has_any(e, imports)),
Expression::Tuple(t) => t.elements.iter().any(|e| element_has_any(e, imports)),
Expression::Dict(d) => d.elements.iter().any(|el| match el {
libcst_native::DictElement::Simple { key, value, .. } => {
expr_has_any(key, imports) || expr_has_any(value, imports)
}
libcst_native::DictElement::Starred(s) => expr_has_any(&s.value, imports),
}),
Expression::ListComp(l) => {
expr_has_any(&l.elt, imports) || comp_for_has_any(&l.for_in, imports)
}
Expression::SetComp(s) => {
expr_has_any(&s.elt, imports) || comp_for_has_any(&s.for_in, imports)
}
Expression::DictComp(d) => {
expr_has_any(&d.key, imports)
|| expr_has_any(&d.value, imports)
|| comp_for_has_any(&d.for_in, imports)
}
Expression::GeneratorExp(g) => {
expr_has_any(&g.elt, imports) || comp_for_has_any(&g.for_in, imports)
}
Expression::FormattedString(fs) => fs.parts.iter().any(|p| {
if let libcst_native::FormattedStringContent::Expression(e) = p {
if expr_has_any(&e.expression, imports) {
return true;
}
if let Some(spec_parts) = &e.format_spec {
return spec_parts.iter().any(|sp| {
matches!(sp, libcst_native::FormattedStringContent::Expression(se) if expr_has_any(&se.expression, imports))
});
}
}
false
}),
Expression::Lambda(_) => false,
Expression::Await(a) => expr_has_any(&a.expression, imports),
Expression::Yield(y) => y.value.as_ref().is_some_and(|v| match v.as_ref() {
libcst_native::YieldValue::Expression(e) => expr_has_any(e, imports),
libcst_native::YieldValue::From(f) => expr_has_any(&f.item, imports),
}),
Expression::NamedExpr(n) => expr_has_any(&n.value, imports),
Expression::StarredElement(s) => expr_has_any(&s.value, imports),
_ => false,
}
}
fn element_has_any(el: &libcst_native::Element, imports: &Imports) -> bool {
match el {
libcst_native::Element::Simple { value, .. } => expr_has_any(value, imports),
libcst_native::Element::Starred(s) => expr_has_any(&s.value, imports),
}
}
fn comp_for_has_any(comp: &libcst_native::CompFor, imports: &Imports) -> bool {
expr_has_any(&comp.iter, imports)
|| comp.ifs.iter().any(|c| expr_has_any(&c.test, imports))
|| comp
.inner_for_in
.as_ref()
.is_some_and(|i| comp_for_has_any(i, imports))
}
fn base_slice_has_any(slice: &libcst_native::BaseSlice, imports: &Imports) -> bool {
match slice {
libcst_native::BaseSlice::Index(i) => expr_has_any(&i.value, imports),
libcst_native::BaseSlice::Slice(s) => {
s.lower.as_ref().is_some_and(|e| expr_has_any(e, imports))
|| s.upper.as_ref().is_some_and(|e| expr_has_any(e, imports))
|| s.step.as_ref().is_some_and(|e| expr_has_any(e, imports))
}
}
}
fn is_cast_any(call: &libcst_native::Call, imports: &Imports) -> bool {
let is_cast = match call.func.as_ref() {
Expression::Name(n) => n.value == "cast",
Expression::Attribute(a) => a.attr.value == "cast" && base_is_typing(&a.value, imports),
_ => false,
};
if !is_cast {
return false;
}
call.args
.first()
.is_some_and(|arg| is_any_type(&arg.value, imports))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::SpanIndex;
fn coverage_of(src: &str, symbol: &str) -> Coverage {
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let unit = units
.iter()
.find(|u| u.symbol == symbol)
.expect("unit not found");
of(unit, &imports)
}
#[test]
fn signature_typing_any_poisons_but_unrelated_attr_any_does_not() {
let typing_any = coverage_of(
"import typing\ndef f(x: typing.Any) -> int:\n return 0\n",
"f",
);
assert!(
typing_any.any_in_signature,
"typing.Any (import typing) must set any_in_signature"
);
let aliased = coverage_of(
"import typing as t\ndef f(x: t.Any) -> int:\n return 0\n",
"f",
);
assert!(
aliased.any_in_signature,
"aliased t.Any (import typing as t) must set any_in_signature"
);
let unrelated = coverage_of(
"import mymod\ndef f(x: mymod.Any) -> int:\n return 0\n",
"f",
);
assert!(
!unrelated.any_in_signature,
"unrelated mymod.Any must NOT set any_in_signature (does not resolve to typing)"
);
}
#[test]
fn body_typing_cast_any_detected_but_unrelated_cast_not() {
let typing_cast = coverage_of(
"import typing\nfrom typing import Any\ndef f(x: int) -> int:\n y = typing.cast(Any, x)\n return y\n",
"f",
);
assert!(
typing_cast.any_in_body,
"typing.cast(Any, x) must set any_in_body"
);
let unrelated_cast = coverage_of(
"import obj\nfrom typing import Any\ndef f(x: int) -> int:\n y = obj.cast(Any, x)\n return y\n",
"f",
);
assert!(
!unrelated_cast.any_in_body,
"obj.cast(Any, x) (obj not typing) must NOT set any_in_body"
);
}
}