ts-error 0.4.0

Error models and reporting for my projects
Documentation
//! # Diagnostic
//!
//! A diagnostic over some source file.

mod context;
mod span;

use alloc::{
    string::{String, ToString},
    vec,
    vec::Vec,
};
use core::fmt::Write;

use ts_ansi::{
    ansi::{BOLD, CYAN, DEFAULT, RED, RESET, YELLOW},
    format_error, format_warning,
};

pub use context::Context;
pub use span::Span;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
/// A diagnostic severity.
pub enum Severity {
    /// An error.
    Error,
    /// A warning.
    Warning,
}
impl Severity {
    /// Return the colour for the severity.
    pub(crate) fn colour(self) -> &'static str {
        match &self {
            Self::Error => RED,
            Self::Warning => YELLOW,
        }
    }

    /// Return the word for the severity.
    pub(crate) fn word(self) -> &'static str {
        match &self {
            Self::Error => "error",
            Self::Warning => "warning",
        }
    }
}

#[derive(Debug)]
/// A collection of diagnostics
pub struct Diagnostics {
    /// The problems.
    pub problems: Vec<Diagnostic>,
    /// The context.
    pub context: String,
}
impl Diagnostics {
    /// Create a new collection of diagnostics.
    pub fn new<S: ToString>(context: S) -> Self {
        Self {
            problems: vec![],
            context: context.to_string(),
        }
    }

    /// Returns if there are no diagnostics.
    pub fn is_empty(&self) -> bool {
        self.problems.is_empty()
    }

    /// Push a diagnostic into this collection.
    pub fn push(&mut self, diagnostic: Diagnostic) {
        self.problems.push(diagnostic);
    }

    /// Returns an iterator over the error diagnostics.
    pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> {
        self.problems
            .iter()
            .filter(|problem| problem.severity == Severity::Error)
    }

    /// Returns an iterator over the warning diagnostics.
    pub fn warnings(&self) -> impl Iterator<Item = &Diagnostic> {
        self.problems
            .iter()
            .filter(|problem| problem.severity == Severity::Warning)
    }
}
impl core::fmt::Display for Diagnostics {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let warnings: Vec<_> = self.warnings().collect();
        let errors: Vec<_> = self.errors().collect();

        for error in &errors {
            writeln!(f, "{error}")?;
        }
        for warning in &warnings {
            writeln!(f, "{warning}")?;
        }

        if !errors.is_empty() {
            writeln!(
                f,
                "{}",
                format_error!("{} generated {} errors", self.context, errors.len())
            )?;
        }
        if !warnings.is_empty() {
            writeln!(
                f,
                "{}",
                format_warning!("{} generated {} warnings", self.context, warnings.len())
            )?;
        }

        Ok(())
    }
}
impl core::error::Error for Diagnostics {}

#[derive(Debug)]
/// A diagnostic over some source file.
pub struct Diagnostic {
    /// The diagnostic severity.
    pub severity: Severity,
    /// The diagnostic headline.
    pub headline: String,
    /// The diagnostic filepath.
    pub file_path: Option<String>,
    /// The diagnostic context.
    pub context: Option<Context>,
    /// The nodes.
    pub notes: Vec<String>,
}

impl Diagnostic {
    /// Create a new diagnostic.
    pub fn new<S: ToString>(severity: Severity, headling: S) -> Self {
        Self {
            severity,
            headline: headling.to_string(),
            file_path: None,
            context: None,
            notes: Vec::new(),
        }
    }

    /// Create an error diagnostic.
    pub fn error<S: ToString>(headling: S) -> Self {
        Self {
            severity: Severity::Error,
            headline: headling.to_string(),
            file_path: None,
            context: None,
            notes: Vec::new(),
        }
    }

    /// Create a warning diagnostic.
    pub fn warning<S: ToString>(headling: S) -> Self {
        Self {
            severity: Severity::Warning,
            headline: headling.to_string(),
            file_path: None,
            context: None,
            notes: Vec::new(),
        }
    }

    /// Set the filepath of the diagnostic.
    pub fn file_path<S: ToString>(mut self, path: S) -> Self {
        self.file_path = Some(path.to_string());
        self
    }

    /// Add a note to the diagnostic.
    pub fn add_note<S: ToString>(mut self, note: S) -> Self {
        self.notes.push(note.to_string());
        self
    }

    /// Set the context of the diagnostic.
    pub fn context(mut self, context: Context) -> Self {
        self.context = Some(context);
        self
    }
}

