use std::sync::LazyLock;
use rustc_hash::FxHashSet;
use crate::template_usage::{TemplateSnippetKind, collect_unresolved_refs};
use super::scanners::{scan_curly_section, scan_html_tag};
pub const ANGULAR_TPL_SENTINEL: &str = "__angular_tpl__";
static HTML_COMMENT_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?s)<!--.*?-->").expect("valid regex"));
static ATTR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?s)([\[()*#a-zA-Z][\w.\-\[\]()]*)\s*=\s*"([^"]*)""#).expect("valid regex")
});
static NG_FOR_OF_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?s)^\s*let\s+(\w+)\s+of\s+(.+)$").expect("valid regex"));
static CONTROL_FOR_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?s)^\s*(\w+)\s+of\s+(.+)$").expect("valid regex"));
pub fn collect_angular_template_refs(source: &str) -> FxHashSet<String> {
let stripped = HTML_COMMENT_RE.replace_all(source, "");
let source = stripped.as_ref();
let bytes = source.as_bytes();
let mut refs = FxHashSet::default();
let mut scopes: Vec<Vec<String>> = vec![Vec::new()];
let mut index = 0;
while index < bytes.len() {
if index + 1 < bytes.len() && bytes[index] == b'{' && bytes[index + 1] == b'{' {
let Some((expr, next_index)) = scan_curly_section(source, index, 2, 2) else {
break;
};
collect_expression_refs(expr.trim(), ¤t_locals(&scopes), &mut refs);
index = next_index;
continue;
}
if bytes[index] == b'@'
&& let Some(next_index) = handle_control_flow(source, index, &mut scopes, &mut refs)
{
index = next_index;
continue;
}
if bytes[index] == b'}' {
if scopes.len() > 1 {
scopes.pop();
}
index += 1;
continue;
}
if bytes[index] == b'<' {
if let Some((tag, next_index)) = scan_html_tag(source, index) {
process_tag(tag, &mut scopes, &mut refs);
index = next_index;
continue;
}
index += 1;
continue;
}
index += 1;
}
refs
}
fn handle_control_flow(
source: &str,
start: usize,
scopes: &mut Vec<Vec<String>>,
refs: &mut FxHashSet<String>,
) -> Option<usize> {
let rest = &source[start + 1..];
let keyword_end = rest.find(|c: char| !c.is_ascii_alphabetic())?;
let keyword = &rest[..keyword_end];
match keyword {
"if" | "switch" | "case" => {
let after_keyword = &source[start + 1 + keyword_end..];
let paren_start = after_keyword.find('(')?;
let paren_content_start = start + 1 + keyword_end + paren_start;
let (expr, after_paren) = scan_parenthesized(source, paren_content_start)?;
let locals = current_locals(scopes);
collect_expression_refs(expr.trim(), &locals, refs);
scopes.push(Vec::new());
let brace_pos = source[after_paren..].find('{')?;
Some(after_paren + brace_pos + 1)
}
"for" => {
let after_keyword = &source[start + 1 + keyword_end..];
let paren_start = after_keyword.find('(')?;
let paren_content_start = start + 1 + keyword_end + paren_start;
let (paren_content, after_paren) = scan_parenthesized(source, paren_content_start)?;
let mut locals_for_scope = Vec::new();
let parts: Vec<&str> = paren_content.split(';').collect();
if let Some(first_part) = parts.first()
&& let Some(caps) = CONTROL_FOR_RE.captures(first_part.trim())
{
let binding = caps.get(1).map_or("", |m| m.as_str());
locals_for_scope.push(binding.to_string());
for implicit in &["$index", "$first", "$last", "$even", "$odd", "$count"] {
locals_for_scope.push((*implicit).to_string());
}
let iterable = caps.get(2).map_or("", |m| m.as_str()).trim();
let current = current_locals(scopes);
collect_expression_refs(iterable, ¤t, refs);
}
for part in parts.iter().skip(1) {
let part = part.trim();
if let Some(track_expr) = part.strip_prefix("track") {
let mut all_locals = current_locals(scopes);
all_locals.extend(locals_for_scope.clone());
collect_expression_refs(track_expr.trim(), &all_locals, refs);
}
}
scopes.push(locals_for_scope);
let brace_pos = source[after_paren..].find('{')?;
Some(after_paren + brace_pos + 1)
}
"defer" => {
let after_keyword = &source[start + 1 + keyword_end..];
let trimmed = after_keyword.trim_start();
let offset = after_keyword.len() - trimmed.len();
let abs_after_keyword = start + 1 + keyword_end + offset;
if trimmed.starts_with('(') {
let paren_content_start = abs_after_keyword;
let (paren_content, after_paren) = scan_parenthesized(source, paren_content_start)?;
let locals = current_locals(scopes);
for part in paren_content.split(';') {
let part = part.trim();
if let Some(pos) = part.find("when") {
let after_when = &part[pos + 4..];
let expr = after_when.trim();
if !expr.is_empty() {
collect_expression_refs(expr, &locals, refs);
}
}
}
scopes.push(Vec::new());
let brace_pos = source[after_paren..].find('{')?;
Some(after_paren + brace_pos + 1)
} else {
scopes.push(Vec::new());
let rest_from = start + 1 + keyword_end;
let brace_pos = source[rest_from..].find('{')?;
Some(rest_from + brace_pos + 1)
}
}
"let" => {
let after_keyword = &source[start + 1 + keyword_end..];
let trimmed = after_keyword.trim_start();
let offset = after_keyword.len() - trimmed.len();
let name_end = trimmed.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')?;
let var_name = &trimmed[..name_end];
let rest_after_name = &trimmed[name_end..];
let eq_pos = rest_after_name.find('=')?;
let expr_start = eq_pos + 1;
let expr_rest = &rest_after_name[expr_start..];
let semi_pos = expr_rest.find(';')?;
let expr = expr_rest[..semi_pos].trim();
let locals = current_locals(scopes);
collect_expression_refs(expr, &locals, refs);
if let Some(scope) = scopes.last_mut() {
scope.push(var_name.to_string());
}
let abs_semi = start + 1 + keyword_end + offset + name_end + expr_start + semi_pos + 1;
Some(abs_semi)
}
"else" => {
let rest_from = start + 1 + keyword_end;
let after_else = source[rest_from..].trim_start();
let trimmed_offset = source[rest_from..].len() - after_else.len();
if after_else.starts_with("if")
&& !after_else
.as_bytes()
.get(2)
.is_some_and(|b| b.is_ascii_alphanumeric())
{
let if_keyword_end = rest_from + trimmed_offset + 2;
let after_if = &source[if_keyword_end..];
let paren_start = after_if.find('(')?;
let paren_content_start = if_keyword_end + paren_start;
let (expr, after_paren) = scan_parenthesized(source, paren_content_start)?;
let locals = current_locals(scopes);
collect_expression_refs(expr.trim(), &locals, refs);
scopes.push(Vec::new());
let brace_pos = source[after_paren..].find('{')?;
Some(after_paren + brace_pos + 1)
} else {
scopes.push(Vec::new());
let brace_pos = source[rest_from..].find('{')?;
Some(rest_from + brace_pos + 1)
}
}
"empty" | "default" | "placeholder" | "loading" | "error" => {
scopes.push(Vec::new());
let rest_from = start + 1 + keyword_end;
let brace_pos = source[rest_from..].find('{')?;
Some(rest_from + brace_pos + 1)
}
_ => None,
}
}
fn scan_parenthesized(source: &str, start: usize) -> Option<(&str, usize)> {
let bytes = source.as_bytes();
if bytes.get(start) != Some(&b'(') {
return None;
}
let mut depth = 1u32;
let mut i = start + 1;
while i < bytes.len() && depth > 0 {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
if depth > 0 {
i += 1;
}
}
if depth == 0 {
Some((&source[start + 1..i], i + 1))
} else {
None
}
}
fn process_tag(tag: &str, scopes: &mut [Vec<String>], refs: &mut FxHashSet<String>) {
let locals = current_locals(scopes);
for caps in ATTR_RE.captures_iter(tag) {
let attr_name = caps.get(1).map_or("", |m| m.as_str());
let attr_value = caps.get(2).map_or("", |m| m.as_str()).trim();
if attr_value.is_empty() {
continue;
}
if attr_name.starts_with('[') && !attr_name.starts_with("[(") {
collect_expression_refs(attr_value, &locals, refs);
continue;
}
if attr_name.starts_with('(') {
collect_statement_refs(attr_value, &locals, refs);
continue;
}
if attr_name.starts_with("[(") {
collect_expression_refs(attr_value, &locals, refs);
continue;
}
if attr_name == "*ngIf" || attr_name == "*ngShow" || attr_name == "*ngSwitch" {
let expr = attr_value.split(';').next().unwrap_or(attr_value).trim();
collect_expression_refs(expr, &locals, refs);
continue;
}
if attr_name == "*ngFor" {
handle_ng_for(attr_value, &locals, scopes, refs);
continue;
}
if attr_name.starts_with('*') {
collect_expression_refs(attr_value, &locals, refs);
continue;
}
if attr_name.starts_with("bind-") {
collect_expression_refs(attr_value, &locals, refs);
continue;
}
if attr_name.starts_with("on-") {
collect_statement_refs(attr_value, &locals, refs);
}
}
}
fn handle_ng_for(
value: &str,
locals: &[String],
scopes: &mut [Vec<String>],
refs: &mut FxHashSet<String>,
) {
let clauses: Vec<&str> = value.split(';').collect();
let mut ng_for_locals = locals.to_vec();
let mut new_scope_locals = Vec::new();
for clause in &clauses {
let clause = clause.trim();
if let Some(caps) = NG_FOR_OF_RE.captures(clause) {
let binding = caps.get(1).map_or("", |m| m.as_str());
ng_for_locals.push(binding.to_string());
new_scope_locals.push(binding.to_string());
let iterable = caps.get(2).map_or("", |m| m.as_str()).trim();
collect_expression_refs(iterable, &ng_for_locals, refs);
continue;
}
if let Some(rest) = clause.strip_prefix("let ") {
if let Some(eq_pos) = rest.find('=') {
let name = rest[..eq_pos].trim();
ng_for_locals.push(name.to_string());
new_scope_locals.push(name.to_string());
}
continue;
}
if let Some(rest) = clause.strip_prefix("trackBy:") {
collect_expression_refs(rest.trim(), &ng_for_locals, refs);
}
}
if let Some(scope) = scopes.last_mut() {
scope.extend(new_scope_locals);
}
}
fn collect_expression_refs(expr: &str, locals: &[String], refs: &mut FxHashSet<String>) {
if expr.is_empty() {
return;
}
let (main_expr, pipe_names) = split_pipes(expr);
let unresolved = collect_unresolved_refs(main_expr, TemplateSnippetKind::Expression, locals);
refs.extend(unresolved);
for pipe_name in pipe_names {
if !pipe_name.is_empty() {
refs.insert(pipe_name.to_string());
}
}
}
fn collect_statement_refs(stmt: &str, locals: &[String], refs: &mut FxHashSet<String>) {
if stmt.is_empty() {
return;
}
let unresolved = collect_unresolved_refs(stmt, TemplateSnippetKind::Statement, locals);
refs.extend(unresolved);
}
fn split_pipes(expr: &str) -> (&str, Vec<&str>) {
let bytes = expr.as_bytes();
let mut pipe_positions = Vec::new();
let mut i = 0;
let mut depth = 0u32; let mut in_string: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(quote) = in_string {
if b == b'\\' {
i += 2; continue;
}
if b == quote {
in_string = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' | b'`' => in_string = Some(b),
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth = depth.saturating_sub(1),
b'|' if depth == 0 => {
let prev_is_pipe = i > 0 && bytes[i - 1] == b'|';
let next_is_pipe = i + 1 < bytes.len() && bytes[i + 1] == b'|';
if !prev_is_pipe && !next_is_pipe {
pipe_positions.push(i);
}
}
_ => {}
}
i += 1;
}
if pipe_positions.is_empty() {
return (expr, Vec::new());
}
let main_expr = expr[..pipe_positions[0]].trim();
let mut pipes = Vec::new();
for (j, &pos) in pipe_positions.iter().enumerate() {
let end = pipe_positions.get(j + 1).copied().unwrap_or(expr.len());
let pipe_part = expr[pos + 1..end].trim();
let name = pipe_part.split(':').next().unwrap_or("").trim();
if !name.is_empty() {
pipes.push(name);
}
}
(main_expr, pipes)
}
fn current_locals(scopes: &[Vec<String>]) -> Vec<String> {
scopes.iter().flat_map(|s| s.iter().cloned()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interpolation_extracts_refs() {
let refs = collect_angular_template_refs("<p>{{ title() }}</p>");
assert!(refs.contains("title"));
}
#[test]
fn property_binding_extracts_refs() {
let refs =
collect_angular_template_refs(r#"<p [class.highlighted]="isHighlighted">text</p>"#);
assert!(refs.contains("isHighlighted"));
}
#[test]
fn event_binding_extracts_refs() {
let refs =
collect_angular_template_refs(r#"<button (click)="onButtonClick()">Click</button>"#);
assert!(refs.contains("onButtonClick"));
}
#[test]
fn two_way_binding_extracts_refs() {
let refs = collect_angular_template_refs(r#"<input [(ngModel)]="userName">"#);
assert!(refs.contains("userName"));
}
#[test]
fn ng_if_extracts_refs() {
let refs = collect_angular_template_refs(r#"<div *ngIf="isLoading()">Loading</div>"#);
assert!(refs.contains("isLoading"));
}
#[test]
fn ng_for_extracts_iterable_not_binding() {
let refs = collect_angular_template_refs(
r#"<li *ngFor="let item of items; trackBy: trackByFn">{{ item }}</li>"#,
);
assert!(refs.contains("items"), "should contain iterable 'items'");
assert!(
refs.contains("trackByFn"),
"should contain trackBy function"
);
assert!(!refs.contains("item"), "binding 'item' should be a local");
}
#[test]
fn control_flow_if_extracts_refs() {
let refs = collect_angular_template_refs(r"@if (isLoading()) { <div>Loading</div> }");
assert!(refs.contains("isLoading"));
}
#[test]
fn control_flow_else_if_extracts_refs() {
let refs = collect_angular_template_refs(
r"@if (condA) { <p>A</p> } @else if (condB) { <p>B</p> } @else { <p>C</p> }",
);
assert!(refs.contains("condA"), "should contain @if condition");
assert!(refs.contains("condB"), "should contain @else if condition");
}
#[test]
fn control_flow_chained_else_if_extracts_refs() {
let refs = collect_angular_template_refs(
r"@if (a) { <p>{{ x }}</p> } @else if (b) { <p>{{ y }}</p> } @else if (c) { <p>{{ z }}</p> }",
);
assert!(refs.contains("a"));
assert!(refs.contains("b"));
assert!(refs.contains("c"));
assert!(refs.contains("x"));
assert!(refs.contains("y"));
assert!(refs.contains("z"));
}
#[test]
fn control_flow_for_extracts_refs() {
let refs = collect_angular_template_refs(
r"@for (item of items; track item.id) { <li>{{ item.name }}</li> }",
);
assert!(refs.contains("items"), "should contain iterable");
assert!(!refs.contains("item"), "binding should be a local");
}
#[test]
fn control_flow_switch_extracts_refs() {
let refs = collect_angular_template_refs(
r#"@switch (status) { @case ("active") { <span>Active</span> } }"#,
);
assert!(refs.contains("status"));
}
#[test]
fn pipe_extracts_name() {
let refs = collect_angular_template_refs("<p>{{ birthday | date:'short' }}</p>");
assert!(refs.contains("birthday"));
assert!(refs.contains("date"));
}
#[test]
fn logical_or_not_confused_with_pipe() {
let refs = collect_angular_template_refs("<p>{{ a || b }}</p>");
assert!(refs.contains("a"));
assert!(refs.contains("b"));
}
#[test]
fn html_comments_stripped() {
let refs = collect_angular_template_refs("<!-- {{ hidden }} -->\n<p>{{ visible }}</p>");
assert!(refs.contains("visible"));
assert!(!refs.contains("hidden"));
}
#[test]
fn empty_template_returns_empty() {
let refs = collect_angular_template_refs("");
assert!(refs.is_empty());
}
#[test]
fn full_angular_template() {
let refs = collect_angular_template_refs(
r#"<h1>{{ title() }}</h1>
<p [class.highlighted]="isHighlighted">{{ greeting() }}</p>
@if (isLoading()) { <div>Loading...</div> }
<button (click)="onButtonClick()">Toggle</button>
<button (click)="addItem()">Add</button>
@for (item of items; track item) { <li>{{ item }}</li> }"#,
);
assert!(refs.contains("title"));
assert!(refs.contains("isHighlighted"));
assert!(refs.contains("greeting"));
assert!(refs.contains("isLoading"));
assert!(refs.contains("onButtonClick"));
assert!(refs.contains("addItem"));
assert!(refs.contains("items"));
assert!(!refs.contains("item"));
}
#[test]
fn bare_less_than_in_text_does_not_abort_scanner() {
let refs = collect_angular_template_refs("count < 10\n<p>{{ title() }}</p>");
assert!(refs.contains("title"), "refs after bare < should be found");
}
#[test]
fn control_flow_with_object_literal_in_expression() {
let refs =
collect_angular_template_refs(r"@if (config.enabled) { <span>{{ label }}</span> }");
assert!(refs.contains("config"));
assert!(refs.contains("label"));
}
#[test]
fn defer_when_extracts_refs() {
let refs = collect_angular_template_refs(
r"@defer (when isDataReady) { <app-heavy /> } @placeholder { <p>Wait</p> }",
);
assert!(refs.contains("isDataReady"));
}
#[test]
fn defer_on_and_when_extracts_refs() {
let refs =
collect_angular_template_refs(r"@defer (on viewport; when isReady) { <app-heavy /> }");
assert!(refs.contains("isReady"));
}
#[test]
fn defer_on_timer_with_nested_parens() {
let refs = collect_angular_template_refs(
r"@defer (on timer(1s); when isReady) { <app-heavy /> } @placeholder { <p>{{ label }}</p> }",
);
assert!(
refs.contains("isReady"),
"when condition through nested parens"
);
assert!(refs.contains("label"), "content after defer block");
}
#[test]
fn defer_prefetch_when_extracts_refs() {
let refs = collect_angular_template_refs(
r"@defer (prefetch when shouldPrefetch) { <app-heavy /> }",
);
assert!(refs.contains("shouldPrefetch"));
}
#[test]
fn defer_without_condition() {
let refs = collect_angular_template_refs(
r"@defer { <app-heavy /> } @placeholder { <p>{{ label }}</p> }",
);
assert!(refs.contains("label"));
}
#[test]
fn let_extracts_expression_refs() {
let refs = collect_angular_template_refs(
r"@let fullName = firstName + ' ' + lastName;
<p>{{ fullName }}</p>",
);
assert!(refs.contains("firstName"));
assert!(refs.contains("lastName"));
assert!(!refs.contains("fullName"));
}
#[test]
fn let_simple_alias() {
let refs = collect_angular_template_refs(
r"@let name = user.name;
<p>{{ name }}</p>",
);
assert!(refs.contains("user"));
assert!(!refs.contains("name"));
}
#[test]
fn let_with_pipe() {
let refs = collect_angular_template_refs(
r"@let formatted = rawDate | date;
<span>{{ formatted }}</span>",
);
assert!(refs.contains("rawDate"));
assert!(refs.contains("date"));
assert!(!refs.contains("formatted"));
}
#[test]
fn split_pipes_no_pipe() {
let (expr, pipes) = split_pipes("foo.bar");
assert_eq!(expr, "foo.bar");
assert!(pipes.is_empty());
}
#[test]
fn split_pipes_single_pipe() {
let (expr, pipes) = split_pipes("value | date");
assert_eq!(expr, "value");
assert_eq!(pipes, vec!["date"]);
}
#[test]
fn split_pipes_with_args() {
let (expr, pipes) = split_pipes("value | date:'short'");
assert_eq!(expr, "value");
assert_eq!(pipes, vec!["date"]);
}
#[test]
fn split_pipes_multiple() {
let (expr, pipes) = split_pipes("value | date:'short' | uppercase");
assert_eq!(expr, "value");
assert_eq!(pipes, vec!["date", "uppercase"]);
}
#[test]
fn split_pipes_preserves_logical_or() {
let (expr, pipes) = split_pipes("a || b");
assert_eq!(expr, "a || b");
assert!(pipes.is_empty());
}
#[test]
fn split_pipes_inside_parens_not_split() {
let (expr, pipes) = split_pipes("fn(a | b)");
assert_eq!(expr, "fn(a | b)");
assert!(pipes.is_empty());
}
}