Architecture

Error Reporting

Nova's error reporting system aims to provide Rust-quality diagnostics with rich context, multi-span highlighting, and helpful suggestions. Beautiful errors are not just nice to have— they're essential for developer productivity and AI-assisted debugging.

Library Comparison

Three mature Rust libraries dominate the diagnostic space. Each has different tradeoffs for Nova's needs.

Library Approach Multi-file Color Control Dependencies
ariadne Builder API Excellent ColorGenerator Minimal
miette Derive macro Good Auto + NO_COLOR More (fancy feature)
codespan-reporting File database Primary/Secondary termcolor Minimal

Option 1: ariadne (Recommended)

Why ariadne?

Sister project of Chumsky parser (which we may use). Best-in-class visual output with automatic label overlap handling. The ColorGenerator ensures distinct colors for each diagnostic element without manual color management.

Complete Example

use ariadne::{Color, ColorGenerator, Fmt, Label, Report, ReportKind, Source};

fn report_type_mismatch(
    source: &str,
    filename: &str,
    expected_span: Range<usize>,
    found_span: Range<usize>,
    expected_ty: &str,
    found_ty: &str,
) {
    let mut colors = ColorGenerator::new();
    let expected_color = colors.next();
    let found_color = colors.next();

    Report::build(ReportKind::Error, (filename, found_span.clone()))
        .with_code("E0308")
        .with_message("incompatible types")
        .with_label(
            Label::new((filename, expected_span))
                .with_message(format!("expected type {}", expected_ty.fg(expected_color)))
                .with_color(expected_color),
        )
        .with_label(
            Label::new((filename, found_span))
                .with_message(format!("found type {}", found_ty.fg(found_color)))
                .with_color(found_color),
        )
        .with_note(format!(
            "expected {}, found {}",
            expected_ty.fg(expected_color),
            found_ty.fg(found_color)
        ))
        .finish()
        .print((filename, Source::from(source)))
        .unwrap();
}

Sample Output

Error[E0308]: incompatible types ┌─ main.nova:5:12 │ 5 │ let x: String = 42 │ ------ ^^ found type `Int` │ │ │ expected type `String` │ = note: expected String, found Int

Multi-file Support

// ariadne handles multi-file diagnostics elegantly
Report::build(ReportKind::Error, ("main.nova", 10..20))
    .with_label(
        Label::new(("utils.nova", 50..60))
            .with_message("function defined here")
            .with_color(Color::Blue),
    )
    .with_label(
        Label::new(("main.nova", 10..20))
            .with_message("called with wrong argument type")
            .with_color(Color::Red),
    )
    .finish()
    .print(sources)  // Cache<(filename, Source)>
    .unwrap();

Option 2: miette

When to consider miette

If Nova's errors are defined as Rust types (not just dynamic strings), miette's derive macro provides excellent ergonomics. Integrates well with thiserror.

Derive-based Error Definition

use miette::{Diagnostic, NamedSource, SourceSpan};
use thiserror::Error;

#[derive(Error, Debug, Diagnostic)]
#[error("type mismatch")]
#[diagnostic(
    code(nova::type_error::E0308),
    url(docsrs),
    help("consider adding a type annotation or cast")
)]
struct TypeMismatch {
    // The source code being diagnosed
    #[source_code]
    src: NamedSource<String>,

    // Primary label at the error location
    #[label("expected {expected}, found {found}")]
    span: SourceSpan,

    // Additional context
    expected: String,
    found: String,

    // Optional secondary label
    #[label("type declared here")]
    declaration: Option<SourceSpan>,
}

// Usage
fn check_types() -> miette::Result<()> {
    Err(TypeMismatch {
        src: NamedSource::new("main.nova", source.to_string()),
        span: (offset, length).into(),
        expected: "String".to_string(),
        found: "Int".to_string(),
        declaration: Some((decl_offset, decl_len).into()),
    })?
}

Cargo.toml Configuration

# Enable fancy output only in the binary crate
[dependencies]
miette = { version = "7.6", features = ["fancy"] }
thiserror = "2.0"

Option 3: codespan-reporting

When to consider codespan-reporting

Minimal dependencies, battle-tested in production compilers. File database pattern separates source management from diagnostic rendering. Inspired rustc's own diagnostics.

File Database Pattern

use codespan_reporting::diagnostic::{Diagnostic, Label};
use codespan_reporting::files::SimpleFiles;
use codespan_reporting::term::termcolor::{ColorChoice, StandardStream};
use codespan_reporting::term;

