use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{AngularComponentSelector, ExportName, ImportedName, ModuleInfo};
use crate::discover::FileId;
use crate::graph::{ModuleGraph, ModuleNode};
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 used = build_rendered_sfc_used_set(graph, resolved_modules, &modules_by_id);
let reexported = build_barrel_reexported_sfcs(graph);
let public_api = public_api_reexported_sfcs(graph, public_api_entry_points);
let scan = SfcScanContext {
graph,
used: &used,
reexported: &reexported,
public_api: &public_api,
public_api_entry_points,
suppressions,
};
let mut findings = Vec::new();
for module in &graph.modules {
let Some(framework) = sfc_framework(&module.path, vue, svelte) else {
continue;
};
if let Some(finding) = evaluate_unrendered_sfc(&scan, module, framework) {
findings.push(finding);
}
}
findings
}
fn build_rendered_sfc_used_set(
graph: &ModuleGraph,
resolved_modules: &[ResolvedModule],
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
) -> FxHashSet<FileId> {
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);
}
}
}
used
}
fn build_barrel_reexported_sfcs(graph: &ModuleGraph) -> FxHashMap<FileId, FileId> {
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);
}
}
}
reexported
}
struct SfcScanContext<'a> {
graph: &'a ModuleGraph,
used: &'a FxHashSet<FileId>,
reexported: &'a FxHashMap<FileId, FileId>,
public_api: &'a FxHashSet<FileId>,
public_api_entry_points: &'a FxHashSet<FileId>,
suppressions: &'a SuppressionContext<'a>,
}
fn evaluate_unrendered_sfc(
scan: &SfcScanContext<'_>,
module: &crate::graph::ModuleNode,
framework: SfcFramework,
) -> Option<UnrenderedComponent> {
if !module.is_reachable() || module.is_entry_point() {
return None;
}
if scan.used.contains(&module.file_id) {
return None;
}
let &barrel_id = scan.reexported.get(&module.file_id)?;
if scan.public_api.contains(&module.file_id)
|| scan.public_api_entry_points.contains(&module.file_id)
{
return None;
}
if scan.suppressions.is_suppressed(
module.file_id,
COMPONENT_LINE,
IssueKind::UnrenderedComponent,
) || scan
.suppressions
.is_file_suppressed(module.file_id, IssueKind::UnrenderedComponent)
{
return None;
}
let component_name = component_name(&module.path);
let reachable_via = scan
.graph
.modules
.get(barrel_id.0 as usize)
.map(|b| b.path.clone());
Some(UnrenderedComponent {
path: module.path.clone(),
component_name,
framework: framework.as_str().to_string(),
reachable_via,
line: COMPONENT_LINE,
col: 0,
})
}
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 => {
credit_all_reexported_sfcs(graph, target, 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 = false;
for re in &module.re_exports {
if re.exported_name != name {
continue;
}
if re.imported_name == "*" {
credit_all_reexported_sfcs(graph, re.source_file, used);
} else {
stack.push((re.source_file, re.imported_name.clone()));
}
matched = true;
}
if matched {
continue;
}
for re in &module.re_exports {
if re.exported_name == "*" {
stack.push((re.source_file, name.clone()));
}
}
}
}
fn credit_all_reexported_sfcs(graph: &ModuleGraph, start: FileId, used: &mut FxHashSet<FileId>) {
let mut visited: FxHashSet<FileId> = FxHashSet::default();
let mut stack: Vec<FileId> = vec![start];
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;
};
if is_sfc_extension(&module.path) {
used.insert(file_id);
}
for re in &module.re_exports {
stack.push(re.source_file);
}
}
}
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 Some(signals) = build_angular_render_signals(modules) else {
return Vec::new();
};
let public_api = public_api_reexported_files(graph, public_api_entry_points);
collect_unrendered_angular_component_findings(
graph,
&modules_by_id,
&public_api,
public_api_entry_points,
&signals,
line_offsets_by_file,
suppressions,
)
}
fn collect_unrendered_angular_component_findings(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
public_api: &FxHashSet<FileId>,
public_api_entry_points: &FxHashSet<FileId>,
signals: &AngularRenderSignals<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
suppressions: &SuppressionContext<'_>,
) -> Vec<UnrenderedComponent> {
let mut findings = Vec::new();
for node in &graph.modules {
let Some(module) =
angular_component_scan_target(node, modules_by_id, public_api, public_api_entry_points)
else {
continue;
};
emit_angular_component_findings(
node,
module,
signals,
line_offsets_by_file,
suppressions,
&mut findings,
);
}
findings
}
fn angular_component_scan_target<'a>(
node: &ModuleNode,
modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
public_api: &FxHashSet<FileId>,
public_api_entry_points: &FxHashSet<FileId>,
) -> Option<&'a ModuleInfo> {
if !node.is_reachable() {
return None;
}
let module = modules_by_id.get(&node.file_id).copied()?;
if module.angular_component_selectors.is_empty() {
return None;
}
if public_api.contains(&node.file_id) || public_api_entry_points.contains(&node.file_id) {
return None;
}
Some(module)
}
struct AngularRenderSignals<'a> {
used_selectors: FxHashSet<String>,
entry_classes: FxHashSet<&'a str>,
}
fn build_angular_render_signals(modules: &[ModuleInfo]) -> Option<AngularRenderSignals<'_>> {
let mut used_selectors: FxHashSet<String> = FxHashSet::default();
let mut entry_classes: FxHashSet<&str> = FxHashSet::default();
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());
}
if module.has_dynamic_component_render {
return None;
}
}
Some(AngularRenderSignals {
used_selectors,
entry_classes,
})
}
fn emit_angular_component_findings(
node: &ModuleNode,
module: &ModuleInfo,
signals: &AngularRenderSignals<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
suppressions: &SuppressionContext<'_>,
findings: &mut Vec<UnrenderedComponent>,
) {
let default_export_referenced = angular_default_export_referenced(node);
for component in &module.angular_component_selectors {
if angular_component_render_abstains(component, signals, default_export_referenced) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, node.file_id, component.span_start);
if angular_component_suppressed(suppressions, node.file_id, line) {
continue;
}
findings.push(build_angular_unrendered_component(
node, component, line, col,
));
}
}
fn angular_default_export_referenced(node: &ModuleNode) -> bool {
node.exports.iter().any(|export| {
matches!(export.name, ExportName::Default)
&& (!export.references.is_empty() || export.is_side_effect_used)
})
}
fn angular_component_render_abstains(
component: &AngularComponentSelector,
signals: &AngularRenderSignals<'_>,
default_export_referenced: bool,
) -> bool {
if !component.selectors.iter().all(|s| is_element_selector(s)) {
return true;
}
if component
.selectors
.iter()
.any(|s| signals.used_selectors.contains(&s.to_ascii_lowercase()))
{
return true;
}
if signals
.entry_classes
.contains(component.class_name.as_str())
{
return true;
}
default_export_referenced
}
fn angular_component_suppressed(
suppressions: &SuppressionContext<'_>,
file_id: FileId,
line: u32,
) -> bool {
suppressions.is_suppressed(file_id, line, IssueKind::UnrenderedComponent)
|| suppressions.is_file_suppressed(file_id, IssueKind::UnrenderedComponent)
}
fn build_angular_unrendered_component(
node: &ModuleNode,
component: &AngularComponentSelector,
line: u32,
col: u32,
) -> UnrenderedComponent {
UnrenderedComponent {
path: node.path.clone(),
component_name: component.class_name.clone(),
framework: "angular".to_string(),
reachable_via: None,
line,
col,
}
}
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
}