use std::collections::BTreeMap; use std::fmt::Display; use std::io::Write; use std::ops::Deref; use rustc_hash::FxHashMap; use ruff_db::diagnostic::{ Annotation, Diagnostic, DiagnosticId, FileResolver, Input, LintName, SecondaryCode, Severity, Span, UnifiedFile, }; use ruff_db::files::File; pub use github::GithubEmitter; pub use gitlab::GitlabEmitter; pub use grouped::GroupedEmitter; use ruff_notebook::NotebookIndex; use ruff_source_file::{LineColumn, SourceFile}; use ruff_text_size::{Ranged, TextRange, TextSize}; pub use sarif::SarifEmitter; pub use text::TextEmitter; use crate::Fix; use crate::registry::Rule; mod diff; mod github; mod gitlab; mod grouped; mod sarif; mod text; /// Creates a `Diagnostic` from a syntax error, with the format expected by Ruff. /// /// This is almost identical to `ruff_db::diagnostic::create_syntax_error_diagnostic`, except the /// `message` is stored as the primary diagnostic message instead of on the primary annotation, and /// `SyntaxError: ` is prepended to the message. /// /// TODO(brent) These should be unified at some point, but we keep them separate for now to avoid a /// ton of snapshot changes while combining ruff's diagnostic type with `Diagnostic`. pub fn create_syntax_error_diagnostic( span: impl Into, message: impl std::fmt::Display, range: impl Ranged, ) -> Diagnostic { let mut diag = Diagnostic::new( DiagnosticId::InvalidSyntax, Severity::Error, format_args!("SyntaxError: {message}"), ); let span = span.into().with_range(range.range()); diag.annotate(Annotation::primary(span)); diag } #[expect(clippy::too_many_arguments)] pub fn create_lint_diagnostic( body: B, suggestion: Option, range: TextRange, fix: Option, parent: Option, file: SourceFile, noqa_offset: Option, rule: Rule, ) -> Diagnostic where B: Display, S: Display, { let mut diagnostic = Diagnostic::new( DiagnosticId::Lint(LintName::of(rule.into())), Severity::Error, body, ); let span = Span::from(file).with_range(range); let annotation = Annotation::primary(span); diagnostic.annotate(annotation); if let Some(suggestion) = suggestion { diagnostic.help(suggestion); } if let Some(fix) = fix { diagnostic.set_fix(fix); } if let Some(parent) = parent { diagnostic.set_parent(parent); } if let Some(noqa_offset) = noqa_offset { diagnostic.set_noqa_offset(noqa_offset); } diagnostic.set_secondary_code(SecondaryCode::new(rule.noqa_code().to_string())); diagnostic } impl FileResolver for EmitterContext<'_> { fn path(&self, _file: File) -> &str { unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic"); } fn input(&self, _file: File) -> Input { unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic"); } fn notebook_index(&self, file: &UnifiedFile) -> Option { match file { UnifiedFile::Ty(_) => { unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic") } UnifiedFile::Ruff(file) => self.notebook_indexes.get(file.name()).cloned(), } } fn is_notebook(&self, file: &UnifiedFile) -> bool { match file { UnifiedFile::Ty(_) => { unimplemented!("Expected a Ruff file for rendering a Ruff diagnostic") } UnifiedFile::Ruff(file) => self.notebook_indexes.get(file.name()).is_some(), } } fn current_directory(&self) -> &std::path::Path { crate::fs::get_cwd() } } struct MessageWithLocation<'a> { message: &'a Diagnostic, start_location: LineColumn, } impl Deref for MessageWithLocation<'_> { type Target = Diagnostic; fn deref(&self) -> &Self::Target { self.message } } fn group_diagnostics_by_filename( diagnostics: &[Diagnostic], ) -> BTreeMap> { let mut grouped_messages = BTreeMap::default(); for diagnostic in diagnostics { grouped_messages .entry(diagnostic.expect_ruff_filename()) .or_insert_with(Vec::new) .push(MessageWithLocation { message: diagnostic, start_location: diagnostic.expect_ruff_start_location(), }); } grouped_messages } /// Display format for [`Diagnostic`]s. /// /// The emitter serializes a slice of [`Diagnostic`]s and writes them to a [`Write`]. pub trait Emitter { /// Serializes the `diagnostics` and writes the output to `writer`. fn emit( &mut self, writer: &mut dyn Write, diagnostics: &[Diagnostic], context: &EmitterContext, ) -> anyhow::Result<()>; } /// Context passed to [`Emitter`]. pub struct EmitterContext<'a> { notebook_indexes: &'a FxHashMap, } impl<'a> EmitterContext<'a> { pub fn new(notebook_indexes: &'a FxHashMap) -> Self { Self { notebook_indexes } } /// Tests if the file with `name` is a jupyter notebook. pub fn is_notebook(&self, name: &str) -> bool { self.notebook_indexes.contains_key(name) } pub fn notebook_index(&self, name: &str) -> Option<&NotebookIndex> { self.notebook_indexes.get(name) } } #[cfg(test)] mod tests { use rustc_hash::FxHashMap; use ruff_db::diagnostic::Diagnostic; use ruff_notebook::NotebookIndex; use ruff_python_parser::{Mode, ParseOptions, parse_unchecked}; use ruff_source_file::{OneIndexed, SourceFileBuilder}; use ruff_text_size::{TextRange, TextSize}; use crate::codes::Rule; use crate::message::{Emitter, EmitterContext, create_lint_diagnostic}; use crate::{Edit, Fix}; use super::create_syntax_error_diagnostic; pub(super) fn create_syntax_error_diagnostics() -> Vec { let source = r"from os import if call(foo def bar(): pass "; let source_file = SourceFileBuilder::new("syntax_errors.py", source).finish(); parse_unchecked(source, ParseOptions::from(Mode::Module)) .errors() .iter() .map(|parse_error| { create_syntax_error_diagnostic(source_file.clone(), &parse_error.error, parse_error) }) .collect() } pub(super) fn create_diagnostics() -> Vec { let fib = r#"import os def fibonacci(n): """Compute the nth number in the Fibonacci sequence.""" x = 1 if n == 0: return 0 elif n == 1: return 1 else: return fibonacci(n - 1) + fibonacci(n - 2) "#; let fib_source = SourceFileBuilder::new("fib.py", fib).finish(); let unused_import_start = TextSize::from(7); let unused_import = create_lint_diagnostic( "`os` imported but unused", Some("Remove unused import: `os`"), TextRange::new(unused_import_start, TextSize::from(9)), Some(Fix::unsafe_edit(Edit::range_deletion(TextRange::new( TextSize::from(0), TextSize::from(10), )))), None, fib_source.clone(), Some(unused_import_start), Rule::UnusedImport, ); let unused_variable_start = TextSize::from(94); let unused_variable = create_lint_diagnostic( "Local variable `x` is assigned to but never used", Some("Remove assignment to unused variable `x`"), TextRange::new(unused_variable_start, TextSize::from(95)), Some(Fix::unsafe_edit(Edit::deletion( TextSize::from(94), TextSize::from(99), ))), None, fib_source, Some(unused_variable_start), Rule::UnusedVariable, ); let file_2 = r"if a == 1: pass"; let undefined_name_start = TextSize::from(3); let undefined_name = create_lint_diagnostic( "Undefined name `a`", Option::<&'static str>::None, TextRange::new(undefined_name_start, TextSize::from(4)), None, None, SourceFileBuilder::new("undef.py", file_2).finish(), Some(undefined_name_start), Rule::UndefinedName, ); vec![unused_import, unused_variable, undefined_name] } pub(super) fn create_notebook_diagnostics() -> (Vec, FxHashMap) { let notebook = r"# cell 1 import os # cell 2 import math print('hello world') # cell 3 def foo(): print() x = 1 "; let notebook_source = SourceFileBuilder::new("notebook.ipynb", notebook).finish(); let unused_import_os_start = TextSize::from(16); let unused_import_os = create_lint_diagnostic( "`os` imported but unused", Some("Remove unused import: `os`"), TextRange::new(unused_import_os_start, TextSize::from(18)), Some(Fix::safe_edit(Edit::range_deletion(TextRange::new( TextSize::from(9), TextSize::from(19), )))), None, notebook_source.clone(), Some(unused_import_os_start), Rule::UnusedImport, ); let unused_import_math_start = TextSize::from(35); let unused_import_math = create_lint_diagnostic( "`math` imported but unused", Some("Remove unused import: `math`"), TextRange::new(unused_import_math_start, TextSize::from(39)), Some(Fix::safe_edit(Edit::range_deletion(TextRange::new( TextSize::from(28), TextSize::from(40), )))), None, notebook_source.clone(), Some(unused_import_math_start), Rule::UnusedImport, ); let unused_variable_start = TextSize::from(98); let unused_variable = create_lint_diagnostic( "Local variable `x` is assigned to but never used", Some("Remove assignment to unused variable `x`"), TextRange::new(unused_variable_start, TextSize::from(99)), Some(Fix::unsafe_edit(Edit::deletion( TextSize::from(94), TextSize::from(104), ))), None, notebook_source, Some(unused_variable_start), Rule::UnusedVariable, ); let mut notebook_indexes = FxHashMap::default(); notebook_indexes.insert( "notebook.ipynb".to_string(), NotebookIndex::new( vec![ OneIndexed::from_zero_indexed(0), OneIndexed::from_zero_indexed(0), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(2), OneIndexed::from_zero_indexed(2), OneIndexed::from_zero_indexed(2), OneIndexed::from_zero_indexed(2), ], vec![ OneIndexed::from_zero_indexed(0), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(0), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(2), OneIndexed::from_zero_indexed(3), OneIndexed::from_zero_indexed(0), OneIndexed::from_zero_indexed(1), OneIndexed::from_zero_indexed(2), OneIndexed::from_zero_indexed(3), ], ), ); ( vec![unused_import_os, unused_import_math, unused_variable], notebook_indexes, ) } pub(super) fn capture_emitter_output( emitter: &mut dyn Emitter, diagnostics: &[Diagnostic], ) -> String { let notebook_indexes = FxHashMap::default(); let context = EmitterContext::new(¬ebook_indexes); let mut output: Vec = Vec::new(); emitter.emit(&mut output, diagnostics, &context).unwrap(); String::from_utf8(output).expect("Output to be valid UTF-8") } pub(super) fn capture_emitter_notebook_output( emitter: &mut dyn Emitter, diagnostics: &[Diagnostic], notebook_indexes: &FxHashMap, ) -> String { let context = EmitterContext::new(notebook_indexes); let mut output: Vec = Vec::new(); emitter.emit(&mut output, diagnostics, &context).unwrap(); String::from_utf8(output).expect("Output to be valid UTF-8") } }