From eddb9ad38dc77b82f577626a1ca62f0382777094 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Tue, 25 Nov 2025 11:54:42 +0100 Subject: [PATCH] [ty] Refactor `CheckSuppressionContext` to use `DiagnosticGuard` (#21587) --- crates/ty/docs/rules.md | 6 +- crates/ty_python_semantic/src/diagnostic.rs | 136 +++++++++++++- crates/ty_python_semantic/src/suppression.rs | 177 ++++++++++++------ crates/ty_python_semantic/src/types.rs | 4 +- .../ty_python_semantic/src/types/context.rs | 122 +----------- 5 files changed, 269 insertions(+), 176 deletions(-) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 34e4227f11..0f4cc130ae 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -2120,7 +2120,7 @@ old_func() # emits [deprecated] diagnostic Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2151,7 +2151,7 @@ a = 20 / 0 # ty: ignore[division-by-zero] Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2536,7 +2536,7 @@ print(x) # NameError: name 'x' is not defined Default level: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/src/diagnostic.rs b/crates/ty_python_semantic/src/diagnostic.rs index a54e7b9083..b8ff2861ea 100644 --- a/crates/ty_python_semantic/src/diagnostic.rs +++ b/crates/ty_python_semantic/src/diagnostic.rs @@ -1,5 +1,11 @@ -use crate::{Db, Program, PythonVersionWithSource}; -use ruff_db::diagnostic::{Annotation, Diagnostic, SubDiagnostic, SubDiagnosticSeverity}; +use crate::{ + Db, Program, PythonVersionWithSource, lint::lint_documentation_url, types::TypeCheckDiagnostics, +}; +use ruff_db::{ + diagnostic::{Annotation, Diagnostic, DiagnosticId, SubDiagnostic, SubDiagnosticSeverity}, + files::File, +}; +use std::cell::RefCell; use std::fmt::Write; /// Suggest a name from `existing_names` that is similar to `wrong_name`. @@ -144,3 +150,129 @@ where buffer } + +/// An abstraction for mutating a diagnostic. +/// +/// Callers likely should use `LintDiagnosticGuard` via +/// `InferContext::report_lint` instead. This guard is only intended for use +/// with non-lint diagnostics or non-type checking diagnostics. It is fundamentally lower level and easier to +/// get things wrong by using it. +/// +/// Unlike `LintDiagnosticGuard`, this API does not guarantee that the +/// constructed `Diagnostic` not only has a primary annotation, but its +/// associated file is equivalent to the file being type checked. As a result, +/// if either is violated, then the `Drop` impl on `DiagnosticGuard` will +/// panic. +pub(super) struct DiagnosticGuard<'sink> { + /// The file of the primary span (to which file does this diagnostic belong). + file: File, + + /// The target where to emit the diagnostic to. + /// + /// We use a [`RefCell`] here over a `&mut TypeCheckDiagnostics` to ensure the fact that + /// `InferContext` (and other contexts with diagnostics) use a [`RefCell`] internally + /// remains abstracted away. Specifically, we want to ensure that calling `report_lint` on + /// `InferContext` twice doesn't result in a panic: + /// + /// ```ignore + /// let diag1 = context.report_lint(...); + /// + /// // would panic if using a `&mut TypeCheckDiagnostics` + /// // because of a second mutable borrow. + /// let diag2 = context.report_lint(...); + /// ``` + sink: &'sink RefCell, + + /// The diagnostic that we want to report. + /// + /// This is always `Some` until the `Drop` impl. + diag: Option, +} + +impl<'sink> DiagnosticGuard<'sink> { + pub(crate) fn new( + file: File, + sink: &'sink std::cell::RefCell, + diag: Diagnostic, + ) -> Self { + Self { + file, + sink, + diag: Some(diag), + } + } +} + +impl std::ops::Deref for DiagnosticGuard<'_> { + type Target = Diagnostic; + + fn deref(&self) -> &Diagnostic { + // OK because `self.diag` is only `None` within `Drop`. + self.diag.as_ref().unwrap() + } +} + +/// Return a mutable borrow of the diagnostic in this guard. +/// +/// Callers may mutate the diagnostic to add new sub-diagnostics +/// or annotations. +/// +/// The diagnostic is added to the typing context, if appropriate, +/// when this guard is dropped. +impl std::ops::DerefMut for DiagnosticGuard<'_> { + fn deref_mut(&mut self) -> &mut Diagnostic { + // OK because `self.diag` is only `None` within `Drop`. + self.diag.as_mut().unwrap() + } +} + +/// Finishes use of this guard. +/// +/// This will add the diagnostic to the typing context if appropriate. +/// +/// # Panics +/// +/// This panics when the underlying diagnostic lacks a primary +/// annotation, or if it has one and its file doesn't match the file +/// being type checked. +impl Drop for DiagnosticGuard<'_> { + fn drop(&mut self) { + if std::thread::panicking() { + // Don't submit diagnostics when panicking because they might be incomplete. + return; + } + + // OK because the only way `self.diag` is `None` + // is via this impl, which can only run at most + // once. + let mut diag = self.diag.take().unwrap(); + + let Some(ann) = diag.primary_annotation() else { + panic!( + "All diagnostics reported by `InferContext` must have a \ + primary annotation, but diagnostic {id} does not", + id = diag.id(), + ); + }; + + let expected_file = self.file; + let got_file = ann.get_span().expect_ty_file(); + assert_eq!( + expected_file, + got_file, + "All diagnostics reported by `InferContext` must have a \ + primary annotation whose file matches the file of the \ + current typing context, but diagnostic {id} has file \ + {got_file:?} and we expected {expected_file:?}", + id = diag.id(), + ); + + if let DiagnosticId::Lint(lint_name) = diag.id() + && diag.documentation_url().is_none() + { + diag.set_documentation_url(Some(lint_documentation_url(lint_name))); + } + + self.sink.borrow_mut().push(diag); + } +} diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 6148e9744f..e337200f94 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -1,7 +1,10 @@ +use crate::diagnostic::DiagnosticGuard; use crate::lint::{GetLintError, Level, LintMetadata, LintRegistry, LintStatus}; use crate::types::TypeCheckDiagnostics; use crate::{Db, declare_lint, lint::LintId}; -use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, Span}; +use ruff_db::diagnostic::{ + Annotation, Diagnostic, DiagnosticId, IntoDiagnosticMessage, Severity, Span, +}; use ruff_db::{files::File, parsed::parsed_module, source::source_text}; use ruff_python_parser::TokenKind; use ruff_python_trivia::Cursor; @@ -133,12 +136,18 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions { builder.finish() } -pub(crate) fn check_suppressions(db: &dyn Db, file: File, diagnostics: &mut TypeCheckDiagnostics) { +pub(crate) fn check_suppressions( + db: &dyn Db, + file: File, + diagnostics: TypeCheckDiagnostics, +) -> Vec { let mut context = CheckSuppressionsContext::new(db, file, diagnostics); check_unknown_rule(&mut context); check_invalid_suppression(&mut context); check_unused_suppressions(&mut context); + + context.diagnostics.into_inner().into_diagnostics() } /// Checks for `ty: ignore` comments that reference unknown rules. @@ -148,7 +157,9 @@ fn check_unknown_rule(context: &mut CheckSuppressionsContext) { } for unknown in &context.suppressions.unknown { - context.report_lint(&IGNORE_COMMENT_UNKNOWN_RULE, unknown.range, &unknown.reason); + if let Some(diag) = context.report_lint(&IGNORE_COMMENT_UNKNOWN_RULE, unknown.range) { + diag.into_diagnostic(&unknown.reason); + } } } @@ -158,15 +169,13 @@ fn check_invalid_suppression(context: &mut CheckSuppressionsContext) { } for invalid in &context.suppressions.invalid { - context.report_lint( - &INVALID_IGNORE_COMMENT, - invalid.error.range, - format_args!( + if let Some(diag) = context.report_lint(&INVALID_IGNORE_COMMENT, invalid.error.range) { + diag.into_diagnostic(format_args!( "Invalid `{kind}` comment: {reason}", kind = invalid.kind, reason = &invalid.error - ), - ); + )); + } } } @@ -179,17 +188,19 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) { return; } + let diagnostics = context.diagnostics.get_mut(); + let all = context.suppressions; let mut unused = Vec::with_capacity( all.file .len() .saturating_add(all.line.len()) - .saturating_sub(context.diagnostics.used_len()), + .saturating_sub(diagnostics.used_len()), ); // Collect all suppressions that are unused after type-checking. for suppression in all { - if context.diagnostics.is_used(suppression.id()) { + if diagnostics.is_used(suppression.id()) { continue; } @@ -203,7 +214,7 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) { // A `unused-ignore-comment` suppression can't ignore itself. // It can only ignore other suppressions. if unused_suppression.id() != suppression.id() { - context.diagnostics.mark_used(unused_suppression.id()); + diagnostics.mark_used(unused_suppression.id()); continue; } } @@ -211,37 +222,52 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) { unused.push(suppression); } - for suppression in unused { + let unused = unused.iter().filter(|suppression| { // This looks silly but it's necessary to check again if a `unused-ignore-comment` is indeed unused // in case the "unused" directive comes after it: // ```py // a = 10 / 2 # ty: ignore[unused-ignore-comment, division-by-zero] // ``` - if context.diagnostics.is_used(suppression.id()) { - continue; - } + !context.is_suppression_used(suppression.id()) + }); + for suppression in unused { match suppression.target { - SuppressionTarget::All => context.report_unchecked( - &UNUSED_IGNORE_COMMENT, - suppression.range, - format_args!("Unused blanket `{}` directive", suppression.kind), - ), - SuppressionTarget::Lint(lint) => context.report_unchecked( - &UNUSED_IGNORE_COMMENT, - suppression.range, - format_args!( + SuppressionTarget::All => { + let Some(diag) = + context.report_unchecked(&UNUSED_IGNORE_COMMENT, suppression.range) + else { + continue; + }; + diag.into_diagnostic(format_args!( + "Unused blanket `{}` directive", + suppression.kind + )) + } + SuppressionTarget::Lint(lint) => { + let Some(diag) = + context.report_unchecked(&UNUSED_IGNORE_COMMENT, suppression.range) + else { + continue; + }; + diag.into_diagnostic(format_args!( "Unused `{kind}` directive: '{code}'", kind = suppression.kind, code = lint.name() - ), - ), - SuppressionTarget::Empty => context.report_unchecked( - &UNUSED_IGNORE_COMMENT, - suppression.range, - format_args!("Unused `{kind}` without a code", kind = suppression.kind), - ), - } + )) + } + SuppressionTarget::Empty => { + let Some(diag) = + context.report_unchecked(&UNUSED_IGNORE_COMMENT, suppression.range) + else { + continue; + }; + diag.into_diagnostic(format_args!( + "Unused `{kind}` without a code", + kind = suppression.kind + )) + } + }; } } @@ -249,17 +275,17 @@ struct CheckSuppressionsContext<'a> { db: &'a dyn Db, file: File, suppressions: &'a Suppressions, - diagnostics: &'a mut TypeCheckDiagnostics, + diagnostics: std::cell::RefCell, } impl<'a> CheckSuppressionsContext<'a> { - fn new(db: &'a dyn Db, file: File, diagnostics: &'a mut TypeCheckDiagnostics) -> Self { + fn new(db: &'a dyn Db, file: File, diagnostics: TypeCheckDiagnostics) -> Self { let suppressions = suppressions(db, file); Self { db, file, suppressions, - diagnostics, + diagnostics: diagnostics.into(), } } @@ -270,38 +296,77 @@ impl<'a> CheckSuppressionsContext<'a> { .is_enabled(LintId::of(lint)) } - fn report_lint( - &mut self, + fn is_suppression_used(&self, id: FileSuppressionId) -> bool { + self.diagnostics.borrow().is_used(id) + } + + fn report_lint<'ctx>( + &'ctx self, lint: &'static LintMetadata, range: TextRange, - message: impl IntoDiagnosticMessage, - ) { + ) -> Option> { if let Some(suppression) = self.suppressions.find_suppression(range, LintId::of(lint)) { - self.diagnostics.mark_used(suppression.id()); - return; + self.diagnostics.borrow_mut().mark_used(suppression.id()); + return None; } - self.report_unchecked(lint, range, message); + self.report_unchecked(lint, range) } /// Reports a diagnostic without checking if the lint at the given range is suppressed or marking /// the suppression as used. - fn report_unchecked( - &mut self, + fn report_unchecked<'ctx>( + &'ctx self, lint: &'static LintMetadata, range: TextRange, - message: impl IntoDiagnosticMessage, - ) { - let Some(severity) = self.db.rule_selection(self.file).severity(LintId::of(lint)) else { - return; - }; + ) -> Option> { + SuppressionDiagnosticGuardBuilder::new(self, lint, range) + } +} - let id = DiagnosticId::Lint(lint.name()); - let mut diag = Diagnostic::new(id, severity, message); - diag.set_documentation_url(Some(lint.documentation_url())); - let span = Span::from(self.file).with_range(range); - diag.annotate(Annotation::primary(span)); - self.diagnostics.push(diag); +/// A builder for constructing a diagnostic guard. +/// +/// This type exists to separate the phases of "check if a diagnostic should +/// be reported" and "build the actual diagnostic." +pub(crate) struct SuppressionDiagnosticGuardBuilder<'ctx, 'db> { + ctx: &'ctx CheckSuppressionsContext<'db>, + id: DiagnosticId, + range: TextRange, + severity: Severity, +} + +impl<'ctx, 'db> SuppressionDiagnosticGuardBuilder<'ctx, 'db> { + fn new( + ctx: &'ctx CheckSuppressionsContext<'db>, + lint: &'static LintMetadata, + range: TextRange, + ) -> Option { + let severity = ctx.db.rule_selection(ctx.file).severity(LintId::of(lint))?; + + Some(Self { + ctx, + id: DiagnosticId::Lint(lint.name()), + severity, + range, + }) + } + + /// Create a new guard. + /// + /// This initializes a new diagnostic using the given message along with + /// the ID and severity used to create this builder. + /// + /// The diagnostic can be further mutated on the guard via its `DerefMut` + /// impl to `Diagnostic`. + pub(crate) fn into_diagnostic( + self, + message: impl IntoDiagnosticMessage, + ) -> DiagnosticGuard<'ctx> { + let mut diag = Diagnostic::new(self.id, self.severity, message); + + let primary_span = Span::from(self.ctx.file).with_range(self.range); + diag.annotate(Annotation::primary(primary_span)); + DiagnosticGuard::new(self.ctx.file, &self.ctx.diagnostics, diag) } } diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index f90810d5e3..b90ac4b01a 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -142,7 +142,7 @@ pub fn check_types(db: &dyn Db, file: File) -> Vec { .map(|error| Diagnostic::invalid_syntax(file, error, error)), ); - check_suppressions(db, file, &mut diagnostics); + let diagnostics = check_suppressions(db, file, diagnostics); let elapsed = start.elapsed(); if elapsed >= Duration::from_millis(100) { @@ -152,7 +152,7 @@ pub fn check_types(db: &dyn Db, file: File) -> Vec { ); } - diagnostics.into_diagnostics() + diagnostics } /// Infer the type of a binding. diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index 6ef5afcdf5..7eb84e0d01 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -11,7 +11,8 @@ use ruff_text_size::{Ranged, TextRange}; use super::{Type, TypeCheckDiagnostics, binding_type}; -use crate::lint::{LintSource, lint_documentation_url}; +use crate::diagnostic::DiagnosticGuard; +use crate::lint::LintSource; use crate::semantic_index::scope::ScopeId; use crate::semantic_index::semantic_index; use crate::types::function::FunctionDecorators; @@ -398,7 +399,7 @@ pub(super) struct LintDiagnosticGuardBuilder<'db, 'ctx> { id: LintId, severity: Severity, source: LintSource, - primary_span: Span, + primary_range: TextRange, } impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { @@ -452,13 +453,12 @@ impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { return None; } - let primary_span = Span::from(ctx.file()).with_range(range); Some(LintDiagnosticGuardBuilder { ctx, id: lint_id, severity, source, - primary_span, + primary_range: range, }) } @@ -483,7 +483,8 @@ impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { // the optional message can be added later. We could accept it here // in this `build` method, but we already accept the main diagnostic // message. So the messages are likely to be quite confusable. - diag.annotate(Annotation::primary(self.primary_span.clone())); + let primary_span = Span::from(self.ctx.file()).with_range(self.primary_range); + diag.annotate(Annotation::primary(primary_span)); LintDiagnosticGuard { ctx: self.ctx, source: self.source, @@ -492,101 +493,6 @@ impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> { } } -/// An abstraction for mutating a diagnostic. -/// -/// Callers can build this guard by starting with -/// `InferContext::report_diagnostic`. -/// -/// Callers likely should use `LintDiagnosticGuard` via -/// `InferContext::report_lint` instead. This guard is only intended for use -/// with non-lint diagnostics. It is fundamentally lower level and easier to -/// get things wrong by using it. -/// -/// Unlike `LintDiagnosticGuard`, this API does not guarantee that the -/// constructed `Diagnostic` not only has a primary annotation, but its -/// associated file is equivalent to the file being type checked. As a result, -/// if either is violated, then the `Drop` impl on `DiagnosticGuard` will -/// panic. -pub(super) struct DiagnosticGuard<'db, 'ctx> { - ctx: &'ctx InferContext<'db, 'ctx>, - /// The diagnostic that we want to report. - /// - /// This is always `Some` until the `Drop` impl. - diag: Option, -} - -impl std::ops::Deref for DiagnosticGuard<'_, '_> { - type Target = Diagnostic; - - fn deref(&self) -> &Diagnostic { - // OK because `self.diag` is only `None` within `Drop`. - self.diag.as_ref().unwrap() - } -} - -/// Return a mutable borrow of the diagnostic in this guard. -/// -/// Callers may mutate the diagnostic to add new sub-diagnostics -/// or annotations. -/// -/// The diagnostic is added to the typing context, if appropriate, -/// when this guard is dropped. -impl std::ops::DerefMut for DiagnosticGuard<'_, '_> { - fn deref_mut(&mut self) -> &mut Diagnostic { - // OK because `self.diag` is only `None` within `Drop`. - self.diag.as_mut().unwrap() - } -} - -/// Finishes use of this guard. -/// -/// This will add the diagnostic to the typing context if appropriate. -/// -/// # Panics -/// -/// This panics when the underlying diagnostic lacks a primary -/// annotation, or if it has one and its file doesn't match the file -/// being type checked. -impl Drop for DiagnosticGuard<'_, '_> { - fn drop(&mut self) { - if std::thread::panicking() { - // Don't submit diagnostics when panicking because they might be incomplete. - return; - } - - // OK because the only way `self.diag` is `None` - // is via this impl, which can only run at most - // once. - let diag = self.diag.take().unwrap(); - - if std::thread::panicking() { - // Don't submit diagnostics when panicking because they might be incomplete. - return; - } - - let Some(ann) = diag.primary_annotation() else { - panic!( - "All diagnostics reported by `InferContext` must have a \ - primary annotation, but diagnostic {id} does not", - id = diag.id(), - ); - }; - - let expected_file = self.ctx.file(); - let got_file = ann.get_span().expect_ty_file(); - assert_eq!( - expected_file, - got_file, - "All diagnostics reported by `InferContext` must have a \ - primary annotation whose file matches the file of the \ - current typing context, but diagnostic {id} has file \ - {got_file:?} and we expected {expected_file:?}", - id = diag.id(), - ); - self.ctx.diagnostics.borrow_mut().push(diag); - } -} - /// A builder for constructing a diagnostic guard. /// /// This type exists to separate the phases of "check if a diagnostic should @@ -625,19 +531,9 @@ impl<'db, 'ctx> DiagnosticGuardBuilder<'db, 'ctx> { /// /// The diagnostic can be further mutated on the guard via its `DerefMut` /// impl to `Diagnostic`. - pub(super) fn into_diagnostic( - self, - message: impl std::fmt::Display, - ) -> DiagnosticGuard<'db, 'ctx> { - let mut diag = Diagnostic::new(self.id, self.severity, message); + pub(super) fn into_diagnostic(self, message: impl std::fmt::Display) -> DiagnosticGuard<'ctx> { + let diag = Diagnostic::new(self.id, self.severity, message); - if let DiagnosticId::Lint(lint_name) = diag.id() { - diag.set_documentation_url(Some(lint_documentation_url(lint_name))); - } - - DiagnosticGuard { - ctx: self.ctx, - diag: Some(diag), - } + DiagnosticGuard::new(self.ctx.file, &self.ctx.diagnostics, diag) } }