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