Skip to main content

bock_errors/
lib.rs

1//! Bock errors — diagnostic types, span, file-id, and error reporting infrastructure.
2//!
3//! `Span` and `FileId` are defined here (not in `bock-source`) to avoid circular
4//! dependencies. Every crate in the pipeline uses these types transitively.
5
6use std::io::IsTerminal;
7
8use ariadne::{Color, Config, Label as AriadneLabel, Report, ReportKind, Source};
9
10pub mod catalog;
11
12// ─── Source location types ────────────────────────────────────────────────────
13
14/// Identifies a source file within the compilation session.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct FileId(pub u32);
17
18/// A byte-offset span within a source file.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct Span {
21    pub file: FileId,
22    /// Start byte offset (inclusive).
23    pub start: usize,
24    /// End byte offset (exclusive).
25    pub end: usize,
26}
27
28impl Span {
29    /// Returns the smallest span that contains both `a` and `b`.
30    /// Uses `a`'s `FileId`; callers should ensure both spans belong to the same file.
31    #[must_use]
32    pub fn merge(a: Span, b: Span) -> Span {
33        Span {
34            file: a.file,
35            start: a.start.min(b.start),
36            end: a.end.max(b.end),
37        }
38    }
39
40    /// A sentinel span for synthetic/compiler-generated nodes.
41    #[must_use]
42    pub fn dummy() -> Span {
43        Span {
44            file: FileId(0),
45            start: 0,
46            end: 0,
47        }
48    }
49}
50
51// ─── Diagnostics ─────────────────────────────────────────────────────────────
52
53/// Diagnostic severity level.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Severity {
56    Error,
57    Warning,
58    Info,
59    Hint,
60}
61
62/// A structured diagnostic code like `E0001` or `W0042`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct DiagnosticCode {
65    /// Category prefix character (`E` for error, `W` for warning, etc.).
66    pub prefix: char,
67    /// Numeric code.
68    pub number: u16,
69}
70
71impl std::fmt::Display for DiagnosticCode {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{}{:04}", self.prefix, self.number)
74    }
75}
76
77/// A secondary label pointing at a span with an explanatory message.
78#[derive(Debug, Clone)]
79pub struct Label {
80    pub span: Span,
81    pub message: String,
82}
83
84/// A structured compiler diagnostic.
85#[derive(Debug, Clone)]
86pub struct Diagnostic {
87    pub severity: Severity,
88    pub code: DiagnosticCode,
89    pub message: String,
90    pub span: Span,
91    pub labels: Vec<Label>,
92    pub notes: Vec<String>,
93}
94
95impl Diagnostic {
96    /// Attach an additional label to this diagnostic (builder-style).
97    pub fn label(&mut self, span: Span, message: impl Into<String>) -> &mut Self {
98        self.labels.push(Label {
99            span,
100            message: message.into(),
101        });
102        self
103    }
104
105    /// Attach a trailing note to this diagnostic (builder-style).
106    pub fn note(&mut self, message: impl Into<String>) -> &mut Self {
107        self.notes.push(message.into());
108        self
109    }
110}
111
112// ─── DiagnosticBag ───────────────────────────────────────────────────────────
113
114/// Accumulates diagnostics emitted during a compilation pass.
115#[derive(Debug, Default)]
116pub struct DiagnosticBag {
117    items: Vec<Diagnostic>,
118}
119
120impl DiagnosticBag {
121    /// Create an empty bag.
122    #[must_use]
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Emit an error diagnostic and return a mutable reference for further decoration.
128    pub fn error(
129        &mut self,
130        code: DiagnosticCode,
131        message: impl Into<String>,
132        span: Span,
133    ) -> &mut Diagnostic {
134        self.push(Severity::Error, code, message, span)
135    }
136
137    /// Emit a warning diagnostic and return a mutable reference for further decoration.
138    pub fn warning(
139        &mut self,
140        code: DiagnosticCode,
141        message: impl Into<String>,
142        span: Span,
143    ) -> &mut Diagnostic {
144        self.push(Severity::Warning, code, message, span)
145    }
146
147    /// Emit an info diagnostic and return a mutable reference for further decoration.
148    pub fn info(
149        &mut self,
150        code: DiagnosticCode,
151        message: impl Into<String>,
152        span: Span,
153    ) -> &mut Diagnostic {
154        self.push(Severity::Info, code, message, span)
155    }
156
157    /// Emit a hint diagnostic and return a mutable reference for further decoration.
158    pub fn hint(
159        &mut self,
160        code: DiagnosticCode,
161        message: impl Into<String>,
162        span: Span,
163    ) -> &mut Diagnostic {
164        self.push(Severity::Hint, code, message, span)
165    }
166
167    /// Returns `true` if any error-severity diagnostics have been emitted.
168    #[must_use]
169    pub fn has_errors(&self) -> bool {
170        self.items.iter().any(|d| d.severity == Severity::Error)
171    }
172
173    /// Returns the number of error-severity diagnostics emitted so far.
174    #[must_use]
175    pub fn error_count(&self) -> usize {
176        self.items
177            .iter()
178            .filter(|d| d.severity == Severity::Error)
179            .count()
180    }
181
182    /// Returns the number of warning-severity diagnostics emitted so far.
183    #[must_use]
184    pub fn warning_count(&self) -> usize {
185        self.items
186            .iter()
187            .filter(|d| d.severity == Severity::Warning)
188            .count()
189    }
190
191    /// Iterate over all collected diagnostics.
192    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
193        self.items.iter()
194    }
195
196    /// Absorb all diagnostics from `other` into this bag, preserving their
197    /// labels and notes.
198    ///
199    /// Used to fold diagnostics produced by a sub-pass (such as
200    /// `ImplTable::build_from`) into the main diagnostic bag.
201    pub fn absorb(&mut self, other: &DiagnosticBag) {
202        self.items.extend(other.items.iter().cloned());
203    }
204
205    /// Total number of diagnostics.
206    #[must_use]
207    pub fn len(&self) -> usize {
208        self.items.len()
209    }
210
211    /// Returns `true` if no diagnostics have been emitted.
212    #[must_use]
213    pub fn is_empty(&self) -> bool {
214        self.items.is_empty()
215    }
216
217    fn push(
218        &mut self,
219        severity: Severity,
220        code: DiagnosticCode,
221        message: impl Into<String>,
222        span: Span,
223    ) -> &mut Diagnostic {
224        self.items.push(Diagnostic {
225            severity,
226            code,
227            message: message.into(),
228            span,
229            labels: Vec::new(),
230            notes: Vec::new(),
231        });
232        self.items.last_mut().expect("just pushed")
233    }
234}
235
236// ─── String distance helpers ─────────────────────────────────────────────────
237
238/// Levenshtein edit distance between two strings.
239///
240/// Counts the minimum number of single-character insertions, deletions,
241/// and substitutions required to transform `a` into `b`. Used by diagnostic
242/// passes to produce "did you mean X?" suggestions.
243#[must_use]
244pub fn levenshtein(a: &str, b: &str) -> usize {
245    let a: Vec<char> = a.chars().collect();
246    let b: Vec<char> = b.chars().collect();
247    if a.is_empty() {
248        return b.len();
249    }
250    if b.is_empty() {
251        return a.len();
252    }
253    let mut prev: Vec<usize> = (0..=b.len()).collect();
254    let mut curr: Vec<usize> = vec![0; b.len() + 1];
255    for (i, ca) in a.iter().enumerate() {
256        curr[0] = i + 1;
257        for (j, cb) in b.iter().enumerate() {
258            let cost = if ca == cb { 0 } else { 1 };
259            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
260        }
261        std::mem::swap(&mut prev, &mut curr);
262    }
263    prev[b.len()]
264}
265
266/// Find the closest candidate within `max_distance` edits of `name`.
267///
268/// Returns a clone of the closest matching candidate. Ties are broken by
269/// insertion order (the first candidate wins).
270#[must_use]
271pub fn suggest_similar<S, I>(name: &str, candidates: I, max_distance: usize) -> Option<String>
272where
273    S: AsRef<str>,
274    I: IntoIterator<Item = S>,
275{
276    candidates
277        .into_iter()
278        .map(|s| {
279            let d = levenshtein(name, s.as_ref());
280            (s, d)
281        })
282        .filter(|(_, d)| *d <= max_distance)
283        .min_by_key(|(_, d)| *d)
284        .map(|(s, _)| s.as_ref().to_string())
285}
286
287// ─── Rendering ───────────────────────────────────────────────────────────────
288
289/// Decide whether diagnostic rendering should include ANSI color escapes.
290///
291/// This is the pure decision core (injectable for tests):
292/// - **`NO_COLOR`** (<https://no-color.org>): the *presence* of the
293///   environment variable — any value, including the empty string —
294///   disables color.
295/// - **TTY**: color is only used when the stream that carries diagnostics
296///   is an interactive terminal. Piped/redirected output (CI logs, agents
297///   parsing `bock check` output) must never contain escape sequences.
298#[must_use]
299pub fn should_colorize(no_color_present: bool, stream_is_terminal: bool) -> bool {
300    !no_color_present && stream_is_terminal
301}
302
303/// [`should_colorize`] wired to the real environment: reads `NO_COLOR` and
304/// probes **stderr** (the stream the CLI prints rendered diagnostics to).
305#[must_use]
306pub fn color_enabled_for_diagnostics() -> bool {
307    should_colorize(
308        std::env::var_os("NO_COLOR").is_some(),
309        std::io::stderr().is_terminal(),
310    )
311}
312
313/// Render a slice of diagnostics to a string using ariadne for source context.
314///
315/// `filename` and `source` must correspond to the file referenced by the
316/// diagnostics' spans. This function is intentionally decoupled from
317/// `bock-source` types so that `bock-errors` stays dependency-free.
318///
319/// Color is decided automatically via [`color_enabled_for_diagnostics`]:
320/// ANSI escapes are emitted only when stderr is an interactive terminal and
321/// `NO_COLOR` is unset. Use [`render_with_color`] to force the decision.
322#[must_use]
323pub fn render(diagnostics: &[Diagnostic], filename: &str, source: &str) -> String {
324    render_with_color(
325        diagnostics,
326        filename,
327        source,
328        color_enabled_for_diagnostics(),
329    )
330}
331
332/// Convert a byte offset into `source` to a character (Unicode scalar) offset.
333///
334/// `Span` stores byte offsets, but `ariadne::Source` indexes by character
335/// offset — so spans must be remapped at the render boundary. Feeding byte
336/// offsets straight through shifts the rendered `line:col` and the underline
337/// once any multibyte character precedes the span (e.g. a span after `é`
338/// rendered one column too far right, and could drop its underline entirely).
339///
340/// Offsets at or past the end of `source` clamp to the total character count,
341/// which keeps end-of-file spans renderable rather than panicking.
342fn byte_to_char_offset(source: &str, byte_offset: usize) -> usize {
343    if byte_offset >= source.len() {
344        return source.chars().count();
345    }
346    // Count characters whose byte position is strictly before `byte_offset`.
347    // A byte offset that lands inside a multibyte character (it should not, for
348    // well-formed spans) rounds down to that character's char index.
349    source
350        .char_indices()
351        .take_while(|(i, _)| *i < byte_offset)
352        .count()
353}
354
355/// Render diagnostics with an explicit color decision.
356///
357/// `color: false` guarantees the output contains no ANSI escape sequences.
358#[must_use]
359pub fn render_with_color(
360    diagnostics: &[Diagnostic],
361    filename: &str,
362    source: &str,
363    color: bool,
364) -> String {
365    let mut out = Vec::new();
366    let cache = (filename, Source::from(source));
367
368    for diag in diagnostics {
369        let kind = severity_to_kind(diag.severity);
370        // `ariadne::Source` indexes by character offset, not byte offset, so
371        // every span fed to it must be remapped from the byte offsets `Span`
372        // carries (see `byte_to_char_offset`).
373        let start_char = byte_to_char_offset(source, diag.span.start);
374        let end_char = byte_to_char_offset(source, diag.span.end);
375        let span_range = start_char..end_char;
376
377        let mut builder = Report::build(kind, filename, start_char)
378            .with_config(Config::default().with_color(color))
379            .with_message(format!("[{}] {}", diag.code, diag.message))
380            .with_label(
381                AriadneLabel::new((filename, span_range))
382                    .with_message(&diag.message)
383                    .with_color(severity_color(diag.severity)),
384            );
385
386        for label in &diag.labels {
387            let l_start = byte_to_char_offset(source, label.span.start);
388            let l_end = byte_to_char_offset(source, label.span.end);
389            builder = builder.with_label(
390                AriadneLabel::new((filename, l_start..l_end))
391                    .with_message(&label.message)
392                    .with_color(Color::Blue),
393            );
394        }
395
396        for note in &diag.notes {
397            builder = builder.with_note(note);
398        }
399
400        builder
401            .finish()
402            .write(cache.clone(), &mut out)
403            .expect("write to Vec is infallible");
404    }
405
406    String::from_utf8_lossy(&out).into_owned()
407}
408
409fn severity_to_kind(severity: Severity) -> ReportKind<'static> {
410    match severity {
411        Severity::Error => ReportKind::Error,
412        Severity::Warning => ReportKind::Warning,
413        Severity::Info | Severity::Hint => ReportKind::Advice,
414    }
415}
416
417fn severity_color(severity: Severity) -> Color {
418    match severity {
419        Severity::Error => Color::Red,
420        Severity::Warning => Color::Yellow,
421        Severity::Info => Color::Cyan,
422        Severity::Hint => Color::Green,
423    }
424}
425
426// ─── Tests ───────────────────────────────────────────────────────────────────
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn make_span(start: usize, end: usize) -> Span {
433        Span {
434            file: FileId(1),
435            start,
436            end,
437        }
438    }
439
440    // ── Span ──────────────────────────────────────────────────────────────────
441
442    #[test]
443    fn span_merge_basic() {
444        let a = make_span(2, 5);
445        let b = make_span(3, 8);
446        let m = Span::merge(a, b);
447        assert_eq!(m.start, 2);
448        assert_eq!(m.end, 8);
449        assert_eq!(m.file, FileId(1));
450    }
451
452    #[test]
453    fn span_merge_disjoint() {
454        let a = make_span(0, 3);
455        let b = make_span(10, 15);
456        let m = Span::merge(a, b);
457        assert_eq!(m.start, 0);
458        assert_eq!(m.end, 15);
459    }
460
461    #[test]
462    fn span_merge_identical() {
463        let s = make_span(4, 9);
464        let m = Span::merge(s, s);
465        assert_eq!(m, s);
466    }
467
468    #[test]
469    fn span_dummy_is_zero() {
470        let d = Span::dummy();
471        assert_eq!(d.file, FileId(0));
472        assert_eq!(d.start, 0);
473        assert_eq!(d.end, 0);
474    }
475
476    // ── DiagnosticCode display ────────────────────────────────────────────────
477
478    #[test]
479    fn diagnostic_code_display() {
480        let c = DiagnosticCode {
481            prefix: 'E',
482            number: 42,
483        };
484        assert_eq!(c.to_string(), "E0042");
485    }
486
487    // ── DiagnosticBag ─────────────────────────────────────────────────────────
488
489    #[test]
490    fn bag_has_errors_false_when_empty() {
491        let bag = DiagnosticBag::new();
492        assert!(!bag.has_errors());
493    }
494
495    #[test]
496    fn bag_has_errors_false_for_warnings() {
497        let mut bag = DiagnosticBag::new();
498        let code = DiagnosticCode {
499            prefix: 'W',
500            number: 1,
501        };
502        bag.warning(code, "watch out", make_span(0, 1));
503        assert!(!bag.has_errors());
504    }
505
506    #[test]
507    fn bag_has_errors_true_for_error() {
508        let mut bag = DiagnosticBag::new();
509        let code = DiagnosticCode {
510            prefix: 'E',
511            number: 1,
512        };
513        bag.error(code, "oops", make_span(0, 1));
514        assert!(bag.has_errors());
515    }
516
517    #[test]
518    fn bag_iter_yields_all() {
519        let mut bag = DiagnosticBag::new();
520        let ec = DiagnosticCode {
521            prefix: 'E',
522            number: 1,
523        };
524        let wc = DiagnosticCode {
525            prefix: 'W',
526            number: 2,
527        };
528        bag.error(ec, "err", make_span(0, 1));
529        bag.warning(wc, "warn", make_span(1, 2));
530        let items: Vec<_> = bag.iter().collect();
531        assert_eq!(items.len(), 2);
532    }
533
534    #[test]
535    fn bag_labels_and_notes() {
536        let mut bag = DiagnosticBag::new();
537        let code = DiagnosticCode {
538            prefix: 'E',
539            number: 5,
540        };
541        bag.error(code, "main", make_span(0, 3))
542            .label(make_span(1, 2), "secondary")
543            .note("fix it");
544        let d = bag.iter().next().unwrap();
545        assert_eq!(d.labels.len(), 1);
546        assert_eq!(d.notes.len(), 1);
547    }
548
549    // ── render ────────────────────────────────────────────────────────────────
550
551    #[test]
552    fn render_error_contains_message() {
553        let source = "let x = ;";
554        let span = Span {
555            file: FileId(1),
556            start: 8,
557            end: 9,
558        };
559        let diag = Diagnostic {
560            severity: Severity::Error,
561            code: DiagnosticCode {
562                prefix: 'E',
563                number: 1,
564            },
565            message: "unexpected token".into(),
566            span,
567            labels: vec![],
568            notes: vec![],
569        };
570        let out = render(&[diag], "test.bock", source);
571        assert!(out.contains("unexpected token"), "output: {out}");
572    }
573
574    #[test]
575    fn render_empty_produces_empty_string() {
576        let out = render(&[], "test.bock", "let x = 1;");
577        assert!(out.is_empty());
578    }
579
580    // ── levenshtein ───────────────────────────────────────────────────────────
581
582    #[test]
583    fn levenshtein_equal_strings_is_zero() {
584        assert_eq!(levenshtein("foo", "foo"), 0);
585    }
586
587    #[test]
588    fn levenshtein_empty_strings() {
589        assert_eq!(levenshtein("", ""), 0);
590        assert_eq!(levenshtein("abc", ""), 3);
591        assert_eq!(levenshtein("", "abc"), 3);
592    }
593
594    #[test]
595    fn levenshtein_single_substitution() {
596        assert_eq!(levenshtein("cat", "bat"), 1);
597    }
598
599    #[test]
600    fn levenshtein_insert_and_delete() {
601        assert_eq!(levenshtein("kitten", "sitting"), 3);
602    }
603
604    #[test]
605    fn suggest_similar_finds_close_match() {
606        let names = vec!["println", "print", "printf"];
607        assert_eq!(suggest_similar("printn", names, 2), Some("println".into()));
608    }
609
610    #[test]
611    fn suggest_similar_rejects_far_matches() {
612        let names = vec!["elephant", "giraffe"];
613        assert_eq!(suggest_similar("cat", names, 2), None);
614    }
615
616    // ── color decision ────────────────────────────────────────────────────────
617
618    #[test]
619    fn should_colorize_only_on_tty_without_no_color() {
620        // Interactive TTY, NO_COLOR unset → color on.
621        assert!(should_colorize(false, true));
622        // NO_COLOR present (any value, per https://no-color.org) → off,
623        // even on a TTY.
624        assert!(!should_colorize(true, true));
625        // Piped / redirected output (not a terminal) → off.
626        assert!(!should_colorize(false, false));
627        // Both: off.
628        assert!(!should_colorize(true, false));
629    }
630
631    fn ansi_test_diag() -> Diagnostic {
632        Diagnostic {
633            severity: Severity::Error,
634            code: DiagnosticCode {
635                prefix: 'E',
636                number: 1,
637            },
638            message: "unexpected token".into(),
639            span: Span {
640                file: FileId(1),
641                start: 8,
642                end: 9,
643            },
644            labels: vec![],
645            notes: vec![],
646        }
647    }
648
649    #[test]
650    fn render_without_color_has_no_ansi_escapes() {
651        let out = render_with_color(&[ansi_test_diag()], "test.bock", "let x = ;", false);
652        assert!(
653            !out.contains('\u{1b}'),
654            "expected no ANSI escapes, got: {out:?}"
655        );
656        assert!(out.contains("unexpected token"), "output: {out}");
657        assert!(out.contains("E0001"), "output: {out}");
658    }
659
660    #[test]
661    fn render_with_color_emits_ansi_escapes() {
662        let out = render_with_color(&[ansi_test_diag()], "test.bock", "let x = ;", true);
663        assert!(
664            out.contains('\u{1b}'),
665            "expected ANSI escapes when color is forced on, got: {out:?}"
666        );
667    }
668
669    #[test]
670    fn render_with_note_without_color_keeps_note_text() {
671        let mut diag = ansi_test_diag();
672        diag.note("declare the variable first");
673        let out = render_with_color(&[diag], "test.bock", "let x = ;", false);
674        assert!(out.contains("declare the variable first"), "output: {out}");
675        assert!(!out.contains('\u{1b}'), "output: {out:?}");
676    }
677
678    // ── byte→char offset remapping for the render boundary ─────────────────────
679
680    #[test]
681    fn byte_to_char_offset_ascii_is_identity() {
682        let s = "abcdef";
683        assert_eq!(byte_to_char_offset(s, 0), 0);
684        assert_eq!(byte_to_char_offset(s, 3), 3);
685        assert_eq!(byte_to_char_offset(s, 6), 6);
686    }
687
688    #[test]
689    fn byte_to_char_offset_multibyte() {
690        // "fée x": bytes f(0) é(1-2) e(3) space(4) x(5); chars f=0 é=1 e=2 ' '=3 x=4.
691        let s = "fée x";
692        assert_eq!(byte_to_char_offset(s, 0), 0); // before 'f'
693        assert_eq!(byte_to_char_offset(s, 1), 1); // before 'é'
694        assert_eq!(byte_to_char_offset(s, 3), 2); // before 'e' (past 2-byte 'é')
695        assert_eq!(byte_to_char_offset(s, 5), 4); // before 'x'
696        assert_eq!(byte_to_char_offset(s, 6), 5); // end of file (clamped)
697                                                  // Past-end clamps to the char count.
698        assert_eq!(byte_to_char_offset(s, 999), 5);
699    }
700
701    #[test]
702    fn render_multibyte_span_underlines_correct_char() {
703        // Regression for Q-errors-render-byte-col-drift: a span after a
704        // multibyte character must render at the correct char column and keep
705        // its underline. "fée x" — the `x` token is byte 5..6, char 4..5, and
706        // must render at column 5 (1-indexed), not column 6.
707        let source = "fée x";
708        let span = Span {
709            file: FileId(1),
710            start: 5,
711            end: 6,
712        };
713        let diag = Diagnostic {
714            severity: Severity::Error,
715            code: DiagnosticCode {
716                prefix: 'E',
717                number: 1,
718            },
719            message: "bad token".into(),
720            span,
721            labels: vec![],
722            notes: vec![],
723        };
724        let out = render_with_color(&[diag], "m.bock", source, false);
725        // Column 5 is correct (f=1, é=2, e=3, ' '=4, x=5); the pre-fix code
726        // reported 1:6 and dropped the underline.
727        assert!(out.contains("m.bock:1:5"), "expected column 5, got:\n{out}");
728        assert!(
729            !out.contains("m.bock:1:6"),
730            "should not drift to col 6:\n{out}"
731        );
732        // An underline must render under the offending char — ariadne uses a
733        // box-drawing underline (`┬`/`─`); pre-fix the span fell off the char
734        // range and no underline was drawn at all.
735        assert!(
736            out.contains('┬') || out.contains('─'),
737            "expected an underline under the span:\n{out}"
738        );
739        // The flagged source line is present and intact.
740        assert!(out.contains("fée x"), "source line should render:\n{out}");
741    }
742
743    #[test]
744    fn render_with_note() {
745        let source = "foo bar";
746        let span = Span {
747            file: FileId(1),
748            start: 0,
749            end: 3,
750        };
751        let mut diag = Diagnostic {
752            severity: Severity::Warning,
753            code: DiagnosticCode {
754                prefix: 'W',
755                number: 99,
756            },
757            message: "test warning".into(),
758            span,
759            labels: vec![],
760            notes: vec![],
761        };
762        diag.note("consider renaming");
763        let out = render(&[diag], "src.bock", source);
764        assert!(out.contains("consider renaming"), "output: {out}");
765    }
766}