1use fxrank_core::score::BoundaryCoverage;
36use libcst_native::{
37 CompoundStatement, Decorator, Expression, OrElse, Param, SmallStatement, StarArg, Statement,
38 Suite,
39};
40
41use crate::detect::expr::render_expr;
42use crate::functions::{FnBody, FnUnit};
43use crate::imports::Imports;
44
45pub struct Coverage {
47 pub boundary: BoundaryCoverage,
49 pub any_in_signature: bool,
51 pub any_in_body: bool,
53 pub unknown_decorator: bool,
55}
56
57pub fn of(unit: &FnUnit, imports: &Imports) -> Coverage {
63 let mut typed = 0usize;
64 let mut total = 0usize;
65 let mut any_in_signature = false;
66
67 let mut visit_param = |p: &Param, total: &mut usize, typed: &mut usize| {
69 if is_receiver(p.name.value) {
70 return;
71 }
72 *total += 1;
73 match classify_annotation(p.annotation.as_ref().map(|a| &a.annotation), imports) {
74 SlotKind::Typed => *typed += 1,
75 SlotKind::Any => any_in_signature = true,
76 SlotKind::Untyped => {}
77 }
78 };
79
80 for p in unit
81 .params
82 .posonly_params
83 .iter()
84 .chain(&unit.params.params)
85 .chain(&unit.params.kwonly_params)
86 {
87 visit_param(p, &mut total, &mut typed);
88 }
89 if let Some(StarArg::Param(p)) = &unit.params.star_arg {
91 visit_param(p, &mut total, &mut typed);
92 }
93 if let Some(p) = &unit.params.star_kwarg {
94 visit_param(p, &mut total, &mut typed);
95 }
96
97 total += 1;
99 match classify_annotation(unit.returns.map(|a| &a.annotation), imports) {
100 SlotKind::Typed => typed += 1,
101 SlotKind::Any => any_in_signature = true,
102 SlotKind::Untyped => {}
103 }
104
105 let boundary = if total > 0 && typed == total {
106 BoundaryCoverage::Full
107 } else if typed > 0 {
108 BoundaryCoverage::Partial
109 } else {
110 BoundaryCoverage::None
111 };
112
113 Coverage {
114 boundary,
115 any_in_signature,
116 any_in_body: body_has_any(&unit.body, imports),
117 unknown_decorator: unit.decorators.iter().any(|d| !is_pure_decorator(d)),
118 }
119}
120
121fn is_receiver(name: &str) -> bool {
123 name == "self" || name == "cls"
124}
125
126enum SlotKind {
128 Typed,
130 Any,
132 Untyped,
134}
135
136fn classify_annotation(ann: Option<&Expression>, imports: &Imports) -> SlotKind {
138 match ann {
139 None => SlotKind::Untyped,
140 Some(expr) if is_any_type(expr, imports) => SlotKind::Any,
141 Some(_) => SlotKind::Typed,
142 }
143}
144
145fn is_any_type(expr: &Expression, imports: &Imports) -> bool {
152 match expr {
153 Expression::Name(n) => n.value == "Any",
154 Expression::Attribute(a) => a.attr.value == "Any" && base_is_typing(&a.value, imports),
155 _ => false,
156 }
157}
158
159fn base_is_typing(base: &Expression, imports: &Imports) -> bool {
164 let Some(rendered) = render_expr(base) else {
165 return false;
166 };
167 let root = rendered.split('.').next().unwrap_or(&rendered);
168 imports.resolve(root) == Some("typing")
169}
170
171fn is_pure_decorator(dec: &Decorator) -> bool {
180 let callee = match &dec.decorator {
183 Expression::Call(c) => c.func.as_ref(),
184 other => other,
185 };
186 match callee {
187 Expression::Name(n) => matches!(
188 n.value,
189 "property" | "staticmethod" | "classmethod" | "dataclass" | "abstractmethod"
190 ),
191 Expression::Attribute(a) => {
192 if matches!(a.attr.value, "wraps" | "cached_property") {
194 return true;
195 }
196 if a.attr.value == "dataclass" {
198 return true;
199 }
200 if a.attr.value == "abstractmethod" {
202 return true;
203 }
204 is_route_method(a.attr.value)
206 }
207 _ => false,
208 }
209}
210
211fn is_route_method(name: &str) -> bool {
213 matches!(
214 name,
215 "route" | "get" | "post" | "put" | "delete" | "patch" | "head" | "options"
216 )
217}
218
219fn body_has_any(body: &FnBody, imports: &Imports) -> bool {
222 match body {
223 FnBody::Suite(suite) => suite_has_any(suite, imports),
224 FnBody::Expr(e) => expr_has_any(e, imports),
226 FnBody::Module(_) => false,
230 }
231}
232
233fn suite_has_any(suite: &Suite, imports: &Imports) -> bool {
234 match suite {
235 Suite::IndentedBlock(b) => b.body.iter().any(|s| stmt_has_any(s, imports)),
236 Suite::SimpleStatementSuite(s) => s.body.iter().any(|s| small_has_any(s, imports)),
237 }
238}
239
240fn stmt_has_any(stmt: &Statement, imports: &Imports) -> bool {
241 match stmt {
242 Statement::Simple(line) => line.body.iter().any(|s| small_has_any(s, imports)),
243 Statement::Compound(c) => compound_has_any(c, imports),
244 }
245}
246
247fn compound_has_any(c: &CompoundStatement, imports: &Imports) -> bool {
248 match c {
249 CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => false,
251 CompoundStatement::If(i) => {
252 expr_has_any(&i.test, imports)
253 || suite_has_any(&i.body, imports)
254 || i.orelse
255 .as_ref()
256 .is_some_and(|o| orelse_has_any(o, imports))
257 }
258 CompoundStatement::For(f) => {
259 expr_has_any(&f.iter, imports)
260 || suite_has_any(&f.body, imports)
261 || f.orelse
262 .as_ref()
263 .is_some_and(|e| suite_has_any(&e.body, imports))
264 }
265 CompoundStatement::While(w) => {
266 expr_has_any(&w.test, imports)
267 || suite_has_any(&w.body, imports)
268 || w.orelse
269 .as_ref()
270 .is_some_and(|e| suite_has_any(&e.body, imports))
271 }
272 CompoundStatement::Try(t) => {
273 suite_has_any(&t.body, imports)
274 || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
275 || t.orelse
276 .as_ref()
277 .is_some_and(|e| suite_has_any(&e.body, imports))
278 || t.finalbody
279 .as_ref()
280 .is_some_and(|e| suite_has_any(&e.body, imports))
281 }
282 CompoundStatement::TryStar(t) => {
283 suite_has_any(&t.body, imports)
284 || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
285 || t.orelse
286 .as_ref()
287 .is_some_and(|e| suite_has_any(&e.body, imports))
288 || t.finalbody
289 .as_ref()
290 .is_some_and(|e| suite_has_any(&e.body, imports))
291 }
292 CompoundStatement::With(w) => {
293 w.items.iter().any(|item| expr_has_any(&item.item, imports))
294 || suite_has_any(&w.body, imports)
295 }
296 CompoundStatement::Match(m) => {
297 expr_has_any(&m.subject, imports)
298 || m.cases
299 .iter()
300 .any(|case| suite_has_any(&case.body, imports))
301 }
302 }
303}
304
305fn orelse_has_any(orelse: &OrElse, imports: &Imports) -> bool {
306 match orelse {
307 OrElse::Elif(elif) => {
308 expr_has_any(&elif.test, imports)
309 || suite_has_any(&elif.body, imports)
310 || elif
311 .orelse
312 .as_ref()
313 .is_some_and(|o| orelse_has_any(o, imports))
314 }
315 OrElse::Else(e) => suite_has_any(&e.body, imports),
316 }
317}
318
319fn small_has_any(small: &SmallStatement, imports: &Imports) -> bool {
320 match small {
321 SmallStatement::AnnAssign(a) => {
323 if is_any_type(&a.annotation.annotation, imports) {
324 return true;
325 }
326 a.value.as_ref().is_some_and(|v| expr_has_any(v, imports))
327 }
328 SmallStatement::Assign(a) => expr_has_any(&a.value, imports),
329 SmallStatement::AugAssign(a) => expr_has_any(&a.value, imports),
330 SmallStatement::Expr(e) => expr_has_any(&e.value, imports),
331 SmallStatement::Return(r) => r.value.as_ref().is_some_and(|v| expr_has_any(v, imports)),
332 SmallStatement::Raise(r) => r.exc.as_ref().is_some_and(|e| expr_has_any(e, imports)),
333 _ => false,
334 }
335}
336
337fn expr_has_any(expr: &Expression, imports: &Imports) -> bool {
339 match expr {
340 Expression::Call(c) => {
341 if is_cast_any(c, imports) {
342 return true;
343 }
344 expr_has_any(&c.func, imports) || c.args.iter().any(|a| expr_has_any(&a.value, imports))
345 }
346 Expression::Attribute(a) => expr_has_any(&a.value, imports),
347 Expression::Subscript(s) => {
348 expr_has_any(&s.value, imports)
349 || s.slice
350 .iter()
351 .any(|el| base_slice_has_any(&el.slice, imports))
352 }
353 Expression::BinaryOperation(b) => {
354 expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
355 }
356 Expression::BooleanOperation(b) => {
357 expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
358 }
359 Expression::UnaryOperation(u) => expr_has_any(&u.expression, imports),
360 Expression::Comparison(c) => {
361 expr_has_any(&c.left, imports)
362 || c.comparisons
363 .iter()
364 .any(|cmp| expr_has_any(&cmp.comparator, imports))
365 }
366 Expression::IfExp(i) => {
367 expr_has_any(&i.test, imports)
368 || expr_has_any(&i.body, imports)
369 || expr_has_any(&i.orelse, imports)
370 }
371 Expression::List(l) => l.elements.iter().any(|e| element_has_any(e, imports)),
373 Expression::Set(s) => s.elements.iter().any(|e| element_has_any(e, imports)),
374 Expression::Tuple(t) => t.elements.iter().any(|e| element_has_any(e, imports)),
375 Expression::Dict(d) => d.elements.iter().any(|el| match el {
376 libcst_native::DictElement::Simple { key, value, .. } => {
377 expr_has_any(key, imports) || expr_has_any(value, imports)
378 }
379 libcst_native::DictElement::Starred(s) => expr_has_any(&s.value, imports),
380 }),
381 Expression::ListComp(l) => {
384 expr_has_any(&l.elt, imports) || comp_for_has_any(&l.for_in, imports)
385 }
386 Expression::SetComp(s) => {
387 expr_has_any(&s.elt, imports) || comp_for_has_any(&s.for_in, imports)
388 }
389 Expression::DictComp(d) => {
390 expr_has_any(&d.key, imports)
391 || expr_has_any(&d.value, imports)
392 || comp_for_has_any(&d.for_in, imports)
393 }
394 Expression::GeneratorExp(g) => {
395 expr_has_any(&g.elt, imports) || comp_for_has_any(&g.for_in, imports)
396 }
397 Expression::FormattedString(fs) => fs.parts.iter().any(|p| {
398 if let libcst_native::FormattedStringContent::Expression(e) = p {
399 if expr_has_any(&e.expression, imports) {
400 return true;
401 }
402 if let Some(spec_parts) = &e.format_spec {
404 return spec_parts.iter().any(|sp| {
405 matches!(sp, libcst_native::FormattedStringContent::Expression(se) if expr_has_any(&se.expression, imports))
406 });
407 }
408 }
409 false
410 }),
411 Expression::Lambda(_) => false,
413 Expression::Await(a) => expr_has_any(&a.expression, imports),
414 Expression::Yield(y) => y.value.as_ref().is_some_and(|v| match v.as_ref() {
415 libcst_native::YieldValue::Expression(e) => expr_has_any(e, imports),
416 libcst_native::YieldValue::From(f) => expr_has_any(&f.item, imports),
417 }),
418 Expression::NamedExpr(n) => expr_has_any(&n.value, imports),
419 Expression::StarredElement(s) => expr_has_any(&s.value, imports),
420 _ => false,
421 }
422}
423
424fn element_has_any(el: &libcst_native::Element, imports: &Imports) -> bool {
426 match el {
427 libcst_native::Element::Simple { value, .. } => expr_has_any(value, imports),
428 libcst_native::Element::Starred(s) => expr_has_any(&s.value, imports),
429 }
430}
431
432fn comp_for_has_any(comp: &libcst_native::CompFor, imports: &Imports) -> bool {
434 expr_has_any(&comp.iter, imports)
435 || comp.ifs.iter().any(|c| expr_has_any(&c.test, imports))
436 || comp
437 .inner_for_in
438 .as_ref()
439 .is_some_and(|i| comp_for_has_any(i, imports))
440}
441
442fn base_slice_has_any(slice: &libcst_native::BaseSlice, imports: &Imports) -> bool {
444 match slice {
445 libcst_native::BaseSlice::Index(i) => expr_has_any(&i.value, imports),
446 libcst_native::BaseSlice::Slice(s) => {
447 s.lower.as_ref().is_some_and(|e| expr_has_any(e, imports))
448 || s.upper.as_ref().is_some_and(|e| expr_has_any(e, imports))
449 || s.step.as_ref().is_some_and(|e| expr_has_any(e, imports))
450 }
451 }
452}
453
454fn is_cast_any(call: &libcst_native::Call, imports: &Imports) -> bool {
460 let is_cast = match call.func.as_ref() {
461 Expression::Name(n) => n.value == "cast",
462 Expression::Attribute(a) => a.attr.value == "cast" && base_is_typing(&a.value, imports),
463 _ => false,
464 };
465 if !is_cast {
466 return false;
467 }
468 call.args
469 .first()
470 .is_some_and(|arg| is_any_type(&arg.value, imports))
471}
472
473#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::source::SpanIndex;
479
480 fn coverage_of(src: &str, symbol: &str) -> Coverage {
482 let module = libcst_native::parse_module(src, None).unwrap();
483 let imports = Imports::build(&module);
484 let span = SpanIndex::new(src);
485 let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
486 let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
487 let unit = units
488 .iter()
489 .find(|u| u.symbol == symbol)
490 .expect("unit not found");
491 of(unit, &imports)
492 }
493
494 #[test]
500 fn signature_typing_any_poisons_but_unrelated_attr_any_does_not() {
501 let typing_any = coverage_of(
502 "import typing\ndef f(x: typing.Any) -> int:\n return 0\n",
503 "f",
504 );
505 assert!(
506 typing_any.any_in_signature,
507 "typing.Any (import typing) must set any_in_signature"
508 );
509
510 let aliased = coverage_of(
511 "import typing as t\ndef f(x: t.Any) -> int:\n return 0\n",
512 "f",
513 );
514 assert!(
515 aliased.any_in_signature,
516 "aliased t.Any (import typing as t) must set any_in_signature"
517 );
518
519 let unrelated = coverage_of(
520 "import mymod\ndef f(x: mymod.Any) -> int:\n return 0\n",
521 "f",
522 );
523 assert!(
524 !unrelated.any_in_signature,
525 "unrelated mymod.Any must NOT set any_in_signature (does not resolve to typing)"
526 );
527 }
528
529 #[test]
536 fn body_typing_cast_any_detected_but_unrelated_cast_not() {
537 let typing_cast = coverage_of(
540 "import typing\nfrom typing import Any\ndef f(x: int) -> int:\n y = typing.cast(Any, x)\n return y\n",
541 "f",
542 );
543 assert!(
544 typing_cast.any_in_body,
545 "typing.cast(Any, x) must set any_in_body"
546 );
547
548 let unrelated_cast = coverage_of(
549 "import obj\nfrom typing import Any\ndef f(x: int) -> int:\n y = obj.cast(Any, x)\n return y\n",
550 "f",
551 );
552 assert!(
553 !unrelated_cast.any_in_body,
554 "obj.cast(Any, x) (obj not typing) must NOT set any_in_body"
555 );
556 }
557}