impl core::fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let colour = self.severity.colour();
        let severity = self.severity.word();

        // Write headling:
        // error: some headline here
        writeln!(
            f,
            "{BOLD}{colour}{severity}{DEFAULT}: {}{RESET}",
            self.headline
        )?;

        let line_number_size = self
            .context
            .as_ref()
            .map_or(1, |context| context.span.line.to_string().len());
        let indent = " ".repeat(line_number_size);

        // Write file path:
        // ` --> some/path/to/a.file:12:2`
        if let Some(file_path) = &self.file_path {
            write!(f, "{indent}{CYAN}{BOLD}-->{RESET} {file_path}",)?;

            // Write file location
            if let Some(context) = &self.context {
                write!(f, ":{}:{}", context.span.line, context.span.column)?;
            }
            f.write_char('\n')?;
        }
        // Otherwise, write line and column:
        // `  | line 12, column 2`
        else if let Some(context) = &self.context {
            writeln!(
                f,
                "{indent}{CYAN}{BOLD}-->{RESET} line {}, column {}",
                context.span.line, context.span.column
            )?;
        }
        // Write spacer
        writeln!(f, "{indent}{CYAN}{BOLD} | {RESET}")?;

        // Write context
        if let Some(context) = &self.context {
            // Write source lines:
            // `98  | some source code here`
            // `99  | some source code here`
            // `100 | some source code here`
            for (index, line) in context.context.iter().enumerate() {
                let line_number = (context.span.line.saturating_sub(
                    context
                        .context
                        .len()
                        .saturating_sub(index)
                        .saturating_sub(1),
                ))
                .to_string();
                let padding = " ".repeat(line_number_size - line_number.len());
                writeln!(f, "{CYAN}{BOLD}{line_number}{padding} | {RESET}{line}",)?;
            }

            // Write span highlighter:
            // `    |      ^^^^^^`
            write!(
                f,
                "{indent}{CYAN}{BOLD} | {RESET}{}{colour}{BOLD}{}",
                " ".repeat(context.span_indent),
                "^".repeat(context.span.length)
            )?;
            // Write label
            if let Some(label) = &context.label {
                f.write_char(' ')?;
                f.write_str(label)?;
            }
            writeln!(f, "{RESET}")?;
        }

        // Write notes
        if !self.notes.is_empty() {
            writeln!(f, "{indent}{CYAN}{BOLD} | {RESET}")?;
            for note in &self.notes {
                writeln!(f, "{indent}{CYAN}{BOLD} = {DEFAULT}note{RESET}: {note}")?;
            }
        }

        Ok(())
    }
}

impl core::error::Error for Diagnostic {}

#[cfg(test)]
mod test {
    extern crate std;

    use std::io::{Write, stderr, stdout};

    use alloc::string::ToString;

    use crate::diagnostic::{Context, Diagnostic, Diagnostics, Span};

    const SOURCE: &str = r#"use alloc::boxed::Box;
use core::{error::Error, fmt};

use ts_ansi::style::{BOLD, DEFAULT, RED, RESET};

/// An error report, displays the error stack of some error.
pub struct Report<'e> {
    /// The error for this report.
    pub source: Box<dyn Error + 'e>,
}
impl<'e> Report<'e> {
    /// Create a new error report.
    pub fn new<E: Error + 'e>(source: E) -> Self {
        Self {
            source: Box::new(source),
        }
    }
}
impl Error for Report<'static> {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(self.source.as_ref())
    }
}
impl fmt::Debug for Report<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self}")
    }
}
impl fmt::Display for Report<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut current_error = Some(self.source.as_ref());
        let mut count = 1;

        while let Some(error) = current_error {
            writeln!(f, " {BOLD}{RED}{count}{DEFAULT}.{RESET} {error}")?;

            count += 1;
            current_error = error.source();
        }

        Ok(())
    }
}"#;

    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}};"#;

    #[test]
    fn show_output() {
        let _stdout = stdout().lock();
        let mut stderr = stderr().lock();

        let warning = Diagnostic::warning("struct `Report` is never used")
            .file_path("crates/ts-error/src/report.rs")
            .context(Context::new(
                SOURCE,
                Span::default().line(7).column(12).length(6),
            ))
            .add_note("`#[warn(dead_code)]` on by default");

        let error = Diagnostic::error("struct `Report` is never used")
            .context(
                Context::new(SOURCE, Span::default().line(7).column(12).length(6))
                    .label("this is unused"),
            )
            .add_note("`#[warn(dead_code)]` on by default");

        let minified_error = Diagnostic::error("some headline here")
            .context(
                Context::new(
                    MINIFIED_SOURCE,
                    Span::default().line(1).column(200).length(50),
                )
                .label("some label here"),
            )
            .add_note("some note here")
            .add_note("this code is trimmed");

        stderr
            .write_all(error.to_string().as_bytes())
            .expect("writing to stderr should not fail");
        stderr
            .write_all(b"\n")
            .expect("writing to stderr should not fail");
        stderr
            .write_all(minified_error.to_string().as_bytes())
            .expect("writing to stderr should not fail");
        stderr
            .write_all(b"\n")
            .expect("writing to stderr should not fail");
        stderr
            .write_all(warning.to_string().as_bytes())
            .expect("writing to stderr should not fail");

        stderr
            .write_all(b"\n-----\n")
            .expect("writing to stderr should not fail");

        let mut diagnostics = Diagnostics::new("test");
        diagnostics.push(warning);
        diagnostics.push(minified_error);
        diagnostics.push(error);
        stderr
            .write_all(diagnostics.to_string().as_bytes())
            .expect("writing to stderr should not fail");

        stderr.flush().expect("flusing stderr should not fail");
    }
}