Skip to main content

wdl_diagnostics/
lib.rs

1//! Utilities for reporting diagnostics to the terminal.
2
3use std::sync::LazyLock;
4
5use anyhow::Context as _;
6use anyhow::anyhow;
7use clap::ValueEnum;
8use codespan_reporting::files::SimpleFiles;
9use codespan_reporting::term::Config as TermConfig;
10use codespan_reporting::term::DisplayStyle;
11use codespan_reporting::term::emit_to_write_style;
12use codespan_reporting::term::termcolor::ColorChoice;
13use codespan_reporting::term::termcolor::StandardStream;
14use serde::Deserialize;
15use serde::Serialize;
16use wdl_ast::Diagnostic;
17
18/// Configuration for full display style.
19static FULL_CONFIG: LazyLock<TermConfig> = LazyLock::new(|| TermConfig {
20    display_style: DisplayStyle::Rich,
21    ..Default::default()
22});
23
24/// Configuration for one-line display style.
25static ONE_LINE_CONFIG: LazyLock<TermConfig> = LazyLock::new(|| TermConfig {
26    display_style: DisplayStyle::Short,
27    ..Default::default()
28});
29
30/// A counter tracking the types of diagnostics emitted during analysis.
31#[derive(Default, Debug)]
32pub struct DiagnosticCounts {
33    /// The number of errors encountered.
34    pub errors: usize,
35    /// The number of warnings encountered.
36    pub warnings: usize,
37    /// The number of notes encountered.
38    pub notes: usize,
39}
40
41impl DiagnosticCounts {
42    /// Returns an error if the `errors` count is 1 or more
43    pub fn verify_no_errors(&self) -> Option<anyhow::Error> {
44        if self.errors == 0 {
45            return None;
46        }
47
48        Some(anyhow!(
49            "failing due to {errors} error{s}",
50            errors = self.errors,
51            s = if self.errors == 1 { "" } else { "s" }
52        ))
53    }
54
55    /// Returns an error if the `warnings` count is 1 or more
56    pub fn verify_no_warnings(&self, user_requested: bool) -> Option<anyhow::Error> {
57        if self.warnings == 0 {
58            return None;
59        }
60
61        Some(anyhow!(
62            "failing due to {warnings} warning{s}{cli_note}",
63            warnings = self.warnings,
64            s = if self.warnings == 1 { "" } else { "s" },
65            cli_note = if user_requested {
66                " (`--deny-warnings` was specified)"
67            } else {
68                ""
69            },
70        ))
71    }
72
73    /// Returns an error if the `notes` count is 1 or more
74    pub fn verify_no_notes(&self, user_requested: bool) -> Option<anyhow::Error> {
75        if self.notes == 0 {
76            return None;
77        }
78
79        Some(anyhow!(
80            "failing due to {notes} note{s}{cli_note}",
81            notes = self.notes,
82            s = if self.notes == 1 { "" } else { "s" },
83            cli_note = if user_requested {
84                " (`--deny-notes` was specified)"
85            } else {
86                ""
87            },
88        ))
89    }
90}
91
92/// The diagnostic mode to use for reporting diagnostics.
93#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq, Deserialize, Serialize)]
94#[serde(rename_all = "kebab-case")]
95pub enum Mode {
96    /// Prints diagnostics as multiple lines.
97    #[default]
98    Full,
99
100    /// Prints diagnostics as one line.
101    OneLine,
102}
103
104impl std::fmt::Display for Mode {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Mode::Full => write!(f, "full"),
108            Mode::OneLine => write!(f, "one-line"),
109        }
110    }
111}
112
113/// Gets the diagnostics display configuration based on the user's preferences.
114pub fn get_diagnostics_display_config(
115    report_mode: Mode,
116    colorize: bool,
117) -> (&'static TermConfig, StandardStream) {
118    let config = match report_mode {
119        Mode::Full => &FULL_CONFIG,
120        Mode::OneLine => &ONE_LINE_CONFIG,
121    };
122
123    let color_choice = if colorize {
124        ColorChoice::Always
125    } else {
126        ColorChoice::Never
127    };
128
129    let stream = StandardStream::stderr(color_choice);
130
131    (config, stream)
132}
133
134/// Emits the given diagnostics to the terminal.
135pub fn emit_diagnostics<'a>(
136    path: &str,
137    source: &str,
138    diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
139    report_mode: Mode,
140    colorize: bool,
141) -> anyhow::Result<()> {
142    use std::borrow::Cow;
143
144    let mut files = SimpleFiles::new();
145
146    let file_id = files.add(Cow::Borrowed(path), Cow::Borrowed(source));
147
148    let (config, mut stream) = get_diagnostics_display_config(report_mode, colorize);
149
150    for diagnostic in diagnostics {
151        let diagnostic = diagnostic.to_codespan(file_id);
152        emit_to_write_style(&mut stream, config, &files, &diagnostic)
153            .context("failed to emit diagnostic")?;
154    }
155
156    Ok(())
157}
158
159/// Emits the given diagnostics to the terminal with accompanying call-stack
160/// locations.
161#[cfg(feature = "backtrace")]
162pub fn emit_diagnostics_with_backtrace<'a>(
163    path: &str,
164    source: &str,
165    diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
166    backtrace: &[wdl_engine::CallLocation],
167    report_mode: Mode,
168    colorize: bool,
169) -> anyhow::Result<()> {
170    use std::borrow::Cow;
171    use std::io::Write;
172
173    use codespan_reporting::diagnostic::Label;
174    use codespan_reporting::diagnostic::LabelStyle;
175    use wdl_ast::AstNode as _;
176
177    /// The maximum number of call locations to print for evaluation errors.
178    const MAX_CALL_LOCATIONS: usize = 10;
179
180    let mut map = std::collections::HashMap::new();
181    let mut files = SimpleFiles::new();
182
183    let file_id = files.add(Cow::Borrowed(path), Cow::Borrowed(source));
184
185    let (config, mut stream) = get_diagnostics_display_config(report_mode, colorize);
186
187    for diagnostic in diagnostics {
188        let diagnostic = diagnostic.to_codespan(file_id).with_labels_iter(
189            backtrace.iter().take(MAX_CALL_LOCATIONS).map(|l| {
190                let id = l.document.id();
191                let file_id = *map.entry(id).or_insert_with(|| {
192                    files.add(
193                        l.document.path(),
194                        Cow::Owned(l.document.root().text().to_string()),
195                    )
196                });
197
198                Label {
199                    style: LabelStyle::Secondary,
200                    file_id,
201                    range: l.span.start()..l.span.end(),
202                    message: "called from this location".into(),
203                }
204            }),
205        );
206
207        emit_to_write_style(&mut stream, config, &files, &diagnostic)
208            .context("failed to emit diagnostic")?;
209
210        if backtrace.len() > MAX_CALL_LOCATIONS {
211            writeln!(
212                &mut stream,
213                "  and {count} more call{s}...",
214                count = backtrace.len() - MAX_CALL_LOCATIONS,
215                s = if backtrace.len() - MAX_CALL_LOCATIONS == 1 {
216                    ""
217                } else {
218                    "s"
219                }
220            )?;
221        }
222    }
223
224    Ok(())
225}