use oxc_ast::ast::{
Argument, ArrayExpressionElement, BinaryExpression, Class, ClassElement, Expression,
ObjectPropertyKind, Statement,
};
use crate::{MemberInfo, MemberKind};
pub struct AngularComponentMetadata {
pub template_url: Option<String>,
pub style_urls: Vec<String>,
pub inline_template: Option<String>,
pub host_member_refs: Vec<String>,
pub input_output_members: Vec<String>,
}
const ANGULAR_SIGNAL_APIS: &[&str] = &[
"input",
"output",
"outputFromObservable",
"model",
"viewChild",
"viewChildren",
"contentChild",
"contentChildren",
];
pub fn extract_angular_component_metadata(class: &Class<'_>) -> Option<AngularComponentMetadata> {
for decorator in &class.decorators {
let Expression::CallExpression(call) = &decorator.expression else {
continue;
};
let Expression::Identifier(id) = &call.callee else {
continue;
};
if !matches!(id.name.as_str(), "Component" | "Directive") {
continue;
}
let Some(Argument::ObjectExpression(obj)) = call.arguments.first() else {
continue;
};
let mut template_url = None;
let mut style_urls = Vec::new();
let mut inline_template = None;
let mut host_member_refs = Vec::new();
let mut input_output_members = Vec::new();
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(p) = prop else {
continue;
};
let Some(key_name) = p.key.static_name() else {
continue;
};
match key_name.as_ref() {
"templateUrl" => {
if let Expression::StringLiteral(lit) = &p.value {
template_url = Some(lit.value.to_string());
}
}
"template" => {
if let Expression::StringLiteral(lit) = &p.value {
inline_template = Some(lit.value.to_string());
} else if let Expression::TemplateLiteral(tpl) = &p.value
&& tpl.expressions.is_empty()
&& let Some(quasi) = tpl.quasis.first()
{
inline_template = Some(quasi.value.raw.to_string());
}
}
"styleUrl" => {
if let Expression::StringLiteral(lit) = &p.value {
style_urls.push(lit.value.to_string());
}
}
"styleUrls" => {
if let Expression::ArrayExpression(arr) = &p.value {
for elem in &arr.elements {
if let ArrayExpressionElement::StringLiteral(lit) = elem {
style_urls.push(lit.value.to_string());
}
}
}
}
"host" => {
if let Expression::ObjectExpression(host_obj) = &p.value {
extract_host_member_refs(host_obj, &mut host_member_refs);
}
}
"inputs" | "outputs" => {
extract_input_output_members(&p.value, &mut input_output_members);
}
"queries" => {
extract_query_members(&p.value, &mut input_output_members);
}
_ => {}
}
}
let has_data = template_url.is_some()
|| !style_urls.is_empty()
|| inline_template.is_some()
|| !host_member_refs.is_empty()
|| !input_output_members.is_empty();
if has_data {
return Some(AngularComponentMetadata {
template_url,
style_urls,
inline_template,
host_member_refs,
input_output_members,
});
}
}
None
}
fn extract_host_member_refs(host_obj: &oxc_ast::ast::ObjectExpression<'_>, refs: &mut Vec<String>) {
for prop in &host_obj.properties {
let ObjectPropertyKind::ObjectProperty(p) = prop else {
continue;
};
if let Expression::StringLiteral(lit) = &p.value {
extract_identifiers_from_host_expr(&lit.value, refs);
}
}
}
fn extract_query_members(value: &Expression<'_>, members: &mut Vec<String>) {
let Expression::ObjectExpression(obj) = value else {
return;
};
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(p) = prop else {
continue;
};
if let Some(name) = p.key.static_name() {
let name = name.to_string();
if !name.is_empty() {
members.push(name);
}
}
}
}
fn extract_input_output_members(value: &Expression<'_>, members: &mut Vec<String>) {
let Expression::ArrayExpression(arr) = value else {
return;
};
for elem in &arr.elements {
let ArrayExpressionElement::StringLiteral(lit) = elem else {
continue;
};
let member = lit
.value
.as_ref()
.split(':')
.next()
.unwrap_or_default()
.trim();
if !member.is_empty() {
members.push(member.to_string());
}
}
}
fn extract_identifiers_from_host_expr(expr: &str, refs: &mut Vec<String>) {
let expr = expr.trim();
if expr.is_empty() {
return;
}
let ident: String = expr
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$')
.collect();
if !is_valid_member_identifier(&ident) || refs.contains(&ident) {
return;
}
refs.push(ident);
}
fn is_valid_member_identifier(ident: &str) -> bool {
!ident.is_empty()
&& ident
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
&& !matches!(
ident,
"true"
| "false"
| "null"
| "undefined"
| "this"
| "event"
| "window"
| "document"
| "console"
| "Math"
| "JSON"
| "Object"
| "Array"
| "String"
| "Number"
| "Boolean"
| "Date"
| "RegExp"
| "Error"
| "Promise"
)
}
pub fn has_angular_class_decorator(class: &Class<'_>) -> bool {
class.decorators.iter().any(|d| {
if let Expression::CallExpression(call) = &d.expression
&& let Expression::Identifier(id) = &call.callee
{
matches!(
id.name.as_str(),
"Component" | "Directive" | "Injectable" | "Pipe"
)
} else {
false
}
})
}
fn is_angular_signal_initializer(value: &Expression<'_>) -> bool {
let Expression::CallExpression(call) = value else {
return false;
};
match &call.callee {
Expression::Identifier(id) => ANGULAR_SIGNAL_APIS.contains(&id.name.as_str()),
Expression::StaticMemberExpression(member) => {
if let Expression::Identifier(obj) = &member.object {
ANGULAR_SIGNAL_APIS.contains(&obj.name.as_str())
&& member.property.name == "required"
} else {
false
}
}
_ => false,
}
}
pub fn extract_class_members(class: &Class<'_>, is_angular_class: bool) -> Vec<MemberInfo> {
let mut members = Vec::new();
for element in &class.body.body {
match element {
ClassElement::MethodDefinition(method) => {
if let Some(name) = method.key.static_name() {
let name_str = name.to_string();
if name_str != "constructor"
&& !matches!(
method.accessibility,
Some(
oxc_ast::ast::TSAccessibility::Private
| oxc_ast::ast::TSAccessibility::Protected
)
)
{
members.push(MemberInfo {
name: name_str,
kind: MemberKind::ClassMethod,
span: method.span,
has_decorator: !method.decorators.is_empty(),
});
}
}
}
ClassElement::PropertyDefinition(prop) => {
if let Some(name) = prop.key.static_name()
&& !matches!(
prop.accessibility,
Some(
oxc_ast::ast::TSAccessibility::Private
| oxc_ast::ast::TSAccessibility::Protected
)
)
{
let has_decorator = !prop.decorators.is_empty()
|| (is_angular_class
&& prop
.value
.as_ref()
.is_some_and(is_angular_signal_initializer));
members.push(MemberInfo {
name: name.to_string(),
kind: MemberKind::ClassProperty,
span: prop.span,
has_decorator,
});
}
}
_ => {}
}
}
members
}
pub fn extract_super_class_name(class: &Class<'_>) -> Option<String> {
match class.super_class.as_ref()? {
Expression::Identifier(ident) => Some(ident.name.to_string()),
_ => None,
}
}
pub(super) fn is_meta_url_arg(arg: &Argument<'_>) -> bool {
if let Argument::StaticMemberExpression(member) = arg
&& member.property.name == "url"
&& matches!(member.object, Expression::MetaProperty(_))
{
return true;
}
false
}
pub(super) fn extract_concat_parts(
expr: &BinaryExpression<'_>,
) -> Option<(String, Option<String>)> {
let prefix = extract_leading_string(&expr.left)?;
let suffix = extract_trailing_string(&expr.right);
Some((prefix, suffix))
}
fn extract_leading_string(expr: &Expression<'_>) -> Option<String> {
match expr {
Expression::StringLiteral(lit) => Some(lit.value.to_string()),
Expression::BinaryExpression(bin)
if bin.operator == oxc_ast::ast::BinaryOperator::Addition =>
{
extract_leading_string(&bin.left)
}
_ => None,
}
}
fn extract_trailing_string(expr: &Expression<'_>) -> Option<String> {
match expr {
Expression::StringLiteral(lit) => {
let s = lit.value.to_string();
if s.is_empty() { None } else { Some(s) }
}
_ => None,
}
}
pub(super) fn regex_pattern_to_suffix(pattern: &str) -> Option<String> {
let p = pattern.strip_prefix('^').unwrap_or(pattern);
let p = p.strip_prefix(".*").unwrap_or(p);
let p = p.strip_prefix("\\.")?;
let p = p.strip_suffix('$')?;
if let Some(base) = p.strip_suffix('?') {
if base.chars().all(|c| c.is_ascii_alphanumeric()) && !base.is_empty() {
let without_last = &base[..base.len() - 1];
if without_last.is_empty() {
return None;
}
return Some(format!(".{{{without_last},{base}}}"));
}
return None;
}
if let Some(inner) = p.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
let exts: Vec<&str> = inner.split('|').collect();
if exts
.iter()
.all(|e| e.chars().all(|c| c.is_ascii_alphanumeric()) && !e.is_empty())
{
return Some(format!(".{{{}}}", exts.join(",")));
}
return None;
}
if p.chars().all(|c| c.is_ascii_alphanumeric()) && !p.is_empty() {
return Some(format!(".{p}"));
}
None
}
pub(super) fn try_extract_factory_new_class(arguments: &[Argument<'_>]) -> Option<String> {
for arg in arguments {
let class_name = match arg {
Argument::ArrowFunctionExpression(arrow) => {
if arrow.expression {
extract_new_class_from_statement(arrow.body.statements.first()?)
} else {
extract_new_class_from_return_body(&arrow.body.statements)
}
}
Argument::FunctionExpression(func) => {
extract_new_class_from_return_body(&func.body.as_ref()?.statements)
}
_ => None,
};
if let Some(name) = class_name
&& !is_builtin_constructor(&name)
{
return Some(name);
}
}
None
}
fn extract_new_class_from_statement(stmt: &Statement<'_>) -> Option<String> {
if let Statement::ExpressionStatement(expr_stmt) = stmt
&& let Expression::NewExpression(new_expr) = &expr_stmt.expression
&& let Expression::Identifier(callee) = &new_expr.callee
{
return Some(callee.name.to_string());
}
None
}
fn extract_new_class_from_return_body(stmts: &[Statement<'_>]) -> Option<String> {
for stmt in stmts.iter().rev() {
if let Statement::ReturnStatement(ret) = stmt
&& let Some(Expression::NewExpression(new_expr)) = &ret.argument
&& let Expression::Identifier(callee) = &new_expr.callee
{
return Some(callee.name.to_string());
}
}
None
}
pub(super) fn is_builtin_constructor(name: &str) -> bool {
matches!(
name,
"Array"
| "ArrayBuffer"
| "Blob"
| "Boolean"
| "DataView"
| "Date"
| "Error"
| "EvalError"
| "Event"
| "Float32Array"
| "Float64Array"
| "FormData"
| "Headers"
| "Int8Array"
| "Int16Array"
| "Int32Array"
| "Map"
| "Number"
| "Object"
| "Promise"
| "Proxy"
| "RangeError"
| "ReferenceError"
| "RegExp"
| "Request"
| "Response"
| "Set"
| "SharedArrayBuffer"
| "String"
| "SyntaxError"
| "TypeError"
| "URIError"
| "URL"
| "URLSearchParams"
| "Uint8Array"
| "Uint8ClampedArray"
| "Uint16Array"
| "Uint32Array"
| "WeakMap"
| "WeakRef"
| "WeakSet"
| "Worker"
| "AbortController"
| "ReadableStream"
| "WritableStream"
| "TransformStream"
| "TextEncoder"
| "TextDecoder"
| "MutationObserver"
| "IntersectionObserver"
| "ResizeObserver"
| "PerformanceObserver"
| "MessageChannel"
| "BroadcastChannel"
| "WebSocket"
| "XMLHttpRequest"
| "EventEmitter"
| "Buffer"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regex_suffix_with_caret_anchor() {
assert_eq!(
regex_pattern_to_suffix(r"^\.vue$"),
Some(".vue".to_string())
);
assert_eq!(
regex_pattern_to_suffix(r"^\.json$"),
Some(".json".to_string())
);
}
#[test]
fn regex_suffix_with_dotstar_anchor() {
assert_eq!(
regex_pattern_to_suffix(r".*\.css$"),
Some(".css".to_string())
);
}
#[test]
fn regex_suffix_with_both_anchors() {
assert_eq!(
regex_pattern_to_suffix(r"^.*\.ts$"),
Some(".ts".to_string())
);
}
#[test]
fn regex_suffix_single_char_optional_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.x?$"), None);
}
#[test]
fn regex_suffix_two_char_optional() {
assert_eq!(
regex_pattern_to_suffix(r"\.ts?$"),
Some(".{t,ts}".to_string())
);
}
#[test]
fn regex_suffix_no_dollar_sign_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.vue"), None);
}
#[test]
fn regex_suffix_no_escaped_dot_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"vue$"), None);
}
#[test]
fn regex_suffix_empty_alternation_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.()$"), None);
}
#[test]
fn regex_suffix_alternation_with_special_chars_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.(j.s|ts)$"), None);
}
#[test]
fn regex_suffix_complex_wildcard_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\..+$"), None);
assert_eq!(regex_pattern_to_suffix(r"\.[a-z]+$"), None);
}
#[test]
fn builtin_constructors_recognized() {
assert!(is_builtin_constructor("Array"));
assert!(is_builtin_constructor("Map"));
assert!(is_builtin_constructor("Set"));
assert!(is_builtin_constructor("WeakMap"));
assert!(is_builtin_constructor("WeakSet"));
assert!(is_builtin_constructor("Promise"));
assert!(is_builtin_constructor("URL"));
assert!(is_builtin_constructor("URLSearchParams"));
assert!(is_builtin_constructor("RegExp"));
assert!(is_builtin_constructor("Date"));
assert!(is_builtin_constructor("Error"));
assert!(is_builtin_constructor("TypeError"));
assert!(is_builtin_constructor("Request"));
assert!(is_builtin_constructor("Response"));
assert!(is_builtin_constructor("Headers"));
assert!(is_builtin_constructor("FormData"));
assert!(is_builtin_constructor("Blob"));
assert!(is_builtin_constructor("AbortController"));
assert!(is_builtin_constructor("ReadableStream"));
assert!(is_builtin_constructor("WritableStream"));
assert!(is_builtin_constructor("TransformStream"));
assert!(is_builtin_constructor("TextEncoder"));
assert!(is_builtin_constructor("TextDecoder"));
assert!(is_builtin_constructor("Worker"));
assert!(is_builtin_constructor("WebSocket"));
assert!(is_builtin_constructor("EventEmitter"));
assert!(is_builtin_constructor("Buffer"));
assert!(is_builtin_constructor("MutationObserver"));
assert!(is_builtin_constructor("IntersectionObserver"));
assert!(is_builtin_constructor("ResizeObserver"));
assert!(is_builtin_constructor("MessageChannel"));
assert!(is_builtin_constructor("BroadcastChannel"));
}
#[test]
fn user_defined_classes_not_builtin() {
assert!(!is_builtin_constructor("MyService"));
assert!(!is_builtin_constructor("UserRepository"));
assert!(!is_builtin_constructor("AppController"));
assert!(!is_builtin_constructor("DatabaseConnection"));
assert!(!is_builtin_constructor("Logger"));
assert!(!is_builtin_constructor("Config"));
assert!(!is_builtin_constructor(""));
}
#[test]
fn builtin_names_are_case_sensitive() {
assert!(!is_builtin_constructor("array"));
assert!(!is_builtin_constructor("map"));
assert!(!is_builtin_constructor("url"));
assert!(!is_builtin_constructor("MAP"));
assert!(!is_builtin_constructor("ARRAY"));
}
}