[ty] Improve UX for [duplicate-base] diagnostics (#17914)

This commit is contained in:
Alex Waygood 2025-05-07 16:27:37 +01:00 committed by GitHub
parent ad658f4d68
commit 2ec0d7e072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 594 additions and 44 deletions

View file

@ -1799,26 +1799,32 @@ impl<'db> ClassLiteral<'db> {
}
}
/// Returns the [`Span`] of the class's "header": the class name
/// Returns a [`Span`] with the range of the class's header.
///
/// See [`Self::header_range`] for more details.
pub(super) fn header_span(self, db: &'db dyn Db) -> Span {
Span::from(self.file(db)).with_range(self.header_range(db))
}
/// Returns the range of the class's "header": the class name
/// and any arguments passed to the `class` statement. E.g.
///
/// ```ignore
/// class Foo(Bar, metaclass=Baz): ...
/// ^^^^^^^^^^^^^^^^^^^^^^^
/// ```
pub(super) fn header_span(self, db: &'db dyn Db) -> Span {
pub(super) fn header_range(self, db: &'db dyn Db) -> TextRange {
let class_scope = self.body_scope(db);
let class_node = class_scope.node(db).expect_class();
let class_name = &class_node.name;
let header_range = TextRange::new(
TextRange::new(
class_name.start(),
class_node
.arguments
.as_deref()
.map(Ranged::end)
.unwrap_or_else(|| class_name.end()),
);
Span::from(class_scope.file(db)).with_range(header_range)
)
}
}

View file

@ -1,4 +1,5 @@
use super::context::InferContext;
use super::mro::DuplicateBaseError;
use super::{ClassLiteral, KnownClass};
use crate::db::Db;
use crate::declare_lint;
@ -1595,3 +1596,51 @@ pub(crate) fn report_attempted_protocol_instantiation(
);
diagnostic.sub(class_def_diagnostic);
}
pub(crate) fn report_duplicate_bases(
context: &InferContext,
class: ClassLiteral,
duplicate_base_error: &DuplicateBaseError,
bases_list: &[ast::Expr],
) {
let db = context.db();
let Some(builder) = context.report_lint(&DUPLICATE_BASE, class.header_range(db)) else {
return;
};
let DuplicateBaseError {
duplicate_base,
first_index,
later_indices,
} = duplicate_base_error;
let duplicate_name = duplicate_base.name(db);
let mut diagnostic =
builder.into_diagnostic(format_args!("Duplicate base class `{duplicate_name}`",));
let mut sub_diagnostic = SubDiagnostic::new(
Severity::Info,
format_args!(
"The definition of class `{}` will raise `TypeError` at runtime",
class.name(db)
),
);
sub_diagnostic.annotate(
Annotation::secondary(
Span::from(context.file()).with_range(bases_list[*first_index].range()),
)
.message(format_args!(
"Class `{duplicate_name}` first included in bases list here"
)),
);
for index in later_indices {
sub_diagnostic.annotate(
Annotation::primary(Span::from(context.file()).with_range(bases_list[*index].range()))
.message(format_args!("Class `{duplicate_name}` later repeated here")),
);
}
diagnostic.sub(sub_diagnostic);
}

View file

