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