Unify Message variants (#18051)

## Summary

This PR unifies the ruff `Message` enum variants for syntax errors and
rule violations into a single `Message` struct consisting of a shared
`db::Diagnostic` and some additional, optional fields used for some rule
violations.

This version of `Message` is nearly a drop-in replacement for
`ruff_diagnostics::Diagnostic`, which is the next step I have in mind
for the refactor.

I think this is also a useful checkpoint because we could possibly add
some of these optional fields to the new `Diagnostic` type. I think
we've previously discussed wanting support for `Fix`es, but the other
fields seem less relevant, so we may just need to preserve the `Message`
wrapper for a bit longer.

## Test plan

Existing tests

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Brent Westbrook 2025-05-19 13:34:04 -04:00 committed by GitHub
parent 236633cd42
commit d6009eb942
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 384 additions and 463 deletions

View file

@ -47,10 +47,14 @@ pub(crate) fn check_noqa(
// Remove any ignored diagnostics.
'outer: for (index, diagnostic) in diagnostics.iter().enumerate() {
if matches!(diagnostic.rule(), Rule::BlanketNOQA) {
let rule = diagnostic.rule();
if matches!(rule, Rule::BlanketNOQA) {
continue;
}
let code = rule.noqa_code();
match &exemption {
FileExemption::All(_) => {
// If the file is exempted, ignore all diagnostics.
@ -59,7 +63,7 @@ pub(crate) fn check_noqa(
}
FileExemption::Codes(codes) => {
// If the diagnostic is ignored by a global exemption, ignore it.
if codes.contains(&&diagnostic.rule().noqa_code()) {
if codes.contains(&&code) {
ignored_diagnostics.push(index);
continue;
}
@ -78,13 +82,13 @@ pub(crate) fn check_noqa(
{
let suppressed = match &directive_line.directive {
Directive::All(_) => {
directive_line.matches.push(diagnostic.rule().noqa_code());
directive_line.matches.push(code);
ignored_diagnostics.push(index);
true
}
Directive::Codes(directive) => {
if directive.includes(diagnostic.rule()) {
directive_line.matches.push(diagnostic.rule().noqa_code());
if directive.includes(code) {
directive_line.matches.push(code);
ignored_diagnostics.push(index);
true
} else {

View file

@ -10,7 +10,7 @@ use crate::registry::{AsRule, Linter};
use crate::rule_selector::is_single_rule_selector;
use crate::rules;
#[derive(PartialEq, Eq, PartialOrd, Ord)]
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct NoqaCode(&'static str, &'static str);
impl NoqaCode {
@ -46,6 +46,15 @@ impl PartialEq<&str> for NoqaCode {
}
}
impl serde::Serialize for NoqaCode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[derive(Debug, Copy, Clone)]
pub enum RuleGroup {
/// The rule is stable.

View file

@ -606,7 +606,7 @@ mod tests {
use crate::fix::edits::{
add_to_dunder_all, make_redundant_alias, next_stmt_break, trailing_semicolon,
};
use crate::message::DiagnosticMessage;
use crate::message::Message;
/// Parse the given source using [`Mode::Module`] and return the first statement.
fn parse_first_stmt(source: &str) -> Result<Stmt> {
@ -745,16 +745,16 @@ x = 1 \
iter.next().ok_or(anyhow!("expected edits nonempty"))?,
iter,
));
DiagnosticMessage {
name: diag.name,
body: diag.body,
suggestion: diag.suggestion,
range: diag.range,
fix: diag.fix,
parent: diag.parent,
file: SourceFileBuilder::new("<filename>", "<code>").finish(),
noqa_offset: TextSize::default(),
}
Message::diagnostic(
diag.name,
diag.body,
diag.suggestion,
diag.range,
diag.fix,
diag.parent,
SourceFileBuilder::new("<filename>", "<code>").finish(),
None,
)
};
assert_eq!(apply_fixes([diag].iter(), &locator).code, expect);
Ok(())

View file

@ -8,8 +8,8 @@ use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use crate::Locator;
use crate::linter::FixTable;
use crate::message::{DiagnosticMessage, Message};
use crate::registry::{AsRule, Rule};
use crate::message::Message;
use crate::registry::Rule;
use crate::settings::types::UnsafeFixes;
pub(crate) mod codemods;
@ -35,11 +35,9 @@ pub(crate) fn fix_file(
let mut with_fixes = messages
.iter()
.filter_map(Message::as_diagnostic_message)
.filter(|message| {
message
.fix
.as_ref()
.fix()
.is_some_and(|fix| fix.applies(required_applicability))
})
.peekable();
@ -53,7 +51,7 @@ pub(crate) fn fix_file(
/// Apply a series of fixes.
fn apply_fixes<'a>(
diagnostics: impl Iterator<Item = &'a DiagnosticMessage>,
diagnostics: impl Iterator<Item = &'a Message>,
locator: &'a Locator<'a>,
) -> FixResult {
let mut output = String::with_capacity(locator.len());
@ -64,7 +62,8 @@ fn apply_fixes<'a>(
let mut source_map = SourceMap::default();
for (rule, fix) in diagnostics
.filter_map(|diagnostic| diagnostic.fix.as_ref().map(|fix| (diagnostic.rule(), fix)))
.filter_map(|msg| msg.to_rule().map(|rule| (rule, msg)))
.filter_map(|(rule, diagnostic)| diagnostic.fix().map(|fix| (rule, fix)))
.sorted_by(|(rule1, fix1), (rule2, fix2)| cmp_fix(*rule1, *rule2, fix1, fix2))
{
let mut edits = fix
@ -164,29 +163,23 @@ mod tests {
use crate::Locator;
use crate::fix::{FixResult, apply_fixes};
use crate::message::DiagnosticMessage;
use crate::message::Message;
use crate::rules::pycodestyle::rules::MissingNewlineAtEndOfFile;
fn create_diagnostics(
filename: &str,
source: &str,
edit: impl IntoIterator<Item = Edit>,
) -> Vec<DiagnosticMessage> {
// The choice of rule here is arbitrary.
) -> Vec<Message> {
edit.into_iter()
.map(|edit| {
let range = edit.range();
let diagnostic = Diagnostic::new(MissingNewlineAtEndOfFile, range);
DiagnosticMessage {
name: diagnostic.name,
body: diagnostic.body,
suggestion: diagnostic.suggestion,
range,
fix: Some(Fix::safe_edit(edit)),
parent: None,
file: SourceFileBuilder::new(filename, source).finish(),
noqa_offset: TextSize::default(),
}
// The choice of rule here is arbitrary.
let diagnostic = Diagnostic::new(MissingNewlineAtEndOfFile, edit.range());
Message::from_diagnostic(
diagnostic.with_fix(Fix::safe_edit(edit)),
SourceFileBuilder::new(filename, source).finish(),
None,
)
})
.collect()
}

View file

@ -535,7 +535,7 @@ fn diagnostics_to_messages(
)
.chain(diagnostics.into_iter().map(|diagnostic| {
let noqa_offset = directives.noqa_line_for.resolve(diagnostic.start());
Message::from_diagnostic(diagnostic, file.deref().clone(), noqa_offset)
Message::from_diagnostic(diagnostic, file.deref().clone(), Some(noqa_offset))
}))
.collect()
}
@ -682,7 +682,7 @@ fn collect_rule_codes(rules: impl IntoIterator<Item = Rule>) -> String {
#[expect(clippy::print_stderr)]
fn report_failed_to_converge_error(path: &Path, transformed: &str, messages: &[Message]) {
let codes = collect_rule_codes(messages.iter().filter_map(Message::rule));
let codes = collect_rule_codes(messages.iter().filter_map(Message::to_rule));
if cfg!(debug_assertions) {
eprintln!(
"{}{} Failed to converge after {} iterations in `{}` with rule codes {}:---\n{}\n---",

View file

@ -33,8 +33,8 @@ impl Emitter for AzureEmitter {
line = location.line,
col = location.column,
code = message
.rule()
.map_or_else(String::new, |rule| format!("code={};", rule.noqa_code())),
.to_noqa_code()
.map_or_else(String::new, |code| format!("code={code};")),
body = message.body(),
)?;
}

View file

@ -33,8 +33,8 @@ impl Emitter for GithubEmitter {
writer,
"::error title=Ruff{code},file={file},line={row},col={column},endLine={end_row},endColumn={end_column}::",
code = message
.rule()
.map_or_else(String::new, |rule| format!(" ({})", rule.noqa_code())),
.to_noqa_code()
.map_or_else(String::new, |code| format!(" ({code})")),
file = message.filename(),
row = source_location.line,
column = source_location.column,
@ -50,8 +50,8 @@ impl Emitter for GithubEmitter {
column = location.column,
)?;
if let Some(rule) = message.rule() {
write!(writer, " {}", rule.noqa_code())?;
if let Some(code) = message.to_noqa_code() {
write!(writer, " {code}")?;
}
writeln!(writer, " {}", message.body())?;

View file

@ -90,8 +90,8 @@ impl Serialize for SerializedMessages<'_> {
}
fingerprints.insert(message_fingerprint);
let (description, check_name) = if let Some(rule) = message.rule() {
(message.body().to_string(), rule.noqa_code().to_string())
let (description, check_name) = if let Some(code) = message.to_noqa_code() {
(message.body().to_string(), code.to_string())
} else {
let description = message.body();
let description_without_prefix = description

View file

@ -81,8 +81,8 @@ pub(crate) fn message_to_json_value(message: &Message, context: &EmitterContext)
}
json!({
"code": message.rule().map(|rule| rule.noqa_code().to_string()),
"url": message.rule().and_then(|rule| rule.url()),
"code": message.to_noqa_code().map(|code| code.to_string()),
"url": message.to_rule().and_then(|rule| rule.url()),
"message": message.body(),
"fix": fix,
"cell": notebook_cell_index,

View file

@ -59,8 +59,8 @@ impl Emitter for JunitEmitter {
body = message.body()
));
let mut case = TestCase::new(
if let Some(rule) = message.rule() {
format!("org.ruff.{}", rule.noqa_code())
if let Some(code) = message.to_noqa_code() {
format!("org.ruff.{code}")
} else {
"org.ruff".to_string()
},

View file

@ -1,10 +1,9 @@
use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::io::Write;
use std::ops::Deref;
use ruff_db::diagnostic::{self as db, Annotation, DiagnosticId, Severity, Span};
use ruff_db::diagnostic::{self as db, Annotation, DiagnosticId, LintName, Severity, Span};
use ruff_python_parser::semantic_errors::SemanticSyntaxError;
use rustc_hash::FxHashMap;
@ -26,8 +25,9 @@ pub use sarif::SarifEmitter;
pub use text::TextEmitter;
use crate::Locator;
use crate::codes::NoqaCode;
use crate::logging::DisplayParseErrorType;
use crate::registry::{AsRule, Rule};
use crate::registry::Rule;
mod azure;
mod diff;
@ -43,39 +43,23 @@ mod sarif;
mod text;
/// Message represents either a diagnostic message corresponding to a rule violation or a syntax
/// error message raised by the parser.
/// error message.
///
/// All of the information for syntax errors is captured in the underlying [`db::Diagnostic`], while
/// rule violations can have the additional optional fields like fixes, suggestions, and (parent)
/// `noqa` offsets.
///
/// For diagnostic messages, the [`db::Diagnostic`]'s primary message contains the
/// [`Diagnostic::body`], and the primary annotation optionally contains the suggestion accompanying
/// a fix. The `db::Diagnostic::id` field contains the kebab-case lint name derived from the `Rule`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Message {
Diagnostic(DiagnosticMessage),
SyntaxError(db::Diagnostic),
}
pub struct Message {
pub diagnostic: db::Diagnostic,
/// A diagnostic message corresponding to a rule violation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiagnosticMessage {
pub name: &'static str,
pub body: String,
pub suggestion: Option<String>,
pub range: TextRange,
// these fields are specific to rule violations
pub fix: Option<Fix>,
pub parent: Option<TextSize>,
pub file: SourceFile,
pub noqa_offset: TextSize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum MessageKind {
Diagnostic(Rule),
SyntaxError,
}
impl MessageKind {
pub fn as_str(&self) -> &str {
match self {
MessageKind::Diagnostic(rule) => rule.as_ref(),
MessageKind::SyntaxError => "syntax-error",
}
}
pub(crate) noqa_offset: Option<TextSize>,
}
impl Message {
@ -84,28 +68,72 @@ impl Message {
range: TextRange,
file: SourceFile,
) -> Message {
let mut diag = db::Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, "");
let mut diag = db::Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, message);
let span = Span::from(file).with_range(range);
diag.annotate(Annotation::primary(span).message(message));
Self::SyntaxError(diag)
diag.annotate(Annotation::primary(span));
Self {
diagnostic: diag,
fix: None,
parent: None,
noqa_offset: None,
}
}
#[expect(clippy::too_many_arguments)]
pub fn diagnostic(
name: &'static str,
body: String,
suggestion: Option<String>,
range: TextRange,
fix: Option<Fix>,
parent: Option<TextSize>,
file: SourceFile,
noqa_offset: Option<TextSize>,
) -> Message {
let mut diagnostic = db::Diagnostic::new(
DiagnosticId::Lint(LintName::of(name)),
Severity::Error,
body,
);
let span = Span::from(file).with_range(range);
let mut annotation = Annotation::primary(span);
if let Some(suggestion) = suggestion {
annotation = annotation.message(suggestion);
}
diagnostic.annotate(annotation);
Message {
diagnostic,
fix,
parent,
noqa_offset,
}
}
/// Create a [`Message`] from the given [`Diagnostic`] corresponding to a rule violation.
pub fn from_diagnostic(
diagnostic: Diagnostic,
file: SourceFile,
noqa_offset: TextSize,
noqa_offset: Option<TextSize>,
) -> Message {
Message::Diagnostic(DiagnosticMessage {
range: diagnostic.range(),
name: diagnostic.name,
body: diagnostic.body,
suggestion: diagnostic.suggestion,
fix: diagnostic.fix,
parent: diagnostic.parent,
let Diagnostic {
name,
body,
suggestion,
range,
fix,
parent,
} = diagnostic;
Self::diagnostic(
name,
body,
suggestion,
range,
fix,
parent,
file,
noqa_offset,
})
)
}
/// Create a [`Message`] from the given [`ParseError`].
@ -157,83 +185,38 @@ impl Message {
)
}
pub const fn as_diagnostic_message(&self) -> Option<&DiagnosticMessage> {
match self {
Message::Diagnostic(m) => Some(m),
Message::SyntaxError(_) => None,
}
}
pub fn into_diagnostic_message(self) -> Option<DiagnosticMessage> {
match self {
Message::Diagnostic(m) => Some(m),
Message::SyntaxError(_) => None,
}
}
/// Returns `true` if `self` is a diagnostic message.
pub const fn is_diagnostic_message(&self) -> bool {
matches!(self, Message::Diagnostic(_))
}
/// Returns `true` if `self` is a syntax error message.
pub fn is_syntax_error(&self) -> bool {
match self {
Message::Diagnostic(_) => false,
Message::SyntaxError(diag) => diag.id().is_invalid_syntax(),
}
}
/// Returns a message kind.
pub fn kind(&self) -> MessageKind {
match self {
Message::Diagnostic(m) => MessageKind::Diagnostic(m.rule()),
Message::SyntaxError(_) => MessageKind::SyntaxError,
}
self.diagnostic.id().is_invalid_syntax()
}
/// Returns the name used to represent the diagnostic.
pub fn name(&self) -> &str {
match self {
Message::Diagnostic(m) => m.name,
Message::SyntaxError(_) => "SyntaxError",
pub fn name(&self) -> &'static str {
if self.is_syntax_error() {
"syntax-error"
} else {
self.diagnostic.id().as_str()
}
}
/// Returns the message body to display to the user.
pub fn body(&self) -> &str {
match self {
Message::Diagnostic(m) => &m.body,
Message::SyntaxError(m) => m
.primary_annotation()
.expect("Expected a primary annotation for a ruff diagnostic")
.get_message()
.expect("Expected a message for a ruff diagnostic"),
}
self.diagnostic.primary_message()
}
/// Returns the fix suggestion for the violation.
pub fn suggestion(&self) -> Option<&str> {
match self {
Message::Diagnostic(m) => m.suggestion.as_deref(),
Message::SyntaxError(_) => None,
}
self.diagnostic.primary_annotation()?.get_message()
}
/// Returns the offset at which the `noqa` comment will be placed if it's a diagnostic message.
pub fn noqa_offset(&self) -> Option<TextSize> {
match self {
Message::Diagnostic(m) => Some(m.noqa_offset),
Message::SyntaxError(_) => None,
}
self.noqa_offset
}
/// Returns the [`Fix`] for the message, if there is any.
pub fn fix(&self) -> Option<&Fix> {
match self {
Message::Diagnostic(m) => m.fix.as_ref(),
Message::SyntaxError(_) => None,
}
self.fix.as_ref()
}
/// Returns `true` if the message contains a [`Fix`].
@ -242,56 +225,64 @@ impl Message {
}
/// Returns the [`Rule`] corresponding to the diagnostic message.
pub fn rule(&self) -> Option<Rule> {
match self {
Message::Diagnostic(m) => Some(m.rule()),
Message::SyntaxError(_) => None,
pub fn to_rule(&self) -> Option<Rule> {
if self.is_syntax_error() {
None
} else {
Some(self.name().parse().expect("Expected a valid rule name"))
}
}
/// Returns the [`NoqaCode`] corresponding to the diagnostic message.
pub fn to_noqa_code(&self) -> Option<NoqaCode> {
self.to_rule().map(|rule| rule.noqa_code())
}
/// Returns the URL for the rule documentation, if it exists.
pub fn to_url(&self) -> Option<String> {
// TODO(brent) Rule::url calls Rule::explanation, which calls ViolationMetadata::explain,
// which when derived (seems always to be the case?) is always `Some`, so I think it's
// pretty safe to inline the Rule::url implementation here, using `self.name()`:
//
// format!("{}/rules/{}", env!("CARGO_PKG_HOMEPAGE"), self.name())
//
// at least in the case of diagnostics, I guess syntax errors will return None
self.to_rule().and_then(|rule| rule.url())
}
/// Returns the filename for the message.
pub fn filename(&self) -> Cow<'_, str> {
match self {
Message::Diagnostic(m) => Cow::Borrowed(m.file.name()),
Message::SyntaxError(diag) => Cow::Owned(
diag.expect_primary_span()
.expect_ruff_file()
.name()
.to_string(),
),
}
pub fn filename(&self) -> String {
self.diagnostic
.expect_primary_span()
.expect_ruff_file()
.name()
.to_string()
}
/// Computes the start source location for the message.
pub fn compute_start_location(&self) -> LineColumn {
match self {
Message::Diagnostic(m) => m.file.to_source_code().line_column(m.range.start()),
Message::SyntaxError(diag) => diag
.expect_primary_span()
.expect_ruff_file()
.to_source_code()
.line_column(self.start()),
}
self.diagnostic
.expect_primary_span()
.expect_ruff_file()
.to_source_code()
.line_column(self.start())
}
/// Computes the end source location for the message.
pub fn compute_end_location(&self) -> LineColumn {
match self {
Message::Diagnostic(m) => m.file.to_source_code().line_column(m.range.end()),
Message::SyntaxError(diag) => diag
.expect_primary_span()
.expect_ruff_file()
.to_source_code()
.line_column(self.end()),
}
self.diagnostic
.expect_primary_span()
.expect_ruff_file()
.to_source_code()
.line_column(self.end())
}
/// Returns the [`SourceFile`] which the message belongs to.
pub fn source_file(&self) -> SourceFile {
match self {
Message::Diagnostic(m) => m.file.clone(),
Message::SyntaxError(m) => m.expect_primary_span().expect_ruff_file().clone(),
}
self.diagnostic
.expect_primary_span()
.expect_ruff_file()
.clone()
}
}
@ -309,13 +300,10 @@ impl PartialOrd for Message {
impl Ranged for Message {
fn range(&self) -> TextRange {
match self {
Message::Diagnostic(m) => m.range,
Message::SyntaxError(m) => m
.expect_primary_span()
.range()
.expect("Expected range for ruff span"),
}
self.diagnostic
.expect_primary_span()
.range()
.expect("Expected range for ruff span")
}
}
@ -390,7 +378,7 @@ mod tests {
use ruff_text_size::{TextRange, TextSize};
use crate::Locator;
use crate::message::{DiagnosticMessage, Emitter, EmitterContext, Message};
use crate::message::{Emitter, EmitterContext, Message};
pub(super) fn create_syntax_error_messages() -> Vec<Message> {
let source = r"from os import
@ -428,54 +416,50 @@ def fibonacci(n):
let fib_source = SourceFileBuilder::new("fib.py", fib).finish();
let unused_import_start = TextSize::from(7);
let unused_import = DiagnosticMessage {
name: "unused-import",
body: "`os` imported but unused".to_string(),
suggestion: Some("Remove unused import: `os`".to_string()),
range: TextRange::new(unused_import_start, TextSize::from(9)),
fix: Some(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
let unused_import = Message::diagnostic(
"unused-import",
"`os` imported but unused".to_string(),
Some("Remove unused import: `os`".to_string()),
TextRange::new(unused_import_start, TextSize::from(9)),
Some(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(0),
TextSize::from(10),
)))),
parent: None,
noqa_offset: unused_import_start,
file: fib_source.clone(),
};
None,
fib_source.clone(),
Some(unused_import_start),
);
let unused_variable_start = TextSize::from(94);
let unused_variable = DiagnosticMessage {
name: "unused-variable",
body: "Local variable `x` is assigned to but never used".to_string(),
suggestion: Some("Remove assignment to unused variable `x`".to_string()),
range: TextRange::new(unused_variable_start, TextSize::from(95)),
fix: Some(Fix::unsafe_edit(Edit::deletion(
let unused_variable = Message::diagnostic(
"unused-variable",
"Local variable `x` is assigned to but never used".to_string(),
Some("Remove assignment to unused variable `x`".to_string()),
TextRange::new(unused_variable_start, TextSize::from(95)),
Some(Fix::unsafe_edit(Edit::deletion(
TextSize::from(94),
TextSize::from(99),
))),
parent: None,
noqa_offset: unused_variable_start,
file: fib_source,
};
None,
fib_source,
Some(unused_variable_start),
);
let file_2 = r"if a == 1: pass";
let undefined_name_start = TextSize::from(3);
let undefined_name = DiagnosticMessage {
name: "undefined-name",
body: "Undefined name `a`".to_string(),
suggestion: None,
range: TextRange::new(undefined_name_start, TextSize::from(4)),
fix: None,
parent: None,
noqa_offset: undefined_name_start,
file: SourceFileBuilder::new("undef.py", file_2).finish(),
};
let undefined_name = Message::diagnostic(
"undefined-name",
"Undefined name `a`".to_string(),
None,
TextRange::new(undefined_name_start, TextSize::from(4)),
None,
None,
SourceFileBuilder::new("undef.py", file_2).finish(),
Some(undefined_name_start),
);
vec![
Message::Diagnostic(unused_import),
Message::Diagnostic(unused_variable),
Message::Diagnostic(undefined_name),
]
vec![unused_import, unused_variable, undefined_name]
}
pub(super) fn create_notebook_messages() -> (Vec<Message>, FxHashMap<String, NotebookIndex>) {
@ -494,49 +478,49 @@ def foo():
let notebook_source = SourceFileBuilder::new("notebook.ipynb", notebook).finish();
let unused_import_os_start = TextSize::from(16);
let unused_import_os = DiagnosticMessage {
name: "unused-import",
body: "`os` imported but unused".to_string(),
suggestion: Some("Remove unused import: `os`".to_string()),
range: TextRange::new(unused_import_os_start, TextSize::from(18)),
fix: Some(Fix::safe_edit(Edit::range_deletion(TextRange::new(
let unused_import_os = Message::diagnostic(
"unused-import",
"`os` imported but unused".to_string(),
Some("Remove unused import: `os`".to_string()),
TextRange::new(unused_import_os_start, TextSize::from(18)),
Some(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(9),
TextSize::from(19),
)))),
parent: None,
file: notebook_source.clone(),
noqa_offset: unused_import_os_start,
};
None,
notebook_source.clone(),
Some(unused_import_os_start),
);
let unused_import_math_start = TextSize::from(35);
let unused_import_math = DiagnosticMessage {
name: "unused-import",
body: "`math` imported but unused".to_string(),
suggestion: Some("Remove unused import: `math`".to_string()),
range: TextRange::new(unused_import_math_start, TextSize::from(39)),
fix: Some(Fix::safe_edit(Edit::range_deletion(TextRange::new(
let unused_import_math = Message::diagnostic(
"unused-import",
"`math` imported but unused".to_string(),
Some("Remove unused import: `math`".to_string()),
TextRange::new(unused_import_math_start, TextSize::from(39)),
Some(Fix::safe_edit(Edit::range_deletion(TextRange::new(
TextSize::from(28),
TextSize::from(40),
)))),
parent: None,
file: notebook_source.clone(),
noqa_offset: unused_import_math_start,
};
None,
notebook_source.clone(),
Some(unused_import_math_start),
);
let unused_variable_start = TextSize::from(98);
let unused_variable = DiagnosticMessage {
name: "unused-variable",
body: "Local variable `x` is assigned to but never used".to_string(),
suggestion: Some("Remove assignment to unused variable `x`".to_string()),
range: TextRange::new(unused_variable_start, TextSize::from(99)),
fix: Some(Fix::unsafe_edit(Edit::deletion(
let unused_variable = Message::diagnostic(
"unused-variable",
"Local variable `x` is assigned to but never used".to_string(),
Some("Remove assignment to unused variable `x`".to_string()),
TextRange::new(unused_variable_start, TextSize::from(99)),
Some(Fix::unsafe_edit(Edit::deletion(
TextSize::from(94),
TextSize::from(104),
))),
parent: None,
file: notebook_source,
noqa_offset: unused_variable_start,
};
None,
notebook_source,
Some(unused_variable_start),
);
let mut notebook_indexes = FxHashMap::default();
notebook_indexes.insert(
@ -570,11 +554,7 @@ def foo():
);
(
vec![
Message::Diagnostic(unused_import_os),
Message::Diagnostic(unused_import_math),
Message::Diagnostic(unused_variable),
],
vec![unused_import_os, unused_import_math, unused_variable],
notebook_indexes,
)
}

View file

@ -26,12 +26,8 @@ impl Emitter for PylintEmitter {
message.compute_start_location().line
};
let body = if let Some(rule) = message.rule() {
format!(
"[{code}] {body}",
code = rule.noqa_code(),
body = message.body()
)
let body = if let Some(code) = message.to_noqa_code() {
format!("[{code}] {body}", body = message.body())
} else {
message.body().to_string()
};

View file

@ -71,8 +71,8 @@ fn message_to_rdjson_value(message: &Message) -> Value {
"range": rdjson_range(start_location, end_location),
},
"code": {
"value": message.rule().map(|rule| rule.noqa_code().to_string()),
"url": message.rule().and_then(|rule| rule.url()),
"value": message.to_noqa_code().map(|code| code.to_string()),
"url": message.to_url(),
},
"suggestions": rdjson_suggestions(fix.edits(), &source_code),
})
@ -84,8 +84,8 @@ fn message_to_rdjson_value(message: &Message) -> Value {
"range": rdjson_range(start_location, end_location),
},
"code": {
"value": message.rule().map(|rule| rule.noqa_code().to_string()),
"url": message.rule().and_then(|rule| rule.url()),
"value": message.to_noqa_code().map(|code| code.to_string()),
"url": message.to_url(),
},
})
}

View file

@ -123,7 +123,7 @@ impl SarifResult {
let end_location = message.compute_end_location();
let path = normalize_path(&*message.filename());
Ok(Self {
rule: message.rule(),
rule: message.to_rule(),
level: "error".to_string(),
message: message.body().to_string(),
uri: url::Url::from_file_path(&path)
@ -143,7 +143,7 @@ impl SarifResult {
let end_location = message.compute_end_location();
let path = normalize_path(&*message.filename());
Ok(Self {
rule: message.rule(),
rule: message.to_rule(),
level: "error".to_string(),
message: message.body().to_string(),
uri: path.display().to_string(),

View file

@ -151,8 +151,8 @@ impl Display for RuleCodeAndBody<'_> {
if let Some(fix) = self.message.fix() {
// Do not display an indicator for inapplicable fixes
if fix.applies(self.unsafe_fixes.required_applicability()) {
if let Some(rule) = self.message.rule() {
write!(f, "{} ", rule.noqa_code().to_string().red().bold())?;
if let Some(code) = self.message.to_noqa_code() {
write!(f, "{} ", code.to_string().red().bold())?;
}
return write!(
f,
@ -164,11 +164,11 @@ impl Display for RuleCodeAndBody<'_> {
}
}
if let Some(rule) = self.message.rule() {
if let Some(code) = self.message.to_noqa_code() {
write!(
f,
"{code} {body}",
code = rule.noqa_code().to_string().red().bold(),
code = code.to_string().red().bold(),
body = self.message.body(),
)
} else {
@ -254,8 +254,8 @@ impl Display for MessageCodeFrame<'_> {
let label = self
.message
.rule()
.map_or_else(String::new, |rule| rule.noqa_code().to_string());
.to_noqa_code()
.map_or_else(String::new, |code| code.to_string());
let line_start = self.notebook_index.map_or_else(
|| start_index.get(),

View file

@ -18,7 +18,7 @@ use crate::Locator;
use crate::codes::NoqaCode;
use crate::fs::relativize_path;
use crate::message::Message;
use crate::registry::{AsRule, Rule, RuleSet};
use crate::registry::{Rule, RuleSet};
use crate::rule_redirects::get_redirect_target;
/// Generates an array of edits that matches the length of `messages`.
@ -105,8 +105,7 @@ impl Codes<'_> {
/// Returns `true` if the string list of `codes` includes `code` (or an alias
/// thereof).
pub(crate) fn includes(&self, needle: Rule) -> bool {
let needle = needle.noqa_code();
pub(crate) fn includes(&self, needle: NoqaCode) -> bool {
self.iter()
.any(|code| needle == get_redirect_target(code.as_str()).unwrap_or(code.as_str()))
}
@ -140,7 +139,7 @@ pub(crate) fn rule_is_ignored(
Ok(Some(NoqaLexerOutput {
directive: Directive::Codes(codes),
..
})) => codes.includes(code),
})) => codes.includes(code.noqa_code()),
_ => false,
}
}
@ -846,11 +845,13 @@ fn find_noqa_comments<'a>(
// Mark any non-ignored diagnostics.
for message in messages {
let Message::Diagnostic(diagnostic) = message else {
let Some(rule) = message.to_rule() else {
comments_by_line.push(None);
continue;
};
let code = rule.noqa_code();
match &exemption {
FileExemption::All(_) => {
// If the file is exempted, don't add any noqa directives.
@ -859,7 +860,7 @@ fn find_noqa_comments<'a>(
}
FileExemption::Codes(codes) => {
// If the diagnostic is ignored by a global exemption, don't add a noqa directive.
if codes.contains(&&diagnostic.rule().noqa_code()) {
if codes.contains(&&code) {
comments_by_line.push(None);
continue;
}
@ -867,7 +868,7 @@ fn find_noqa_comments<'a>(
}
// Is the violation ignored by a `noqa` directive on the parent line?
if let Some(parent) = diagnostic.parent {
if let Some(parent) = message.parent {
if let Some(directive_line) =
directives.find_line_with_directive(noqa_line_for.resolve(parent))
{
@ -877,7 +878,7 @@ fn find_noqa_comments<'a>(
continue;
}
Directive::Codes(codes) => {
if codes.includes(diagnostic.rule()) {
if codes.includes(code) {
comments_by_line.push(None);
continue;
}
@ -886,9 +887,7 @@ fn find_noqa_comments<'a>(
}
}
let noqa_offset = noqa_line_for.resolve(diagnostic.range.start());
let rule = diagnostic.rule();
let noqa_offset = noqa_line_for.resolve(message.range().start());
// Or ignored by the directive itself?
if let Some(directive_line) = directives.find_line_with_directive(noqa_offset) {
@ -898,7 +897,7 @@ fn find_noqa_comments<'a>(
continue;
}
directive @ Directive::Codes(codes) => {
if !codes.includes(rule) {
if !codes.includes(code) {
comments_by_line.push(Some(NoqaComment {
line: directive_line.start(),
rule,
@ -1260,7 +1259,7 @@ mod tests {
) -> Message {
let noqa_offset = diagnostic.start();
let file = SourceFileBuilder::new(path.as_ref().to_string_lossy(), source).finish();
Message::from_diagnostic(diagnostic, file, noqa_offset)
Message::from_diagnostic(diagnostic, file, Some(noqa_offset))
}
#[test]

View file

@ -30,11 +30,7 @@ pub fn lint_pyproject_toml(source_file: SourceFile, settings: &LinterSettings) -
);
if settings.rules.enabled(Rule::IOError) {
let diagnostic = Diagnostic::new(IOError { message }, TextRange::default());
messages.push(Message::from_diagnostic(
diagnostic,
source_file,
TextSize::default(),
));
messages.push(Message::from_diagnostic(diagnostic, source_file, None));
} else {
warn!(
"{}{}{} {message}",
@ -56,11 +52,7 @@ pub fn lint_pyproject_toml(source_file: SourceFile, settings: &LinterSettings) -
if settings.rules.enabled(Rule::InvalidPyprojectToml) {
let toml_err = err.message().to_string();
let diagnostic = Diagnostic::new(InvalidPyprojectToml { message: toml_err }, range);
messages.push(Message::from_diagnostic(
diagnostic,
source_file,
TextSize::default(),
));
messages.push(Message::from_diagnostic(diagnostic, source_file, None));
}
messages

View file

@ -24,7 +24,7 @@ mod tests {
use crate::Locator;
use crate::linter::check_path;
use crate::message::Message;
use crate::registry::{AsRule, Linter, Rule};
use crate::registry::{Linter, Rule};
use crate::rules::isort;
use crate::rules::pyflakes;
use crate::settings::types::PreviewMode;
@ -776,8 +776,7 @@ mod tests {
messages.sort_by_key(Ranged::start);
let actual = messages
.iter()
.filter_map(Message::as_diagnostic_message)
.map(AsRule::rule)
.filter_map(Message::to_rule)
.collect::<Vec<_>>();
assert_eq!(actual, expected);
}

View file

@ -7,6 +7,7 @@ use std::path::Path;
#[cfg(not(fuzzing))]
use anyhow::Result;
use itertools::Itertools;
use ruff_text_size::Ranged;
use rustc_hash::FxHashMap;
use ruff_diagnostics::{Applicability, FixAvailability};
@ -25,7 +26,6 @@ use crate::linter::check_path;
use crate::message::{Emitter, EmitterContext, Message, TextEmitter};
use crate::package::PackageRoot;
use crate::packaging::detect_package_root;
use crate::registry::AsRule;
use crate::settings::types::UnsafeFixes;
use crate::settings::{LinterSettings, flags};
use crate::source_kind::SourceKind;
@ -233,10 +233,9 @@ Source with applied fixes:
let messages = messages
.into_iter()
.filter_map(Message::into_diagnostic_message)
.map(|mut diagnostic| {
let rule = diagnostic.rule();
let fixable = diagnostic.fix.as_ref().is_some_and(|fix| {
.filter_map(|msg| Some((msg.to_rule()?, msg)))
.map(|(rule, mut diagnostic)| {
let fixable = diagnostic.fix().is_some_and(|fix| {
matches!(
fix.applicability(),
Applicability::Safe | Applicability::Unsafe
@ -269,16 +268,22 @@ Either ensure you always emit a fix or change `Violation::FIX_AVAILABILITY` to e
}
assert!(
!(fixable && diagnostic.suggestion.is_none()),
!(fixable && diagnostic.suggestion().is_none()),
"Diagnostic emitted by {rule:?} is fixable but \
`Violation::fix_title` returns `None`"
);
// Not strictly necessary but adds some coverage for this code path
diagnostic.noqa_offset = directives.noqa_line_for.resolve(diagnostic.range.start());
diagnostic.file = source_code.clone();
// Not strictly necessary but adds some coverage for this code path by overriding the
// noqa offset and the source file
let range = diagnostic.range();
diagnostic.noqa_offset = Some(directives.noqa_line_for.resolve(range.start()));
if let Some(annotation) = diagnostic.diagnostic.primary_annotation_mut() {
annotation.set_span(
ruff_db::diagnostic::Span::from(source_code.clone()).with_range(range),
);
}
Message::Diagnostic(diagnostic)
diagnostic
})
.chain(parsed.errors().iter().map(|parse_error| {
Message::from_parse_error(parse_error, &locator, source_code.clone())
@ -311,7 +316,7 @@ fn print_syntax_errors(
/// Print the [`Message::Diagnostic`]s in `messages`.
fn print_diagnostics(mut messages: Vec<Message>, path: &Path, source: &SourceKind) -> String {
messages.retain(Message::is_diagnostic_message);
messages.retain(|msg| !msg.is_syntax_error());
if let Some(notebook) = source.as_ipy_notebook() {
print_jupyter_messages(&messages, path, notebook)