Skip to main content

duck_diagnostic/
formatter.rs

1use colored::*;
2use unicode_width::UnicodeWidthStr;
3
4use crate::diagnostic::{Diagnostic, DiagnosticCode, Label, LabelStyle, Severity, Suggestion};
5
6/// Pre-split source cache. Build once per source string, reuse for many diagnostics.
7#[derive(Debug, Clone)]
8pub struct SourceCache<'a> {
9  lines: Vec<&'a str>,
10}
11
12impl<'a> SourceCache<'a> {
13  pub fn new(source: &'a str) -> Self {
14    Self { lines: source.lines().collect() }
15  }
16
17  pub fn line(&self, line_num_1based: usize) -> Option<&str> {
18    if line_num_1based == 0 {
19      return None;
20    }
21    self.lines.get(line_num_1based - 1).copied()
22  }
23
24  pub fn len(&self) -> usize {
25    self.lines.len()
26  }
27
28  pub fn is_empty(&self) -> bool {
29    self.lines.is_empty()
30  }
31}
32
33/// Owned variant — allocates a `Vec<String>` internally. Use this when you
34/// don't have a `&str` source to borrow (or for back-compat with v0.1).
35#[derive(Debug, Clone)]
36struct OwnedSource(Vec<String>);
37
38impl OwnedSource {
39  fn new(source: &str) -> Self {
40    Self(source.lines().map(String::from).collect())
41  }
42  fn line(&self, n: usize) -> Option<&str> {
43    if n == 0 {
44      None
45    } else {
46      self.0.get(n - 1).map(String::as_str)
47    }
48  }
49  fn len(&self) -> usize {
50    self.0.len()
51  }
52}
53
54enum CacheRef<'a, 'src> {
55  Borrowed(&'a SourceCache<'src>),
56  Owned(OwnedSource),
57}
58
59impl<'a, 'src> CacheRef<'a, 'src> {
60  fn line(&self, n: usize) -> Option<&str> {
61    match self {
62      Self::Borrowed(c) => c.line(n),
63      Self::Owned(o) => o.line(n),
64    }
65  }
66  fn len(&self) -> usize {
67    match self {
68      Self::Borrowed(c) => c.len(),
69      Self::Owned(o) => o.len(),
70    }
71  }
72}
73
74/// Tunables for rendered output.
75#[derive(Debug, Clone, Copy)]
76pub struct RenderOptions {
77  /// Tab stop width when expanding tabs in source lines.
78  pub tab_width: usize,
79  /// Number of context lines printed above + below each label region.
80  pub context_lines: usize,
81  /// Maximum rendered line width before truncation. `0` disables truncation.
82  pub max_line_width: usize,
83  /// Use ANSI color codes.
84  pub color: bool,
85}
86
87impl Default for RenderOptions {
88  fn default() -> Self {
89    Self { tab_width: 4, context_lines: 0, max_line_width: 0, color: true }
90  }
91}
92
93pub struct DiagnosticFormatter<'a, 'src, C: DiagnosticCode> {
94  diagnostic: &'a Diagnostic<C>,
95  cache: CacheRef<'a, 'src>,
96  options: RenderOptions,
97}
98
99impl<'a, 'src, C: DiagnosticCode> DiagnosticFormatter<'a, 'src, C> {
100  /// Construct from a raw source string. For repeated formatting against the
101  /// same source, build a [`SourceCache`] once and use [`DiagnosticFormatter::with_cache`].
102  pub fn new(diagnostic: &'a Diagnostic<C>, source: &str) -> Self {
103    Self {
104      diagnostic,
105      cache: CacheRef::Owned(OwnedSource::new(source)),
106      options: RenderOptions::default(),
107    }
108  }
109
110  pub fn with_cache(diagnostic: &'a Diagnostic<C>, cache: &'a SourceCache<'src>) -> Self {
111    Self { diagnostic, cache: CacheRef::Borrowed(cache), options: RenderOptions::default() }
112  }
113
114  pub fn with_options(mut self, options: RenderOptions) -> Self {
115    self.options = options;
116    self
117  }
118
119  fn severity_text(&self) -> &'static str {
120    match self.diagnostic.severity {
121      Severity::Bug => "internal error",
122      Severity::Error => "error",
123      Severity::Warning => "warning",
124      Severity::Note => "note",
125      Severity::Help => "help",
126    }
127  }
128
129  fn underline_char(style: LabelStyle) -> char {
130    match style {
131      LabelStyle::Primary => '^',
132      LabelStyle::Secondary => '-',
133    }
134  }
135
136  /// Pretty (colored) format. Falls back to plain if `options.color = false`.
137  pub fn format(&self) -> String {
138    if self.options.color {
139      self.format_inner(true)
140    } else {
141      self.format_inner(false)
142    }
143  }
144
145  /// Plain (no color, deterministic) format. Suitable for CI logs.
146  pub fn format_plain(&self) -> String {
147    self.format_inner(false)
148  }
149
150  fn format_inner(&self, color: bool) -> String {
151    let mut out = String::new();
152    self.write_header(&mut out, color);
153    self.write_labels_grouped(&mut out, color);
154    self.write_notes_help(&mut out, color);
155    self.write_suggestions(&mut out, color);
156    // Trailing blank line so consecutive diagnostics don't visually merge.
157    out.push('\n');
158    out
159  }
160
161  fn write_header(&self, out: &mut String, color: bool) {
162    let sev = self.severity_text();
163    let code_str = self.diagnostic.code.code();
164    let url = self.diagnostic.code.url();
165
166    if color {
167      let (sev_c, code_c) = match self.diagnostic.severity {
168        Severity::Bug | Severity::Error => (sev.red().bold(), code_str.red().bold()),
169        Severity::Warning => (sev.yellow().bold(), code_str.yellow().bold()),
170        _ => (sev.cyan().bold(), code_str.cyan().bold()),
171      };
172      out.push_str(&format!("{}: [{}]: {}", sev_c, code_c, self.diagnostic.message));
173    } else {
174      out.push_str(&format!("{}: [{}]: {}", sev, code_str, self.diagnostic.message));
175    }
176
177    if let Some(u) = url {
178      if color {
179        out.push_str(&format!(" {}", format!("(see {u})").blue().italic()));
180      } else {
181        out.push_str(&format!(" (see {u})"));
182      }
183    }
184    out.push('\n');
185  }
186
187  fn write_labels_grouped(&self, out: &mut String, color: bool) {
188    let labels = &self.diagnostic.labels;
189    if labels.is_empty() {
190      return;
191    }
192
193    // Group by file so multi-file diagnostics render as separate sections.
194    let mut files: Vec<&str> = Vec::new();
195    for l in labels {
196      if !files.iter().any(|f| *f == l.span.file.as_str()) {
197        files.push(l.span.file.as_str());
198      }
199    }
200
201    for (idx, file) in files.iter().enumerate() {
202      let in_file: Vec<&Label> = labels.iter().filter(|l| l.span.file.as_str() == *file).collect();
203      let primary_in_file = in_file
204        .iter()
205        .find(|l| l.style == LabelStyle::Primary)
206        .copied()
207        .or(in_file.first().copied());
208      let primary = match primary_in_file {
209        Some(l) => l,
210        None => continue,
211      };
212
213      // File header
214      let header_line = format!(
215        "  {} {}:{}:{}",
216        if color { "-->".blue().bold().to_string() } else { "-->".to_string() },
217        if color {
218          primary.span.file.white().bold().to_string()
219        } else {
220          primary.span.file.clone()
221        },
222        if color {
223          primary.span.line.to_string().white().bold().to_string()
224        } else {
225          primary.span.line.to_string()
226        },
227        if color {
228          primary.span.column.to_string().white().bold().to_string()
229        } else {
230          primary.span.column.to_string()
231        },
232      );
233      out.push_str(&header_line);
234      out.push('\n');
235
236      self.write_file_section(out, &in_file, color);
237
238      if idx + 1 < files.len() {
239        out.push('\n');
240      }
241    }
242  }
243
244  fn write_file_section(&self, out: &mut String, labels: &[&Label], color: bool) {
245    // Determine line range to render: min..=max of all labels in this file,
246    // padded by context_lines.
247    let min_line = labels.iter().map(|l| l.span.line).min().unwrap_or(0);
248    let max_line = labels.iter().map(|l| l.span.line).max().unwrap_or(0);
249    if min_line == 0 {
250      // synthetic span — nothing to render
251      return;
252    }
253
254    let start = min_line.saturating_sub(self.options.context_lines).max(1);
255    let end = (max_line + self.options.context_lines).min(self.cache.len());
256
257    let gutter_w = end.to_string().len().max(2);
258    let bar = if color { "|".blue().bold().to_string() } else { "|".to_string() };
259
260    let blank_gutter = " ".repeat(gutter_w);
261    out.push_str(&format!("  {} {}\n", blank_gutter, bar));
262
263    for line_num in start..=end {
264      let raw = self.cache.line(line_num).unwrap_or("");
265      let expanded = expand_tabs(raw, self.options.tab_width);
266      let truncated = truncate_line(&expanded, self.options.max_line_width);
267      let line_label = format!("{:>w$}", line_num, w = gutter_w);
268      let line_label_c =
269        if color { line_label.blue().bold().to_string() } else { line_label.clone() };
270      out.push_str(&format!("  {} {} {}\n", line_label_c, bar, truncated));
271
272      // collect labels touching this line, sorted by start column
273      let mut on_line: Vec<&Label> =
274        labels.iter().copied().filter(|l| label_touches(l, line_num)).collect();
275      if on_line.is_empty() {
276        continue;
277      }
278      on_line.sort_by_key(|l| l.span.column);
279      self.write_caret_block(out, &on_line, line_num, raw, gutter_w, color);
280    }
281
282    out.push_str(&format!("  {} {}\n", blank_gutter, bar));
283  }
284
285  /// Render all labels on one source line as a stacked block.
286  ///
287  /// Layout (rustc-style):
288  ///   row 0  : carets for every label  →  message of last (rightmost) label
289  ///   row 1  : carets up to label[n-2] →  message of label[n-2]
290  ///   …
291  ///   row n-1: caret for label[0]      →  message of label[0]
292  ///
293  /// Each label keeps its own color. Optional per-label `note` renders right
294  /// after that label's message row.
295  fn write_caret_block(
296    &self,
297    out: &mut String,
298    sorted: &[&Label],
299    line_num: usize,
300    raw_line: &str,
301    gutter_w: usize,
302    color: bool,
303  ) {
304    let infos: Vec<(usize, usize, &Label)> = sorted
305      .iter()
306      .map(|label| {
307        let (col_start, col_end) = label_columns_on_line(label, line_num, raw_line);
308        let pad =
309          display_width_prefix(raw_line, col_start.saturating_sub(1), self.options.tab_width);
310        let len = display_width_range(
311          raw_line,
312          col_start.saturating_sub(1),
313          col_end.saturating_sub(1),
314          self.options.tab_width,
315        )
316        .max(1);
317        (pad, len, *label)
318      })
319      .collect();
320
321    let n = infos.len();
322    let bar = if color { "|".blue().bold().to_string() } else { "|".to_string() };
323    let blank_gutter = " ".repeat(gutter_w);
324
325    for k in 0..n {
326      let m = n - 1 - k;
327      let visible = &infos[..=m];
328
329      // Build the caret row by walking visible labels left→right.
330      let mut buf = String::new();
331      let mut cursor = 0usize;
332      for (pad, len, lbl) in visible {
333        while cursor < *pad {
334          buf.push(' ');
335          cursor += 1;
336        }
337        let ch = Self::underline_char(lbl.style);
338        let underline: String = std::iter::repeat(ch).take(*len).collect();
339        let painted = if color {
340          color_for(self.diagnostic.severity, lbl.style, &underline)
341        } else {
342          underline
343        };
344        buf.push_str(&painted);
345        cursor += *len;
346      }
347
348      // Append the message of `m` after the last caret with one space of gap.
349      let m_label = visible.last().unwrap().2;
350      let line = match &m_label.message {
351        Some(msg) => {
352          let painted_msg = if color {
353            color_for(self.diagnostic.severity, m_label.style, msg)
354          } else {
355            msg.clone()
356          };
357          format!("  {} {} {} {}\n", blank_gutter, bar, buf, painted_msg)
358        },
359        None => format!("  {} {} {}\n", blank_gutter, bar, buf),
360      };
361      out.push_str(&line);
362
363      // Per-label inline note, rendered on its own row directly under this row.
364      if let Some(note) = &m_label.note {
365        let note_c = if color { note.cyan().italic().to_string() } else { format!("note: {note}") };
366        out.push_str(&format!(
367          "  {} {} {}↳ {}\n",
368          blank_gutter,
369          bar,
370          " ".repeat(infos[m].0),
371          note_c
372        ));
373      }
374    }
375  }
376
377  fn write_notes_help(&self, out: &mut String, color: bool) {
378    let eq = if color { "=".blue().bold().to_string() } else { "=".to_string() };
379    for note in &self.diagnostic.notes {
380      let label = if color { "note".cyan().bold().to_string() } else { "note".to_string() };
381      out.push_str(&format!("   {} {}: {}\n", eq, label, note));
382    }
383    if let Some(help) = &self.diagnostic.help {
384      let label = if color { "help".cyan().bold().to_string() } else { "help".to_string() };
385      out.push_str(&format!("   {} {}: {}\n", eq, label, help));
386    }
387  }
388
389  fn write_suggestions(&self, out: &mut String, color: bool) {
390    if self.diagnostic.suggestions.is_empty() {
391      return;
392    }
393    let eq = if color { "=".blue().bold().to_string() } else { "=".to_string() };
394    let label = if color { "help".cyan().bold().to_string() } else { "help".to_string() };
395    for s in &self.diagnostic.suggestions {
396      let header = match &s.message {
397        Some(m) => m.to_string(),
398        None => "try this:".to_string(),
399      };
400      out.push_str(&format!("   {} {}: {}\n", eq, label, header));
401      self.write_suggestion_diff(out, s, color);
402      Self::write_applicability(out, s, color);
403    }
404  }
405
406  /// Render a suggestion as rustc-style minus/plus diff lines. Falls back to
407  /// flat replacement render when the source line isn't available (synthetic
408  /// span or out-of-range line).
409  fn write_suggestion_diff(&self, out: &mut String, s: &Suggestion, color: bool) {
410    let line_num = s.span.line;
411    let orig_line = match self.cache.line(line_num) {
412      Some(l) => l,
413      None => {
414        // No source — flat render.
415        for line in s.replacement.lines() {
416          let body = if color { line.green().to_string() } else { line.to_string() };
417          out.push_str(&format!("       {}\n", body));
418        }
419        return;
420      },
421    };
422
423    // Convert 1-based column → byte offset. Use saturating arithmetic to
424    // tolerate suggestions slightly off the line end (e.g. column = line.len() + 1).
425    let col0 = s.span.column.saturating_sub(1);
426    let line_bytes = orig_line.len();
427    let start = col0.min(line_bytes);
428    let end = (start + s.span.length).min(line_bytes);
429    let prefix = &orig_line[..start];
430    let suffix = &orig_line[end..];
431
432    // Build rewritten content by splicing replacement between prefix + suffix.
433    // First rewritten line includes prefix + first replacement line; subsequent
434    // replacement lines stand alone; final replacement line gets suffix appended.
435    let repl_lines: Vec<&str> = s.replacement.split('\n').collect();
436    let mut new_lines: Vec<String> = Vec::with_capacity(repl_lines.len());
437    for (i, r) in repl_lines.iter().enumerate() {
438      let head = if i == 0 { prefix } else { "" };
439      let tail = if i == repl_lines.len() - 1 { suffix } else { "" };
440      new_lines.push(format!("{}{}{}", head, r, tail));
441    }
442
443    // Gutter width = max line number we'll print.
444    let last_line = line_num + new_lines.len().saturating_sub(1);
445    let gutter_w = last_line.to_string().len().max(2);
446    let bar = if color { "|".blue().bold().to_string() } else { "|".to_string() };
447    let blank_gutter = " ".repeat(gutter_w);
448
449    out.push_str(&format!("  {} {}\n", blank_gutter, bar));
450
451    // Old line (single, since spans on one line; multi-line removal not modelled).
452    let line_label = format!("{:>w$}", line_num, w = gutter_w);
453    let line_label_c =
454      if color { line_label.blue().bold().to_string() } else { line_label.clone() };
455    let minus = if color { "-".red().bold().to_string() } else { "-".to_string() };
456    let orig_body = if color { orig_line.red().to_string() } else { orig_line.to_string() };
457    out.push_str(&format!("  {} {} {}\n", line_label_c, minus, orig_body));
458
459    // New lines (one or many).
460    let plus = if color { "+".green().bold().to_string() } else { "+".to_string() };
461    for (i, body) in new_lines.iter().enumerate() {
462      let n = line_num + i;
463      let lbl = format!("{:>w$}", n, w = gutter_w);
464      let lbl_c = if color { lbl.blue().bold().to_string() } else { lbl.clone() };
465      let body_c = if color { body.green().to_string() } else { body.to_string() };
466      out.push_str(&format!("  {} {} {}\n", lbl_c, plus, body_c));
467    }
468
469    out.push_str(&format!("  {} {}\n", blank_gutter, bar));
470  }
471
472  fn write_applicability(out: &mut String, s: &Suggestion, color: bool) {
473    let kind = match s.applicability {
474      crate::diagnostic::Applicability::MachineApplicable => "auto-applicable",
475      crate::diagnostic::Applicability::MaybeIncorrect => "review needed",
476      crate::diagnostic::Applicability::HasPlaceholders => "has placeholders",
477      crate::diagnostic::Applicability::Unspecified => return,
478    };
479    let body = if color { kind.dimmed().to_string() } else { kind.to_string() };
480    out.push_str(&format!("       ({})\n", body));
481  }
482}
483
484// ---------- helpers ----------
485
486fn color_for(sev: Severity, style: LabelStyle, s: &str) -> String {
487  match (sev, style) {
488    (Severity::Bug | Severity::Error, LabelStyle::Primary) => s.red().bold().to_string(),
489    (Severity::Warning, LabelStyle::Primary) => s.yellow().bold().to_string(),
490    _ => s.cyan().bold().to_string(),
491  }
492}
493
494fn label_touches(label: &Label, line: usize) -> bool {
495  let start = label.span.line;
496  let end_line = end_line_of(label);
497  line >= start && line <= end_line
498}
499
500/// For a possibly multi-line label, clamp its column range to the given line.
501fn label_columns_on_line(label: &Label, line: usize, raw_line: &str) -> (usize, usize) {
502  let start_line = label.span.line;
503  let line_byte_len = raw_line.len();
504  let line_end_col = line_byte_len + 1; // 1-based inclusive end-of-line column
505
506  let start_col = if line == start_line { label.span.column.max(1) } else { 1 };
507  let end_col_inclusive = if end_line_of(label) == line {
508    if line == start_line {
509      // single-line label: column..column+length
510      (label.span.column + label.span.length).max(label.span.column + 1)
511    } else {
512      // last line of multi-line label: end at remaining length on this line
513      // we don't have per-line offsets, so just stop at line end
514      line_end_col
515    }
516  } else {
517    line_end_col
518  };
519
520  (start_col, end_col_inclusive.min(line_end_col).max(start_col + 1))
521}
522
523fn end_line_of(label: &Label) -> usize {
524  // Without per-line offsets we treat `length` as a byte budget consumed
525  // top-down; callers that use multi-line spans should split them into
526  // multiple labels for precise rendering. For our purposes the label
527  // ends on its start line unless the user attaches multiple labels.
528  label.span.line
529}
530
531fn expand_tabs(line: &str, tab_width: usize) -> String {
532  if !line.contains('\t') {
533    return line.to_string();
534  }
535  let mut out = String::with_capacity(line.len() + tab_width);
536  let mut col = 0usize;
537  for ch in line.chars() {
538    if ch == '\t' {
539      let advance = tab_width - (col % tab_width.max(1));
540      for _ in 0..advance {
541        out.push(' ');
542      }
543      col += advance;
544    } else {
545      out.push(ch);
546      col += UnicodeWidthStr::width(ch.to_string().as_str());
547    }
548  }
549  out
550}
551
552fn truncate_line(line: &str, max: usize) -> String {
553  if max == 0 {
554    return line.to_string();
555  }
556  let w = UnicodeWidthStr::width(line);
557  if w <= max {
558    return line.to_string();
559  }
560  // keep first max-1 cols, then ellipsis
561  let mut out = String::new();
562  let mut acc = 0usize;
563  for ch in line.chars() {
564    let cw = UnicodeWidthStr::width(ch.to_string().as_str());
565    if acc + cw + 1 > max {
566      break;
567    }
568    out.push(ch);
569    acc += cw;
570  }
571  out.push('…');
572  out
573}
574
575/// Display width of `&line[..n_byte_cols]`, accounting for tabs + unicode width.
576fn display_width_prefix(line: &str, byte_offset_0based: usize, tab_width: usize) -> usize {
577  let mut width = 0usize;
578  let mut byte_seen = 0usize;
579  for ch in line.chars() {
580    if byte_seen >= byte_offset_0based {
581      break;
582    }
583    if ch == '\t' {
584      width += tab_width - (width % tab_width.max(1));
585    } else {
586      width += UnicodeWidthStr::width(ch.to_string().as_str());
587    }
588    byte_seen += ch.len_utf8();
589  }
590  width
591}
592
593fn display_width_range(
594  line: &str,
595  start_byte_0based: usize,
596  end_byte_0based: usize,
597  tab_width: usize,
598) -> usize {
599  if end_byte_0based <= start_byte_0based {
600    return 0;
601  }
602  let mut width = 0usize;
603  let mut byte_seen = 0usize;
604  for ch in line.chars() {
605    if byte_seen >= end_byte_0based {
606      break;
607    }
608    if byte_seen >= start_byte_0based {
609      if ch == '\t' {
610        width += tab_width - (width % tab_width.max(1));
611      } else {
612        width += UnicodeWidthStr::width(ch.to_string().as_str());
613      }
614    }
615    byte_seen += ch.len_utf8();
616  }
617  width
618}