mago-reporting 1.20.1

Structured error and diagnostic reporting utilities.
Documentation
use std::borrow::Cow;
use std::cell::Cell;
use std::cmp::Ordering;
use std::io::IsTerminal;
use std::io::Write;
use std::ops::Range;

use codespan_reporting::diagnostic::Diagnostic;
use codespan_reporting::diagnostic::Label;
use codespan_reporting::diagnostic::LabelStyle;
use codespan_reporting::diagnostic::Severity;
use codespan_reporting::files::Error;
use codespan_reporting::files::Files;
use codespan_reporting::term;
use codespan_reporting::term::Config;
use codespan_reporting::term::DisplayStyle;
use mago_database::file::FileId;
use termcolor::Buffer;

use mago_database::DatabaseReader;
use mago_database::ReadDatabase;

use crate::Annotation;
use crate::AnnotationKind;
use crate::Issue;
use crate::IssueCollection;
use crate::Level;
use crate::error::ReportingError;
use crate::formatter::Formatter;
use crate::formatter::FormatterConfig;
use crate::formatter::utils::osc8_hyperlink;

/// Formatter that outputs issues in rich diagnostic format with full context.
pub(crate) struct RichFormatter;

impl Formatter for RichFormatter {
    fn format(
        &self,
        writer: &mut dyn Write,
        issues: &IssueCollection,
        database: &ReadDatabase,
        config: &FormatterConfig,
    ) -> Result<(), ReportingError> {
        codespan_format_with_config(
            writer,
            issues,
            database,
            config,
            &Config { display_style: DisplayStyle::Rich, ..Default::default() },
        )
    }
}

pub(super) fn codespan_format_with_config(
    writer: &mut dyn Write,
    issues: &IssueCollection,
    database: &ReadDatabase,
    config: &FormatterConfig,
    codespan_config: &Config,
) -> Result<(), ReportingError> {
    let use_colors = config.color_choice.should_use_colors(std::io::stdout().is_terminal());
    let mut buffer = if use_colors { Buffer::ansi() } else { Buffer::no_color() };

    let editor_url = if use_colors { config.editor_url.as_deref() } else { None };
    let files = DatabaseFiles { database, editor_url, line_hint: Cell::new(None), column_hint: Cell::new(None) };

    let mut highest_level: Option<Level> = None;
    let mut errors = 0;
    let mut warnings = 0;
    let mut notes = 0;
    let mut help = 0;
    let mut suggestions = 0;

    for issue in crate::formatter::utils::filter_issues(issues, config, true) {
        match issue.level {
            Level::Note => notes += 1,
            Level::Help => help += 1,
            Level::Warning => warnings += 1,
            Level::Error => errors += 1,
        }

        highest_level = Some(highest_level.map_or(issue.level, |cur| cur.max(issue.level)));

        if !issue.edits.is_empty() {
            suggestions += 1;
        }

        if editor_url.is_some() {
            if let Some(annotation) = issue.annotations.iter().find(|a| a.is_primary()) {
                if let Ok(file) = database.get_ref(&annotation.span.file_id) {
                    let line = file.line_number(annotation.span.start.offset) + 1;
                    let column = file.column_number(annotation.span.start.offset) + 1;
                    files.line_hint.set(Some(line));
                    files.column_hint.set(Some(column));
                }
            } else {
                files.line_hint.set(None);
                files.column_hint.set(None);
            }
        }

        let diagnostic: Diagnostic<FileId> = issue.into();

        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
    }

    if let Some(highest_level) = highest_level {
        let total_issues = errors + warnings + notes + help;
        let mut message_notes = vec![];
        if errors > 0 {
            message_notes.push(format!("{errors} error(s)"));
        }

        if warnings > 0 {
            message_notes.push(format!("{warnings} warning(s)"));
        }

        if notes > 0 {
            message_notes.push(format!("{notes} note(s)"));
        }

        if help > 0 {
            message_notes.push(format!("{help} help message(s)"));
        }

        let mut diagnostic: Diagnostic<FileId> = Diagnostic::new(highest_level.into()).with_message(format!(
            "found {} issues: {}",
            total_issues,
            message_notes.join(", ")
        ));

        if suggestions > 0 {
            diagnostic = diagnostic.with_notes(vec![format!("{} issues contain auto-fix suggestions", suggestions)]);
        }

        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
    }

    // Write buffer to writer
    writer.write_all(buffer.as_slice())?;

    Ok(())
}

struct DatabaseFiles<'a> {
    database: &'a ReadDatabase,
    editor_url: Option<&'a str>,
    line_hint: Cell<Option<u32>>,
    column_hint: Cell<Option<u32>>,
}

