use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{ImportedName, ModuleInfo};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::{ResolvedImport, ResolvedModule};
use crate::results::UnrenderedComponent;
use crate::suppress::{IssueKind, SuppressionContext};
use super::{LineOffsetsMap, byte_offset_to_line_col};
const COMPONENT_LINE: u32 = 1;
#[derive(Clone, Copy)]
enum SfcFramework {
Vue,
Svelte,
}
impl SfcFramework {
const fn as_str(self) -> &'static str {
match self {
Self::Vue => "vue",
Self::Svelte => "svelte",
}
}
}
fn sfc_framework(path: &Path, vue: bool, svelte: bool) -> Option<SfcFramework> {
match path.extension().and_then(|ext| ext.to_str()) {
Some("vue") if vue => Some(SfcFramework::Vue),
Some("svelte") if svelte => Some(SfcFramework::Svelte),
_ => None,
}
}
fn is_sfc_extension(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("vue") | Some("svelte")
)
}
#[must_use]
pub fn find_unrendered_components(
graph: &ModuleGraph,
resolved_modules: &[ResolvedModule],
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
public_api_entry_points: &FxHashSet<FileId>,
suppressions: &SuppressionContext<'_>,
) -> Vec<UnrenderedComponent> {
let vue = declared_deps.contains("vue")
|| declared_deps.contains("@vue/runtime-core")
|| declared_deps.contains("nuxt");
let svelte = declared_deps.contains("svelte") || declared_deps.contains("@sveltejs/kit");
if !vue && !svelte {
return Vec::new();
}
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let mut used: FxHashSet<FileId> = FxHashSet::default();
for resolved in resolved_modules {
let referenced: &[String] = modules_by_id
.get(&resolved.file_id)
.map_or(&[], |m| m.referenced_import_bindings.as_slice());
for import in &resolved.resolved_imports {
credit_static_import(graph, import, referenced, &mut used);
}
for import in &resolved.resolved_dynamic_imports {
if let Some(target) = import.target.internal_file_id() {
credit_rendered_sfc_chain(graph, target, "default", &mut used);
}
}
}
let mut reexported: FxHashMap<FileId, FileId> = FxHashMap::default();
for barrel in &graph.modules {
if !barrel.is_reachable() {
continue;
}
for re in &barrel.re_exports {
if re.imported_name == "default" && is_sfc_extension(&graph_path(graph, re.source_file))
{
reexported.entry(re.source_file).or_insert(barrel.file_id);
}
}
}
let public_api = public_api_reexported_sfcs(graph, public_api_entry_points);
let mut findings = Vec::new();
for module in &graph.modules {
let Some(framework) = sfc_framework(&module.path, vue, svelte) else {
continue;
};
if !module.is_reachable() || module.is_entry_point() {
continue;
}
if used.contains(&module.file_id) {
continue;
}
let Some(&barrel_id) = reexported.get(&module.file_id) else {
continue;
};
if public_api.contains(&module.file_id) || public_api_entry_points.contains(&module.file_id)
{
continue;
}
if suppressions.is_suppressed(
module.file_id,
COMPONENT_LINE,
IssueKind::UnrenderedComponent,
) || suppressions.is_file_suppressed(module.file_id, IssueKind::UnrenderedComponent)
{
continue;
}
let component_name = component_name(&module.path);
let reachable_via = graph
.modules
.get(barrel_id.0 as usize)
.map(|b| b.path.clone());
findings.push(UnrenderedComponent {
path: module.path.clone(),
component_name,
framework: framework.as_str().to_string(),
reachable_via,
line: COMPONENT_LINE,
col: 0,
});
}
findings
}
fn credit_static_import(
graph: &ModuleGraph,
import: &ResolvedImport,
referenced: &[String],
used: &mut FxHashSet<FileId>,
) {
let Some(target) = import.target.internal_file_id() else {
return;
};
let is_auto_import = import.info.source.starts_with("<auto-import:");
let is_referenced = referenced
.iter()
.any(|name| name == &import.info.local_name);
if !is_auto_import && !is_referenced {
return;
}
match &import.info.imported_name {
ImportedName::Named(name) => credit_rendered_sfc_chain(graph, target, name, used),
ImportedName::Default => credit_rendered_sfc_chain(graph, target, "default", used),
ImportedName::SideEffect => {
if is_sfc_extension(&graph_path(graph, target)) {
used.insert(target);
}
}
ImportedName::Namespace => {
if is_sfc_extension(&graph_path(graph, target)) {
used.insert(target);
}
if let Some(module) = graph.modules.get(target.0 as usize) {
let names: Vec<(FileId, String)> = module
.re_exports
.iter()
.map(|re| (re.source_file, re.imported_name.clone()))
.collect();
for (source, name) in names {
credit_rendered_sfc_chain(graph, source, &name, used);
}
}
}
}
}
fn credit_rendered_sfc_chain(
graph: &ModuleGraph,
start_file: FileId,
start_name: &str,
used: &mut FxHashSet<FileId>,
) {
let mut visited: FxHashSet<(FileId, String)> = FxHashSet::default();
let mut stack: Vec<(FileId, String)> = vec![(start_file, start_name.to_string())];
while let Some((file_id, name)) = stack.pop() {
if !visited.insert((file_id, name.clone())) {
continue;
}
let Some(module) = graph.modules.get(file_id.0 as usize) else {
continue;
};
if is_sfc_extension(&module.path) {
used.insert(file_id);
}
let mut matched_named = false;
for re in &module.re_exports {
if re.exported_name != "*" && re.imported_name != "*" && re.exported_name == name {
stack.push((re.source_file, re.imported_name.clone()));
matched_named = true;
}
}
if matched_named {
continue;
}
for re in &module.re_exports {
if re.exported_name == "*" {
stack.push((re.source_file, name.clone()));
}
}
}
}
fn graph_path(graph: &ModuleGraph, file_id: FileId) -> std::path::PathBuf {
graph
.modules
.get(file_id.0 as usize)
.map(|m| m.path.clone())
.unwrap_or_default()
}
fn public_api_reexported_sfcs(
graph: &ModuleGraph,
public_api_entry_points: &FxHashSet<FileId>,
) -> FxHashSet<FileId> {
let mut result: FxHashSet<FileId> = FxHashSet::default();
let mut visited: FxHashSet<FileId> = FxHashSet::default();
let mut stack: Vec<FileId> = public_api_entry_points.iter().copied().collect();
while let Some(file_id) = stack.pop() {
if !visited.insert(file_id) {
continue;
}
let Some(module) = graph.modules.get(file_id.0 as usize) else {
continue;
};
for re in &module.re_exports {
let source = re.source_file;
if is_sfc_extension(&graph_path(graph, source)) {
result.insert(source);
}
stack.push(source);
}
}
result
}
fn component_name(path: &Path) -> String {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("component")
.to_string()
}
fn is_element_selector(selector: &str) -> bool {
let s = selector.trim();
!s.is_empty()
&& s.contains('-')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
#[must_use]
pub fn find_unrendered_angular_components(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
public_api_entry_points: &FxHashSet<FileId>,
line_offsets_by_file: &LineOffsetsMap<'_>,
suppressions: &SuppressionContext<'_>,
) -> Vec<UnrenderedComponent> {
if !declared_deps.contains("@angular/core") {
return Vec::new();
}
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let mut used_selectors: FxHashSet<String> = FxHashSet::default();
let mut entry_classes: FxHashSet<&str> = FxHashSet::default();
let mut dynamic_render = false;
for module in modules {
for selector in &module.angular_used_selectors {
used_selectors.insert(selector.clone());
}
for class_name in &module.angular_entry_component_refs {
entry_classes.insert(class_name.as_str());
}
dynamic_render = dynamic_render || module.has_dynamic_component_render;
}
if dynamic_render {
return Vec::new();
}
let public_api = public_api_reexported_files(graph, public_api_entry_points);
let mut findings = Vec::new();
for node in &graph.modules {
if !node.is_reachable() {
continue;
}
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
if module.angular_component_selectors.is_empty() {
continue;
}
if public_api.contains(&node.file_id) || public_api_entry_points.contains(&node.file_id) {
continue;
}
let default_export_referenced = node.exports.iter().any(|export| {
matches!(export.name, fallow_types::extract::ExportName::Default)
&& (!export.references.is_empty() || export.is_side_effect_used)
});
for component in &module.angular_component_selectors {
if !component.selectors.iter().all(|s| is_element_selector(s)) {
continue;
}
if component
.selectors
.iter()
.any(|s| used_selectors.contains(&s.to_ascii_lowercase()))
{
continue;
}
if entry_classes.contains(component.class_name.as_str()) {
continue;
}
if default_export_referenced {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, node.file_id, component.span_start);
if suppressions.is_suppressed(node.file_id, line, IssueKind::UnrenderedComponent)
|| suppressions.is_file_suppressed(node.file_id, IssueKind::UnrenderedComponent)
{
continue;
}
findings.push(UnrenderedComponent {
path: node.path.clone(),
component_name: component.class_name.clone(),
framework: "angular".to_string(),
reachable_via: None,
line,
col,
});
}
}
findings
}
fn public_api_reexported_files(
graph: &ModuleGraph,
public_api_entry_points: &FxHashSet<FileId>,
) -> FxHashSet<FileId> {
let mut result: FxHashSet<FileId> = FxHashSet::default();
let mut visited: FxHashSet<FileId> = FxHashSet::default();
let mut stack: Vec<FileId> = public_api_entry_points.iter().copied().collect();
while let Some(file_id) = stack.pop() {
if !visited.insert(file_id) {
continue;
}
let Some(module) = graph.modules.get(file_id.0 as usize) else {
continue;
};
for re in &module.re_exports {
let source = re.source_file;
result.insert(source);
stack.push(source);
}
}
result
}