deno_ast/
diagnostics.rs

1// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::fmt;
6use std::fmt::Display;
7use std::fmt::Write as _;
8use std::path::PathBuf;
9
10use deno_terminal::colors;
11use unicode_width::UnicodeWidthStr;
12
13use crate::ModuleSpecifier;
14use crate::SourcePos;
15use crate::SourceRange;
16use crate::SourceRanged;
17use crate::SourceTextInfo;
18
19pub enum DiagnosticLevel {
20  Error,
21  Warning,
22}
23
24#[derive(Clone, Copy, Debug)]
25pub struct DiagnosticSourceRange {
26  pub start: DiagnosticSourcePos,
27  pub end: DiagnosticSourcePos,
28}
29
30#[derive(Clone, Copy, Debug)]
31pub enum DiagnosticSourcePos {
32  SourcePos(SourcePos),
33  ByteIndex(usize),
34  LineAndCol {
35    // 0-indexed line number in bytes
36    line: usize,
37    // 0-indexed column number in bytes
38    column: usize,
39  },
40}
41
42impl DiagnosticSourcePos {
43  fn pos(&self, source: &SourceTextInfo) -> SourcePos {
44    match self {
45      DiagnosticSourcePos::SourcePos(pos) => *pos,
46      DiagnosticSourcePos::ByteIndex(index) => source.range().start() + *index,
47      DiagnosticSourcePos::LineAndCol { line, column } => {
48        source.line_start(*line) + *column
49      }
50    }
51  }
52}
53
54#[derive(Clone, Debug)]
55pub enum DiagnosticLocation<'a> {
56  /// The diagnostic is relevant to a specific path.
57  Path { path: PathBuf },
58  /// The diagnostic is relevant to an entire module.
59  Module {
60    /// The specifier of the module that contains the diagnostic.
61    specifier: Cow<'a, ModuleSpecifier>,
62  },
63  /// The diagnostic is relevant to a specific position in a module.
64  ///
65  /// This variant will get the relevant `SouceTextInfo` from the cache using
66  /// the given specifier, and will then calculate the line and column numbers
67  /// from the given `SourcePos`.
68  ModulePosition {
69    /// The specifier of the module that contains the diagnostic.
70    specifier: Cow<'a, ModuleSpecifier>,
71    /// The source position of the diagnostic.
72    source_pos: DiagnosticSourcePos,
73    text_info: Cow<'a, SourceTextInfo>,
74  },
75}
76
77impl DiagnosticLocation<'_> {
78  /// Return the line and column number of the diagnostic.
79  ///
80  /// The line number is 1-indexed.
81  ///
82  /// The column number is 1-indexed. This is the number of UTF-16 code units
83  /// from the start of the line to the diagnostic.
84  /// Why UTF-16 code units? Because that's what VS Code understands, and
85  /// everyone uses VS Code. :)
86  fn position(&self) -> Option<(usize, usize)> {
87    match self {
88      DiagnosticLocation::Path { .. } => None,
89      DiagnosticLocation::Module { .. } => None,
90      DiagnosticLocation::ModulePosition {
91        specifier: _specifier,
92        source_pos,
93        text_info,
94      } => {
95        let pos = source_pos.pos(text_info);
96        let line_index = text_info.line_index(pos);
97        let line_start_pos = text_info.line_start(line_index);
98        // todo(dsherret): fix in text_lines
99        let content =
100          text_info.range_text(&SourceRange::new(line_start_pos, pos));
101        let line = line_index + 1;
102        let column = content.encode_utf16().count() + 1;
103        Some((line, column))
104      }
105    }
106  }
107}
108
109pub struct DiagnosticSnippet<'a> {
110  /// The source text for this snippet. The
111  pub source: Cow<'a, crate::SourceTextInfo>,
112  /// The piece of the snippet that should be highlighted. For best results, the
113  /// highlights should not overlap and be ordered by their start position.
114  pub highlights: Vec<DiagnosticSnippetHighlight<'a>>,
115}
116
117#[derive(Clone)]
118pub struct DiagnosticSnippetHighlight<'a> {
119  /// The range of the snippet that should be highlighted.
120  pub range: DiagnosticSourceRange,
121  /// The style of the highlight.
122  pub style: DiagnosticSnippetHighlightStyle,
123  /// An optional inline description of the highlight.
124  pub description: Option<Cow<'a, str>>,
125}
126
127#[derive(Clone, Copy)]
128pub enum DiagnosticSnippetHighlightStyle {
129  /// The highlight is an error. This will place red carets under the highlight.
130  Error,
131  #[allow(dead_code)]
132  /// The highlight is a warning. This will place yellow carets under the
133  /// highlight.
134  Warning,
135  #[allow(dead_code)]
136  /// The highlight shows a hint. This will place blue dashes under the
137  /// highlight.
138  Hint,
139}
140
141impl DiagnosticSnippetHighlightStyle {
142  fn style_underline(
143    &self,
144    s: impl std::fmt::Display,
145  ) -> impl std::fmt::Display {
146    match self {
147      DiagnosticSnippetHighlightStyle::Error => colors::red_bold(s),
148      DiagnosticSnippetHighlightStyle::Warning => colors::yellow_bold(s),
149      DiagnosticSnippetHighlightStyle::Hint => colors::intense_blue(s),
150    }
151  }
152
153  fn underline_char(&self) -> char {
154    match self {
155      DiagnosticSnippetHighlightStyle::Error => '^',
156      DiagnosticSnippetHighlightStyle::Warning => '^',
157      DiagnosticSnippetHighlightStyle::Hint => '-',
158    }
159  }
160}
161
162/// Returns the text of the line with the given number.
163fn line_text(source: &SourceTextInfo, line_number: usize) -> &str {
164  source.line_text(line_number - 1)
165}
166
167/// Returns the line number (1 indexed) of the line that contains the given
168/// position.
169fn line_number(source: &SourceTextInfo, pos: DiagnosticSourcePos) -> usize {
170  source.line_index(pos.pos(source)) + 1
171}
172
173pub trait Diagnostic {
174  /// The level of the diagnostic.
175  fn level(&self) -> DiagnosticLevel;
176
177  /// The diagnostic code, like `no-explicit-any` or `ban-untagged-ignore`.
178  fn code(&self) -> Cow<'_, str>;
179
180  /// The human-readable diagnostic message.
181  fn message(&self) -> Cow<'_, str>;
182
183  /// The location this diagnostic is associated with.
184  fn location(&self) -> DiagnosticLocation<'_>;
185
186  /// A snippet showing the source code associated with the diagnostic.
187  fn snippet(&self) -> Option<DiagnosticSnippet<'_>>;
188
189  /// A hint for fixing the diagnostic.
190  fn hint(&self) -> Option<Cow<'_, str>>;
191
192  /// A snippet showing how the diagnostic can be fixed.
193  fn snippet_fixed(&self) -> Option<DiagnosticSnippet<'_>>;
194
195  fn info(&self) -> Cow<'_, [Cow<'_, str>]>;
196
197  /// An optional URL to the documentation for the diagnostic.
198  fn docs_url(&self) -> Option<Cow<'_, str>>;
199
200  fn display(&self) -> DiagnosticDisplay<'_, Self> {
201    DiagnosticDisplay { diagnostic: self }
202  }
203}
204
205struct RepeatingCharFmt(char, usize);
206impl fmt::Display for RepeatingCharFmt {
207  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208    for _ in 0..self.1 {
209      f.write_char(self.0)?;
210    }
211    Ok(())
212  }
213}
214
215/// How many spaces a tab should be displayed as. 2 is the default used for
216/// `deno fmt`, so we'll use that here.
217const TAB_WIDTH: usize = 2;
218
219struct ReplaceTab<'a>(&'a str);
220impl fmt::Display for ReplaceTab<'_> {
221  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222    let mut written = 0;
223    for (i, c) in self.0.char_indices() {
224      if c == '\t' {
225        self.0[written..i].fmt(f)?;
226        RepeatingCharFmt(' ', TAB_WIDTH).fmt(f)?;
227        written = i + 1;
228      }
229    }
230    self.0[written..].fmt(f)?;
231    Ok(())
232  }
233}
234
235/// The width of the string as displayed, assuming tabs are 2 spaces wide.
236///
237/// This display width assumes that zero-width-joined characters are the width
238/// of their consituent characters. This means that "Person: Red Hair" (which is
239/// represented as "Person" + "ZWJ" + "Red Hair") will have a width of 4.
240///
241/// Whether this is correct is unfortunately dependent on the font / terminal
242/// being used. Here is a list of what terminals consider the length of
243/// "Person: Red Hair" to be:
244///
245/// | Terminal         | Rendered Width |
246/// | ---------------- | -------------- |
247/// | Windows Terminal | 5 chars        |
248/// | iTerm (macOS)    | 2 chars        |
249/// | Terminal (macOS) | 2 chars        |
250/// | VS Code terminal | 4 chars        |
251/// | GNOME Terminal   | 4 chars        |
252///
253/// If we really wanted to, we could try and detect the terminal being used and
254/// adjust the width accordingly. However, this is probably not worth the
255/// effort.
256fn display_width(str: &str) -> usize {
257  let num_tabs = str.chars().filter(|c| *c == '\t').count();
258  str.width_cjk() + num_tabs * TAB_WIDTH - num_tabs
259}
260
261pub struct DiagnosticDisplay<'a, T: Diagnostic + ?Sized> {
262  diagnostic: &'a T,
263}
264
265impl<T: Diagnostic + ?Sized> Display for DiagnosticDisplay<'_, T> {
266  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267    print_diagnostic(f, self.diagnostic)
268  }
269}
270
271// error[missing-return-type]: missing explicit return type on public function
272//   at /mnt/artemis/Projects/github.com/denoland/deno/test.ts:1:16
273//    |
274//  1 | export function test() {
275//    |                 ^^^^
276//    = hint: add an explicit return type to the function
277//    |
278//  1 | export function test(): string {
279//    |                       ^^^^^^^^
280//
281//   info: all functions that are exported from a module must have an explicit return type to support fast check and documentation generation.
282//   docs: https://jsr.io/d/missing-return-type
283fn print_diagnostic(
284  io: &mut dyn std::fmt::Write,
285  diagnostic: &(impl Diagnostic + ?Sized),
286) -> Result<(), std::fmt::Error> {
287  match diagnostic.level() {
288    DiagnosticLevel::Error => {
289      write!(
290        io,
291        "{}",
292        colors::red_bold(format_args!("error[{}]", diagnostic.code()))
293      )?;
294    }
295    DiagnosticLevel::Warning => {
296      write!(
297        io,
298        "{}",
299        colors::yellow_bold(format_args!("warning[{}]", diagnostic.code()))
300      )?;
301    }
302  }
303
304  writeln!(io, ": {}", colors::bold(diagnostic.message()))?;
305
306  let mut max_line_number_digits = 1;
307  if let Some(snippet) = diagnostic.snippet() {
308    for highlight in snippet.highlights.iter() {
309      let last_line = line_number(&snippet.source, highlight.range.end);
310      max_line_number_digits =
311        max_line_number_digits.max(last_line.ilog10() + 1);
312    }
313  }
314
315  if let Some(snippet) = diagnostic.snippet_fixed() {
316    for highlight in snippet.highlights.iter() {
317      let last_line = line_number(&snippet.source, highlight.range.end);
318      max_line_number_digits =
319        max_line_number_digits.max(last_line.ilog10() + 1);
320    }
321  }
322
323  let location = diagnostic.location();
324  write!(
325    io,
326    "{}{}",
327    RepeatingCharFmt(' ', max_line_number_digits as usize),
328    colors::intense_blue("-->"),
329  )?;
330  match &location {
331    DiagnosticLocation::Path { path } => {
332      write!(io, " {}", colors::cyan(path.display()))?;
333    }
334    DiagnosticLocation::Module { specifier }
335    | DiagnosticLocation::ModulePosition { specifier, .. } => {
336      if let Some(path) = specifier_to_file_path(specifier) {
337        write!(io, " {}", colors::cyan(path.display()))?;
338      } else {
339        write!(io, " {}", colors::cyan(specifier.as_str()))?;
340      }
341    }
342  }
343  if let Some((line, column)) = location.position() {
344    write!(
345      io,
346      "{}",
347      colors::yellow(format_args!(":{}:{}", line, column))
348    )?;
349  }
350
351  if diagnostic.snippet().is_some()
352    || diagnostic.hint().is_some()
353    || diagnostic.snippet_fixed().is_some()
354    || !diagnostic.info().is_empty()
355    || diagnostic.docs_url().is_some()
356  {
357    writeln!(io)?;
358  }
359
360  if let Some(snippet) = diagnostic.snippet() {
361    print_snippet(io, &snippet, max_line_number_digits)?;
362  };
363
364  if let Some(hint) = diagnostic.hint() {
365    write!(
366      io,
367      "{} {} ",
368      RepeatingCharFmt(' ', max_line_number_digits as usize),
369      colors::intense_blue("=")
370    )?;
371    writeln!(io, "{}: {}", colors::bold("hint"), hint)?;
372  }
373
374  if let Some(snippet) = diagnostic.snippet_fixed() {
375    print_snippet(io, &snippet, max_line_number_digits)?;
376  }
377
378  if !diagnostic.info().is_empty() || diagnostic.docs_url().is_some() {
379    writeln!(io)?;
380  }
381
382  for info in diagnostic.info().iter() {
383    writeln!(io, "  {}: {}", colors::intense_blue("info"), info)?;
384  }
385  if let Some(docs_url) = diagnostic.docs_url() {
386    writeln!(io, "  {}: {}", colors::intense_blue("docs"), docs_url)?;
387  }
388
389  Ok(())
390}
391
392/// Prints a snippet to the given writer and returns the line number indent.
393fn print_snippet(
394  io: &mut dyn std::fmt::Write,
395  snippet: &DiagnosticSnippet<'_>,
396  max_line_number_digits: u32,
397) -> Result<(), std::fmt::Error> {
398  let DiagnosticSnippet { source, highlights } = snippet;
399
400  fn print_padded(
401    io: &mut dyn std::fmt::Write,
402    text: impl std::fmt::Display,
403    padding: u32,
404  ) -> Result<(), std::fmt::Error> {
405    for _ in 0..padding {
406      write!(io, " ")?;
407    }
408    write!(io, "{}", text)?;
409    Ok(())
410  }
411
412  let mut lines_to_show = HashMap::<usize, Vec<usize>>::new();
413  let mut highlights_info = Vec::new();
414  for (i, highlight) in highlights.iter().enumerate() {
415    let start_line_number = line_number(source, highlight.range.start);
416    let end_line_number = line_number(source, highlight.range.end);
417    highlights_info.push((start_line_number, end_line_number));
418    for line_number in start_line_number..=end_line_number {
419      lines_to_show.entry(line_number).or_default().push(i);
420    }
421  }
422
423  let mut lines_to_show = lines_to_show.into_iter().collect::<Vec<_>>();
424  lines_to_show.sort();
425
426  print_padded(io, colors::intense_blue(" | "), max_line_number_digits)?;
427  writeln!(io)?;
428  let mut previous_line_number = None;
429  let mut previous_line_empty = false;
430  for (line_number, highlight_indexes) in lines_to_show {
431    if previous_line_number.is_some()
432      && previous_line_number == Some(line_number - 1)
433      && !previous_line_empty
434    {
435      print_padded(io, colors::intense_blue(" | "), max_line_number_digits)?;
436      writeln!(io)?;
437    }
438
439    print_padded(
440      io,
441      colors::intense_blue(format_args!("{} | ", line_number)),
442      max_line_number_digits - line_number.ilog10() - 1,
443    )?;
444
445    let line_start_pos = source.line_start(line_number - 1);
446    let line_end_pos = source.line_end(line_number - 1);
447    let line_text = line_text(source, line_number);
448    writeln!(io, "{}", ReplaceTab(line_text))?;
449    previous_line_empty = false;
450
451    let mut wrote_description = false;
452    for highlight_index in highlight_indexes {
453      let highlight = &highlights[highlight_index];
454      let (start_line_number, end_line_number) =
455        highlights_info[highlight_index];
456
457      let padding_width;
458      let highlight_width;
459      if start_line_number == end_line_number {
460        padding_width = display_width(source.range_text(&SourceRange::new(
461          line_start_pos,
462          highlight.range.start.pos(source),
463        )));
464        highlight_width = display_width(source.range_text(&SourceRange::new(
465          highlight.range.start.pos(source),
466          highlight.range.end.pos(source),
467        )));
468      } else if start_line_number == line_number {
469        padding_width = display_width(source.range_text(&SourceRange::new(
470          line_start_pos,
471          highlight.range.start.pos(source),
472        )));
473        highlight_width = display_width(source.range_text(&SourceRange::new(
474          highlight.range.start.pos(source),
475          line_end_pos,
476        )));
477      } else if end_line_number == line_number {
478        padding_width = 0;
479        highlight_width = display_width(source.range_text(&SourceRange::new(
480          line_start_pos,
481          highlight.range.end.pos(source),
482        )));
483      } else {
484        padding_width = 0;
485        highlight_width = display_width(line_text);
486      }
487
488      let underline =
489        RepeatingCharFmt(highlight.style.underline_char(), highlight_width);
490      print_padded(io, colors::intense_blue(" | "), max_line_number_digits)?;
491      write!(io, "{}", RepeatingCharFmt(' ', padding_width))?;
492      write!(io, "{}", highlight.style.style_underline(underline))?;
493
494      if line_number == end_line_number
495        && let Some(description) = &highlight.description
496      {
497        write!(io, " {}", highlight.style.style_underline(description))?;
498        wrote_description = true;
499      }
500
501      writeln!(io)?;
502    }
503
504    if wrote_description {
505      print_padded(io, colors::intense_blue(" | "), max_line_number_digits)?;
506      writeln!(io)?;
507      previous_line_empty = true;
508    }
509
510    previous_line_number = Some(line_number);
511  }
512
513  Ok(())
514}
515
516/// Attempts to convert a specifier to a file path. By default, uses the Url
517/// crate's `to_file_path()` method, but falls back to try and resolve unix-style
518/// paths on Windows.
519fn specifier_to_file_path(specifier: &ModuleSpecifier) -> Option<PathBuf> {
520  fn to_file_path_if_not_wasm(_specifier: &ModuleSpecifier) -> Option<PathBuf> {
521    #[cfg(target_arch = "wasm32")]
522    {
523      None
524    }
525    #[cfg(not(target_arch = "wasm32"))]
526    {
527      // not available in Wasm
528      _specifier.to_file_path().ok()
529    }
530  }
531
532  if specifier.scheme() != "file" {
533    None
534  } else if cfg!(windows) {
535    match to_file_path_if_not_wasm(specifier) {
536      Some(path) => Some(path),
537      None => {
538        // This might be a unix-style path which is used in the tests even on Windows.
539        // Attempt to see if we can convert it to a `PathBuf`. This code should be removed
540        // once/if https://github.com/servo/rust-url/issues/730 is implemented.
541        if specifier.scheme() == "file"
542          && specifier.host().is_none()
543          && specifier.port().is_none()
544          && specifier.path_segments().is_some()
545        {
546          let path_str = specifier.path();
547          match String::from_utf8(
548            percent_encoding::percent_decode(path_str.as_bytes()).collect(),
549          ) {
550            Ok(path_str) => Some(PathBuf::from(path_str)),
551            Err(_) => None,
552          }
553        } else {
554          None
555        }
556      }
557    }
558  } else {
559    to_file_path_if_not_wasm(specifier)
560  }
561}
562
563#[cfg(test)]
564mod tests {
565  use std::borrow::Cow;
566
567  use super::*;
568  use crate::ModuleSpecifier;
569  use crate::SourceTextInfo;
570
571  #[test]
572  fn test_display_width() {
573    assert_eq!(display_width("abc"), 3);
574    assert_eq!(display_width("\t"), 2);
575    assert_eq!(display_width("\t\t123"), 7);
576    assert_eq!(display_width("πŸŽ„"), 2);
577    assert_eq!(display_width("πŸŽ„πŸŽ„"), 4);
578    assert_eq!(display_width("πŸ§‘β€πŸ¦°"), 2);
579  }
580
581  #[test]
582  fn test_position_in_file_from_text_info_simple() {
583    let specifier: ModuleSpecifier = "file:///dev/test.ts".parse().unwrap();
584    let text_info = SourceTextInfo::new("foo\nbar\nbaz".into());
585    let pos = text_info.line_start(1);
586    let location = DiagnosticLocation::ModulePosition {
587      specifier: Cow::Borrowed(&specifier),
588      source_pos: DiagnosticSourcePos::SourcePos(pos),
589      text_info: Cow::Owned(text_info),
590    };
591    let position = location.position().unwrap();
592    assert_eq!(position, (2, 1))
593  }
594
595  #[test]
596  fn test_position_in_file_from_text_info_emoji() {
597    let specifier: ModuleSpecifier = "file:///dev/test.ts".parse().unwrap();
598    let text_info = SourceTextInfo::new("πŸ§‘β€πŸ¦°text".into());
599    let pos = text_info.line_start(0) + 11; // the end of the emoji
600    let location = DiagnosticLocation::ModulePosition {
601      specifier: Cow::Borrowed(&specifier),
602      source_pos: DiagnosticSourcePos::SourcePos(pos),
603      text_info: Cow::Owned(text_info),
604    };
605    let position = location.position().unwrap();
606    assert_eq!(position, (1, 6))
607  }
608
609  #[test]
610  fn test_specifier_to_file_path() {
611    run_success_test("file:///", "/");
612    run_success_test("file:///test", "/test");
613    run_success_test("file:///dir/test/test.txt", "/dir/test/test.txt");
614    run_success_test(
615      "file:///dir/test%20test/test.txt",
616      "/dir/test test/test.txt",
617    );
618
619    fn run_success_test(specifier: &str, expected_path: &str) {
620      let result =
621        specifier_to_file_path(&ModuleSpecifier::parse(specifier).unwrap())
622          .unwrap();
623      assert_eq!(result, PathBuf::from(expected_path));
624    }
625  }
626}