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 path_literals: Vec<String>,
240 pub halstead_volume: f64,
241 had_errors: bool,
242}
243
244impl ParsedModule {
245 pub fn had_errors(&self) -> bool {
247 self.had_errors
248 }
249}
250
251#[derive(Default)]
254pub struct PyParser;
255
256impl PyParser {
257 pub fn new() -> Result<Self, ParseError> {
258 Ok(Self)
259 }
260
261 pub fn parse(&mut self, path: &Utf8Path, source: &str) -> Result<ParsedModule, ParseError> {
263 let li = LineIndex::from_source_text(source);
264 let mut m = ParsedModule {
265 path: path.to_owned(),
266 definitions: Vec::new(),
267 imports: Vec::new(),
268 nested_imports: Vec::new(),
269 calls: Vec::new(),
270 functions: Vec::new(),
271 security_hits: Vec::new(),
272 dunder_all: None,
273 dunder_all_dynamic: false,
274 used_names: Vec::new(),
275 local_uses: Vec::new(),
276 attr_accessed: Vec::new(),
277 module_used: Vec::new(),
278 ignores: Vec::new(),
279 scope_findings: Vec::new(),
280 classes: Vec::new(),
281 unreachable: Vec::new(),
282 type_leaks: Vec::new(),
283 name_counts: HashMap::new(),
284 has_dynamic_sink: false,
285 has_main_guard: false,
286 path_literals: Vec::new(),
287 halstead_volume: 0.0,
288 had_errors: false,
289 };
290
291 let parsed = match parse_module(source) {
292 Ok(p) => p,
293 Err(_) => {
294 m.had_errors = true;
296 return Ok(m);
297 }
298 };
299 m.had_errors = !parsed.errors().is_empty();
300 let module = parsed.syntax();
301
302 let mut name_tokens: Vec<(TextSize, &str)> = Vec::new();
306 let mut h_total_ops = 0u64;
307 let mut h_total_oprs = 0u64;
308 let mut h_ops: HashSet<TokenKind> = HashSet::new();
309 let mut h_oprs: HashSet<&str> = HashSet::new();
310 for tok in parsed.tokens() {
311 let kind = tok.kind();
312 let text = &source[tok.range()];
313 if kind == TokenKind::Name {
314 *m.name_counts.entry(text.to_string()).or_insert(0) += 1;
315 m.used_names.push(text.to_string());
316 name_tokens.push((tok.range().start(), text));
317 }
318 if kind == TokenKind::Comment {
319 let line = line1(&li, tok.range().start());
320 if let Some(rules) = parse_ignore_comment(text) {
321 for r in rules {
322 m.ignores.push((line, r));
323 }
324 }
325 if let Some(rules) = parse_noqa_comment(text) {
326 for r in rules {
327 m.ignores.push((line, r));
328 }
329 }
330 }
331 if is_operand(kind) {
333 h_total_oprs += 1;
334 h_oprs.insert(text);
335 } else if !kind.is_trivia()
336 && !matches!(
337 kind,
338 TokenKind::Newline
339 | TokenKind::Indent
340 | TokenKind::Dedent
341 | TokenKind::EndOfFile
342 )
343 {
344 h_total_ops += 1;
345 h_ops.insert(kind);
346 }
347 }
348 m.used_names.sort();
349 m.used_names.dedup();
350 let vocab = (h_ops.len() + h_oprs.len()) as f64;
351 let length = (h_total_ops + h_total_oprs) as f64;
352 m.halstead_volume = if vocab <= 1.0 {
353 0.0
354 } else {
355 length * vocab.log2()
356 };
357
358 scan_top_level(&module.body, &li, false, &mut m);
360
361 let mut nested = NestedImportVisitor {
364 li: &li,
365 depth: 0,
366 out: Vec::new(),
367 };
368 for stmt in &module.body {
369 nested.visit_stmt(stmt);
370 }
371 m.nested_imports = nested.out;
372
373 let mut main = MainVisitor {
375 li: &li,
376 m: &mut m,
377 sanitized_idents: vec![HashSet::new()],
378 };
379 for stmt in &module.body {
380 main.visit_stmt(stmt);
381 }
382 m.path_literals.sort();
383 m.path_literals.dedup();
384
385 let mut lu = LocalUseVisitor {
388 uses: Vec::new(),
389 attrs: Vec::new(),
390 };
391 for stmt in &module.body {
392 lu.visit_stmt(stmt);
393 }
394 lu.uses.sort();
395 lu.uses.dedup();
396 m.local_uses = lu.uses;
397 lu.attrs.sort();
398 lu.attrs.dedup();
399 m.attr_accessed = lu.attrs;
400
401 let mut res = Resolver {
404 scopes: Vec::new(),
405 used: HashSet::new(),
406 };
407 for stmt in &module.body {
408 res.visit_stmt(stmt);
409 }
410 let mut mu: Vec<String> = res.used.into_iter().collect();
411 mu.sort();
412 m.module_used = mu;
413
414 let mut defs = DefVisitor {
416 funcs: Vec::new(),
417 classes: Vec::new(),
418 };
419 for stmt in &module.body {
420 defs.visit_stmt(stmt);
421 }
422 for f in &defs.funcs {
423 m.functions.push(function_complexity(f, &li));
424 analyze_scope(f, &name_tokens, &mut m.scope_findings, &li);
425 }
426 m.functions.sort_by_key(|f| f.line);
427 m.scope_findings.sort_by_key(|s| s.line);
428 for c in &defs.classes {
429 m.classes.push(class_info(c, &li));
430 }
431 m.classes.sort_by_key(|c| c.line);
432
433 let mut ur = UnreachableVisitor {
436 li: &li,
437 out: Vec::new(),
438 };
439 ur.scan(&module.body);
440 for stmt in &module.body {
441 ur.visit_stmt(stmt);
442 }
443 ur.out.sort_by_key(|u| u.line);
444 ur.out.dedup();
445 m.unreachable = ur.out;
446
447 scan_type_leaks(&module.body, &li, &mut m.type_leaks);
449 m.type_leaks
450 .sort_by(|a, b| a.line.cmp(&b.line).then(a.type_name.cmp(&b.type_name)));
451 m.type_leaks.dedup();
452
453 security_imports(&mut m);
455 m.security_hits
456 .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(b.rule)));
457 m.security_hits
458 .dedup_by(|a, b| a.rule == b.rule && a.line == b.line);
459
460 let mut span_ignores: Vec<(u32, String)> = Vec::new();
466 for (line, rule) in &m.ignores {
467 for imp in &m.imports {
468 if *line > imp.line && *line <= imp.end_line {
469 span_ignores.push((imp.line, rule.clone()));
470 }
471 }
472 }
473 m.ignores.extend(span_ignores);
474 Ok(m)
475 }
476}
477
478const DYNAMIC_SINKS: &[&str] = &["getattr", "setattr", "eval", "exec", "__import__"];
483
484fn line1(li: &LineIndex, off: TextSize) -> u32 {
486 li.line_index(off).get() as u32
487}
488
489fn end_line1(li: &LineIndex, range: TextRange) -> u32 {
491 let end = range.end();
492 if end > range.start() {
493 line1(li, end.checked_sub(TextSize::from(1)).unwrap_or(end))
494 } else {
495 line1(li, end)
496 }
497}
498
499fn is_operand(kind: TokenKind) -> bool {
501 matches!(
502 kind,
503 TokenKind::Name
504 | TokenKind::Int
505 | TokenKind::Float
506 | TokenKind::Complex
507 | TokenKind::String
508 | TokenKind::FStringStart
509 | TokenKind::FStringMiddle
510 | TokenKind::FStringEnd
511 | TokenKind::True
512 | TokenKind::False
513 | TokenKind::None
514 )
515}
516
517fn expr_path(e: &Expr) -> Option<String> {
519 match e {
520 Expr::Name(n) => Some(n.id.as_str().to_string()),
521 Expr::Attribute(a) => Some(format!("{}.{}", expr_path(&a.value)?, a.attr.as_str())),
522 _ => None,
523 }
524}
525
526fn decorator_path(e: &Expr) -> Option<String> {
528 match e {
529 Expr::Call(c) => expr_path(&c.func),
530 other => expr_path(other),
531 }
532}
533
534fn is_private(name: &str) -> bool {
535 name.starts_with('_')
536}
537
538fn scan_top_level(stmts: &[Stmt], li: &LineIndex, type_checking: bool, m: &mut ParsedModule) {
543 for stmt in stmts {
544 match stmt {
545 Stmt::FunctionDef(f) => m.definitions.push(Definition {
546 private_by_convention: is_private(f.name.as_str()),
547 name: f.name.to_string(),
548 kind: DefKind::Function,
549 line: line1(li, f.name.range().start()),
551 end_line: end_line1(li, f.range()),
552 decorators: f
553 .decorator_list
554 .iter()
555 .filter_map(|d| decorator_path(&d.expression))
556 .collect(),
557 }),
558 Stmt::ClassDef(c) => m.definitions.push(Definition {
559 private_by_convention: is_private(c.name.as_str()),
560 name: c.name.to_string(),
561 kind: DefKind::Class,
562 line: line1(li, c.name.range().start()),
563 end_line: end_line1(li, c.range()),
564 decorators: c
565 .decorator_list
566 .iter()
567 .filter_map(|d| decorator_path(&d.expression))
568 .collect(),
569 }),
570 Stmt::Import(i) => parse_import(i, li, &mut m.imports),
571 Stmt::ImportFrom(i) => {
572 let mut imp = parse_import_from(i, li);
573 imp.type_checking_only = type_checking;
574 m.imports.push(imp);
575 }
576 Stmt::Assign(a) => {
577 if let [Expr::Name(target)] = a.targets.as_slice() {
578 let name = target.id.as_str();
579 if name == "__all__" {
580 match string_list(&a.value) {
581 Some(items) => m.dunder_all = Some(items),
582 None => m.dunder_all_dynamic = true,
584 }
585 } else {
586 m.definitions.push(Definition {
587 private_by_convention: is_private(name),
588 name: name.to_string(),
589 kind: DefKind::Variable,
590 line: line1(li, a.range().start()),
591 end_line: end_line1(li, a.range()),
592 decorators: Vec::new(),
593 });
594 }
595 }
596 }
597 Stmt::AnnAssign(a) => {
598 if let Expr::Name(target) = &*a.target {
599 let name = target.id.as_str();
600 if name == "__all__" {
601 if let Some(v) = &a.value {
602 match string_list(v) {
603 Some(items) => m.dunder_all = Some(items),
604 None => m.dunder_all_dynamic = true,
605 }
606 }
607 } else {
608 m.definitions.push(Definition {
609 private_by_convention: is_private(name),
610 name: name.to_string(),
611 kind: DefKind::Variable,
612 line: line1(li, a.range().start()),
613 end_line: end_line1(li, a.range()),
614 decorators: Vec::new(),
615 });
616 }
617 }
618 }
619 Stmt::AugAssign(a) => {
624 if let Expr::Name(t) = &*a.target {
625 if t.id.as_str() == "__all__" {
626 match string_list(&a.value) {
627 Some(items) => match &mut m.dunder_all {
628 Some(all) => all.extend(items),
629 None => m.dunder_all = Some(items),
630 },
631 None => {
632 m.dunder_all = None;
633 m.dunder_all_dynamic = true;
634 }
635 }
636 }
637 }
638 }
639 Stmt::Expr(e) => {
641 if let Expr::Call(c) = &*e.value {
642 match expr_path(&c.func).as_deref() {
643 Some("__all__.extend") => {
644 match c.arguments.args.first().and_then(string_list) {
645 Some(items) => match &mut m.dunder_all {
646 Some(all) => all.extend(items),
647 None => m.dunder_all = Some(items),
648 },
649 None => {
650 m.dunder_all = None;
651 m.dunder_all_dynamic = true;
652 }
653 }
654 }
655 Some("__all__.append") => match c.arguments.args.first() {
656 Some(Expr::StringLiteral(s)) => match &mut m.dunder_all {
657 Some(all) => all.push(s.value.to_str().to_string()),
658 None => m.dunder_all = Some(vec![s.value.to_str().to_string()]),
659 },
660 _ => {
661 m.dunder_all = None;
662 m.dunder_all_dynamic = true;
663 }
664 },
665 _ => {}
666 }
667 }
668 }
669 Stmt::If(i) => {
671 if is_main_guard(&i.test) {
672 m.has_main_guard = true;
673 }
674 let body_tc = type_checking || is_type_checking_guard(&i.test);
678 let else_tc = type_checking || is_not_type_checking_guard(&i.test);
679 let before = m.imports.len();
680 scan_top_level(&i.body, li, body_tc, m);
681 for imp in m.imports[before..].iter_mut() {
682 if body_tc {
683 imp.type_checking_only = true;
684 } else {
685 imp.in_try = true;
691 }
692 }
693 for clause in &i.elif_else_clauses {
694 let before = m.imports.len();
695 scan_top_level(&clause.body, li, else_tc, m);
696 for imp in m.imports[before..].iter_mut() {
697 if else_tc {
698 imp.type_checking_only = true;
699 } else {
700 imp.in_try = true;
701 }
702 }
703 }
704 }
705 Stmt::Try(t) => {
706 let before = m.imports.len();
711 scan_top_level(&t.body, li, type_checking, m);
712 for h in &t.handlers {
713 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
714 scan_top_level(&eh.body, li, type_checking, m);
715 }
716 for imp in m.imports[before..].iter_mut() {
717 imp.in_try = true;
718 }
719 scan_top_level(&t.orelse, li, type_checking, m);
720 scan_top_level(&t.finalbody, li, type_checking, m);
721 }
722 Stmt::With(w) => scan_top_level(&w.body, li, type_checking, m),
724 Stmt::For(f) => {
725 scan_top_level(&f.body, li, type_checking, m);
726 scan_top_level(&f.orelse, li, type_checking, m);
727 }
728 Stmt::While(w) => {
729 scan_top_level(&w.body, li, type_checking, m);
730 scan_top_level(&w.orelse, li, type_checking, m);
731 }
732 Stmt::Match(mt) => {
733 for case in &mt.cases {
734 scan_top_level(&case.body, li, type_checking, m);
735 }
736 }
737 _ => {}
738 }
739 }
740}
741
742struct NestedImportVisitor<'a> {
745 li: &'a LineIndex,
746 depth: u32,
747 out: Vec<Import>,
748}
749
750impl<'a> Visitor<'a> for NestedImportVisitor<'a> {
751 fn visit_stmt(&mut self, stmt: &'a Stmt) {
752 match stmt {
753 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {
754 self.depth += 1;
755 walk_stmt(self, stmt);
756 self.depth -= 1;
757 }
758 Stmt::Import(i) if self.depth > 0 => {
759 parse_import(i, self.li, &mut self.out);
760 walk_stmt(self, stmt);
761 }
762 Stmt::ImportFrom(i) if self.depth > 0 => {
763 self.out.push(parse_import_from(i, self.li));
764 walk_stmt(self, stmt);
765 }
766 _ => walk_stmt(self, stmt),
767 }
768 }
769}
770
771fn is_main_guard(test: &Expr) -> bool {
774 let Expr::Compare(c) = test else {
775 return false;
776 };
777 let Some((left, op, right)) = c.as_single() else {
778 return false;
779 };
780 if *op != ruff_python_ast::CmpOp::Eq {
781 return false;
782 }
783 let is_name = |e: &Expr| matches!(e, Expr::Name(n) if n.id.as_str() == "__name__");
784 let is_main_str =
785 |e: &Expr| matches!(e, Expr::StringLiteral(s) if s.value.to_str() == "__main__");
786 (is_name(left) && is_main_str(right)) || (is_main_str(left) && is_name(right))
787}
788
789fn is_type_checking_guard(test: &Expr) -> bool {
792 if let Expr::BooleanLiteral(b) = test {
793 return !b.value; }
795 expr_path(test)
796 .map(|p| p == "TYPE_CHECKING" || p.ends_with(".TYPE_CHECKING"))
797 .unwrap_or(false)
798}
799
800fn is_not_type_checking_guard(test: &Expr) -> bool {
803 if let Expr::UnaryOp(u) = test {
804 return matches!(u.op, ruff_python_ast::UnaryOp::Not) && is_type_checking_guard(&u.operand);
805 }
806 false
807}
808
809fn parse_import(i: &StmtImport, li: &LineIndex, out: &mut Vec<Import>) {
810 let line = line1(li, i.range().start());
811 let end_line = end_line1(li, i.range());
812 for alias in &i.names {
813 let module = alias.name.as_str().to_string();
814 let redundant = matches!(&alias.asname, Some(a) if a.as_str() == alias.name.as_str());
815 let binding = match &alias.asname {
816 Some(a) => a.as_str().to_string(),
817 None => module.split('.').next().unwrap_or(&module).to_string(),
818 };
819 if !module.is_empty() {
820 let bindings = if binding.is_empty() {
821 vec![]
822 } else {
823 vec![binding]
824 };
825 out.push(Import {
826 module,
827 relative_dots: 0,
828 names: vec![],
829 redundant: vec![redundant; bindings.len()],
830 bindings,
831 is_star: false,
832 type_checking_only: false,
833 in_try: false,
834 line,
835 end_line,
836 });
837 }
838 }
839}
840
841fn parse_import_from(i: &StmtImportFrom, li: &LineIndex) -> Import {
842 let line = line1(li, i.range().start());
843 let end_line = end_line1(li, i.range());
844 let module = i.module.as_ref().map(|m| m.to_string()).unwrap_or_default();
845 let mut names = Vec::new();
846 let mut bindings = Vec::new();
847 let mut redundant = Vec::new();
848 let mut is_star = false;
849 for alias in &i.names {
850 let name = alias.name.as_str();
851 if name == "*" {
852 is_star = true;
853 continue;
854 }
855 names.push(name.to_string());
856 redundant.push(matches!(&alias.asname, Some(a) if a.as_str() == name));
857 bindings.push(match &alias.asname {
858 Some(a) => a.as_str().to_string(),
859 None => name.to_string(),
860 });
861 }
862 Import {
863 module,
864 relative_dots: i.level.min(u8::MAX as u32) as u8,
865 names,
866 bindings,
867 redundant,
868 is_star,
869 type_checking_only: false,
870 in_try: false,
871 line,
872 end_line,
873 }
874}
875
876fn string_list(e: &Expr) -> Option<Vec<String>> {
878 let elts = match e {
879 Expr::List(l) => &l.elts,
880 Expr::Tuple(t) => &t.elts,
881 _ => return None,
882 };
883 Some(
884 elts.iter()
885 .filter_map(|el| match el {
886 Expr::StringLiteral(s) => Some(s.value.to_str().to_string()),
887 _ => None,
888 })
889 .collect(),
890 )
891}
892
893fn function_complexity(f: &StmtFunctionDef, li: &LineIndex) -> FunctionComplexity {
898 let (params_total, params_annotated) = count_params(&f.parameters);
899 let mut cv = CycloVisitor { count: 0 };
900 for s in &f.body {
901 cv.visit_stmt(s);
902 }
903 FunctionComplexity {
904 name: f.name.to_string(),
905 line: line1(li, f.name.range().start()),
907 end_line: end_line1(li, f.range()),
908 cyclomatic: 1 + cv.count,
909 cognitive: cog_stmts(&f.body, 0),
910 params_total,
911 params_annotated,
912 return_annotated: f.returns.is_some(),
913 }
914}
915
916fn count_params(params: &Parameters) -> (u32, u32) {
917 let positional: Vec<_> = params
918 .posonlyargs
919 .iter()
920 .chain(params.args.iter())
921 .collect();
922 let mut total = 0u32;
923 let mut annotated = 0u32;
924 for (idx, p) in positional.iter().enumerate() {
925 let name = p.parameter.name.as_str();
926 if idx == 0 && (name == "self" || name == "cls") {
927 continue;
928 }
929 total += 1;
930 if p.parameter.annotation.is_some() {
931 annotated += 1;
932 }
933 }
934 for p in ¶ms.kwonlyargs {
935 total += 1;
936 if p.parameter.annotation.is_some() {
937 annotated += 1;
938 }
939 }
940 (total, annotated.min(total))
941}
942
943struct CycloVisitor {
945 count: u32,
946}
947impl<'a> Visitor<'a> for CycloVisitor {
948 fn visit_stmt(&mut self, stmt: &'a Stmt) {
949 match stmt {
950 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return, Stmt::If(i) => {
952 self.count += 1 + i
953 .elif_else_clauses
954 .iter()
955 .filter(|c| c.test.is_some())
956 .count() as u32;
957 }
958 Stmt::For(_) | Stmt::While(_) => self.count += 1,
959 Stmt::Try(t) => self.count += t.handlers.len() as u32,
960 Stmt::Assert(_) => self.count += 1,
961 Stmt::Match(mt) => self.count += mt.cases.len() as u32,
962 _ => {}
963 }
964 walk_stmt(self, stmt);
965 }
966 fn visit_expr(&mut self, expr: &'a Expr) {
967 match expr {
968 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
969 Expr::If(_) => self.count += 1, Expr::ListComp(c) => self.count += comp_points(&c.generators),
971 Expr::SetComp(c) => self.count += comp_points(&c.generators),
972 Expr::DictComp(c) => self.count += comp_points(&c.generators),
973 Expr::Generator(c) => self.count += comp_points(&c.generators),
974 _ => {}
975 }
976 walk_expr(self, expr);
977 }
978}
979
980fn comp_points(gens: &[ruff_python_ast::Comprehension]) -> u32 {
981 gens.iter().map(|g| 1 + g.ifs.len() as u32).sum()
982}
983
984fn cog_stmts(stmts: &[Stmt], nesting: u32) -> u32 {
986 stmts.iter().map(|s| cog_stmt(s, nesting)).sum()
987}
988
989fn cog_stmt(s: &Stmt, nesting: u32) -> u32 {
990 match s {
991 Stmt::FunctionDef(_) | Stmt::ClassDef(_) => 0,
992 Stmt::If(i) => {
993 let mut c = 1 + nesting + cog_cond(&i.test);
994 c += cog_stmts(&i.body, nesting + 1);
995 for clause in &i.elif_else_clauses {
996 c += 1; if let Some(t) = &clause.test {
998 c += cog_cond(t);
999 }
1000 c += cog_stmts(&clause.body, nesting + 1);
1001 }
1002 c
1003 }
1004 Stmt::For(f) => {
1005 1 + nesting + cog_stmts(&f.body, nesting + 1) + cog_stmts(&f.orelse, nesting + 1)
1006 }
1007 Stmt::While(w) => {
1008 1 + nesting
1009 + cog_cond(&w.test)
1010 + cog_stmts(&w.body, nesting + 1)
1011 + cog_stmts(&w.orelse, nesting + 1)
1012 }
1013 Stmt::With(w) => cog_stmts(&w.body, nesting),
1014 Stmt::Try(t) => {
1015 let mut c = cog_stmts(&t.body, nesting);
1016 for h in &t.handlers {
1017 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1018 c += 1 + nesting + cog_stmts(&eh.body, nesting + 1);
1019 }
1020 c += cog_stmts(&t.orelse, nesting) + cog_stmts(&t.finalbody, nesting);
1021 c
1022 }
1023 Stmt::Match(mt) => {
1024 let mut c = 0;
1025 for case in &mt.cases {
1026 c += 1 + nesting + cog_stmts(&case.body, nesting + 1);
1027 }
1028 c
1029 }
1030 Stmt::Expr(e) => cog_cond(&e.value),
1031 Stmt::Return(r) => r.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1032 Stmt::Assign(a) => cog_cond(&a.value),
1033 Stmt::AugAssign(a) => cog_cond(&a.value),
1034 Stmt::AnnAssign(a) => a.value.as_ref().map(|v| cog_cond(v)).unwrap_or(0),
1035 _ => 0,
1036 }
1037}
1038
1039fn cog_cond(e: &Expr) -> u32 {
1041 let mut v = CondVisitor { count: 0 };
1042 v.visit_expr(e);
1043 v.count
1044}
1045struct CondVisitor {
1046 count: u32,
1047}
1048impl<'a> Visitor<'a> for CondVisitor {
1049 fn visit_expr(&mut self, expr: &'a Expr) {
1050 match expr {
1051 Expr::BoolOp(b) => self.count += (b.values.len() as u32).saturating_sub(1),
1052 Expr::If(_) => self.count += 1,
1053 _ => {}
1054 }
1055 walk_expr(self, expr);
1056 }
1057}
1058
1059const SCOPE_DYNAMIC: &[&str] = &["locals", "vars", "globals", "eval", "exec"];
1064
1065fn analyze_scope(
1066 f: &StmtFunctionDef,
1067 name_tokens: &[(TextSize, &str)],
1068 out: &mut Vec<ScopeFinding>,
1069 li: &LineIndex,
1070) {
1071 let range = f.range();
1073 let mut freq: HashMap<&str, u32> = HashMap::new();
1074 for (off, text) in name_tokens {
1075 if *off >= range.start() && *off < range.end() {
1076 *freq.entry(*text).or_insert(0) += 1;
1077 }
1078 }
1079 if SCOPE_DYNAMIC.iter().any(|d| freq.contains_key(*d)) {
1080 return;
1081 }
1082
1083 let mut gv = GlobalVisitor {
1085 names: HashSet::new(),
1086 };
1087 for s in &f.body {
1088 gv.visit_stmt(s);
1089 }
1090 let declared_global = gv.names;
1091
1092 let decorated = !f.decorator_list.is_empty();
1093 let fname = f.name.as_str();
1094 let is_dunder = fname.starts_with("__") && fname.ends_with("__");
1095 let stub = is_stub_body(&f.body);
1096
1097 if !decorated && !is_dunder && !stub {
1098 let positional: Vec<_> = f
1099 .parameters
1100 .posonlyargs
1101 .iter()
1102 .chain(f.parameters.args.iter())
1103 .collect();
1104 for (idx, p) in positional.iter().enumerate() {
1105 let name = p.parameter.name.as_str();
1106 if idx == 0 && (name == "self" || name == "cls") {
1107 continue;
1108 }
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 for p in &f.parameters.kwonlyargs {
1121 let name = p.parameter.name.as_str();
1122 if name.starts_with('_') || declared_global.contains(name) {
1123 continue;
1124 }
1125 if freq.get(name).copied().unwrap_or(0) == 1 {
1126 out.push(ScopeFinding {
1127 line: line1(li, p.parameter.range().start()),
1128 name: name.to_string(),
1129 is_param: true,
1130 });
1131 }
1132 }
1133 }
1134
1135 for stmt in &f.body {
1137 if let Stmt::Assign(a) = stmt {
1138 if let [Expr::Name(target)] = a.targets.as_slice() {
1139 let name = target.id.as_str();
1140 if name == "_" || declared_global.contains(name) {
1141 continue;
1142 }
1143 if freq.get(name).copied().unwrap_or(0) == 1 {
1144 out.push(ScopeFinding {
1145 line: line1(li, a.range().start()),
1146 name: name.to_string(),
1147 is_param: false,
1148 });
1149 }
1150 }
1151 }
1152 }
1153}
1154
1155struct GlobalVisitor {
1156 names: HashSet<String>,
1157}
1158impl<'a> Visitor<'a> for GlobalVisitor {
1159 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1160 match stmt {
1161 Stmt::Global(g) => {
1162 for n in &g.names {
1163 self.names.insert(n.as_str().to_string());
1164 }
1165 }
1166 Stmt::Nonlocal(g) => {
1167 for n in &g.names {
1168 self.names.insert(n.as_str().to_string());
1169 }
1170 }
1171 _ => {}
1172 }
1173 walk_stmt(self, stmt);
1174 }
1175}
1176
1177fn is_stub_body(body: &[Stmt]) -> bool {
1179 body.iter().all(|s| match s {
1180 Stmt::Pass(_) => true,
1181 Stmt::Raise(_) => true,
1182 Stmt::Expr(e) => matches!(&*e.value, Expr::StringLiteral(_) | Expr::EllipsisLiteral(_)),
1183 _ => false,
1184 })
1185}
1186
1187fn class_info(c: &StmtClassDef, li: &LineIndex) -> ClassInfo {
1192 let mut methods = Vec::new();
1193 let mut members: Vec<ClassMember> = Vec::new();
1194 for stmt in &c.body {
1195 match stmt {
1196 Stmt::FunctionDef(f) => {
1197 methods.push((f.name.to_string(), self_attrs(f)));
1198 members.push(ClassMember {
1199 name: f.name.to_string(),
1200 line: line1(li, f.name.range().start()),
1202 end_line: end_line1(li, f.range()),
1203 is_method: true,
1204 is_private: is_private(f.name.as_str()),
1205 decorators: f
1206 .decorator_list
1207 .iter()
1208 .filter_map(|d| decorator_path(&d.expression))
1209 .collect(),
1210 });
1211 }
1212 Stmt::Assign(a) => {
1213 if let [Expr::Name(t)] = a.targets.as_slice() {
1214 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1215 }
1216 }
1217 Stmt::AnnAssign(a) => {
1218 if let Expr::Name(t) = &*a.target {
1219 members.push(class_attr_member(t.id.as_str(), a.range(), li));
1220 }
1221 }
1222 _ => {}
1223 }
1224 }
1225 let bases: Vec<String> = c
1226 .arguments
1227 .as_ref()
1228 .map(|args| args.args.iter().filter_map(expr_path).collect())
1229 .unwrap_or_default();
1230 let is_enum = bases.iter().any(|b| {
1231 let last = b.rsplit('.').next().unwrap_or(b);
1232 matches!(
1233 last,
1234 "Enum" | "IntEnum" | "StrEnum" | "Flag" | "IntFlag" | "ReprEnum" | "EnumMeta"
1235 )
1236 });
1237 ClassInfo {
1238 name: c.name.to_string(),
1239 line: line1(li, c.name.range().start()),
1241 end_line: end_line1(li, c.range()),
1242 is_private: is_private(c.name.as_str()),
1243 decorators: c
1244 .decorator_list
1245 .iter()
1246 .filter_map(|d| decorator_path(&d.expression))
1247 .collect(),
1248 bases,
1249 is_enum,
1250 methods,
1251 members,
1252 }
1253}
1254
1255fn class_attr_member(name: &str, range: TextRange, li: &LineIndex) -> ClassMember {
1256 ClassMember {
1257 name: name.to_string(),
1258 line: line1(li, range.start()),
1259 end_line: end_line1(li, range),
1260 is_method: false,
1261 is_private: is_private(name),
1262 decorators: Vec::new(),
1263 }
1264}
1265
1266struct UnreachableVisitor<'li> {
1271 li: &'li LineIndex,
1272 out: Vec<UnreachableCode>,
1273}
1274impl<'li> UnreachableVisitor<'li> {
1275 fn scan(&mut self, body: &[Stmt]) {
1277 for (i, stmt) in body.iter().enumerate() {
1278 if let Some(term) = terminator_kind(stmt) {
1279 if let Some(next) = body.get(i + 1) {
1280 self.out.push(UnreachableCode {
1282 line: line1(self.li, next.range().start()),
1283 after: term,
1284 });
1285 }
1286 break; }
1288 }
1289 }
1290}
1291impl<'a, 'li> Visitor<'a> for UnreachableVisitor<'li> {
1292 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1293 match stmt {
1295 Stmt::FunctionDef(f) => self.scan(&f.body),
1296 Stmt::ClassDef(c) => self.scan(&c.body),
1297 Stmt::If(i) => {
1298 self.scan(&i.body);
1299 for c in &i.elif_else_clauses {
1300 self.scan(&c.body);
1301 }
1302 }
1303 Stmt::For(f) => {
1304 self.scan(&f.body);
1305 self.scan(&f.orelse);
1306 }
1307 Stmt::While(w) => {
1308 self.scan(&w.body);
1309 self.scan(&w.orelse);
1310 }
1311 Stmt::With(w) => self.scan(&w.body),
1312 Stmt::Try(t) => {
1313 self.scan(&t.body);
1314 for h in &t.handlers {
1315 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1316 self.scan(&eh.body);
1317 }
1318 self.scan(&t.orelse);
1319 self.scan(&t.finalbody);
1320 }
1321 Stmt::Match(mt) => {
1322 for case in &mt.cases {
1323 self.scan(&case.body);
1324 }
1325 }
1326 _ => {}
1327 }
1328 walk_stmt(self, stmt);
1329 }
1330}
1331
1332fn terminator_kind(stmt: &Stmt) -> Option<&'static str> {
1334 match stmt {
1335 Stmt::Return(_) => Some("return"),
1336 Stmt::Raise(_) => Some("raise"),
1337 Stmt::Break(_) => Some("break"),
1338 Stmt::Continue(_) => Some("continue"),
1339 Stmt::Expr(e) if is_noreturn_call(&e.value) => Some("exit call"),
1340 _ => None,
1341 }
1342}
1343
1344fn is_noreturn_call(e: &Expr) -> bool {
1346 if let Expr::Call(c) = e {
1347 if let Some(p) = expr_path(&c.func) {
1348 return matches!(p.as_str(), "sys.exit" | "os._exit" | "exit" | "quit");
1351 }
1352 }
1353 false
1354}
1355
1356fn is_private_type(name: &str) -> bool {
1363 name.starts_with('_') && !(name.starts_with("__") && name.ends_with("__"))
1364}
1365
1366fn scan_type_leaks(body: &[Stmt], li: &LineIndex, out: &mut Vec<TypeLeak>) {
1367 let mut typevars: HashSet<String> = HashSet::new();
1370 collect_typevars(body, &mut typevars);
1371 for stmt in body {
1372 match stmt {
1373 Stmt::FunctionDef(f) if !is_private(f.name.as_str()) => {
1374 collect_fn_leaks(None, f, li, &typevars, out);
1375 }
1376 Stmt::ClassDef(c) if !is_private(c.name.as_str()) => {
1377 for s in &c.body {
1378 if let Stmt::FunctionDef(f) = s {
1379 if !is_private(f.name.as_str()) {
1380 collect_fn_leaks(Some(c.name.as_str()), f, li, &typevars, out);
1381 }
1382 }
1383 }
1384 }
1385 _ => {}
1386 }
1387 }
1388}
1389
1390fn collect_typevars(body: &[Stmt], out: &mut HashSet<String>) {
1393 for stmt in body {
1394 match stmt {
1395 Stmt::Assign(a) => {
1396 if let (Some(Expr::Name(t)), Expr::Call(c)) = (a.targets.first(), &*a.value) {
1397 if let Some(p) = expr_path(&c.func) {
1398 let last = p.rsplit('.').next().unwrap_or(&p);
1399 if matches!(last, "TypeVar" | "ParamSpec" | "TypeVarTuple") {
1400 out.insert(t.id.as_str().to_string());
1401 }
1402 }
1403 }
1404 }
1405 Stmt::If(i) => {
1406 collect_typevars(&i.body, out);
1407 for clause in &i.elif_else_clauses {
1408 collect_typevars(&clause.body, out);
1409 }
1410 }
1411 Stmt::Try(t) => {
1412 collect_typevars(&t.body, out);
1413 for h in &t.handlers {
1414 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1415 collect_typevars(&eh.body, out);
1416 }
1417 collect_typevars(&t.orelse, out);
1418 collect_typevars(&t.finalbody, out);
1419 }
1420 _ => {}
1421 }
1422 }
1423}
1424
1425fn collect_fn_leaks(
1426 class: Option<&str>,
1427 f: &StmtFunctionDef,
1428 li: &LineIndex,
1429 typevars: &HashSet<String>,
1430 out: &mut Vec<TypeLeak>,
1431) {
1432 let qualified = match class {
1433 Some(c) => format!("{c}.{}", f.name),
1434 None => f.name.to_string(),
1435 };
1436 let push_leaks = |ann: &Expr, line: u32, is_return: bool, out: &mut Vec<TypeLeak>| {
1437 let mut idents = Vec::new();
1438 annotation_idents(ann, &mut idents);
1439 for id in idents {
1440 if is_private_type(&id) && !typevars.contains(&id) {
1441 out.push(TypeLeak {
1442 function: qualified.clone(),
1443 type_name: id,
1444 line,
1445 is_return,
1446 });
1447 }
1448 }
1449 };
1450 for p in f
1451 .parameters
1452 .posonlyargs
1453 .iter()
1454 .chain(f.parameters.args.iter())
1455 .chain(f.parameters.kwonlyargs.iter())
1456 {
1457 if let Some(ann) = &p.parameter.annotation {
1458 push_leaks(ann, line1(li, p.parameter.range().start()), false, out);
1459 }
1460 }
1461 if let Some(r) = &f.returns {
1462 push_leaks(r, line1(li, f.name.range().start()), true, out);
1464 }
1465}
1466
1467fn annotation_idents(e: &Expr, out: &mut Vec<String>) {
1471 match e {
1472 Expr::Name(n) => out.push(n.id.as_str().to_string()),
1473 Expr::Attribute(a) => {
1474 annotation_idents(&a.value, out);
1475 out.push(a.attr.as_str().to_string());
1476 }
1477 Expr::Subscript(s) => {
1478 annotation_idents(&s.value, out);
1479 annotation_idents(&s.slice, out);
1480 }
1481 Expr::Tuple(t) => t.elts.iter().for_each(|el| annotation_idents(el, out)),
1482 Expr::List(l) => l.elts.iter().for_each(|el| annotation_idents(el, out)),
1483 Expr::BinOp(b) => {
1484 annotation_idents(&b.left, out);
1485 annotation_idents(&b.right, out);
1486 }
1487 Expr::StringLiteral(s) => {
1488 for tok in identifier_tokens(s.value.to_str()) {
1489 out.push(tok);
1490 }
1491 }
1492 _ => {}
1493 }
1494}
1495
1496fn self_attrs(f: &StmtFunctionDef) -> Vec<String> {
1497 let mut v = SelfAttrVisitor {
1498 attrs: std::collections::BTreeSet::new(),
1499 };
1500 for s in &f.body {
1501 v.visit_stmt(s);
1502 }
1503 v.attrs.into_iter().collect()
1504}
1505
1506struct SelfAttrVisitor {
1507 attrs: std::collections::BTreeSet<String>,
1508}
1509impl<'a> Visitor<'a> for SelfAttrVisitor {
1510 fn visit_expr(&mut self, expr: &'a Expr) {
1511 if let Expr::Attribute(a) = expr {
1512 if let Expr::Name(obj) = &*a.value {
1513 if obj.id.as_str() == "self" || obj.id.as_str() == "cls" {
1514 self.attrs.insert(a.attr.as_str().to_string());
1515 }
1516 }
1517 }
1518 walk_expr(self, expr);
1519 }
1520}
1521
1522struct DefVisitor<'a> {
1527 funcs: Vec<&'a StmtFunctionDef>,
1528 classes: Vec<&'a StmtClassDef>,
1529}
1530impl<'a> Visitor<'a> for DefVisitor<'a> {
1531 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1532 match stmt {
1533 Stmt::FunctionDef(f) => self.funcs.push(f),
1534 Stmt::ClassDef(c) => self.classes.push(c),
1535 _ => {}
1536 }
1537 walk_stmt(self, stmt);
1538 }
1539}
1540
1541struct LocalUseVisitor {
1546 uses: Vec<String>,
1547 attrs: Vec<String>,
1549}
1550impl<'a> Visitor<'a> for LocalUseVisitor {
1551 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1552 if matches!(stmt, Stmt::Import(_) | Stmt::ImportFrom(_)) {
1554 return;
1555 }
1556 if let Stmt::AnnAssign(a) = stmt {
1558 collect_annotation_strings(&a.annotation, &mut self.uses);
1559 if is_type_alias_annotation(&a.annotation) {
1565 if let Some(v) = &a.value {
1566 collect_annotation_strings(v, &mut self.uses);
1567 }
1568 }
1569 }
1570 if let Stmt::FunctionDef(f) = stmt {
1571 if let Some(r) = &f.returns {
1572 collect_annotation_strings(r, &mut self.uses);
1573 }
1574 for p in f
1575 .parameters
1576 .posonlyargs
1577 .iter()
1578 .chain(f.parameters.args.iter())
1579 .chain(f.parameters.kwonlyargs.iter())
1580 {
1581 if let Some(ann) = &p.parameter.annotation {
1582 collect_annotation_strings(ann, &mut self.uses);
1583 }
1584 }
1585 }
1586 walk_stmt(self, stmt);
1587 }
1588 fn visit_expr(&mut self, expr: &'a Expr) {
1589 match expr {
1590 Expr::Name(n) => self.uses.push(n.id.as_str().to_string()),
1591 Expr::Attribute(a) => {
1592 self.uses.push(a.attr.as_str().to_string());
1593 self.attrs.push(a.attr.as_str().to_string());
1594 }
1595 Expr::Call(c) => {
1599 let is_cast = expr_path(&c.func)
1600 .map(|p| p == "cast" || p.ends_with(".cast"))
1601 .unwrap_or(false);
1602 if is_cast {
1603 if let Some(first) = c.arguments.args.first() {
1604 collect_annotation_strings(first, &mut self.uses);
1605 }
1606 }
1607 }
1608 _ => {}
1609 }
1610 walk_expr(self, expr);
1611 }
1612}
1613
1614fn is_type_alias_annotation(e: &Expr) -> bool {
1617 expr_path(e)
1618 .map(|p| p == "TypeAlias" || p.ends_with(".TypeAlias"))
1619 .unwrap_or(false)
1620}
1621
1622fn collect_annotation_strings(e: &Expr, out: &mut Vec<String>) {
1625 match e {
1626 Expr::StringLiteral(s) => {
1627 for tok in identifier_tokens(s.value.to_str()) {
1628 out.push(tok);
1629 }
1630 }
1631 Expr::Subscript(s) => {
1632 collect_annotation_strings(&s.value, out);
1633 collect_annotation_strings(&s.slice, out);
1634 }
1635 Expr::Tuple(t) => {
1636 for el in &t.elts {
1637 collect_annotation_strings(el, out);
1638 }
1639 }
1640 Expr::List(l) => {
1641 for el in &l.elts {
1642 collect_annotation_strings(el, out);
1643 }
1644 }
1645 Expr::BinOp(b) => {
1646 collect_annotation_strings(&b.left, out);
1647 collect_annotation_strings(&b.right, out);
1648 }
1649 _ => {}
1650 }
1651}
1652
1653fn identifier_tokens(s: &str) -> Vec<String> {
1654 let mut out = Vec::new();
1655 let mut cur = String::new();
1656 let flush = |cur: &mut String, out: &mut Vec<String>| {
1657 if !cur.is_empty() && !cur.chars().next().unwrap().is_ascii_digit() {
1658 out.push(std::mem::take(cur));
1659 } else {
1660 cur.clear();
1661 }
1662 };
1663 for ch in s.chars() {
1664 if ch.is_ascii_alphanumeric() || ch == '_' {
1665 cur.push(ch);
1666 } else {
1667 flush(&mut cur, &mut out);
1668 }
1669 }
1670 flush(&mut cur, &mut out);
1671 out
1672}
1673
1674struct FnScope {
1687 locals: HashSet<String>,
1688 globals: HashSet<String>,
1689}
1690
1691struct Resolver {
1692 scopes: Vec<FnScope>,
1693 used: HashSet<String>,
1694}
1695
1696impl Resolver {
1697 fn resolve_load(&mut self, name: &str) {
1698 for s in self.scopes.iter().rev() {
1699 if s.globals.contains(name) {
1700 self.used.insert(name.to_string()); return;
1702 }
1703 if s.locals.contains(name) {
1704 return; }
1706 }
1707 self.used.insert(name.to_string());
1709 }
1710
1711 fn enter_function(&mut self, f: &StmtFunctionDef) {
1712 let mut bv = BindingVisitor {
1713 locals: HashSet::new(),
1714 globals: HashSet::new(),
1715 };
1716 for p in param_names(&f.parameters) {
1717 bv.locals.insert(p);
1718 }
1719 for stmt in &f.body {
1720 bv.visit_stmt(stmt);
1721 }
1722 for g in &bv.globals {
1724 bv.locals.remove(g);
1725 }
1726 self.scopes.push(FnScope {
1727 locals: bv.locals,
1728 globals: bv.globals,
1729 });
1730 }
1731
1732 fn visit_signature_exprs(&mut self, params: &Parameters) {
1735 for p in params
1736 .posonlyargs
1737 .iter()
1738 .chain(params.args.iter())
1739 .chain(params.kwonlyargs.iter())
1740 {
1741 if let Some(d) = &p.default {
1742 self.visit_expr(d);
1743 }
1744 if let Some(a) = &p.parameter.annotation {
1745 self.visit_expr(a);
1746 }
1747 }
1748 if let Some(v) = ¶ms.vararg {
1749 if let Some(a) = &v.annotation {
1750 self.visit_expr(a);
1751 }
1752 }
1753 if let Some(k) = ¶ms.kwarg {
1754 if let Some(a) = &k.annotation {
1755 self.visit_expr(a);
1756 }
1757 }
1758 }
1759}
1760
1761impl<'a> Visitor<'a> for Resolver {
1762 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1763 match stmt {
1764 Stmt::FunctionDef(f) => {
1765 for d in &f.decorator_list {
1768 self.visit_expr(&d.expression);
1769 }
1770 self.visit_signature_exprs(&f.parameters);
1771 if let Some(r) = &f.returns {
1772 self.visit_expr(r);
1773 }
1774 self.enter_function(f);
1775 for stmt in &f.body {
1776 self.visit_stmt(stmt);
1777 }
1778 self.scopes.pop();
1779 }
1780 Stmt::ClassDef(c) => {
1781 for d in &c.decorator_list {
1782 self.visit_expr(&d.expression);
1783 }
1784 if let Some(args) = &c.arguments {
1785 for a in args.args.iter() {
1786 self.visit_expr(a);
1787 }
1788 for kw in args.keywords.iter() {
1789 self.visit_expr(&kw.value);
1790 }
1791 }
1792 for stmt in &c.body {
1794 self.visit_stmt(stmt);
1795 }
1796 }
1797 _ => walk_stmt(self, stmt),
1798 }
1799 }
1800
1801 fn visit_expr(&mut self, expr: &'a Expr) {
1802 match expr {
1803 Expr::Name(n) => {
1804 if matches!(n.ctx, ExprContext::Load) {
1805 self.resolve_load(n.id.as_str());
1806 }
1807 }
1808 Expr::Lambda(l) => {
1809 let mut locals = HashSet::new();
1810 if let Some(params) = &l.parameters {
1811 self.visit_signature_exprs(params);
1813 for p in param_names(params) {
1814 locals.insert(p);
1815 }
1816 }
1817 self.scopes.push(FnScope {
1818 locals,
1819 globals: HashSet::new(),
1820 });
1821 self.visit_expr(&l.body);
1822 self.scopes.pop();
1823 }
1824 _ => walk_expr(self, expr),
1825 }
1826 }
1827}
1828
1829struct BindingVisitor {
1833 locals: HashSet<String>,
1834 globals: HashSet<String>,
1835}
1836impl<'a> Visitor<'a> for BindingVisitor {
1837 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1838 match stmt {
1839 Stmt::FunctionDef(f) => {
1840 self.locals.insert(f.name.to_string());
1841 }
1842 Stmt::ClassDef(c) => {
1843 self.locals.insert(c.name.to_string());
1844 }
1845 Stmt::Global(g) => {
1846 for n in &g.names {
1847 self.globals.insert(n.to_string());
1848 }
1849 }
1850 Stmt::Nonlocal(g) => {
1851 for n in &g.names {
1852 self.locals.insert(n.to_string());
1854 }
1855 }
1856 _ => walk_stmt(self, stmt),
1857 }
1858 }
1859 fn visit_expr(&mut self, expr: &'a Expr) {
1860 match expr {
1861 Expr::Name(n) if matches!(n.ctx, ExprContext::Store) => {
1862 self.locals.insert(n.id.as_str().to_string());
1863 }
1864 Expr::Lambda(_)
1869 | Expr::ListComp(_)
1870 | Expr::SetComp(_)
1871 | Expr::DictComp(_)
1872 | Expr::Generator(_) => {}
1873 _ => walk_expr(self, expr),
1874 }
1875 }
1876}
1877
1878fn param_names(params: &Parameters) -> Vec<String> {
1879 let mut out = Vec::new();
1880 for p in params
1881 .posonlyargs
1882 .iter()
1883 .chain(params.args.iter())
1884 .chain(params.kwonlyargs.iter())
1885 {
1886 out.push(p.parameter.name.as_str().to_string());
1887 }
1888 if let Some(v) = ¶ms.vararg {
1889 out.push(v.name.as_str().to_string());
1890 }
1891 if let Some(k) = ¶ms.kwarg {
1892 out.push(k.name.as_str().to_string());
1893 }
1894 out
1895}
1896
1897struct MainVisitor<'a, 'm> {
1902 li: &'a LineIndex,
1903 m: &'m mut ParsedModule,
1904 sanitized_idents: Vec<HashSet<String>>,
1907}
1908impl<'a, 'm> Visitor<'a> for MainVisitor<'a, 'm> {
1909 fn visit_stmt(&mut self, stmt: &'a Stmt) {
1910 if matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) {
1911 self.sanitized_idents.push(HashSet::new());
1912 walk_stmt(self, stmt);
1913 self.sanitized_idents.pop();
1914 return;
1915 }
1916 match stmt {
1917 Stmt::Assign(a) => {
1918 if expr_is_quote_replace(&a.value) {
1919 if let Some(set) = self.sanitized_idents.last_mut() {
1920 for t in &a.targets {
1921 if let Expr::Name(n) = t {
1922 set.insert(n.id.to_string());
1923 }
1924 }
1925 }
1926 }
1927 if let [Expr::Name(t)] = a.targets.as_slice() {
1928 security_secret(t.id.as_str(), &a.value, a.range(), self.li, self.m);
1929 }
1930 }
1931 Stmt::AnnAssign(a) => {
1932 if let (Expr::Name(t), Some(v)) = (&*a.target, &a.value) {
1933 security_secret(t.id.as_str(), v, a.range(), self.li, self.m);
1934 }
1935 }
1936 Stmt::If(i) => {
1937 if block_aborts(&i.body) {
1940 if let Some(set) = self.sanitized_idents.last_mut() {
1941 note_quote_rejection(&i.test, set);
1942 }
1943 }
1944 }
1945 Stmt::Try(t) => {
1946 for h in &t.handlers {
1949 let ruff_python_ast::ExceptHandler::ExceptHandler(eh) = h;
1950 let broad = match &eh.type_ {
1951 None => true,
1952 Some(ty) => expr_path(ty)
1953 .map(|p| {
1954 matches!(
1955 p.rsplit('.').next().unwrap_or(&p),
1956 "Exception" | "BaseException"
1957 )
1958 })
1959 .unwrap_or(false),
1960 };
1961 if broad && eh.body.iter().all(|s| matches!(s, Stmt::Pass(_))) {
1962 self.m.security_hits.push(SecurityHit {
1963 rule: "try-except-pass",
1964 line: line1(self.li, eh.range().start()),
1965 detail:
1966 "broad `except: pass` silently swallows errors; log or handle them"
1967 .into(),
1968 });
1969 }
1970 }
1971 }
1972 _ => {}
1973 }
1974 walk_stmt(self, stmt);
1975 }
1976 fn visit_expr(&mut self, expr: &'a Expr) {
1977 if let Expr::StringLiteral(s) = expr {
1978 if let Some(path) = py_path_literal(s.value.to_str()) {
1979 self.m.path_literals.push(path);
1980 }
1981 }
1982 if let Expr::Call(c) = expr {
1983 let callee = expr_path(&c.func).unwrap_or_default();
1984 if !callee.is_empty() {
1985 if DYNAMIC_SINKS.contains(&callee.as_str()) || callee.starts_with("importlib") {
1986 self.m.has_dynamic_sink = true;
1987 }
1988 self.m.calls.push(CallSite {
1989 callee: callee.clone(),
1990 line: line1(self.li, c.func.range().start()),
1991 });
1992 }
1993 if let Some(module) = loaded_module_literal(&callee, c) {
1997 let line = line1(self.li, c.range().start());
1998 self.m.nested_imports.push(Import {
1999 module,
2000 relative_dots: 0,
2001 names: Vec::new(),
2002 bindings: Vec::new(),
2003 is_star: false,
2004 type_checking_only: false,
2005 redundant: Vec::new(),
2006 in_try: false,
2007 line,
2008 end_line: line,
2009 });
2010 }
2011 let sanitized = self.sanitized_idents.last().cloned().unwrap_or_default();
2012 security_call(
2013 c,
2014 &callee,
2015 line1(self.li, c.range().start()),
2016 self.m,
2017 &sanitized,
2018 );
2019 }
2020 walk_expr(self, expr);
2021 }
2022}
2023
2024const SECRET_NAMES: &[&str] = &[
2025 "password",
2026 "passwd",
2027 "secret",
2028 "token",
2029 "api_key",
2030 "apikey",
2031 "access_key",
2032 "secret_key",
2033 "private_key",
2034 "auth_token",
2035];
2036
2037fn security_secret(
2038 name: &str,
2039 value: &Expr,
2040 range: TextRange,
2041 li: &LineIndex,
2042 m: &mut ParsedModule,
2043) {
2044 let lname = name.to_ascii_lowercase();
2045 if !SECRET_NAMES.iter().any(|s| lname.contains(s)) {
2046 return;
2047 }
2048 if let Expr::StringLiteral(s) = value {
2049 let val = s.value.to_str();
2050 if val.eq_ignore_ascii_case(name) {
2052 return;
2053 }
2054 if val.starts_with("http://")
2058 || val.starts_with("https://")
2059 || val.contains(char::is_whitespace)
2060 {
2061 return;
2062 }
2063 if val.len() >= 4 && !val.contains("${") && !val.eq_ignore_ascii_case("changeme") {
2064 m.security_hits.push(SecurityHit {
2065 rule: "hardcoded-secret",
2066 line: line1(li, range.start()),
2067 detail: format!("`{name}` assigned a hardcoded string literal"),
2068 });
2069 }
2070 }
2071}
2072
2073const WEAK_CIPHERS: &[&str] = &[
2074 "DES",
2075 "DES3",
2076 "TripleDES",
2077 "ARC2",
2078 "RC2",
2079 "ARC4",
2080 "RC4",
2081 "Blowfish",
2082 "IDEA",
2083 "CAST",
2084 "XOR",
2085];
2086
2087fn kwarg_bool(c: &ruff_python_ast::ExprCall, name: &str, want: bool) -> bool {
2088 c.arguments
2089 .find_keyword(name)
2090 .map(|kw| matches!(&kw.value, Expr::BooleanLiteral(b) if b.value == want))
2091 .unwrap_or(false)
2092}
2093
2094fn has_kwarg(c: &ruff_python_ast::ExprCall, name: &str) -> bool {
2095 c.arguments.find_keyword(name).is_some()
2096}
2097
2098fn first_positional_is_string(c: &ruff_python_ast::ExprCall) -> bool {
2099 matches!(c.arguments.args.first(), Some(Expr::StringLiteral(_)))
2100}
2101
2102fn py_path_literal(text: &str) -> Option<String> {
2105 let text = text.trim().strip_prefix("./").unwrap_or(text.trim());
2106 if text.is_empty()
2107 || text.starts_with('/')
2108 || text.contains('\\')
2109 || text.contains("..")
2110 || text.contains('\n')
2111 || !text.ends_with(".py")
2112 || text == "__init__.py"
2113 || text.ends_with("/__init__.py")
2114 {
2115 return None;
2116 }
2117 let ok = text.split('/').all(|seg| {
2118 !seg.is_empty()
2119 && seg
2120 .chars()
2121 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
2122 });
2123 if ok {
2124 Some(text.to_string())
2125 } else {
2126 None
2127 }
2128}
2129
2130fn loaded_module_literal(callee: &str, call: &ruff_python_ast::ExprCall) -> Option<String> {
2133 let leaf = callee.rsplit('.').next().unwrap_or(callee);
2134 let loader = matches!(
2135 leaf,
2136 "import_module" | "__import__" | "load_hook" | "load_plugin"
2137 );
2138 if !loader {
2139 return None;
2140 }
2141 let arg = call.arguments.args.first()?;
2142 let Expr::StringLiteral(s) = arg else {
2143 return None;
2144 };
2145 let module = s.value.to_str().trim();
2146 if module.split('.').all(|seg| {
2147 let mut chars = seg.chars();
2148 match chars.next() {
2149 Some(c) if c.is_ascii_alphabetic() || c == '_' => {
2150 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2151 }
2152 _ => false,
2153 }
2154 }) {
2155 Some(module.to_string())
2156 } else {
2157 None
2158 }
2159}
2160
2161fn block_aborts(body: &[Stmt]) -> bool {
2164 !body.is_empty()
2165 && body
2166 .iter()
2167 .all(|s| matches!(s, Stmt::Raise(_) | Stmt::Return(_)))
2168}
2169
2170fn note_quote_rejection(test: &Expr, set: &mut HashSet<String>) {
2171 match test {
2172 Expr::BoolOp(b) => {
2173 for v in &b.values {
2174 note_quote_rejection(v, set);
2175 }
2176 }
2177 Expr::Compare(c) => {
2178 if let Some((left, op, right)) = c.as_single() {
2179 if *op == ruff_python_ast::CmpOp::In && string_contains_quote(left) {
2180 if let Expr::Name(n) = right {
2181 set.insert(n.id.to_string());
2182 }
2183 }
2184 }
2185 }
2186 _ => {}
2187 }
2188}
2189
2190fn expr_is_quote_replace(expr: &Expr) -> bool {
2193 let Expr::Call(c) = expr else {
2194 return false;
2195 };
2196 let Expr::Attribute(attr) = c.func.as_ref() else {
2197 return false;
2198 };
2199 if attr.attr.as_str() != "replace" {
2200 return false;
2201 }
2202 c.arguments.args.first().is_some_and(string_contains_quote)
2203}
2204
2205fn string_contains_quote(expr: &Expr) -> bool {
2206 let Expr::StringLiteral(s) = expr else {
2207 return false;
2208 };
2209 let text = s.value.to_str();
2210 text.contains('\'') || text.contains('"')
2211}
2212
2213fn is_sanitized_identifier_sql(arg: &Expr, sanitized: &HashSet<String>) -> bool {
2217 let Expr::FString(f) = arg else {
2218 return false;
2219 };
2220 let mut static_sql = String::new();
2221 for el in f.value.elements() {
2222 match el {
2223 ruff_python_ast::InterpolatedStringElement::Literal(lit) => {
2224 static_sql.push_str(&lit.value);
2225 }
2226 ruff_python_ast::InterpolatedStringElement::Interpolation(interp) => {
2227 let Expr::Name(n) = interp.expression.as_ref() else {
2228 return false;
2229 };
2230 if !sanitized.contains(n.id.as_str()) {
2231 return false;
2232 }
2233 }
2234 }
2235 }
2236 let upper = static_sql.to_ascii_uppercase();
2237 let identifier = upper.contains("ATTACH") || upper.contains("COPY") || upper.contains("CREATE");
2238 let value = upper.contains("WHERE") || upper.contains("VALUES") || upper.contains(" SET ");
2239 identifier && !value
2240}
2241
2242fn db_handle_call(path: &str) -> bool {
2246 let Some((recv, _)) = path.rsplit_once('.') else {
2247 return false;
2248 };
2249 let name = recv.rsplit('.').next().unwrap_or(recv).to_ascii_lowercase();
2250 matches!(
2251 name.as_str(),
2252 "cursor" | "cur" | "connection" | "conn" | "con" | "db" | "session" | "engine"
2253 ) || name.ends_with("cursor")
2254 || name.ends_with("connection")
2255 || name.ends_with("_conn")
2256 || name.ends_with("_db")
2257 || name.ends_with("session")
2258 || name.ends_with("engine")
2259}
2260
2261fn is_dynamic_string(arg: &Expr) -> bool {
2262 match arg {
2263 Expr::FString(_) => true,
2264 Expr::BinOp(_) => true,
2265 Expr::Call(c) => expr_path(&c.func)
2266 .map(|p| p.ends_with(".format"))
2267 .unwrap_or(false),
2268 _ => false,
2269 }
2270}
2271
2272fn args_reference_ecb(c: &ruff_python_ast::ExprCall) -> bool {
2274 let refs = |e: &Expr| {
2275 expr_path(e)
2276 .map(|p| p.contains("MODE_ECB"))
2277 .unwrap_or(false)
2278 };
2279 c.arguments.args.iter().any(refs) || c.arguments.keywords.iter().any(|k| refs(&k.value))
2280}
2281
2282fn security_call(
2283 c: &ruff_python_ast::ExprCall,
2284 f: &str,
2285 line: u32,
2286 m: &mut ParsedModule,
2287 sanitized_idents: &HashSet<String>,
2288) {
2289 let last = f.rsplit('.').next().unwrap_or(f);
2290 let mut hit = |rule: &'static str, detail: String| {
2291 m.security_hits.push(SecurityHit { rule, line, detail });
2292 };
2293
2294 if matches!(
2298 f,
2299 "eval" | "exec" | "compile" | "builtins.eval" | "builtins.exec" | "builtins.compile"
2300 ) && !first_positional_is_string(c)
2301 {
2302 hit(
2303 "dangerous-eval",
2304 format!("`{f}` on a non-literal expression executes dynamic code"),
2305 );
2306 }
2307 if f == "yaml.load" && !has_kwarg(c, "Loader") {
2308 hit(
2309 "unsafe-yaml-load",
2310 "yaml.load without an explicit Loader= is unsafe; use yaml.safe_load".into(),
2311 );
2312 }
2313 if matches!(
2314 f,
2315 "pickle.load"
2316 | "pickle.loads"
2317 | "cPickle.load"
2318 | "cPickle.loads"
2319 | "marshal.load"
2320 | "marshal.loads"
2321 | "dill.load"
2322 | "dill.loads"
2323 | "shelve.open"
2324 | "jsonpickle.decode"
2325 ) {
2326 hit(
2327 "unsafe-deserialization",
2328 format!("`{f}` can execute arbitrary code on untrusted input"),
2329 );
2330 }
2331 if matches!(
2332 last,
2333 "call" | "run" | "Popen" | "check_output" | "check_call"
2334 ) && kwarg_bool(c, "shell", true)
2335 {
2336 hit(
2337 "subprocess-shell-true",
2338 "subprocess call with shell=True risks shell injection".into(),
2339 );
2340 }
2341 if matches!(f, "os.system" | "os.popen" | "os.popen2" | "os.popen3") {
2342 hit(
2343 "subprocess-shell-true",
2344 format!("`{f}` runs a command through the shell; prefer subprocess with an argv list"),
2345 );
2346 }
2347 if kwarg_bool(c, "verify", false) {
2348 hit(
2349 "tls-verify-disabled",
2350 "TLS certificate verification disabled (verify=False)".into(),
2351 );
2352 }
2353 if f == "ssl._create_unverified_context" {
2354 hit(
2355 "tls-verify-disabled",
2356 "ssl._create_unverified_context disables certificate validation".into(),
2357 );
2358 }
2359 if matches!(f, "hashlib.md5" | "hashlib.sha1" | "md5.new") {
2360 hit(
2361 "weak-hash",
2362 format!("`{f}` is a weak hash; use sha256+ (or pass usedforsecurity=False)"),
2363 );
2364 }
2365 if WEAK_CIPHERS.contains(&last) {
2366 hit(
2367 "weak-cipher",
2368 format!("`{f}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305"),
2369 );
2370 }
2371 if args_reference_ecb(c) {
2372 hit(
2373 "weak-cipher",
2374 "ECB mode leaks plaintext structure; use an authenticated mode (GCM)".into(),
2375 );
2376 }
2377 if matches!(
2378 f,
2379 "random.random"
2380 | "random.randint"
2381 | "random.randrange"
2382 | "random.choice"
2383 | "random.getrandbits"
2384 ) {
2385 hit(
2386 "insecure-random",
2387 format!("`{f}` is not cryptographically secure; use the `secrets` module for tokens"),
2388 );
2389 }
2390 let sql_method = matches!(last, "raw" | "extra")
2391 || (matches!(last, "execute" | "executemany" | "executescript") && db_handle_call(f));
2392 if sql_method {
2393 if let Some(arg) = c.arguments.args.first() {
2394 if is_dynamic_string(arg) && !is_sanitized_identifier_sql(arg, sanitized_idents) {
2395 hit(
2396 "sql-injection",
2397 format!(
2398 "`{last}(...)` builds SQL from a dynamic string; use parameterized queries"
2399 ),
2400 );
2401 }
2402 }
2403 }
2404 if matches!(
2405 f,
2406 "requests.get"
2407 | "requests.post"
2408 | "requests.put"
2409 | "requests.delete"
2410 | "requests.patch"
2411 | "requests.head"
2412 | "requests.request"
2413 ) && !has_kwarg(c, "timeout")
2414 {
2415 hit(
2416 "request-without-timeout",
2417 format!("`{f}` without a timeout= can block indefinitely"),
2418 );
2419 }
2420 if last == "run" && kwarg_bool(c, "debug", true) {
2423 hit(
2424 "flask-debug-true",
2425 "running a web app with debug=True exposes the interactive debugger".into(),
2426 );
2427 }
2428 if last == "Environment" && kwarg_bool(c, "autoescape", false) {
2431 hit(
2432 "jinja2-autoescape-false",
2433 "Jinja2 Environment with autoescape=False risks XSS; enable autoescaping".into(),
2434 );
2435 }
2436}
2437
2438fn security_imports(m: &mut ParsedModule) {
2439 let mut hits: Vec<SecurityHit> = Vec::new();
2440 for imp in m.imports.iter().chain(m.nested_imports.iter()) {
2441 let from_crypto = imp.module.contains("Crypto") || imp.module.contains("cryptography");
2442 if !from_crypto {
2443 continue;
2444 }
2445 for name in &imp.names {
2446 if WEAK_CIPHERS.contains(&name.as_str()) {
2447 hits.push(SecurityHit {
2448 rule: "weak-cipher",
2449 line: imp.line,
2450 detail: format!(
2451 "`{name}` (imported from `{}`) is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2452 imp.module
2453 ),
2454 });
2455 }
2456 }
2457 if imp.names.is_empty() {
2458 if let Some(seg) = imp.module.rsplit('.').next() {
2459 if WEAK_CIPHERS.contains(&seg) {
2460 hits.push(SecurityHit {
2461 rule: "weak-cipher",
2462 line: imp.line,
2463 detail: format!(
2464 "`{}` is a broken/weak cipher; use AES-GCM or ChaCha20-Poly1305",
2465 imp.module
2466 ),
2467 });
2468 }
2469 }
2470 }
2471 }
2472 m.security_hits.extend(hits);
2473}
2474
2475fn parse_noqa_comment(text: &str) -> Option<Vec<String>> {
2484 let t = text.trim_start_matches('#').trim();
2485 if t.len() < 4 || !t.is_char_boundary(4) || !t[..4].eq_ignore_ascii_case("noqa") {
2486 return None;
2487 }
2488 let rest = t[4..].trim_start();
2489 if rest.is_empty() || rest.starts_with('#') {
2490 return Some(vec!["unused-import".into(), "unused-variable".into()]);
2491 }
2492 let codes = rest.strip_prefix(':')?;
2493 let mut rules = Vec::new();
2494 for code in codes.split([',', ' ', '#']).map(str::trim) {
2495 match code.to_ascii_uppercase().as_str() {
2496 "F401" => rules.push("unused-import".to_string()),
2497 "F841" => rules.push("unused-variable".to_string()),
2498 _ => {}
2499 }
2500 }
2501 if rules.is_empty() {
2502 None
2503 } else {
2504 Some(rules)
2505 }
2506}
2507
2508fn parse_ignore_comment(text: &str) -> Option<Vec<String>> {
2509 let t = text.trim_start_matches('#').trim();
2510 let rest = t.strip_prefix("mollify:")?.trim();
2511 let rest = rest.strip_prefix("ignore")?.trim();
2512 if let Some(inner) = rest
2513 .strip_prefix('[')
2514 .and_then(|r| r.find(']').map(|i| &r[..i]))
2515 {
2516 let rules: Vec<String> = inner
2517 .split(',')
2518 .map(|s| s.trim().to_string())
2519 .filter(|s| !s.is_empty())
2520 .collect();
2521 if rules.is_empty() {
2522 Some(vec!["*".into()])
2523 } else {
2524 Some(rules)
2525 }
2526 } else if rest.is_empty() {
2527 Some(vec!["*".into()])
2528 } else {
2529 None
2530 }
2531}
2532
2533#[cfg(test)]
2534mod tests {
2535 use super::*;
2536
2537 fn parse(src: &str) -> ParsedModule {
2538 let mut p = PyParser::new().unwrap();
2539 p.parse(Utf8Path::new("m.py"), src).unwrap()
2540 }
2541
2542 #[test]
2543 fn extracts_functions_and_classes() {
2544 let m = parse("def foo():\n pass\n\nclass Bar:\n pass\n");
2545 let names: Vec<_> = m.definitions.iter().map(|d| d.name.as_str()).collect();
2546 assert!(names.contains(&"foo"));
2547 assert!(names.contains(&"Bar"));
2548 }
2549
2550 #[test]
2551 fn private_convention_detected() {
2552 let m = parse("def _helper():\n pass\n");
2553 assert!(m.definitions[0].private_by_convention);
2554 }
2555
2556 #[test]
2557 fn detects_expanded_security_rules() {
2558 let m = parse(
2559 "app.run(debug=True)\nenv = Environment(autoescape=False)\ntry:\n risky()\nexcept Exception:\n pass\n",
2560 );
2561 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2562 assert!(rules.contains(&"flask-debug-true"), "got {rules:?}");
2563 assert!(rules.contains(&"jinja2-autoescape-false"), "got {rules:?}");
2564 assert!(rules.contains(&"try-except-pass"), "got {rules:?}");
2565 let narrow = parse("try:\n x()\nexcept ValueError:\n pass\n");
2567 assert!(!narrow
2568 .security_hits
2569 .iter()
2570 .any(|h| h.rule == "try-except-pass"));
2571 }
2572
2573 #[test]
2574 fn extracts_imports() {
2575 let m = parse("import os\nfrom a.b import c, d\nfrom . import e\nfrom x import *\n");
2576 assert!(m.imports.iter().any(|i| i.module == "os"));
2577 let frm = m.imports.iter().find(|i| i.module == "a.b").unwrap();
2578 assert_eq!(frm.names, vec!["c", "d"]);
2579 assert!(m.imports.iter().any(|i| i.relative_dots == 1));
2580 assert!(m.imports.iter().any(|i| i.is_star));
2581 }
2582
2583 #[test]
2584 fn extracts_dunder_all() {
2585 let m = parse("__all__ = ['foo', 'bar']\n");
2586 assert_eq!(m.dunder_all, Some(vec!["foo".into(), "bar".into()]));
2587 }
2588
2589 #[test]
2590 fn detects_security_candidates() {
2591 let m = parse("import subprocess\npassword = \"hunter2xyz\"\nsubprocess.run(cmd, shell=True)\neval(user_input)\n");
2592 let rules: Vec<_> = m.security_hits.iter().map(|h| h.rule).collect();
2593 assert!(rules.contains(&"hardcoded-secret"), "got {rules:?}");
2594 assert!(rules.contains(&"subprocess-shell-true"), "got {rules:?}");
2595 assert!(rules.contains(&"dangerous-eval"), "got {rules:?}");
2596 let ok = parse("eval(\"1+1\")\n");
2597 assert!(!ok.security_hits.iter().any(|h| h.rule == "dangerous-eval"));
2598 }
2599
2600 #[test]
2601 fn dangerous_eval_only_matches_builtins_not_methods() {
2602 for src in [
2605 "session.exec(select(Item))\n",
2606 "conn.exec(query)\n",
2607 "obj.eval(expr)\n",
2608 "db.compile(stmt)\n",
2609 ] {
2610 let m = parse(src);
2611 assert!(
2612 !m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2613 "method call wrongly flagged: {src}"
2614 );
2615 }
2616 for src in [
2618 "exec(code)\n",
2619 "eval(user_input)\n",
2620 "compile(src, '<s>', 'exec')\n",
2621 ] {
2622 let m = parse(src);
2623 assert!(
2624 m.security_hits.iter().any(|h| h.rule == "dangerous-eval"),
2625 "builtin not flagged: {src}"
2626 );
2627 }
2628 }
2629
2630 #[test]
2631 fn detects_weak_cipher_imports() {
2632 let m = parse(
2633 "from Crypto.Cipher import DES as pycrypto_des\n\
2634 from Cryptodome.Cipher import ARC4 as ax\n\
2635 cipher = pycrypto_des.new(key, pycrypto_des.MODE_CTR)\n\
2636 c2 = ax.new(key)\n",
2637 );
2638 let cipher_hits: Vec<_> = m
2639 .security_hits
2640 .iter()
2641 .filter(|h| h.rule == "weak-cipher")
2642 .collect();
2643 assert_eq!(
2644 cipher_hits.len(),
2645 2,
2646 "expected DES + ARC4 imports flagged, got {:?}",
2647 m.security_hits
2648 );
2649 let lines: Vec<u32> = cipher_hits.iter().map(|h| h.line).collect();
2650 assert!(lines.contains(&1) && lines.contains(&2), "lines {lines:?}");
2651 }
2652
2653 #[test]
2654 fn detects_weak_cipher_direct_constructor_and_ecb() {
2655 let m = parse(
2656 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2657 c = Cipher(algorithms.ARC4(key), mode=None)\n",
2658 );
2659 assert!(
2660 m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2661 "expected ARC4 constructor flagged, got {:?}",
2662 m.security_hits
2663 );
2664 let ecb = parse("from Crypto.Cipher import AES\nc = AES.new(key, AES.MODE_ECB)\n");
2665 assert!(
2666 ecb.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2667 "expected ECB mode flagged, got {:?}",
2668 ecb.security_hits
2669 );
2670 }
2671
2672 #[test]
2673 fn strong_cipher_and_modes_not_flagged() {
2674 let m = parse(
2675 "from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher\n\
2676 c = Cipher(algorithms.AES(key), modes.GCM(iv))\n",
2677 );
2678 assert!(
2679 !m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
2680 "AES-GCM should not be flagged, got {:?}",
2681 m.security_hits
2682 );
2683 let unrelated = parse("from myapp.utils import DES\nDES.do_thing()\n");
2684 assert!(
2685 !unrelated
2686 .security_hits
2687 .iter()
2688 .any(|h| h.rule == "weak-cipher"),
2689 "non-crypto `DES` import should not be flagged, got {:?}",
2690 unrelated.security_hits
2691 );
2692 }
2693
2694 #[test]
2695 fn counts_type_annotations() {
2696 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");
2697 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2698 assert_eq!(f.params_total, 2);
2699 assert_eq!(f.params_annotated, 1);
2700 assert!(f.return_annotated);
2701 let mm = m.functions.iter().find(|f| f.name == "m").unwrap();
2702 assert_eq!(mm.params_total, 1, "self should be excluded");
2703 assert_eq!(mm.params_annotated, 1);
2704 assert!(!mm.return_annotated);
2705 }
2706
2707 #[test]
2708 fn computes_complexity() {
2709 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");
2710 let f = m.functions.iter().find(|f| f.name == "f").unwrap();
2711 assert!(f.cyclomatic >= 4, "cyclo {:?}", f.cyclomatic);
2712 assert!(f.cognitive >= 3, "cog {:?}", f.cognitive);
2713 }
2714
2715 #[test]
2716 fn captures_decorators() {
2717 let m = parse("import app\n@app.route('/x')\ndef view():\n return 1\n");
2718 let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2719 assert!(
2720 d.decorators.iter().any(|x| x == "app.route"),
2721 "got {:?}",
2722 d.decorators
2723 );
2724 }
2725
2726 #[test]
2727 fn detects_dynamic_sink() {
2728 let m = parse("x = getattr(obj, 'attr')\n");
2729 assert!(m.has_dynamic_sink);
2730 let m2 = parse("y = 1 + 2\n");
2731 assert!(!m2.has_dynamic_sink);
2732 }
2733
2734 #[test]
2735 fn conditional_import_seen() {
2736 let m = parse("try:\n import fast\nexcept ImportError:\n import slow as fast\n");
2737 assert!(m.imports.iter().any(|i| i.module == "fast"));
2738 }
2739
2740 #[test]
2741 fn scope_resolution_excludes_shadows_and_attributes() {
2742 let m = parse(
2747 "def helper():\n pass\n\ndef f():\n helper = 1\n return helper\n\nobj.helper()\n",
2748 );
2749 assert!(
2750 !m.module_used.iter().any(|s| s == "helper"),
2751 "module_used should exclude shadowed/attribute `helper`: {:?}",
2752 m.module_used
2753 );
2754 let m2 = parse("def g():\n pass\n\ng()\n");
2756 assert!(
2757 m2.module_used.iter().any(|s| s == "g"),
2758 "{:?}",
2759 m2.module_used
2760 );
2761 let m3 =
2764 parse("counter = 0\n\ndef bump():\n global counter\n counter = counter + 1\n");
2765 assert!(
2766 m3.module_used.iter().any(|s| s == "counter"),
2767 "{:?}",
2768 m3.module_used
2769 );
2770 let m4 = parse("counter = 0\n\ndef bump():\n counter = counter + 1\n");
2772 assert!(
2773 !m4.module_used.iter().any(|s| s == "counter"),
2774 "{:?}",
2775 m4.module_used
2776 );
2777 }
2778
2779 #[test]
2780 fn scope_resolution_sees_defaults_and_annotations() {
2781 let m = parse("DEFAULT = 5\nMyType = int\ndef f(x=DEFAULT) -> MyType: ...\n");
2784 assert!(
2785 m.module_used.iter().any(|s| s == "DEFAULT"),
2786 "{:?}",
2787 m.module_used
2788 );
2789 assert!(
2790 m.module_used.iter().any(|s| s == "MyType"),
2791 "{:?}",
2792 m.module_used
2793 );
2794 let m2 = parse("MyType = int\ndef g(x: MyType): ...\n");
2795 assert!(
2796 m2.module_used.iter().any(|s| s == "MyType"),
2797 "{:?}",
2798 m2.module_used
2799 );
2800 let m3 = parse("DEFAULT = 5\ng = lambda x=DEFAULT: x\n");
2802 assert!(
2803 m3.module_used.iter().any(|s| s == "DEFAULT"),
2804 "{:?}",
2805 m3.module_used
2806 );
2807 }
2808
2809 #[test]
2810 fn imports_inside_module_level_suites_seen() {
2811 let m = parse(
2812 "from contextlib import suppress\n\
2813 with suppress(ImportError):\n import ujson\n\
2814 for _i in range(1):\n import for_mod\n\
2815 while cond():\n import while_mod\n\
2816 match val:\n case 1:\n import match_mod\n",
2817 );
2818 for want in ["ujson", "for_mod", "while_mod", "match_mod"] {
2819 assert!(
2820 m.imports.iter().any(|i| i.module == want),
2821 "missing {want}: {:?}",
2822 m.imports
2823 );
2824 }
2825 }
2826
2827 #[test]
2828 fn type_checking_marks_body_not_else() {
2829 let m = parse(
2830 "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n import a\nelse:\n import b\n",
2831 );
2832 let a = m.imports.iter().find(|i| i.module == "a").unwrap();
2833 let b = m.imports.iter().find(|i| i.module == "b").unwrap();
2834 assert!(a.type_checking_only);
2835 assert!(!b.type_checking_only, "else branch is the runtime branch");
2836 let m2 = parse(
2838 "from typing import TYPE_CHECKING\nif not TYPE_CHECKING:\n import rt\nelse:\n import tc\n",
2839 );
2840 let rt = m2.imports.iter().find(|i| i.module == "rt").unwrap();
2841 let tc = m2.imports.iter().find(|i| i.module == "tc").unwrap();
2842 assert!(!rt.type_checking_only);
2843 assert!(tc.type_checking_only);
2844 }
2845
2846 #[test]
2847 fn type_checking_guard_is_exact() {
2848 let fp = parse("if MY_TYPE_CHECKING_OVERRIDE:\n from x import y\n");
2849 assert!(
2850 !fp.imports
2851 .iter()
2852 .find(|i| i.module == "x")
2853 .unwrap()
2854 .type_checking_only,
2855 "substring match must not treat this as a guard"
2856 );
2857 let ok = parse("import typing\nif typing.TYPE_CHECKING:\n from x import y\n");
2858 assert!(
2859 ok.imports
2860 .iter()
2861 .find(|i| i.module == "x")
2862 .unwrap()
2863 .type_checking_only
2864 );
2865 }
2866
2867 #[test]
2868 fn comprehension_targets_are_not_function_locals() {
2869 let m =
2872 parse("item = 1\ndef f(items):\n xs = [item for item in items]\n return item\n");
2873 assert!(
2874 m.module_used.iter().any(|s| s == "item"),
2875 "{:?}",
2876 m.module_used
2877 );
2878 }
2879
2880 #[test]
2881 fn dunder_all_mutations() {
2882 let m = parse("__all__ = ['a']\n__all__ += ['b']\n");
2883 assert_eq!(m.dunder_all, Some(vec!["a".into(), "b".into()]));
2884 let m2 = parse("__all__ = ['a']\n__all__.extend(['b', 'c'])\n__all__.append('d')\n");
2885 assert_eq!(
2886 m2.dunder_all,
2887 Some(vec!["a".into(), "b".into(), "c".into(), "d".into()])
2888 );
2889 let m3 = parse("__all__ = ['a']\n__all__ += make()\n");
2892 assert_eq!(m3.dunder_all, None);
2893 let m4 = parse("__all__ = ['a']\n__all__.extend(names)\n");
2894 assert_eq!(m4.dunder_all, None);
2895 let m5 = parse("__all__ = ['a']\n__all__.append(name)\n");
2896 assert_eq!(m5.dunder_all, None);
2897 }
2898
2899 #[test]
2900 fn decorated_def_line_points_at_def() {
2901 let m = parse("import app\n\n@app.route('/x')\ndef view() -> _Priv:\n return 1\n");
2902 let d = m.definitions.iter().find(|d| d.name == "view").unwrap();
2903 assert_eq!(d.line, 4, "decorator on line 3, def on line 4");
2904 assert_eq!(d.end_line, 5, "end_line keeps the full range");
2905 let f = m.functions.iter().find(|f| f.name == "view").unwrap();
2906 assert_eq!(f.line, 4);
2907 let leak = m
2908 .type_leaks
2909 .iter()
2910 .find(|l| l.type_name == "_Priv")
2911 .unwrap();
2912 assert_eq!(leak.line, 4);
2913 let m2 = parse("@decorate\nclass C:\n @property\n def p(self):\n return 1\n");
2914 let c = m2.classes.iter().find(|c| c.name == "C").unwrap();
2915 assert_eq!(c.line, 2);
2916 let p = c.members.iter().find(|mb| mb.name == "p").unwrap();
2917 assert_eq!(p.line, 4);
2918 let cd = m2.definitions.iter().find(|d| d.name == "C").unwrap();
2919 assert_eq!(cd.line, 2);
2920 }
2921
2922 #[test]
2923 fn typevar_under_guard_not_a_leak() {
2924 let m = parse(
2925 "from typing import TYPE_CHECKING, TypeVar\nif TYPE_CHECKING:\n _T = TypeVar('_T')\ndef f(x: _T) -> _T: ...\n",
2926 );
2927 assert!(m.type_leaks.is_empty(), "{:?}", m.type_leaks);
2928 let m2 = parse(
2929 "try:\n _P = ParamSpec('_P')\nexcept ImportError:\n pass\ndef g(x: _P): ...\n",
2930 );
2931 assert!(m2.type_leaks.is_empty(), "{:?}", m2.type_leaks);
2932 }
2933
2934 #[test]
2935 fn comment_parsers_fuzz_no_panic() {
2936 let mut state = 0x0123_4567_89AB_CDEFu64;
2940 let mut next = move || {
2941 state ^= state << 13;
2942 state ^= state >> 7;
2943 state ^= state << 17;
2944 state
2945 };
2946 let alphabet: &[&str] = &[
2947 "#", "n", "o", "q", "a", "N", "Q", "A", ":", ",", " ", "F", "4", "0", "1", "8",
2948 "mollify", "ignore", "[", "]", "ß", "é", "—", "\t",
2949 ];
2950 for _ in 0..4000u32 {
2951 let len = (next() % 40) as usize;
2952 let mut s = String::from("#");
2953 for _ in 0..len {
2954 s.push_str(alphabet[(next() as usize) % alphabet.len()]);
2955 }
2956 let _ = parse_noqa_comment(&s);
2957 let _ = parse_ignore_comment(&s);
2958 }
2959 }
2960
2961 #[test]
2962 fn noqa_comments_map_to_unused_binding_rules() {
2963 assert_eq!(
2965 parse_noqa_comment("# noqa"),
2966 Some(vec!["unused-import".into(), "unused-variable".into()])
2967 );
2968 assert_eq!(
2969 parse_noqa_comment("#NOQA"),
2970 Some(vec!["unused-import".into(), "unused-variable".into()])
2971 );
2972 assert_eq!(
2974 parse_noqa_comment("# noqa: F401"),
2975 Some(vec!["unused-import".into()])
2976 );
2977 assert_eq!(
2978 parse_noqa_comment("# noqa: E501, F841"),
2979 Some(vec!["unused-variable".into()])
2980 );
2981 assert_eq!(parse_noqa_comment("# noqa: E501"), None);
2983 assert_eq!(parse_noqa_comment("# noqable"), None);
2984 assert_eq!(parse_noqa_comment("# see noqa docs"), None);
2985 let m = parse("from hello import app # noqa: F401\n");
2987 assert!(
2988 m.ignores.contains(&(1, "unused-import".into())),
2989 "{:?}",
2990 m.ignores
2991 );
2992 }
2993
2994 #[test]
2995 fn redundant_alias_and_try_body_imports_are_marked() {
2996 let m = parse(
2997 "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",
2998 );
2999 let state = m.imports.iter().find(|i| i.bindings == ["State"]).unwrap();
3000 assert_eq!(state.redundant, vec![true]);
3001 let aliased = m.imports.iter().find(|i| i.bindings == ["Sansio"]).unwrap();
3002 assert_eq!(aliased.redundant, vec![false]);
3003 let probe = m.imports.iter().find(|i| i.module == "fast_json").unwrap();
3004 assert!(probe.in_try, "try-body import not marked: {probe:?}");
3005 let fallback = m.imports.iter().find(|i| i.module == "json").unwrap();
3006 assert!(fallback.in_try, "except-handler import not marked");
3007 let plain = m.imports.iter().find(|i| i.module == "os").unwrap();
3008 assert!(!plain.in_try);
3009 std::assert!(!plain.redundant.iter().any(|r| *r));
3010 }
3011
3012 #[test]
3013 fn ignore_comment_allows_trailing_text() {
3014 assert_eq!(
3015 parse_ignore_comment("# mollify: ignore[dead-code] -- migrating soon"),
3016 Some(vec!["dead-code".into()])
3017 );
3018 assert_eq!(
3019 parse_ignore_comment("# mollify: ignore[a, b] reason"),
3020 Some(vec!["a".into(), "b".into()])
3021 );
3022 let m = parse("x = 1 # mollify: ignore[dead-code] -- reason\n");
3023 assert!(
3024 m.ignores.contains(&(1, "dead-code".into())),
3025 "{:?}",
3026 m.ignores
3027 );
3028 }
3029
3030 #[test]
3031 fn nested_weak_cipher_import_flagged() {
3032 let m = parse("def f():\n from Crypto.Cipher import DES\n return DES\n");
3033 assert!(
3034 m.security_hits.iter().any(|h| h.rule == "weak-cipher"),
3035 "nested import must be scanned: {:?}",
3036 m.security_hits
3037 );
3038 }
3039}