fob-bundler 0.5.0

Fob bundler - Rolldown-based bundling on top of fob foundation
Documentation
//! Miette diagnostic conversion for fob-bundler errors.
//!
//! This module provides conversion from fob-bundler diagnostics to miette's
//! diagnostic format for beautiful error reporting.

use crate::diagnostics::{DiagnosticKind, DiagnosticSeverity, ExtractedDiagnostic};
use miette::{Diagnostic, LabeledSpan, Severity, SourceSpan};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{LazyLock, Mutex};

/// Cache for loaded source code files
static SOURCE_CACHE: LazyLock<Mutex<HashMap<String, String>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

/// Clear the source code cache.
///
/// This should be called before rebuilds to ensure stale cached source
/// code doesn't persist across file changes.
pub fn clear_source_cache() {
    SOURCE_CACHE.lock().unwrap().clear();
}

/// Load source code from a file path
pub fn load_source(file: &str) -> Option<String> {
    let mut cache = SOURCE_CACHE.lock().unwrap();

    // Check cache first
    if let Some(source) = cache.get(file) {
        return Some(source.clone());
    }

    // Try to load from file system
    let path = Path::new(file);
    if path.exists() && path.is_file() {
        if let Ok(content) = std::fs::read_to_string(path) {
            cache.insert(file.to_string(), content.clone());
            return Some(content);
        }
    }

    None
}

/// Convert line and column to byte offset
pub fn line_col_to_offset(source: &str, line: u32, column: u32) -> Option<usize> {
    let lines: Vec<&str> = source.lines().collect();
    if line == 0 || line as usize > lines.len() {
        return None;
    }

    let line_idx = (line - 1) as usize;
    let mut offset = 0;

    // Sum up bytes for all lines before the target line
    for line in lines.iter().take(line_idx) {
        offset += line.len() + 1; // +1 for newline
    }

    // Add column offset (handle UTF-8 by using byte position)
    let target_line = lines[line_idx];
    let col_bytes = if column == 0 {
        0
    } else {
        target_line
            .char_indices()
            .nth((column - 1) as usize)
            .map(|(pos, _)| pos)
            .unwrap_or(target_line.len())
    };

    Some(offset + col_bytes)
}

/// Calculate span length from source code
pub fn calculate_span_length(source: &str, offset: usize) -> usize {
    if offset >= source.len() {
        return 1;
    }

    // Try to find the end of the current token/identifier
    let remaining = &source[offset..];

    // For identifiers, find the end of the word
    if let Some(end) = remaining
        .char_indices()
        .find(|(_, c)| !c.is_alphanumeric() && *c != '_')
        .map(|(pos, _)| pos)
    {
        end.max(1)
    } else {
        // Default to highlighting a single character
        1
    }
}

/// Enhanced span calculation for better error highlighting
pub fn calculate_enhanced_span(source: &str, offset: usize, kind: &DiagnosticKind) -> usize {
    if offset >= source.len() {
        return 1;
    }

    let remaining = &source[offset..];

    match kind {
        DiagnosticKind::MissingExport => {
            // For missing exports, try to highlight the import statement
            // Look for import/export keywords nearby
            if let Some(import_pos) = remaining.find("import") {
                if import_pos < 50 {
                    // Highlight from import to end of line or semicolon
                    if let Some(end) = remaining[import_pos..].find([';', '\n']) {
                        return (import_pos + end).max(1);
                    }
                }
            }
            calculate_span_length(source, offset)
        }
        DiagnosticKind::ParseError | DiagnosticKind::Transform => {
            // For parse errors, try to highlight the problematic token
            // Look for common syntax error patterns
            if let Some(quote_pos) = remaining.find(['"', '\'', '`']) {
                if quote_pos < 20 {
                    // Find matching quote
                    let quote_char = remaining.as_bytes()[quote_pos] as char;
                    if let Some(end_quote) = remaining[quote_pos + 1..].find(quote_char) {
                        return (quote_pos + end_quote + 2).max(1);
                    }
                }
            }
            calculate_span_length(source, offset)
        }
        _ => calculate_span_length(source, offset),
    }
}

