mod miette;
pub use miette::{
DiagnosticError, calculate_enhanced_span, calculate_span_length, clear_source_cache,
line_col_to_offset, load_source, to_diagnostic_error,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedDiagnostic {
pub kind: DiagnosticKind,
pub severity: DiagnosticSeverity,
pub message: String,
pub file: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
pub help: Option<String>,
pub context: Option<DiagnosticContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DiagnosticContext {
MissingExport {
export_name: String,
module_id: String,
available_exports: Vec<String>,
},
CircularDependency {
cycle_path: Vec<String>,
},
Plugin {
plugin_name: String,
},
Transform {
file_path: String,
},
UnresolvedEntry {
entry_path: String,
},
UnresolvedImport {
specifier: String,
from_file: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticKind {
MissingExport,
ParseError,
CircularDependency,
UnresolvedEntry,
UnresolvedImport,
InvalidOption,
Plugin,
Transform,
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
Error,
Warning,
}
pub fn extract_from_rolldown_error(error: &dyn std::fmt::Debug) -> Vec<ExtractedDiagnostic> {
let error_str = format!("{error:?}");
if error_str.contains("BatchedBuildDiagnostic") || error_str.contains("diagnostics") {
return extract_multiple_from_string(&error_str);
}
vec![extract_single_from_string(&error_str)]
}
fn extract_single_from_string(error_str: &str) -> ExtractedDiagnostic {
let kind = if error_str.contains("MissingExport") {
DiagnosticKind::MissingExport
} else if error_str.contains("Parse error")
|| error_str.contains("Syntax")
|| error_str.contains("Expected")
{
DiagnosticKind::ParseError
} else if error_str.contains("Circular") || error_str.contains("cycle") {
DiagnosticKind::CircularDependency
} else if error_str.contains("UnresolvedEntry") || error_str.contains("entry") {
DiagnosticKind::UnresolvedEntry
} else if error_str.contains("UnresolvedImport") || error_str.contains("Cannot resolve") {
DiagnosticKind::UnresolvedImport
} else if error_str.contains("Plugin") {
DiagnosticKind::Plugin
} else if error_str.contains("Transform") || error_str.contains("transform") {
DiagnosticKind::Transform
} else {
DiagnosticKind::Other(error_str.to_string())
};
let severity = if error_str.contains("warning") || error_str.contains("Warning") {
DiagnosticSeverity::Warning
} else {
DiagnosticSeverity::Error
};
let file = extract_file_path(error_str);
let line = extract_line_number(error_str);
let column = extract_column_number(error_str);
let help = extract_help_text(error_str);
let context = match &kind {
DiagnosticKind::MissingExport => extract_missing_export_context(error_str, &help),
DiagnosticKind::CircularDependency => extract_circular_dependency_context(error_str),
DiagnosticKind::Plugin => extract_plugin_context(error_str),
DiagnosticKind::Transform => file.as_ref().map(|f| DiagnosticContext::Transform {
file_path: f.clone(),
}),
DiagnosticKind::UnresolvedEntry => {
file.as_ref().map(|f| DiagnosticContext::UnresolvedEntry {
entry_path: f.clone(),
})
}
DiagnosticKind::UnresolvedImport => {
extract_unresolved_import_context(error_str, file.as_ref())
}
_ => None,
};
ExtractedDiagnostic {
kind,
severity,
message: error_str.to_string(),
file,
line,
column,
help,
context,
}
}
fn extract_multiple_from_string(error_str: &str) -> Vec<ExtractedDiagnostic> {
let parts: Vec<&str> = error_str
.split("BatchedBuildDiagnostic")
.filter(|s| !s.trim().is_empty())
.collect();
if parts.len() > 1 {
parts
.iter()
.map(|part| extract_single_from_string(part))
.collect()
} else {
vec![extract_single_from_string(error_str)]
}
}
fn extract_file_path(text: &str) -> Option<String> {
for ext in &[".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"] {
if let Some(pos) = text.find(ext) {
let before = &text[..=pos + ext.len()];
for indicator in &["in ", "at ", "file: ", "path: ", "\"", "'"] {
if let Some(start) = before.rfind(indicator) {
let path_start = start + indicator.len();
let path_str = &before[path_start..];
if let Some(end) = path_str.find([' ', '\n', '"', '\'', ',']) {
return Some(path_str[..end].trim().to_string());
}
return Some(path_str.trim().to_string());
}
}
}
}
None
}
fn extract_line_number(text: &str) -> Option<u32> {
for pattern in &["line ", ":"] {
if let Some(pos) = text.find(pattern) {
let after = &text[pos + pattern.len()..];
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if !num_str.is_empty() {
if let Ok(num) = num_str.parse::<u32>() {
return Some(num);
}
}
}
}
None
}
fn extract_column_number(text: &str) -> Option<u32> {
for pattern in &["column ", "col ", ":"] {
if let Some(pos) = text.find(pattern) {
let after = &text[pos + pattern.len()..];
if *pattern == ":" && after.starts_with(|c: char| c.is_ascii_digit()) {
if let Some(colon_pos) = after[1..].find(':') {
let after_colon = &after[colon_pos + 2..];
let num_str: String = after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !num_str.is_empty() {
if let Ok(num) = num_str.parse::<u32>() {
return Some(num);
}
}
}
} else {
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if !num_str.is_empty() {
if let Ok(num) = num_str.parse::<u32>() {
return Some(num);
}
}
}
}
}
None
}
fn extract_help_text(text: &str) -> Option<String> {
for indicator in &[
"help: ",
"Help: ",
"hint: ",
"Hint: ",
"suggestion: ",
"Suggestion: ",
] {
if let Some(pos) = text.find(indicator) {
let after = &text[pos + indicator.len()..];
let help_str: String = after.lines().next().unwrap_or("").trim().to_string();
if !help_str.is_empty() {
return Some(help_str);
}
}
}
None
}
fn extract_missing_export_context(
error_str: &str,
help: &Option<String>,
) -> Option<DiagnosticContext> {
let export_name = extract_quoted_string_after(error_str, "export")
.or_else(|| extract_quoted_string_after(error_str, "MissingExport"))
.unwrap_or_else(|| "unknown".to_string());
let module_id = extract_file_path(error_str)
.or_else(|| extract_quoted_string_after(error_str, "module"))
.unwrap_or_else(|| "unknown".to_string());
let available_exports = help
.as_ref()
.and_then(|h| extract_available_exports_list(h))
.unwrap_or_default();
Some(DiagnosticContext::MissingExport {
export_name,
module_id,
available_exports,
})
}
fn extract_circular_dependency_context(error_str: &str) -> Option<DiagnosticContext> {
let cycle_path = if let Some(start) = error_str.find("->") {
error_str[start..]
.split("->")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else if let Some(start) = error_str.find("→") {
error_str[start..]
.split("→")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
let mut paths = Vec::new();
if let Some(file) = extract_file_path(error_str) {
paths.push(file);
}
paths
};
if !cycle_path.is_empty() {
Some(DiagnosticContext::CircularDependency { cycle_path })
} else {
None
}
}
fn extract_plugin_context(error_str: &str) -> Option<DiagnosticContext> {
let plugin_name = extract_quoted_string_after(error_str, "Plugin")
.or_else(|| {
if let Some(start) = error_str.find("Plugin") {
let after = &error_str[start + 6..];
extract_quoted_string(after)
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
Some(DiagnosticContext::Plugin { plugin_name })
}
fn extract_unresolved_import_context(
error_str: &str,
file: Option<&String>,
) -> Option<DiagnosticContext> {
let specifier = extract_quoted_string_after(error_str, "Cannot resolve")
.or_else(|| extract_quoted_string_after(error_str, "import"))
.unwrap_or_else(|| "unknown".to_string());
let from_file = file.cloned().unwrap_or_else(|| "unknown".to_string());
Some(DiagnosticContext::UnresolvedImport {
specifier,
from_file,
})
}
fn extract_quoted_string_after(text: &str, keyword: &str) -> Option<String> {
if let Some(pos) = text.find(keyword) {
let after = &text[pos + keyword.len()..];
extract_quoted_string(after)
} else {
None
}
}
fn extract_quoted_string(text: &str) -> Option<String> {
for quote in &['"', '\'', '`'] {
if let Some(start) = text.find(*quote) {
let after = &text[start + 1..];
if let Some(end) = after.find(*quote) {
return Some(after[..end].to_string());
}
}
}
None
}
fn extract_available_exports_list(help: &str) -> Option<Vec<String>> {
for pattern in &["Available: ", "Available exports: ", "Exports: "] {
if let Some(pos) = help.find(pattern) {
let after = &help[pos + pattern.len()..];
let exports_str = after.lines().next().unwrap_or("").trim();
if !exports_str.is_empty() {
let exports: Vec<String> = exports_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !exports.is_empty() {
return Some(exports);
}
}
}
}
None
}