use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_extract::ANGULAR_TPL_SENTINEL;
use fallow_types::extract::ModuleInfo;
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::results::UnusedComponentOutput;
use super::{LineOffsetsMap, byte_offset_to_line_col};
#[must_use]
pub fn find_unused_component_outputs(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<UnusedComponentOutput> {
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 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_outputs.is_empty() {
continue;
}
if component_has_extends(module) {
continue;
}
if super::unused_component_input::component_spreads_this(module) {
continue;
}
let external_templates = external_template_modules(graph, &modules_by_id, node.file_id);
let template_emitted = template_emitted_outputs(module, &external_templates);
let component_name = component_name_for(&node.path);
for output in &module.angular_outputs {
if output_is_emitted(module, &output.name)
|| template_emitted.contains(output.name.as_str())
{
continue;
}
if super::unused_component_input::is_js_reserved_word(&output.name) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, node.file_id, output.span_start);
findings.push(UnusedComponentOutput {
path: node.path.clone(),
component_name: component_name.clone(),
output_name: output.name.clone(),
line,
col,
});
}
}
findings.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.output_name.cmp(&b.output_name))
});
findings
}
fn output_is_emitted(component: &ModuleInfo, name: &str) -> bool {
let emit_object = format!("this.{name}");
component.member_accesses.iter().any(|access| {
(access.object == emit_object && access.member == "emit")
|| (access.object == "this" && access.member == name)
})
}
fn template_emitted_outputs<'a>(
component: &'a ModuleInfo,
external_templates: &[&'a ModuleInfo],
) -> FxHashSet<&'a str> {
let mut emitted: FxHashSet<&str> = FxHashSet::default();
for access in &component.member_accesses {
if access.object == ANGULAR_TPL_SENTINEL {
emitted.insert(access.member.as_str());
}
}
for template in external_templates {
for access in &template.member_accesses {
if access.object == ANGULAR_TPL_SENTINEL {
emitted.insert(access.member.as_str());
}
}
}
emitted
}
fn external_template_modules<'a>(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &'a ModuleInfo>,
from: FileId,
) -> Vec<&'a ModuleInfo> {
let Some(component) = modules_by_id.get(&from) else {
return Vec::new();
};
if !component.has_angular_component_template_url {
return Vec::new();
}
let mut out = Vec::new();
for target in graph.edges_for(from) {
let Some(target_module) = modules_by_id.get(&target) else {
continue;
};
if target_module
.member_accesses
.iter()
.any(|a| a.object == ANGULAR_TPL_SENTINEL)
{
out.push(*target_module);
}
}
out
}
fn component_has_extends(module: &ModuleInfo) -> bool {
module.exports.iter().any(|e| e.super_class.is_some())
|| module
.class_heritage
.iter()
.any(|h| h.super_class.is_some())
}
fn component_name_for(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
}
#[cfg(test)]
mod tests {
use fallow_types::extract::{AngularOutputMember, ClassHeritageInfo, MemberAccess};
use rustc_hash::FxHashSet;
use super::*;
use crate::analyze::test_support::empty_module;
fn output(name: &str, span: u32) -> AngularOutputMember {
AngularOutputMember {
name: name.to_string(),
span_start: span,
}
}
fn access(object: &str, member: &str) -> MemberAccess {
MemberAccess {
object: object.to_string(),
member: member.to_string(),
}
}
#[test]
fn unemitted_output_is_not_emitted() {
let component = ModuleInfo {
angular_outputs: vec![output("changed", 10)],
..empty_module()
};
assert!(
!output_is_emitted(&component, "changed"),
"an output emitted nowhere is reported"
);
}
#[test]
fn emitted_output_is_credited() {
let component = ModuleInfo {
angular_outputs: vec![output("changed", 10)],
member_accesses: vec![access("this.changed", "emit")],
..empty_module()
};
assert!(
output_is_emitted(&component, "changed"),
"a `this.changed.emit(...)` call credits the output"
);
}
#[test]
fn inline_template_emit_credits_output() {
let component = ModuleInfo {
angular_outputs: vec![output("changed", 10)],
member_accesses: vec![access(ANGULAR_TPL_SENTINEL, "changed")],
..empty_module()
};
let emitted = template_emitted_outputs(&component, &[]);
assert!(
emitted.contains("changed"),
"an inline-template handler emit must credit the output"
);
assert!(
!output_is_emitted(&component, "changed"),
"the template sentinel is not a `this.changed.emit` script call"
);
}
#[test]
fn forwarded_output_value_read_is_credited() {
let component = ModuleInfo {
angular_outputs: vec![output("changed", 10)],
member_accesses: vec![access("this", "changed")],
..empty_module()
};
assert!(
output_is_emitted(&component, "changed"),
"a `this.changed` value read (forwarded) over-credits the output"
);
}
#[test]
fn extends_abstain_holds() {
let component = ModuleInfo {
angular_outputs: vec![output("changed", 10)],
class_heritage: vec![ClassHeritageInfo {
export_name: "Foo".to_string(),
super_class: Some("Base".to_string()),
implements: Vec::new(),
instance_bindings: Vec::new(),
}],
..empty_module()
};
assert!(
component_has_extends(&component),
"an `extends` clause abstains the whole component"
);
}
#[test]
fn dep_gate_returns_empty_without_angular_core() {
let graph = ModuleGraph::build(&[], &[], &[]);
let modules = Vec::new();
let declared: FxHashSet<String> = std::iter::once("react".to_string()).collect();
let offsets = LineOffsetsMap::default();
let findings = find_unused_component_outputs(&graph, &modules, &declared, &offsets);
assert!(
findings.is_empty(),
"no `@angular/core` dependency means no findings"
);
}
}