@ -72,9 +72,9 @@ use crate::types::diagnostic::{
report_invalid_attribute_assignment, report_invalid_generator_function_return_type,
report_invalid_return_type, report_possibly_unbound_attribute, TypeCheckDiagnostics,
CALL_NON_CALLABLE, CALL_POSSIBLY_UNBOUND_METHOD, CONFLICTING_DECLARATIONS,
CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, DUPLICATE_BASE,
INCONSISTENT_MRO, INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS,
INVALID_BASE, INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_LEGACY_TYPE_VARIABLE,
CONFLICTING_METACLASS, CYCLIC_CLASS_DEFINITION, DIVISION_BY_ZERO, INCONSISTENT_MRO,
INVALID_ARGUMENT_TYPE, INVALID_ASSIGNMENT, INVALID_ATTRIBUTE_ACCESS, INVALID_BASE,
INVALID_DECLARATION, INVALID_GENERIC_CLASS, INVALID_LEGACY_TYPE_VARIABLE,
INVALID_PARAMETER_DEFAULT, INVALID_TYPE_FORM, INVALID_TYPE_VARIABLE_CONSTRAINTS,
POSSIBLY_UNBOUND_IMPORT, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_IMPORT,
UNSUPPORTED_OPERATOR,
@ -99,9 +99,10 @@ use crate::{Db, FxOrderSet};
use super::context::{InNoTypeCheck, InferContext};
use super::diagnostic::{
report_attempted_protocol_instantiation, report_bad_argument_to_get_protocol_members,
report_index_out_of_bounds, report_invalid_exception_caught, report_invalid_exception_cause,
report_invalid_exception_raised, report_invalid_type_checking_constant,
report_non_subscriptable, report_possibly_unresolved_reference,
report_duplicate_bases, report_index_out_of_bounds, report_invalid_exception_caught,
report_invalid_exception_cause, report_invalid_exception_raised,
report_invalid_type_checking_constant, report_non_subscriptable,
report_possibly_unresolved_reference,
report_runtime_check_against_non_runtime_checkable_protocol, report_slice_step_size_zero,
report_unresolved_reference, INVALID_METACLASS, INVALID_OVERLOAD, INVALID_PROTOCOL,
REDUNDANT_CAST, STATIC_ASSERT_ERROR, SUBCLASS_OF_FINAL_CLASS, TYPE_ASSERTION_FAILURE,
@ -855,17 +856,8 @@ impl<'db> TypeInferenceBuilder<'db> {
match mro_error.reason() {
MroErrorKind::DuplicateBases(duplicates) => {
let base_nodes = class_node.bases();
for (index, duplicate) in duplicates {
let Some(builder) = self
.context
.report_lint(&DUPLICATE_BASE, &base_nodes[*index])
else {
continue;
};
builder.into_diagnostic(format_args!(
"Duplicate base class `{}`",
duplicate.name(self.db())
));
for duplicate in duplicates {
report_duplicate_bases(&self.context, class, duplicate, base_nodes);
}
}
MroErrorKind::InvalidBases(bases) => {

View file

@ -1,7 +1,7 @@
use std::collections::VecDeque;
use std::ops::Deref;
use rustc_hash::FxHashSet;
use rustc_hash::FxHashMap;
use crate::types::class_base::ClassBase;
use crate::types::generics::Specialization;
@ -154,25 +154,40 @@ impl<'db> Mro<'db> {
);
c3_merge(seqs).ok_or_else(|| {
let mut seen_bases = FxHashSet::default();
let mut duplicate_bases = vec![];
for (index, base) in valid_bases
.iter()
.enumerate()
.filter_map(|(index, base)| Some((index, base.into_class()?)))
{
if !seen_bases.insert(base) {
let (base_class_literal, _) = base.class_literal(db);
duplicate_bases.push((index, base_class_literal));
let duplicate_bases: Box<[DuplicateBaseError<'db>]> = {
let mut base_to_indices: FxHashMap<ClassType<'db>, Vec<usize>> =
FxHashMap::default();
for (index, base) in valid_bases
.iter()
.enumerate()
.filter_map(|(index, base)| Some((index, base.into_class()?)))
{
base_to_indices.entry(base).or_default().push(index);
}
}
base_to_indices
.iter()
.filter_map(|(base, indices)| {
let (first_index, later_indices) = indices.split_first()?;
if later_indices.is_empty() {
return None;
}
Some(DuplicateBaseError {
duplicate_base: base.class_literal(db).0,
first_index: *first_index,
later_indices: later_indices.iter().copied().collect(),
})
})
.collect()
};
if duplicate_bases.is_empty() {
MroErrorKind::UnresolvableMro {
bases_list: valid_bases.into_boxed_slice(),
}
} else {
MroErrorKind::DuplicateBases(duplicate_bases.into_boxed_slice())
MroErrorKind::DuplicateBases(duplicate_bases)
}
})
}
@ -328,12 +343,8 @@ pub(super) enum MroErrorKind<'db> {
InvalidBases(Box<[(usize, Type<'db>)]>),
/// The class has one or more duplicate bases.
///
/// This variant records the indices and [`ClassLiteral`]s
/// of the duplicate bases. The indices are the indices of nodes
/// in the bases list of the class's [`StmtClassDef`](ruff_python_ast::StmtClassDef) node.
/// Each index is the index of a node representing a duplicate base.
DuplicateBases(Box<[(usize, ClassLiteral<'db>)]>),
/// See [`DuplicateBaseError`] for more details.
DuplicateBases(Box<[DuplicateBaseError<'db>]>),
/// The MRO is otherwise unresolvable through the C3-merge algorithm.
///
@ -350,6 +361,17 @@ impl<'db> MroErrorKind<'db> {
}
}
/// Error recording the fact that a class definition was found to have duplicate bases.
#[derive(Debug, PartialEq, Eq, salsa::Update)]
pub(super) struct DuplicateBaseError<'db> {
/// The base that is duplicated in the class's bases list.
pub(super) duplicate_base: ClassLiteral<'db>,
/// The index of the first occurrence of the base in the class's bases list.
pub(super) first_index: usize,
/// The indices of the base's later occurrences in the class's bases list.
pub(super) later_indices: Box<[usize]>,
}
/// Implementation of the [C3-merge algorithm] for calculating a Python class's
/// [method resolution order].
///