tessellate-core 0.3.0

Compiler and deterministic runtime for the Tess rule language
use crate::source::{SourceFile, Span};
use serde::{Deserialize, Serialize};
use std::fmt::Write;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Error,
    Warning,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Label {
    pub span: Span,
    pub message: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Diagnostic {
    pub severity: Severity,
    pub code: String,
    pub message: String,
    pub primary: Span,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub labels: Vec<Label>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub help: Option<String>,
}

impl Diagnostic {
    #[must_use]
    pub fn error(code: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
        Self {
            severity: Severity::Error,
            code: code.into(),
            message: message.into(),
            primary: span,
            labels: Vec::new(),
            help: None,
        }
    }

    #[must_use]
    pub fn warning(code: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
        Self {
            severity: Severity::Warning,
            code: code.into(),
            message: message.into(),
            primary: span,
            labels: Vec::new(),
            help: None,
        }
    }

    #[must_use]
    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
        self.labels.push(Label {
            span,
            message: message.into(),
        });
        self
    }

    #[must_use]
    pub fn with_help(mut self, help: impl Into<String>) -> Self {
        self.help = Some(help.into());
        self
    }

    #[must_use]
    pub fn render(&self, source: &SourceFile) -> String {
        let (line, column) = source.line_col(self.primary.start);
        let kind = match self.severity {
            Severity::Error => "error",
            Severity::Warning => "warning",
        };
        let mut out = format!(
            "{kind}[{}]: {}\n  --> {}:{line}:{column}\n",
            self.code,
            self.message,
            source.name_at(self.primary.start)
        );
        let text = source.line_text_at(self.primary.start);
        if !text.is_empty() {
            let _ = writeln!(out, "   |\n{line:>3}| {text}");
            let width = source
                .text
                .get(self.primary.start..self.primary.end)
                .and_then(|text| text.lines().next())
                .map_or(1, |text| text.chars().count().max(1));
            let _ = writeln!(
                out,
                "   | {}{}",
                " ".repeat(column.saturating_sub(1)),
                "^".repeat(width.min(text.len().saturating_add(1)))
            );
        }
        for label in &self.labels {
            let (label_line, label_column) = source.line_col(label.span.start);
            let _ = writeln!(
                out,
                "   = related {}:{label_line}:{label_column}: {}",
                source.name_at(label.span.start),
                label.message
            );
        }
        if let Some(help) = &self.help {
            let _ = writeln!(out, "   = help: {help}");
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unicode_highlight_width_uses_scalar_columns_not_bytes() {
        let source = SourceFile::new("test.tes", "module 한글\n");
        let start = "module ".len();
        let diagnostic =
            Diagnostic::error("T0001", "sample", Span::new(start, start + "한글".len()));
        let rendered = diagnostic.render(&source);
        let marker = rendered.lines().find(|line| line.contains('^')).unwrap();
        assert_eq!(
            marker.chars().filter(|character| *character == '^').count(),
            2
        );
    }
}