Skip to main content

duck_diag/
formatter.rs

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