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 }
227}
228
229fn suite_has_any(suite: &Suite, imports: &Imports) -> bool {
230 match suite {
231 Suite::IndentedBlock(b) => b.body.iter().any(|s| stmt_has_any(s, imports)),
232 Suite::SimpleStatementSuite(s) => s.body.iter().any(|s| small_has_any(s, imports)),
233 }
234}
235
236fn stmt_has_any(stmt: &Statement, imports: &Imports) -> bool {
237 match stmt {
238 Statement::Simple(line) => line.body.iter().any(|s| small_has_any(s, imports)),
239 Statement::Compound(c) => compound_has_any(c, imports),
240 }
241}
242
243fn compound_has_any(c: &CompoundStatement, imports: &Imports) -> bool {
244 match c {
245 CompoundStatement::FunctionDef(_) | CompoundStatement::ClassDef(_) => false,
247 CompoundStatement::If(i) => {
248 expr_has_any(&i.test, imports)
249 || suite_has_any(&i.body, imports)
250 || i.orelse
251 .as_ref()
252 .is_some_and(|o| orelse_has_any(o, imports))
253 }
254 CompoundStatement::For(f) => {
255 expr_has_any(&f.iter, imports)
256 || suite_has_any(&f.body, imports)
257 || f.orelse
258 .as_ref()
259 .is_some_and(|e| suite_has_any(&e.body, imports))
260 }
261 CompoundStatement::While(w) => {
262 expr_has_any(&w.test, imports)
263 || suite_has_any(&w.body, imports)
264 || w.orelse
265 .as_ref()
266 .is_some_and(|e| suite_has_any(&e.body, imports))
267 }
268 CompoundStatement::Try(t) => {
269 suite_has_any(&t.body, imports)
270 || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
271 || t.orelse
272 .as_ref()
273 .is_some_and(|e| suite_has_any(&e.body, imports))
274 || t.finalbody
275 .as_ref()
276 .is_some_and(|e| suite_has_any(&e.body, imports))
277 }
278 CompoundStatement::TryStar(t) => {
279 suite_has_any(&t.body, imports)
280 || t.handlers.iter().any(|h| suite_has_any(&h.body, imports))
281 || t.orelse
282 .as_ref()
283 .is_some_and(|e| suite_has_any(&e.body, imports))
284 || t.finalbody
285 .as_ref()
286 .is_some_and(|e| suite_has_any(&e.body, imports))
287 }
288 CompoundStatement::With(w) => {
289 w.items.iter().any(|item| expr_has_any(&item.item, imports))
290 || suite_has_any(&w.body, imports)
291 }
292 CompoundStatement::Match(m) => {
293 expr_has_any(&m.subject, imports)
294 || m.cases
295 .iter()
296 .any(|case| suite_has_any(&case.body, imports))
297 }
298 }
299}
300
301fn orelse_has_any(orelse: &OrElse, imports: &Imports) -> bool {
302 match orelse {
303 OrElse::Elif(elif) => {
304 expr_has_any(&elif.test, imports)
305 || suite_has_any(&elif.body, imports)
306 || elif
307 .orelse
308 .as_ref()
309 .is_some_and(|o| orelse_has_any(o, imports))
310 }
311 OrElse::Else(e) => suite_has_any(&e.body, imports),
312 }
313}
314
315fn small_has_any(small: &SmallStatement, imports: &Imports) -> bool {
316 match small {
317 SmallStatement::AnnAssign(a) => {
319 if is_any_type(&a.annotation.annotation, imports) {
320 return true;
321 }
322 a.value.as_ref().is_some_and(|v| expr_has_any(v, imports))
323 }
324 SmallStatement::Assign(a) => expr_has_any(&a.value, imports),
325 SmallStatement::AugAssign(a) => expr_has_any(&a.value, imports),
326 SmallStatement::Expr(e) => expr_has_any(&e.value, imports),
327 SmallStatement::Return(r) => r.value.as_ref().is_some_and(|v| expr_has_any(v, imports)),
328 SmallStatement::Raise(r) => r.exc.as_ref().is_some_and(|e| expr_has_any(e, imports)),
329 _ => false,
330 }
331}
332
333fn expr_has_any(expr: &Expression, imports: &Imports) -> bool {
335 match expr {
336 Expression::Call(c) => {
337 if is_cast_any(c, imports) {
338 return true;
339 }
340 expr_has_any(&c.func, imports) || c.args.iter().any(|a| expr_has_any(&a.value, imports))
341 }
342 Expression::Attribute(a) => expr_has_any(&a.value, imports),
343 Expression::Subscript(s) => {
344 expr_has_any(&s.value, imports)
345 || s.slice
346 .iter()
347 .any(|el| base_slice_has_any(&el.slice, imports))
348 }
349 Expression::BinaryOperation(b) => {
350 expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
351 }
352 Expression::BooleanOperation(b) => {
353 expr_has_any(&b.left, imports) || expr_has_any(&b.right, imports)
354 }
355 Expression::UnaryOperation(u) => expr_has_any(&u.expression, imports),
356 Expression::Comparison(c) => {
357 expr_has_any(&c.left, imports)
358 || c.comparisons
359 .iter()
360 .any(|cmp| expr_has_any(&cmp.comparator, imports))
361 }
362 Expression::IfExp(i) => {
363 expr_has_any(&i.test, imports)
364 || expr_has_any(&i.body, imports)
365 || expr_has_any(&i.orelse, imports)
366 }
367 Expression::List(l) => l.elements.iter().any(|e| element_has_any(e, imports)),
369 Expression::Set(s) => s.elements.iter().any(|e| element_has_any(e, imports)),
370 Expression::Tuple(t) => t.elements.iter().any(|e| element_has_any(e, imports)),
371 Expression::Dict(d) => d.elements.iter().any(|el| match el {
372 libcst_native::DictElement::Simple { key, value, .. } => {
373 expr_has_any(key, imports) || expr_has_any(value, imports)
374 }
375 libcst_native::DictElement::Starred(s) => expr_has_any(&s.value, imports),
376 }),
377 Expression::ListComp(l) => {
380 expr_has_any(&l.elt, imports) || comp_for_has_any(&l.for_in, imports)
381 }
382 Expression::SetComp(s) => {
383 expr_has_any(&s.elt, imports) || comp_for_has_any(&s.for_in, imports)
384 }
385 Expression::DictComp(d) => {
386 expr_has_any(&d.key, imports)
387 || expr_has_any(&d.value, imports)
388 || comp_for_has_any(&d.for_in, imports)
389 }
390 Expression::GeneratorExp(g) => {
391 expr_has_any(&g.elt, imports) || comp_for_has_any(&g.for_in, imports)
392 }
393 Expression::FormattedString(fs) => fs.parts.iter().any(|p| {
394 if let libcst_native::FormattedStringContent::Expression(e) = p {
395 if expr_has_any(&e.expression, imports) {
396 return true;
397 }
398 if let Some(spec_parts) = &e.format_spec {
400 return spec_parts.iter().any(|sp| {
401 matches!(sp, libcst_native::FormattedStringContent::Expression(se) if expr_has_any(&se.expression, imports))
402 });
403 }
404 }
405 false
406 }),
407 Expression::Lambda(_) => false,
409 Expression::Await(a) => expr_has_any(&a.expression, imports),
410 Expression::Yield(y) => y.value.as_ref().is_some_and(|v| match v.as_ref() {
411 libcst_native::YieldValue::Expression(e) => expr_has_any(e, imports),
412 libcst_native::YieldValue::From(f) => expr_has_any(&f.item, imports),
413 }),
414 Expression::NamedExpr(n) => expr_has_any(&n.value, imports),
415 Expression::StarredElement(s) => expr_has_any(&s.value, imports),
416 _ => false,
417 }
418}
419
420fn element_has_any(el: &libcst_native::Element, imports: &Imports) -> bool {
422 match el {
423 libcst_native::Element::Simple { value, .. } => expr_has_any(value, imports),
424 libcst_native::Element::Starred(s) => expr_has_any(&s.value, imports),
425 }
426}
427
428fn comp_for_has_any(comp: &libcst_native::CompFor, imports: &Imports) -> bool {
430 expr_has_any(&comp.iter, imports)
431 || comp.ifs.iter().any(|c| expr_has_any(&c.test, imports))
432 || comp
433 .inner_for_in
434 .as_ref()
435 .is_some_and(|i| comp_for_has_any(i, imports))
436}
437
438fn base_slice_has_any(slice: &libcst_native::BaseSlice, imports: &Imports) -> bool {
440 match slice {
441 libcst_native::BaseSlice::Index(i) => expr_has_any(&i.value, imports),
442 libcst_native::BaseSlice::Slice(s) => {
443 s.lower.as_ref().is_some_and(|e| expr_has_any(e, imports))
444 || s.upper.as_ref().is_some_and(|e| expr_has_any(e, imports))
445 || s.step.as_ref().is_some_and(|e| expr_has_any(e, imports))
446 }
447 }
448}
449
450fn is_cast_any(call: &libcst_native::Call, imports: &Imports) -> bool {
456 let is_cast = match call.func.as_ref() {
457 Expression::Name(n) => n.value == "cast",
458 Expression::Attribute(a) => a.attr.value == "cast" && base_is_typing(&a.value, imports),
459 _ => false,
460 };
461 if !is_cast {
462 return false;
463 }
464 call.args
465 .first()
466 .is_some_and(|arg| is_any_type(&arg.value, imports))
467}
468
469#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::source::SpanIndex;
475
476 fn coverage_of(src: &str, symbol: &str) -> Coverage {
478 let module = libcst_native::parse_module(src, None).unwrap();
479 let imports = Imports::build(&module);
480 let span = SpanIndex::new(src);
481 let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
482 let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
483 let unit = units
484 .iter()
485 .find(|u| u.symbol == symbol)
486 .expect("unit not found");
487 of(unit, &imports)
488 }
489
490 #[test]
496 fn signature_typing_any_poisons_but_unrelated_attr_any_does_not() {
497 let typing_any = coverage_of(
498 "import typing\ndef f(x: typing.Any) -> int:\n return 0\n",
499 "f",
500 );
501 assert!(
502 typing_any.any_in_signature,
503 "typing.Any (import typing) must set any_in_signature"
504 );
505
506 let aliased = coverage_of(
507 "import typing as t\ndef f(x: t.Any) -> int:\n return 0\n",
508 "f",
509 );
510 assert!(
511 aliased.any_in_signature,
512 "aliased t.Any (import typing as t) must set any_in_signature"
513 );
514
515 let unrelated = coverage_of(
516 "import mymod\ndef f(x: mymod.Any) -> int:\n return 0\n",
517 "f",
518 );
519 assert!(
520 !unrelated.any_in_signature,
521 "unrelated mymod.Any must NOT set any_in_signature (does not resolve to typing)"
522 );
523 }
524
525 #[test]
532 fn body_typing_cast_any_detected_but_unrelated_cast_not() {
533 let typing_cast = coverage_of(
536 "import typing\nfrom typing import Any\ndef f(x: int) -> int:\n y = typing.cast(Any, x)\n return y\n",
537 "f",
538 );
539 assert!(
540 typing_cast.any_in_body,
541 "typing.cast(Any, x) must set any_in_body"
542 );
543
544 let unrelated_cast = coverage_of(
545 "import obj\nfrom typing import Any\ndef f(x: int) -> int:\n y = obj.cast(Any, x)\n return y\n",
546 "f",
547 );
548 assert!(
549 !unrelated_cast.any_in_body,
550 "obj.cast(Any, x) (obj not typing) must NOT set any_in_body"
551 );
552 }
553}