polydat-grammar 0.3.1

The Polydat language: lexer, parser, AST, and pretty-printer, without the runtime
Documentation
// Copyright 2024-2026 Jonathan Shook
// SPDX-License-Identifier: Apache-2.0

//! Structured error types for the Polydat DSL with rich source context.
//!
//! Every error includes a source location (line:col), the relevant
//! source text, and a clear message with suggestions where possible.

use crate::lexer::Span;
use std::fmt;

/// Severity level for diagnostics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    /// A fault that fails the compile.
    Error,
    /// A note that does not.
    Warning,
}

/// A single diagnostic message with source context.
#[derive(Debug, Clone)]
pub struct Diagnostic {
    /// Error or warning.
    pub severity: Severity,
    /// Where in the source.
    pub span: Span,
    /// The message.
    pub message: String,
    /// A suggested fix, if any.
    pub hint: Option<String>,
    /// The source line text (for display).
    pub source_line: Option<String>,
}

impl Diagnostic {
    /// An error at `span`.
    pub fn error(span: Span, message: impl Into<String>) -> Self {
        Self {
            severity: Severity::Error,
            span,
            message: message.into(),
            hint: None,
            source_line: None,
        }
    }

    /// A warning at `span`.
    pub fn warning(span: Span, message: impl Into<String>) -> Self {
        Self {
            severity: Severity::Warning,
            span,
            message: message.into(),
            hint: None,
            source_line: None,
        }
    }

    /// The same diagnostic with a hint.
    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    /// The same diagnostic with the source line shown under it.
    pub fn with_source_line(mut self, line: impl Into<String>) -> Self {
        self.source_line = Some(line.into());
        self
    }
}

impl fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let severity = match self.severity {
            Severity::Error => "error",
            Severity::Warning => "warning",
        };
        write!(
            f,
            "{}:{}:{}: {}",
            self.span.line, self.span.col, severity, self.message
        )?;

        if let Some(ref line) = self.source_line {
            write!(f, "\n  | {line}")?;
            // Underline the position
            if self.span.col > 0 {
                let padding = " ".repeat(self.span.col - 1);
                write!(f, "\n  | {padding}^")?;
            }
        }

        if let Some(ref hint) = self.hint {
            write!(f, "\n  = hint: {hint}")?;
        }

        Ok(())
    }
}

/// A collection of diagnostics from compilation.
#[derive(Debug, Clone)]
pub struct DiagnosticReport {
    /// Every diagnostic recorded, in order.
    pub diagnostics: Vec<Diagnostic>,
    /// The original source text (for extracting source lines).
    source_lines: Vec<String>,
}

impl DiagnosticReport {
    /// An empty report over `source`, whose lines the diagnostics quote.
    pub fn new(source: &str) -> Self {
        Self {
            diagnostics: Vec::new(),
            source_lines: source.lines().map(|l| l.to_string()).collect(),
        }
    }

    /// Record an error at `span`, quoting its source line.
    pub fn error(&mut self, span: Span, message: impl Into<String>) {
        let mut diag = Diagnostic::error(span, message);
        if span.line > 0 && span.line <= self.source_lines.len() {
            diag.source_line = Some(self.source_lines[span.line - 1].clone());
        }
        self.diagnostics.push(diag);
    }

    /// Record an error at `span` with a hint, quoting its source line.
    pub fn error_with_hint(
        &mut self,
        span: Span,
        message: impl Into<String>,
        hint: impl Into<String>,
    ) {
        let mut diag = Diagnostic::error(span, message).with_hint(hint);
        if span.line > 0 && span.line <= self.source_lines.len() {
            diag.source_line = Some(self.source_lines[span.line - 1].clone());
        }
        self.diagnostics.push(diag);
    }

    /// Record a warning at `span`, quoting its source line.
    pub fn warning(&mut self, span: Span, message: impl Into<String>) {
        let mut diag = Diagnostic::warning(span, message);
        if span.line > 0 && span.line <= self.source_lines.len() {
            diag.source_line = Some(self.source_lines[span.line - 1].clone());
        }
        self.diagnostics.push(diag);
    }

    /// Record a warning at `span` with a hint, quoting its source line.
    pub fn warning_with_hint(
        &mut self,
        span: Span,
        message: impl Into<String>,
        hint: impl Into<String>,
    ) {
        let mut diag = Diagnostic::warning(span, message).with_hint(hint);
        if span.line > 0 && span.line <= self.source_lines.len() {
            diag.source_line = Some(self.source_lines[span.line - 1].clone());
        }
        self.diagnostics.push(diag);
    }

    /// Whether any diagnostic is an error.
    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|d| d.severity == Severity::Error)
    }

    /// The errors, in order.
    pub fn errors(&self) -> Vec<&Diagnostic> {
        self.diagnostics
            .iter()
            .filter(|d| d.severity == Severity::Error)
            .collect()
    }

    /// The warnings, in order.
    pub fn warnings(&self) -> Vec<&Diagnostic> {
        self.diagnostics
            .iter()
            .filter(|d| d.severity == Severity::Warning)
            .collect()
    }
}

impl fmt::Display for DiagnosticReport {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for (i, diag) in self.diagnostics.iter().enumerate() {
            if i > 0 {
                writeln!(f)?;
            }
            write!(f, "{diag}")?;
        }
        Ok(())
    }
}

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

    #[test]
    fn diagnostic_display() {
        let diag = Diagnostic::error(Span { line: 3, col: 12 }, "unknown function 'foobar'")
            .with_hint("did you mean 'hash'?")
            .with_source_line("  result := foobar(cycle)");
        let s = diag.to_string();
        assert!(s.contains("3:12:error"));
        assert!(s.contains("unknown function"));
        assert!(s.contains("foobar(cycle)"));
        assert!(s.contains("did you mean"));
    }

    #[test]
    fn report_collects() {
        let mut report = DiagnosticReport::new("line1\nline2\nline3");
        report.error(Span { line: 1, col: 1 }, "first error");
        report.warning(Span { line: 2, col: 5 }, "a warning");
        report.error(Span { line: 3, col: 1 }, "second error");
        assert!(report.has_errors());
        assert_eq!(report.errors().len(), 2);
        assert_eq!(report.warnings().len(), 1);
    }

    #[test]
    fn report_includes_source_line() {
        let mut report = DiagnosticReport::new("input cycle: u64\nbad := ???");
        report.error(Span { line: 2, col: 8 }, "unexpected token");
        let s = report.to_string();
        assert!(s.contains("bad := ???"));
    }
}