use crate::cross_file::diagnostics::{
CrossFileDiagnostic, CrossFileDiagnosticKind, DiagnosticSeverity,
};
use crate::cross_file::graph::{DependencyEdge, DependencyGraph};
use crate::cross_file::registry::{FileId, ModuleRegistry};
use vize_carton::{CompactString, FxHashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundaryKind {
ServerClient,
Error,
Suspense,
}
#[derive(Debug, Clone)]
pub struct BoundaryInfo {
pub file_id: FileId,
pub kind: BoundaryKind,
pub offset: u32,
pub protects: Vec<FileId>,
}
pub fn analyze_boundaries(
registry: &ModuleRegistry,
graph: &DependencyGraph,
) -> (Vec<BoundaryInfo>, Vec<CrossFileDiagnostic>) {
let mut boundaries = Vec::new();
let mut diagnostics = Vec::new();
let mut error_boundaries: FxHashSet<FileId> = FxHashSet::default();
let mut suspense_boundaries: FxHashSet<FileId> = FxHashSet::default();
let mut client_only_apis: Vec<(FileId, CompactString, u32)> = Vec::new();
let mut async_components: FxHashSet<FileId> = FxHashSet::default();
let mut components_with_errors: Vec<(FileId, u32)> = Vec::new();
for entry in registry.vue_components() {
let analysis = &entry.analysis;
if has_error_captured(analysis) {
error_boundaries.insert(entry.id);
boundaries.push(BoundaryInfo {
file_id: entry.id,
kind: BoundaryKind::Error,
offset: 0,
protects: Vec::new(),
});
}
if uses_suspense(analysis) {
suspense_boundaries.insert(entry.id);
boundaries.push(BoundaryInfo {
file_id: entry.id,
kind: BoundaryKind::Suspense,
offset: 0,
protects: Vec::new(),
});
}
if analysis.macros.is_async() {
async_components.insert(entry.id);
}
let browser_usages = find_browser_api_usage(analysis);
for (api, offset, context) in browser_usages {
if !is_in_client_only_context(analysis, offset) {
client_only_apis.push((entry.id, api.clone(), offset));
diagnostics.push(
CrossFileDiagnostic::new(
CrossFileDiagnosticKind::BrowserApiInSsr {
api,
context: CompactString::new(context),
},
DiagnosticSeverity::Warning,
entry.id,
offset,
"Browser API used in potentially SSR context",
)
.with_suggestion("Wrap in onMounted() or use import.meta.client check"),
);
}
}
let error_sources = find_error_sources(analysis);
for offset in error_sources {
components_with_errors.push((entry.id, offset));
}
}
for async_id in &async_components {
let has_suspense = has_ancestor_with_boundary(*async_id, &suspense_boundaries, graph);
if !has_suspense {
let component_name = registry
.get(*async_id)
.and_then(|e| e.component_name.clone())
.unwrap_or_else(|| CompactString::new("Component"));
diagnostics.push(
CrossFileDiagnostic::new(
CrossFileDiagnosticKind::AsyncWithoutSuspense { component_name },
DiagnosticSeverity::Warning,
*async_id,
0,
"Async component without Suspense boundary",
)
.with_suggestion(
"Wrap in <Suspense> or use defineAsyncComponent with loading state",
),
);
}
}
for (file_id, offset) in &components_with_errors {
let has_boundary = has_ancestor_with_boundary(*file_id, &error_boundaries, graph);
if !has_boundary {
diagnostics.push(
CrossFileDiagnostic::new(
CrossFileDiagnosticKind::UncaughtErrorBoundary,
DiagnosticSeverity::Info,
*file_id,
*offset,
"Potential error without error boundary",
)
.with_suggestion("Add onErrorCaptured in a parent component"),
);
}
}
for boundary in &mut boundaries {
boundary.protects = find_protected_components(boundary.file_id, graph);
}
(boundaries, diagnostics)
}
fn has_error_captured(analysis: &crate::Croquis) -> bool {
analysis.bindings.contains("onErrorCaptured")
|| analysis.scopes.is_defined("onErrorCaptured")
|| analysis
.template_expressions
.iter()
.any(|e| e.content.contains("onErrorCaptured"))
}
fn uses_suspense(analysis: &crate::Croquis) -> bool {
analysis.used_components.contains("Suspense")
|| analysis
.used_components
.iter()
.any(|c| c.as_str() == "Suspense")
}
fn find_browser_api_usage(analysis: &crate::Croquis) -> Vec<(CompactString, u32, &'static str)> {
let mut usages = Vec::new();
let browser_apis = [
("window", "Browser global"),
("document", "DOM API"),
("navigator", "Browser API"),
("localStorage", "Web Storage"),
("sessionStorage", "Web Storage"),
("location", "Browser location"),
("history", "Browser history"),
("fetch", "Fetch API"), ("XMLHttpRequest", "XHR"),
("WebSocket", "WebSocket"),
("IntersectionObserver", "Intersection Observer"),
("ResizeObserver", "Resize Observer"),
("MutationObserver", "Mutation Observer"),
("requestAnimationFrame", "Animation API"),
("cancelAnimationFrame", "Animation API"),
("getComputedStyle", "CSSOM"),
("matchMedia", "Media Query"),
("alert", "Browser dialog"),
("confirm", "Browser dialog"),
("prompt", "Browser dialog"),
];
for expr in &analysis.template_expressions {
for (api, context) in &browser_apis {
if expr.content.contains(api) {
usages.push((CompactString::new(*api), expr.start, *context));
}
}
}
usages
}
fn is_in_client_only_context(analysis: &crate::Croquis, offset: u32) -> bool {
for scope in analysis.scopes.iter() {
if scope.span.start <= offset && offset <= scope.span.end {
if scope.kind == crate::scope::ScopeKind::ClientOnly {
return true;
}
for &parent_id in &scope.parents {
if let Some(parent) = analysis.scopes.get_scope(parent_id) {
if parent.kind == crate::scope::ScopeKind::ClientOnly {
return true;
}
}
}
}
}
false
}
fn find_error_sources(analysis: &crate::Croquis) -> Vec<u32> {
let mut sources = Vec::new();
let error_patterns = [
"throw",
"Error(",
"reject(",
"JSON.parse",
"fetch(",
"axios",
"await ",
];
for expr in &analysis.template_expressions {
for pattern in &error_patterns {
if expr.content.contains(pattern) {
sources.push(expr.start);
break;
}
}
}
sources
}
fn has_ancestor_with_boundary(
file_id: FileId,
boundaries: &FxHashSet<FileId>,
graph: &DependencyGraph,
) -> bool {
let mut visited = FxHashSet::default();
let mut queue = vec![file_id];
while let Some(current) = queue.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current);
if current != file_id && boundaries.contains(¤t) {
return true;
}
for (parent_id, edge_type) in graph.dependents(current) {
if edge_type == DependencyEdge::ComponentUsage && !visited.contains(&parent_id) {
queue.push(parent_id);
}
}
}
false
}
fn find_protected_components(boundary_id: FileId, graph: &DependencyGraph) -> Vec<FileId> {
let mut protected = Vec::new();
let mut visited = FxHashSet::default();
let mut queue = vec![boundary_id];
while let Some(current) = queue.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current);
for (child_id, edge_type) in graph.dependencies(current) {
if edge_type == DependencyEdge::ComponentUsage {
protected.push(child_id);
if !visited.contains(&child_id) {
queue.push(child_id);
}
}
}
}
protected
}
#[cfg(test)]
mod tests {
use super::BoundaryKind;
#[test]
fn test_boundary_kind() {
let kind = BoundaryKind::Error;
assert_eq!(kind, BoundaryKind::Error);
}
}