use super::docstrings::{DocstringSectionKind, PythonDocstring, docstring_of};
use super::functions::{PythonFunctionSignature, function_signature};
use super::scopes::{collect_local_bound_names, parameters_shadow_name};
use super::{
AstNode, DecoratorInfo, ParsedFile, direct_function_definitions, extract_decorators_from_slice,
in_place_mutated_receiver_expr, is_bare_final_annotation_expr, method_receiver_name_ast,
resolved_path_and_terminal_expr,
};
use crate::code_lint::ast::{
CallableItem, CallableScope, MethodVisibility, TypeMethod, TypeMethodScope,
span_from_ruff_range,
};
use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, walk_expr, walk_stmt};
use ruff_python_ast::{Expr, Stmt, StmtAnnAssign, StmtClassDef, StmtFunctionDef};
use ruff_text_size::Ranged as _;
use std::collections::{HashMap, HashSet};
const SELF_RECEIVER: &str = "self";
const CLS_RECEIVER: &str = "cls";
const DATACLASS_DECORATOR_PATHS: &[&str] = &["dataclass", "dataclasses.dataclass"];
const PROTOCOL_PATHS: &[&str] = &["Protocol", "typing.Protocol", "typing_extensions.Protocol"];
const ABC_PATHS: &[&str] = &["ABC", "abc.ABC"];
const NAMED_TUPLE_PATHS: &[&str] = &[
"NamedTuple",
"typing.NamedTuple",
"typing_extensions.NamedTuple",
];
const STRUCTURAL_MARKER_PATHS: &[&str] = &[
"object",
"builtins.object",
"Generic",
"typing.Generic",
"typing_extensions.Generic",
"Protocol",
"typing.Protocol",
"typing_extensions.Protocol",
"ABC",
"abc.ABC",
];
const PYTHON_CONSTRUCTOR_NAMES: &[&str] = &[
"__prepare__",
"__init_subclass__",
"__new__",
"__init__",
"__post_init__",
"__attrs_pre_init__",
"__attrs_post_init__",
];
const INIT_METHOD: &str = "__init__";
const INITIALIZER_METHODS: &[&str] = &[
INIT_METHOD,
"__post_init__",
"__attrs_post_init__",
"model_post_init",
];
const ENUM_BASE_PATHS: &[&str] = &[
"Enum",
"IntEnum",
"StrEnum",
"Flag",
"IntFlag",
"ReprEnum",
"enum.Enum",
"enum.IntEnum",
"enum.StrEnum",
"enum.Flag",
"enum.IntFlag",
"enum.ReprEnum",
];
const INIT_VAR_PATHS: &[&str] = &["InitVar", "dataclasses.InitVar"];
#[derive(Clone)]
pub struct PythonBaseClass {
pub name: String,
resolved_path: Option<String>,
}
impl PythonBaseClass {
#[must_use]
pub fn unsubscripted_name(&self) -> &str {
self.name
.split_once('[')
.map_or(self.name.as_str(), |(base, _)| base.trim())
}
#[must_use]
pub fn is_structural_marker(&self) -> bool {
self.resolved_path
.as_deref()
.is_some_and(|path| STRUCTURAL_MARKER_PATHS.contains(&path))
}
}
#[derive(Clone)]
pub struct PythonClassInfo<'a> {
pub name: String,
pub name_node: AstNode<'a>,
pub bases: Vec<PythonBaseClass>,
pub docstring: Option<PythonDocstring<'a>>,
pub(super) decorators: Vec<DecoratorInfo>,
pub(super) definition: &'a StmtClassDef,
}
impl<'a> PythonClassInfo<'a> {
pub(super) fn from_ast(class_def: &'a StmtClassDef, file: &'a ParsedFile) -> Self {
let bases = class_def
.bases()
.iter()
.map(|base| {
let base_span = span_from_ruff_range(base.range());
PythonBaseClass {
name: file.source[base_span.start..base_span.end].to_string(),
resolved_path: resolved_base_class_path(base, file),
}
})
.collect();
let decorators = extract_decorators_from_slice(&class_def.decorator_list, file);
Self {
name: class_def.name.id.to_string(),
name_node: AstNode::from_span(file, span_from_ruff_range(class_def.name.range)),
bases,
docstring: docstring_of(&class_def.body, file),
decorators,
definition: class_def,
}
}
#[must_use]
pub fn methods(&self) -> Vec<PythonFunctionSignature<'a>> {
let file = self.name_node.file;
self.definition
.body
.iter()
.filter_map(|statement| match statement {
Stmt::FunctionDef(func_def) => Some(function_signature(
func_def,
file,
Some(self.definition),
None,
)),
_ => None,
})
.collect()
}
#[must_use]
pub fn constructor(&self) -> Option<PythonFunctionSignature<'a>> {
let file = self.name_node.file;
self.methods().into_iter().rfind(|method| {
method.name == INIT_METHOD
&& !extract_decorators_from_slice(&method.definition.decorator_list, file)
.iter()
.any(|decorator| decorator.matches_any(OVERLOAD_DECORATOR_PATHS))
})
}
#[must_use]
pub fn attribute_definitions(&self) -> Vec<PythonAttributeDefinition<'a>> {
let file = self.name_node.file;
let mut collector = AttributeCollector {
file,
seen: HashSet::new(),
definitions: Vec::new(),
};
for statement in &self.definition.body {
match statement {
Stmt::Assign(assign) => {
for target in &assign.targets {
collector.add_class_body_targets(target, None);
}
}
Stmt::AnnAssign(annotated) => {
collector
.add_class_body_targets(&annotated.target, Some(&annotated.annotation));
}
Stmt::FunctionDef(function_def)
if INITIALIZER_METHODS.contains(&function_def.name.id.as_str()) =>
{
if let Some(receiver) = method_receiver_name_ast(function_def, false, file) {
collector.add_initializer_targets(function_def, &receiver);
}
}
_ => {}
}
}
collector.definitions
}
}
impl PythonClassInfo<'_> {
#[must_use]
pub fn defines_function(&self, name: &str) -> bool {
direct_function_definitions(&self.definition.body)
.iter()
.any(|function_def| function_def.name.id == name)
}
#[must_use]
pub fn is_enum(&self) -> bool {
resolved_base_class_paths(self.definition, self.name_node.file)
.iter()
.any(|path| ENUM_BASE_PATHS.contains(&path.as_str()))
}
#[must_use]
pub fn is_protocol(&self) -> bool {
is_protocol_class(self.definition, self.name_node.file)
}
#[must_use]
pub fn is_abc(&self) -> bool {
is_abc_class(self.definition, self.name_node.file)
}
#[must_use]
pub fn is_named_tuple(&self) -> bool {
resolved_base_class_paths(self.definition, self.name_node.file)
.iter()
.any(|path| NAMED_TUPLE_PATHS.contains(&path.as_str()))
}
#[must_use]
pub fn is_dataclass_missing_arg(&self, key: &str) -> bool {
self.dataclass_decorator()
.is_some_and(|decorator| !decorator.has_arg(key))
}
#[must_use]
pub fn unchanged_constructor_parameters(&self) -> HashSet<String> {
let Some(constructor) = self.constructor() else {
return HashSet::new();
};
let Some(receiver) =
method_receiver_name_ast(constructor.definition, false, self.name_node.file)
else {
return HashSet::new();
};
let parameter_names: HashSet<&str> = constructor
.parameters
.iter()
.map(|parameter| parameter.name.as_str())
.collect();
unchanged_self_copies(&constructor.definition.body, &receiver, ¶meter_names)
}
#[must_use]
pub fn documented_unchanged_constructor_parameters(&self) -> HashSet<String> {
let Some(docstring) = &self.docstring else {
return HashSet::new();
};
let documented = docstring.section_entry_names(DocstringSectionKind::Attributes);
self.unchanged_constructor_parameters()
.into_iter()
.filter(|name| documented.contains(name))
.collect()
}
fn dataclass_decorator(&self) -> Option<&DecoratorInfo> {
self.decorators
.iter()
.find(|decorator| decorator.matches_any(DATACLASS_DECORATOR_PATHS))
}
}
#[must_use]
pub fn extract_classes(file: &ParsedFile) -> Vec<PythonClassInfo<'_>> {
struct ClassVisitor<'a> {
file: &'a ParsedFile,
classes: Vec<PythonClassInfo<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for ClassVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement {
self.classes
.push(PythonClassInfo::from_ast(class_def, self.file));
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = ClassVisitor {
file,
classes: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.classes
}
#[must_use]
pub fn local_base_classes(classes: &[PythonClassInfo<'_>], index: usize) -> Vec<usize> {
classes[index]
.bases
.iter()
.filter_map(|base| {
let name = base.unsubscripted_name();
classes[..index]
.iter()
.rposition(|candidate| candidate.name == name)
})
.collect()
}
#[must_use]
pub fn local_ancestors(classes: &[PythonClassInfo<'_>], index: usize) -> Vec<usize> {
let mut ancestors = Vec::new();
let mut pending: Vec<usize> = local_base_classes(classes, index)
.into_iter()
.rev()
.collect();
while let Some(next) = pending.pop() {
if !ancestors.contains(&next) {
ancestors.push(next);
pending.extend(local_base_classes(classes, next).into_iter().rev());
}
}
ancestors
}
#[must_use]
pub fn is_fake_class_name(name: &str) -> bool {
name.trim_start_matches('_')
.strip_prefix("Fake")
.is_some_and(|rest| !rest.starts_with(|character: char| character.is_ascii_lowercase()))
}
pub struct PythonInheritedDocstring<'c, 'a> {
pub ancestor: &'c PythonClassInfo<'a>,
pub docstring: PythonDocstring<'a>,
}
#[must_use]
pub fn inherited_method_docstring<'c, 'a>(
classes: &'c [PythonClassInfo<'a>],
index: usize,
name: &str,
) -> Option<PythonInheritedDocstring<'c, 'a>> {
local_ancestors(classes, index)
.into_iter()
.find_map(|ancestor| {
let class = &classes[ancestor];
class
.methods()
.iter()
.rev()
.filter(|method| method.name == name)
.find_map(PythonFunctionSignature::docstring)
.map(|docstring| PythonInheritedDocstring {
ancestor: class,
docstring,
})
})
}
#[derive(Clone)]
pub struct PythonAttributeDefinition<'a> {
pub name: String,
pub node: AstNode<'a>,
pub annotation: Option<AstNode<'a>>,
pub is_in_class_body: bool,
pub is_init_var: bool,
}
struct AttributeCollector<'a> {
file: &'a ParsedFile,
seen: HashSet<String>,
definitions: Vec<PythonAttributeDefinition<'a>>,
}
impl AttributeCollector<'_> {
fn add_class_body_targets(&mut self, target: &Expr, annotation: Option<&Expr>) {
for target in unpacked_targets(target) {
if let Expr::Name(name) = target {
self.add(name.id.as_str(), target, annotation, true);
}
}
}
fn add_initializer_targets(&mut self, function_def: &StmtFunctionDef, receiver: &str) {
for (target, annotation) in direct_assignment_targets(&function_def.body) {
for unpacked in unpacked_targets(target) {
if let Expr::Attribute(attribute) = unpacked
&& matches!(attribute.value.as_ref(), Expr::Name(object) if object.id == receiver)
{
self.add(attribute.attr.id.as_str(), unpacked, annotation, false);
}
}
}
}
fn add(
&mut self,
name: &str,
target: &Expr,
annotation: Option<&Expr>,
is_in_class_body: bool,
) {
if !self.seen.insert(name.to_owned()) {
return;
}
let file = self.file;
self.definitions.push(PythonAttributeDefinition {
name: name.to_owned(),
node: AstNode::from_span(file, span_from_ruff_range(target.range())),
annotation: annotation.map(|annotation| {
AstNode::from_span(file, span_from_ruff_range(annotation.range()))
}),
is_in_class_body,
is_init_var: annotation
.is_some_and(|annotation| is_init_var_annotation(annotation, file)),
});
}
}
pub struct PythonAnnotatedAttribute<'a> {
pub class_name: String,
pub is_in_protocol_or_abc: bool,
pub name: String,
pub type_node: AstNode<'a>,
pub is_mutated_in_class: bool,
pub is_typed_dict_key: bool,
}
#[must_use]
pub fn collect_class_attributes(file: &ParsedFile) -> Vec<PythonAnnotatedAttribute<'_>> {
struct ClassAttrVisitor<'a> {
file: &'a ParsedFile,
out: Vec<PythonAnnotatedAttribute<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for ClassAttrVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement {
let class_name = class_def.name.id.to_string();
let is_typed_dict = is_typed_dict_class(class_def, self.file);
let is_in_protocol_or_abc = is_protocol_or_abc_class(class_def, self.file);
let mutated_attrs = collect_mutated_class_attr_names(&class_def.body, &class_name);
for child in &class_def.body {
if let Stmt::AnnAssign(ann) = child
&& let Expr::Name(left) = ann.target.as_ref()
{
let attr_name = left.id.to_string();
let is_mutated_in_class = mutated_attrs.contains(&attr_name);
self.out.push(PythonAnnotatedAttribute {
class_name: class_name.clone(),
is_in_protocol_or_abc,
name: attr_name,
type_node: AstNode::from_span(
self.file,
span_from_ruff_range(ann.annotation.range()),
),
is_mutated_in_class,
is_typed_dict_key: is_typed_dict,
});
}
}
for function_def in direct_function_definitions(&class_def.body) {
if function_def.name.id == INIT_METHOD
&& method_receiver_name_ast(function_def, false, self.file).is_some()
{
for (_, attr_name, type_expr) in
collect_self_annotated_assignments(&function_def.body)
{
let is_mutated_in_class = mutated_attrs.contains(&attr_name);
self.out.push(PythonAnnotatedAttribute {
class_name: class_name.clone(),
is_in_protocol_or_abc,
name: attr_name,
type_node: AstNode::from_span(
self.file,
span_from_ruff_range(type_expr.range()),
),
is_mutated_in_class,
is_typed_dict_key: false,
});
}
}
}
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = ClassAttrVisitor {
file,
out: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.out
}
fn collect_mutated_class_attr_names(body: &[Stmt], class_name: &str) -> HashSet<String> {
struct MutationVisitor<'a> {
class_name: &'a str,
out: HashSet<String>,
}
impl<'a> SourceOrderVisitor<'a> for MutationVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
match statement {
Stmt::ClassDef(_) => return,
Stmt::AugAssign(aug) => {
record_mutated_class_receiver(&aug.target, self.class_name, &mut self.out);
}
_ => {}
}
walk_stmt(self, statement);
}
fn visit_expr(&mut self, expr: &'a Expr) {
if let Some(receiver) = in_place_mutated_receiver_expr(expr) {
record_mutated_class_receiver(receiver, self.class_name, &mut self.out);
}
walk_expr(self, expr);
}
}
let mut visitor = MutationVisitor {
class_name,
out: HashSet::new(),
};
visitor.visit_body(body);
visitor.out
}
fn record_mutated_class_receiver(receiver: &Expr, class_name: &str, out: &mut HashSet<String>) {
if let Expr::Attribute(attr) = receiver
&& let Expr::Name(object) = attr.value.as_ref()
&& (matches!(object.id.as_str(), SELF_RECEIVER | CLS_RECEIVER)
|| object.id.as_str() == class_name)
{
out.insert(attr.attr.to_string());
}
}
pub(super) fn is_protocol_or_abc_class(class_def: &StmtClassDef, file: &ParsedFile) -> bool {
is_protocol_class(class_def, file) || is_abc_class(class_def, file)
}
pub(super) fn is_typed_dict_class(class_def: &StmtClassDef, file: &ParsedFile) -> bool {
resolved_base_class_paths(class_def, file)
.iter()
.any(|path| {
matches!(
path.as_str(),
"TypedDict" | "typing.TypedDict" | "typing_extensions.TypedDict"
)
})
}
#[derive(Clone)]
pub struct PythonInstanceAttributeAnnotation<'a> {
pub class_name: String,
pub is_in_field_synthesizing_class: bool,
pub method_name: String,
pub name: String,
pub annotation: AstNode<'a>,
pub assignment_node: AstNode<'a>,
is_bare_final: bool,
}
impl PythonInstanceAttributeAnnotation<'_> {
#[must_use]
pub const fn is_bare_final(&self) -> bool {
self.is_bare_final
}
}
#[must_use]
pub fn collect_instance_attribute_annotations(
file: &ParsedFile,
) -> Vec<PythonInstanceAttributeAnnotation<'_>> {
struct InstanceAttrVisitor<'a> {
file: &'a ParsedFile,
out: Vec<PythonInstanceAttributeAnnotation<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for InstanceAttrVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement {
let class_name = class_def.name.id.to_string();
let is_in_field_synthesizing_class =
is_field_synthesizing_class(class_def, self.file);
for function_def in direct_function_definitions(&class_def.body) {
if method_receiver_name_ast(function_def, false, self.file).is_some() {
let method_name = function_def.name.id.to_string();
for (ann_assign, attribute_name, type_expr) in
collect_self_annotated_assignments(&function_def.body)
{
self.out.push(PythonInstanceAttributeAnnotation {
class_name: class_name.clone(),
is_in_field_synthesizing_class,
method_name: method_name.clone(),
name: attribute_name,
annotation: AstNode::from_span(
self.file,
span_from_ruff_range(type_expr.range()),
),
assignment_node: AstNode::from_span(
self.file,
span_from_ruff_range(ann_assign.range),
),
is_bare_final: is_bare_final_annotation_expr(type_expr, self.file),
});
}
}
}
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = InstanceAttrVisitor {
file,
out: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.out
}
const FIELD_SYNTHESIZING_DECORATOR_PATHS: &[&str] = &[
"dataclass",
"dataclasses.dataclass",
"pydantic.dataclasses.dataclass",
"define",
"frozen",
"mutable",
"s",
"attrs",
"attr.define",
"attr.frozen",
"attr.mutable",
"attr.s",
"attr.attrs",
"attr.dataclass",
"attrs.define",
"attrs.frozen",
"attrs.mutable",
"attrs.s",
"attrs.attrs",
"attrs.dataclass",
];
fn is_field_synthesizing_class(class_def: &StmtClassDef, file: &ParsedFile) -> bool {
let has_field_decorator = extract_decorators_from_slice(&class_def.decorator_list, file)
.iter()
.any(|decorator| decorator.matches_any(FIELD_SYNTHESIZING_DECORATOR_PATHS));
has_field_decorator
|| resolved_base_class_paths(class_def, file)
.iter()
.any(|path| {
matches!(
path.as_str(),
"BaseModel" | "pydantic.BaseModel" | "pydantic.main.BaseModel"
)
})
}
struct GroupedPythonCallable<'a, 'ast> {
name_node: AstNode<'a>,
name: String,
visibility: MethodVisibility,
is_constructor: bool,
defs: Vec<&'ast StmtFunctionDef>,
}
#[must_use]
pub(in crate::code_lint::ast) fn collect_type_method_scopes(
file: &ParsedFile,
) -> Vec<TypeMethodScope<'_>> {
struct ClassMethodVisitor<'a> {
file: &'a ParsedFile,
scopes: Vec<TypeMethodScope<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for ClassMethodVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement {
self.scopes.push(TypeMethodScope {
type_name: class_def.name.id.to_string(),
methods: collect_class_methods(class_def, self.file),
});
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = ClassMethodVisitor {
file,
scopes: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.scopes
}
fn collect_class_methods<'a>(
class_def: &StmtClassDef,
file: &'a ParsedFile,
) -> Vec<TypeMethod<'a>> {
group_python_callables(&class_def.body, true, file)
.into_iter()
.map(|callable| TypeMethod {
name_node: callable.name_node,
name: callable.name,
visibility: callable.visibility,
is_constructor: callable.is_constructor,
})
.collect()
}
#[must_use]
pub(in crate::code_lint::ast) fn collect_callable_scopes(
file: &ParsedFile,
) -> Vec<CallableScope<'_>> {
struct ScopeCollector<'a> {
file: &'a ParsedFile,
scopes: Vec<CallableScope<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for ScopeCollector<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement
&& let Some(scope) = build_class_callable_scope(class_def, self.file)
{
self.scopes.push(scope);
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let scopes = build_module_callable_scopes(&parsed.syntax().body, file);
let mut collector = ScopeCollector { file, scopes };
collector.visit_body(&parsed.syntax().body);
collector.scopes
}
fn build_module_callable_scopes<'a>(
module_body: &[Stmt],
file: &'a ParsedFile,
) -> Vec<CallableScope<'a>> {
let grouped = group_python_callables(module_body, false, file);
if grouped.len() < 2 {
return Vec::new();
}
let mut segments: Vec<Vec<GroupedPythonCallable<'a, '_>>> = Vec::new();
let mut current_segment: Vec<GroupedPythonCallable<'a, '_>> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
for item in grouped {
if !seen_names.insert(item.name.clone()) {
segments.push(std::mem::take(&mut current_segment));
seen_names.clear();
seen_names.insert(item.name.clone());
}
current_segment.push(item);
}
if !current_segment.is_empty() {
segments.push(current_segment);
}
let empty_classes = HashMap::new();
let empty_locals = HashSet::new();
let mut scopes = Vec::new();
for segment in segments {
if segment.len() < 2 {
continue;
}
let fn_by_name: HashMap<String, usize> = segment
.iter()
.enumerate()
.map(|(idx, item)| (item.name.clone(), idx))
.collect();
let mut class_callees: HashMap<&str, Vec<usize>> = HashMap::new();
for statement in module_body {
if let Stmt::ClassDef(class_def) = statement {
let mut callees = Vec::new();
for class_statement in &class_def.body {
let method_locals;
let local_names = if let Stmt::FunctionDef(method_def) = class_statement {
method_locals = collect_local_bound_names(method_def, file);
&method_locals
} else {
&empty_locals
};
let mut visitor = ModuleRefVisitor {
fn_by_name: &fn_by_name,
class_callees: &empty_classes,
local_names,
callees: &mut callees,
};
visitor.visit_stmt(class_statement);
}
if !callees.is_empty() {
class_callees.insert(class_def.name.id.as_str(), callees);
}
}
}
let callables = segment
.into_iter()
.map(|group| {
let mut local_names = HashSet::new();
for def in &group.defs {
local_names.extend(collect_local_bound_names(def, file));
}
let mut callees = Vec::new();
let mut visitor = ModuleRefVisitor {
fn_by_name: &fn_by_name,
class_callees: &class_callees,
local_names: &local_names,
callees: &mut callees,
};
for def in &group.defs {
visitor.visit_body(&def.body);
}
CallableItem {
name_node: group.name_node,
name: group.name,
visibility: group.visibility,
is_constructor: group.is_constructor,
callees,
}
})
.collect();
scopes.push(CallableScope { callables });
}
scopes
}
struct ModuleRefVisitor<'map, 'local> {
fn_by_name: &'map HashMap<String, usize>,
class_callees: &'map HashMap<&'map str, Vec<usize>>,
local_names: &'local HashSet<String>,
callees: &'local mut Vec<usize>,
}
impl SourceOrderVisitor<'_> for ModuleRefVisitor<'_, '_> {
fn visit_expr(&mut self, expr: &Expr) {
if let Expr::Name(name) = expr
&& name.ctx.is_load()
{
let ident = name.id.as_str();
if !self.local_names.contains(ident) {
if let Some(&idx) = self.fn_by_name.get(ident)
&& !self.callees.contains(&idx)
{
self.callees.push(idx);
}
if let Some(bridged) = self.class_callees.get(ident) {
for &idx in bridged {
if !self.callees.contains(&idx) {
self.callees.push(idx);
}
}
}
}
}
walk_expr(self, expr);
}
}
fn build_class_callable_scope<'a>(
class_def: &StmtClassDef,
file: &'a ParsedFile,
) -> Option<CallableScope<'a>> {
struct ClassMethodCallVisitor<'map, 'local> {
class_name: &'map str,
method_by_name: &'map HashMap<String, usize>,
local_names: &'local HashSet<String>,
has_self_param: bool,
has_cls_param: bool,
callees: &'local mut Vec<usize>,
}
impl SourceOrderVisitor<'_> for ClassMethodCallVisitor<'_, '_> {
fn visit_stmt(&mut self, statement: &Stmt) {
if matches!(statement, Stmt::ClassDef(_)) {
return;
}
walk_stmt(self, statement);
}
fn visit_expr(&mut self, expr: &Expr) {
if let Expr::Attribute(attr) = expr
&& attr.ctx.is_load()
&& let Expr::Name(recv) = attr.value.as_ref()
{
let recv_name = recv.id.as_str();
let is_valid_receiver = (recv_name == SELF_RECEIVER
&& (self.has_self_param || !self.local_names.contains(SELF_RECEIVER)))
|| (recv_name == CLS_RECEIVER
&& (self.has_cls_param || !self.local_names.contains(CLS_RECEIVER)))
|| (recv_name == self.class_name && !self.local_names.contains(recv_name));
if is_valid_receiver
&& let Some(&idx) = self.method_by_name.get(attr.attr.id.as_str())
&& !self.callees.contains(&idx)
{
self.callees.push(idx);
}
}
walk_expr(self, expr);
}
}
let grouped = group_python_callables(&class_def.body, true, file);
if grouped.len() < 2 {
return None;
}
let method_by_name: HashMap<String, usize> = grouped
.iter()
.enumerate()
.map(|(idx, item)| (item.name.clone(), idx))
.collect();
let class_name = class_def.name.id.as_str();
let callables = grouped
.into_iter()
.map(|group| {
let mut callees = Vec::new();
for def in &group.defs {
let local_names = collect_local_bound_names(def, file);
let has_self_param = parameters_shadow_name(&def.parameters, SELF_RECEIVER);
let has_cls_param = parameters_shadow_name(&def.parameters, CLS_RECEIVER);
let mut visitor = ClassMethodCallVisitor {
class_name,
method_by_name: &method_by_name,
local_names: &local_names,
has_self_param,
has_cls_param,
callees: &mut callees,
};
visitor.visit_body(&def.body);
}
CallableItem {
name_node: group.name_node,
name: group.name,
visibility: group.visibility,
is_constructor: group.is_constructor,
callees,
}
})
.collect();
Some(CallableScope { callables })
}
pub struct PythonFieldAfterMethod<'a> {
pub class_name: String,
pub name: String,
pub node: AstNode<'a>,
}
#[must_use]
pub fn collect_fields_after_methods(file: &ParsedFile) -> Vec<PythonFieldAfterMethod<'_>> {
struct FieldAfterMethodVisitor<'a> {
file: &'a ParsedFile,
out: Vec<PythonFieldAfterMethod<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for FieldAfterMethodVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
if let Stmt::ClassDef(class_def) = statement {
let class_name = class_def.name.id.to_string();
let mut seen_method = false;
for child in &class_def.body {
match child {
Stmt::FunctionDef(_) => {
seen_method = true;
}
Stmt::AnnAssign(ann) if seen_method => {
if let Expr::Name(target) = ann.target.as_ref() {
self.out.push(PythonFieldAfterMethod {
class_name: class_name.clone(),
name: target.id.to_string(),
node: AstNode::from_span(
self.file,
span_from_ruff_range(ann.range),
),
});
}
}
_ => {}
}
}
}
walk_stmt(self, statement);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = FieldAfterMethodVisitor {
file,
out: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.out
}
fn is_protocol_class(class_def: &StmtClassDef, file: &ParsedFile) -> bool {
resolved_base_class_paths(class_def, file)
.iter()
.any(|path| PROTOCOL_PATHS.contains(&path.as_str()))
}
fn is_abc_class(class_def: &StmtClassDef, file: &ParsedFile) -> bool {
let has_abc_metaclass = class_def.keywords().iter().any(|keyword| {
keyword
.arg
.as_ref()
.is_some_and(|arg| arg.id == "metaclass")
&& resolved_path_and_terminal_expr(&keyword.value, file)
.is_some_and(|name| matches!(name.path.as_str(), "ABCMeta" | "abc.ABCMeta"))
});
has_abc_metaclass
|| resolved_base_class_paths(class_def, file)
.iter()
.any(|path| ABC_PATHS.contains(&path.as_str()))
}
fn resolved_base_class_paths(class_def: &StmtClassDef, file: &ParsedFile) -> Vec<String> {
class_def
.bases()
.iter()
.filter_map(|base| resolved_base_class_path(base, file))
.collect()
}
fn resolved_base_class_path(base: &Expr, file: &ParsedFile) -> Option<String> {
let base_expr = if let Expr::Subscript(subscript) = base {
subscript.value.as_ref()
} else {
base
};
resolved_path_and_terminal_expr(base_expr, file).map(|name| name.path)
}
fn collect_self_annotated_assignments(body: &[Stmt]) -> Vec<(&StmtAnnAssign, String, &Expr)> {
struct SelfAnnVisitor<'a> {
out: Vec<(&'a StmtAnnAssign, String, &'a Expr)>,
}
impl<'a> SourceOrderVisitor<'a> for SelfAnnVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
match statement {
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
Stmt::AnnAssign(ann) => {
if let Expr::Attribute(left) = ann.target.as_ref()
&& let Expr::Name(object) = left.value.as_ref()
&& object.id == SELF_RECEIVER
{
self.out
.push((ann, left.attr.to_string(), ann.annotation.as_ref()));
}
}
_ => {}
}
walk_stmt(self, statement);
}
fn visit_expr(&mut self, _expr: &'a Expr) {}
}
let mut visitor = SelfAnnVisitor { out: Vec::new() };
visitor.visit_body(body);
visitor.out
}
const OVERLOAD_DECORATOR_PATHS: &[&str] =
&["overload", "typing.overload", "typing_extensions.overload"];
fn group_python_callables<'a, 'ast>(
scope_body: &'ast [Stmt],
is_class_scope: bool,
file: &'a ParsedFile,
) -> Vec<GroupedPythonCallable<'a, 'ast>> {
struct SeenCallable {
index: usize,
has_overload: bool,
has_non_overload: bool,
}
let mut callables: Vec<GroupedPythonCallable<'a, 'ast>> = Vec::new();
let mut seen_by_name: HashMap<String, SeenCallable> = HashMap::new();
for function_def in direct_function_definitions(scope_body) {
let name = function_def.name.id.to_string();
let decorators = extract_decorators_from_slice(&function_def.decorator_list, file);
let is_overload = decorators
.iter()
.any(|decorator| decorator.matches_any(OVERLOAD_DECORATOR_PATHS));
let is_property_accessor = decorators
.iter()
.any(|decorator| decorator.is_attribute_named(&["getter", "setter", "deleter"]));
if let Some(seen) = seen_by_name.get_mut(&name) {
let continues_overload = is_overload || (seen.has_overload && !seen.has_non_overload);
if is_property_accessor || continues_overload {
seen.has_overload |= is_overload;
seen.has_non_overload |= !is_overload;
callables[seen.index].defs.push(function_def);
continue;
}
}
let index = callables.len();
seen_by_name.insert(
name.clone(),
SeenCallable {
index,
has_overload: is_overload,
has_non_overload: !is_overload,
},
);
let visibility = python_method_visibility(&name);
let is_constructor = is_class_scope && PYTHON_CONSTRUCTOR_NAMES.contains(&name.as_str());
callables.push(GroupedPythonCallable {
name_node: AstNode::from_span(file, span_from_ruff_range(function_def.name.range)),
name,
visibility,
is_constructor,
defs: vec![function_def],
});
}
callables
}
fn python_method_visibility(name: &str) -> MethodVisibility {
let is_dunder = name.starts_with("__") && name.ends_with("__") && name.len() > 4;
if is_dunder || !name.starts_with('_') {
MethodVisibility::Public
} else {
MethodVisibility::Private
}
}
fn direct_assignment_targets(body: &[Stmt]) -> Vec<(&Expr, Option<&Expr>)> {
struct AssignmentVisitor<'b> {
out: Vec<(&'b Expr, Option<&'b Expr>)>,
}
impl<'b> SourceOrderVisitor<'b> for AssignmentVisitor<'b> {
fn visit_stmt(&mut self, statement: &'b Stmt) {
match statement {
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
Stmt::Assign(assign) => {
self.out
.extend(assign.targets.iter().map(|target| (target, None)));
}
Stmt::AnnAssign(annotated) => {
self.out
.push((&annotated.target, Some(&annotated.annotation)));
}
_ => {}
}
walk_stmt(self, statement);
}
fn visit_expr(&mut self, _expr: &'b Expr) {}
}
let mut visitor = AssignmentVisitor { out: Vec::new() };
visitor.visit_body(body);
visitor.out
}
fn unchanged_self_copies(
body: &[Stmt],
receiver: &str,
parameter_names: &HashSet<&str>,
) -> HashSet<String> {
struct CopyVisitor<'b> {
receiver: &'b str,
rebound_locals: HashSet<&'b str>,
plain_copies: HashSet<&'b str>,
transformed_attrs: HashSet<&'b str>,
}
impl<'b> CopyVisitor<'b> {
fn record_assignment(&mut self, target: &'b Expr, value: Option<&'b Expr>) {
for unpacked in unpacked_targets(target) {
if let Expr::Name(name) = unpacked {
self.rebound_locals.insert(name.id.as_str());
} else if let Expr::Attribute(attribute) = unpacked
&& matches!(attribute.value.as_ref(), Expr::Name(object) if object.id == self.receiver)
{
let attr = attribute.attr.id.as_str();
let is_plain_copy = matches!(target, Expr::Attribute(_))
&& matches!(value, Some(Expr::Name(rhs)) if rhs.id == attr);
if is_plain_copy {
self.plain_copies.insert(attr);
} else if value.is_some() {
self.transformed_attrs.insert(attr);
}
}
}
}
}
impl<'b> SourceOrderVisitor<'b> for CopyVisitor<'b> {
fn visit_stmt(&mut self, statement: &'b Stmt) {
match statement {
Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return,
Stmt::Assign(assign) => {
for target in &assign.targets {
self.record_assignment(target, Some(&assign.value));
}
}
Stmt::AnnAssign(annotated) => {
self.record_assignment(&annotated.target, annotated.value.as_deref());
}
Stmt::AugAssign(aug_assign) => {
self.record_assignment(&aug_assign.target, Some(&aug_assign.value));
}
_ => {}
}
walk_stmt(self, statement);
}
fn visit_expr(&mut self, _expr: &'b Expr) {}
}
let mut visitor = CopyVisitor {
receiver,
rebound_locals: HashSet::new(),
plain_copies: HashSet::new(),
transformed_attrs: HashSet::new(),
};
visitor.visit_body(body);
visitor
.plain_copies
.into_iter()
.filter(|name| {
parameter_names.contains(name)
&& !visitor.rebound_locals.contains(name)
&& !visitor.transformed_attrs.contains(name)
})
.map(str::to_owned)
.collect()
}
fn unpacked_targets(target: &Expr) -> Vec<&Expr> {
match target {
Expr::Tuple(tuple) => tuple.elts.iter().flat_map(unpacked_targets).collect(),
Expr::List(list) => list.elts.iter().flat_map(unpacked_targets).collect(),
Expr::Starred(starred) => unpacked_targets(&starred.value),
_ => vec![target],
}
}
fn is_init_var_annotation(annotation: &Expr, file: &ParsedFile) -> bool {
let base = if let Expr::Subscript(subscript) = annotation {
subscript.value.as_ref()
} else {
annotation
};
resolved_path_and_terminal_expr(base, file)
.is_some_and(|name| INIT_VAR_PATHS.contains(&name.path.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::Language;
#[test]
fn methods_are_direct_definitions_in_source_order() {
let source = "class Outer:\n def b(self): pass\n x = 1\n def a(self):\n def nested(): pass\n class Inner:\n def inner(self): pass\n";
let file = ParsedFile::new(source, Language::Python);
let method_names: Vec<String> = extract_classes(&file)[0]
.methods()
.into_iter()
.map(|method| method.name)
.collect();
assert_eq!(method_names, ["b", "a"]);
}
fn attribute_facts(source: &str) -> Vec<(String, Option<String>, bool, bool)> {
let file = ParsedFile::new(source, Language::Python);
extract_classes(&file)[0]
.attribute_definitions()
.into_iter()
.map(|definition| {
(
definition.name,
definition
.annotation
.map(|annotation| annotation.text().into_owned()),
definition.is_in_class_body,
definition.is_init_var,
)
})
.collect()
}
fn owned_facts(
facts: &[(&str, Option<&str>, bool, bool)],
) -> Vec<(String, Option<String>, bool, bool)> {
facts
.iter()
.map(|&(name, annotation, is_in_class_body, is_init_var)| {
(
name.to_owned(),
annotation.map(str::to_owned),
is_in_class_body,
is_init_var,
)
})
.collect()
}
#[test]
fn attribute_definitions_read_class_body_targets() {
let source = "from dataclasses import InitVar\nclass C:\n a = b = 1\n c, (d, *e) = f()\n g: int\n h: InitVar[int] = 0\n a: str = ''\n if x:\n hidden = 1\n";
assert_eq!(
attribute_facts(source),
owned_facts(&[
("a", None, true, false),
("b", None, true, false),
("c", None, true, false),
("d", None, true, false),
("e", None, true, false),
("g", Some("int"), true, false),
("h", Some("InitVar[int]"), true, true),
])
);
}
#[test]
fn attribute_definitions_read_initializers_without_nested_scopes() {
let source = "class C:\n def __init__(self, host, port):\n self.host = host\n self.port: int = int(port)\n self.a, self.b = host, port\n other.x = 1\n if host:\n self.flag = True\n def bind():\n self.leak = 1\n f = lambda: setattr(self, 'y', 1)\n def __post_init__(self, host):\n self.later = host\n def connect(self):\n self.socket = 1\n";
assert_eq!(
attribute_facts(source),
owned_facts(&[
("host", None, false, false),
("port", Some("int"), false, false),
("a", None, false, false),
("b", None, false, false),
("flag", None, false, false),
("later", None, false, false),
])
);
}
#[test]
fn attribute_definitions_keep_the_first_definition_in_source_order() {
let source = "class C:\n def __init__(self, x):\n self.x = 0\n self.x: int = x\n x: int\n";
assert_eq!(
attribute_facts(source),
owned_facts(&[("x", None, false, false)])
);
}
#[test]
fn constructor_skips_overloads_and_nested_definitions() {
let source = "from typing import overload\nclass C:\n @overload\n def __init__(self, x: int) -> None: ...\n def __init__(self, x, y=0):\n pass\n class Inner:\n def __init__(self): pass\n";
let file = ParsedFile::new(source, Language::Python);
let classes = extract_classes(&file);
let parameter_names: Option<Vec<String>> = classes[0].constructor().map(|constructor| {
constructor
.parameters
.into_iter()
.map(|parameter| parameter.name)
.collect()
});
assert_eq!(
parameter_names,
Some(vec!["self".to_owned(), "x".to_owned(), "y".to_owned()])
);
assert!(
extract_classes(&ParsedFile::new("class D:\n x = 1\n", Language::Python))[0]
.constructor()
.is_none()
);
}
#[test]
fn is_enum_resolves_direct_enum_bases() {
let source = "import enum\nfrom enum import IntEnum as Code\nclass A(enum.Enum): pass\nclass B(Code): pass\nclass C(A): pass\nclass D: pass\n";
let file = ParsedFile::new(source, Language::Python);
let enum_names: Vec<String> = extract_classes(&file)
.into_iter()
.filter(PythonClassInfo::is_enum)
.map(|class| class.name)
.collect();
assert_eq!(enum_names, ["A", "B"]);
}
#[test]
fn defines_function_reads_direct_definitions_only() {
let source = "class C:\n @property\n def area(self): pass\n def m(self):\n def inner(): pass\n";
let file = ParsedFile::new(source, Language::Python);
let class = &extract_classes(&file)[0];
let defined: Vec<&str> = ["area", "m", "inner"]
.into_iter()
.filter(|name| class.defines_function(name))
.collect();
assert_eq!(defined, ["area", "m"]);
}
#[test]
fn base_binds_to_last_class_of_that_name_defined_before() {
let source = "class Base: pass\nclass Base: pass\nclass Sub(Base, mod.Base, Later): pass\nclass Later: pass\n";
let file = ParsedFile::new(source, Language::Python);
assert_eq!(local_base_classes(&extract_classes(&file), 2), [1]);
}
#[test]
fn ancestors_are_depth_first_left_to_right_without_repeats() {
let source = "class Root: pass\nclass Left(Root): pass\nclass Right(Root): pass\nclass Sub(Left, Right): pass\n";
let file = ParsedFile::new(source, Language::Python);
assert_eq!(local_ancestors(&extract_classes(&file), 3), [1, 0, 2]);
}
}