1use camino::Utf8Path;
13use ruff_python_ast::token::TokenKind;
14use ruff_python_ast::visitor::{walk_expr, walk_stmt, Visitor};
15use ruff_python_ast::{
16 Expr, ExprContext, Parameters, Stmt, StmtClassDef, StmtFunctionDef, StmtImport, StmtImportFrom,
17};
18use ruff_python_parser::parse_module;
19use ruff_source_file::LineIndex;
20use ruff_text_size::{Ranged, TextRange, TextSize};
21use std::collections::{HashMap, HashSet};
22
23#[derive(Debug, thiserror::Error)]
24pub enum ParseError {
25 #[error("failed to initialize the Python grammar")]
26 Grammar,
27 #[error("parser produced no tree for {0}")]
28 NoTree(String),
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DefKind {
34 Function,
35 Class,
36 Variable,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Definition {
43 pub name: String,
44 pub kind: DefKind,
45 pub line: u32,
46 pub end_line: u32,
47 pub private_by_convention: bool,
49 pub decorators: Vec<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Import {
57 pub module: String,
60 pub relative_dots: u8,
62 pub names: Vec<String>,
64 pub bindings: Vec<String>,
67 pub is_star: bool,
69 pub type_checking_only: bool,
72 pub redundant: Vec<bool>,
76 pub in_try: bool,
81 pub line: u32,
82 pub end_line: u32,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct FunctionComplexity {
91 pub name: String,
92 pub line: u32,
93 pub end_line: u32,
95 pub cyclomatic: u32,
97 pub cognitive: u32,
99 pub params_total: u32,
101 pub params_annotated: u32,
103 pub return_annotated: bool,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SecurityHit {
111 pub rule: &'static str,
113 pub line: u32,
114 pub detail: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct CallSite {
120 pub callee: String,
121 pub line: u32,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ScopeFinding {
127 pub name: String,
128 pub line: u32,
129 pub is_param: bool,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ClassInfo {
138 pub name: String,
139 pub line: u32,
140 pub end_line: u32,
141 pub is_private: bool,
143 pub decorators: Vec<String>,
145 pub bases: Vec<String>,
147 pub is_enum: bool,
149 pub methods: Vec<(String, Vec<String>)>,
151 pub members: Vec<ClassMember>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ClassMember {
159 pub name: String,
160 pub line: u32,
161 pub end_line: u32,
162 pub is_method: bool,
164 pub is_private: bool,
165 pub decorators: Vec<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct UnreachableCode {
174 pub line: u32,
175 pub after: &'static str,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct TypeLeak {
183 pub function: String,
185 pub type_name: String,
187 pub line: u32,
188 pub is_return: bool,
190}
191
192#[derive(Debug, Clone)]
194pub struct ParsedModule {
195 pub path: camino::Utf8PathBuf,
196 pub definitions: Vec<Definition>,
197 pub imports: Vec<Import>,
198 pub nested_imports: Vec<Import>,
202 pub calls: Vec<CallSite>,
203 pub functions: Vec<FunctionComplexity>,
204 pub security_hits: Vec<SecurityHit>,
205 pub dunder_all: Option<Vec<String>>,
206 pub dunder_all_dynamic: bool,
211 pub used_names: Vec<String>,
212 pub local_uses: Vec<String>,
213 pub attr_accessed: Vec<String>,
218 pub module_used: Vec<String>,
224 pub ignores: Vec<(u32, String)>,
225 pub scope_findings: Vec<ScopeFinding>,
226 pub classes: Vec<ClassInfo>,
227 pub unreachable: Vec<UnreachableCode>,
229 pub type_leaks: Vec<TypeLeak>,
231 pub name_counts: HashMap<String, u32>,
232 pub has_dynamic_sink: bool,
233 pub has_main_guard: bool,
236 pub halstead_volume: f64,
237 had_errors: bool,
238}
239
240impl ParsedModule {
241 pub fn had_errors(&self) -> bool {
243 self.had_errors
244 }
245}
246
247#[derive(Default)]
250pub struct PyParser;
251
252impl PyParser {
253 pub fn new() -> Result<Self, ParseError> {
254 Ok(Self)
255 }
256
257 pub fn parse(&mut self, path: &Utf8Path, source: &str) -> Result<ParsedModule, ParseError> {
259 let li = LineIndex::from_source_text(source);
260 let mut m = ParsedModule {
261 path: path.to_owned(),
262 definitions: Vec::new(),
263 imports: Vec::new(),
264 nested_imports: Vec::new(),
265 calls: Vec::new(),
266 functions: Vec::new(),
267 security_hits: Vec::new(),
268 dunder_all: None,
269 dunder_all_dynamic: false,
270 used_names: Vec::new(),
271 local_uses: Vec::new(),
272 attr_accessed: Vec::new(),
273 module_used: Vec::new(),
274 ignores: Vec::new(),
275 scope_findings: Vec::new(),
276 classes: Vec::new(),
277 unreachable: Vec::new(),
278 type_leaks: Vec::new(),
279 name_counts: HashMap::new(),
280 has_dynamic_sink: false,
281 has_main_guard: false,
282 halstead_volume: 0.0,
283 had_errors: false,
284 };
285
286 let parsed = match parse_module(source) {
287 Ok(p) => p,
288 Err(_) => {
289 m.had_errors = true;
291 return Ok(m);
292 }
293 };
294 m.had_errors = !parsed.errors().is_empty();
295 let module = parsed.syntax();
296
297 let mut name_tokens: Vec<(TextSize, &str)> = Vec::new();
301 let mut h_total_ops = 0u64;
302 let mut h_total_oprs = 0u64;
303 let mut h_ops: HashSet<TokenKind> = HashSet::new();
304 let mut h_oprs: HashSet<&str> = HashSet::new();
305 for tok in parsed.tokens() {
306 let kind = tok.kind();
307 let text = &source[tok.range()];
308 if kind == TokenKind::Name {
309 *m.name_counts.entry(text.to_string()).or_insert(0) += 1;
310 m.used_names.push(text.to_string());
311 name_tokens.push((tok.range().start(), text));
312 }
313 if kind == TokenKind::Comment {
314 let line = line1(&li, tok.range().start());
315 if let Some(rules) = parse_ignore_comment(text) {
316 for r in rules {
317 m.ignores.push((line, r));
318 }
319 }
320 if let Some(rules) = parse_noqa_comment(text) {
321 for r in rules {
322 m.ignores.push((line, r));
323 }
324 }
325 }
326 if is_operand(kind) {
328 h_total_oprs += 1;
329 h_oprs.insert(text);
330 } else if !kind.is_trivia()
331 && !matches!(
332 kind,
333 TokenKind::Newline
334 | TokenKind::Indent
335 | TokenKind::Dedent
336 | TokenKind::EndOfFile
337 )
338 {
339 h_total_ops += 1;
340 h_ops.insert(kind);
341 }
342 }
343 m.used_names.sort();
344 m.used_names.dedup();
345 let vocab = (h_ops.len() + h_oprs.len()) as f64;
346 let length = (h_total_ops + h_total_oprs) as f64;
347 m.halstead_volume = if vocab <= 1.0 {
348 0.0
349 } else {
350 length * vocab.log2()
351 };
352
353 scan_top_level(&module.body, &li, false, &mut m);
355
356 let mut nested = NestedImportVisitor {
359 li: &li,
360 depth: 0,
361 out: Vec::new(),
362 };
363 for stmt in &module.body {
364 nested.visit_stmt(stmt);
365 }
366 m.nested_imports = nested.out;
367
368 let mut main = MainVisitor { li: &li, m: &mut m };
370 for stmt in &module.body {
371 main.visit_stmt(stmt);
372 }
373
374 let mut lu = LocalUseVisitor {
377 uses: Vec::new(),
378 attrs: Vec::new(),
379 };
380 for stmt in &module.body {
381 lu.visit_stmt(stmt);
382 }
383 lu.uses.sort();
384 lu.uses.dedup();
385 m.local_uses = lu.uses;
386 lu.attrs.sort();
387 lu.attrs.dedup();
388 m.attr_accessed = lu.attrs;
389
390 let mut res = Resolver {
393 scopes: Vec::new(),
394 used: HashSet::new(),
395 };
396 for stmt in &module.body {
397 res.visit_stmt(stmt);
398 }
399 let mut mu: Vec<String> = res.used.into_iter().collect();
400 mu.sort();
401 m.module_used = mu;
402
403 let mut defs = DefVisitor {
405 funcs: Vec::new(),
406 classes: Vec::new(),
407 };
408 for stmt in &module.body {
409 defs.visit_stmt(stmt);
410 }
411 for f in &defs.funcs {
412 m.functions.push(function_complexity(f, &li));
413 analyze_scope(f, &name_tokens, &mut m.scope_findings, &li);
414 }
415 m.functions.sort_by_key(|f| f.line);
416 m.scope_findings.sort_by_key(|s| s.line);
417 for c in &defs.classes {
418 m.classes.push(class_info(c, &li));
419 }
420 m.classes.sort_by_key(|c| c.line);
421
422 let mut ur = UnreachableVisitor {
425 li: &li,
426 out: Vec::new(),
427 };
428 ur.scan(&module.body);
429 for stmt in &module.body {
430 ur.visit_stmt(stmt);
431 }
432 ur.out.sort_by_key(|u| u.line);
433 ur.out.dedup();
434 m.unreachable = ur.out;
435
436 scan_type_leaks(&module.body, &li, &mut m.type_leaks);
438 m.type_leaks
439 .sort_by(|a, b| a.line.cmp(&b.line).then(a.type_name.cmp(&b.type_name)));
440 m.type_leaks.dedup();
441
442 security_imports(&mut m);
444 m.security_hits
445 .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(b.rule)));
446 m.security_hits
447 .dedup_by(|a, b| a.rule == b.rule && a.line == b.line);
448
449 let mut span_ignores: Vec<(u32, String)> = Vec::new();
455 for (line, rule) in &m.ignores {
456 for imp in &m.imports {
457 if *line > imp.line && *line <= imp.end_line {
458 span_ignores.push((imp.line, rule.clone()));
459 }
460 }
461 }
462 m.ignores.extend(span_ignores);
463 Ok(m)
464 }
465}
466
467const DYNAMIC_SINKS: &[&str] = &["getattr", "setattr", "eval", "exec", "__import__"];
472
473fn line1(li: &LineIndex, off: TextSize) -> u32 {
475 li.line_index(off).get() as u32
476}
477
478fn end_line1(li: &LineIndex, range: TextRange) -> u32 {
480 let end = range.end();
481 if end > range.start() {
482 line1(li, end.checked_sub(TextSize::from(1)).unwrap_or(end))
483 } else {
484 line1(li, end)
485 }
486}
487
488fn is_operand(kind: TokenKind) -> bool {
490 matches!(
491 kind,
492 TokenKind::Name
493 | TokenKind::Int
494 | TokenKind::Float
495 | TokenKind::Complex
496 | TokenKind::String
497 | TokenKind::FStringStart
498 | TokenKind::FStringMiddle
499 | TokenKind::FStringEnd
500 | TokenKind::True
501 | TokenKind::False
502 | TokenKind::None
503 )
504}
505
506fn expr_path(e: &Expr) -> Option<String> {
508 match e {
509 Expr::Name(n) => Some(n.id.as_str().to_string()),
510 Expr::Attribute(a) => Some(format!("{}.{}", expr_path(&a.value)?, a.attr.as_str())),
511 _ => None,
512 }
513}
514
515fn decorator_path(e: &Expr) -> Option<String> {
517 match e {
518 Expr::Call(c) => expr_path(&c.func),
519 other => expr_path(other),
520 }
521}
522
523fn is_private(name: &str) -> bool {
524 name.starts_with('_')
525}
526
527fn scan_top_level(stmts: &[Stmt], li: &LineIndex, type_checking: bool, m: &mut ParsedModule) {
532 for stmt in stmts {
533 match stmt {
534 Stmt::FunctionDef(f) => m.definitions.push(Definition {
535 private_by_convention: is_private(f.name.as_str()),
536 name: f.name.to_string(),
537 kind: DefKind::Function,
538 line: line1(li, f.name.range().start()),
540 end_line: end_line1(li, f.range()),
541 decorators: f
542 .decorator_list
543 .iter()
544 .filter_map(|d| decorator_path(&d.expression))
545 .collect(),
546 }),
547 Stmt::ClassDef(c) => m.definitions.push(Definition {
548 private_by_convention: is_private(c.name.as_str()),
549 name: c.name.to_string(),
550 kind: DefKind::Class,
551 line: line1(li, c.name.range().start()),
552 end_line: end_line1(li, c.range()),
553 decorators: c
554 .decorator_list
555 .iter()
556 .filter_map(|d| decorator_path(&d.expression))
557 .collect(),
558 }),
559 Stmt::Import(i) => parse_import(i, li, &mut m.imports),
560 Stmt::ImportFrom(i) => {
561 let mut imp = parse_import_from(i, li);
562 imp.type_checking_only = type_checking;
563 m.imports.push(imp);
564 }
565 Stmt::Assign(a) => {
566 if let [Expr::Name(target)] = a.targets.as_slice() {
567 let name = target.id.as_str();
568 if name == "__all__" {
569 match string_list(&a.value) {
570 Some(items) => m.dunder_all = Some(items),
571 None => m.dunder_all_dynamic = true,
573 }
574 } else {
575 m.definitions.push(Definition {
576 private_by_convention: is_private(name),
577 name: name.to_string(),
578 kind: DefKind::Variable,
579 line: line1(li, a.range().start()),
580 end_line: end_line1(li, a.range()),
581 decorators: Vec::new(),
582 });
583 }
584 }
585 }
586 Stmt::AnnAssign(a) => {
587 if let Expr::Name(target) = &*a.target {
588 let name = target.id.as_str();
589 if name == "__all__" {
590 if let Some(v) = &a.value {
591 match string_list(v) {
592 Some(items) => m.dunder_all = Some(items),
593 None => m.dunder_all_dynamic = true,
594 }
595 }
596 } else {
597 m.definitions.push(Definition {
598 private_by_convention: is_private(name),
599 name: name.to_string(),
600 kind: DefKind::Variable,
601 line: line1(li, a.range().start()),
602 end_line: end_line1(li, a.range()),
603 decorators: Vec::new(),
604 });
605 }
606 }
607 }
608 Stmt::AugAssign(a) => {
613 if let Expr::Name(t) = &*a.target {
614 if t.id.as_str() == "__all__" {
615 match string_list(&a.value) {
616 Some(items) => match &mut m.dunder_all {
617 Some(all) => all.extend(items),
618 None => m.dunder_all = Some(items),
619 },
620 None => {
621 m.dunder_all = None;
622 m.dunder_all_dynamic = true;
623 }
624 }
625 }
626 }
627 }
628 Stmt::Expr(e) => {
630 if let Expr::Call(c) = &*e.value {
631 match expr_path(&c.func).as_deref() {
632 Some("__all__.extend") => {
633 match c.arguments.args.first().and_then(string_list) {
634 Some(items) => match &mut m.dunder_all {
635 Some(all) => all.extend(items),
636 None => m.dunder_all = Some(items),
637 },
638 None => {
639 m.dunder_all = None;
640 m.dunder_all_dynamic = true;
641 }
642 }
643 }
644 Some("__all__.append") => match c.arguments.args.first() {
645 Some(Expr::StringLiteral(s)) => match &mut m.dunder_all {
646 Some(all) => all.push(s.value.to_str().to_string()),
647 None => m.dunder_all = Some(vec![s.value.to_str().to_string()]),
648 },
649 _ => {
650 m.dunder_all = None;
651 m.dunder_all_dynamic = true;
652 }
653 },
654 _ => {}
655 }
656 }
657 }
658 Stmt::If(i) => {
660 if is_main_guard(&i.test) {
661 m.has_main_guard = true;
662 }
663 let body_tc = type_checking || is_type_checking_guard(&i.test);
667 let else_tc = type_checking || is_not_type_checking_guard(&i.test);
668 let before = m.imports.len();
669 scan_top_level(&i.body, li, body_tc, m);
670 for imp in m.imports[before..].iter_mut() {
671 if body_tc {
672 imp.type_checking_only = true;
673 } else {
674 imp.in_try = true;
680 }
681 }
682 for clause in &i.elif_else_clauses {
683 let before = m.imports.len();
684 scan_top_level(&clause.body, li, else_tc, m);
685 for imp in m.imports[before..].iter_mut() {
686 if else_tc {
687 imp.type_checking_only = true;
688 } else {
689 imp.in_try = true;
690 }
691 }
692 }
693 }
694 Stmt::Try(t) => {
695 let before = m.imports.len();
700 scan_top_level(&t.body, li, type_checking, m);
701 for h in &t.handlers {
702 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
703 scan_top_level(&eh.body, li, type_checking, m);
704 }
705 for imp in m.imports[before..].iter_mut() {
706 imp.in_try = true;
707 }
708 scan_top_level(&t.orelse, li, type_checking, m);
709 scan_top_level(&t.finalbody, li, type_checking, m);
710 }
711 Stmt::With(w) => scan_top_level(&w.body, li, type_checking, m),
713 Stmt::For(f) => {
714 scan_top_level(&f.body, li, type_checking, m);
715 scan_top_level(&f.orelse, li, type_checking, m);
716 }
717 Stmt::While(w) => {
718 scan_top_level(&w.body, li, type_checking, m);
719 scan_top_level(&w.orelse, li, type_checking, m);
720 }
721 Stmt::Match(mt) => {
722 for case in &mt.cases {
723 scan_top_level(&case.body, li, type_checking, m);
724 }
725 }
726 _ => {}
727 }
728 }
729}
730
731struct NestedImportVisitor<'a> {
734 li: &'a LineIndex,
735 depth: u32,
736 out: Vec<Import>,
737}
738
739impl<'a> Visitor<'a> for NestedImportVisitor<'a> {
740 fn visit_stmt(&mut self, stmt: &'a Stmt) {
741 match stmt {
742 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {
743 self.depth += 1;
744 walk_stmt(self, stmt);
745 self.depth -= 1;
746 }
747 Stmt::Import(i) if self.depth > 0 => {
748 parse_import(i, self.li, &mut self.out);
749 walk_stmt(self, stmt);
750 }
751 Stmt::ImportFrom(i) if self.depth > 0 => {
752 self.out.push(parse_import_from(i, self.li));
753 walk_stmt(self, stmt);
754 }
755 _ => walk_stmt(self, stmt),
756 }
757 }
758}
759
760fn is_main_guard(test: &Expr) -> bool {
763 let Expr::Compare(c) = test else {
764 return false;
765 };
766 if c.ops.as_ref() != [ruff_python_ast::CmpOp::Eq] || c.comparators.len() != 1 {
767 return false;
768 }
769 let is_name = |e: &Expr| matches!(e, Expr::Name(n) if n.id.as_str() == "__name__");
770 let is_main_str =
771 |e: &Expr| matches!(e, Expr::StringLiteral(s) if s.value.to_str() == "__main__");
772 (is_name(&c.left) && is_main_str(&c.comparators[0]))
773 || (is_main_str(&c.left) && is_name(&c.comparators[0]))
774}
775
776fn is_type_checking_guard(test: &Expr) -> bool {
779 if let Expr::BooleanLiteral(b) = test {
780 return !b.value; }
782 expr_path(test)
783 .map(|p| p == "TYPE_CHECKING" || p.ends_with(".TYPE_CHECKING"))
784 .unwrap_or(false)
785}
786
787fn is_not_type_checking_guard(test: &Expr) -> bool {
790 if let Expr::UnaryOp(u) = test {
791 return matches!(u.op, ruff_python_ast::UnaryOp::Not) && is_type_checking_guard(&u.operand);
792 }
793 false
794}
795
796fn parse_import(i: &StmtImport, li: &LineIndex, out: &mut Vec<Import>) {
797 let line = line1(li, i.range().start());
798 let end_line = end_line1(li, i.range());
799 for alias in &i.names {
800 let module = alias.name.as_str().to_string();
801 let redundant = matches!(&alias.asname, Some(a) if a.as_str() == alias.name.as_str());
802 let binding = match &alias.asname {
803 Some(a) => a.as_str().to_string(),
804 None => module.split('.').next().unwrap_or(&module).to_string(),
805 };
806 if !module.is_empty() {
807 let bindings = if binding.is_empty() {
808 vec![]
809 } else {
810 vec![binding]
811 };
812 out.push(Import {
813 module,
814 relative_dots: 0,
815 names: vec![],
816 redundant: vec![redundant; bindings.len()],
817 bindings,
818 is_star: false,
819 type_checking_only: false,
820 in_try: false,
821 line,
822 end_line,
823 });
824 }
825 }
826}
827
828fn parse_import_from(i: &StmtImportFrom, li: &LineIndex) -> Import {
829 let line = line1(li, i.range().start());
830 let end_line = end_line1(li, i.range());
831 let module = i.module.as_ref().map(|m| m.to_string()).unwrap_or_default();
832 let mut names = Vec::new();
833 let mut bindings = Vec::new();
834 let mut redundant = Vec::new();
835 let mut is_star = false;
836 for alias in &i.names {
837 let name = alias.name.as_str();
838 if name == "*" {
839 is_star = true;
840 continue;
841 }
842 names.push(name.to_string());
843 redundant.push(matches!(&alias.asname, Some(a) if a.as_str() == name));
844 bindings.push(match &alias.asname {
845 Some(a) => a.as_str().to_string(),
846 None => name.to_string(),
847 });
848 }
849 Import {
850 module,
851 relative_dots: i.level.min(u8::MAX as u32) as u8,
852 names,
853 bindings,
854 redundant,
855 is_star,
856 type_checking_only: false,
857 in_try: false,
858 line,
859 end_line,
860 }
861}
862
863fn string_list(e: &Expr) -> Option<Vec<String>> {
865 let elts = match e {
866 Expr::List(l) => &l.elts,
867 Expr::Tuple(t) => &t.elts,
868 _ => return None,
869 };
870 Some(
871 elts.iter()
872 .filter_map(|el| match el {
873 Expr::StringLiteral(s) => Some(s.value.to_str().to_string()),
874 _ => None,
875 })
876 .collect(),
877 )
878}
879
880fn function_complexity(f: &StmtFunctionDef, li: &LineIndex) -> FunctionComplexity {
885 let (params_total, params_annotated) = count_params(&f.parameters);
886 let mut cv = CycloVisitor { count: 0 };
887 for s in &f.body {
888 cv.visit_stmt(s);
889 }
890 FunctionComplexity {
891 name: f.name.to_string(),
892 line: line1(li, f.name.range().start()),
894 end_line: end_line1(li, f.range()),
895 cyclomatic: 1 + cv.count,
896 cognitive: cog_stmts(&f.body, 0),
897 params_total,
898 params_annotated,
899 return_annotated: f.returns.is_some(),
900 }
901}
902
903fn count_params(params: &Parameters) -> (u32, u32) {
904 let positional: Vec<_> = params
905 .posonlyargs
906 .iter()
907 .chain(params.args.iter())
908 .collect();
909 let mut total = 0u32;
910 let mut annotated = 0u32;
911 for (idx, p) in positional.iter().enumerate() {
912 let name = p.parameter.name.as_str();
913 if idx == 0 && (name == "self" || name == "cls") {
914 continue;
915 }
916 total += 1;
917 if p.parameter.annotation.is_some() {
918 annotated += 1;
919 }
920 }
921 for p in ¶ms.kwonlyargs {
922 total += 1;
923 if p.parameter.annotation.is_some() {
924 annotated += 1;
925 }
926 }
927 (total, annotated.min(total))
928}
929
930struct CycloVisitor {
932 count: u32,
933}
934impl<'a> Visitor<'a> for CycloVisitor {
935 fn visit_stmt(&mut self, stmt: &'a Stmt) {
936 match stmt {
937 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return, Stmt::If(i) => {
939 self.count += 1 + i
940 .elif_else_clauses
941 .iter()
942 .filter(|c| c.test.is_some())
943 .count() as u32;
944 }
945 Stmt::For(_) | Stmt::While(_) => self.count += 1,
946 Stmt::Try(t) => self.count += t.handlers.len() as u32,
947 Stmt::Assert(_) => self.count += 1,
948 Stmt::Match(mt) => self.count += mt.cases.len() as u32,
949 _ => {}
950 }
951 walk_stmt(self, stmt);
952 }
953 fn visit_expr(&mut self, expr: &'a Expr) {
954 match expr {
955 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
956 Expr::If(_) => self.count += 1, Expr::ListComp(c) => self.count += comp_points(&c.generators),
958 Expr::SetComp(c) => self.count += comp_points(&c.generators),
959 Expr::DictComp(c) => self.count += comp_points(&c.generators),
960 Expr::Generator(c) => self.count += comp_points(&c.generators),
961 _ => {}
962 }
963 walk_expr(self, expr);
964 }
965}
966
967fn comp_points(gens: &[ruff_python_ast::Comprehension]) -> u32 {
968 gens.iter().map(|g| 1 + g.ifs.len() as u32).sum()
969}
970
971fn cog_stmts(stmts: &[Stmt], nesting: u32) -> u32 {
973 stmts.iter().map(|s| cog_stmt(s, nesting)).sum()
974}
975
976fn cog_stmt(s: &Stmt, nesting: u32) -> u32 {
977 match s {
978 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => 0,
979 Stmt::If(i) => {
980 let mut c = 1 + nesting + cog_cond(&i.test);
981 c += cog_stmts(&i.body, nesting + 1);
982 for clause in &i.elif_else_clauses {
983 c += 1; if let Some(t) = &clause.test {
985 c += cog_cond(t);
986 }
987 c += cog_stmts(&clause.body, nesting + 1);
988 }
989 c
990 }
991 Stmt::For(f) => {
992 1 + nesting + cog_stmts(&f.body, nesting + 1) + cog_stmts(&f.orelse, nesting + 1)
993 }
994 Stmt::While(w) => {
995 1 + nesting
996 + cog_cond(&w.test)
997 + cog_stmts(&w.body, nesting + 1)
998 + cog_stmts(&w.orelse, nesting + 1)
999 }
1000 Stmt::With(w) => cog_stmts(&w.body, nesting),
1001 Stmt::Try(t) => {
1002 let mut c = cog_stmts(&t.body, nesting);
1003 for h in &t.handlers {
1004 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1005 c += 1 + nesting + cog_stmts(&eh.body, nesting + 1);
1006 }
1007 c += cog_stmts(&t.orelse, nesting) + cog_stmts(&t.finalbody, nesting);
1008 c
1009 }
1010 Stmt::Match(mt) => {
1011 let mut c = 0;
1012 for case in &mt.cases {
1013 c += 1 + nesting + cog_stmts(&case.body, nesting + 1);
1014 }
1015 c
1016 }
1017 Stmt::Expr(e) => cog_cond(&e.value),
1018 Stmt::Return(r) => r.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1019 Stmt::Assign(a) => cog_cond(&a.value),
1020 Stmt::AugAssign(a) => cog_cond(&a.value),
1021 Stmt::AnnAssign(a) => a.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1022 _ => 0,
1023 }
1024}
1025
1026fn cog_cond(e: &Expr) -> u32 {
1028 let mut v = CondVisitor { count: 0 };
1029 v.visit_expr(e);
1030 v.count
1031}
1032struct CondVisitor {
1033 count: u32,
1034}
1035impl<'a> Visitor<'a> for CondVisitor {
1036 fn visit_expr(&mut self, expr: &'a Expr) {
1037 match expr {
1038 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
1039 Expr::If(_) => self.count += 1,
1040 _ => {}
1041 }
1042 walk_expr(self, expr);
1043 }
1044}
1045
1046const SCOPE_DYNAMIC: &[&str] = &["locals", "vars", "globals", "eval", "exec"];
1051
1052fn analyze_scope(
1053 f: &StmtFunctionDef,
1054 name_tokens: &[(TextSize, &str)],
1055 out: &mut Vec<ScopeFinding>,
1056 li: &LineIndex,
1057) {
1058 let range = f.range();
1060 let mut freq: HashMap<&str, u32> = HashMap::new();
1061 for (off, text) in name_tokens {
1062 if *off >= range.start() && *off < range.end() {
1063 *freq.entry(*text).or_insert(0) += 1;
1064 }
1065 }
1066 if SCOPE_DYNAMIC.iter().any(|d| freq.contains_key(*d)) {
1067 return;
1068 }
1069
1070 let mut gv = GlobalVisitor {
1072 names: HashSet::new(),
1073 };
1074 for s in &f.body {
1075 gv.visit_stmt(s);
1076 }
1077 let declared_global = gv.names;
1078
1079 let decorated = !f.decorator_list.is_empty();
1080 let fname = f.name.as_str();
1081 let is_dunder = fname.starts_with("__") && fname.ends_with("__");
1082 let stub = is_stub_body(&f.body);
1083
1084 if !decorated && !is_dunder && !stub {
1085 let positional: Vec<_> = f
1086 .parameters
1087 .posonlyargs
1088 .iter()
1089 .chain(f.parameters.args.iter())
1090 .collect();
1091 for (idx, p) in positional.iter().enumerate() {
1092 let name = p.parameter.name.as_str();
1093 if idx == 0 && (name == "self" || name == "cls") {
1094 continue;
1095 }
1096 if name.starts_with('_') || declared_global.contains(name) {
1097 continue;
1098 }
1099 if freq.get(name).copied().unwrap_or(0) == 1 {
1100 out.push(ScopeFinding {
1101 line: line1(li, p.parameter.range().start()),
1102 name: name.to_string(),
1103 is_param: true,
1104 });
1105 }
1106 }
1107 for p in &f.parameters.kwonlyargs {
1108 let name = p.parameter.name.as_str();
1109 if name.starts_with('_') || declared_global.contains(name) {
1110 continue;
1111 }
1112 if freq.get(name).copied().unwrap_or(0) == 1 {
1113 out.push(ScopeFinding {
1114 line: line1(li, p.parameter.range().start()),
1115 name: name.to_string(),
1116 is_param: true,
1117 });
1118 }
1119 }
1120 }
1121
1122 for stmt in &f.body {
1124 if let Stmt::Assign(a) = stmt {
1125 if let [Expr::Name(target)] = a.targets.as_slice() {
1126 let name = target.id.as_str();
1127 if name == "_" || declared_global.contains(name) {
1128 continue;
1129 }
1130 if freq.get(name).copied().unwrap_or(0) == 1 {
1131 out.push(ScopeFinding {
1132 line: line1(li, a.range().start()),
1133 name: name.to_string(),
1134 is_param: false,
1135 });
1136 }
1137 }
1138 }
1139 }
1140}
1141
1142struct GlobalVisitor {
1143 names: HashSet<String>,
1144}
1145impl<'a> Visitor<'a> for GlobalVisitor {
1146 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1147 match stmt {
1148 Stmt::Global(g) => {
1149 for n in &g.names {
1150 self.names.insert(n.as_str().to_string());
1151 }
1152 }
1153 Stmt::Nonlocal(g) => {
1154 for n in &g.names {
1155 self.names.insert(n.as_str().to_string());
1156 }
1157 }
1158 _ => {}
1159 }
1160 walk_stmt(self, stmt);
1161 }
1162}
1163
1164fn is_stub_body(body: &[Stmt]) -> bool {
1166 body.iter().all(|s| match s {
1167 Stmt::Pass(_) => true,
1168 Stmt::Raise(_) => true,
1169 Stmt::Expr(e) => matches!(&*e.value, Expr::StringLiteral(_) | Expr::EllipsisLiteral(_)),
1170 _ => false,
1171 })
1172}
1173
1174fn class_info(c: &StmtClassDef, li: &LineIndex) -> ClassInfo {
1179 let mut methods = Vec::new();
1180 let mut members: Vec<ClassMember> = Vec::new();
1181 for stmt in &c.body {
1182 match stmt {
1183 Stmt::FunctionDef(f) => {
1184 methods.push((f.name.to_string(), self_attrs(f)));
1185 members.push(ClassMember {
1186 name: f.name.to_string(),
1187 line: line1(li, f.name.range().start()),
1189 end_line: end_line1(li, f.range()),
1190 is_method: true,
1191 is_private: is_private(f.name.as_str()),
1192 decorators: f
1193 .decorator_list
1194 .iter()
1195 .filter_map(|d| decorator_path(&d.expression))
1196 .collect(),
1197 });
1198 }
1199 Stmt::Assign(a) => {
1200 if let [Expr::Name(t)] = a.targets.as_slice() {
1201 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1202 }
1203 }
1204 Stmt::AnnAssign(a) => {
1205 if let Expr::Name(t) = &*a.target {
1206 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1207 }
1208 }
1209 _ => {}
1210 }
1211 }
1212 let bases: Vec<String> = c
1213 .arguments
1214 .as_ref()
1215 .map(|args| args.args.iter().filter_map(expr_path).collect())
1216 .unwrap_or_default();
1217 let is_enum = bases.iter().any(|b| {
1218 let last = b.rsplit('.').next().unwrap_or(b);
1219 matches!(
1220 last,
1221 "Enum" | "IntEnum" | "StrEnum" | "Flag" | "IntFlag" | "ReprEnum" | "EnumMeta"
1222 )
1223 });
1224 ClassInfo {
1225 name: c.name.to_string(),
1226 line: line1(li, c.name.range().start()),
1228 end_line: end_line1(li, c.range()),
1229 is_private: is_private(c.name.as_str()),
1230 decorators: c
1231 .decorator_list
1232 .iter()
1233 .filter_map(|d| decorator_path(&d.expression))
1234 .collect(),
1235 bases,
1236 is_enum,
1237 methods,
1238 members,
1239 }
1240}
1241
1242fn class_attr_member(name: &str, range: TextRange, li: &LineIndex) -> ClassMember {
1243 ClassMember {
1244 name: name.to_string(),
1245 line: line1(li, range.start()),
1246 end_line: end_line1(li, range),
1247 is_method: false,
1248 is_private: is_private(name),
1249 decorators: Vec::new(),
1250 }
1251}
1252
1253struct UnreachableVisitor<'li> {
1258 li: &'li LineIndex,
1259 out: Vec<UnreachableCode>,
1260}
1261impl<'li> UnreachableVisitor<'li> {
1262 fn scan(&mut self, body: &[Stmt]) {
1264 for (i, stmt) in body.iter().enumerate() {
1265 if let Some(term) = terminator_kind(stmt) {
1266 if let Some(next) = body.get(i + 1) {
1267 self.out.push(UnreachableCode {
1269 line: line1(self.li, next.range().start()),
1270 after: term,
1271 });
1272 }
1273 break; }
1275 }
1276 }
1277}
1278impl<'a, 'li> Visitor<'a> for UnreachableVisitor<'li> {
1279 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1280 match stmt {
1282 Stmt::FunctionDef(f) => self.scan(&f.body),
1283 Stmt::ClassDef(c) => self.scan(&c.body),
1284 Stmt::If(i) => {
1285 self.scan(&i.body);
1286 for c in &i.elif_else_clauses {
1287 self.scan(&c.body);
1288 }
1289 }
1290 Stmt::For(f) => {
1291 self.scan(&f.body);
1292 self.scan(&f.orelse);
1293 }
1294 Stmt::While(w) => {
1295 self.scan(&w.body);
1296 self.scan(&w.orelse);
1297 }
1298 Stmt::With(w) => self.scan(&w.body),
1299 Stmt::Try(t) => {
1300 self.scan(&t.body);
1301 for h in &t.handlers {
1302 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1303 self.scan(&eh.body);
1304 }
1305 self.scan(&t.orelse);
1306 self.scan(&t.finalbody);
1307 }
1308 Stmt::Match(mt) => {
1309 for case in &mt.cases {
1310 self.scan(&case.body);
1311 }
1312 }
1313 _ => {}
1314 }
1315 walk_stmt(self, stmt);
1316 }
1317}
1318
1319fn terminator_kind(stmt: &Stmt) -> Option<&'static str> {
1321 match stmt {
1322 Stmt::Return(_) => Some("return"),
1323 Stmt::Raise(_) => Some("raise"),
1324 Stmt::Break(_) => Some("break"),
1325 Stmt::Continue(_) => Some("continue"),
1326 Stmt::Expr(e) if is_noreturn_call(&e.value) => Some("exit call"),
1327 _ => None,
1328 }
1329}
1330
1331fn is_noreturn_call(e: &Expr) -> bool {
1333 if let Expr::Call(c) = e {
1334 if let Some(p) = expr_path(&c.func) {
1335 return matches!(p.as_str(), "sys.exit" | "os._exit" | "exit" | "quit");
1338 }
1339 }
1340 false
1341}
1342
1343fn is_private_type(name: &str) -> bool {
1350 name.starts_with('_') && !(name.starts_with("__") && name.ends_with("__"))
1351}
1352
1353fn scan_type_leaks(body: &[Stmt], li: &LineIndex, out: &mut Vec<TypeLeak>) {
1354 let mut typevars: HashSet<String> = HashSet::new();
1357 collect_typevars(body, &mut typevars);
1358 for stmt in body {
1359 match stmt {
1360 Stmt::FunctionDef(f) if !is_private(f.name.as_str()) => {
1361 collect_fn_leaks(None, f, li, &typevars, out);
1362 }
1363 Stmt::ClassDef(c) if !is_private(c.name.as_str()) => {
1364 for s in &c.body {
1365 if let Stmt::FunctionDef(f) = s {
1366 if !is_private(f.name.as_str()) {
1367 collect_fn_leaks(Some(c.name.as_str()), f, li, &typevars, out);
1368 }
1369 }
1370 }
1371 }
1372 _ => {}
1373 }
1374 }
1375}
1376
1377fn collect_typevars(body: &[Stmt], out: &mut HashSet<String>) {
1380 for stmt in body {
1381 match stmt {
1382 Stmt::Assign(a) => {
1383 if let (Some(Expr::Name(t)), Expr::Call(c)) = (a.targets.first(), &*a.value) {
1384 if let Some(p) = expr_path(&c.func) {
1385 let last = p.rsplit('.').next().unwrap_or(&p);
1386 if matches!(last, "TypeVar" | "ParamSpec" | "TypeVarTuple") {
1387 out.insert(t.id.as_str().to_string());
1388 }
1389 }
1390 }
1391 }
1392 Stmt::If(i) => {
1393 collect_typevars(&i.body, out);
1394 for clause in &i.elif_else_clauses {
1395 collect_typevars(&clause.body, out);
1396 }
1397 }
1398 Stmt::Try(t) => {
1399 collect_typevars(&t.body, out);
1400 for h in &t.handlers {
1401 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1402 collect_typevars(&eh.body, out);
1403 }
1404 collect_typevars(&t.orelse, out);
1405 collect_typevars(&t.finalbody, out);
1406 }
1407 _ => {}
1408 }
1409 }
1410}
1411
1412fn collect_fn_leaks(
1413 class: Option<&str>,
1414 f: &StmtFunctionDef,
1415 li: &LineIndex,
1416 typevars: &HashSet<String>,
1417 out: &mut Vec<TypeLeak>,
1418) {
1419 let qualified = match class {
1420 Some(c) => format!("{c}.{}", f.name),
1421 None => f.name.to_string(),
1422 };
1423 let push_leaks = |ann: &Expr, line: u32, is_return: bool, out: &mut Vec<TypeLeak>| {
1424 let mut idents = Vec::new();
1425 annotation_idents(ann, &mut idents);
1426 for id in idents {
1427 if is_private_type(&id) && !typevars.contains(&id) {
1428 out.push(TypeLeak {
1429 function: qualified.clone(),
1430 type_name: id,
1431 line,
1432 is_return,
1433 });
1434 }
1435 }
1436 };
1437 for p in f
1438 .parameters
1439 .posonlyargs
1440 .iter()
1441 .chain(f.parameters.args.iter())
1442 .chain(f.parameters.kwonlyargs.iter())
1443 {
1444 if let Some(ann) = &p.parameter.annotation {
1445 push_leaks(ann, line1(li, p.parameter.range().start()), false, out);
1446 }
1447 }
1448 if let Some(r) = &f.returns {
1449 push_leaks(r, line1(li, f.name.range().start()), true, out);
1451 }
1452}
1453
1454fn annotation_idents(e: &Expr, out: &mut Vec<String>) {
1458 match e {
1459 Expr::Name(n) => out.push(n.id.as_str().to_string()),
1460 Expr::Attribute(a) => {
1461 annotation_idents(&a.value, out);
1462 out.push(a.attr.as_str().to_string());
1463 }
1464 Expr::Subscript(s) => {
1465 annotation_idents(&s.value, out);
1466 annotation_idents(&s.slice, out);
1467 }
1468 Expr::Tuple(t) => t.elts.iter().for_each(|el| annotation_idents(el, out)),
1469 Expr::List(l) => l.elts.iter().for_each(|el| annotation_idents(el, out)),
1470 Expr::BinOp(b) => {
1471 annotation_idents(&b.left, out);
1472 annotation_idents(&b.right, out);
1473 }
1474 Expr::StringLiteral(s) => {
1475 for tok in identifier_tokens(s.value.to_str()) {
1476 out.push(tok);
1477 }
1478 }
1479 _ => {}
1480 }
1481}
1482
1483fn self_attrs(f: &StmtFunctionDef) -> Vec<String> {
1484 let mut v = SelfAttrVisitor {
1485 attrs: std::collections::BTreeSet::new(),
1486 };
1487 for s in &f.body {
1488 v.visit_stmt(s);
1489 }
1490 v.attrs.into_iter().collect()
1491}
1492
1493struct SelfAttrVisitor {
1494 attrs: std::collections::BTreeSet<String>,
1495}
1496impl<'a> Visitor<'a> for SelfAttrVisitor {
1497 fn visit_expr(&mut self, expr: &'a Expr) {
1498 if let Expr::Attribute(a) = expr {
1499 if let Expr::Name(obj) = &*a.value {
1500 if obj.id.as_str() == "self" || obj.id.as_str() == "cls" {
1501 self.attrs.insert(a.attr.as_str().to_string());
1502 }
1503 }
1504 }
1505 walk_expr(self, expr);
1506 }
1507}
1508
1509struct DefVisitor<'a> {
1514 funcs: Vec<&'a StmtFunctionDef>,
1515 classes: Vec<&'a StmtClassDef>,
1516}
1517impl<'a> Visitor<'a> for DefVisitor<'a> {
1518 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1519 match stmt {
1520 Stmt::FunctionDef(f) => self.funcs.push(f),
1521 Stmt::ClassDef(c) => self.classes.push(c),
1522 _ => {}
1523 }
1524 walk_stmt(self, stmt);
1525 }
1526}
1527
1528struct LocalUseVisitor {
1533 uses: Vec<String>,
1534 attrs: Vec<String>,
1536}
1537impl<'a> Visitor<'a> for LocalUseVisitor {
1538 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1539 if matches!(stmt, Stmt::Import(_) | Stmt::ImportFrom(_)) {
1541 return;
1542 }
1543 if let Stmt::AnnAssign(a) = stmt {
1545 collect_annotation_strings(&a.annotation, &mut self.uses);
1546 if is_type_alias_annotation(&a.annotation) {
1552 if let Some(v) = &a.value {
1553 collect_annotation_strings(v, &mut self.uses);
1554 }
1555 }
1556 }
1557 if let Stmt::FunctionDef(f) = stmt {
1558 if let Some(r) = &f.returns {
1559 collect_annotation_strings(r, &mut self.uses);
1560 }
1561 for p in f
1562 .parameters
1563 .posonlyargs
1564 .iter()
1565 .chain(f.parameters.args.iter())
1566 .chain(f.parameters.kwonlyargs.iter())
1567 {
1568 if let Some(ann) = &p.parameter.annotation {
1569 collect_annotation_strings(ann, &mut self.uses);
1570 }
1571 }
1572 }
1573 walk_stmt(self, stmt);
1574 }
1575 fn visit_expr(&mut self, expr: &'a Expr) {
1576 match expr {
1577 Expr::Name(n) => self.uses.push(n.id.as_str().to_string()),
1578 Expr::Attribute(a) => {
1579 self.uses.push(a.attr.as_str().to_string());
1580 self.attrs.push(a.attr.as_str().to_string());
1581 }
1582 Expr::Call(c) => {
1586 let is_cast = expr_path(&c.func)
1587 .map(|p| p == "cast" || p.ends_with(".cast"))
1588 .unwrap_or(false);
1589 if is_cast {
1590 if let Some(first) = c.arguments.args.first() {
1591 collect_annotation_strings(first, &mut self.uses);
1592 }
1593 }
1594 }
1595 _ => {}
1596 }
1597 walk_expr(self, expr);
1598 }
1599}
1600
1601fn is_type_alias_annotation(e: &Expr) -> bool {
1604 expr_path(e)
1605 .map(|p| p == "TypeAlias" || p.ends_with(".TypeAlias"))
1606 .unwrap_or(false)
1607}
1608
1609fn collect_annotation_strings(e: &Expr, out: &mut Vec<String>) {
1612 match e {
1613 Expr::StringLiteral(s) => {
1614 for tok in identifier_tokens(s.value.to_str()) {
1615 out.push(tok);
1616 }
1617 }
1618 Expr::Subscript(s) => {
1619 collect_annotation_strings(&s.value, out);
1620 collect_annotation_strings(&s.slice, out);
1621 }
1622 Expr::Tuple(t) => {
1623 for el in &t.elts {
1624 collect_annotation_strings(el, out);
1625 }
1626 }
1627 Expr::List(l) => {
1628 for el in &l.elts {
1629 collect_annotation_strings(el, out);
1630 }
1631 }
1632 Expr::BinOp(b) => {
1633 collect_annotation_strings(&b.left, out);
1634 collect_annotation_strings(&b.right, out);
1635 }
1636 _ => {}
1637 }
1638}
1639
1640fn identifier_tokens(s: &str) -> Vec<String> {
1641 let mut out = Vec::new();
1642 let mut cur = String::new();
1643 let flush = |cur: &mut String, out: &mut Vec<String>| {
1644 if !cur.is_empty() && !cur.chars().next().unwrap().is_ascii_digit() {
1645 out.push(std::mem::take(cur));
1646 } else {
1647 cur.clear();
1648 }
1649 };
1650 for ch in s.chars() {
1651 if ch.is_ascii_alphanumeric() || ch == '_' {
1652 cur.push(ch);
1653 } else {
1654 flush(&mut cur, &mut out);
1655 }
1656 }
1657 flush(&mut cur, &mut out);
1658 out
1659}
1660
1661struct FnScope {
1674 locals: HashSet<String>,
1675 globals: HashSet<String>,
1676}
1677
1678struct Resolver {
1679 scopes: Vec<FnScope>,
1680 used: HashSet<String>,
1681}
1682
1683impl Resolver {
1684 fn resolve_load(&mut self, name: &str) {
1685 for s in self.scopes.iter().rev() {
1686 if s.globals.contains(name) {
1687 self.used.insert(name.to_string()); return;
1689 }
1690 if s.locals.contains(name) {
1691 return; }
1693 }
1694 self.used.insert(name.to_string());
1696 }
1697
1698 fn enter_function(&mut self, f: &StmtFunctionDef) {
1699 let mut bv = BindingVisitor {
1700 locals: HashSet::new(),
1701 globals: HashSet::new(),
1702 };
1703 for p in param_names(&f.parameters) {
1704 bv.locals.insert(p);
1705 }
1706 for stmt in &f.body {
1707 bv.visit_stmt(stmt);
1708 }
1709 for g in &bv.globals {
1711 bv.locals.remove(g);
1712 }
1713 self.scopes.push(FnScope {
1714 locals: bv.locals,
1715 globals: bv.globals,
1716 });
1717 }
1718
1719 fn visit_signature_exprs(&mut self, params: &Parameters) {
1722 for p in params
1723 .posonlyargs
1724 .iter()
1725 .chain(params.args.iter())
1726 .chain(params.kwonlyargs.iter())
1727 {
1728 if let Some(d) = &p.default {
1729 self.visit_expr(d);
1730 }
1731 if let Some(a) = &p.parameter.annotation {
1732 self.visit_expr(a);
1733 }
1734 }
1735 if let Some(v) = ¶ms.vararg {
1736 if let Some(a) = &v.annotation {
1737 self.visit_expr(a);
1738 }
1739 }
1740 if let Some(k) = ¶ms.kwarg {
1741 if let Some(a) = &k.annotation {
1742 self.visit_expr(a);
1743 }
1744 }
1745 }
1746}
1747
1748impl<'a> Visitor<'a> for Resolver {
1749 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1750 match stmt {
1751 Stmt::FunctionDef(f) => {
1752 for d in &f.decorator_list {
1755 self.visit_expr(&d.expression);
1756 }
1757 self.visit_signature_exprs(&f.parameters);
1758 if let Some(r) = &f.returns {
1759 self.visit_expr(r);
1760 }
1761 self.enter_function(f);
1762 for stmt in &f.body {
1763 self.visit_stmt(stmt);
1764 }
1765 self.scopes.pop();
1766 }
1767 Stmt::ClassDef(c) => {
1768 for d in &c.decorator_list {
1769 self.visit_expr(&d.expression);
1770 }
1771 if let Some(args) = &c.arguments {
1772 for a in args.args.iter() {
1773 self.visit_expr(a);
1774 }
1775 for kw in args.keywords.iter() {
1776 self.visit_expr(&kw.value);
1777 }
1778 }
1779 for stmt in &c.body {
1781 self.visit_stmt(stmt);
1782 }
1783 }
1784 _ => walk_stmt(self, stmt),
1785 }
1786 }
1787
1788 fn visit_expr(&mut self, expr: &'a Expr) {
1789 match expr {
1790 Expr::Name(n) => {
1791 if matches!(n.ctx, ExprContext::Load) {
1792 self.resolve_load(n.id.as_str());
1793 }
1794 }
1795 Expr::Lambda(l) => {
1796 let mut locals = HashSet::new();
1797 if let Some(params) = &l.parameters {
1798 self.visit_signature_exprs(params);
1800 for p in param_names(params) {
1801 locals.insert(p);
1802 }
1803 }
1804 self.scopes.push(FnScope {
1805 locals,
1806 globals: HashSet::new(),
1807 });
1808 self.visit_expr(&l.body);
1809 self.scopes.pop();
1810 }
1811 _ => walk_expr(self, expr),
1812 }
1813 }
1814}
1815
1816struct BindingVisitor {
1820 locals: HashSet<String>,
1821 globals: HashSet<String>,
1822}
1823impl<'a> Visitor<'a> for BindingVisitor {
1824 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1825 match stmt {
1826 Stmt::FunctionDef(f) => {
1827 self.locals.insert(f.name.to_string());
1828 }
1829 Stmt::ClassDef(c) => {
1830 self.locals.insert(c.name.to_string());
1831 }
1832 Stmt::Global(g) => {
1833 for n in &g.names {
1834 self.globals.insert(n.to_string());
1835 }
1836 }
1837 Stmt::Nonlocal(g) => {
1838 for n in &g.names {
1839 self.locals.insert(n.to_string());
1841 }
1842 }
1843 _ => walk_stmt(self, stmt),
1844 }
1845 }
1846 fn visit_expr(&mut self, expr: &'a Expr) {
1847 match expr {
1848 Expr::Name(n) if matches!(n.ctx, ExprContext::Store) => {
1849 self.locals.insert(n.id.as_str().to_string());
1850 }
1851 Expr::Lambda(_)
1856 | Expr::ListComp(_)
1857 | Expr::SetComp(_)
1858 | Expr::DictComp(_)
1859 | Expr::Generator(_) => {}
1860 _ => walk_expr(self, expr),
1861 }
1862 }
1863}
1864
1865fn param_names(params: &Parameters) -> Vec<String> {
1866 let mut out = Vec::new();
1867 for p in params
1868 .posonlyargs
1869 .iter()
1870 .chain(params.args.iter())
1871 .chain(params.kwonlyargs.iter())
1872 {
1873 out.push(p.parameter.name.as_str().to_string());
1874 }
1875 if let Some(v) = ¶ms.vararg {
1876 out.push(v.name.as_str().to_string());
1877 }
1878 if let Some(k) = ¶ms.kwarg {
1879 out.push(k.name.as_str().to_string());
1880 }
1881 out
1882}
1883
1884struct MainVisitor<'a, 'm> {
1889 li: &'a LineIndex,
1890 m: &'m mut ParsedModule,
1891}
1892impl<'a, 'm> Visitor<'a> for MainVisitor<'a, 'm> {
1893 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1894 match stmt {
1895 Stmt::Assign(a) => {
1896 if let [Expr::Name(t)] = a.targets.as_slice() {
1897 security_secret(t.id.as_str(), &a.value, a.range(), self.li, self.m);
1898 }
1899 }
1900 Stmt::AnnAssign(a) => {
1901 if let (Expr::Name(t), Some(v)) = (&*a.target, &a.value) {
1902 security_secret(t.id.as_str(), v, a.range(), self.li, self.m);
1903 }
1904 }
1905 Stmt::Try(t) => {
1906 for h in &t.handlers {
1909 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1910 let broad = match &eh.type_ {
1911 None => true,
1912 Some(ty) => expr_path(ty)
1913 .map(|p| {
1914 matches!(
1915 p.rsplit('.').next().unwrap_or(&p),
1916 "Exception" | "BaseException"
1917 )
1918 })
1919 .unwrap_or(false),
1920 };
1921 if broad && eh.body.iter().all(|s| matches!(s, Stmt::Pass(_))) {
1922 self.m.security_hits.push(SecurityHit {
1923 rule: "try-except-pass",
1924 line: line1(self.li, eh.range().start()),
1925 detail:
1926 "broad `except: pass` silently swallows errors; log or handle them"
1927 .into(),
1928 });
1929 }
1930 }
1931 }
1932 _ => {}
1933 }
1934 walk_stmt(self, stmt);
1935 }
1936 fn visit_expr(&mut self, expr: &'a Expr) {
1937 if let Expr::Call(c) = expr {
1938 let callee = expr_path(&c.func).unwrap_or_default();
1939 if !callee.is_empty() {
1940 if DYNAMIC_SINKS.contains(&callee.as_str()) || callee.starts_with("importlib") {
1941 self.m.has_dynamic_sink = true;
1942 }
1943 self.m.calls.push(CallSite {
1944 callee: callee.clone(),
1945 line: line1(self.li, c.func.range().start()),
1946 });
1947 }
1948 security_call(c, &callee, line1(self.li, c.range().start()), self.m);
1949 }
1950 walk_expr(self, expr);
1951 }
1952}
1953
1954const SECRET_NAMES: &[&str] = &[
1955 "password",
1956 "passwd",
1957 "secret",
1958 "token",
1959 "api_key",
1960 "apikey",
1961 "access_key",
1962 "secret_key",
1963 "private_key",
1964 "auth_token",
1965];
1966
1967fn security_secret(
1968 name: &str,
1969 value: &Expr,
1970 range: TextRange,
1971 li: &LineIndex,
1972 m: &mut ParsedModule,
1973) {
1974 let lname = name.to_ascii_lowercase();
1975 if !SECRET_NAMES.iter().any(|s| lname.contains(s)) {
1976 return;
1977 }
1978 if let Expr::StringLiteral(s) = value {
1979 let val = s.value.to_str();
1980 if val.len() >= 4 && !val.contains("${") && !val.eq_ignore_ascii_case("changeme") {
1981 m.security_hits.push(SecurityHit {
1982 rule: "hardcoded-secret",
1983 line: line1(li, range.start()),
1984 detail: format!("`{name}` assigned a hardcoded string literal"),
1985 });
1986 }
1987 }
1988}
1989
1990const WEAK_CIPHERS: &[&str] = &[
1991 "DES",
1992 "DES3",
1993 "TripleDES",
1994 "ARC2",
1995 "RC2",
1996 "ARC4",
1997 "RC4",
1998 "Blowfish",
1999 "IDEA",
2000 "CAST",
2001 "XOR",
2002];
2003
2004fn kwarg_bool(c: &ruff_python_ast::ExprCall, name: &str, want: bool) -> bool {
2005 c.arguments
2006 .find_keyword(name)
2007 .map(|kw| matches!(&kw.value, Expr::BooleanLiteral(b) if b.value == want))
2008 .unwrap_or(false)
2009}
2010
2011fn has_kwarg(c: &ruff_python_ast::ExprCall, name: &str) -> bool {
2012 c.arguments.find_keyword(name).is_some()
2013}
2014
2015fn first_positional_is_string(c: &ruff_python_ast::ExprCall) -> bool {
2016 matches!(c.arguments.args.first(), Some(Expr::StringLiteral(_)))
2017}
2018
2019fn is_dynamic_string(arg: &Expr) -> bool {
2020 match arg {
2021 Expr::FString(_) => true,
2022 Expr::BinOp(_) => true,
2023 Expr::Call(c) => expr_path(&c.func)
2024 .map(|p| p.ends_with(".format"))
2025 .unwrap_or(false),
2026 _ => false,
2027 }
2028}
2029
2030fn args_reference_ecb(c: &ruff_python_ast::ExprCall) -> bool {
2032 let refs = |e: &Expr| {
2033 expr_path(e)
2034 .map(|p| p.contains("MODE_ECB"))
2035 .unwrap_or(false)
2036 };
2037 c.arguments.args.iter().any(refs) || c.arguments.keywords.iter().any(|k| refs(&k.value))
2038}
2039
2040fn security_call(c: &ruff_python_ast::ExprCall, f: &str, line: u32, m: &mut ParsedModule) {
2041 let last = f.rsplit('.').next().unwrap_or(f);
2042 let mut hit = |rule: &'static str, detail: String| {
2043 m.security_hits.push(SecurityHit { rule, line, detail });
2044 };
2045
2046 if matches!(
2050 f,
2051 "eval" | "exec" | "compile" | "builtins.eval" | "builtins.exec" | "builtins.compile"
2052 ) && !first_positional_is_string(c)
2053 {
2054 hit(
2055 "dangerous-eval",
2056 format!("`{f}` on a non-literal expression executes dynamic code"),
2057 );
2058 }
2059 if f == "yaml.load" && !has_kwarg(c, "Loader") {
2060 hit(
2061 "unsafe-yaml-load",
2062 "yaml.load without an explicit Loader= is unsafe; use yaml.safe_load".into(),
2063 );
2064 }
2065 if matches!(
2066 f,
2067 "pickle.load"
2068 | "pickle.loads"
2069 | "cPickle.load"
2070 | "cPickle.loads"
2071 | "marshal.load"
2072 | "marshal.loads"
2073 | "dill.load"
2074 | "dill.loads"
2075 | "shelve.open"
2076 | "jsonpickle.decode"
2077 ) {
2078 hit(
2079 "unsafe-deserialization",
2080 format!("`{f}` can execute arbitrary code on untrusted input"),
2081 );
2082 }
2083 if matches!(
2084 last,
2085 "call" | "run" | "Popen" | "check_output" | "check_call"
2086 ) && kwarg_bool(c, "shell", true)
2087 {
2088 hit(
2089 "subprocess-shell-true",
2090 "subprocess call with shell=True risks shell injection".into(),
2091 );
2092 }
2093 if matches!(f, "os.system" | "os.popen" | "os.popen2" | "os.popen3") {
2094 hit(
2095 "subprocess-shell-true",
2096 format!("`{f}` runs a command through the shell; prefer subprocess with an argv list"),
2097 );
2098 }
2099 if kwarg_bool(c, "verify", false) {
2100 hit(
2101 "tls-verify-disabled",
2102 "TLS certificate verification disabled (verify=False)".into(),
2103 );
2104 }
2105 if f == "ssl._create_unverified_context" {
2106 hit(
2107 "tls-verify-disabled",
2108 "ssl._create_unverified_context disables certificate validation".into(),
2109 );
2110 }
2111 if matches!(f, "hashlib.md5" | "hashlib.sha1" | "md5.new") {
2112 hit(
2113 "weak-hash",
2114 format!("`{f}` is a weak hash; use sha256+ (or pass usedforsecurity=False)"),
2115 );
2116 }
2117 if WEAK_CIPHERS.contains(&last) {
2118 hit(
2119 "weak-cipher",
2120 format!("`{f}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305"),
2121 );
2122 }
2123 if args_reference_ecb(c) {
2124 hit(
2125 "weak-cipher",
2126 "ECB mode leaks plaintext structure; use an authenticated mode (GCM)".into(),
2127 );
2128 }
2129 if matches!(
2130 f,
2131 "random.random"
2132 | "random.randint"
2133 | "random.randrange"
2134 | "random.choice"
2135 | "random.getrandbits"
2136 ) {
2137 hit(
2138 "insecure-random",
2139 format!("`{f}` is not cryptographically secure; use the `secrets` module for tokens"),
2140 );
2141 }
2142 if matches!(
2143 last,
2144 "execute" | "executemany" | "executescript" | "raw" | "extra"
2145 ) {
2146 if let Some(arg) = c.arguments.args.first() {
2147 if is_dynamic_string(arg) {
2148 hit(
2149 "sql-injection",
2150 format!(
2151 "`{last}(...)` builds SQL from a dynamic string; use parameterized queries"
2152 ),
2153 );
2154 }
2155 }
2156 }
2157 if matches!(
2158 f,
2159 "requests.get"
2160 | "requests.post"
2161 | "requests.put"
2162 | "requests.delete"
2163 | "requests.patch"
2164 | "requests.head"
2165 | "requests.request"
2166 ) && !has_kwarg(c, "timeout")
2167 {
2168 hit(
2169 "request-without-timeout",
2170 format!("`{f}` without a timeout= can block indefinitely"),
2171 );
2172 }
2173 if last == "run" && kwarg_bool(c, "debug", true) {
2176 hit(
2177 "flask-debug-true",
2178 "running a web app with debug=True exposes the interactive debugger".into(),
2179 );
2180 }
2181 if last == "Environment" && kwarg_bool(c, "autoescape", false) {
2184 hit(
2185 "jinja2-autoescape-false",
2186 "Jinja2 Environment with autoescape=False risks XSS; enable autoescaping".into(),
2187 );
2188 }
2189}
2190
2191fn security_imports(m: &mut ParsedModule) {
2192 let mut hits: Vec<SecurityHit> = Vec::new();
2193 for imp in m.imports.iter().chain(m.nested_imports.iter()) {
2194 let from_crypto = imp.module.contains("Crypto") || imp.module.contains("cryptography");
2195 if !from_crypto {
2196 continue;
2197 }
2198 for name in &imp.names {
2199 if WEAK_CIPHERS.contains(&name.as_str()) {
2200 hits.push(SecurityHit {
2201 rule: "weak-cipher",
2202 line: imp.line,
2203 detail: format!(
2204 "`{name}` (imported from `{}`) is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2205 imp.module
2206 ),
2207 });
2208 }
2209 }
2210 if imp.names.is_empty() {
2211 if let Some(seg) = imp.module.rsplit('.').next() {
2212 if WEAK_CIPHERS.contains(&seg) {
2213 hits.push(SecurityHit {
2214 rule: "weak-cipher",
2215 line: imp.line,
2216 detail: format!(
2217 "`{}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2218 imp.module
2219 ),
2220 });
2221 }
2222 }
2223 }
2224 }
2225 m.security_hits.extend(hits);
2226}
2227
2228fn parse_noqa_comment(text: &str) -> Option<Vec<String>> {
2237 let t = text.trim_start_matches('#').trim();
2238 if t.len() < 4 || !t.is_char_boundary(4) || !t[..4].eq_ignore_ascii_case("noqa") {
2239 return None;
2240 }
2241 let rest = t[4..].trim_start();
2242 if rest.is_empty() || rest.starts_with('#') {
2243 return Some(vec!["unused-import".into(), "unused-variable".into()]);
2244 }
2245 let codes = rest.strip_prefix(':')?;
2246 let mut rules = Vec::new();
2247 for code in codes.split([',', ' ', '#']).map(str::trim) {
2248 match code.to_ascii_uppercase().as_str() {
2249 "F401" => rules.push("unused-import".to_string()),
2250 "F841" => rules.push("unused-variable".to_string()),
2251 _ => {}
2252 }
2253 }
2254 if rules.is_empty() {
2255 None
2256 } else {
2257 Some(rules)
2258 }
2259}
2260
2261fn parse_ignore_comment(text: &str) -> Option<Vec<String>> {
2262 let t = text.trim_start_matches('#').trim();
2263 let rest = t.strip_prefix("mollify:")?.trim();
2264 let rest = rest.strip_prefix("ignore")?.trim();
2265 if let Some(inner) = rest
2266 .strip_prefix('[')
2267 .and_then(|r| r.find(']').map(|i| &r[..i]))
2268 {
2269 let rules: Vec<String> = inner
2270 .split(',')
2271 .map(|s| s.trim().to_string())
2272 .filter(|s| !s.is_empty())
2273 .collect();
2274 if rules.is_empty() {
2275 Some(vec!["*".into()])
2276 } else {
2277 Some(rules)
2278 }
2279 } else if rest.is_empty() {
2280 Some(vec!["*".into()])
2281 } else {
2282 None
2283 }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288 use super::*;
2289
2290 fn parse(src: &str) -> ParsedModule {
2291 let mut p = PyParser::new().unwrap();
2292 p.parse(Utf8Path::new("m.py"), src).unwrap()
2293 }
2294
2295 #[test]
2296 fn extracts_functions_and_classes() {
2297 let m = parse("def foo():\n pass\n\nclass Bar:\n pass\n");
2298 let names: Vec<_> = m.definitions.iter().map(|d| d.name.as_str()).collect();
2299 assert!(names.contains(&"foo"));
2300 assert!(names.contains(&"Bar"));
2301 }
2302
2303 #[test]
2304 fn private_convention_detected() {
2305 let m = parse("def _helper():\n pass\n");
2306 assert!(m.definitions[0].private_by_convention);
2307 }
2308
2309 #[test]
2310 fn detects_expanded_security_rules() {
2311 let m = parse(
2312 "app.run(debug=True)\nenv = Environment(autoescape=False)\ntry:\n risky()\nexcept Exception:\n pass\n",
2313 );
2314 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2315 assert!(rules.contains(&"flask-debug-true"), "got {rules:?}");
2316 assert!(rules.contains(&"jinja2-autoescape-false"), "got {rules:?}");
2317 assert!(rules.contains(&"try-except-pass"), "got {rules:?}");
2318 let narrow = parse("try:\n x()\nexcept ValueError:\n pass\n");
2320 assert!(!narrow
2321 .security_hits
2322 .iter()
2323 .any(|h| h.rule == "try-except-pass"));
2324 }
2325
2326 #[test]
2327 fn extracts_imports() {
2328 let m = parse("import os\nfrom a.b import c, d\nfrom . import e\nfrom x import *\n");
2329 assert!(m.imports.iter().any(|i| i.module == "os"));
2330 let frm = m.imports.iter().find(|i| i.module == "a.b").unwrap();
2331 assert_eq!(frm.names, vec!["c", "d"]);
2332 assert!(m.imports.iter().any(|i| i.relative_dots == 1));
2333 assert!(m.imports.iter().any(|i| i.is_star));
2334 }
2335
2336 #[test]
2337 fn extracts_dunder_all() {
2338 let m = parse("__all__ = ['foo', 'bar']\n");
2339 assert_eq!(m.dunder_all, Some(vec!["foo".into(), "bar".into()]));
2340 }
2341
2342 #[test]
2343 fn detects_security_candidates() {
2344 let m = parse("import subprocess\npassword = \"hunter2xyz\"\nsubprocess.run(cmd, shell=True)\neval(user_input)\n");
2345 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2346 assert!(rules.contains(&"hardcoded-secret"), "got {rules:?}");
2347 assert!(rules.contains(&"subprocess-shell-true"), "got {rules:?}");
2348 assert!(rules.contains(&"dangerous-eval"), "got {rules:?}");
2349 let ok = parse("eval(\"1+1\")\n");
2350 assert!(!ok.security_hits.iter().any(|h| h.rule == "dangerous-eval"));
2351 }
2352
2353 #[test]
2354 fn dangerous_eval_only_matches_builtins_not_methods() {
2355 for src in [
2358 "session.exec(select(Item))\n",
2359 "conn.exec(query)\n",
2360 "obj.eval(expr)\n",
2361 "db.compile(stmt)\n",
2362 ] {
2363 let m = parse(src);
2364 assert!(
2365 !m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2366 "method call wrongly flagged: {src}"
2367 );
2368 }
2369 for src in [
2371 "exec(code)\n",
2372 "eval(user_input)\n",
2373 "compile(src, '<s>', 'exec')\n",
2374 ] {
2375 let m = parse(src);
2376 assert!(
2377 m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2378 "builtin not flagged: {src}"
2379 );
2380 }
2381 }
2382
2383 #[test]
2384 fn detects_weak_cipher_imports() {
2385 let m = parse(
2386 "from Crypto.Cipher import DES as pycrypto_des\n\
2387 from Cryptodome.Cipher import ARC4 as ax\n\
2388 cipher = pycrypto_des.new(key, pycrypto_des.MODE_CTR)\n\
2389 c2 = ax.new(key)\n",
2390 );
2391 let cipher_hits: Vec<_> = m
2392 .security_hits
2393 .iter()
2394 .filter(|h| h.rule == "weak-cipher")
2395 .collect();
2396 assert_eq!(
2397 cipher_hits.len(),
2398 2,
2399 "expected DES + ARC4 imports flagged, got {:?}",
2400 m.security_hits
2401 );
2402 let lines: Vec<u32> = cipher_hits.iter().map(|h| h.line).collect();
2403 assert!(lines.contains(&1) && lines.contains(&2), "lines {lines:?}");
2404 }
2405
2406 #[test]
2407 fn detects_weak_cipher_direct_constructor_and_ecb() {
2408 let m = parse(
2409 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2410 c = Cipher(algorithms.ARC4(key), mode=None)\n",
2411 );
2412 assert!(
2413 m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2414 "expected ARC4 constructor flagged, got {:?}",
2415 m.security_hits
2416 );
2417 let ecb = parse("from Crypto.Cipher import AES\nc = AES.new(key, AES.MODE_ECB)\n");
2418 assert!(
2419 ecb.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2420 "expected ECB mode flagged, got {:?}",
2421 ecb.security_hits
2422 );
2423 }
2424
2425 #[test]
2426 fn strong_cipher_and_modes_not_flagged() {
2427 let m = parse(
2428 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2429 c = Cipher(algorithms.AES(key), modes.GCM(iv))\n",
2430 );
2431 assert!(
2432 !m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2433 "AES-GCM should not be flagged, got {:?}",
2434 m.security_hits
2435 );
2436 let unrelated = parse("from myapp.utils import DES\nDES.do_thing()\n");
2437 assert!(
2438 !unrelated
2439 .security_hits
2440 .iter()
2441 .any(|h| h.rule == "weak-cipher"),
2442 "non-crypto `DES` import should not be flagged, got {:?}",
2443 unrelated.security_hits
2444 );
2445 }
2446
2447 #[test]
2448 fn counts_type_annotations() {
2449 let m = parse("def f(a: int, b) -> int:\n return a\n\nclass C:\n def m(self, x: int):\n return x\n");
2450 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2451 assert_eq!(f.params_total, 2);
2452 assert_eq!(f.params_annotated, 1);
2453 assert!(f.return_annotated);
2454 let mm = m.functions.iter().find(|f| f.name == "m").unwrap();
2455 assert_eq!(mm.params_total, 1, "self should be excluded");
2456 assert_eq!(mm.params_annotated, 1);
2457 assert!(!mm.return_annotated);
2458 }
2459
2460 #[test]
2461 fn computes_complexity() {
2462 let m = parse("def f(x):\n if x:\n for i in range(x):\n if i and x:\n return i\n return 0\n");
2463 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2464 assert!(f.cyclomatic >= 4, "cyclo {:?}", f.cyclomatic);
2465 assert!(f.cognitive >= 3, "cog {:?}", f.cognitive);
2466 }
2467
2468 #[test]
2469 fn captures_decorators() {
2470 let m = parse("import app\n@app.route('/x')\ndef view():\n return 1\n");
2471 let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2472 assert!(
2473 d.decorators.iter().any(|x| x == "app.route"),
2474 "got {:?}",
2475 d.decorators
2476 );
2477 }
2478
2479 #[test]
2480 fn detects_dynamic_sink() {
2481 let m = parse("x = getattr(obj, 'attr')\n");
2482 assert!(m.has_dynamic_sink);
2483 let m2 = parse("y = 1 + 2\n");
2484 assert!(!m2.has_dynamic_sink);
2485 }
2486
2487 #[test]
2488 fn conditional_import_seen() {
2489 let m = parse("try:\n import fast\nexcept ImportError:\n import slow as fast\n");
2490 assert!(m.imports.iter().any(|i| i.module == "fast"));
2491 }
2492
2493 #[test]
2494 fn scope_resolution_excludes_shadows_and_attributes() {
2495 let m = parse(
2500 "def helper():\n pass\n\ndef f():\n helper = 1\n return helper\n\nobj.helper()\n",
2501 );
2502 assert!(
2503 !m.module_used.iter().any(|s| s == "helper"),
2504 "module_used should exclude shadowed/attribute `helper`: {:?}",
2505 m.module_used
2506 );
2507 let m2 = parse("def g():\n pass\n\ng()\n");
2509 assert!(
2510 m2.module_used.iter().any(|s| s == "g"),
2511 "{:?}",
2512 m2.module_used
2513 );
2514 let m3 =
2517 parse("counter = 0\n\ndef bump():\n global counter\n counter = counter + 1\n");
2518 assert!(
2519 m3.module_used.iter().any(|s| s == "counter"),
2520 "{:?}",
2521 m3.module_used
2522 );
2523 let m4 = parse("counter = 0\n\ndef bump():\n counter = counter + 1\n");
2525 assert!(
2526 !m4.module_used.iter().any(|s| s == "counter"),
2527 "{:?}",
2528 m4.module_used
2529 );
2530 }
2531
2532 #[test]
2533 fn scope_resolution_sees_defaults_and_annotations() {
2534 let m = parse("DEFAULT = 5\nMyType = int\ndef f(x=DEFAULT) -> MyType: ...\n");
2537 assert!(
2538 m.module_used.iter().any(|s| s == "DEFAULT"),
2539 "{:?}",
2540 m.module_used
2541 );
2542 assert!(
2543 m.module_used.iter().any(|s| s == "MyType"),
2544 "{:?}",
2545 m.module_used
2546 );
2547 let m2 = parse("MyType = int\ndef g(x: MyType): ...\n");
2548 assert!(
2549 m2.module_used.iter().any(|s| s == "MyType"),
2550 "{:?}",
2551 m2.module_used
2552 );
2553 let m3 = parse("DEFAULT = 5\ng = lambda x=DEFAULT: x\n");
2555 assert!(
2556 m3.module_used.iter().any(|s| s == "DEFAULT"),
2557 "{:?}",
2558 m3.module_used
2559 );
2560 }
2561
2562 #[test]
2563 fn imports_inside_module_level_suites_seen() {
2564 let m = parse(
2565 "from contextlib import suppress\n\
2566 with suppress(ImportError):\n import ujson\n\
2567 for _i in range(1):\n import for_mod\n\
2568 while cond():\n import while_mod\n\
2569 match val:\n case 1:\n import match_mod\n",
2570 );
2571 for want in ["ujson", "for_mod", "while_mod", "match_mod"] {
2572 assert!(
2573 m.imports.iter().any(|i| i.module == want),
2574 "missing {want}: {:?}",
2575 m.imports
2576 );
2577 }
2578 }
2579
2580 #[test]
2581 fn type_checking_marks_body_not_else() {
2582 let m = parse(
2583 "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n import a\nelse:\n import b\n",
2584 );
2585 let a = m.imports.iter().find(|i| i.module == "a").unwrap();
2586 let b = m.imports.iter().find(|i| i.module == "b").unwrap();
2587 assert!(a.type_checking_only);
2588 assert!(!b.type_checking_only, "else branch is the runtime branch");
2589 let m2 = parse(
2591 "from typing import TYPE_CHECKING\nif not TYPE_CHECKING:\n import rt\nelse:\n import tc\n",
2592 );
2593 let rt = m2.imports.iter().find(|i| i.module == "rt").unwrap();
2594 let tc = m2.imports.iter().find(|i| i.module == "tc").unwrap();
2595 assert!(!rt.type_checking_only);
2596 assert!(tc.type_checking_only);
2597 }
2598
2599 #[test]
2600 fn type_checking_guard_is_exact() {
2601 let fp = parse("if MY_TYPE_CHECKING_OVERRIDE:\n from x import y\n");
2602 assert!(
2603 !fp.imports
2604 .iter()
2605 .find(|i| i.module == "x")
2606 .unwrap()
2607 .type_checking_only,
2608 "substring match must not treat this as a guard"
2609 );
2610 let ok = parse("import typing\nif typing.TYPE_CHECKING:\n from x import y\n");
2611 assert!(
2612 ok.imports
2613 .iter()
2614 .find(|i| i.module == "x")
2615 .unwrap()
2616 .type_checking_only
2617 );
2618 }
2619
2620 #[test]
2621 fn comprehension_targets_are_not_function_locals() {
2622 let m =
2625 parse("item = 1\ndef f(items):\n xs = [item for item in items]\n return item\n");
2626 assert!(
2627 m.module_used.iter().any(|s| s == "item"),
2628 "{:?}",
2629 m.module_used
2630 );
2631 }
2632
2633 #[test]
2634 fn dunder_all_mutations() {
2635 let m = parse("__all__ = ['a']\n__all__ += ['b']\n");
2636 assert_eq!(m.dunder_all, Some(vec!["a".into(), "b".into()]));
2637 let m2 = parse("__all__ = ['a']\n__all__.extend(['b', 'c'])\n__all__.append('d')\n");
2638 assert_eq!(
2639 m2.dunder_all,
2640 Some(vec!["a".into(), "b".into(), "c".into(), "d".into()])
2641 );
2642 let m3 = parse("__all__ = ['a']\n__all__ += make()\n");
2645 assert_eq!(m3.dunder_all, None);
2646 let m4 = parse("__all__ = ['a']\n__all__.extend(names)\n");
2647 assert_eq!(m4.dunder_all, None);
2648 let m5 = parse("__all__ = ['a']\n__all__.append(name)\n");
2649 assert_eq!(m5.dunder_all, None);
2650 }
2651
2652 #[test]
2653 fn decorated_def_line_points_at_def() {
2654 let m = parse("import app\n\n@app.route('/x')\ndef view() -> _Priv:\n return 1\n");
2655 let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2656 assert_eq!(d.line, 4, "decorator on line 3, def on line 4");
2657 assert_eq!(d.end_line, 5, "end_line keeps the full range");
2658 let f = m.functions.iter().find(|f| f.name == "view").unwrap();
2659 assert_eq!(f.line, 4);
2660 let leak = m
2661 .type_leaks
2662 .iter()
2663 .find(|l| l.type_name == "_Priv")
2664 .unwrap();
2665 assert_eq!(leak.line, 4);
2666 let m2 = parse("@decorate\nclass C:\n @property\n def p(self):\n return 1\n");
2667 let c = m2.classes.iter().find(|c| c.name == "C").unwrap();
2668 assert_eq!(c.line, 2);
2669 let p = c.members.iter().find(|mb| mb.name == "p").unwrap();
2670 assert_eq!(p.line, 4);
2671 let cd = m2.definitions.iter().find(|d| d.name == "C").unwrap();
2672 assert_eq!(cd.line, 2);
2673 }
2674
2675 #[test]
2676 fn typevar_under_guard_not_a_leak() {
2677 let m = parse(
2678 "from typing import TYPE_CHECKING, TypeVar\nif TYPE_CHECKING:\n _T = TypeVar('_T')\ndef f(x: _T) -> _T: ...\n",
2679 );
2680 assert!(m.type_leaks.is_empty(), "{:?}", m.type_leaks);
2681 let m2 = parse(
2682 "try:\n _P = ParamSpec('_P')\nexcept ImportError:\n pass\ndef g(x: _P): ...\n",
2683 );
2684 assert!(m2.type_leaks.is_empty(), "{:?}", m2.type_leaks);
2685 }
2686
2687 #[test]
2688 fn comment_parsers_fuzz_no_panic() {
2689 let mut state = 0x0123_4567_89AB_CDEFu64;
2693 let mut next = move || {
2694 state ^= state << 13;
2695 state ^= state >> 7;
2696 state ^= state << 17;
2697 state
2698 };
2699 let alphabet: &[&str] = &[
2700 "#", "n", "o", "q", "a", "N", "Q", "A", ":", ",", " ", "F", "4", "0", "1", "8",
2701 "mollify", "ignore", "[", "]", "ß", "é", "—", "\t",
2702 ];
2703 for _ in 0..4000u32 {
2704 let len = (next() % 40) as usize;
2705 let mut s = String::from("#");
2706 for _ in 0..len {
2707 s.push_str(alphabet[(next() as usize) % alphabet.len()]);
2708 }
2709 let _ = parse_noqa_comment(&s);
2710 let _ = parse_ignore_comment(&s);
2711 }
2712 }
2713
2714 #[test]
2715 fn noqa_comments_map_to_unused_binding_rules() {
2716 assert_eq!(
2718 parse_noqa_comment("# noqa"),
2719 Some(vec!["unused-import".into(), "unused-variable".into()])
2720 );
2721 assert_eq!(
2722 parse_noqa_comment("#NOQA"),
2723 Some(vec!["unused-import".into(), "unused-variable".into()])
2724 );
2725 assert_eq!(
2727 parse_noqa_comment("# noqa: F401"),
2728 Some(vec!["unused-import".into()])
2729 );
2730 assert_eq!(
2731 parse_noqa_comment("# noqa: E501, F841"),
2732 Some(vec!["unused-variable".into()])
2733 );
2734 assert_eq!(parse_noqa_comment("# noqa: E501"), None);
2736 assert_eq!(parse_noqa_comment("# noqable"), None);
2737 assert_eq!(parse_noqa_comment("# see noqa docs"), None);
2738 let m = parse("from hello import app # noqa: F401\n");
2740 assert!(
2741 m.ignores.contains(&(1, "unused-import".into())),
2742 "{:?}",
2743 m.ignores
2744 );
2745 }
2746
2747 #[test]
2748 fn redundant_alias_and_try_body_imports_are_marked() {
2749 let m = parse(
2750 "from sansio import State as State\nfrom sansio import Blueprint as Sansio\ntry:\n import fast_json\nexcept ImportError:\n import json as fast_json\nimport os\n",
2751 );
2752 let state = m.imports.iter().find(|i| i.bindings == ["State"]).unwrap();
2753 assert_eq!(state.redundant, vec![true]);
2754 let aliased = m.imports.iter().find(|i| i.bindings == ["Sansio"]).unwrap();
2755 assert_eq!(aliased.redundant, vec![false]);
2756 let probe = m.imports.iter().find(|i| i.module == "fast_json").unwrap();
2757 assert!(probe.in_try, "try-body import not marked: {probe:?}");
2758 let fallback = m.imports.iter().find(|i| i.module == "json").unwrap();
2759 assert!(fallback.in_try, "except-handler import not marked");
2760 let plain = m.imports.iter().find(|i| i.module == "os").unwrap();
2761 assert!(!plain.in_try);
2762 std::assert!(!plain.redundant.iter().any(|r| *r));
2763 }
2764
2765 #[test]
2766 fn ignore_comment_allows_trailing_text() {
2767 assert_eq!(
2768 parse_ignore_comment("# mollify: ignore[dead-code] -- migrating soon"),
2769 Some(vec!["dead-code".into()])
2770 );
2771 assert_eq!(
2772 parse_ignore_comment("# mollify: ignore[a, b] reason"),
2773 Some(vec!["a".into(), "b".into()])
2774 );
2775 let m = parse("x = 1 # mollify: ignore[dead-code] -- reason\n");
2776 assert!(
2777 m.ignores.contains(&(1, "dead-code".into())),
2778 "{:?}",
2779 m.ignores
2780 );
2781 }
2782
2783 #[test]
2784 fn nested_weak_cipher_import_flagged() {
2785 let m = parse("def f():\n from Crypto.Cipher import DES\n return DES\n");
2786 assert!(
2787 m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2788 "nested import must be scanned: {:?}",
2789 m.security_hits
2790 );
2791 }
2792}