impl<'a> Files<'a> for DatabaseFiles<'_> {
    type FileId = FileId;
    type Name = Cow<'a, str>;
    type Source = &'a str;

    fn name(&'a self, file_id: FileId) -> Result<Cow<'a, str>, Error> {
        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;
        let name = file.name.as_ref();

        if let (Some(template), Some(path)) = (self.editor_url, file.path.as_ref()) {
            let abs_path = path.display().to_string();
            let line = self.line_hint.get().unwrap_or(1);
            let column = self.column_hint.get().unwrap_or(1);

            Ok(Cow::Owned(osc8_hyperlink(template, &abs_path, line, column, name)))
        } else {
            Ok(Cow::Borrowed(name))
        }
    }

    fn source(&'a self, file_id: FileId) -> Result<&'a str, Error> {
        self.database.get_ref(&file_id).map(|source| source.contents.as_ref()).map_err(|_| Error::FileMissing)
    }

    fn line_index(&self, file_id: FileId, byte_index: usize) -> Result<usize, Error> {
        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;

        Ok(file.line_number(
            byte_index.try_into().map_err(|_| Error::IndexTooLarge { given: byte_index, max: u32::MAX as usize })?,
        ) as usize)
    }

    fn line_range(&self, file_id: FileId, line_index: usize) -> Result<Range<usize>, Error> {
        let file = self.database.get(&file_id).map_err(|_| Error::FileMissing)?;

        codespan_line_range(&file.lines, file.size, line_index)
    }
}

fn codespan_line_start(lines: &[u32], size: u32, line_index: usize) -> Result<usize, Error> {
    match line_index.cmp(&lines.len()) {
        Ordering::Less => Ok(lines.get(line_index).copied().expect("failed despite previous check") as usize),
        Ordering::Equal => Ok(size as usize),
        Ordering::Greater => Err(Error::LineTooLarge { given: line_index, max: lines.len() - 1 }),
    }
}

fn codespan_line_range(lines: &[u32], size: u32, line_index: usize) -> Result<Range<usize>, Error> {
    let line_start = codespan_line_start(lines, size, line_index)?;
    let next_line_start = codespan_line_start(lines, size, line_index + 1)?;

    Ok(line_start..next_line_start)
}

impl From<AnnotationKind> for LabelStyle {
    fn from(kind: AnnotationKind) -> LabelStyle {
        match kind {
            AnnotationKind::Primary => LabelStyle::Primary,
            AnnotationKind::Secondary => LabelStyle::Secondary,
        }
    }
}

impl From<Annotation> for Label<FileId> {
    fn from(annotation: Annotation) -> Label<FileId> {
        let mut label = Label::new(annotation.kind.into(), annotation.span.file_id, annotation.span);

        if let Some(message) = annotation.message {
            label.message = message;
        }

        label
    }
}

impl From<&Annotation> for Label<FileId> {
    fn from(annotation: &Annotation) -> Label<FileId> {
        let mut label = Label::new(annotation.kind.into(), annotation.span.file_id, annotation.span);

        if let Some(message) = &annotation.message {
            label.message = message.clone();
        }

        label
    }
}

impl From<Level> for Severity {
    fn from(level: Level) -> Severity {
        match level {
            Level::Note => Severity::Note,
            Level::Help => Severity::Help,
            Level::Warning => Severity::Warning,
            Level::Error => Severity::Error,
        }
    }
}

impl From<Issue> for Diagnostic<FileId> {
    fn from(issue: Issue) -> Diagnostic<FileId> {
        let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message);

        if let Some(code) = issue.code {
            diagnostic.code = Some(code);
        }

        for annotation in issue.annotations {
            diagnostic.labels.push(annotation.into());
        }

        for note in issue.notes {
            diagnostic.notes.push(note);
        }

        if let Some(help) = issue.help {
            diagnostic.notes.push(format!("Help: {help}"));
        }

        if let Some(link) = issue.link {
            diagnostic.notes.push(format!("See: {link}"));
        }

        diagnostic
    }
}

impl From<&Issue> for Diagnostic<FileId> {
    fn from(issue: &Issue) -> Diagnostic<FileId> {
        let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message.clone());

        if let Some(code) = &issue.code {
            diagnostic.code = Some(code.clone());
        }

        for annotation in &issue.annotations {
            diagnostic.labels.push(annotation.into());
        }

        for note in &issue.notes {
            diagnostic.notes.push(note.clone());
        }

        if let Some(help) = &issue.help {
            diagnostic.notes.push(format!("Help: {help}"));
        }

        if let Some(link) = &issue.link {
            diagnostic.notes.push(format!("See: {link}"));
        }

        diagnostic
    }
}