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