diff --git a/Cargo.lock b/Cargo.lock index dd9f1f40f6..c7acff6053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4332,8 +4332,10 @@ dependencies = [ "rayon", "regex", "ruff_db", + "ruff_diagnostics", "ruff_python_ast", "ruff_python_trivia", + "ruff_text_size", "salsa", "tempfile", "toml", @@ -4428,6 +4430,7 @@ dependencies = [ "regex-automata", "ruff_cache", "ruff_db", + "ruff_diagnostics", "ruff_macros", "ruff_memory_usage", "ruff_options_metadata", diff --git a/crates/ruff_db/src/source.rs b/crates/ruff_db/src/source.rs index e7786a3511..9373708c4c 100644 --- a/crates/ruff_db/src/source.rs +++ b/crates/ruff_db/src/source.rs @@ -1,6 +1,7 @@ use std::ops::Deref; use std::sync::Arc; +use ruff_diagnostics::SourceMap; use ruff_notebook::Notebook; use ruff_python_ast::PySourceType; use ruff_source_file::LineIndex; @@ -90,6 +91,34 @@ impl SourceText { pub fn read_error(&self) -> Option<&SourceTextError> { self.inner.read_error.as_ref() } + + pub fn updated(&mut self, new_source: String, source_map: &SourceMap) { + let inner = Arc::make_mut(&mut self.inner); + + match &mut inner.kind { + SourceTextKind::Text(text) => *text = new_source, + SourceTextKind::Notebook { notebook } => { + notebook.update(&source_map, new_source); + } + }; + } + + pub fn to_raw_content(&self) -> std::borrow::Cow<'_, str> { + match &self.inner.kind { + SourceTextKind::Text(text) => text.as_str().into(), + SourceTextKind::Notebook { notebook } => { + let mut output = Vec::new(); + notebook + .write(&mut output) + .expect("Writing to a `Vec` should not fail"); + String::from_utf8(output) + .expect( + "Notebook should serialize to valid UTF-8 if the source was valid UTF-8", + ) + .into() + } + } + } } impl Deref for SourceText { @@ -117,13 +146,13 @@ impl std::fmt::Debug for SourceText { } } -#[derive(Eq, PartialEq, get_size2::GetSize)] +#[derive(Eq, PartialEq, get_size2::GetSize, Clone)] struct SourceTextInner { kind: SourceTextKind, read_error: Option, } -#[derive(Eq, PartialEq, get_size2::GetSize)] +#[derive(Eq, PartialEq, get_size2::GetSize, Clone)] enum SourceTextKind { Text(String), Notebook { diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml index 4189758bb1..792f18768b 100644 --- a/crates/ty/Cargo.toml +++ b/crates/ty/Cargo.toml @@ -16,6 +16,8 @@ license.workspace = true [dependencies] ruff_db = { workspace = true, features = ["os", "cache"] } ruff_python_ast = { workspace = true } +ruff_diagnostics = { workspace = true } +ruff_text_size = { workspace = true } ty_combine = { workspace = true } ty_python_semantic = { workspace = true } ty_project = { workspace = true, features = ["zstd"] } diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index ac334f37bf..ee920f1521 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -53,6 +53,9 @@ pub(crate) struct CheckCommand { )] pub paths: Vec, + #[arg(long)] + pub(crate) add_ignore: bool, + /// Run the command within the given project directory. /// /// All `pyproject.toml` files will be discovered by walking up the directory tree from the given project directory, diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index 96e85fea6b..cbd0c21d58 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -5,9 +5,14 @@ mod python_version; mod version; pub use args::Cli; +use ruff_db::source::source_text; +use ruff_diagnostics::{Fix, SourceMap}; +use ruff_text_size::{Ranged as _, TextLen, TextRange, TextSize}; use ty_project::metadata::settings::TerminalSettings; +use ty_python_semantic::suppress_all; use ty_static::EnvVars; +use std::collections::BTreeMap; use std::fmt::Write; use std::process::{ExitCode, Termination}; @@ -22,16 +27,17 @@ use clap::{CommandFactory, Parser}; use colored::Colorize; use crossbeam::channel as crossbeam_channel; use rayon::ThreadPoolBuilder; +use ruff_db::Db as _; use ruff_db::diagnostic::{ Diagnostic, DiagnosticId, DisplayDiagnosticConfig, DisplayDiagnostics, Severity, }; -use ruff_db::files::File; +use ruff_db::files::{File, FilePath}; use ruff_db::max_parallelism; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use salsa::Database; use ty_project::metadata::options::ProjectOptionsOverrides; use ty_project::watch::ProjectWatcher; -use ty_project::{CollectReporter, Db, watch}; +use ty_project::{CollectReporter, Db, suppress_all_diagnostics, watch}; use ty_project::{ProjectDatabase, ProjectMetadata}; use ty_server::run_server; @@ -111,6 +117,12 @@ fn run_check(args: CheckCommand) -> anyhow::Result { .map(|path| SystemPath::absolute(path, &cwd)) .collect(); + let mode = if args.add_ignore { + MainLoopMode::AddIgnore + } else { + MainLoopMode::Check + }; + let system = OsSystem::new(&cwd); let watch = args.watch; let exit_zero = args.exit_zero; @@ -138,7 +150,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result { } let (main_loop, main_loop_cancellation_token) = - MainLoop::new(project_options_overrides, printer); + MainLoop::new(mode, project_options_overrides, printer); // Listen to Ctrl+C and abort the watch mode. let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token)); @@ -209,6 +221,8 @@ impl Termination for ExitStatus { } struct MainLoop { + mode: MainLoopMode, + /// Sender that can be used to send messages to the main loop. sender: crossbeam_channel::Sender, @@ -226,6 +240,7 @@ struct MainLoop { impl MainLoop { fn new( + mode: MainLoopMode, project_options_overrides: ProjectOptionsOverrides, printer: Printer, ) -> (Self, MainLoopCancellationToken) { @@ -233,6 +248,7 @@ impl MainLoop { ( Self { + mode, sender: sender.clone(), receiver, watcher: None, @@ -310,77 +326,103 @@ impl MainLoop { result, revision: check_revision, } => { + if check_revision != revision { + tracing::debug!( + "Discarding check result for outdated revision: current: {revision}, result revision: {check_revision}" + ); + continue; + } + + if db.project().files(db).is_empty() { + tracing::warn!("No python files found under the given path(s)"); + } + + // TODO: We should have an official flag to silence workspace diagnostics. + if std::env::var("TY_MEMORY_REPORT").as_deref() == Ok("mypy_primer") { + return Ok(ExitStatus::Success); + } + let terminal_settings = db.project().settings(db).terminal(); - let display_config = DisplayDiagnosticConfig::default() - .format(terminal_settings.output_format.into()) - .color(colored::control::SHOULD_COLORIZE.should_colorize()) - .show_fix_diff(true); + let is_human_readable = terminal_settings.output_format.is_human_readable(); - if check_revision == revision { - if db.project().files(db).is_empty() { - tracing::warn!("No python files found under the given path(s)"); + let diagnostics = match self.mode { + MainLoopMode::Check => { + let display_config = DisplayDiagnosticConfig::default() + .format(terminal_settings.output_format.into()) + .color(colored::control::SHOULD_COLORIZE.should_colorize()) + .show_fix_diff(true); + + if result.is_empty() { + if is_human_readable { + writeln!( + self.printer.stream_for_success_summary(), + "{}", + "All checks passed!".green().bold() + )?; + } + } else { + let diagnostics_count = result.len(); + + let mut stdout = self.printer.stream_for_details().lock(); + + // Only render diagnostics if they're going to be displayed, since doing + // so is expensive. + if stdout.is_enabled() { + write!( + stdout, + "{}", + DisplayDiagnostics::new(db, &display_config, &result) + )?; + } + + if is_human_readable { + writeln!( + self.printer.stream_for_failure_summary(), + "Found {} diagnostic{}", + diagnostics_count, + if diagnostics_count > 1 { "s" } else { "" } + )?; + } + } + + result } - - // TODO: We should have an official flag to silence workspace diagnostics. - if std::env::var("TY_MEMORY_REPORT").as_deref() == Ok("mypy_primer") { - return Ok(ExitStatus::Success); - } - - let is_human_readable = terminal_settings.output_format.is_human_readable(); - - if result.is_empty() { - if is_human_readable { - writeln!( - self.printer.stream_for_success_summary(), - "{}", - "All checks passed!".green().bold() - )?; - } - - if self.watcher.is_none() { - return Ok(ExitStatus::Success); - } - } else { - let diagnostics_count = result.len(); - - let mut stdout = self.printer.stream_for_details().lock(); - let exit_status = - exit_status_from_diagnostics(&result, terminal_settings); - - // Only render diagnostics if they're going to be displayed, since doing - // so is expensive. - if stdout.is_enabled() { - write!( - stdout, - "{}", - DisplayDiagnostics::new(db, &display_config, &result) - )?; - } + MainLoopMode::AddIgnore => { + let result = suppress_all_diagnostics(db, result); if is_human_readable { writeln!( self.printer.stream_for_failure_summary(), - "Found {} diagnostic{}", - diagnostics_count, - if diagnostics_count > 1 { "s" } else { "" } + "Ignored {} diagnostic{}", + result.count, + if result.count > 1 { "s" } else { "" } )?; } - if exit_status.is_internal_error() { - tracing::warn!( - "A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details." - ); - } - - if self.watcher.is_none() { - return Ok(exit_status); - } + result.diagnostics } + }; + + if self.watcher.is_some() { + continue; + } + + let exit_status = if diagnostics.is_empty() { + ExitStatus::Success } else { - tracing::debug!( - "Discarding check result for outdated revision: current: {revision}, result revision: {check_revision}" + let exit_status = + exit_status_from_diagnostics(&diagnostics, terminal_settings); + + exit_status + }; + + if exit_status.is_internal_error() { + tracing::warn!( + "A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details." ); } + + return Ok(exit_status); } MainLoopMessage::ApplyChanges(changes) => { @@ -406,6 +448,12 @@ impl MainLoop { } } +#[derive(Copy, Clone, Debug)] +enum MainLoopMode { + Check, + AddIgnore, +} + fn exit_status_from_diagnostics( diagnostics: &[Diagnostic], terminal_settings: &TerminalSettings, diff --git a/crates/ty_ide/src/code_action.rs b/crates/ty_ide/src/code_action.rs index 1a02389735..9aa4e1deca 100644 --- a/crates/ty_ide/src/code_action.rs +++ b/crates/ty_ide/src/code_action.rs @@ -4,7 +4,7 @@ use ruff_db::{files::File, parsed::parsed_module}; use ruff_diagnostics::Edit; use ruff_text_size::TextRange; use ty_project::Db; -use ty_python_semantic::create_suppression_fix; +use ty_python_semantic::suppress_single; use ty_python_semantic::types::UNRESOLVED_REFERENCE; /// A `QuickFix` Code Action @@ -36,7 +36,7 @@ pub fn code_actions( actions.push(QuickFix { title: format!("Ignore '{}' for this line", lint_id.name()), - edits: create_suppression_fix(db, file, lint_id, diagnostic_range).into_edits(), + edits: suppress_single(db, file, lint_id, diagnostic_range).into_edits(), preferred: false, }); diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 74356dac24..9820cdcde8 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -14,6 +14,7 @@ license.workspace = true [dependencies] ruff_cache = { workspace = true } ruff_db = { workspace = true, features = ["cache", "serde"] } +ruff_diagnostics = { workspace = true } ruff_macros = { workspace = true } ruff_memory_usage = { workspace = true } ruff_options_metadata = { workspace = true } diff --git a/crates/ty_project/src/fixes.rs b/crates/ty_project/src/fixes.rs new file mode 100644 index 0000000000..c6576de267 --- /dev/null +++ b/crates/ty_project/src/fixes.rs @@ -0,0 +1,142 @@ +use std::collections::BTreeMap; + +use ruff_db::{ + diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span}, + files::{File, FilePath}, + source::source_text, +}; +use ruff_diagnostics::{Fix, SourceMap}; +use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; +use ty_python_semantic::suppress_all; + +use crate::Db; + +pub struct SuppressAllResult { + /// The non-lint diagnostics that can't be suppressed. + pub diagnostics: Vec, + + /// The number of diagnostics that were suppressed. + pub count: usize, +} + +/// Suppress all +pub fn suppress_all_diagnostics(db: &dyn Db, diagnostics: Vec) -> SuppressAllResult { + let system = db + .system() + .as_writable() + .expect("System should be writable"); + + let mut non_lint_diagnostics = diagnostics; + let mut by_file: BTreeMap> = BTreeMap::new(); + + non_lint_diagnostics.retain(|diagnostic| { + let DiagnosticId::Lint(lint_id) = diagnostic.id() else { + return true; + }; + + let Some(span) = diagnostic.primary_span() else { + return true; + }; + + let Some(range) = span.range() else { + return true; + }; + + by_file + .entry(span.expect_ty_file()) + .or_default() + .push((lint_id, range)); + + false + }); + + let mut count = 0usize; + for (file, to_suppress) in by_file { + let FilePath::System(path) = file.path(db) else { + tracing::debug!( + "Skipping file `{}` with non-system path because vendored and system virtual file paths are read-only", + file.path(db) + ); + continue; + }; + + let mut source = source_text(db, file); + + let count_current_file = to_suppress.len(); + + let fixes = suppress_all(db, file, to_suppress); + let (new_source, source_map) = apply_fixes(db, file, fixes); + + source.updated(new_source, &source_map); + + // Create new source from applying fixes + if let Err(err) = system.write_file(path, &*source.to_raw_content()) { + let mut diag = Diagnostic::new( + DiagnosticId::Io, + Severity::Error, + format_args!("Failed to write fixes: {err}"), + ); + diag.annotate(Annotation::primary(Span::from(file))); + non_lint_diagnostics.push(diag); + continue; + } + + count += count_current_file; + } + + SuppressAllResult { + diagnostics: non_lint_diagnostics, + count, + } +} + +/// Apply a series of fixes to `File` and returns the updated source code along with the source map. +fn apply_fixes(db: &dyn Db, file: File, mut fixes: Vec) -> (String, SourceMap) { + let source = source_text(db, file); + let source = source.as_str(); + + let mut output = String::with_capacity(source.len()); + let mut last_pos: Option = None; + + let mut source_map = SourceMap::default(); + + fixes.sort_unstable_by_key(|fix| fix.min_start()); + + for fix in fixes { + let mut edits = fix.edits().iter().peekable(); + + // If the fix contains at least one new edit, enforce isolation and positional requirements. + if let Some(first) = edits.peek() { + // If this fix overlaps with a fix we've already applied, skip it. + if last_pos.is_some_and(|last_pos| last_pos >= first.start()) { + continue; + } + } + + let mut applied_edits = Vec::with_capacity(fix.edits().len()); + for edit in edits { + // Add all contents from `last_pos` to `fix.location`. + let slice = &source[TextRange::new(last_pos.unwrap_or_default(), edit.start())]; + output.push_str(slice); + + // Add the start source marker for the patch. + source_map.push_start_marker(edit, output.text_len()); + + // Add the patch itself. + output.push_str(edit.content().unwrap_or_default()); + + // Add the end source marker for the added patch. + source_map.push_end_marker(edit, output.text_len()); + + // Track that the edit was applied. + last_pos = Some(edit.end()); + applied_edits.push(edit); + } + } + + // Add the remaining content. + let slice = &source[last_pos.unwrap_or_default().to_usize()..]; + output.push_str(slice); + + (output, source_map) +} diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index fe034b0a07..1134a3bf13 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -9,6 +9,7 @@ use crate::walk::{ProjectFilesFilter, ProjectFilesWalker}; pub use db::tests::TestDb; pub use db::{ChangeResult, CheckMode, Db, ProjectDatabase, SalsaMemoryDump}; use files::{Index, Indexed, IndexedFiles}; +pub use fixes::suppress_all_diagnostics; use metadata::settings::Settings; pub use metadata::{ProjectMetadata, ProjectMetadataError}; use ruff_db::diagnostic::{ @@ -34,6 +35,7 @@ use ty_python_semantic::types::check_types; mod db; mod files; +mod fixes; mod glob; pub mod metadata; mod walk; diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index be50dc9b52..997c0dcbb9 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -25,7 +25,7 @@ pub use semantic_model::{ Completion, HasDefinition, HasType, MemberDefinition, NameKind, SemanticModel, }; pub use site_packages::{PythonEnvironment, SitePackagesPaths, SysPrefixPathOrigin}; -pub use suppression::create_suppression_fix; +pub use suppression::{suppress_all, suppress_single}; pub use types::DisplaySettings; pub use types::ide_support::{ ImportAliasResolution, ResolvedDefinition, definitions_for_attribute, definitions_for_bin_op, diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index c0524728a2..de23de7cd6 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -1,4 +1,8 @@ +use ruff_db::diagnostic::LintName; use smallvec::{SmallVec, smallvec}; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::error::Error; use std::fmt; use std::fmt::Formatter; @@ -375,20 +379,147 @@ fn check_unused_suppressions(context: &mut CheckSuppressionsContext) { } } +pub fn suppress_all(db: &dyn Db, file: File, ids_with_range: I) -> Vec +where + I: IntoIterator, +{ + let grouped = group_by_suppression_range(db, file, ids_with_range); + create_all_fixes(db, file, grouped) +} + +/// Creates a fix to suppress a single lint. +pub fn suppress_single(db: &dyn Db, file: File, id: LintId, range: TextRange) -> Fix { + let suppression_range = suppression_range(db, file, range); + create_suppression_fix(db, file, id.name(), suppression_range) +} + +fn create_all_fixes( + db: &dyn Db, + file: File, + grouped: BTreeMap>, +) -> Vec { + let mut fixes = Vec::new(); + + for (range, lints) in grouped { + for lint in lints.into_iter().rev() { + let fix = create_suppression_fix(db, file, lint, range); + fixes.push(fix); + } + } + + fixes +} + +fn group_by_suppression_range( + db: &dyn Db, + file: File, + ids_with_range: I, +) -> BTreeMap> +where + I: IntoIterator, +{ + let mut map: BTreeMap> = BTreeMap::new(); + for (id, range) in ids_with_range { + let full_range = suppression_range(db, file, range); + map.entry(full_range).or_default().insert(id); + } + + map +} + +/// Returns the suppression range for the given `range`. +/// +/// The suppression range is defined as: +/// +/// * `start`: The `end` of the preceding `Newline` or `NonLogicalLine` token. +/// * `end`: The `start` of the first `NonLogicalLine` or `Newline` token coming after the range. +/// +/// For most ranges, this means the suppression range starts at the beginning of the physical line +/// and ends at the end of the physical line containing `range`. The exceptions to this are: +/// +/// * If `range` is within a single-line interpolated expression, then the start and end are extended to the start and end of the enclosing interpolated string. +/// * If there's a line continuation, then the suppression range is extended to include the following line too. +/// * If there's a multiline string, then the suppression range is extended to cover the starting and ending line of the multiline string. +fn suppression_range(db: &dyn Db, file: File, range: TextRange) -> SuppressionRange { + let parsed = parsed_module(db, file).load(db); + + let before_tokens = parsed.tokens().before(range.start()); + let line_start = before_tokens + .iter() + .rfind(|token| { + matches!( + token.kind(), + TokenKind::Newline | TokenKind::NonLogicalNewline + ) + }) + .map(Ranged::end) + .unwrap_or(TextSize::default()); + + let after_tokens = parsed.tokens().after(range.end()); + let line_end = after_tokens + .iter() + .find(|token| { + matches!( + token.kind(), + TokenKind::Newline | TokenKind::NonLogicalNewline + ) + }) + .map(Ranged::start) + .unwrap_or(range.end()); + + SuppressionRange(TextRange::new(line_start, line_end)) +} + +/// The range of the suppression. +/// +/// Guranteed to start at the start of a line and +/// ends at the end of a line (right before the `\n`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SuppressionRange(TextRange); + +impl SuppressionRange { + fn text_range(&self) -> TextRange { + self.0 + } + + fn line_end(&self) -> TextSize { + self.0.end() + } +} + +impl PartialOrd for SuppressionRange { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SuppressionRange { + fn cmp(&self, other: &Self) -> Ordering { + self.0.ordering(other.0) + } +} + /// Creates a fix for adding a suppression comment to suppress `lint` for `range`. /// /// The fix prefers adding the code to an existing `ty: ignore[]` comment over /// adding a new suppression comment. -pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRange) -> Fix { +fn create_suppression_fix( + db: &dyn Db, + file: File, + name: LintName, + suppression_range: SuppressionRange, +) -> Fix { let suppressions = suppressions(db, file); let source = source_text(db, file); - let mut existing_suppressions = suppressions.line_suppressions(range).filter(|suppression| { - matches!( - suppression.target, - SuppressionTarget::Lint(_) | SuppressionTarget::Empty, - ) - }); + let mut existing_suppressions = suppressions + .line_suppressions(suppression_range.text_range()) + .filter(|suppression| { + matches!( + suppression.target, + SuppressionTarget::Lint(_) | SuppressionTarget::Empty, + ) + }); // If there's an existing `ty: ignore[]` comment, append the code to it instead of creating a new suppression comment. if let Some(existing) = existing_suppressions.next() { @@ -398,9 +529,9 @@ pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRa let up_to_last_code = before_closing_paren.trim_end(); let insertion = if up_to_last_code.ends_with(',') { - format!(" {id}", id = id.name()) + format!(" {name}") } else { - format!(", {id}", id = id.name()) + format!(", {name}") }; let relative_offset_from_end = comment_text.text_len() - up_to_last_code.text_len(); @@ -414,28 +545,13 @@ pub fn create_suppression_fix(db: &dyn Db, file: File, id: LintId, range: TextRa // Always insert a new suppression at the end of the range to avoid having to deal with multiline strings // etc. - let parsed = parsed_module(db, file).load(db); - let tokens_after = parsed.tokens().after(range.end()); - - // Same as for `line_end` when building up the `suppressions`: Ignore newlines - // in multiline-strings, inside f-strings, or after a line continuation because we can't - // place a comment on those lines. - let line_end = tokens_after - .iter() - .find(|token| { - matches!( - token.kind(), - TokenKind::Newline | TokenKind::NonLogicalNewline - ) - }) - .map(Ranged::start) - .unwrap_or(source.text_len()); + let line_end = suppression_range.line_end(); let up_to_line_end = &source[..line_end.to_usize()]; let up_to_first_content = up_to_line_end.trim_end(); let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len(); - let insertion = format!(" # ty:ignore[{id}]", id = id.name()); + let insertion = format!(" # ty:ignore[{name}]"); Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO { Edit::insertion(insertion, line_end) @@ -613,7 +729,7 @@ impl Suppressions { // Don't use intersect to avoid that suppressions on inner-expression // ignore errors for outer expressions suppression.suppressed_range.contains(range.start()) - || suppression.suppressed_range.contains(range.end()) + || suppression.suppressed_range.contains_inclusive(range.end()) }) }