use std::ops::Range;
use rustc_hash::FxHashSet;
use crate::MemberAccess;
use crate::template_usage::TemplateUsage;
const BUILTIN_KEYWORDS: &[&str] = &[
"if",
"unless",
"else",
"each",
"each-in",
"let",
"with",
"in-element",
"key",
"yield",
"outlet",
"component",
"helper",
"modifier",
"mount",
"unbound",
"link-to",
"LinkTo",
"debugger",
"log",
"true",
"false",
"null",
"undefined",
"this",
];
#[must_use]
pub fn collect_glimmer_template_usage(
source: &str,
template_ranges: &[Range<usize>],
imported_bindings: &FxHashSet<String>,
) -> TemplateUsage {
let mut usage = TemplateUsage::default();
if template_ranges.is_empty() {
return usage;
}
for range in template_ranges {
let Some(body) = template_body(source, range.clone()) else {
continue;
};
let locals = extract_block_params(body);
scan_tags(body, imported_bindings, &locals, &mut usage);
scan_mustaches(body, imported_bindings, &locals, &mut usage);
}
usage
}
fn template_body(source: &str, range: Range<usize>) -> Option<&str> {
let slice = source.get(range)?;
let body_start = slice.find('>').map(|i| i + 1)?;
let body_end = slice.rfind("</template>").unwrap_or(slice.len());
if body_end <= body_start {
return None;
}
slice.get(body_start..body_end)
}
fn extract_block_params(body: &str) -> Vec<String> {
let mut locals = Vec::new();
let bytes = body.as_bytes();
let mut cursor = 0;
while let Some(relative) = body[cursor..].find("as ") {
let absolute = cursor + relative;
let after_as = absolute + "as ".len();
let Some(open_pipe_rel) = body[after_as..].find('|') else {
break;
};
let open_pipe = after_as + open_pipe_rel;
if body[after_as..open_pipe]
.bytes()
.any(|b| !b.is_ascii_whitespace())
{
cursor = open_pipe + 1;
continue;
}
let Some(close_pipe_rel) = body[open_pipe + 1..].find('|') else {
break;
};
let close_pipe = open_pipe + 1 + close_pipe_rel;
let inner = &body[open_pipe + 1..close_pipe];
for token in inner.split_whitespace() {
if is_plain_identifier(token) {
locals.push(token.to_string());
}
}
cursor = close_pipe + 1;
if cursor >= bytes.len() {
break;
}
}
locals
}
fn scan_tags(
body: &str,
imported_bindings: &FxHashSet<String>,
locals: &[String],
usage: &mut TemplateUsage,
) {
let bytes = body.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'<' {
index += 1;
continue;
}
if bytes[index..].starts_with(b"<!--") {
let after_open = index + b"<!--".len();
index = body[after_open..]
.find("-->")
.map_or(bytes.len(), |rel| after_open + rel + b"-->".len());
continue;
}
let next = bytes.get(index + 1).copied();
if matches!(next, Some(b'/' | b'!' | b'?')) {
index += 1;
continue;
}
let Some(first) = next else { break };
if !first.is_ascii_uppercase() {
index += 1;
continue;
}
let name_start = index + 1;
let mut end = name_start;
while end < bytes.len() {
let byte = bytes[end];
if !(byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$') {
break;
}
end += 1;
}
if end > name_start {
credit_binding(&body[name_start..end], imported_bindings, locals, usage);
}
index = end;
}
}
fn scan_mustaches(
body: &str,
imported_bindings: &FxHashSet<String>,
locals: &[String],
usage: &mut TemplateUsage,
) {
let bytes = body.as_bytes();
let mut index = 0;
while index + 1 < bytes.len() {
if bytes[index] != b'{' || bytes[index + 1] != b'{' {
index += 1;
continue;
}
let triple_stash = matches!(bytes.get(index + 2), Some(b'{'));
let after_open = index + if triple_stash { 3 } else { 2 };
let close_token = if triple_stash { "}}}" } else { "}}" };
let comment_form = !triple_stash && matches!(bytes.get(after_open), Some(b'!'));
let Some(close_rel) = body[after_open..].find(close_token) else {
break;
};
let close = after_open + close_rel;
if comment_form {
index = close + close_token.len();
continue;
}
let inner = &body[after_open..close];
scan_mustache_inner(inner, imported_bindings, locals, usage);
index = close + close_token.len();
}
}
fn scan_mustache_inner(
inner: &str,
imported_bindings: &FxHashSet<String>,
locals: &[String],
usage: &mut TemplateUsage,
) {
let inner = inner.trim_matches(|c: char| c.is_whitespace() || c == '~');
if inner.is_empty() {
return;
}
let inner = inner
.strip_prefix('#')
.or_else(|| inner.strip_prefix('/'))
.or_else(|| inner.strip_prefix('^'))
.unwrap_or(inner);
for token in TokenSplitter::new(inner) {
credit_token(token, imported_bindings, locals, usage);
}
}
fn credit_token(
token: &str,
imported_bindings: &FxHashSet<String>,
locals: &[String],
usage: &mut TemplateUsage,
) {
let token = token.trim();
if token.is_empty() {
return;
}
if BUILTIN_KEYWORDS.contains(&token) {
return;
}
if let Some(stripped) = token.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
scan_mustache_inner(stripped, imported_bindings, locals, usage);
return;
}
if let Some((_key, value)) = token.split_once('=')
&& !value.is_empty()
{
credit_token(value, imported_bindings, locals, usage);
return;
}
if is_literal(token) {
return;
}
if token.starts_with('@') || token == "this" {
return;
}
if token.strip_prefix("this.").is_some() {
emit_chain_member_accesses(token, usage);
return;
}
if let Some((head, _rest)) = token.split_once('.')
&& is_plain_identifier(head)
{
if BUILTIN_KEYWORDS.contains(&head) {
return;
}
credit_binding(head, imported_bindings, locals, usage);
emit_chain_member_accesses(token, usage);
return;
}
if is_plain_identifier(token) {
credit_binding(token, imported_bindings, locals, usage);
}
}
struct TokenSplitter<'a> {
bytes: &'a [u8],
source: &'a str,
index: usize,
}
impl<'a> TokenSplitter<'a> {
fn new(source: &'a str) -> Self {
Self {
bytes: source.as_bytes(),
source,
index: 0,
}
}
}
impl<'a> Iterator for TokenSplitter<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
while self.index < self.bytes.len() && self.bytes[self.index].is_ascii_whitespace() {
self.index += 1;
}
if self.index >= self.bytes.len() {
return None;
}
let start = self.index;
let mut paren_depth: u32 = 0;
let mut in_quote: Option<u8> = None;
let mut escape = false;
while self.index < self.bytes.len() {
let byte = self.bytes[self.index];
if let Some(quote) = in_quote {
if escape {
escape = false;
} else if byte == b'\\' {
escape = true;
} else if byte == quote {
in_quote = None;
}
self.index += 1;
continue;
}
match byte {
b'(' => paren_depth += 1,
b')' => paren_depth = paren_depth.saturating_sub(1),
b'"' | b'\'' | b'`' => in_quote = Some(byte),
b if b.is_ascii_whitespace() && paren_depth == 0 => break,
_ => {}
}
self.index += 1;
}
Some(&self.source[start..self.index])
}
}
fn is_plain_identifier(token: &str) -> bool {
let mut chars = token.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_' || first == '$') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn is_literal(token: &str) -> bool {
let first = token.as_bytes().first().copied();
matches!(
first,
Some(b'"' | b'\'' | b'`' | b'0'..=b'9' | b'-' | b'+' | b'.')
)
}
fn credit_binding(
name: &str,
imported_bindings: &FxHashSet<String>,
locals: &[String],
usage: &mut TemplateUsage,
) {
if name.is_empty()
|| locals.iter().any(|local| local == name)
|| !imported_bindings.contains(name)
{
return;
}
usage.used_bindings.insert(name.to_string());
}
fn emit_chain_member_accesses(token: &str, usage: &mut TemplateUsage) {
let mut segments = token.split('.');
let Some(first) = segments.next() else {
return;
};
let mut object_end = first.len();
for member in segments {
if !is_plain_identifier(member) {
return;
}
push_member_access(usage, &token[..object_end], member);
object_end += 1 + member.len();
}
}
fn push_member_access(usage: &mut TemplateUsage, object: &str, member: &str) {
let already_present = usage
.member_accesses
.iter()
.any(|existing| existing.object == object && existing.member == member);
if already_present {
return;
}
usage.member_accesses.push(MemberAccess {
object: object.to_string(),
member: member.to_string(),
});
}
#[cfg(test)]
mod tests {
use super::*;
fn imported(names: &[&str]) -> FxHashSet<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
fn usage_for(source: &str, names: &[&str]) -> TemplateUsage {
let ranges = crate::glimmer::find_template_ranges(source);
collect_glimmer_template_usage(source, &ranges, &imported(names))
}
#[test]
fn pascal_case_tag_credits_binding() {
let usage = usage_for(
"<template><HelloWorld @name=\"x\" /></template>",
&["HelloWorld"],
);
assert!(usage.used_bindings.contains("HelloWorld"));
}
#[test]
fn namespaced_tag_credits_only_head_segment() {
let usage = usage_for("<template><Forms::Input /></template>", &["Forms"]);
assert!(usage.used_bindings.contains("Forms"));
assert!(
usage.member_accesses.is_empty(),
"member-style splits are intentionally not tracked",
);
}
#[test]
fn member_style_tag_credits_only_head_segment() {
let usage = usage_for("<template><Buttons.Primary /></template>", &["Buttons"]);
assert!(usage.used_bindings.contains("Buttons"));
assert!(usage.member_accesses.is_empty());
}
#[test]
fn mustache_identifier_credits_binding() {
let usage = usage_for("<template>{{capitalize name}}</template>", &["capitalize"]);
assert!(usage.used_bindings.contains("capitalize"));
}
#[test]
fn triple_stash_helper_credits_binding() {
let usage = usage_for(
"<template>{{{formatHtml body}}}</template>",
&["formatHtml"],
);
assert!(usage.used_bindings.contains("formatHtml"));
}
#[test]
fn sub_expression_helper_credits_binding() {
let usage = usage_for(
"<template>{{if (and a b) \"yes\" \"no\"}}</template>",
&["and"],
);
assert!(usage.used_bindings.contains("and"));
}
#[test]
fn modifier_invocation_credits_binding() {
let usage = usage_for(
"<template><button {{on \"click\" handleClick}} /></template>",
&["on"],
);
assert!(usage.used_bindings.contains("on"));
}
#[test]
fn this_and_arg_references_are_not_credited_as_imports() {
let usage = usage_for(
"<template>{{this.name}} {{@arg}}</template>",
&["name", "arg"],
);
assert!(usage.used_bindings.is_empty());
}
#[test]
fn this_dot_member_emits_member_access() {
let usage = usage_for(
"<template>{{this.handleClick}} {{this.tab}}</template>",
&[],
);
assert!(usage.used_bindings.is_empty());
let access_keys: Vec<(&str, &str)> = usage
.member_accesses
.iter()
.map(|a| (a.object.as_str(), a.member.as_str()))
.collect();
assert!(access_keys.contains(&("this", "handleClick")));
assert!(access_keys.contains(&("this", "tab")));
}
#[test]
fn this_dot_chained_emits_one_member_access_per_hop() {
let usage = usage_for("<template>{{this.foo.bar.baz}}</template>", &[]);
let access_keys: Vec<(&str, &str)> = usage
.member_accesses
.iter()
.map(|a| (a.object.as_str(), a.member.as_str()))
.collect();
assert!(access_keys.contains(&("this", "foo")));
assert!(access_keys.contains(&("this.foo", "bar")));
assert!(access_keys.contains(&("this.foo.bar", "baz")));
}
#[test]
fn block_params_shadow_imports_template_wide() {
let usage = usage_for(
"<template>{{#each items as |item|}}{{item}}{{/each}}</template>",
&["item", "items"],
);
assert!(usage.used_bindings.contains("items"));
assert!(!usage.used_bindings.contains("item"));
}
#[test]
fn dotted_namespace_credits_binding_and_member() {
let usage = usage_for(
"<template>{{utils.formatDate value}}</template>",
&["utils"],
);
assert!(usage.used_bindings.contains("utils"));
assert_eq!(usage.member_accesses.len(), 1);
assert_eq!(usage.member_accesses[0].object, "utils");
assert_eq!(usage.member_accesses[0].member, "formatDate");
}
#[test]
fn deep_dotted_namespace_emits_chain_member_accesses() {
let usage = usage_for(
"<template>{{utils.formatters.date value}}</template>",
&["utils"],
);
assert!(usage.used_bindings.contains("utils"));
let access_keys: Vec<(&str, &str)> = usage
.member_accesses
.iter()
.map(|a| (a.object.as_str(), a.member.as_str()))
.collect();
assert!(access_keys.contains(&("utils", "formatters")));
assert!(access_keys.contains(&("utils.formatters", "date")));
}
#[test]
fn multiple_template_blocks_all_contribute() {
let usage = usage_for(
"<template><Foo /></template>\nconst x = 1;\n<template>{{bar}}</template>",
&["Foo", "bar"],
);
assert!(usage.used_bindings.contains("Foo"));
assert!(usage.used_bindings.contains("bar"));
}
#[test]
fn malformed_template_produces_empty_usage() {
let usage = usage_for("<template>{{ unclosed", &["unclosed"]);
assert!(usage.used_bindings.is_empty());
}
#[test]
fn html_comment_in_template_does_not_credit_inner_tag() {
let usage = usage_for(
"<template><!-- <Foo /> --><Bar /></template>",
&["Foo", "Bar"],
);
assert!(!usage.used_bindings.contains("Foo"));
assert!(usage.used_bindings.contains("Bar"));
}
#[test]
fn handlebars_comment_is_skipped() {
let usage = usage_for(
"<template>{{!-- {{capitalize x}} --}}</template>",
&["capitalize"],
);
assert!(usage.used_bindings.is_empty());
}
#[test]
fn builtin_keywords_are_not_credited() {
let usage = usage_for("<template>{{if condition \"a\" \"b\"}}</template>", &["if"]);
assert!(usage.used_bindings.is_empty());
}
#[test]
fn strict_mode_helper_imports_are_credited() {
for name in ["hash", "array", "concat", "fn", "mut", "get"] {
let template = format!("<template>{{{{{name} a=1}}}}</template>");
let usage = usage_for(&template, &[name]);
assert!(
usage.used_bindings.contains(name),
"expected strict-mode helper `{name}` to be credited",
);
}
}
#[test]
fn strict_mode_input_textarea_tag_imports_are_credited() {
let usage = usage_for(
"<template><form><Input /><Textarea /></form></template>",
&["Input", "Textarea"],
);
assert!(usage.used_bindings.contains("Input"));
assert!(usage.used_bindings.contains("Textarea"));
}
#[test]
fn lowercase_dom_tags_are_never_credited() {
let usage = usage_for(
"<template><input /><textarea /></template>",
&["input", "textarea"],
);
assert!(usage.used_bindings.is_empty());
}
#[test]
fn named_arg_value_is_credited_not_key() {
let usage = usage_for(
"<template>{{my-helper key=binding}}</template>",
&["my-helper", "binding", "key"],
);
assert!(usage.used_bindings.contains("binding"));
assert!(!usage.used_bindings.contains("key"));
}
#[test]
fn pascal_case_block_tag_with_closing_does_not_double_count() {
let usage = usage_for("<template><MyMenu>inner</MyMenu></template>", &["MyMenu"]);
assert!(usage.used_bindings.contains("MyMenu"));
}
#[test]
fn render_template_expression_form_credits_bindings() {
let usage = usage_for(
"import { module, test } from 'qunit';\n\
import { render } from '@ember/test-helpers';\n\
import HelloWorld from './hello-world';\n\
module('it', function (hooks) {\n \
test('renders', async function (assert) {\n \
await render(<template><HelloWorld @name=\"x\" /></template>);\n \
assert.ok(true);\n \
});\n\
});\n",
&["HelloWorld", "render", "module", "test"],
);
assert!(
usage.used_bindings.contains("HelloWorld"),
"HelloWorld inside an inline `render(<template>...</template>)` \
expression must be credited; used_bindings = {:?}",
usage.used_bindings,
);
}
#[test]
fn empty_imports_short_circuits() {
let usage = usage_for("<template>{{foo}} <Bar /></template>", &[]);
assert!(usage.used_bindings.is_empty());
assert!(usage.member_accesses.is_empty());
}
}