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