LCOV - code coverage report
Current view: top level - dashql-cli/src - report.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 91.3 % 127 116
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! The text output of the CLI
       2              : 
       3              : use crate::lint::{line_column, Diagnostic, Severity};
       4              : use std::time::Duration;
       5              : 
       6              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
       7              : pub enum Color {
       8              :     Bold,
       9              :     Dim,
      10              :     Red,
      11              :     Yellow,
      12              :     Green,
      13              :     Cyan,
      14              : }
      15              : 
      16              : /// Applies text styles with ANSI escapes, they do not change the visible width
      17              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      18              : pub struct Style {
      19              :     /// Whether to emit ANSI escapes at all
      20              :     pub colors: bool,
      21              : }
      22              : 
      23              : impl Style {
      24              :     /// Colors when stdout is a terminal. `FORCE_COLOR` (other than `0`) forces them, `NO_COLOR` turns them off.
      25            0 :     pub fn for_stdout() -> Self {
      26              :         use std::io::IsTerminal;
      27            0 :         let set = |name| std::env::var_os(name).is_some_and(|v| !v.is_empty());
      28            0 :         let colors = match std::env::var("FORCE_COLOR") {
      29            0 :             Ok(v) if !v.is_empty() => v != "0",
      30            0 :             _ => !set("NO_COLOR") && std::io::stdout().is_terminal(),
      31              :         };
      32            0 :         Style { colors }
      33            0 :     }
      34              : 
      35           98 :     pub fn apply(self, color: Color, text: &str) -> String {
      36           98 :         if !self.colors {
      37           96 :             return text.to_string();
      38            2 :         }
      39            2 :         let (on, off) = match color {
      40            1 :             Color::Bold => (1, 22),
      41            0 :             Color::Dim => (2, 22),
      42            1 :             Color::Red => (31, 39),
      43            0 :             Color::Green => (32, 39),
      44            0 :             Color::Yellow => (33, 39),
      45            0 :             Color::Cyan => (36, 39),
      46              :         };
      47            2 :         format!("\x1b[{on}m{text}\x1b[{off}m")
      48           98 :     }
      49              : }
      50              : 
      51              : /// "warning"
      52              : const LABEL_WIDTH: usize = 7;
      53              : /// "analyze", "failure"
      54              : const STAGE_WIDTH: usize = 7;
      55              : 
      56           45 : fn plural(n: usize, word: &str) -> String {
      57           45 :     format!("{n} {word}{}", if n == 1 { "" } else { "s" })
      58           45 : }
      59              : 
      60              : /// A duration in milliseconds, in seconds from one second on
      61            5 : pub fn duration(d: Duration) -> String {
      62            5 :     let ms = d.as_secs_f64() * 1000.0;
      63            5 :     if ms < 1000.0 {
      64            4 :         format!("{} ms", ms.round())
      65              :     } else {
      66            1 :         format!("{:.1} s", ms / 1000.0)
      67              :     }
      68            5 : }
      69              : 
      70           11 : fn severity_color(severity: Severity) -> Color {
      71           11 :     match severity {
      72            9 :         Severity::Error => Color::Red,
      73            2 :         Severity::Warning => Color::Yellow,
      74              :     }
      75           11 : }
      76              : 
      77              : /// The source line of a diagnostic and the underline of its span
      78           11 : fn code_frame(text: &str, d: &Diagnostic) -> Option<(String, String)> {
      79           11 :     let location = d.location?;
      80              :     // Columns count code points, tabs are shown as a single space to keep the caret aligned
      81           10 :     let line = text.split('\n').nth(location.line - 1).unwrap_or("");
      82           10 :     let line = line.strip_suffix('\r').unwrap_or(line).replace('\t', " ");
      83           10 :     let (end_line, end_column) = line_column(text, location.offset + location.length);
      84           10 :     let end_column = if end_line == location.line { end_column } else { line.chars().count() + 1 };
      85           10 :     let width = end_column.saturating_sub(location.column).max(1);
      86           10 :     Some((line, format!("{}{}", " ".repeat(location.column - 1), "^".repeat(width))))
      87           11 : }
      88              : 
      89              : /// Format the diagnostics of one script, the file name followed by one indented line per finding
      90           11 : pub fn format_file(file: &str, text: &str, diagnostics: &[Diagnostic], style: Style) -> String {
      91           11 :     let locations: Vec<String> = diagnostics
      92           11 :         .iter()
      93           11 :         .map(|d| d.location.map(|l| format!("{}:{}", l.line, l.column)).unwrap_or_default())
      94           11 :         .collect();
      95           11 :     let location_width = locations.iter().map(String::len).max().unwrap_or(0);
      96           11 :     let indent = " ".repeat(2 + location_width + 2);
      97           11 :     let continuation = " ".repeat(indent.len() + LABEL_WIDTH + 2 + STAGE_WIDTH + 2);
      98              : 
      99           11 :     let mut out = vec![style.apply(Color::Bold, file)];
     100           11 :     for (d, location) in diagnostics.iter().zip(&locations) {
     101           11 :         let color = severity_color(d.severity);
     102           11 :         let mut lines = d.message.split('\n');
     103           11 :         out.push(format!(
     104              :             "  {}  {}  {}  {}",
     105           11 :             style.apply(Color::Cyan, &format!("{location:location_width$}")),
     106           11 :             style.apply(color, &format!("{:LABEL_WIDTH$}", d.severity.name())),
     107           11 :             style.apply(Color::Dim, &format!("{:STAGE_WIDTH$}", d.stage.name())),
     108           11 :             lines.next().unwrap_or(""),
     109              :         ));
     110           11 :         out.extend(lines.map(|line| format!("{continuation}{line}")));
     111           11 :         if let Some((line, underline)) = code_frame(text, d) {
     112           10 :             let bar = style.apply(Color::Dim, "│");
     113           10 :             out.push(format!("{indent}{bar} {line}"));
     114           10 :             out.push(format!("{indent}{bar} {}", style.apply(color, &underline)));
     115           10 :         }
     116              :     }
     117           11 :     out.join("\n")
     118           11 : }
     119              : 
     120           15 : pub fn format_summary(files: usize, errors: usize, warnings: usize, style: Style) -> String {
     121           30 :     let count = |n, word, color| style.apply(if n > 0 { color } else { Color::Green }, &plural(n, word));
     122           15 :     format!("{}, {}, {}", plural(files, "file"), count(errors, "error", Color::Red), count(warnings, "warning", Color::Yellow))
     123           15 : }
     124              : 
     125              : /// Where the time of a run went
     126            2 : pub fn format_timings(lint: Duration, style: Style) -> String {
     127            2 :     format!("{}  scan, parse and analyze {}", style.apply(Color::Dim, "dashql-core"), duration(lint))
     128            2 : }
     129              : 
     130              : #[cfg(test)]
     131              : mod tests {
     132              :     use super::*;
     133              :     use crate::lint::{Location, Stage};
     134              : 
     135              :     const PLAIN: Style = Style { colors: false };
     136              : 
     137            1 :     fn parse_error() -> Diagnostic {
     138            1 :         Diagnostic {
     139            1 :             stage: Stage::Parse,
     140            1 :             severity: Severity::Error,
     141            1 :             message: "syntax error, unexpected SEMICOLON".into(),
     142            1 :             location: Some(Location { line: 2, column: 11, offset: 20, length: 1 }),
     143            1 :         }
     144            1 :     }
     145              : 
     146              :     #[test]
     147            1 :     fn shows_diagnostics_with_the_underlined_source_line() {
     148            1 :         let text = "select 1;\nselect 1 +;\n";
     149            1 :         assert_eq!(
     150            1 :             format_file("q.sql", text, &[parse_error()], PLAIN),
     151            1 :             [
     152            1 :                 "q.sql",
     153            1 :                 "  2:11  error    parse    syntax error, unexpected SEMICOLON",
     154            1 :                 "        │ select 1 +;",
     155            1 :                 "        │           ^",
     156            1 :             ]
     157            1 :             .join("\n"),
     158              :         );
     159            1 :     }
     160              : 
     161              :     #[test]
     162            1 :     fn underlines_the_whole_span_in_code_points() {
     163            1 :         let text = "select 'ä' + bogus;";
     164            1 :         let d = Diagnostic {
     165            1 :             stage: Stage::Analyze,
     166            1 :             severity: Severity::Warning,
     167            1 :             message: "hm".into(),
     168            1 :             location: Some(Location { line: 1, column: 14, offset: 14, length: 5 }),
     169            1 :         };
     170            1 :         let out = format_file("q.sql", text, &[d], PLAIN);
     171            1 :         assert_eq!(out.split('\n').skip(2).collect::<Vec<_>>(), ["        │ select 'ä' + bogus;", "        │              ^^^^^"]);
     172            1 :     }
     173              : 
     174              :     #[test]
     175            1 :     fn indents_multi_line_messages_and_failures() {
     176            1 :         let failure = Diagnostic { stage: Stage::Failure, severity: Severity::Error, message: "boom\nmore".into(), location: None };
     177            1 :         assert_eq!(
     178            1 :             format_file("q.sql", "", &[failure], PLAIN),
     179            1 :             ["q.sql", "    error    failure  boom", "                      more"].join("\n"),
     180              :         );
     181            1 :     }
     182              : 
     183              :     #[test]
     184            1 :     fn styles_without_changing_the_text() {
     185            1 :         let style = Style { colors: true };
     186            1 :         assert_eq!(style.apply(Color::Red, "x"), "\x1b[31mx\x1b[39m");
     187            1 :         assert_eq!(style.apply(Color::Bold, "x"), "\x1b[1mx\x1b[22m");
     188            1 :     }
     189              : 
     190              :     #[test]
     191            1 :     fn counts_files_errors_and_warnings() {
     192            1 :         assert_eq!(format_summary(1, 0, 2, PLAIN), "1 file, 0 errors, 2 warnings");
     193            1 :         assert_eq!(format_summary(3, 1, 0, PLAIN), "3 files, 1 error, 0 warnings");
     194            1 :     }
     195              : 
     196              :     #[test]
     197            1 :     fn formats_durations() {
     198            1 :         assert_eq!(duration(Duration::from_micros(400)), "0 ms");
     199            1 :         assert_eq!(duration(Duration::from_millis(999)), "999 ms");
     200            1 :         assert_eq!(duration(Duration::from_millis(1260)), "1.3 s");
     201            1 :         assert_eq!(format_timings(Duration::from_millis(42), PLAIN), "dashql-core  scan, parse and analyze 42 ms");
     202            1 :     }
     203              : }
        

Generated by: LCOV version 2.0-1