fn compile(sources: Vec<(&str, &str)>) -> Result<(), ()> {
    // Build file database
    let mut files = SimpleFiles::new();
    for (name, content) in sources {
        files.add(name, content);
    }

    // Create diagnostic with primary and secondary labels
    let diagnostic = Diagnostic::error()
        .with_message("`case` clauses have incompatible types")
        .with_code("E0308")
        .with_labels(vec![
            Label::primary(file_id, 328..331)
                .with_message("expected `String`, found `Nat`"),
            Label::secondary(file_id, 186..192)
                .with_message("expected type `String` found here"),
            Label::secondary(file_id, 258..268)
                .with_message("this is of type `String`"),
        ])
        .with_notes(vec![
            "expected type `String`\n   found type `Nat`".to_string(),
        ]);

    // Render to stderr
    let writer = StandardStream::stderr(ColorChoice::Always);
    let config = term::Config::default();
    term::emit(&mut writer.lock(), &config, &files, &diagnostic)?;

    Err(())
}

Nova's Error Architecture

Error Types

/// All Nova compiler errors
pub enum NovaError {
    // Lexer errors
    UnterminatedString { span: Span },
    InvalidNumber { span: Span, reason: String },
    UnexpectedCharacter { span: Span, ch: char },

    // Parser errors
    UnexpectedToken { span: Span, expected: Vec<TokenKind>, found: TokenKind },
    MissingClosingDelimiter { open_span: Span, expected: char },

    // Type errors
    TypeMismatch { span: Span, expected: Type, found: Type, context: Option<Span> },
    UndefinedVariable { span: Span, name: String, suggestions: Vec<String> },

    // Verification errors (Nova-specific)
    VerificationFailed { span: Span, property: String, counterexample: Option<String> },
    UnsatisfiableContract { span: Span, contract: String },
}

Span Definition

/// Source location tracking
#[derive(Clone, Copy, Debug)]
pub struct Span {
    pub file: FileId,
    pub start: u32,
    pub end: u32,
}

impl Span {
    pub fn merge(self, other: Span) -> Span {
        debug_assert_eq!(self.file, other.file);
        Span {
            file: self.file,
            start: self.start.min(other.start),
            end: self.end.max(other.end),
        }
    }
}

/// Newtype for file identifiers
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileId(u32);

Error Rendering with ariadne

impl NovaError {
    pub fn render(&self, sources: &SourceCache) {
        use ariadne::{Report, ReportKind, Label, Color};

        match self {
            NovaError::TypeMismatch { span, expected, found, context } => {
                let mut report = Report::build(ReportKind::Error, span.file, span.start)
                    .with_code("E0308")
                    .with_message("type mismatch")
                    .with_label(
                        Label::new((span.file, span.start..span.end))
                            .with_message(format!("expected `{}`, found `{}`", expected, found))
                            .with_color(Color::Red),
                    );

                if let Some(ctx) = context {
                    report = report.with_label(
                        Label::new((ctx.file, ctx.start..ctx.end))
                            .with_message("type expected due to this")
                            .with_color(Color::Blue),
                    );
                }

                report.finish().print(sources).unwrap();
            }

            NovaError::UndefinedVariable { span, name, suggestions } => {
                let mut report = Report::build(ReportKind::Error, span.file, span.start)
                    .with_code("E0425")
                    .with_message(format!("undefined variable `{}`", name))
                    .with_label(
                        Label::new((span.file, span.start..span.end))
                            .with_message("not found in this scope")
                            .with_color(Color::Red),
                    );

                if !suggestions.is_empty() {
                    report = report.with_help(format!(
                        "did you mean: {}?",
                        suggestions.join(", ")
                    ));
                }

                report.finish().print(sources).unwrap();
            }

            // ... other error variants
        }
    }
}

AI-Friendly Error Format

For AI-assisted debugging, Nova errors include structured JSON output alongside human-readable messages. This enables LLMs to understand and fix errors programmatically.

impl NovaError {
    pub fn to_json(&self) -> serde_json::Value {
        json!({
            "code": self.code(),
            "severity": "error",
            "message": self.message(),
            "spans": self.spans().iter().map(|s| {
                json!({
                    "file": s.file.name(),
                    "start": { "line": s.start_line, "column": s.start_col },
                    "end": { "line": s.end_line, "column": s.end_col },
                    "label": s.label,
                    "primary": s.is_primary,
                })
            }).collect::<Vec<_>>(),
            "suggestions": self.suggestions(),
            "help": self.help(),
        })
    }
}

Recommendation for Nova

Use ariadne for error rendering. It's a sister project of Chumsky (potential parser), has the best visual output, and handles label overlap automatically. The ColorGenerator ensures distinct colors without manual management.

Additionally, implement JSON error output for AI integration. Nova's value proposition includes AI-assisted development, and structured errors enable LLMs to understand and fix issues automatically.

Implementation Plan

  1. Define NovaError enum with all error variants
  2. Implement Span tracking throughout lexer/parser/type-checker
  3. Add render() method using ariadne for human-readable output
  4. Add to_json() method for AI/LSP integration
  5. Implement suggestion generation (typo detection, type coercions)
  6. Add verification error formatting with counterexamples

References