ts_error/diagnostic/
mod.rs

1//! # Diagnostic
2//!
3//! A diagnostic over some source file.
4
5mod context;
6mod span;
7
8use alloc::{
9    string::{String, ToString},
10    vec,
11    vec::Vec,
12};
13use core::fmt::Write;
14
15use ts_ansi::{
16    format_error, format_warning,
17    style::{BOLD, CYAN, DEFAULT, RED, RESET, YELLOW},
18};
19
20pub use context::Context;
21pub use span::Span;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25/// A diagnostic severity.
26pub enum Severity {
27    /// An error.
28    Error,
29    /// A warning.
30    Warning,
31}
32impl Severity {
33    pub(crate) fn colour(self) -> &'static str {
34        match &self {
35            Self::Error => RED,
36            Self::Warning => YELLOW,
37        }
38    }
39
40    pub(crate) fn word(self) -> &'static str {
41        match &self {
42            Self::Error => "error",
43            Self::Warning => "warning",
44        }
45    }
46}
47
48#[derive(Debug)]
49/// A collection of diagnostics
50pub struct Diagnostics {
51    /// The problems.
52    pub problems: Vec<Diagnostic>,
53    /// The context.
54    pub context: String,
55}
56impl Diagnostics {
57    /// Create a new collection of diagnostics.
58    pub fn new<S: ToString>(context: S) -> Self {
59        Self {
60            problems: vec![],
61            context: context.to_string(),
62        }
63    }
64
65    /// Returns if there are no diagnostics.
66    pub fn is_empty(&self) -> bool {
67        self.problems.is_empty()
68    }
69
70    /// Push a diagnostic into this collection.
71    pub fn push(&mut self, diagnostic: Diagnostic) {
72        self.problems.push(diagnostic);
73    }
74
75    /// Returns an iterator over the error diagnostics.
76    pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> {
77        self.problems
78            .iter()
79            .filter(|problem| problem.severity == Severity::Error)
80    }
81
82    /// Returns an iterator over the warning diagnostics.
83    pub fn warnings(&self) -> impl Iterator<Item = &Diagnostic> {
84        self.problems
85            .iter()
86            .filter(|problem| problem.severity == Severity::Warning)
87    }
88}
89impl core::fmt::Display for Diagnostics {
90    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        let warnings: Vec<_> = self.warnings().collect();
92        let errors: Vec<_> = self.errors().collect();
93
94        for error in &errors {
95            writeln!(f, "{error}")?;
96        }
97        for warning in &warnings {
98            writeln!(f, "{warning}")?;
99        }
100
101        if !errors.is_empty() {
102            writeln!(
103                f,
104                "{}",
105                format_error!("{} generated {} errors", self.context, errors.len())
106            )?;
107        }
108        if !warnings.is_empty() {
109            writeln!(
110                f,
111                "{}",
112                format_warning!("{} generated {} warnings", self.context, warnings.len())
113            )?;
114        }
115
116        Ok(())
117    }
118}
119impl core::error::Error for Diagnostics {}
120
121#[derive(Debug)]
122/// A diagnostic over some source file.
123pub struct Diagnostic {
124    /// The diagnostic severity.
125    pub severity: Severity,
126    /// The diagnostic headline.
127    pub headline: String,
128    /// The diagnostic filepath.
129    pub file_path: Option<String>,
130    /// The diagnostic context.
131    pub context: Option<Context>,
132    /// The nodes.
133    pub notes: Vec<String>,
134}
135
136impl Diagnostic {
137    /// Create a new diagnostic.
138    pub fn new<S: ToString>(severity: Severity, headling: S) -> Self {
139        Self {
140            severity,
141            headline: headling.to_string(),
142            file_path: None,
143            context: None,
144            notes: Vec::new(),
145        }
146    }
147
148    /// Create an error diagnostic.
149    pub fn error<S: ToString>(headling: S) -> Self {
150        Self {
151            severity: Severity::Error,
152            headline: headling.to_string(),
153            file_path: None,
154            context: None,
155            notes: Vec::new(),
156        }
157    }
158
159    /// Create a warning diagnostic.
160    pub fn warning<S: ToString>(headling: S) -> Self {
161        Self {
162            severity: Severity::Warning,
163            headline: headling.to_string(),
164            file_path: None,
165            context: None,
166            notes: Vec::new(),
167        }
168    }
169
170    /// Set the file path of the diagnostic.
171    pub fn file_path<S: ToString>(mut self, path: S) -> Self {
172        self.file_path = Some(path.to_string());
173        self
174    }
175
176    /// Add a note to the diagnostic.
177    pub fn add_note<S: ToString>(mut self, note: S) -> Self {
178        self.notes.push(note.to_string());
179        self
180    }
181
182    /// Set the context of the diagnostic.
183    pub fn context(mut self, context: Context) -> Self {
184        self.context = Some(context);
185        self
186    }
187}
188
189impl core::fmt::Display for Diagnostic {
190    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
191        let colour = self.severity.colour();
192        let severity = self.severity.word();
193
194        // Write headling:
195        // error: some headline here
196        writeln!(
197            f,
198            "{BOLD}{colour}{severity}{DEFAULT}: {}{RESET}",
199            self.headline
200        )?;
201
202        let line_number_size = self
203            .context
204            .as_ref()
205            .map_or(1, |context| context.span.line.to_string().len());
206        let indent = " ".repeat(line_number_size);
207
208        // Write file path:
209        // ` --> some/path/to/a.file:12:2`
210        if let Some(file_path) = &self.file_path {
211            write!(f, "{indent}{CYAN}{BOLD}-->{RESET} {file_path}",)?;
212
213            // Write file location
214            if let Some(context) = &self.context {
215                write!(f, ":{}:{}", context.span.line, context.span.column)?;
216            }
217            f.write_char('\n')?;
218        }
219        // Otherwide write line and column:
220        // `  | line 12, column 2`
221        else if let Some(context) = &self.context {
222            writeln!(
223                f,
224                "{indent}{CYAN}{BOLD}-->{RESET} line {}, column {}",
225                context.span.line, context.span.column
226            )?;
227        }
228        // Write spacer
229        writeln!(f, "{indent}{CYAN}{BOLD} | {RESET}")?;
230
231        // Write context
232        if let Some(context) = &self.context {
233            // Write source lines:
234            // `98  | some source code here`
235            // `99  | some source code here`
236            // `100 | some source code here`
237            for (index, line) in context.context.iter().enumerate() {
238                let line_number = (context.span.line.saturating_sub(
239                    context
240                        .context
241                        .len()
242                        .saturating_sub(index)
243                        .saturating_sub(1),
244                ))
245                .to_string();
246                let padding = " ".repeat(line_number_size - line_number.len());
247                writeln!(f, "{CYAN}{BOLD}{line_number}{padding} | {RESET}{line}",)?;
248            }
249
250            // Write span highlighter:
251            // `    |      ^^^^^^`
252            write!(
253                f,
254                "{indent}{CYAN}{BOLD} | {RESET}{}{colour}{BOLD}{}",
255                " ".repeat(context.span_indent),
256                "^".repeat(context.span.length)
257            )?;
258            // Write label
259            if let Some(label) = &context.label {
260                f.write_char(' ')?;
261                f.write_str(label)?;
262            }
263            writeln!(f, "{RESET}")?;
264        }
265
266        // Write notes
267        if !self.notes.is_empty() {
268            writeln!(f, "{indent}{CYAN}{BOLD} | {RESET}")?;
269            for note in &self.notes {
270                writeln!(f, "{indent}{CYAN}{BOLD} = {DEFAULT}note{RESET}: {note}")?;
271            }
272        }
273
274        Ok(())
275    }
276}
277
278impl core::error::Error for Diagnostic {}
279
280#[cfg(test)]
281mod test {
282    extern crate std;
283
284    use std::io::{Write, stderr, stdout};
285
286    use alloc::string::ToString;
287
288    use crate::diagnostic::{Context, Diagnostic, Diagnostics, Span};
289
290    const SOURCE: &str = r#"use alloc::boxed::Box;
291use core::{error::Error, fmt};
292
293use ts_ansi::style::{BOLD, DEFAULT, RED, RESET};
294
295/// An error report, displays the error stack of some error.
296pub struct Report<'e> {
297    /// The error for this report.
298    pub source: Box<dyn Error + 'e>,
299}
300impl<'e> Report<'e> {
301    /// Create a new error report.
302    pub fn new<E: Error + 'e>(source: E) -> Self {
303        Self {
304            source: Box::new(source),
305        }
306    }
307}
308impl Error for Report<'static> {
309    fn source(&self) -> Option<&(dyn Error + 'static)> {
310        Some(self.source.as_ref())
311    }
312}
313impl fmt::Debug for Report<'_> {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "{self}")
316    }
317}
318impl fmt::Display for Report<'_> {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        let mut current_error = Some(self.source.as_ref());
321        let mut count = 1;
322
323        while let Some(error) = current_error {
324            writeln!(f, " {BOLD}{RED}{count}{DEFAULT}.{RESET} {error}")?;
325
326            count += 1;
327            current_error = error.source();
328        }
329
330        Ok(())
331    }
332}"#;
333
334    const MINIFIED_SOURCE: &str = r#"async function Ui(n){return location.href=n,await mu()}function mu(){let n=t=>{setTimeout(()=>n(t),400)};return new Promise(n)}var br=class{element;contents;action;constructor(t,e){this.element=ht(`${t}/error`,HTMLElement),this.contents=ht(`${t}/error/content`,HTMLElement),this.action=e}clearError(){this.element.classList.add("collapse"),this.element.ariaHidden="true",this.contents.textContent=""}addError(t){if(this.contents.textContent===""){this.element.classList.remove("collapse"),this.element.ariaHidden="false",this.contents.textContent=`Could not ${this.action}: ${t}`;return}this.contents.textContent+=`, ${t}`}setSomethingWentWrong(){this.element.classList.remove("collapse"),this.element.ariaHidden="false",this.contents.textContent=`Something went wrong while trying to ${this.action}. Try again later.`}},Nr=class{input;error;constructor(t,e){this.input=ht(`${t}${e}/input`,HTMLInputElement),this.error=ht(`${t}${e}/error`,HTMLElement),this.input.addEventListener("input",()=>{this.input.setCustomValidity("")})}getValue(){return this.input.type==="checkbox"?this.input.checked?"checked":"unchecked":this.input.value}setLock(t){this.input.disabled=t}clearError(){this.input.setCustomValidity(""),this.error.classList.add("hidden"),this.error.ariaHidden="true",this.error.textContent="!"}addError(t){if(this.error.textContent==="!"){this.input.setCustomValidity(t),this.error.classList.remove("hidden"),this.error.ariaHidden="false",this.error.textContent=`Invalid value: ${t}`;return}this.error.textContent+=`, ${t}`,this.input.setCustomValidity(this.error.textContent??"Invalid value")}},ge=class{form;formError;submitButton;inputs;constructor(t,e,r){this.form=ht(t,HTMLFormElement),this.formError=new br(t,r),this.submitButton=ht(`${t}/submit`,HTMLButtonElement);let o=new Map;for(let i of e)o.set(i,new Nr(t,i));this.inputs=o}clearErrors(){this.formError.clearError();for(let t of this.inputs.values())t.clearError()}setLock(t){this.submitButton.disabled=t;for(let e of this.inputs.values())e.setLock(t)}setInputErrors(t){if(!t||t.length===0){this.formError.addError("an unknown field is invalid");return}for(let e of t){let r=this.inputs.get(e.pointer)??null;r?r.addError(e.detail):this.formError.addError(`field ${e.pointer} ${e.detail}`)}}getValues(){let t=new Map;for(let[e,r]of this.inputs)t.set(e,r.getValue());return t}};"#;
335
336    #[test]
337    fn show_output() {
338        let _stdout = stdout().lock();
339        let mut stderr = stderr().lock();
340
341        let warning = Diagnostic::warning("struct `Report` is never used")
342            .file_path("crates/ts-error/src/report.rs")
343            .context(Context::new(
344                SOURCE,
345                Span::default().line(7).column(12).length(6),
346            ))
347            .add_note("`#[warn(dead_code)]` on by default");
348
349        let error = Diagnostic::error("struct `Report` is never used")
350            .context(
351                Context::new(SOURCE, Span::default().line(7).column(12).length(6))
352                    .label("this is unused"),
353            )
354            .add_note("`#[warn(dead_code)]` on by default");
355
356        let minified_error = Diagnostic::error("some headline here")
357            .context(
358                Context::new(
359                    MINIFIED_SOURCE,
360                    Span::default().line(1).column(200).length(50),
361                )
362                .label("some label here"),
363            )
364            .add_note("some note here")
365            .add_note("this code is trimmed");
366
367        stderr
368            .write_all(error.to_string().as_bytes())
369            .expect("writing to stderr should not fail");
370        stderr
371            .write_all(b"\n")
372            .expect("writing to stderr should not fail");
373        stderr
374            .write_all(minified_error.to_string().as_bytes())
375            .expect("writing to stderr should not fail");
376        stderr
377            .write_all(b"\n")
378            .expect("writing to stderr should not fail");
379        stderr
380            .write_all(warning.to_string().as_bytes())
381            .expect("writing to stderr should not fail");
382
383        stderr
384            .write_all(b"\n-----\n")
385            .expect("writing to stderr should not fail");
386
387        let mut diagnostics = Diagnostics::new("test");
388        diagnostics.push(warning);
389        diagnostics.push(minified_error);
390        diagnostics.push(error);
391        stderr
392            .write_all(diagnostics.to_string().as_bytes())
393            .expect("writing to stderr should not fail");
394
395        stderr.flush().expect("flusing stderr should not fail");
396    }
397}