use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Formatter;
use ruff_db::PythonFile;
use ruff_db::diagnostic::LintName;
use ruff_db::display::FormatterJoinExtension;
use ruff_db::parsed::parsed_module;
use ruff_db::source::source_text;
use ruff_diagnostics::{Edit, Fix};
use ruff_python_ast::token::TokenKind;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use smallvec::SmallVec;
use crate::Db;
use crate::lint::LintId;
use crate::suppression::{
SuppressionKind, Suppressions, select_preferred_suppression, suppressions,
};
pub(crate) fn suppress_all(
db: &dyn Db,
file: PythonFile<'_>,
ids_with_range: &[(LintName, TextRange)],
) -> Vec<SuppressFix> {
let suppressions = suppressions(db, file);
let source = source_text(db, file.file(db));
let parsed = parsed_module(db, file).load(db);
let tokens = parsed.tokens();
let mut ids_with_suppression_range: Vec<_> = ids_with_range
.iter()
.map(|&(id, diagnostic_range)| {
(
id,
diagnostic_range,
suppression_range(db, file, diagnostic_range),
)
})
.collect();
ids_with_suppression_range.sort_unstable_by_key(|(_, _, range)| (range.start(), range.end()));
let mut fixes = Vec::with_capacity(ids_with_suppression_range.len());
let mut with_existing = Vec::new();
let mut without_existing = Vec::new();
for (id, diagnostic_range, suppression_range) in ids_with_suppression_range {
if let Some(existing) = find_existing_suppression(suppressions, &source, diagnostic_range) {
with_existing.push((id, suppression_range, existing));
} else {
without_existing.push((id, suppression_range));
}
}
let mut by_line = BTreeMap::<TextSize, BTreeMap<LintName, TextSize>>::new();
let mut by_end: BTreeMap<TextSize, (BTreeSet<LintName>, usize)> = BTreeMap::new();
for (id, range) in without_existing {
let existing_end = by_line
.get(&range.start())
.and_then(|planned| planned.get(&id))
.copied();
if let Some(end_offset) = existing_end {
let (_, suppressed_diagnostics) = by_end.entry(end_offset).or_default();
*suppressed_diagnostics += 1;
continue;
}
let (lints, suppressed_diagnostics) = by_end.entry(range.end()).or_default();
lints.insert(id);
*suppressed_diagnostics += 1;
by_line
.entry(line_start(tokens, range.end()))
.or_default()
.entry(id)
.or_insert(range.end());
}
let mut by_suppression =
BTreeMap::<TextSize, (ExistingSuppression, BTreeSet<LintName>, usize)>::new();
for (id, range, existing) in with_existing {
if let Some(end_offset) = by_line
.get(&range.start())
.and_then(|planned| planned.get(&id))
{
let (_, suppressed_diagnostics) = by_end.entry(*end_offset).or_default();
*suppressed_diagnostics += 1;
continue;
}
let insertion_offset = existing.insertion_offset;
let (_, grouped_codes, grouped_diagnostics) = by_suppression
.entry(insertion_offset)
.or_insert_with(|| (existing, BTreeSet::new(), 0));
grouped_codes.insert(id);
*grouped_diagnostics += 1;
}
for (end_offset, (lints, suppressed_diagnostics)) in by_end {
let codes: SmallVec<[LintName; 2]> = lints.into_iter().collect();
fixes.push(SuppressFix {
fix: add_end_of_line_suppression(&source, &codes, end_offset),
suppressed_diagnostics,
});
}
for (existing, codes, suppressed_diagnostics) in by_suppression.into_values() {
let codes: SmallVec<[LintName; 2]> = codes.into_iter().collect();
fixes.push(SuppressFix {
fix: add_to_existing_suppression(existing, &codes),
suppressed_diagnostics,
});
}
fixes
}
pub(crate) struct SuppressFix {
pub(crate) fix: Fix,
pub(crate) suppressed_diagnostics: usize,
}
pub fn suppress_single(db: &dyn Db, file: PythonFile<'_>, id: LintId, range: TextRange) -> Fix {
let suppression_range = suppression_range(db, file, range);
let suppressions = suppressions(db, file);
let source = source_text(db, file.file(db));
let codes = &[id.name()];
if let Some(existing) = find_existing_suppression(suppressions, &source, range) {
return add_to_existing_suppression(existing, codes);
}
add_end_of_line_suppression(&source, codes, suppression_range.end())
}
fn suppression_range(db: &dyn Db, file: PythonFile<'_>, range: TextRange) -> TextRange {
let parsed = parsed_module(db, file).load(db);
let line_start = line_start(parsed.tokens(), range.start());
let after_token_range = match parsed.tokens().at_offset(range.end()) {
ruff_python_ast::token::TokenAt::None => range,
ruff_python_ast::token::TokenAt::Single(token) => token.range(),
ruff_python_ast::token::TokenAt::Between(..) => range,
};
let after_tokens = parsed.tokens().after(after_token_range.end());
let line_end = after_tokens
.iter()
.find(|token| {
matches!(
token.kind(),
TokenKind::Newline | TokenKind::NonLogicalNewline
)
})
.map(Ranged::start)
.unwrap_or(range.end());
TextRange::new(line_start, line_end)
}
fn line_start(tokens: &ruff_python_ast::token::Tokens, offset: TextSize) -> TextSize {
tokens
.before(tokens.token_range(offset).start())
.iter()
.rfind(|token| {
matches!(
token.kind(),
TokenKind::Newline | TokenKind::NonLogicalNewline
)
})
.map(Ranged::end)
.unwrap_or_default()
}
fn add_end_of_line_suppression(source: &str, codes: &[LintName], line_end: TextSize) -> Fix {
let up_to_line_end = &source[..line_end.to_usize()];
let up_to_first_content =
up_to_line_end.trim_end_matches(|c| !matches!(c, '\n' | '\r') && c.is_whitespace());
let trailing_whitespace_len = up_to_line_end.text_len() - up_to_first_content.text_len();
let insertion = format!(
" # ty: ignore[{codes}]",
codes = Codes(SuppressionKind::Ty, codes)
);
Fix::safe_edit(if trailing_whitespace_len == TextSize::ZERO {
Edit::insertion(insertion, line_end)
} else {
Edit::replacement(insertion, line_end - trailing_whitespace_len, line_end)
})
}
fn find_existing_suppression(
suppressions: &Suppressions,
source: &str,
range: TextRange,
) -> Option<ExistingSuppression> {
let suppression = select_preferred_suppression(
suppressions
.editable_inline_suppressions_rev(range)
.filter(|suppression| {
editable_suppression_prefix(&source[suppression.comment_range]).is_some()
}),
range,
)?;
let prefix = editable_suppression_prefix(&source[suppression.comment_range])?;
let separator = if prefix.ends_with('[') {
""
} else if prefix.ends_with(',') {
" "
} else {
", "
};
Some(ExistingSuppression {
insertion_offset: suppression.comment_range.start() + prefix.text_len(),
kind: suppression.kind,
separator,
})
}
fn add_to_existing_suppression(existing: ExistingSuppression, codes: &[LintName]) -> Fix {
let separator = existing.separator;
let insertion = format!("{separator}{codes}", codes = Codes(existing.kind, codes));
Fix::safe_edit(Edit::insertion(insertion, existing.insertion_offset))
}
fn editable_suppression_prefix(comment_text: &str) -> Option<&str> {
let (before_closing_bracket, after_closing_bracket) = comment_text.split_once(']')?;
after_closing_bracket
.trim()
.is_empty()
.then(|| before_closing_bracket.trim_end())
}
#[derive(Copy, Clone)]
struct ExistingSuppression {
insertion_offset: TextSize,
kind: SuppressionKind,
separator: &'static str,
}
struct Codes<'a>(SuppressionKind, &'a [LintName]);
impl std::fmt::Display for Codes<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut joiner = f.join(", ");
let namespace = if self.0.is_type_ignore() { "ty:" } else { "" };
for item in self.1 {
joiner.entry(&format_args!("{namespace}{item}"));
}
joiner.finish()
}
}