Skip to main content

ruff_db/diagnostic/
render.rs

1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use annotate_snippets::{
6    Annotation as AnnotateAnnotation, AnnotationKind, Group as AnnotateGroup,
7    Level as AnnotateLevel, Snippet as AnnotateSnippet,
8};
9use full::FullRenderer;
10use ruff_notebook::{Notebook, NotebookIndex};
11use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
12use ruff_text_size::{TextLen, TextRange, TextSize};
13
14use crate::{
15    Db,
16    files::File,
17    source::{SourceText, line_index, source_text},
18};
19
20use super::{
21    Annotation, Diagnostic, DiagnosticFormat, DiagnosticSource, DisplayDiagnosticConfig,
22    SubDiagnostic, UnifiedFile,
23};
24
25use azure::AzureRenderer;
26use concise::ConciseRenderer;
27use github::GithubRenderer;
28use pylint::PylintRenderer;
29
30mod azure;
31mod concise;
32mod full;
33pub mod github;
34#[cfg(feature = "serde")]
35mod gitlab;
36#[cfg(feature = "serde")]
37mod json;
38#[cfg(feature = "serde")]
39mod json_lines;
40#[cfg(feature = "junit")]
41mod junit;
42mod pylint;
43#[cfg(feature = "serde")]
44mod rdjson;
45
46/// A type that implements `std::fmt::Display` for diagnostic rendering.
47///
48/// It is created via [`Diagnostic::display`].
49///
50/// The lifetime parameter, `'a`, refers to the shorter of:
51///
52/// * The lifetime of the rendering configuration.
53/// * The lifetime of the resolver used to load the contents of `Span`
54///   values. When using Salsa, this most commonly corresponds to the lifetime
55///   of a Salsa `Db`.
56/// * The lifetime of the diagnostic being rendered.
57pub struct DisplayDiagnostic<'a> {
58    config: &'a DisplayDiagnosticConfig,
59    resolver: &'a dyn FileResolver,
60    diag: &'a Diagnostic,
61}
62
63impl<'a> DisplayDiagnostic<'a> {
64    pub(crate) fn new(
65        resolver: &'a dyn FileResolver,
66        config: &'a DisplayDiagnosticConfig,
67        diag: &'a Diagnostic,
68    ) -> DisplayDiagnostic<'a> {
69        DisplayDiagnostic {
70            config,
71            resolver,
72            diag,
73        }
74    }
75}
76
77impl std::fmt::Display for DisplayDiagnostic<'_> {
78    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
79        DisplayDiagnostics::new(self.resolver, self.config, std::slice::from_ref(self.diag)).fmt(f)
80    }
81}
82
83/// A type that implements `std::fmt::Display` for rendering a collection of diagnostics.
84///
85/// It is intended for collections of diagnostics that need to be serialized together, as is the
86/// case for JSON, for example.
87///
88/// See [`DisplayDiagnostic`] for rendering individual `Diagnostic`s and details about the lifetime
89/// constraints.
90pub struct DisplayDiagnostics<'a> {
91    config: &'a DisplayDiagnosticConfig,
92    resolver: &'a dyn FileResolver,
93    diagnostics: &'a [Diagnostic],
94}
95
96impl<'a> DisplayDiagnostics<'a> {
97    pub fn new(
98        resolver: &'a dyn FileResolver,
99        config: &'a DisplayDiagnosticConfig,
100        diagnostics: &'a [Diagnostic],
101    ) -> DisplayDiagnostics<'a> {
102        DisplayDiagnostics {
103            config,
104            resolver,
105            diagnostics,
106        }
107    }
108}
109
110impl std::fmt::Display for DisplayDiagnostics<'_> {
111    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
112        match self.config.format {
113            DiagnosticFormat::Concise => {
114                ConciseRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
115            }
116            DiagnosticFormat::Full => {
117                FullRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
118            }
119            DiagnosticFormat::Azure => {
120                AzureRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
121            }
122            #[cfg(feature = "serde")]
123            DiagnosticFormat::Json => {
124                json::JsonRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
125            }
126            #[cfg(feature = "serde")]
127            DiagnosticFormat::JsonLines => {
128                json_lines::JsonLinesRenderer::new(self.resolver, self.config)
129                    .render(f, self.diagnostics)?;
130            }
131            #[cfg(feature = "serde")]
132            DiagnosticFormat::Rdjson => {
133                rdjson::RdjsonRenderer::new(self.resolver, self.config)
134                    .render(f, self.diagnostics)?;
135            }
136            DiagnosticFormat::Pylint => {
137                PylintRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
138            }
139            #[cfg(feature = "junit")]
140            DiagnosticFormat::Junit => {
141                junit::JunitRenderer::new(self.resolver, self.config)
142                    .render(f, self.diagnostics)?;
143            }
144            #[cfg(feature = "serde")]
145            DiagnosticFormat::Gitlab => {
146                gitlab::GitlabRenderer::new(self.resolver, self.config)
147                    .render(f, self.diagnostics)?;
148            }
149            DiagnosticFormat::Github => {
150                GithubRenderer::new(self.resolver, self.config).render(f, self.diagnostics)?;
151            }
152        }
153
154        Ok(())
155    }
156}
157
158/// A sequence of resolved diagnostics.
159///
160/// Resolving a diagnostic refers to the process of restructuring its internal
161/// data in a way that enables rendering decisions. For example, a `Span`
162/// on an `Annotation` in a `Diagnostic` is intentionally very minimal, and
163/// thus doesn't have information like line numbers or even the actual file
164/// path. Resolution retrieves this information and puts it into a structured
165/// representation specifically intended for diagnostic rendering.
166///
167/// The lifetime `'a` refers to the shorter of the lifetimes between the file
168/// resolver and the diagnostic itself. (The resolved types borrow data from
169/// both.)
170#[derive(Debug)]
171struct Resolved<'a> {
172    diagnostics: Vec<ResolvedDiagnostic<'a>>,
173}
174
175impl<'a> Resolved<'a> {
176    /// Creates a new resolved set of diagnostics.
177    fn new(
178        resolver: &'a dyn FileResolver,
179        diag: &'a Diagnostic,
180        config: &DisplayDiagnosticConfig,
181    ) -> Resolved<'a> {
182        let mut diagnostics = vec![];
183        diagnostics.push(ResolvedDiagnostic::from_diagnostic(resolver, config, diag));
184        for sub in &diag.inner.subs {
185            diagnostics.push(ResolvedDiagnostic::from_sub_diagnostic(resolver, sub));
186        }
187        Resolved { diagnostics }
188    }
189
190    /// Creates a value that is amenable to rendering directly.
191    fn to_renderable(&self, config: &DisplayDiagnosticConfig) -> Renderable<'_> {
192        Renderable {
193            diagnostics: self
194                .diagnostics
195                .iter()
196                .map(|diag| diag.to_renderable(config))
197                .collect(),
198        }
199    }
200}
201
202/// A single resolved diagnostic.
203///
204/// The lifetime `'a` refers to the shorter of the lifetimes between the file
205/// resolver and the diagnostic itself. (The resolved types borrow data from
206/// both.)
207#[derive(Debug)]
208struct ResolvedDiagnostic<'a> {
209    level: AnnotateLevel<'static>,
210    id: Option<String>,
211    documentation_url: Option<String>,
212    message: String,
213    annotations: Vec<ResolvedAnnotation<'a>>,
214    is_fixable: bool,
215    header_offset: usize,
216}
217
218impl<'a> ResolvedDiagnostic<'a> {
219    /// Resolve a single diagnostic.
220    fn from_diagnostic(
221        resolver: &'a dyn FileResolver,
222        config: &DisplayDiagnosticConfig,
223        diag: &'a Diagnostic,
224    ) -> ResolvedDiagnostic<'a> {
225        let annotations: Vec<_> = diag
226            .inner
227            .annotations
228            .iter()
229            .filter_map(|ann| {
230                let path = ann
231                    .span
232                    .file
233                    .relative_path(resolver)
234                    .to_str()
235                    .unwrap_or_else(|| ann.span.file.path(resolver));
236                let diagnostic_source = ann.span.file.diagnostic_source(resolver);
237                ResolvedAnnotation::new(path, &diagnostic_source, ann, resolver)
238            })
239            .collect();
240
241        let use_code = !config.preview || config.prefer_rule_codes;
242        let id = if use_code && let Some(code) = diag.secondary_code() {
243            code.to_string()
244        } else if config.hide_severity {
245            // When Ruff gets real severities, we should put the colon back in
246            // `DisplaySet::format_annotation` for both cases, but this is a small hack to improve the
247            // formatting of human-readable names for now. This should also be kept consistent with the
248            // concise formatting.
249            format!("{id}:", id = diag.id())
250        } else {
251            diag.id().to_string()
252        };
253
254        let level = diag.inner.severity.to_annotate();
255        let level = if config.hide_severity {
256            level.no_name()
257        } else {
258            level
259        };
260
261        ResolvedDiagnostic {
262            level,
263            id: Some(id),
264            documentation_url: diag.documentation_url().map(ToString::to_string),
265            message: diag.inner.message.as_str().to_string(),
266            annotations,
267            is_fixable: config.show_fix_status
268                && diag.has_applicable_fix(config.fix_applicability()),
269            header_offset: diag.inner.header_offset,
270        }
271    }
272
273    /// Resolve a single sub-diagnostic.
274    fn from_sub_diagnostic(
275        resolver: &'a dyn FileResolver,
276        diag: &'a SubDiagnostic,
277    ) -> ResolvedDiagnostic<'a> {
278        let annotations: Vec<_> = diag
279            .inner
280            .annotations
281            .iter()
282            .filter_map(|ann| {
283                let path = ann
284                    .span
285                    .file
286                    .relative_path(resolver)
287                    .to_str()
288                    .unwrap_or_else(|| ann.span.file.path(resolver));
289                let diagnostic_source = ann.span.file.diagnostic_source(resolver);
290                ResolvedAnnotation::new(path, &diagnostic_source, ann, resolver)
291            })
292            .collect();
293        ResolvedDiagnostic {
294            level: diag.inner.severity.to_annotate(),
295            id: None,
296            documentation_url: None,
297            message: diag.inner.message.as_str().to_string(),
298            annotations,
299            is_fixable: false,
300            header_offset: 0,
301        }
302    }
303
304    /// Create a diagnostic amenable for rendering.
305    ///
306    /// `context` refers to the number of lines both before and after to show
307    /// for each snippet.
308    fn to_renderable<'r>(&'r self, config: &DisplayDiagnosticConfig) -> RenderableDiagnostic<'r> {
309        let mut ann_by_path: BTreeMap<&'a str, Vec<&ResolvedAnnotation<'a>>> = BTreeMap::new();
310        for ann in &self.annotations {
311            ann_by_path.entry(ann.path).or_default().push(ann);
312        }
313        for anns in ann_by_path.values_mut() {
314            anns.sort_by_key(|ann1| ann1.range.start());
315        }
316
317        // The merge window determines how close two annotations need
318        // to be (in lines) to be rendered inside a single snippet.
319        // This is independent of `context`, which controls how many
320        // extra padding lines appear before and after each snippet.
321        let merge_window = config.merge_window.max(config.context);
322
323        let mut snippet_by_path: BTreeMap<&'a str, Vec<Vec<&ResolvedAnnotation<'a>>>> =
324            BTreeMap::new();
325        for (path, anns) in ann_by_path {
326            let mut snippet = vec![];
327            for ann in anns {
328                let Some(prev) = snippet.last() else {
329                    snippet.push(ann);
330                    continue;
331                };
332
333                let prev_context_ends = context_after(
334                    &prev.diagnostic_source.as_source_code(),
335                    merge_window,
336                    prev.line_end,
337                    prev.notebook_index.as_ref(),
338                )
339                .get();
340                let this_context_begins = context_before(
341                    &ann.diagnostic_source.as_source_code(),
342                    merge_window,
343                    ann.line_start,
344                    ann.notebook_index.as_ref(),
345                )
346                .get();
347
348                // For notebooks, check whether the end of the
349                // previous annotation and the start of the current
350                // annotation are in different cells.
351                let prev_cell_index = prev.notebook_index.as_ref().map(|notebook_index| {
352                    let prev_end = prev
353                        .diagnostic_source
354                        .as_source_code()
355                        .line_column(prev.range.end());
356                    notebook_index.cell(prev_end.line).unwrap_or_default().get()
357                });
358                let this_cell_index = ann.notebook_index.as_ref().map(|notebook_index| {
359                    let this_start = ann
360                        .diagnostic_source
361                        .as_source_code()
362                        .line_column(ann.range.start());
363                    notebook_index
364                        .cell(this_start.line)
365                        .unwrap_or_default()
366                        .get()
367                });
368                let in_different_cells = prev_cell_index != this_cell_index;
369
370                // The boundary case here is when `prev_context_ends`
371                // is exactly one less than `this_context_begins`. In
372                // that case, the context windows are adjacent and we
373                // should fall through below to add this annotation to
374                // the existing snippet.
375                //
376                // For notebooks, also check that the context windows
377                // are in the same cell. Windows from different cells
378                // should never be considered adjacent.
379                if in_different_cells || this_context_begins.saturating_sub(prev_context_ends) > 1 {
380                    snippet_by_path
381                        .entry(path)
382                        .or_default()
383                        .push(std::mem::take(&mut snippet));
384                }
385                snippet.push(ann);
386            }
387            if !snippet.is_empty() {
388                snippet_by_path.entry(path).or_default().push(snippet);
389            }
390        }
391
392        let mut snippets_by_input = vec![];
393        for (path, snippets) in snippet_by_path {
394            snippets_by_input.push(RenderableSnippets::new(config.context, path, &snippets));
395        }
396        snippets_by_input
397            .sort_by(|snips1, snips2| snips1.has_primary.cmp(&snips2.has_primary).reverse());
398        RenderableDiagnostic {
399            level: self.level.clone(),
400            id: self.id.as_deref(),
401            documentation_url: self.documentation_url.as_deref(),
402            message: &self.message,
403            snippets_by_input,
404            is_fixable: self.is_fixable,
405            header_offset: self.header_offset,
406        }
407    }
408}
409
410/// A resolved annotation with information needed for rendering.
411///
412/// For example, this annotation has the corresponding file path, entire
413/// source code and the line numbers corresponding to its range in the source
414/// code. This information can be used to create renderable data and also
415/// sort/organize the annotations into snippets.
416#[derive(Debug)]
417struct ResolvedAnnotation<'a> {
418    path: &'a str,
419    diagnostic_source: DiagnosticSource,
420    range: TextRange,
421    line_start: OneIndexed,
422    line_end: OneIndexed,
423    message: Option<&'a str>,
424    is_primary: bool,
425    hide_snippet: bool,
426    notebook_index: Option<NotebookIndex>,
427}
428
429impl<'a> ResolvedAnnotation<'a> {
430    /// Resolve an annotation.
431    ///
432    /// `path` is the path of the file that this annotation points to.
433    ///
434    /// `input` is the contents of the file that this annotation points to.
435    fn new(
436        path: &'a str,
437        diagnostic_source: &DiagnosticSource,
438        ann: &'a Annotation,
439        resolver: &'a dyn FileResolver,
440    ) -> Option<ResolvedAnnotation<'a>> {
441        let source = diagnostic_source.as_source_code();
442        let (range, line_start, line_end) = match (ann.span.range(), ann.message.is_some()) {
443            // An annotation with no range AND no message is probably(?)
444            // meaningless, but we should try to render it anyway.
445            (None, _) => (
446                TextRange::empty(TextSize::new(0)),
447                OneIndexed::MIN,
448                OneIndexed::MIN,
449            ),
450            (Some(range), _) => {
451                let line_start = source.line_index(range.start());
452                let mut line_end = source.line_index(range.end());
453                // As a special case, if the *end* of our range comes
454                // right after a line terminator, we say that the last
455                // line number for this annotation is the previous
456                // line and not the next line. In other words, in this
457                // case, we treat our line number as an inclusive
458                // upper bound.
459                if source.slice(range).ends_with(['\r', '\n']) {
460                    line_end = line_end.saturating_sub(1).max(line_start);
461                }
462                (range, line_start, line_end)
463            }
464        };
465        Some(ResolvedAnnotation {
466            path,
467            diagnostic_source: diagnostic_source.clone(),
468            range,
469            line_start,
470            line_end,
471            message: ann.get_message(),
472            is_primary: ann.is_primary,
473            hide_snippet: ann.hide_snippet,
474            notebook_index: resolver.notebook_index(&ann.span.file),
475        })
476    }
477}
478
479/// A single unit of rendering consisting of one or more diagnostics.
480///
481/// There is always exactly one "main" diagnostic that comes first, followed by
482/// zero or more sub-diagnostics.
483///
484/// The lifetime parameter `'r` refers to the lifetime of whatever created this
485/// renderable value. This is usually the lifetime of `Resolved`.
486#[derive(Debug)]
487struct Renderable<'r> {
488    diagnostics: Vec<RenderableDiagnostic<'r>>,
489}
490
491/// A single diagnostic amenable to rendering.
492#[derive(Debug)]
493struct RenderableDiagnostic<'r> {
494    /// The severity of the diagnostic.
495    level: AnnotateLevel<'static>,
496    /// The ID of the diagnostic. The ID can usually be used on the CLI or in a
497    /// config file to change the severity of a lint.
498    ///
499    /// An ID is always present for top-level diagnostics and always absent for
500    /// sub-diagnostics.
501    id: Option<&'r str>,
502    documentation_url: Option<&'r str>,
503    /// The message emitted with the diagnostic, before any snippets are
504    /// rendered.
505    message: &'r str,
506    /// A collection of collections of snippets. Each collection of snippets
507    /// should be from the same file, and none of the snippets inside of a
508    /// collection should overlap with one another or be directly adjacent.
509    snippets_by_input: Vec<RenderableSnippets<'r>>,
510    /// Whether or not the diagnostic is fixable.
511    ///
512    /// This is rendered as a `[*]` indicator after the diagnostic ID.
513    is_fixable: bool,
514    /// Offset to align the header sigil (`-->`) with the subsequent line number separators.
515    ///
516    /// This is only needed for formatter diagnostics where we don't render a snippet via
517    /// `annotate-snippets` and thus the alignment isn't computed automatically.
518    header_offset: usize,
519}
520
521impl RenderableDiagnostic<'_> {
522    /// Convert this to an "annotate" snippet.
523    fn to_annotate(&self) -> AnnotateGroup<'_> {
524        let snippets = self.snippets_by_input.iter().flat_map(|snippets| {
525            let path = snippets.path;
526            snippets
527                .snippets
528                .iter()
529                .map(|snippet| snippet.to_annotate(path))
530        });
531        let mut title = self
532            .level
533            .clone()
534            .primary_title(self.message)
535            .is_fixable(self.is_fixable);
536        if let Some(id) = self.id {
537            title = title.id(id);
538            if let Some(url) = self.documentation_url {
539                title = title.id_url(url);
540            }
541        }
542        title.elements(snippets).lineno_offset(self.header_offset)
543    }
544}
545
546/// A collection of renderable snippets for a single file.
547#[derive(Debug)]
548struct RenderableSnippets<'r> {
549    /// The path to the file from which all snippets originate from.
550    path: &'r str,
551    /// The snippets, the in order of desired rendering.
552    snippets: Vec<RenderableSnippet<'r>>,
553    /// Whether this contains any snippets with any annotations marked
554    /// as primary. This is useful for re-sorting snippets such that
555    /// the ones with primary annotations are rendered first.
556    has_primary: bool,
557}
558
559impl<'r> RenderableSnippets<'r> {
560    /// Creates a new collection of renderable snippets.
561    ///
562    /// `context` is the number of lines to include before and after each
563    /// snippet.
564    ///
565    /// `path` is the file path containing the given snippets. (They should all
566    /// come from the same file path.)
567    ///
568    /// The lifetime parameter `'r` refers to the lifetime of the resolved
569    /// annotation given (since the renderable snippet returned borrows from
570    /// the resolved annotation's `Input`). This is no longer than the lifetime
571    /// of the resolver that produced the resolved annotation.
572    ///
573    /// # Panics
574    ///
575    /// When `resolved_snippets.is_empty()`.
576    fn new<'a>(
577        context: usize,
578        path: &'r str,
579        resolved_snippets: &'a [Vec<&'r ResolvedAnnotation<'r>>],
580    ) -> RenderableSnippets<'r> {
581        assert!(!resolved_snippets.is_empty());
582
583        let mut has_primary = false;
584        let mut snippets = vec![];
585        for anns in resolved_snippets {
586            let snippet = RenderableSnippet::new(context, anns);
587            has_primary = has_primary || snippet.has_primary;
588            snippets.push(snippet);
589        }
590        snippets.sort_by(|s1, s2| s1.has_primary.cmp(&s2.has_primary).reverse());
591        RenderableSnippets {
592            path,
593            snippets,
594            has_primary,
595        }
596    }
597}
598
599/// A single snippet of code that is rendered as part of a diagnostic message.
600///
601/// The intent is that a snippet for one diagnostic does not overlap (or is
602/// even directly adjacent to) any other snippets for that same diagnostic.
603/// Callers creating a `RenderableSnippet` should enforce this guarantee by
604/// grouping annotations according to the lines on which they start and stop.
605///
606/// Snippets from different diagnostics (including sub-diagnostics) may
607/// overlap.
608#[derive(Debug)]
609struct RenderableSnippet<'r> {
610    /// The actual snippet text.
611    snippet: Cow<'r, str>,
612    /// The absolute line number corresponding to where this
613    /// snippet begins.
614    line_start: OneIndexed,
615    /// A non-zero number of annotations on this snippet.
616    annotations: Vec<RenderableAnnotation<'r>>,
617    /// Whether this snippet contains at least one primary
618    /// annotation.
619    has_primary: bool,
620    /// The cell index in a Jupyter notebook, if this snippet refers to a notebook.
621    ///
622    /// This is used for rendering annotations with offsets like `cell 1:2:3` instead of simple row
623    /// and column numbers.
624    cell_index: Option<usize>,
625}
626
627impl<'r> RenderableSnippet<'r> {
628    /// Creates a new snippet with one or more annotations that is ready to be
629    /// rendered.
630    ///
631    /// The first line of the snippet is the smallest line number on which one
632    /// of the annotations begins, minus the context window size. The last line
633    /// is the largest line number on which one of the annotations ends, plus
634    /// the context window size.
635    ///
636    /// For Jupyter notebooks, the context window may also be truncated at cell
637    /// boundaries. If multiple annotations are present, and they point to
638    /// different cells, these will have already been split into separate
639    /// snippets by `ResolvedDiagnostic::to_renderable`.
640    ///
641    /// Callers should guarantee that the `input` on every `ResolvedAnnotation`
642    /// given is identical.
643    ///
644    /// The lifetime of the snippet returned is only tied to the lifetime of
645    /// the borrowed resolved annotation given (which is no longer than the
646    /// lifetime of the resolver that produced the resolved annotation).
647    ///
648    /// # Panics
649    ///
650    /// When `anns.is_empty()`.
651    fn new<'a>(context: usize, anns: &'a [&'r ResolvedAnnotation<'r>]) -> RenderableSnippet<'r> {
652        assert!(
653            !anns.is_empty(),
654            "creating a renderable snippet requires a non-zero number of annotations",
655        );
656        let diagnostic_source = &anns[0].diagnostic_source;
657        let notebook_index = anns[0].notebook_index.as_ref();
658        let source = diagnostic_source.as_source_code();
659        let has_primary = anns.iter().any(|ann| ann.is_primary);
660
661        let content_start_index = anns.iter().map(|ann| ann.line_start).min().unwrap();
662        let line_start = context_before(&source, context, content_start_index, notebook_index);
663
664        let start = source.line_column(anns[0].range.start());
665        let cell_index = notebook_index
666            .map(|notebook_index| notebook_index.cell(start.line).unwrap_or_default().get());
667
668        let content_end_index = anns.iter().map(|ann| ann.line_end).max().unwrap();
669        let line_end = context_after(&source, context, content_end_index, notebook_index);
670
671        let snippet_start = source.line_start(line_start);
672        let snippet_end = source.line_end(line_end);
673        let snippet = diagnostic_source
674            .as_source_code()
675            .slice(TextRange::new(snippet_start, snippet_end));
676
677        // Strip the BOM from the beginning of the snippet, if present. Doing this here saves us the
678        // trouble of updating the annotation ranges in `replace_unprintable`, and also allows us to
679        // check that the BOM is at the very beginning of the file, not just the beginning of the
680        // snippet.
681        const BOM: char = '\u{feff}';
682        let bom_len = BOM.text_len();
683        let (snippet, snippet_start) =
684            if snippet_start == TextSize::ZERO && snippet.starts_with(BOM) {
685                (
686                    &snippet[bom_len.to_usize()..],
687                    snippet_start + TextSize::new(bom_len.to_u32()),
688                )
689            } else {
690                (snippet, snippet_start)
691            };
692
693        let annotations = anns
694            .iter()
695            .map(|ann| RenderableAnnotation::new(snippet_start, ann))
696            .collect();
697
698        let EscapedSourceCode {
699            text: snippet,
700            annotations,
701        } = replace_unprintable(snippet, annotations).fix_up_empty_spans_after_line_terminator();
702
703        let line_start = notebook_index.map_or(line_start, |notebook_index| {
704            notebook_index
705                .cell_row(line_start)
706                .unwrap_or(OneIndexed::MIN)
707        });
708
709        RenderableSnippet {
710            snippet,
711            line_start,
712            annotations,
713            has_primary,
714            cell_index,
715        }
716    }
717
718    /// Convert this to an "annotate" snippet.
719    fn to_annotate<'a>(&'a self, path: &'a str) -> AnnotateSnippet<'a, AnnotateAnnotation<'a>> {
720        AnnotateSnippet::source(self.snippet.as_ref())
721            .path(path)
722            .line_start(self.line_start.get())
723            .fold(false)
724            .annotations(
725                self.annotations
726                    .iter()
727                    .map(RenderableAnnotation::to_annotate),
728            )
729            .cell_index(self.cell_index)
730    }
731}
732
733/// A single annotation represented in a way that is amenable to rendering.
734#[derive(Debug)]
735struct RenderableAnnotation<'r> {
736    /// The range of the annotation relative to the snippet
737    /// it points to. This is *not* the absolute range in the
738    /// corresponding file.
739    range: TextRange,
740    /// An optional message or label associated with this annotation.
741    message: Option<&'r str>,
742    /// Whether this annotation is considered "primary" or not.
743    is_primary: bool,
744    /// Whether the snippet for this annotation should be hidden instead of rendered.
745    hide_snippet: bool,
746}
747
748impl<'r> RenderableAnnotation<'r> {
749    /// Create a new renderable annotation.
750    ///
751    /// `snippet_start` should be the absolute offset at which the snippet
752    /// pointing to by the given annotation begins.
753    ///
754    /// The lifetime of the resolved annotation does not matter. The `'r`
755    /// lifetime parameter here refers to the lifetime of the resolver that
756    /// created the given `ResolvedAnnotation`.
757    fn new(snippet_start: TextSize, ann: &'_ ResolvedAnnotation<'r>) -> RenderableAnnotation<'r> {
758        // This should only ever saturate if a BOM is present _and_ the annotation range points
759        // before the BOM (i.e. at offset 0). In Ruff this typically results from the use of
760        // `TextRange::default()` for a diagnostic range instead of a range relative to file
761        // contents.
762        let range = ann.range.checked_sub(snippet_start).unwrap_or(ann.range);
763        RenderableAnnotation {
764            range,
765            message: ann.message,
766            is_primary: ann.is_primary,
767            hide_snippet: ann.hide_snippet,
768        }
769    }
770
771    /// Convert this to an "annotate" annotation.
772    fn to_annotate(&self) -> AnnotateAnnotation<'_> {
773        let kind = if self.is_primary {
774            AnnotationKind::Primary
775        } else {
776            AnnotationKind::Context
777        };
778        let mut ann = kind.span(self.range.into());
779        if let Some(message) = self.message {
780            ann = ann.label(message);
781        }
782        ann.hide_snippet(self.hide_snippet)
783    }
784}
785
786/// A trait that facilitates the retrieval of source code from a `Span`.
787///
788/// At present, this is tightly coupled with a Salsa database. In the future,
789/// it is intended for this resolver to become an abstraction providing a
790/// similar API. We define things this way for now to keep the Salsa coupling
791/// at "arm's" length, and to make it easier to do the actual de-coupling in
792/// the future.
793///
794/// For example, at time of writing (2025-03-07), the plan is (roughly) for
795/// Ruff to grow its own interner of file paths so that a `Span` can store an
796/// interned ID instead of a (roughly) `Arc<Path>`. This interner is planned
797/// to be entirely separate from the Salsa interner used by ty, and so,
798/// callers will need to pass in a different "resolver" for turning `Span`s
799/// into actual file paths/contents. The infrastructure for this isn't fully in
800/// place, but this type serves to demarcate the intended abstraction boundary.
801pub trait FileResolver {
802    /// Returns the path associated with the file given.
803    fn path(&self, file: File) -> &str;
804
805    /// Returns the input contents associated with the file given.
806    fn input(&self, file: File) -> Input;
807
808    /// Returns the [`NotebookIndex`] associated with the file given, if it's a Jupyter notebook.
809    fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex>;
810
811    /// Returns whether the file given is a Jupyter notebook.
812    fn is_notebook(&self, file: &UnifiedFile) -> bool;
813
814    /// Returns the current working directory.
815    fn current_directory(&self) -> &Path;
816}
817
818impl<T> FileResolver for T
819where
820    T: Db,
821{
822    fn path(&self, file: File) -> &str {
823        file.path(self).as_str()
824    }
825
826    fn input(&self, file: File) -> Input {
827        Input {
828            text: source_text(self, file),
829            line_index: line_index(self, file),
830        }
831    }
832
833    fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex> {
834        match file {
835            UnifiedFile::Ty(file) => self
836                .input(*file)
837                .text
838                .as_notebook()
839                .map(Notebook::index)
840                .cloned(),
841            UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
842        }
843    }
844
845    fn is_notebook(&self, file: &UnifiedFile) -> bool {
846        match file {
847            UnifiedFile::Ty(file) => self.input(*file).text.as_notebook().is_some(),
848            UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
849        }
850    }
851
852    fn current_directory(&self) -> &Path {
853        self.system().current_directory().as_std_path()
854    }
855}
856
857impl FileResolver for &dyn Db {
858    fn path(&self, file: File) -> &str {
859        file.path(*self).as_str()
860    }
861
862    fn input(&self, file: File) -> Input {
863        Input {
864            text: source_text(*self, file),
865            line_index: line_index(*self, file),
866        }
867    }
868
869    fn notebook_index(&self, file: &UnifiedFile) -> Option<NotebookIndex> {
870        match file {
871            UnifiedFile::Ty(file) => self
872                .input(*file)
873                .text
874                .as_notebook()
875                .map(Notebook::index)
876                .cloned(),
877            UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
878        }
879    }
880
881    fn is_notebook(&self, file: &UnifiedFile) -> bool {
882        match file {
883            UnifiedFile::Ty(file) => self.input(*file).text.as_notebook().is_some(),
884            UnifiedFile::Ruff(_) => unimplemented!("Expected an interned ty file"),
885        }
886    }
887
888    fn current_directory(&self) -> &Path {
889        self.system().current_directory().as_std_path()
890    }
891}
892
893/// An abstraction over a unit of user input.
894///
895/// A single unit of user input usually corresponds to a `File`.
896/// This contains the actual content of that input as well as a
897/// line index for efficiently querying its contents.
898#[derive(Clone, Debug)]
899pub struct Input {
900    pub(crate) text: SourceText,
901    pub(crate) line_index: LineIndex,
902}
903
904/// Returns the line number accounting for the given `len`
905/// number of preceding context lines.
906///
907/// The line number returned is guaranteed to be less than
908/// or equal to `start`.
909///
910/// In Jupyter notebooks, lines outside the cell containing
911/// `start` will be omitted.
912fn context_before(
913    source: &SourceCode<'_, '_>,
914    len: usize,
915    start: OneIndexed,
916    notebook_index: Option<&NotebookIndex>,
917) -> OneIndexed {
918    let mut line = start.saturating_sub(len);
919    // Trim leading empty lines.
920    while line < start {
921        if !source.line_text(line).trim().is_empty() {
922            break;
923        }
924        line = line.saturating_add(1);
925    }
926
927    if let Some(index) = notebook_index {
928        let content_start_cell = index.cell(start).unwrap_or(OneIndexed::MIN);
929        while line < start {
930            if index.cell(line).unwrap_or(OneIndexed::MIN) == content_start_cell {
931                break;
932            }
933            line = line.saturating_add(1);
934        }
935    }
936
937    line
938}
939
940/// Returns the line number accounting for the given `len`
941/// number of following context lines.
942///
943/// The line number returned is guaranteed to be greater
944/// than or equal to `start` and no greater than the
945/// number of lines in `source`.
946///
947/// In Jupyter notebooks, lines outside the cell containing
948/// `start` will be omitted.
949fn context_after(
950    source: &SourceCode<'_, '_>,
951    len: usize,
952    start: OneIndexed,
953    notebook_index: Option<&NotebookIndex>,
954) -> OneIndexed {
955    let max_lines = OneIndexed::from_zero_indexed(source.line_count());
956    let mut line = start.saturating_add(len).min(max_lines);
957    // Trim trailing empty lines.
958    while line > start {
959        if !source.line_text(line).trim().is_empty() {
960            break;
961        }
962        line = line.saturating_sub(1);
963    }
964
965    if let Some(index) = notebook_index {
966        let content_end_cell = index.cell(start).unwrap_or(OneIndexed::MIN);
967        while line > start {
968            if index.cell(line).unwrap_or(OneIndexed::MIN) == content_end_cell {
969                break;
970            }
971            line = line.saturating_sub(1);
972        }
973    }
974
975    line
976}
977
978/// Given some source code and annotation ranges, this routine replaces
979/// unprintable characters with printable representations of them.
980///
981/// The source code and annotations returned are updated to reflect changes made
982/// to the source code (if any).
983///
984/// We don't need to normalize whitespace, such as converting tabs to spaces,
985/// because `annotate-snippets` handles that internally. Similarly, it's safe to
986/// modify the annotation ranges by inserting 3-byte Unicode replacements
987/// because `annotate-snippets` will account for their actual width when
988/// rendering and displaying the column to the user.
989fn replace_unprintable<'r>(
990    source: &'r str,
991    mut annotations: Vec<RenderableAnnotation<'r>>,
992) -> EscapedSourceCode<'r> {
993    // Updates the annotation ranges given by the caller whenever a single byte (at `index` in
994    // `source`) is replaced with `len` bytes.
995    //
996    // When the index occurs before the start of the range, the range is
997    // offset by `len`. When the range occurs after or at the start but before
998    // the end, then the end of the range only is offset by `len`.
999    let mut update_ranges = |index: usize, len: u32| {
1000        for ann in &mut annotations {
1001            if index < usize::from(ann.range.start()) {
1002                ann.range += TextSize::new(len - 1);
1003            } else if index < usize::from(ann.range.end()) {
1004                ann.range = ann.range.add_end(TextSize::new(len - 1));
1005            }
1006        }
1007    };
1008
1009    // If `c` is an unprintable character, then this returns a printable
1010    // representation of it (using a fancier Unicode codepoint).
1011    let unprintable_replacement = |c: char| -> Option<char> {
1012        match c {
1013            '\x07' => Some('␇'),
1014            '\x08' => Some('␈'),
1015            '\x1b' => Some('␛'),
1016            '\x7f' => Some('␡'),
1017            _ => None,
1018        }
1019    };
1020
1021    let mut last_end = 0;
1022    let mut result = String::new();
1023    for (index, c) in source.char_indices() {
1024        // normalize `\r` line endings but don't double `\r\n`
1025        if c == '\r' && !source[index + 1..].starts_with("\n") {
1026            result.push_str(&source[last_end..index]);
1027            result.push('\n');
1028            last_end = index + 1;
1029        } else if let Some(printable) = unprintable_replacement(c) {
1030            result.push_str(&source[last_end..index]);
1031
1032            let len = printable.text_len().to_u32();
1033            update_ranges(result.text_len().to_usize(), len);
1034
1035            result.push(printable);
1036            last_end = index + 1;
1037        }
1038    }
1039
1040    // No tabs or unprintable chars
1041    if result.is_empty() {
1042        EscapedSourceCode {
1043            annotations,
1044            text: Cow::Borrowed(source),
1045        }
1046    } else {
1047        result.push_str(&source[last_end..]);
1048        EscapedSourceCode {
1049            annotations,
1050            text: Cow::Owned(result),
1051        }
1052    }
1053}
1054
1055struct EscapedSourceCode<'r> {
1056    text: Cow<'r, str>,
1057    annotations: Vec<RenderableAnnotation<'r>>,
1058}
1059
1060impl<'r> EscapedSourceCode<'r> {
1061    // This attempts to "fix up" the spans on each annotation  in the case where
1062    // it's an empty span immediately following a line terminator.
1063    //
1064    // At present, `annotate-snippets` (both upstream and our vendored copy)
1065    // will render annotations of such spans to point to the space immediately
1066    // following the previous line. But ideally, this should point to the space
1067    // immediately preceding the next line.
1068    //
1069    // After attempting to fix `annotate-snippets` and giving up after a couple
1070    // hours, this routine takes a different tact: it adjusts the span to be
1071    // non-empty and it will cover the first codepoint of the following line.
1072    // This forces `annotate-snippets` to point to the right place.
1073    //
1074    // See also: <https://github.com/astral-sh/ruff/issues/15509> and
1075    // `ruff_linter::message::text::SourceCode::fix_up_empty_spans_after_line_terminator`,
1076    // from which this was adapted.
1077    fn fix_up_empty_spans_after_line_terminator(mut self) -> EscapedSourceCode<'r> {
1078        for ann in &mut self.annotations {
1079            let range = ann.range;
1080            if !range.is_empty()
1081                || range.start() == TextSize::from(0)
1082                || range.start() >= self.text.text_len()
1083            {
1084                continue;
1085            }
1086            if !matches!(
1087                self.text.as_bytes()[range.start().to_usize() - 1],
1088                b'\n' | b'\r'
1089            ) {
1090                continue;
1091            }
1092            let start = range.start();
1093            let end =
1094                TextSize::try_from(self.text.ceil_char_boundary(start.to_usize() + 1)).unwrap();
1095            ann.range = TextRange::new(start, end);
1096        }
1097
1098        self
1099    }
1100}
1101
1102/// A stub implementation of [`FileResolver`] intended for testing.
1103pub struct DummyFileResolver;
1104
1105impl FileResolver for DummyFileResolver {
1106    fn path(&self, _file: File) -> &str {
1107        unimplemented!()
1108    }
1109
1110    fn input(&self, _file: File) -> Input {
1111        unimplemented!()
1112    }
1113
1114    fn notebook_index(&self, _file: &UnifiedFile) -> Option<NotebookIndex> {
1115        None
1116    }
1117
1118    fn is_notebook(&self, _file: &UnifiedFile) -> bool {
1119        false
1120    }
1121
1122    fn current_directory(&self) -> &Path {
1123        Path::new(".")
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129
1130    use ruff_diagnostics::{Applicability, Edit, Fix};
1131
1132    use crate::diagnostic::{
1133        Annotation, DiagnosticId, IntoDiagnosticMessage, SecondaryCode, Severity, Span,
1134        SubDiagnosticSeverity,
1135    };
1136    use crate::files::system_path_to_file;
1137    use crate::system::{DbWithWritableSystem, SystemPath};
1138    use crate::tests::TestDb;
1139
1140    use super::*;
1141
1142    static ANIMALS: &str = "\
1143aardvark
1144beetle
1145canary
1146dog
1147elephant
1148finch
1149gorilla
1150hippopotamus
1151inchworm
1152jackrabbit
1153kangaroo
1154";
1155
1156    // Useful for testing context windows that trim leading/trailing
1157    // lines that are pure whitespace or empty.
1158    static SPACEY_ANIMALS: &str = "\
1159aardvark
1160
1161beetle
1162
1163canary
1164
1165dog
1166elephant
1167finch
1168
1169gorilla
1170hippopotamus
1171inchworm
1172jackrabbit
1173
1174kangaroo
1175";
1176
1177    static FRUITS: &str = "\
1178apple
1179banana
1180cantaloupe
1181lime
1182orange
1183pear
1184raspberry
1185strawberry
1186tomato
1187watermelon
1188";
1189
1190    static NON_ASCII: &str = "\
1191☃☃☃☃☃☃☃☃☃☃☃☃
1192💩💩💩💩💩💩💩💩💩💩💩💩
1193ΔΔΔΔΔΔΔΔΔΔΔΔ
1194ββββββββββββ
1195ΣΣΣΣΣΣΣΣΣΣΣΣ
1196ξξξξξξξξξξξξ
1197ππππππππππππ
1198θθθθθθθθθθθθ
1199ΦΦΦΦΦΦΦΦΦΦΦΦ
1200λλλλλλλλλλλλ
1201";
1202
1203    #[test]
1204    fn basic() {
1205        let mut env = TestEnvironment::new();
1206        env.add("animals", ANIMALS);
1207
1208        let diag = env.err().primary("animals", "5", "5", "").build();
1209        insta::assert_snapshot!(
1210            env.render(&diag),
1211            @"
1212        error[test-diagnostic]: main diagnostic message
1213         --> animals:5:1
1214          |
1215        3 | canary
1216        4 | dog
1217        5 | elephant
1218          | ^^^^^^^^
1219        6 | finch
1220        7 | gorilla
1221          |
1222        ",
1223        );
1224
1225        let diag = env
1226            .builder(
1227                "test-diagnostic",
1228                Severity::Warning,
1229                "main diagnostic message",
1230            )
1231            .primary("animals", "5", "5", "")
1232            .build();
1233        insta::assert_snapshot!(
1234            env.render(&diag),
1235            @"
1236        warning[test-diagnostic]: main diagnostic message
1237         --> animals:5:1
1238          |
1239        3 | canary
1240        4 | dog
1241        5 | elephant
1242          | ^^^^^^^^
1243        6 | finch
1244        7 | gorilla
1245          |
1246        ",
1247        );
1248
1249        let diag = env
1250            .builder("test-diagnostic", Severity::Info, "main diagnostic message")
1251            .primary("animals", "5", "5", "")
1252            .build();
1253        insta::assert_snapshot!(
1254            env.render(&diag),
1255            @"
1256        info[test-diagnostic]: main diagnostic message
1257         --> animals:5:1
1258          |
1259        3 | canary
1260        4 | dog
1261        5 | elephant
1262          | ^^^^^^^^
1263        6 | finch
1264        7 | gorilla
1265          |
1266        ",
1267        );
1268    }
1269
1270    #[test]
1271    fn no_range() {
1272        let mut env = TestEnvironment::new();
1273        env.add("animals", ANIMALS);
1274
1275        let mut builder = env.err();
1276        builder
1277            .diag
1278            .annotate(Annotation::primary(builder.env.path("animals")));
1279        let diag = builder.build();
1280        insta::assert_snapshot!(
1281            env.render(&diag),
1282            @"
1283        error[test-diagnostic]: main diagnostic message
1284         --> animals:1:1
1285          |
1286        1 | aardvark
1287          | ^
1288        2 | beetle
1289        3 | canary
1290          |
1291        ",
1292        );
1293
1294        let mut builder = env.err();
1295        builder.diag.annotate(
1296            Annotation::primary(builder.env.path("animals")).message("primary annotation message"),
1297        );
1298        let diag = builder.build();
1299        insta::assert_snapshot!(
1300            env.render(&diag),
1301            @"
1302        error[test-diagnostic]: main diagnostic message
1303         --> animals:1:1
1304          |
1305        1 | aardvark
1306          | ^ primary annotation message
1307        2 | beetle
1308        3 | canary
1309          |
1310        ",
1311        );
1312    }
1313
1314    #[test]
1315    fn non_ascii() {
1316        let mut env = TestEnvironment::new();
1317        env.add("non-ascii", NON_ASCII);
1318
1319        let diag = env.err().primary("non-ascii", "5", "5", "").build();
1320        insta::assert_snapshot!(
1321            env.render(&diag),
1322            @"
1323        error[test-diagnostic]: main diagnostic message
1324         --> non-ascii:5:1
1325          |
1326        3 | ΔΔΔΔΔΔΔΔΔΔΔΔ
1327        4 | ββββββββββββ
1328        5 | ΣΣΣΣΣΣΣΣΣΣΣΣ
1329          | ^^^^^^^^^^^^
1330        6 | ξξξξξξξξξξξξ
1331        7 | ππππππππππππ
1332          |
1333        ",
1334        );
1335
1336        // Just highlight one multi-byte codepoint
1337        // that has a >1 Unicode width.
1338        let diag = env.err().primary("non-ascii", "2:4", "2:8", "").build();
1339        insta::assert_snapshot!(
1340            env.render(&diag),
1341            @"
1342        error[test-diagnostic]: main diagnostic message
1343         --> non-ascii:2:2
1344          |
1345        1 | ☃☃☃☃☃☃☃☃☃☃☃☃
1346        2 | 💩💩💩💩💩💩💩💩💩💩💩💩
1347          |   ^^
1348        3 | ΔΔΔΔΔΔΔΔΔΔΔΔ
1349        4 | ββββββββββββ
1350          |
1351        ",
1352        );
1353    }
1354
1355    #[test]
1356    fn config_context() {
1357        let mut env = TestEnvironment::new();
1358        env.add("animals", ANIMALS);
1359
1360        // Smaller context
1361        let diag = env.err().primary("animals", "5", "5", "").build();
1362        env.context(1);
1363        insta::assert_snapshot!(
1364            env.render(&diag),
1365            @"
1366        error[test-diagnostic]: main diagnostic message
1367         --> animals:5:1
1368          |
1369        4 | dog
1370        5 | elephant
1371          | ^^^^^^^^
1372        6 | finch
1373          |
1374        ",
1375        );
1376
1377        // No context
1378        let diag = env.err().primary("animals", "5", "5", "").build();
1379        env.context(0);
1380        insta::assert_snapshot!(
1381            env.render(&diag),
1382            @"
1383        error[test-diagnostic]: main diagnostic message
1384         --> animals:5:1
1385          |
1386        5 | elephant
1387          | ^^^^^^^^
1388        ",
1389        );
1390
1391        // No context before snippet
1392        let diag = env.err().primary("animals", "1", "1", "").build();
1393        env.context(2);
1394        insta::assert_snapshot!(
1395            env.render(&diag),
1396            @"
1397        error[test-diagnostic]: main diagnostic message
1398         --> animals:1:1
1399          |
1400        1 | aardvark
1401          | ^^^^^^^^
1402        2 | beetle
1403        3 | canary
1404          |
1405        ",
1406        );
1407
1408        // No context after snippet
1409        let diag = env.err().primary("animals", "11", "11", "").build();
1410        env.context(2);
1411        insta::assert_snapshot!(
1412            env.render(&diag),
1413            @"
1414        error[test-diagnostic]: main diagnostic message
1415          --> animals:11:1
1416           |
1417         9 | inchworm
1418        10 | jackrabbit
1419        11 | kangaroo
1420           | ^^^^^^^^
1421        ",
1422        );
1423
1424        // Context that exceeds source
1425        let diag = env.err().primary("animals", "5", "5", "").build();
1426        env.context(200);
1427        insta::assert_snapshot!(
1428            env.render(&diag),
1429            @"
1430        error[test-diagnostic]: main diagnostic message
1431          --> animals:5:1
1432           |
1433         1 | aardvark
1434         2 | beetle
1435         3 | canary
1436         4 | dog
1437         5 | elephant
1438           | ^^^^^^^^
1439         6 | finch
1440         7 | gorilla
1441         8 | hippopotamus
1442         9 | inchworm
1443        10 | jackrabbit
1444        11 | kangaroo
1445           |
1446        ",
1447        );
1448    }
1449
1450    #[test]
1451    fn multiple_annotations_non_overlapping() {
1452        let mut env = TestEnvironment::new();
1453        env.add("animals", ANIMALS);
1454
1455        let diag = env
1456            .err()
1457            .primary("animals", "1", "1", "")
1458            .primary("animals", "11", "11", "")
1459            .build();
1460        insta::assert_snapshot!(
1461            env.render(&diag),
1462            @"
1463        error[test-diagnostic]: main diagnostic message
1464          --> animals:1:1
1465           |
1466         1 | aardvark
1467           | ^^^^^^^^
1468         2 | beetle
1469         3 | canary
1470           |
1471          ::: animals:11:1
1472           |
1473         9 | inchworm
1474        10 | jackrabbit
1475        11 | kangaroo
1476           | ^^^^^^^^
1477        ",
1478        );
1479    }
1480
1481    #[test]
1482    fn multiple_annotations_adjacent_context() {
1483        let mut env = TestEnvironment::new();
1484        env.add("animals", ANIMALS);
1485
1486        // Set the context explicitly to 1 to make
1487        // it easier to reason about, and to avoid
1488        // making this test tricky to update if the
1489        // default context changes.
1490        env.context(1);
1491
1492        let diag = env
1493            .err()
1494            .primary("animals", "1", "1", "")
1495            // This is the line that immediately follows
1496            // the context from the first annotation,
1497            // so there is no overlap. But since it's
1498            // adjacent, the snippet "expands" out to
1499            // include this line. (And the line after,
1500            // for one additional line of context.)
1501            .primary("animals", "3", "3", "")
1502            .build();
1503        insta::assert_snapshot!(
1504            env.render(&diag),
1505            @"
1506        error[test-diagnostic]: main diagnostic message
1507         --> animals:1:1
1508          |
1509        1 | aardvark
1510          | ^^^^^^^^
1511        2 | beetle
1512        3 | canary
1513          | ^^^^^^
1514        4 | dog
1515          |
1516        ",
1517        );
1518
1519        // If the annotation were on the next line,
1520        // then the context windows for each annotation
1521        // are adjacent, and thus we still end up with
1522        // one snippet.
1523        let diag = env
1524            .err()
1525            .primary("animals", "1", "1", "")
1526            .primary("animals", "4", "4", "")
1527            .build();
1528        insta::assert_snapshot!(
1529            env.render(&diag),
1530            @"
1531        error[test-diagnostic]: main diagnostic message
1532         --> animals:1:1
1533          |
1534        1 | aardvark
1535          | ^^^^^^^^
1536        2 | beetle
1537        3 | canary
1538        4 | dog
1539          | ^^^
1540        5 | elephant
1541          |
1542        ",
1543        );
1544
1545        // But the line after that one, the context
1546        // windows are no longer adjacent. You can
1547        // tell this is correct because line 3 is
1548        // omitted from the snippet below, since it
1549        // is not in either annotation's context
1550        // window.
1551        let diag = env
1552            .err()
1553            .primary("animals", "1", "1", "")
1554            .primary("animals", "5", "5", "")
1555            .build();
1556        insta::assert_snapshot!(
1557            env.render(&diag),
1558            @"
1559        error[test-diagnostic]: main diagnostic message
1560         --> animals:1:1
1561          |
1562        1 | aardvark
1563          | ^^^^^^^^
1564        2 | beetle
1565          |
1566         ::: animals:5:1
1567          |
1568        4 | dog
1569        5 | elephant
1570          | ^^^^^^^^
1571        6 | finch
1572          |
1573        ",
1574        );
1575
1576        // Do the same round of tests as above,
1577        // but with a bigger context window.
1578        env.context(3);
1579        let diag = env
1580            .err()
1581            .primary("animals", "1", "1", "")
1582            .primary("animals", "5", "5", "")
1583            .build();
1584        insta::assert_snapshot!(
1585            env.render(&diag),
1586            @"
1587        error[test-diagnostic]: main diagnostic message
1588         --> animals:1:1
1589          |
1590        1 | aardvark
1591          | ^^^^^^^^
1592        2 | beetle
1593        3 | canary
1594        4 | dog
1595        5 | elephant
1596          | ^^^^^^^^
1597        6 | finch
1598        7 | gorilla
1599        8 | hippopotamus
1600          |
1601        ",
1602        );
1603
1604        let diag = env
1605            .err()
1606            .primary("animals", "1", "1", "")
1607            .primary("animals", "8", "8", "")
1608            .build();
1609        insta::assert_snapshot!(
1610            env.render(&diag),
1611            @"
1612        error[test-diagnostic]: main diagnostic message
1613          --> animals:1:1
1614           |
1615         1 | aardvark
1616           | ^^^^^^^^
1617         2 | beetle
1618         3 | canary
1619         4 | dog
1620         5 | elephant
1621         6 | finch
1622         7 | gorilla
1623         8 | hippopotamus
1624           | ^^^^^^^^^^^^
1625         9 | inchworm
1626        10 | jackrabbit
1627        11 | kangaroo
1628           |
1629        ",
1630        );
1631
1632        let diag = env
1633            .err()
1634            .primary("animals", "1", "1", "")
1635            .primary("animals", "9", "9", "")
1636            .build();
1637        // Line 5 is missing, as expected, since
1638        // it is not in either annotation's context
1639        // window.
1640        insta::assert_snapshot!(
1641            env.render(&diag),
1642            @"
1643        error[test-diagnostic]: main diagnostic message
1644          --> animals:1:1
1645           |
1646         1 | aardvark
1647           | ^^^^^^^^
1648         2 | beetle
1649         3 | canary
1650         4 | dog
1651           |
1652          ::: animals:9:1
1653           |
1654         6 | finch
1655         7 | gorilla
1656         8 | hippopotamus
1657         9 | inchworm
1658           | ^^^^^^^^
1659        10 | jackrabbit
1660        11 | kangaroo
1661           |
1662        ",
1663        );
1664    }
1665
1666    #[test]
1667    fn trimmed_context() {
1668        let mut env = TestEnvironment::new();
1669        env.add("spacey-animals", SPACEY_ANIMALS);
1670
1671        // Set the context to `2` and pick `elephant`
1672        // from the input. It has two adjacent non-whitespace
1673        // lines on both sides, but then two whitespace
1674        // lines after that. As a result, the context window
1675        // effectively shrinks to `1`.
1676        env.context(2);
1677        let diag = env.err().primary("spacey-animals", "8", "8", "").build();
1678        insta::assert_snapshot!(
1679            env.render(&diag),
1680            @"
1681        error[test-diagnostic]: main diagnostic message
1682         --> spacey-animals:8:1
1683          |
1684        7 | dog
1685        8 | elephant
1686          | ^^^^^^^^
1687        9 | finch
1688          |
1689        ",
1690        );
1691
1692        // Same thing, but where trimming only happens
1693        // in the preceding context.
1694        let diag = env.err().primary("spacey-animals", "12", "12", "").build();
1695        insta::assert_snapshot!(
1696            env.render(&diag),
1697            @"
1698        error[test-diagnostic]: main diagnostic message
1699          --> spacey-animals:12:1
1700           |
1701        11 | gorilla
1702        12 | hippopotamus
1703           | ^^^^^^^^^^^^
1704        13 | inchworm
1705        14 | jackrabbit
1706           |
1707        ",
1708        );
1709
1710        // Again, with trimming only happening in the
1711        // following context.
1712        let diag = env.err().primary("spacey-animals", "13", "13", "").build();
1713        insta::assert_snapshot!(
1714            env.render(&diag),
1715            @"
1716        error[test-diagnostic]: main diagnostic message
1717          --> spacey-animals:13:1
1718           |
1719        11 | gorilla
1720        12 | hippopotamus
1721        13 | inchworm
1722           | ^^^^^^^^
1723        14 | jackrabbit
1724           |
1725        ",
1726        );
1727    }
1728
1729    #[test]
1730    fn multiple_annotations_trimmed_context() {
1731        let mut env = TestEnvironment::new();
1732        env.add("spacey-animals", SPACEY_ANIMALS);
1733
1734        env.context(1);
1735        let diag = env
1736            .err()
1737            .primary("spacey-animals", "3", "3", "")
1738            .primary("spacey-animals", "5", "5", "")
1739            .build();
1740        // Normally this would be one snippet, since
1741        // a context of `1` on line `3` will be adjacent
1742        // to the same sized context on line `5`. But since
1743        // the context calculation trims leading/trailing
1744        // whitespace lines, the context is not actually
1745        // adjacent.
1746        //
1747        // Arguably, this is perhaps not what we want. In
1748        // this case, the whitespace trimming is probably
1749        // getting in the way of a more succinct and less
1750        // jarring snippet. I wasn't 100% sure which
1751        // behavior we wanted, so I left it as-is for now
1752        // instead of special casing the snippet assembly.
1753        insta::assert_snapshot!(
1754            env.render(&diag),
1755            @"
1756        error[test-diagnostic]: main diagnostic message
1757         --> spacey-animals:3:1
1758          |
1759        3 | beetle
1760          | ^^^^^^
1761          |
1762         ::: spacey-animals:5:1
1763          |
1764        5 | canary
1765          | ^^^^^^
1766        ",
1767        );
1768    }
1769
1770    #[test]
1771    fn multiple_files_basic() {
1772        let mut env = TestEnvironment::new();
1773        env.add("animals", ANIMALS);
1774        env.add("fruits", FRUITS);
1775
1776        let diag = env
1777            .err()
1778            .primary("animals", "3", "3", "")
1779            .primary("fruits", "3", "3", "")
1780            .build();
1781        insta::assert_snapshot!(
1782            env.render(&diag),
1783            @"
1784        error[test-diagnostic]: main diagnostic message
1785         --> animals:3:1
1786          |
1787        1 | aardvark
1788        2 | beetle
1789        3 | canary
1790          | ^^^^^^
1791        4 | dog
1792        5 | elephant
1793          |
1794         ::: fruits:3:1
1795          |
1796        1 | apple
1797        2 | banana
1798        3 | cantaloupe
1799          | ^^^^^^^^^^
1800        4 | lime
1801        5 | orange
1802          |
1803        ",
1804        );
1805    }
1806
1807    #[test]
1808    fn sub_diag_note_only_message() {
1809        let mut env = TestEnvironment::new();
1810        env.add("animals", ANIMALS);
1811        env.add("fruits", FRUITS);
1812
1813        let mut diag = env.err().primary("animals", "3", "3", "").build();
1814        diag.sub(
1815            env.sub_builder(SubDiagnosticSeverity::Info, "this is a helpful note")
1816                .build(),
1817        );
1818        insta::assert_snapshot!(
1819            env.render(&diag),
1820            @"
1821        error[test-diagnostic]: main diagnostic message
1822         --> animals:3:1
1823          |
1824        1 | aardvark
1825        2 | beetle
1826        3 | canary
1827          | ^^^^^^
1828        4 | dog
1829        5 | elephant
1830          |
1831        info: this is a helpful note
1832        ",
1833        );
1834    }
1835
1836    #[test]
1837    fn sub_diag_many_notes() {
1838        let mut env = TestEnvironment::new();
1839        env.add("animals", ANIMALS);
1840        env.add("fruits", FRUITS);
1841
1842        let mut diag = env.err().primary("animals", "3", "3", "").build();
1843        diag.sub(
1844            env.sub_builder(SubDiagnosticSeverity::Info, "this is a helpful note")
1845                .build(),
1846        );
1847        diag.sub(
1848            env.sub_builder(SubDiagnosticSeverity::Info, "another helpful note")
1849                .build(),
1850        );
1851        diag.sub(
1852            env.sub_builder(SubDiagnosticSeverity::Info, "and another helpful note")
1853                .build(),
1854        );
1855        insta::assert_snapshot!(
1856            env.render(&diag),
1857            @"
1858        error[test-diagnostic]: main diagnostic message
1859         --> animals:3:1
1860          |
1861        1 | aardvark
1862        2 | beetle
1863        3 | canary
1864          | ^^^^^^
1865        4 | dog
1866        5 | elephant
1867          |
1868        info: this is a helpful note
1869        info: another helpful note
1870        info: and another helpful note
1871        ",
1872        );
1873    }
1874
1875    #[test]
1876    fn sub_diag_warning_with_annotation() {
1877        let mut env = TestEnvironment::new();
1878        env.add("animals", ANIMALS);
1879        env.add("fruits", FRUITS);
1880
1881        let mut diag = env.err().primary("animals", "3", "3", "").build();
1882        diag.sub(env.sub_warn().primary("fruits", "3", "3", "").build());
1883        insta::assert_snapshot!(
1884            env.render(&diag),
1885            @"
1886        error[test-diagnostic]: main diagnostic message
1887         --> animals:3:1
1888          |
1889        1 | aardvark
1890        2 | beetle
1891        3 | canary
1892          | ^^^^^^
1893        4 | dog
1894        5 | elephant
1895          |
1896        warning: sub-diagnostic message
1897         --> fruits:3:1
1898          |
1899        1 | apple
1900        2 | banana
1901        3 | cantaloupe
1902          | ^^^^^^^^^^
1903        4 | lime
1904        5 | orange
1905          |
1906        ",
1907        );
1908    }
1909
1910    #[test]
1911    fn sub_diag_many_warning_with_annotation_order() {
1912        let mut env = TestEnvironment::new();
1913        env.add("animals", ANIMALS);
1914        env.add("fruits", FRUITS);
1915
1916        let mut diag = env.err().primary("animals", "3", "3", "").build();
1917        diag.sub(env.sub_warn().primary("fruits", "3", "3", "").build());
1918        diag.sub(env.sub_warn().primary("animals", "11", "11", "").build());
1919        insta::assert_snapshot!(
1920            env.render(&diag),
1921            @"
1922        error[test-diagnostic]: main diagnostic message
1923         --> animals:3:1
1924          |
1925        1 | aardvark
1926        2 | beetle
1927        3 | canary
1928          | ^^^^^^
1929        4 | dog
1930        5 | elephant
1931          |
1932        warning: sub-diagnostic message
1933         --> fruits:3:1
1934          |
1935        1 | apple
1936        2 | banana
1937        3 | cantaloupe
1938          | ^^^^^^^^^^
1939        4 | lime
1940        5 | orange
1941          |
1942        warning: sub-diagnostic message
1943          --> animals:11:1
1944           |
1945         9 | inchworm
1946        10 | jackrabbit
1947        11 | kangaroo
1948           | ^^^^^^^^
1949        ",
1950        );
1951
1952        // Flip the order of the subs and ensure
1953        // this is reflected in the output.
1954        let mut diag = env.err().primary("animals", "3", "3", "").build();
1955        diag.sub(env.sub_warn().primary("animals", "11", "11", "").build());
1956        diag.sub(env.sub_warn().primary("fruits", "3", "3", "").build());
1957        insta::assert_snapshot!(
1958            env.render(&diag),
1959            @"
1960        error[test-diagnostic]: main diagnostic message
1961         --> animals:3:1
1962          |
1963        1 | aardvark
1964        2 | beetle
1965        3 | canary
1966          | ^^^^^^
1967        4 | dog
1968        5 | elephant
1969          |
1970        warning: sub-diagnostic message
1971          --> animals:11:1
1972           |
1973         9 | inchworm
1974        10 | jackrabbit
1975        11 | kangaroo
1976           | ^^^^^^^^
1977        warning: sub-diagnostic message
1978         --> fruits:3:1
1979          |
1980        1 | apple
1981        2 | banana
1982        3 | cantaloupe
1983          | ^^^^^^^^^^
1984        4 | lime
1985        5 | orange
1986          |
1987        ",
1988        );
1989    }
1990
1991    #[test]
1992    fn sub_diag_repeats_snippet() {
1993        let mut env = TestEnvironment::new();
1994        env.add("animals", ANIMALS);
1995
1996        let mut diag = env.err().primary("animals", "3", "3", "").build();
1997        // There's nothing preventing a sub-diagnostic from referencing
1998        // the same snippet rendered in another sub-diagnostic or the
1999        // parent diagnostic. While annotations *within* a diagnostic
2000        // (sub or otherwise) are coalesced into a minimal number of
2001        // snippets, no such minimizing is done for sub-diagnostics.
2002        // Namely, they are generally treated as completely separate.
2003        diag.sub(env.sub_warn().secondary("animals", "3", "3", "").build());
2004        insta::assert_snapshot!(
2005            env.render(&diag),
2006            @"
2007        error[test-diagnostic]: main diagnostic message
2008         --> animals:3:1
2009          |
2010        1 | aardvark
2011        2 | beetle
2012        3 | canary
2013          | ^^^^^^
2014        4 | dog
2015        5 | elephant
2016          |
2017        warning: sub-diagnostic message
2018         --> animals:3:1
2019          |
2020        1 | aardvark
2021        2 | beetle
2022        3 | canary
2023          | ------
2024        4 | dog
2025        5 | elephant
2026          |
2027        ",
2028        );
2029    }
2030
2031    #[test]
2032    fn annotation_multi_line() {
2033        let mut env = TestEnvironment::new();
2034        env.add("animals", ANIMALS);
2035
2036        // We just try out various offsets here.
2037
2038        // Two entire lines.
2039        let diag = env.err().primary("animals", "5", "6", "").build();
2040        insta::assert_snapshot!(
2041            env.render(&diag),
2042            @"
2043        error[test-diagnostic]: main diagnostic message
2044         --> animals:5:1
2045          |
2046        3 |   canary
2047        4 |   dog
2048        5 | / elephant
2049        6 | | finch
2050          | |_____^
2051        7 |   gorilla
2052        8 |   hippopotamus
2053          |
2054        ",
2055        );
2056
2057        // Two lines plus the start of a third. Since we treat the end
2058        // position as inclusive AND because `ruff_annotate_snippets`
2059        // will render the position of the start of the line as just
2060        // past the end of the previous line, our annotation still only
2061        // extends across two lines.
2062        let diag = env.err().primary("animals", "5", "7:0", "").build();
2063        insta::assert_snapshot!(
2064            env.render(&diag),
2065            @"
2066        error[test-diagnostic]: main diagnostic message
2067         --> animals:5:1
2068          |
2069        3 |   canary
2070        4 |   dog
2071        5 | / elephant
2072        6 | | finch
2073          | |_____^
2074        7 |   gorilla
2075        8 |   hippopotamus
2076          |
2077        ",
2078        );
2079
2080        // Add one more to our end position though, and the third
2081        // line gets included (as you might expect).
2082        let diag = env.err().primary("animals", "5", "7:1", "").build();
2083        insta::assert_snapshot!(
2084            env.render(&diag),
2085            @"
2086        error[test-diagnostic]: main diagnostic message
2087         --> animals:5:1
2088          |
2089        3 |   canary
2090        4 |   dog
2091        5 | / elephant
2092        6 | | finch
2093        7 | | gorilla
2094          | |_^
2095        8 |   hippopotamus
2096        9 |   inchworm
2097          |
2098        ",
2099        );
2100
2101        // Starting and stopping in the middle of two different lines.
2102        let diag = env.err().primary("animals", "5:3", "8:8", "").build();
2103        insta::assert_snapshot!(
2104            env.render(&diag),
2105            @"
2106        error[test-diagnostic]: main diagnostic message
2107          --> animals:5:4
2108           |
2109         3 |   canary
2110         4 |   dog
2111         5 |   elephant
2112           |  ____^
2113         6 | | finch
2114         7 | | gorilla
2115         8 | | hippopotamus
2116           | |________^
2117         9 |   inchworm
2118        10 |   jackrabbit
2119           |
2120        ",
2121        );
2122
2123        // Same as above, but with a secondary annotation.
2124        let diag = env.err().secondary("animals", "5:3", "8:8", "").build();
2125        insta::assert_snapshot!(
2126            env.render(&diag),
2127            @"
2128        error[test-diagnostic]: main diagnostic message
2129          --> animals:5:4
2130           |
2131         3 |   canary
2132         4 |   dog
2133         5 |   elephant
2134           |  ____-
2135         6 | | finch
2136         7 | | gorilla
2137         8 | | hippopotamus
2138           | |________-
2139         9 |   inchworm
2140        10 |   jackrabbit
2141           |
2142        ",
2143        );
2144    }
2145
2146    #[test]
2147    fn annotation_overlapping_multi_line() {
2148        let mut env = TestEnvironment::new();
2149        env.add("animals", ANIMALS);
2150
2151        // One annotation fully contained within another.
2152        let diag = env
2153            .err()
2154            .primary("animals", "5", "6", "")
2155            .primary("animals", "4", "7", "")
2156            .build();
2157        insta::assert_snapshot!(
2158            env.render(&diag),
2159            @"
2160        error[test-diagnostic]: main diagnostic message
2161         --> animals:4:1
2162          |
2163        2 |    beetle
2164        3 |    canary
2165        4 | /  dog
2166        5 | |/ elephant
2167        6 | || finch
2168          | ||_____^
2169        7 | |  gorilla
2170          | |________^
2171        8 |    hippopotamus
2172        9 |    inchworm
2173          |
2174        ",
2175        );
2176
2177        // Same as above, but with order swapped.
2178        // Shouldn't impact rendering.
2179        let diag = env
2180            .err()
2181            .primary("animals", "4", "7", "")
2182            .primary("animals", "5", "6", "")
2183            .build();
2184        insta::assert_snapshot!(
2185            env.render(&diag),
2186            @"
2187        error[test-diagnostic]: main diagnostic message
2188         --> animals:4:1
2189          |
2190        2 |    beetle
2191        3 |    canary
2192        4 | /  dog
2193        5 | |/ elephant
2194        6 | || finch
2195          | ||_____^
2196        7 | |  gorilla
2197          | |________^
2198        8 |    hippopotamus
2199        9 |    inchworm
2200          |
2201        ",
2202        );
2203
2204        // One annotation is completely contained
2205        // by the other, but the other has one
2206        // non-overlapping line preceding the
2207        // overlapping portion.
2208        let diag = env
2209            .err()
2210            .primary("animals", "5", "7", "")
2211            .primary("animals", "6", "7", "")
2212            .build();
2213        insta::assert_snapshot!(
2214            env.render(&diag),
2215            @"
2216        error[test-diagnostic]: main diagnostic message
2217         --> animals:5:1
2218          |
2219        3 |    canary
2220        4 |    dog
2221        5 | /  elephant
2222        6 | |/ finch
2223        7 | || gorilla
2224          | ||_______^
2225          |  |_______|
2226          |
2227        8 |    hippopotamus
2228        9 |    inchworm
2229          |
2230        ",
2231        );
2232
2233        // One annotation is completely contained
2234        // by the other, but the other has one
2235        // non-overlapping line following the
2236        // overlapping portion.
2237        let diag = env
2238            .err()
2239            .primary("animals", "5", "6", "")
2240            .primary("animals", "5", "7", "")
2241            .build();
2242        // NOTE: I find the rendering here pretty
2243        // confusing, but I believe it is correct.
2244        // I'm not sure if it's possible to do much
2245        // better using only ASCII art.
2246        insta::assert_snapshot!(
2247            env.render(&diag),
2248            @"
2249        error[test-diagnostic]: main diagnostic message
2250         --> animals:5:1
2251          |
2252        3 |    canary
2253        4 |    dog
2254        5 | // elephant
2255        6 | || finch
2256          | ||_____^
2257        7 | |  gorilla
2258          | |________^
2259        8 |    hippopotamus
2260        9 |    inchworm
2261          |
2262        ",
2263        );
2264
2265        // Annotations partially overlap, but both
2266        // contain lines that aren't in the other.
2267        let diag = env
2268            .err()
2269            .primary("animals", "5", "6", "")
2270            .primary("animals", "6", "7", "")
2271            .build();
2272        insta::assert_snapshot!(
2273            env.render(&diag),
2274            @"
2275        error[test-diagnostic]: main diagnostic message
2276         --> animals:5:1
2277          |
2278        3 |    canary
2279        4 |    dog
2280        5 | /  elephant
2281        6 | |  finch
2282          | |__^___^
2283          |   _|
2284          |  |
2285        7 |  | gorilla
2286          |  |_______^
2287        8 |    hippopotamus
2288        9 |    inchworm
2289          |
2290        ",
2291        );
2292    }
2293
2294    #[test]
2295    fn annotation_message() {
2296        let mut env = TestEnvironment::new();
2297        env.add("animals", ANIMALS);
2298
2299        let diag = env
2300            .err()
2301            .primary("animals", "5:2", "5:6", "giant land mammal")
2302            .build();
2303        insta::assert_snapshot!(
2304            env.render(&diag),
2305            @"
2306        error[test-diagnostic]: main diagnostic message
2307         --> animals:5:3
2308          |
2309        3 | canary
2310        4 | dog
2311        5 | elephant
2312          |   ^^^^ giant land mammal
2313        6 | finch
2314        7 | gorilla
2315          |
2316        ",
2317        );
2318
2319        // Same as above, but add two annotations for the same range.
2320        let diag = env
2321            .err()
2322            .primary("animals", "5:2", "5:6", "giant land mammal")
2323            .secondary("animals", "5:2", "5:6", "but afraid of mice")
2324            .build();
2325        insta::assert_snapshot!(
2326            env.render(&diag),
2327            @"
2328        error[test-diagnostic]: main diagnostic message
2329         --> animals:5:3
2330          |
2331        3 | canary
2332        4 | dog
2333        5 | elephant
2334          |   ^^^^
2335          |   |
2336          |   giant land mammal
2337          |   but afraid of mice
2338        6 | finch
2339        7 | gorilla
2340          |
2341        ",
2342        );
2343    }
2344
2345    #[test]
2346    fn annotation_one_file_primary_always_comes_first() {
2347        let mut env = TestEnvironment::new();
2348        env.add("animals", ANIMALS);
2349
2350        // The secondary annotation is not only added first,
2351        // but it appears first in the source. But it still
2352        // comes second.
2353        let diag = env
2354            .err()
2355            .secondary("animals", "1", "1", "secondary")
2356            .primary("animals", "8", "8", "primary")
2357            .build();
2358        insta::assert_snapshot!(
2359            env.render(&diag),
2360            @"
2361        error[test-diagnostic]: main diagnostic message
2362          --> animals:8:1
2363           |
2364         6 | finch
2365         7 | gorilla
2366         8 | hippopotamus
2367           | ^^^^^^^^^^^^ primary
2368         9 | inchworm
2369        10 | jackrabbit
2370           |
2371          ::: animals:1:1
2372           |
2373         1 | aardvark
2374           | -------- secondary
2375         2 | beetle
2376         3 | canary
2377           |
2378        ",
2379        );
2380
2381        // This is a weirder case where there are multiple
2382        // snippets with primary annotations. We ensure that
2383        // all such snippets appear before any snippets with
2384        // zero primary annotations. Otherwise, the snippets
2385        // appear in source order.
2386        //
2387        // (We also drop the context so that we can squeeze
2388        // more snippets out of our test data.)
2389        env.context(0);
2390        let diag = env
2391            .err()
2392            .secondary("animals", "7", "7", "secondary 7")
2393            .primary("animals", "9", "9", "primary 9")
2394            .secondary("animals", "3", "3", "secondary 3")
2395            .secondary("animals", "1", "1", "secondary 1")
2396            .primary("animals", "5", "5", "primary 5")
2397            .build();
2398        insta::assert_snapshot!(
2399            env.render(&diag),
2400            @"
2401        error[test-diagnostic]: main diagnostic message
2402         --> animals:5:1
2403          |
2404        5 | elephant
2405          | ^^^^^^^^ primary 5
2406          |
2407         ::: animals:9:1
2408          |
2409        9 | inchworm
2410          | ^^^^^^^^ primary 9
2411          |
2412         ::: animals:1:1
2413          |
2414        1 | aardvark
2415          | -------- secondary 1
2416          |
2417         ::: animals:3:1
2418          |
2419        3 | canary
2420          | ------ secondary 3
2421          |
2422         ::: animals:7:1
2423          |
2424        7 | gorilla
2425          | ------- secondary 7
2426        ",
2427        );
2428    }
2429
2430    #[test]
2431    fn annotation_many_files_primary_always_comes_first() {
2432        let mut env = TestEnvironment::new();
2433        env.add("animals", ANIMALS);
2434        env.add("fruits", FRUITS);
2435
2436        let diag = env
2437            .err()
2438            .secondary("animals", "1", "1", "secondary")
2439            .primary("fruits", "1", "1", "primary")
2440            .build();
2441        insta::assert_snapshot!(
2442            env.render(&diag),
2443            @"
2444        error[test-diagnostic]: main diagnostic message
2445         --> fruits:1:1
2446          |
2447        1 | apple
2448          | ^^^^^ primary
2449        2 | banana
2450        3 | cantaloupe
2451          |
2452         ::: animals:1:1
2453          |
2454        1 | aardvark
2455          | -------- secondary
2456        2 | beetle
2457        3 | canary
2458          |
2459        ",
2460        );
2461
2462        // Same as the single file test, we try adding
2463        // multiple primary annotations across multiple
2464        // files. Those should always appear first
2465        // *within* each file.
2466        env.context(0);
2467        let diag = env
2468            .err()
2469            .secondary("animals", "7", "7", "secondary animals 7")
2470            .secondary("fruits", "2", "2", "secondary fruits 2")
2471            .secondary("animals", "3", "3", "secondary animals 3")
2472            .secondary("animals", "1", "1", "secondary animals 1")
2473            .primary("animals", "11", "11", "primary animals 11")
2474            .primary("fruits", "10", "10", "primary fruits 10")
2475            .build();
2476        insta::assert_snapshot!(
2477            env.render(&diag),
2478            @"
2479        error[test-diagnostic]: main diagnostic message
2480          --> animals:11:1
2481           |
2482        11 | kangaroo
2483           | ^^^^^^^^ primary animals 11
2484           |
2485          ::: animals:1:1
2486           |
2487         1 | aardvark
2488           | -------- secondary animals 1
2489           |
2490          ::: animals:3:1
2491           |
2492         3 | canary
2493           | ------ secondary animals 3
2494           |
2495          ::: animals:7:1
2496           |
2497         7 | gorilla
2498           | ------- secondary animals 7
2499           |
2500          ::: fruits:10:1
2501           |
2502        10 | watermelon
2503           | ^^^^^^^^^^ primary fruits 10
2504           |
2505          ::: fruits:2:1
2506           |
2507         2 | banana
2508           | ------ secondary fruits 2
2509        ",
2510        );
2511    }
2512
2513    #[test]
2514    fn diagnostics_with_equal_locations_sort_by_concise_message() {
2515        let mut env = TestEnvironment::new();
2516        env.add("fruits", FRUITS);
2517        let mut diagnostics = [
2518            env.invalid_syntax("checking mod.py")
2519                .primary("fruits", "1", "1", "")
2520                .build(),
2521            env.invalid_syntax("checking main.py")
2522                .primary("fruits", "1", "1", "")
2523                .build(),
2524        ];
2525
2526        diagnostics.sort_by(|left, right| {
2527            left.rendering_sort_key(&env.db)
2528                .cmp(&right.rendering_sort_key(&env.db))
2529        });
2530
2531        assert_eq!(
2532            diagnostics
2533                .iter()
2534                .map(Diagnostic::headline_message)
2535                .collect::<Vec<_>>(),
2536            ["checking main.py", "checking mod.py"]
2537        );
2538    }
2539
2540    /// A small harness for setting up an environment specifically for testing
2541    /// diagnostic rendering.
2542    pub(super) struct TestEnvironment {
2543        db: TestDb,
2544        config: DisplayDiagnosticConfig,
2545    }
2546
2547    impl TestEnvironment {
2548        /// Create a new test harness.
2549        ///
2550        /// This uses the default diagnostic rendering configuration.
2551        pub(super) fn new() -> TestEnvironment {
2552            let mut env = TestEnvironment {
2553                db: TestDb::new(),
2554                config: DisplayDiagnosticConfig::new("ty"),
2555            };
2556            // Default to a merge window of 0 for testing purposes,
2557            // even though this is not the default for user-facing diagnostics.
2558            env.merge_window(0);
2559            env
2560        }
2561
2562        /// Set the number of contextual lines to include for each snippet
2563        /// in diagnostic rendering.
2564        pub(super) fn context(&mut self, lines: usize) {
2565            // Kind of annoying. I considered making `DisplayDiagnosticConfig`
2566            // be `Copy` (which it could be, at time of writing, 2025-03-07),
2567            // but it seems likely to me that it will grow non-`Copy`
2568            // configuration. So just deal with this inconvenience for now.
2569            let config = self.config.clone();
2570            self.config = config.context(lines);
2571        }
2572
2573        /// Set the "merge window" for annotations and fix diff hunks in this test.
2574        ///
2575        /// Nearby annotations or fix edits are rendered in a single source frame even when their
2576        /// configured context windows would not otherwise overlap.
2577        pub(super) fn merge_window(&mut self, lines: usize) {
2578            let config = self.config.clone();
2579            self.config = config.merge_window(lines);
2580        }
2581
2582        /// Set the output format to use in diagnostic rendering.
2583        pub(super) fn format(&mut self, format: DiagnosticFormat) {
2584            let config = self.config.clone();
2585            self.config = config.format(format);
2586        }
2587
2588        /// Enable preview functionality for diagnostic rendering.
2589        #[allow(
2590            dead_code,
2591            reason = "This is currently only used for JSON but will be needed soon for other formats"
2592        )]
2593        pub(super) fn preview(&mut self, yes: bool) {
2594            let config = self.config.clone();
2595            self.config = config.preview(yes);
2596        }
2597
2598        /// Hide diagnostic severity when rendering.
2599        pub(super) fn hide_severity(&mut self, yes: bool) {
2600            let config = self.config.clone();
2601            self.config = config.hide_severity(yes);
2602        }
2603
2604        /// Show fix availability when rendering.
2605        pub(super) fn show_fix_status(&mut self, yes: bool) {
2606            let config = self.config.clone();
2607            self.config = config.with_show_fix_status(yes);
2608        }
2609
2610        /// The lowest fix applicability to show when rendering.
2611        pub(super) fn fix_applicability(&mut self, applicability: Applicability) {
2612            let config = self.config.clone();
2613            self.config = config.with_fix_applicability(applicability);
2614        }
2615
2616        /// Add a file with the given path and contents to this environment.
2617        pub(super) fn add(&mut self, path: &str, contents: &str) {
2618            let path = SystemPath::new(path);
2619            self.db.write_file(path, contents).unwrap();
2620        }
2621
2622        /// Conveniently create a `Span` that points into a file in this
2623        /// environment.
2624        ///
2625        /// The path given must have been added via `TestEnvironment::add`.
2626        ///
2627        /// The offset strings given should be in `{line}(:{offset})?` format.
2628        /// `line` is a 1-indexed offset corresponding to the line number,
2629        /// while `offset` is a 0-indexed *byte* offset starting from the
2630        /// beginning of the corresponding line. When `offset` is missing from
2631        /// the start of the span, it is assumed to be `0`. When `offset` is
2632        /// missing from the end of the span, it is assumed to be the length
2633        /// of the corresponding line minus one. (The "minus one" is because
2634        /// otherwise, the span will end where the next line begins, and this
2635        /// confuses `ruff_annotate_snippets` as of 2025-03-13.)
2636        fn span(&self, path: &str, line_offset_start: &str, line_offset_end: &str) -> Span {
2637            let span = self.path(path);
2638
2639            let file = span.expect_ty_file();
2640            let text = source_text(&self.db, file);
2641            let line_index = line_index(&self.db, file);
2642            let source = SourceCode::new(text.as_str(), &line_index);
2643
2644            let (line_start, offset_start) = parse_line_offset(line_offset_start);
2645            let (line_end, offset_end) = parse_line_offset(line_offset_end);
2646
2647            let start = match offset_start {
2648                None => source.line_start(line_start),
2649                Some(offset) => source.line_start(line_start) + offset,
2650            };
2651            let end = match offset_end {
2652                None => source.line_end(line_end) - TextSize::from(1),
2653                Some(offset) => source.line_start(line_end) + offset,
2654            };
2655            span.with_range(TextRange::new(start, end))
2656        }
2657
2658        /// Like `span`, but only attaches a file path.
2659        pub(super) fn path(&self, path: &str) -> Span {
2660            let file = system_path_to_file(&self.db, path).unwrap();
2661            Span::from(file)
2662        }
2663
2664        /// A convenience function for returning a builder for a diagnostic
2665        /// with "error" severity and canned values for its identifier
2666        /// and message.
2667        pub(super) fn err(&mut self) -> DiagnosticBuilder<'_> {
2668            self.builder(
2669                "test-diagnostic",
2670                Severity::Error,
2671                "main diagnostic message",
2672            )
2673        }
2674
2675        /// A convenience function for returning a builder for a
2676        /// sub-diagnostic with "error" severity and canned values for
2677        /// its identifier and message.
2678        fn sub_warn(&mut self) -> SubDiagnosticBuilder<'_> {
2679            self.sub_builder(SubDiagnosticSeverity::Warning, "sub-diagnostic message")
2680        }
2681
2682        /// Returns a builder for tersely constructing diagnostics.
2683        pub(super) fn builder(
2684            &mut self,
2685            identifier: &'static str,
2686            severity: Severity,
2687            message: &str,
2688        ) -> DiagnosticBuilder<'_> {
2689            let diag = Diagnostic::new(id(identifier), severity, message);
2690            DiagnosticBuilder { env: self, diag }
2691        }
2692
2693        /// A convenience function for returning a builder for an invalid syntax diagnostic.
2694        fn invalid_syntax(&mut self, message: &str) -> DiagnosticBuilder<'_> {
2695            let diag = Diagnostic::new(DiagnosticId::InvalidSyntax, Severity::Error, message);
2696            DiagnosticBuilder { env: self, diag }
2697        }
2698
2699        /// Returns a builder for tersely constructing sub-diagnostics.
2700        fn sub_builder(
2701            &mut self,
2702            severity: SubDiagnosticSeverity,
2703            message: &str,
2704        ) -> SubDiagnosticBuilder<'_> {
2705            let subdiag = SubDiagnostic::new(severity, message);
2706            SubDiagnosticBuilder { env: self, subdiag }
2707        }
2708
2709        /// Render the given diagnostic into a `String`.
2710        ///
2711        /// (This will set the "printed" flag on `Diagnostic`.)
2712        pub(super) fn render(&self, diag: &Diagnostic) -> String {
2713            diag.display(&self.db, &self.config).to_string()
2714        }
2715
2716        /// Render the given diagnostics into a `String`.
2717        ///
2718        /// See `render` for rendering a single diagnostic.
2719        ///
2720        /// (This will set the "printed" flag on `Diagnostic`.)
2721        pub(super) fn render_diagnostics(&self, diagnostics: &[Diagnostic]) -> String {
2722            DisplayDiagnostics::new(&self.db, &self.config, diagnostics).to_string()
2723        }
2724    }
2725
2726    /// A helper builder for tersely populating a `Diagnostic`.
2727    ///
2728    /// If you need to mutate the diagnostic in a way that isn't
2729    /// supported by this builder, and this only needs to be done
2730    /// infrequently, consider doing it more verbosely on `diag`
2731    /// itself.
2732    pub(super) struct DiagnosticBuilder<'e> {
2733        env: &'e mut TestEnvironment,
2734        diag: Diagnostic,
2735    }
2736
2737    impl<'e> DiagnosticBuilder<'e> {
2738        /// Return the built diagnostic.
2739        pub(super) fn build(self) -> Diagnostic {
2740            self.diag
2741        }
2742
2743        /// Add a primary annotation with a message.
2744        ///
2745        /// If the message is empty, then an annotation without any
2746        /// message be created.
2747        ///
2748        /// See the docs on `TestEnvironment::span` for the meaning of
2749        /// `path`, `line_offset_start` and `line_offset_end`.
2750        pub(super) fn primary(
2751            mut self,
2752            path: &str,
2753            line_offset_start: &str,
2754            line_offset_end: &str,
2755            label: &str,
2756        ) -> DiagnosticBuilder<'e> {
2757            let span = self.env.span(path, line_offset_start, line_offset_end);
2758            let mut ann = Annotation::primary(span);
2759            if !label.is_empty() {
2760                ann = ann.message(label);
2761            }
2762            self.diag.annotate(ann);
2763            self
2764        }
2765
2766        /// Add a secondary annotation with a message.
2767        ///
2768        /// If the message is empty, then an annotation without any
2769        /// message be created.
2770        ///
2771        /// See the docs on `TestEnvironment::span` for the meaning of
2772        /// `path`, `line_offset_start` and `line_offset_end`.
2773        pub(super) fn secondary(
2774            mut self,
2775            path: &str,
2776            line_offset_start: &str,
2777            line_offset_end: &str,
2778            label: &str,
2779        ) -> DiagnosticBuilder<'e> {
2780            let span = self.env.span(path, line_offset_start, line_offset_end);
2781            let mut ann = Annotation::secondary(span);
2782            if !label.is_empty() {
2783                ann = ann.message(label);
2784            }
2785            self.diag.annotate(ann);
2786            self
2787        }
2788
2789        /// Set the secondary code on the diagnostic.
2790        fn secondary_code(mut self, secondary_code: &str) -> DiagnosticBuilder<'e> {
2791            self.diag
2792                .set_secondary_code(SecondaryCode::new(secondary_code.to_string()));
2793            self
2794        }
2795
2796        /// Set the fix on the diagnostic.
2797        fn fix(mut self, fix: Fix) -> DiagnosticBuilder<'e> {
2798            self.diag.set_fix(fix);
2799            self
2800        }
2801
2802        /// Set the noqa offset on the diagnostic.
2803        fn noqa_offset(mut self, noqa_offset: TextSize) -> DiagnosticBuilder<'e> {
2804            self.diag.set_noqa_offset(noqa_offset);
2805            self
2806        }
2807
2808        /// Adds a "help" sub-diagnostic with the given message.
2809        pub(super) fn help(mut self, message: impl IntoDiagnosticMessage) -> DiagnosticBuilder<'e> {
2810            self.diag.help(message);
2811            self
2812        }
2813
2814        /// Adds a sub-diagnostic constructed with this diagnostic's environment.
2815        fn sub(
2816            mut self,
2817            f: impl Fn(&mut TestEnvironment) -> SubDiagnostic,
2818        ) -> DiagnosticBuilder<'e> {
2819            let sub = f(self.env);
2820            self.diag.sub(sub);
2821            self
2822        }
2823
2824        /// Set the documentation URL for the diagnostic.
2825        pub(super) fn documentation_url(mut self, url: impl Into<String>) -> DiagnosticBuilder<'e> {
2826            self.diag.set_documentation_url(Some(url.into()));
2827            self
2828        }
2829    }
2830
2831    /// A helper builder for tersely populating a `SubDiagnostic`.
2832    ///
2833    /// If you need to mutate the sub-diagnostic in a way that isn't
2834    /// supported by this builder, and this only needs to be done
2835    /// infrequently, consider doing it more verbosely on `diag`
2836    /// itself.
2837    struct SubDiagnosticBuilder<'e> {
2838        env: &'e mut TestEnvironment,
2839        subdiag: SubDiagnostic,
2840    }
2841
2842    impl<'e> SubDiagnosticBuilder<'e> {
2843        /// Return the built sub-diagnostic.
2844        fn build(self) -> SubDiagnostic {
2845            self.subdiag
2846        }
2847
2848        /// Add a primary annotation with a message.
2849        ///
2850        /// If the message is empty, then an annotation without any
2851        /// message be created.
2852        ///
2853        /// See the docs on `TestEnvironment::span` for the meaning of
2854        /// `path`, `line_offset_start` and `line_offset_end`.
2855        fn primary(
2856            mut self,
2857            path: &str,
2858            line_offset_start: &str,
2859            line_offset_end: &str,
2860            label: &str,
2861        ) -> SubDiagnosticBuilder<'e> {
2862            let span = self.env.span(path, line_offset_start, line_offset_end);
2863            let mut ann = Annotation::primary(span);
2864            if !label.is_empty() {
2865                ann = ann.message(label);
2866            }
2867            self.subdiag.annotate(ann);
2868            self
2869        }
2870
2871        /// Add a secondary annotation with a message.
2872        ///
2873        /// If the message is empty, then an annotation without any
2874        /// message be created.
2875        ///
2876        /// See the docs on `TestEnvironment::span` for the meaning of
2877        /// `path`, `line_offset_start` and `line_offset_end`.
2878        fn secondary(
2879            mut self,
2880            path: &str,
2881            line_offset_start: &str,
2882            line_offset_end: &str,
2883            label: &str,
2884        ) -> SubDiagnosticBuilder<'e> {
2885            let span = self.env.span(path, line_offset_start, line_offset_end);
2886            let mut ann = Annotation::secondary(span);
2887            if !label.is_empty() {
2888                ann = ann.message(label);
2889            }
2890            self.subdiag.annotate(ann);
2891            self
2892        }
2893    }
2894
2895    fn id(lint_name: &'static str) -> DiagnosticId {
2896        DiagnosticId::lint(lint_name)
2897    }
2898
2899    fn parse_line_offset(s: &str) -> (OneIndexed, Option<TextSize>) {
2900        let Some((line, offset)) = s.split_once(":") else {
2901            let line_number = OneIndexed::new(s.parse().unwrap()).unwrap();
2902            return (line_number, None);
2903        };
2904        let line_number = OneIndexed::new(line.parse().unwrap()).unwrap();
2905        let offset = TextSize::from(offset.parse::<u32>().unwrap());
2906        (line_number, Some(offset))
2907    }
2908
2909    /// Create Ruff-style diagnostics for testing the various output formats.
2910    pub(crate) fn create_diagnostics(
2911        format: DiagnosticFormat,
2912    ) -> (TestEnvironment, Vec<Diagnostic>) {
2913        let mut env = TestEnvironment::new();
2914        env.add(
2915            "fib.py",
2916            r#"import os
2917
2918
2919def fibonacci(n):
2920    """Compute the nth number in the Fibonacci sequence."""
2921    x = 1
2922    if n == 0:
2923        return 0
2924    elif n == 1:
2925        return 1
2926    else:
2927        return fibonaccii(n - 1) + fibonacci(n - 2)
2928"#,
2929        );
2930        env.add("undef.py", r"if a == 1: pass");
2931        env.format(format);
2932
2933        let diagnostics = vec![
2934            env.builder("unused-import", Severity::Error, "`os` imported but unused")
2935                .primary("fib.py", "1:7", "1:9", "")
2936                .help("Remove unused import: `os`")
2937                .secondary_code("F401")
2938                .fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
2939                    TextSize::from(0),
2940                    TextSize::from(10),
2941                ))))
2942                .noqa_offset(TextSize::from(7))
2943                .documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
2944                .build(),
2945            env.builder(
2946                "unused-variable",
2947                Severity::Error,
2948                "Local variable `x` is assigned to but never used",
2949            )
2950            .primary("fib.py", "6:4", "6:5", "")
2951            .help("Remove assignment to unused variable `x`")
2952            .secondary_code("F841")
2953            .fix(Fix::unsafe_edit(Edit::deletion(
2954                TextSize::from(94),
2955                TextSize::from(99),
2956            )))
2957            .noqa_offset(TextSize::from(94))
2958            .documentation_url("https://docs.astral.sh/ruff/rules/unused-variable")
2959            .build(),
2960            env.builder("undefined-name", Severity::Error, "Undefined name `a`")
2961                .primary("undef.py", "1:3", "1:4", "")
2962                .secondary_code("F821")
2963                .noqa_offset(TextSize::from(3))
2964                .documentation_url("https://docs.astral.sh/ruff/rules/undefined-name")
2965                .build(),
2966            env.builder(
2967                "undefined-name",
2968                Severity::Error,
2969                "Undefined name `fibonaccii`",
2970            )
2971            .primary("fib.py", "12:15", "12:25", "")
2972            .secondary_code("F821")
2973            .noqa_offset(ruff_text_size::TextSize::from(0))
2974            .documentation_url("https://docs.astral.sh/ruff/rules/undefined-name")
2975            .secondary("fib.py", "12:35", "12:36", "")
2976            .sub(|env| {
2977                env.sub_builder(
2978                    SubDiagnosticSeverity::Info,
2979                    "Did you mean to import it from `/some/path/def.py`?",
2980                )
2981                .primary("fib.py", "4:4", "4:13", "`fibonacci` is defined here")
2982                .secondary("fib.py", "5:4", "5", "`fibonacci` is documented here")
2983                .build()
2984            })
2985            .build(),
2986        ];
2987
2988        (env, diagnostics)
2989    }
2990
2991    /// Create Ruff-style syntax error diagnostics for testing the various output formats.
2992    pub(crate) fn create_syntax_error_diagnostics(
2993        format: DiagnosticFormat,
2994    ) -> (TestEnvironment, Vec<Diagnostic>) {
2995        let mut env = TestEnvironment::new();
2996        env.add(
2997            "syntax_errors.py",
2998            r"from os import
2999
3000if call(foo
3001    def bar():
3002        pass
3003",
3004        );
3005        env.format(format);
3006
3007        let diagnostics = vec![
3008            env.invalid_syntax("Expected one or more symbol names after import")
3009                .primary("syntax_errors.py", "1:14", "1:15", "")
3010                .build(),
3011            env.invalid_syntax("Expected ')', found newline")
3012                .primary("syntax_errors.py", "3:11", "3:12", "")
3013                .build(),
3014        ];
3015
3016        (env, diagnostics)
3017    }
3018
3019    /// A Jupyter notebook for testing diagnostics.
3020    ///
3021    ///
3022    /// The concatenated cells look like this:
3023    ///
3024    /// ```python
3025    /// # cell 1
3026    /// import os
3027    /// # cell 2
3028    /// import math
3029    ///
3030    /// print('hello world')
3031    /// # cell 3
3032    /// def foo():
3033    ///     print()
3034    ///     x = 1
3035    /// ```
3036    ///
3037    /// The first diagnostic is on the unused `os` import with location cell 1, row 2, column 8
3038    /// (`cell 1:2:8`). The second diagnostic is the unused `math` import at `cell 2:2:8`, and the
3039    /// third diagnostic is an unfixable unused variable at `cell 3:4:5`.
3040    pub(super) static NOTEBOOK: &str = r##"
3041        {
3042 "cells": [
3043  {
3044   "cell_type": "code",
3045   "metadata": {},
3046   "outputs": [],
3047   "source": [
3048    "# cell 1\n",
3049    "import os"
3050   ]
3051  },
3052  {
3053   "cell_type": "code",
3054   "metadata": {},
3055   "outputs": [],
3056   "source": [
3057    "# cell 2\n",
3058    "import math\n",
3059    "\n",
3060    "print('hello world')"
3061   ]
3062  },
3063  {
3064   "cell_type": "code",
3065   "metadata": {},
3066   "outputs": [],
3067   "source": [
3068    "# cell 3\n",
3069    "def foo():\n",
3070    "    print()\n",
3071    "    x = 1\n"
3072   ]
3073  }
3074 ],
3075 "metadata": {},
3076 "nbformat": 4,
3077 "nbformat_minor": 5
3078}
3079"##;
3080
3081    /// Create Ruff-style diagnostics for testing the various output formats for a notebook.
3082    pub(crate) fn create_notebook_diagnostics(
3083        format: DiagnosticFormat,
3084    ) -> (TestEnvironment, Vec<Diagnostic>) {
3085        let mut env = TestEnvironment::new();
3086        env.add("notebook.ipynb", NOTEBOOK);
3087        env.format(format);
3088
3089        let diagnostics = vec![
3090            env.builder("unused-import", Severity::Error, "`os` imported but unused")
3091                .primary("notebook.ipynb", "2:7", "2:9", "")
3092                .help("Remove unused import: `os`")
3093                .secondary_code("F401")
3094                .fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
3095                    TextSize::from(9),
3096                    TextSize::from(19),
3097                ))))
3098                .noqa_offset(TextSize::from(16))
3099                .documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
3100                .build(),
3101            env.builder(
3102                "unused-import",
3103                Severity::Error,
3104                "`math` imported but unused",
3105            )
3106            .primary("notebook.ipynb", "4:7", "4:11", "")
3107            .help("Remove unused import: `math`")
3108            .secondary_code("F401")
3109            .fix(Fix::safe_edit(Edit::range_deletion(TextRange::new(
3110                TextSize::from(28),
3111                TextSize::from(40),
3112            ))))
3113            .noqa_offset(TextSize::from(35))
3114            .documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
3115            .build(),
3116            env.builder(
3117                "unused-variable",
3118                Severity::Error,
3119                "Local variable `x` is assigned to but never used",
3120            )
3121            .primary("notebook.ipynb", "10:4", "10:5", "")
3122            .help("Remove assignment to unused variable `x`")
3123            .secondary_code("F841")
3124            .fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
3125                TextSize::from(94),
3126                TextSize::from(104),
3127            ))))
3128            .noqa_offset(TextSize::from(98))
3129            .documentation_url("https://docs.astral.sh/ruff/rules/unused-variable")
3130            .build(),
3131        ];
3132
3133        (env, diagnostics)
3134    }
3135}