/// Wrapper error type that implements Diagnostic for ExtractedDiagnostic
#[derive(Debug)]
pub struct DiagnosticError {
    diag: ExtractedDiagnostic,
    /// Named source for miette to display source code
    named_source: Option<miette::NamedSource<String>>,
    /// Related errors (from error chain)
    related_errors: Vec<ChainedError>,
}

/// A simple error type for error chain entries
#[derive(Debug)]
pub struct ChainedError {
    message: String,
}

impl std::error::Error for ChainedError {}

impl std::fmt::Display for ChainedError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl Diagnostic for ChainedError {
    fn code(&self) -> Option<Box<dyn std::fmt::Display + '_>> {
        None
    }

    fn severity(&self) -> Option<Severity> {
        Some(Severity::Advice)
    }
}

impl std::error::Error for DiagnosticError {}

impl std::fmt::Display for DiagnosticError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.diag.message)?;

        // Append error chain if present
        if !self.diag.error_chain.is_empty() {
            writeln!(f)?;
            writeln!(f, "Caused by:")?;
            for (i, cause) in self.diag.error_chain.iter().enumerate() {
                writeln!(f, "  {}: {}", i + 1, cause)?;
            }
        }

        Ok(())
    }
}

impl Diagnostic for DiagnosticError {
    fn code(&self) -> Option<Box<dyn std::fmt::Display + '_>> {
        Some(Box::new(format!("{:?}", self.diag.kind)))
    }

    fn severity(&self) -> Option<Severity> {
        Some(match self.diag.severity {
            DiagnosticSeverity::Error => Severity::Error,
            DiagnosticSeverity::Warning => Severity::Warning,
        })
    }

    fn help(&self) -> Option<Box<dyn std::fmt::Display + '_>> {
        self.diag
            .help
            .as_ref()
            .map(|h| Box::new(h.clone()) as Box<dyn std::fmt::Display>)
    }

    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
        self.named_source
            .as_ref()
            .map(|s| s as &dyn miette::SourceCode)
    }

    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
        if let (Some(_file), Some(line), Some(column)) =
            (&self.diag.file, self.diag.line, self.diag.column)
        {
            if let Some(source) = &self.named_source {
                let source_str = source.inner();
                if let Some(offset) = line_col_to_offset(source_str, line, column) {
                    let length = calculate_enhanced_span(source_str, offset, &self.diag.kind);
                    let span = SourceSpan::new(offset.into(), length);

                    let label = match &self.diag.kind {
                        DiagnosticKind::MissingExport => "Missing export",
                        DiagnosticKind::ParseError => "Parse error",
                        DiagnosticKind::CircularDependency => "Circular dependency",
                        DiagnosticKind::UnresolvedEntry => "Unresolved entry",
                        DiagnosticKind::UnresolvedImport => "Unresolved import",
                        DiagnosticKind::InvalidOption => "Invalid option",
                        DiagnosticKind::Plugin => "Plugin error",
                        DiagnosticKind::Transform => "Transform error",
                        DiagnosticKind::Other(_) => "Error",
                    };

                    return Some(Box::new(std::iter::once(LabeledSpan::new(
                        Some(label.to_string()),
                        span.offset(),
                        span.len(),
                    ))));
                }
            }
        }
        None
    }

    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
        if self.related_errors.is_empty() {
            None
        } else {
            Some(Box::new(
                self.related_errors.iter().map(|e| e as &dyn Diagnostic),
            ))
        }
    }
}

/// Convert ExtractedDiagnostic to a DiagnosticError with source code loaded
pub fn to_diagnostic_error(diag: ExtractedDiagnostic) -> DiagnosticError {
    let named_source = diag.file.as_ref().and_then(|file| {
        load_source(file).map(|source| miette::NamedSource::new(file.clone(), source))
    });

    // Convert error chain to related errors
    let related_errors: Vec<ChainedError> = diag
        .error_chain
        .iter()
        .map(|msg| ChainedError {
            message: msg.clone(),
        })
        .collect();

    DiagnosticError {
        diag,
        named_source,
        related_errors,
    }
}