diff --git a/crates/ruff_annotate_snippets/src/renderer/display_list.rs b/crates/ruff_annotate_snippets/src/renderer/display_list.rs index 339484290a..4c35bc58a8 100644 --- a/crates/ruff_annotate_snippets/src/renderer/display_list.rs +++ b/crates/ruff_annotate_snippets/src/renderer/display_list.rs @@ -1183,6 +1183,21 @@ fn format_snippet<'m>( let main_range = snippet.annotations.first().map(|x| x.range.start); let origin = snippet.origin; let need_empty_header = origin.is_some() || is_first; + + let is_file_level = snippet.annotations.iter().any(|ann| ann.is_file_level); + if is_file_level { + assert!( + snippet.source.is_empty(), + "Non-empty file-level snippet that won't be rendered: {:?}", + snippet.source + ); + let header = format_header(origin, main_range, &[], is_first); + return DisplaySet { + display_lines: header.map_or_else(Vec::new, |header| vec![header]), + margin: Margin::new(0, 0, 0, 0, term_width, 0), + }; + } + let mut body = format_body( snippet, need_empty_header, diff --git a/crates/ruff_annotate_snippets/src/snippet.rs b/crates/ruff_annotate_snippets/src/snippet.rs index 386080e181..beb5dc1c66 100644 --- a/crates/ruff_annotate_snippets/src/snippet.rs +++ b/crates/ruff_annotate_snippets/src/snippet.rs @@ -124,6 +124,7 @@ pub struct Annotation<'a> { pub(crate) range: Range, pub(crate) label: Option<&'a str>, pub(crate) level: Level, + pub(crate) is_file_level: bool, } impl<'a> Annotation<'a> { @@ -131,6 +132,11 @@ impl<'a> Annotation<'a> { self.label = Some(label); self } + + pub fn is_file_level(mut self, yes: bool) -> Self { + self.is_file_level = yes; + self + } } /// Types of annotations. @@ -165,6 +171,7 @@ impl Level { range: span, label: None, level: self, + is_file_level: false, } } } diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 13e3bc6a77..7de84efe12 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -712,6 +712,11 @@ pub struct Annotation { is_primary: bool, /// The diagnostic tags associated with this annotation. tags: Vec, + /// Whether this annotation is a file-level or full-file annotation. + /// + /// When set, rendering will only include the file's name and (optional) range. Everything else + /// is omitted, including any file snippet or message. + is_file_level: bool, } impl Annotation { @@ -730,6 +735,7 @@ impl Annotation { message: None, is_primary: true, tags: Vec::new(), + is_file_level: false, } } @@ -746,6 +752,7 @@ impl Annotation { message: None, is_primary: false, tags: Vec::new(), + is_file_level: false, } } @@ -811,6 +818,21 @@ impl Annotation { pub fn push_tag(&mut self, tag: DiagnosticTag) { self.tags.push(tag); } + + /// Set whether or not this annotation is file-level. + /// + /// File-level annotations are only rendered with their file name and range, if available. This + /// is intended for backwards compatibility with Ruff diagnostics, which historically used + /// `TextRange::default` to indicate a file-level diagnostic. In the new diagnostic model, a + /// [`Span`] with a range of `None` should be used instead, as mentioned in the `Span` + /// documentation. + /// + /// TODO(brent) update this usage in Ruff and remove `is_file_level` entirely. See + /// , especially my first comment, for more + /// details. + pub fn set_file_level(&mut self, yes: bool) { + self.is_file_level = yes; + } } /// Tags that can be associated with an annotation. diff --git a/crates/ruff_db/src/diagnostic/render.rs b/crates/ruff_db/src/diagnostic/render.rs index a1794c0533..1c69999cf1 100644 --- a/crates/ruff_db/src/diagnostic/render.rs +++ b/crates/ruff_db/src/diagnostic/render.rs @@ -387,6 +387,7 @@ struct ResolvedAnnotation<'a> { line_end: OneIndexed, message: Option<&'a str>, is_primary: bool, + is_file_level: bool, } impl<'a> ResolvedAnnotation<'a> { @@ -432,6 +433,7 @@ impl<'a> ResolvedAnnotation<'a> { line_end, message: ann.get_message(), is_primary: ann.is_primary, + is_file_level: ann.is_file_level, }) } } @@ -653,6 +655,8 @@ struct RenderableAnnotation<'r> { message: Option<&'r str>, /// Whether this annotation is considered "primary" or not. is_primary: bool, + /// Whether this annotation applies to an entire file, rather than a snippet within it. + is_file_level: bool, } impl<'r> RenderableAnnotation<'r> { @@ -670,6 +674,7 @@ impl<'r> RenderableAnnotation<'r> { range, message: ann.message, is_primary: ann.is_primary, + is_file_level: ann.is_file_level, } } @@ -695,7 +700,7 @@ impl<'r> RenderableAnnotation<'r> { if let Some(message) = self.message { ann = ann.label(message); } - ann + ann.is_file_level(self.is_file_level) } } @@ -2551,7 +2556,12 @@ watermelon /// of the corresponding line minus one. (The "minus one" is because /// otherwise, the span will end where the next line begins, and this /// confuses `ruff_annotate_snippets` as of 2025-03-13.) - fn span(&self, path: &str, line_offset_start: &str, line_offset_end: &str) -> Span { + pub(super) fn span( + &self, + path: &str, + line_offset_start: &str, + line_offset_end: &str, + ) -> Span { let span = self.path(path); let file = span.expect_ty_file(); @@ -2574,7 +2584,7 @@ watermelon } /// Like `span`, but only attaches a file path. - fn path(&self, path: &str) -> Span { + pub(super) fn path(&self, path: &str) -> Span { let file = system_path_to_file(&self.db, path).unwrap(); Span::from(file) } diff --git a/crates/ruff_db/src/diagnostic/render/full.rs b/crates/ruff_db/src/diagnostic/render/full.rs index d4c286291c..95a3e1f968 100644 --- a/crates/ruff_db/src/diagnostic/render/full.rs +++ b/crates/ruff_db/src/diagnostic/render/full.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { use ruff_diagnostics::Applicability; + use ruff_text_size::TextRange; use crate::diagnostic::{ - DiagnosticFormat, Severity, + Annotation, DiagnosticFormat, Severity, render::tests::{TestEnvironment, create_diagnostics, create_syntax_error_diagnostics}, }; @@ -264,4 +265,24 @@ print() | "); } + + /// For file-level diagnostics, we expect to see the header line with the diagnostic information + /// and the `-->` line with the file information but no lines of source code. + #[test] + fn file_level() { + let mut env = TestEnvironment::new(); + env.add("example.py", ""); + env.format(DiagnosticFormat::Full); + + let mut diagnostic = env.err().build(); + let span = env.path("example.py").with_range(TextRange::default()); + let mut annotation = Annotation::primary(span); + annotation.set_file_level(true); + diagnostic.annotate(annotation); + + insta::assert_snapshot!(env.render(&diagnostic), @r" + error[test-diagnostic]: main diagnostic message + --> example.py:1:1 + "); + } } diff --git a/crates/ruff_linter/src/message/mod.rs b/crates/ruff_linter/src/message/mod.rs index 9ef41c4ed4..a23e3bbb23 100644 --- a/crates/ruff_linter/src/message/mod.rs +++ b/crates/ruff_linter/src/message/mod.rs @@ -70,7 +70,15 @@ where ); let span = Span::from(file).with_range(range); - let annotation = Annotation::primary(span); + let mut annotation = Annotation::primary(span); + // The `0..0` range is used to highlight file-level diagnostics. + // + // TODO(brent) We should instead set this flag on annotations for individual lint rules that + // actually need it, but we need to be able to cache the new diagnostic model first. See + // https://github.com/astral-sh/ruff/issues/19688. + if range == TextRange::default() { + annotation.set_file_level(true); + } diagnostic.annotate(annotation); if let Some(suggestion) = suggestion {