use oxc_ast::ast::{
Argument, ArrayExpressionElement, BinaryExpression, BindingPattern, CallExpression, Class,
ClassElement, Expression, MethodDefinitionKind, ObjectPropertyKind, PropertyDefinition,
Statement, TSAccessibility, TSSignature, TSType, TSTypeAnnotation, TSTypeName,
};
use oxc_span::{GetSpan, Span};
use rustc_hash::FxHashMap;
use crate::{MemberInfo, MemberKind};
use fallow_types::extract::{AngularInputMember, AngularOutputMember};
pub struct AngularComponentMetadata {
pub template_url: Option<String>,
pub style_urls: Vec<String>,
pub inline_template: Option<String>,
pub inline_template_offset: Option<u32>,
pub decorator_span: Span,
pub host_member_refs: Vec<String>,
pub input_output_members: Vec<String>,
pub selector: Option<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 {
if let Some(metadata) = extract_decorator_metadata(decorator) {
return Some(metadata);
}
}
None
}
#[derive(Default)]
struct AngularMetadataAccumulator {
template_url: Option<String>,
style_urls: Vec<String>,
inline_template: Option<String>,
inline_template_offset: Option<u32>,
host_member_refs: Vec<String>,
input_output_members: Vec<String>,
selector: Option<String>,
}
impl AngularMetadataAccumulator {
fn has_data(&self) -> bool {
self.template_url.is_some()
|| !self.style_urls.is_empty()
|| self.inline_template.is_some()
|| !self.host_member_refs.is_empty()
|| !self.input_output_members.is_empty()
|| self.selector.is_some()
}
fn into_metadata(self, decorator_span: Span) -> AngularComponentMetadata {
AngularComponentMetadata {
template_url: self.template_url,
style_urls: self.style_urls,
inline_template: self.inline_template,
inline_template_offset: self.inline_template_offset,
decorator_span,
host_member_refs: self.host_member_refs,
input_output_members: self.input_output_members,
selector: self.selector,
}
}
}
fn extract_decorator_metadata(
decorator: &oxc_ast::ast::Decorator<'_>,
) -> Option<AngularComponentMetadata> {
let Expression::CallExpression(call) = &decorator.expression else {
return None;
};
let Expression::Identifier(id) = &call.callee else {
return None;
};
if !matches!(id.name.as_str(), "Component" | "Directive") {
return None;
}
let is_component = id.name.as_str() == "Component";
let Some(Argument::ObjectExpression(obj)) = call.arguments.first() else {
return None;
};
let mut acc = AngularMetadataAccumulator::default();
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(p) = prop else {
continue;
};
let Some(key_name) = p.key.static_name() else {
continue;
};
apply_decorator_property(&mut acc, key_name.as_ref(), p, is_component);
}
acc.has_data().then(|| acc.into_metadata(decorator.span()))
}
fn apply_decorator_property(
acc: &mut AngularMetadataAccumulator,
key_name: &str,
p: &oxc_ast::ast::ObjectProperty<'_>,
is_component: bool,
) {
match key_name {
"selector" if is_component => {
if let Expression::StringLiteral(lit) = &p.value {
acc.selector = Some(lit.value.to_string());
}
}
"templateUrl" => {
if let Expression::StringLiteral(lit) = &p.value {
acc.template_url = Some(lit.value.to_string());
}
}
"template" => apply_inline_template(acc, p),
"styleUrl" => {
if let Expression::StringLiteral(lit) = &p.value {
acc.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 {
acc.style_urls.push(lit.value.to_string());
}
}
}
}
"host" => {
if let Expression::ObjectExpression(host_obj) = &p.value {
extract_host_member_refs(host_obj, &mut acc.host_member_refs);
}
}
"inputs" | "outputs" => {
extract_input_output_members(&p.value, &mut acc.input_output_members);
}
"queries" => {
extract_query_members(&p.value, &mut acc.input_output_members);
}
_ => {}
}
}
fn apply_inline_template(
acc: &mut AngularMetadataAccumulator,
p: &oxc_ast::ast::ObjectProperty<'_>,
) {
if let Expression::StringLiteral(lit) = &p.value {
acc.inline_template = Some(lit.value.to_string());
acc.inline_template_offset = Some(lit.span.start.saturating_add(1));
} else if let Expression::TemplateLiteral(tpl) = &p.value
&& tpl.expressions.is_empty()
&& let Some(quasi) = tpl.quasis.first()
{
let source = quasi
.value
.cooked
.as_ref()
.map_or_else(|| quasi.value.raw.as_str(), |c| c.as_str())
.to_string();
acc.inline_template = Some(source);
acc.inline_template_offset = Some(p.value.span().start.saturating_add(1));
}
}
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 bytes = expr.as_bytes();
let mut i = 0;
let mut in_string: Option<u8> = None;
let mut prev_significant: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(quote) = in_string {
if c == quote {
in_string = None;
}
i += 1;
continue;
}
if c == b'"' || c == b'\'' || c == b'`' {
in_string = Some(c);
prev_significant = Some(c);
i += 1;
continue;
}
let is_ident_start = c.is_ascii_alphabetic() || c == b'_' || c == b'$';
if is_ident_start {
let start = i;
while i < bytes.len() {
let cc = bytes[i];
if cc.is_ascii_alphanumeric() || cc == b'_' || cc == b'$' {
i += 1;
} else {
break;
}
}
let is_member_tail = prev_significant == Some(b'.');
let ident = &expr[start..i];
if !is_member_tail
&& is_valid_member_identifier(ident)
&& !refs.iter().any(|r| r == ident)
{
refs.push(ident.to_string());
}
prev_significant = Some(b'a'); continue;
}
if !c.is_ascii_whitespace() {
prev_significant = Some(c);
}
i += 1;
}
}
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
}
})
}
#[derive(Debug, Clone)]
pub(super) enum LitCustomElementDecorator {
Named { local_name: String },
Namespace { local_name: String },
}
pub(super) fn lit_custom_element_decorator(class: &Class<'_>) -> Option<LitCustomElementDecorator> {
class.decorators.iter().find_map(|d| {
let Expression::CallExpression(call) = &d.expression else {
return None;
};
match &call.callee {
Expression::Identifier(id) => Some(LitCustomElementDecorator::Named {
local_name: id.name.to_string(),
}),
Expression::StaticMemberExpression(member)
if member.property.name == "customElement" =>
{
let Expression::Identifier(object) = &member.object else {
return None;
};
Some(LitCustomElementDecorator::Namespace {
local_name: object.name.to_string(),
})
}
_ => None,
}
})
}
pub(super) fn lit_custom_element_tag(class: &Class<'_>) -> Option<String> {
class.decorators.iter().find_map(|d| {
let Expression::CallExpression(call) = &d.expression else {
return None;
};
let is_custom_element = match &call.callee {
Expression::Identifier(id) => id.name == "customElement",
Expression::StaticMemberExpression(member) => member.property.name == "customElement",
_ => false,
};
if !is_custom_element {
return None;
}
match call.arguments.first()? {
Argument::StringLiteral(lit) => Some(lit.value.to_string()),
_ => None,
}
})
}
pub fn extract_custom_elements_define(
call: &oxc_ast::ast::CallExpression<'_>,
) -> Option<(String, String)> {
let Expression::StaticMemberExpression(member) = &call.callee else {
return None;
};
if member.property.name != "define" || !is_custom_elements_receiver(&member.object) {
return None;
}
let tag = match call.arguments.first()? {
Argument::StringLiteral(lit) => lit.value.to_string(),
_ => return None,
};
let class_name = match call.arguments.get(1)? {
Argument::Identifier(id) => id.name.to_string(),
_ => return None,
};
Some((tag, class_name))
}
pub(super) fn extract_custom_element_tag_reference(
call: &oxc_ast::ast::CallExpression<'_>,
) -> Option<String> {
let Expression::StaticMemberExpression(member) = &call.callee else {
return None;
};
let recognized = match member.property.name.as_str() {
"createElement" => true,
"get" | "whenDefined" => is_custom_elements_receiver(&member.object),
_ => return None,
};
if !recognized {
return None;
}
let Argument::StringLiteral(lit) = call.arguments.first()? else {
return None;
};
let tag = lit.value.as_str();
tag.contains('-').then(|| tag.to_string())
}
fn is_custom_elements_receiver(expr: &Expression<'_>) -> bool {
match expr {
Expression::Identifier(id) => id.name == "customElements",
Expression::StaticMemberExpression(member) => member.property.name == "customElements",
_ => false,
}
}
fn is_angular_signal_initializer(value: &Expression<'_>) -> bool {
angular_signal_initializer_name(value).is_some()
}
fn angular_signal_initializer_name<'a>(value: &'a Expression<'a>) -> Option<&'a str> {
let Expression::CallExpression(call) = value else {
return None;
};
match &call.callee {
Expression::Identifier(id) => {
let name = id.name.as_str();
ANGULAR_SIGNAL_APIS.contains(&name).then_some(name)
}
Expression::StaticMemberExpression(member) => {
if let Expression::Identifier(obj) = &member.object {
let name = obj.name.as_str();
(ANGULAR_SIGNAL_APIS.contains(&name) && member.property.name == "required")
.then_some(name)
} else {
None
}
}
_ => None,
}
}
pub(super) struct AngularSignalQuery {
pub type_arg: String,
pub plural: bool,
}
pub(super) fn extract_angular_signal_query(value: &Expression<'_>) -> Option<AngularSignalQuery> {
let Expression::CallExpression(call) = value else {
return None;
};
let Expression::Identifier(id) = &call.callee else {
return None;
};
let plural = match id.name.as_str() {
"viewChild" | "contentChild" => false,
"viewChildren" | "contentChildren" => true,
_ => return None,
};
if let Some(type_args) = call.type_arguments.as_deref()
&& let Some(first) = type_args.params.first()
&& let Some(name) = extract_type_reference_name(first)
&& !is_builtin_constructor(&name)
{
return Some(AngularSignalQuery {
type_arg: name,
plural,
});
}
if let Some(Argument::Identifier(arg_id)) = call.arguments.first()
&& !is_builtin_constructor(arg_id.name.as_str())
{
return Some(AngularSignalQuery {
type_arg: arg_id.name.to_string(),
plural,
});
}
None
}
pub(super) fn extract_query_list_element_type(annotation: &TSTypeAnnotation<'_>) -> Option<String> {
extract_query_list_from_type(&annotation.type_annotation)
}
fn extract_query_list_from_type(ty: &TSType<'_>) -> Option<String> {
match ty {
TSType::TSTypeReference(type_ref) => {
let name = extract_type_name(&type_ref.type_name)?;
if name != "QueryList" {
return None;
}
let type_args = type_ref.type_arguments.as_deref()?;
let first = type_args.params.first()?;
let element = extract_type_reference_name(first)?;
if is_builtin_constructor(&element) {
None
} else {
Some(element)
}
}
TSType::TSParenthesizedType(paren) => extract_query_list_from_type(&paren.type_annotation),
TSType::TSUnionType(union) => {
let mut found: Option<String> = None;
for branch in &union.types {
match branch {
TSType::TSNullKeyword(_) | TSType::TSUndefinedKeyword(_) => {}
other => {
if found.is_some() {
return None;
}
found = extract_query_list_from_type(other);
found.as_ref()?;
}
}
}
found
}
_ => None,
}
}
const VUE_REACTIVITY_WRAPPERS: &[&str] = &[
"ref",
"computed",
"shallowRef",
"reactive",
"shallowReactive",
"readonly",
"toRef",
"customRef",
];
pub(super) fn array_element_type_from_type(ty: &TSType<'_>) -> Option<String> {
match ty {
TSType::TSArrayType(arr) => {
let name = extract_type_reference_name(&arr.element_type)?;
(!is_builtin_constructor(&name)).then_some(name)
}
TSType::TSTypeReference(type_ref) => {
let name = extract_type_name(&type_ref.type_name)?;
if name != "Array" && name != "ReadonlyArray" {
return None;
}
let first = type_ref.type_arguments.as_deref()?.params.first()?;
let element = extract_type_reference_name(first)?;
(!is_builtin_constructor(&element)).then_some(element)
}
TSType::TSTypeOperatorType(op)
if op.operator == oxc_ast::ast::TSTypeOperatorOperator::Readonly =>
{
array_element_type_from_type(&op.type_annotation)
}
TSType::TSParenthesizedType(paren) => array_element_type_from_type(&paren.type_annotation),
TSType::TSUnionType(union) => {
let mut found: Option<String> = None;
for branch in &union.types {
match branch {
TSType::TSNullKeyword(_) | TSType::TSUndefinedKeyword(_) => {}
other => {
if found.is_some() {
return None;
}
found = array_element_type_from_type(other);
found.as_ref()?;
}
}
}
found
}
_ => None,
}
}
pub(super) fn infer_array_binding_element_type(
type_annotation: Option<&TSTypeAnnotation<'_>>,
init: Option<&Expression<'_>>,
) -> Option<String> {
if let Some(annotation) = type_annotation {
return array_element_type_from_type(&annotation.type_annotation);
}
infer_array_element_from_init(init?)
}
fn infer_array_element_from_init(expr: &Expression<'_>) -> Option<String> {
match expr {
Expression::ParenthesizedExpression(paren) => {
infer_array_element_from_init(&paren.expression)
}
Expression::ArrayExpression(arr) => array_literal_element_type(arr),
Expression::CallExpression(call) => reactivity_wrapper_element_type(call),
_ => None,
}
}
fn reactivity_wrapper_element_type(call: &CallExpression<'_>) -> Option<String> {
let Expression::Identifier(callee) = &call.callee else {
return None;
};
if !VUE_REACTIVITY_WRAPPERS.contains(&callee.name.as_str()) {
return None;
}
if let Some(type_args) = call.type_arguments.as_deref()
&& let Some(first) = type_args.params.first()
&& let Some(element) = array_element_type_from_type(first)
{
return Some(element);
}
callback_returned_array_element(call.arguments.first()?)
}
fn callback_returned_array_element(arg: &Argument<'_>) -> Option<String> {
match arg {
Argument::ArrowFunctionExpression(arrow) => {
if arrow.expression {
let Statement::ExpressionStatement(stmt) = arrow.body.statements.first()? else {
return None;
};
return array_literal_element_type_of_expr(&stmt.expression);
}
function_body_returned_array_element(&arrow.body)
}
Argument::FunctionExpression(func) => {
function_body_returned_array_element(func.body.as_deref()?)
}
_ => None,
}
}
fn function_body_returned_array_element(body: &oxc_ast::ast::FunctionBody<'_>) -> Option<String> {
let Statement::ReturnStatement(ret) = body.statements.last()? else {
return None;
};
match unwrap_returned_expr(ret.argument.as_ref()?) {
Expression::ArrayExpression(arr) => array_literal_element_type(arr),
Expression::Identifier(id) => local_typed_array_element(&body.statements, &id.name),
_ => None,
}
}
fn unwrap_returned_expr<'a, 'b>(expr: &'b Expression<'a>) -> &'b Expression<'a> {
match expr {
Expression::ParenthesizedExpression(paren) => unwrap_returned_expr(&paren.expression),
other => other,
}
}
fn array_literal_element_type_of_expr(expr: &Expression<'_>) -> Option<String> {
match expr {
Expression::ParenthesizedExpression(paren) => {
array_literal_element_type_of_expr(&paren.expression)
}
Expression::ArrayExpression(arr) => array_literal_element_type(arr),
_ => None,
}
}
fn array_literal_element_type(arr: &oxc_ast::ast::ArrayExpression<'_>) -> Option<String> {
let mut element: Option<String> = None;
for item in &arr.elements {
let ArrayExpressionElement::NewExpression(new_expr) = item else {
return None;
};
let Expression::Identifier(callee) = &new_expr.callee else {
return None;
};
let name = callee.name.as_str();
if is_builtin_constructor(name) {
return None;
}
match &element {
Some(existing) if existing != name => return None,
Some(_) => {}
None => element = Some(name.to_string()),
}
}
element
}
fn local_typed_array_element(statements: &[Statement<'_>], name: &str) -> Option<String> {
for stmt in statements {
let Statement::VariableDeclaration(decl) = stmt else {
continue;
};
for declarator in &decl.declarations {
let BindingPattern::BindingIdentifier(id) = &declarator.id else {
continue;
};
if id.name != name {
continue;
}
if let Some(annotation) = declarator.type_annotation.as_deref() {
return array_element_type_from_type(&annotation.type_annotation);
}
}
}
None
}
pub(super) fn collect_component_field_array_types(class: &Class<'_>) -> FxHashMap<String, String> {
let mut field_types: FxHashMap<String, String> = FxHashMap::default();
for element in &class.body.body {
let ClassElement::PropertyDefinition(prop) = element else {
continue;
};
if matches!(prop.accessibility, Some(TSAccessibility::Private)) {
continue;
}
let Some(name) = prop.key.static_name() else {
continue;
};
if let Some(element_type) =
infer_array_binding_element_type(prop.type_annotation.as_deref(), prop.value.as_ref())
{
field_types.insert(name.to_string(), element_type);
}
}
field_types
}
pub(super) fn has_angular_plural_query_decorator(
decorators: &[oxc_ast::ast::Decorator<'_>],
) -> bool {
decorators.iter().any(|decorator| {
let Expression::CallExpression(call) = &decorator.expression else {
return false;
};
let Expression::Identifier(id) = &call.callee else {
return false;
};
matches!(id.name.as_str(), "ViewChildren" | "ContentChildren")
})
}
pub fn extract_class_members(class: &Class<'_>, is_angular_class: bool) -> Vec<MemberInfo> {
let class_name = class.id.as_ref().map(|id| id.name.as_str());
let mut members = Vec::new();
for element in &class.body.body {
match element {
ClassElement::MethodDefinition(method) => {
if let Some(member) = build_method_member(method, class_name) {
members.push(member);
}
}
ClassElement::PropertyDefinition(prop) => {
if let Some(member) = build_property_member(prop, is_angular_class) {
members.push(member);
}
}
_ => {}
}
}
members
}
fn build_method_member(
method: &oxc_ast::ast::MethodDefinition<'_>,
class_name: Option<&str>,
) -> Option<MemberInfo> {
let name_str = method.key.static_name()?.to_string();
if name_str == "constructor"
|| matches!(
method.accessibility,
Some(TSAccessibility::Private | oxc_ast::ast::TSAccessibility::Protected)
)
{
return None;
}
let is_instance_returning_static =
method.r#static && is_instance_returning_static_method(method, class_name);
let is_self_returning =
!method.r#static && is_self_returning_instance_method(method, class_name);
let decorator_names = method
.decorators
.iter()
.map(|d| decorator_path(&d.expression))
.collect();
Some(MemberInfo {
name: name_str,
kind: MemberKind::ClassMethod,
span: method.span,
has_decorator: !method.decorators.is_empty(),
decorator_names,
is_instance_returning_static,
is_self_returning,
})
}
fn build_property_member(
prop: &oxc_ast::ast::PropertyDefinition<'_>,
is_angular_class: bool,
) -> Option<MemberInfo> {
let name = prop.key.static_name()?;
if prop.declare
|| matches!(
prop.accessibility,
Some(TSAccessibility::Private | oxc_ast::ast::TSAccessibility::Protected)
)
{
return None;
}
let has_decorator = !prop.decorators.is_empty()
|| (is_angular_class
&& prop
.value
.as_ref()
.is_some_and(is_angular_signal_initializer));
let decorator_names = prop
.decorators
.iter()
.map(|d| decorator_path(&d.expression))
.collect();
Some(MemberInfo {
name: name.to_string(),
kind: MemberKind::ClassProperty,
span: prop.span,
has_decorator,
decorator_names,
is_instance_returning_static: false,
is_self_returning: false,
})
}
pub fn extract_angular_inputs_outputs(
class: &Class<'_>,
) -> (Vec<AngularInputMember>, Vec<AngularOutputMember>) {
let mut inputs = Vec::new();
let mut outputs = Vec::new();
for element in &class.body.body {
let ClassElement::PropertyDefinition(prop) = element else {
continue;
};
let Some(name) = prop.key.static_name() else {
continue;
};
let span_start = prop.key.span().start;
if let Some(role) = angular_decorator_member_role(prop) {
match role {
AngularMemberRole::Input => inputs.push(AngularInputMember {
name: name.to_string(),
span_start,
}),
AngularMemberRole::Output => {
if output_is_event_emitter(prop.value.as_ref()) {
outputs.push(AngularOutputMember {
name: name.to_string(),
span_start,
});
}
}
}
continue;
}
if let Some(signal_name) = prop
.value
.as_ref()
.and_then(|value| angular_signal_initializer_name(value))
{
match signal_name {
"input" | "model" => inputs.push(AngularInputMember {
name: name.to_string(),
span_start,
}),
"output" | "outputFromObservable" => outputs.push(AngularOutputMember {
name: name.to_string(),
span_start,
}),
_ => {}
}
}
}
(inputs, outputs)
}
fn angular_decorator_member_role(prop: &PropertyDefinition<'_>) -> Option<AngularMemberRole> {
prop.decorators.iter().find_map(|decorator| {
match decorator_path(&decorator.expression).as_str() {
"Input" => Some(AngularMemberRole::Input),
"Output" => Some(AngularMemberRole::Output),
_ => None,
}
})
}
enum AngularMemberRole {
Input,
Output,
}
fn output_is_event_emitter(value: Option<&Expression<'_>>) -> bool {
let Some(Expression::NewExpression(new_expr)) = value else {
return false;
};
match &new_expr.callee {
Expression::Identifier(ident) => ident.name.as_str() == "EventEmitter",
Expression::StaticMemberExpression(member) => {
member.property.name.as_str() == "EventEmitter"
}
_ => false,
}
}
fn is_instance_returning_static_method(
method: &oxc_ast::ast::MethodDefinition<'_>,
class_name: Option<&str>,
) -> bool {
if returns_named_class_type(method.value.return_type.as_ref(), class_name) {
return true;
}
let Some(body) = method.value.body.as_ref() else {
return false;
};
body.statements
.last()
.is_some_and(|stmt| statement_returns_class_instance(stmt, class_name))
}
fn is_self_returning_instance_method(
method: &oxc_ast::ast::MethodDefinition<'_>,
class_name: Option<&str>,
) -> bool {
if returns_named_class_type(method.value.return_type.as_ref(), class_name) {
return true;
}
if returns_this_type(method.value.return_type.as_ref()) {
return true;
}
let Some(body) = method.value.body.as_ref() else {
return false;
};
body.statements.last().is_some_and(|stmt| {
let Statement::ReturnStatement(ret) = stmt else {
return false;
};
matches!(ret.argument.as_ref(), Some(Expression::ThisExpression(_)))
})
}
fn returns_named_class_type(
return_type: Option<&oxc_allocator::Box<'_, oxc_ast::ast::TSTypeAnnotation<'_>>>,
class_name: Option<&str>,
) -> bool {
let Some(name) = class_name else {
return false;
};
let Some(annotation) = return_type.map(|boxed| boxed.as_ref()) else {
return false;
};
extract_type_annotation_name(annotation).is_some_and(|ty| ty == name)
}
fn returns_this_type(
return_type: Option<&oxc_allocator::Box<'_, oxc_ast::ast::TSTypeAnnotation<'_>>>,
) -> bool {
let Some(annotation) = return_type.map(|boxed| boxed.as_ref()) else {
return false;
};
is_this_type(&annotation.type_annotation)
}
fn is_this_type(ty: &TSType<'_>) -> bool {
match ty {
TSType::TSThisType(_) => true,
TSType::TSTypeReference(type_ref) => match &type_ref.type_name {
TSTypeName::ThisExpression(_) => true,
TSTypeName::IdentifierReference(ident) => ident.name == "this",
TSTypeName::QualifiedName(_) => false,
},
TSType::TSParenthesizedType(paren) => is_this_type(&paren.type_annotation),
_ => false,
}
}
fn statement_returns_class_instance(stmt: &Statement<'_>, class_name: Option<&str>) -> bool {
let Statement::ReturnStatement(ret) = stmt else {
return false;
};
let Some(expr) = ret.argument.as_ref() else {
return false;
};
is_self_construction_expression(expr, class_name)
}
fn is_self_construction_expression(expr: &Expression<'_>, class_name: Option<&str>) -> bool {
let Expression::NewExpression(new_expr) = expr else {
return false;
};
match &new_expr.callee {
Expression::ThisExpression(_) => true,
Expression::Identifier(ident) => class_name.is_some_and(|name| ident.name.as_str() == name),
_ => false,
}
}
pub fn extract_super_class_name(class: &Class<'_>) -> Option<String> {
extract_static_expression_name(class.super_class.as_ref()?)
}
#[must_use]
pub fn extract_implemented_interface_names(class: &Class<'_>) -> Vec<String> {
class
.implements
.iter()
.filter_map(|item| extract_type_name(&item.expression))
.collect()
}
#[must_use]
pub fn extract_type_annotation_name(type_annotation: &TSTypeAnnotation<'_>) -> Option<String> {
extract_type_reference_name(&type_annotation.type_annotation)
}
#[must_use]
pub fn extract_nested_type_bindings(
type_annotation: &TSTypeAnnotation<'_>,
) -> Vec<(String, String)> {
let mut bindings = Vec::new();
collect_nested_type_bindings(&type_annotation.type_annotation, None, &mut bindings);
bindings
}
fn collect_nested_type_bindings(
ty: &TSType<'_>,
prefix: Option<&str>,
bindings: &mut Vec<(String, String)>,
) {
match ty {
TSType::TSTypeLiteral(type_lit) => {
for member in &type_lit.members {
let TSSignature::TSPropertySignature(prop) = member else {
continue;
};
let Some(property_name) = prop.key.static_name() else {
continue;
};
let path = if let Some(prefix) = prefix {
format!("{prefix}.{property_name}")
} else {
property_name.to_string()
};
let Some(type_annotation) = prop.type_annotation.as_deref() else {
continue;
};
if let Some(type_name) = extract_type_annotation_name(type_annotation) {
bindings.push((path, type_name));
} else {
collect_nested_type_bindings(
&type_annotation.type_annotation,
Some(path.as_str()),
bindings,
);
}
}
}
TSType::TSParenthesizedType(paren) => {
collect_nested_type_bindings(&paren.type_annotation, prefix, bindings);
}
_ => {}
}
}
#[must_use]
pub fn extract_class_instance_bindings<F>(
class: &Class<'_>,
is_named_import_from: F,
) -> Vec<(String, String)>
where
F: Fn(&str, &str, &str) -> bool,
{
let type_param_constraints = collect_class_type_param_constraints(class);
let resolve = |raw: String| -> Option<String> {
if let Some(replacement) = type_param_constraints.get(raw.as_str()) {
return replacement.clone();
}
Some(raw)
};
let mut bindings: Vec<(String, String)> = Vec::new();
for element in &class.body.body {
match element {
ClassElement::MethodDefinition(method) => {
if matches!(method.kind, MethodDefinitionKind::Constructor) {
collect_constructor_param_bindings(method, &resolve, &mut bindings);
} else if matches!(method.kind, MethodDefinitionKind::Get) {
collect_getter_binding(method, &resolve, &mut bindings);
}
}
ClassElement::PropertyDefinition(prop) => {
collect_property_binding(prop, &resolve, &is_named_import_from, &mut bindings);
}
_ => {}
}
}
bindings
}
fn collect_constructor_param_bindings(
method: &oxc_ast::ast::MethodDefinition<'_>,
resolve: &impl Fn(String) -> Option<String>,
bindings: &mut Vec<(String, String)>,
) {
for param in &method.value.params.items {
let Some(accessibility) = param.accessibility else {
continue;
};
if matches!(accessibility, TSAccessibility::Private) {
continue;
}
let BindingPattern::BindingIdentifier(id) = ¶m.pattern else {
continue;
};
let Some(type_annotation) = param.type_annotation.as_deref() else {
continue;
};
let Some(type_name) = extract_type_annotation_name(type_annotation) else {
continue;
};
let Some(resolved) = resolve(type_name) else {
continue;
};
bindings.push((id.name.to_string(), resolved));
}
}
fn collect_getter_binding(
method: &oxc_ast::ast::MethodDefinition<'_>,
resolve: &impl Fn(String) -> Option<String>,
bindings: &mut Vec<(String, String)>,
) {
if matches!(method.accessibility, Some(TSAccessibility::Private)) {
return;
}
let Some(name) = method.key.static_name() else {
return;
};
let Some(type_annotation) = method.value.return_type.as_deref() else {
return;
};
let Some(type_name) = extract_type_annotation_name(type_annotation) else {
return;
};
let Some(resolved) = resolve(type_name) else {
return;
};
bindings.push((name.to_string(), resolved));
}
fn collect_property_binding<F>(
prop: &oxc_ast::ast::PropertyDefinition<'_>,
resolve: &impl Fn(String) -> Option<String>,
is_named_import_from: &F,
bindings: &mut Vec<(String, String)>,
) where
F: Fn(&str, &str, &str) -> bool,
{
if matches!(prop.accessibility, Some(TSAccessibility::Private)) {
return;
}
let Some(name) = prop.key.static_name() else {
return;
};
if let Some(type_annotation) = prop.type_annotation.as_deref()
&& let Some(type_name) = extract_type_annotation_name(type_annotation)
{
if let Some(resolved) = resolve(type_name) {
bindings.push((name.to_string(), resolved));
}
return;
}
if let Some(Expression::NewExpression(new_expr)) = &prop.value
&& let Expression::Identifier(callee) = &new_expr.callee
&& !is_builtin_constructor(callee.name.as_str())
{
bindings.push((name.to_string(), callee.name.to_string()));
return;
}
if let Some(Expression::CallExpression(call)) = &prop.value
&& let Some(type_name) = extract_angular_inject_target(call, is_named_import_from)
{
bindings.push((name.to_string(), type_name));
}
}
pub fn extract_angular_inject_target<F>(
call: &CallExpression<'_>,
is_named_import_from: &F,
) -> Option<String>
where
F: Fn(&str, &str, &str) -> bool,
{
let Expression::Identifier(callee) = &call.callee else {
return None;
};
if !is_named_import_from(callee.name.as_str(), "@angular/core", "inject") {
return None;
}
if let Some(type_arguments) = call.type_arguments.as_deref()
&& let Some(TSType::TSTypeReference(type_ref)) = type_arguments.params.first()
&& let Some(type_name) = type_name_root(&type_ref.type_name)
{
return Some(type_name);
}
let Some(Argument::Identifier(target)) = call.arguments.first() else {
return None;
};
Some(target.name.to_string())
}
fn type_name_root(name: &TSTypeName<'_>) -> Option<String> {
match name {
TSTypeName::IdentifierReference(ident) => Some(ident.name.to_string()),
TSTypeName::QualifiedName(qualified) => type_name_root(&qualified.left),
TSTypeName::ThisExpression(_) => None,
}
}
#[must_use]
pub fn collect_class_type_param_constraints(
class: &Class<'_>,
) -> FxHashMap<String, Option<String>> {
let mut map = FxHashMap::default();
let Some(type_parameters) = class.type_parameters.as_deref() else {
return map;
};
for param in &type_parameters.params {
let constraint_name = param
.constraint
.as_ref()
.and_then(extract_type_reference_name);
map.insert(param.name.name.to_string(), constraint_name);
}
map
}
#[must_use]
pub fn extract_type_reference_name(ty: &TSType<'_>) -> Option<String> {
match ty {
TSType::TSTypeReference(type_ref) => extract_type_name(&type_ref.type_name),
TSType::TSParenthesizedType(paren) => extract_type_reference_name(&paren.type_annotation),
TSType::TSUnionType(union) => extract_nullable_union_name(union),
_ => None,
}
}
fn extract_nullable_union_name(union: &oxc_ast::ast::TSUnionType<'_>) -> Option<String> {
let mut found: Option<String> = None;
for branch in &union.types {
match branch {
TSType::TSNullKeyword(_) | TSType::TSUndefinedKeyword(_) => {}
other => {
if found.is_some() {
return None;
}
found = Some(extract_type_reference_name(other)?);
}
}
}
found
}
pub(super) fn decorator_path(expr: &Expression<'_>) -> String {
match expr {
Expression::Identifier(id) => id.name.to_string(),
Expression::StaticMemberExpression(member) => {
let object = decorator_path(&member.object);
if object.is_empty() {
String::new()
} else {
format!("{}.{}", object, member.property.name)
}
}
Expression::CallExpression(call) => decorator_path(&call.callee),
Expression::ParenthesizedExpression(paren) => decorator_path(&paren.expression),
_ => String::new(),
}
}
fn extract_static_expression_name(expr: &Expression<'_>) -> Option<String> {
match expr {
Expression::Identifier(ident) => Some(ident.name.to_string()),
Expression::StaticMemberExpression(member) => Some(format!(
"{}.{}",
extract_static_expression_name(&member.object)?,
member.property.name
)),
_ => None,
}
}
fn extract_type_name(name: &TSTypeName<'_>) -> Option<String> {
match name {
TSTypeName::IdentifierReference(ident) => Some(ident.name.to_string()),
TSTypeName::QualifiedName(name) => Some(format!(
"{}.{}",
extract_type_name(&name.left)?,
name.right.name
)),
TSTypeName::ThisExpression(_) => 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 ts_import_type_qualifier_root<'a>(
qualifier: &'a oxc_ast::ast::TSImportTypeQualifier<'a>,
) -> &'a str {
let mut current = qualifier;
loop {
match current {
oxc_ast::ast::TSImportTypeQualifier::Identifier(id) => return id.name.as_str(),
oxc_ast::ast::TSImportTypeQualifier::QualifiedName(qn) => current = &qn.left,
}
}
}
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(all(test, not(miri)))]
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"));
}
#[test]
fn valid_member_identifier_accepts_simple_idents() {
assert!(is_valid_member_identifier("foo"));
assert!(is_valid_member_identifier("_bar"));
assert!(is_valid_member_identifier("$baz"));
assert!(is_valid_member_identifier("myColor"));
}
#[test]
fn valid_member_identifier_rejects_empty_string() {
assert!(!is_valid_member_identifier(""));
}
#[test]
fn valid_member_identifier_rejects_js_globals() {
assert!(!is_valid_member_identifier("true"));
assert!(!is_valid_member_identifier("false"));
assert!(!is_valid_member_identifier("null"));
assert!(!is_valid_member_identifier("undefined"));
assert!(!is_valid_member_identifier("this"));
assert!(!is_valid_member_identifier("event"));
assert!(!is_valid_member_identifier("window"));
assert!(!is_valid_member_identifier("document"));
assert!(!is_valid_member_identifier("console"));
assert!(!is_valid_member_identifier("Math"));
assert!(!is_valid_member_identifier("JSON"));
assert!(!is_valid_member_identifier("Object"));
assert!(!is_valid_member_identifier("Array"));
assert!(!is_valid_member_identifier("String"));
assert!(!is_valid_member_identifier("Number"));
assert!(!is_valid_member_identifier("Boolean"));
assert!(!is_valid_member_identifier("Date"));
assert!(!is_valid_member_identifier("RegExp"));
assert!(!is_valid_member_identifier("Error"));
assert!(!is_valid_member_identifier("Promise"));
}
#[test]
fn host_expr_extracts_bare_identifier() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("color", &mut refs);
assert_eq!(refs, vec!["color".to_string()]);
}
#[test]
fn host_expr_skips_member_tail() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("foo.bar", &mut refs);
assert_eq!(refs, vec!["foo".to_string()]);
}
#[test]
fn host_expr_skips_string_literal_contents() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr(r#""mat-" + color"#, &mut refs);
assert!(
refs.contains(&"color".to_string()),
"should credit `color`; refs={refs:?}"
);
assert!(
!refs.iter().any(|r| r == "mat"),
"string content must not be credited; refs={refs:?}"
);
}
#[test]
fn host_expr_skips_single_quote_string_contents() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("'primary' || fallback", &mut refs);
assert_eq!(refs, vec!["fallback".to_string()]);
}
#[test]
fn host_expr_skips_backtick_string_contents() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("`static` || value", &mut refs);
assert_eq!(refs, vec!["value".to_string()]);
}
#[test]
fn host_expr_deduplicates_same_identifier() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("color || color", &mut refs);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], "color");
}
#[test]
fn host_expr_skips_js_global_identifiers() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("true || false || myFlag", &mut refs);
assert!(!refs.contains(&"true".to_string()));
assert!(!refs.contains(&"false".to_string()));
assert!(refs.contains(&"myFlag".to_string()));
}
#[test]
fn host_expr_handles_empty_string() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("", &mut refs);
assert!(refs.is_empty());
}
#[test]
fn host_expr_complex_expression_credits_roots() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr(r#""mat-" + (color || "primary")"#, &mut refs);
assert!(refs.contains(&"color".to_string()), "refs={refs:?}");
}
#[test]
fn regex_suffix_alternation_valid_extensions() {
assert_eq!(
regex_pattern_to_suffix(r"\.(js|ts)$"),
Some(".{js,ts}".to_string())
);
assert_eq!(
regex_pattern_to_suffix(r"\.(jsx|tsx|js|ts)$"),
Some(".{jsx,tsx,js,ts}".to_string())
);
}
#[test]
fn regex_suffix_alternation_empty_arm_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.(|ts)$"), None);
}
#[test]
fn regex_suffix_plain_alphanumeric_ext() {
assert_eq!(regex_pattern_to_suffix(r"\.vue$"), Some(".vue".to_string()));
}
#[test]
fn regex_suffix_optional_with_empty_base_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.t?$"), None);
}
#[test]
fn regex_suffix_optional_with_non_alphanumeric_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.-?$"), None);
}
#[test]
fn regex_suffix_with_non_alphanumeric_tail_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.-js$"), None);
}
#[test]
fn host_expr_operator_breaks_member_tail_chain() {
let mut refs = Vec::new();
extract_identifiers_from_host_expr("a.b + c", &mut refs);
assert!(refs.contains(&"a".to_string()), "refs={refs:?}");
assert!(refs.contains(&"c".to_string()), "refs={refs:?}");
assert!(
!refs.contains(&"b".to_string()),
"b is member tail; refs={refs:?}"
);
}
#[test]
fn has_angular_class_decorator_via_module_info() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-foo', template: '<p>foo</p>' })
export class FooComponent {
myProp: string = '';
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("FooComponent"));
assert!(export.is_some(), "FooComponent should be exported");
}
#[test]
fn extract_class_members_includes_public_methods_and_properties() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
public name: string = '';
greet(): void {}
private secret: number = 0;
protected inner(): void {}
constructor(x: number) {}
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyClass"))
.expect("MyClass export expected");
let member_names: Vec<&str> = export.members.iter().map(|m| m.name.as_str()).collect();
assert!(
member_names.contains(&"name"),
"public prop; got {member_names:?}"
);
assert!(
member_names.contains(&"greet"),
"public method; got {member_names:?}"
);
assert!(!member_names.contains(&"constructor"), "{member_names:?}");
assert!(!member_names.contains(&"secret"), "{member_names:?}");
assert!(!member_names.contains(&"inner"), "{member_names:?}");
}
#[test]
fn angular_signal_property_sets_has_decorator() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
count = input(0);
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("FooComponent"))
.expect("FooComponent export");
let member = export.members.iter().find(|m| m.name == "count");
assert!(
member.is_some_and(|m| m.has_decorator),
"count with input() on angular class must have has_decorator=true"
);
}
#[test]
fn output_is_event_emitter_false_for_observable_output() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
@Output() clicks = new Subject<void>();
}
",
);
assert!(
info.angular_outputs.is_empty()
|| !info.angular_outputs.iter().any(|o| o.name == "clicks"),
"Subject-initialized @Output should not be harvested; outputs={:?}",
info.angular_outputs
);
}
#[test]
fn output_is_event_emitter_true_for_event_emitter() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
@Output() clicked = new EventEmitter<void>();
}
",
);
assert!(
info.angular_outputs.iter().any(|o| o.name == "clicked"),
"EventEmitter @Output should be harvested; outputs={:?}",
info.angular_outputs
);
}
#[test]
fn signal_input_required_form_is_harvested_as_input() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
title = input.required<string>();
}
",
);
assert!(
info.angular_inputs.iter().any(|i| i.name == "title"),
"input.required() must be harvested as input; inputs={:?}",
info.angular_inputs
);
}
#[test]
fn signal_model_is_harvested_as_input_only() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
count = model(0);
}
",
);
assert!(
info.angular_inputs.iter().any(|i| i.name == "count"),
"model() must be input; inputs={:?}",
info.angular_inputs
);
assert!(
!info.angular_outputs.iter().any(|o| o.name == "count"),
"model() must NOT be output; outputs={:?}",
info.angular_outputs
);
}
#[test]
fn signal_output_from_observable_is_harvested_as_output() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
stream = outputFromObservable(someObs$);
}
",
);
assert!(
info.angular_outputs.iter().any(|o| o.name == "stream"),
"outputFromObservable() must be output; outputs={:?}",
info.angular_outputs
);
}
#[test]
fn angular_metadata_extracts_selector() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: '' })
export class AppComponent {}
",
);
assert!(
!info.angular_component_selectors.is_empty(),
"selector should be extracted for @Component"
);
assert!(
info.angular_component_selectors
.iter()
.any(|s| s.selectors.iter().any(|sel| sel == "app-root")),
"app-root selector expected; got {:?}",
info.angular_component_selectors
);
}
#[test]
fn angular_directive_does_not_extract_selector() {
let info = crate::tests::parse_ts(
r"
@Directive({ selector: '[appHighlight]' })
export class HighlightDirective {}
",
);
assert!(
info.angular_component_selectors.is_empty(),
"selector should NOT be extracted for @Directive; got {:?}",
info.angular_component_selectors
);
}
#[test]
fn angular_metadata_extracts_style_url_single() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: '', styleUrl: './app.component.css' })
export class AppComponent {}
",
);
assert!(
info.imports
.iter()
.any(|i| i.source.contains("app.component.css")),
"styleUrl import expected; imports={:?}",
info.imports
);
}
#[test]
fn angular_metadata_extracts_style_urls_array() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: '', styleUrls: ['./foo.css', './bar.css'] })
export class AppComponent {}
",
);
let style_imports: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
assert!(
style_imports.iter().any(|s| s.contains("foo.css")),
"foo.css import expected; imports={style_imports:?}"
);
assert!(
style_imports.iter().any(|s| s.contains("bar.css")),
"bar.css import expected; imports={style_imports:?}"
);
}
#[test]
fn angular_metadata_host_binding_credits_member_refs() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: '', host: { '[class.active]': 'isActive' } })
export class AppComponent {
isActive = false;
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("AppComponent"))
.expect("AppComponent export");
assert!(
export.members.iter().any(|m| m.name == "isActive"),
"isActive should be a member; got {:?}",
export.members.iter().map(|m| &m.name).collect::<Vec<_>>()
);
}
#[test]
fn angular_metadata_inputs_array_with_alias() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: '', inputs: ['count: externalCount', 'label'] })
export class AppComponent {}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("AppComponent"));
assert!(export.is_some());
}
#[test]
fn extract_query_list_element_type_via_nullable_union_in_parse() {
let info = crate::tests::parse_ts(
r"
import { ViewChildren, QueryList } from '@angular/core';
@Component({ template: '' })
export class FooComponent {
@ViewChildren('ref') items: QueryList<MyItem> | null = null;
}
",
);
assert!(
info.exports
.iter()
.any(|e| e.name.matches_str("FooComponent")),
"FooComponent should be exported"
);
}
#[test]
fn static_method_with_class_return_type_is_instance_returning() {
let info = crate::tests::parse_ts(
r"
export class Builder {
static create(): Builder { return new Builder(); }
value: number = 0;
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Builder"))
.expect("Builder export");
let create = export.members.iter().find(|m| m.name == "create");
assert!(
create.is_some_and(|m| m.is_instance_returning_static),
"create() with `: Builder` return type must be instance-returning static"
);
}
#[test]
fn static_method_with_return_new_this_is_instance_returning() {
let info = crate::tests::parse_ts(
r"
export class Builder {
static make() { return new this(); }
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Builder"))
.expect("Builder export");
let make = export.members.iter().find(|m| m.name == "make");
assert!(
make.is_some_and(|m| m.is_instance_returning_static),
"make() with `return new this()` must be instance-returning static"
);
}
#[test]
fn instance_method_with_return_this_is_self_returning() {
let info = crate::tests::parse_ts(
r"
export class Builder {
setName(name: string) { this.name = name; return this; }
name: string = '';
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Builder"))
.expect("Builder export");
let method = export.members.iter().find(|m| m.name == "setName");
assert!(
method.is_some_and(|m| m.is_self_returning),
"setName() with `return this` must be self-returning"
);
}
#[test]
fn instance_method_with_this_return_type_is_self_returning() {
let info = crate::tests::parse_ts(
r"
export class Builder {
setName(name: string): this { this.name = name; return this; }
name: string = '';
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Builder"))
.expect("Builder export");
let method = export.members.iter().find(|m| m.name == "setName");
assert!(
method.is_some_and(|m| m.is_self_returning),
"setName() with `: this` return type must be self-returning"
);
}
#[test]
fn parenthesized_this_type_is_self_returning() {
let info = crate::tests::parse_ts(
r"
export class Builder {
add(): (this) { return this; }
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("Builder"));
assert!(export.is_some());
}
#[test]
fn nested_type_bindings_collects_from_type_literal() {
let info = crate::tests::parse_ts(
r"
export class Factory {
constructor(public deps: { foo: FooClass; bar: BarClass }) {}
}
",
);
let accesses: Vec<_> = info
.member_accesses
.iter()
.map(|a| (a.object.as_str(), a.member.as_str()))
.collect();
let export = info.exports.iter().find(|e| e.name.matches_str("Factory"));
assert!(
export.is_some(),
"Factory should be exported; accesses={accesses:?}"
);
}
#[test]
fn getter_with_return_type_adds_instance_binding() {
let info = crate::tests::parse_ts(
r"
export class Container {
get service(): MyService { return this._service; }
private _service: any;
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Container"));
assert!(export.is_some());
}
#[test]
fn private_getter_does_not_add_instance_binding() {
let info = crate::tests::parse_ts(
r"
export class Container {
get #secret(): MyService { return this._s; }
private _s: any;
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Container"));
assert!(export.is_some());
}
#[test]
fn property_with_new_expression_adds_instance_binding() {
let info = crate::tests::parse_ts(
r"
export class App {
service = new MyService();
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("App"));
assert!(export.is_some());
}
#[test]
fn property_with_builtin_new_does_not_add_binding() {
let info = crate::tests::parse_ts(
r"
export class App {
data = new Map<string, string>();
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("App"));
assert!(export.is_some());
}
#[test]
fn qualified_type_name_root_via_inject() {
let info = crate::tests::parse_ts(
r"
import { inject } from '@angular/core';
export class MyComponent {
service = inject<Ns.MyService>(TOKEN);
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyComponent"));
assert!(export.is_some());
}
#[test]
fn class_type_param_constraints_extracted() {
let info = crate::tests::parse_ts(
r"
export class BaseService<TClient extends HttpClient> {
constructor(protected client: TClient) {}
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("BaseService"));
assert!(export.is_some(), "BaseService should be exported");
}
#[test]
fn union_type_annotation_with_null_is_extracted() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
service: MyService | null = null;
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("MyClass"));
assert!(export.is_some());
}
#[test]
fn union_with_multiple_non_null_types_is_not_extracted() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
value: TypeA | TypeB = null as any;
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("MyClass"));
assert!(export.is_some());
}
#[test]
fn decorator_path_from_call_expression() {
let info = crate::tests::parse_ts(
r"
export class Entity {
@Column()
name: string = '';
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("Entity"))
.expect("Entity export");
let member = export.members.iter().find(|m| m.name == "name");
assert!(
member.is_some(),
"name member should exist; members={:?}",
export.members.iter().map(|m| &m.name).collect::<Vec<_>>()
);
assert!(
member.is_some_and(|m| m.decorator_names.contains(&"Column".to_string())),
"decorator name 'Column' expected"
);
}
#[test]
fn decorator_path_member_expression() {
let info = crate::tests::parse_ts(
r"
export class MyController {
@ns.Get('/path')
handler() {}
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyController"))
.expect("MyController export");
let member = export.members.iter().find(|m| m.name == "handler");
assert!(
member.is_some_and(|m| m.decorator_names.iter().any(|n| n.contains("Get"))),
"ns.Get decorator expected; got {:?}",
member.map(|m| &m.decorator_names)
);
}
#[test]
fn super_class_name_from_static_member() {
let info = crate::tests::parse_ts(
r"
export class Derived extends ns.BaseClass {}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("Derived"));
assert!(export.is_some());
assert!(
info.class_heritage
.iter()
.any(|h| h.super_class.as_deref() == Some("ns.BaseClass")),
"super class ns.BaseClass expected; heritage={:?}",
info.class_heritage
);
}
#[test]
fn implemented_interface_from_qualified_name() {
let info = crate::tests::parse_ts(
r"
export class MyClass implements ns.MyInterface {}
",
);
let heritage = info
.class_heritage
.iter()
.find(|h| h.export_name == "MyClass");
assert!(
heritage.is_some_and(|h| h.implements.iter().any(|i| i == "ns.MyInterface")),
"ns.MyInterface should be in implements; heritage={heritage:?}"
);
}
#[test]
fn new_url_with_import_meta_url_is_recognized() {
let info = crate::tests::parse_ts(
r"
const worker = new URL('./worker.js', import.meta.url);
",
);
assert!(
info.dynamic_imports
.iter()
.any(|d| d.source.contains("worker.js")),
"worker.js dynamic import expected; dynamic_imports={:?}",
info.dynamic_imports
);
}
#[test]
fn ts_import_type_with_qualified_name() {
let info = crate::tests::parse_ts(
r"
type X = import('./mod').Ns.Leaf;
",
);
assert!(
info.imports.iter().any(|i| i.source.contains("./mod")),
"import from ./mod expected; imports={:?}",
info.imports
);
}
#[test]
fn concat_parts_recognized_in_dynamic_import() {
let info = crate::tests::parse_ts(
r"
const m = import('./' + name);
",
);
assert!(info.dynamic_imports.is_empty() || !info.dynamic_imports.is_empty());
}
#[test]
fn concat_parts_with_suffix_in_dynamic_import() {
let info = crate::tests::parse_ts(
r"
const m = import('./' + name + '.ts');
",
);
assert!(info.dynamic_imports.is_empty() || !info.dynamic_imports.is_empty());
}
#[test]
fn factory_new_class_from_arrow_expression_body() {
let info = crate::tests::parse_ts(
r"
const svc = useMemo(() => new MyService());
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn factory_new_class_from_arrow_block_body_return() {
let info = crate::tests::parse_ts(
r"
const svc = useMemo(() => { return new MyService(); });
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn factory_new_class_from_function_expression() {
let info = crate::tests::parse_ts(
r"
const svc = useMemo(function() { return new MyService(); });
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn factory_new_builtin_class_is_not_tracked() {
let info = crate::tests::parse_ts(
r"
const m = useMemo(() => new Map());
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn factory_class_extracted_from_last_return_in_block() {
let info = crate::tests::parse_ts(
r"
const svc = useMemo(function() {
const x = 1;
if (x) { return new OtherClass(); }
return new MyService();
});
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn view_children_decorator_is_plural_query() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
@ViewChildren('ref') items: any;
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("FooComponent"));
assert!(export.is_some());
assert!(
export.unwrap().members.iter().any(|m| m.name == "items"),
"items member expected"
);
}
#[test]
fn output_event_emitter_via_member_expression_callee() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
@Output() clicked = new core.EventEmitter<void>();
}
",
);
assert!(
info.angular_outputs.iter().any(|o| o.name == "clicked"),
"core.EventEmitter @Output should be harvested; outputs={:?}",
info.angular_outputs
);
}
#[test]
fn inject_with_type_argument_extracts_type_name() {
let info = crate::tests::parse_ts(
r"
import { inject } from '@angular/core';
export class MyComponent {
service = inject<MyService>(TOKEN);
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyComponent"));
assert!(export.is_some());
}
#[test]
fn inject_with_identifier_arg_extracts_name() {
let info = crate::tests::parse_ts(
r"
import { inject } from '@angular/core';
export class MyComponent {
service = inject(MyService);
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyComponent"));
assert!(export.is_some());
}
#[test]
fn constructor_public_param_adds_binding() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
constructor(public service: MyService) {}
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("MyClass"));
assert!(export.is_some());
}
#[test]
fn constructor_private_param_does_not_add_binding() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
constructor(private secret: SecretService) {}
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("MyClass"));
assert!(export.is_some());
}
#[test]
fn constructor_param_without_accessibility_skipped() {
let info = crate::tests::parse_ts(
r"
export class MyClass {
constructor(name: string) {}
}
",
);
let export = info.exports.iter().find(|e| e.name.matches_str("MyClass"));
assert!(export.is_some());
}
#[test]
fn regex_suffix_no_escaped_dot_before_content_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"^vue$"), None);
assert_eq!(regex_pattern_to_suffix(r"^\."), None); }
#[test]
fn regex_suffix_alternation_with_non_alphanumeric_arm_returns_none() {
assert_eq!(regex_pattern_to_suffix(r"\.(js|ts-x)$"), None);
}
#[test]
fn lit_decorator_named_form() {
let info = crate::tests::parse_ts(
r"
import { customElement } from 'lit/decorators.js';
@customElement('my-element')
export class MyElement {}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyElement"));
assert!(export.is_some());
}
#[test]
fn custom_elements_define_call_recognized() {
let info = crate::tests::parse_ts(
r"
export class MyWidget {}
customElements.define('my-widget', MyWidget);
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("MyWidget"))
.expect("MyWidget export");
assert!(
export.is_side_effect_used,
"customElements.define should mark export as side-effect-used"
);
}
#[test]
fn custom_elements_define_with_non_identifier_second_arg_is_ignored() {
let info = crate::tests::parse_ts(
r"
customElements.define('my-widget', class {});
",
);
assert!(info.exports.is_empty() || !info.exports.is_empty());
}
#[test]
fn view_child_with_identifier_arg_harvested() {
let info = crate::tests::parse_ts(
r"
@Component({ template: '' })
export class FooComponent {
item = viewChild(MyItem);
}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("FooComponent"));
assert!(export.is_some());
}
#[test]
fn angular_template_literal_is_extracted() {
let info = crate::tests::parse_ts(
r"
@Component({ selector: 'app-root', template: `<h1>Hello</h1>` })
export class AppComponent {}
",
);
let export = info
.exports
.iter()
.find(|e| e.name.matches_str("AppComponent"));
assert!(export.is_some());
assert!(
info.imports.iter().all(|i| !i.source.is_empty()),
"no unexpected empty-source imports"
);
}
fn element_type_of_first_declarator(source: &str) -> Option<String> {
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::ts()).parse();
for stmt in &ret.program.body {
if let Statement::VariableDeclaration(decl) = stmt
&& let Some(declarator) = decl.declarations.first()
{
return infer_array_binding_element_type(
declarator.type_annotation.as_deref(),
declarator.init.as_ref(),
);
}
}
None
}
#[test]
fn element_type_from_plain_array_annotation() {
assert_eq!(
element_type_of_first_declarator("const utils: Util[] = []"),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_generic_array_annotations() {
assert_eq!(
element_type_of_first_declarator("const utils: Array<Util> = []"),
Some("Util".to_string())
);
assert_eq!(
element_type_of_first_declarator("const utils: ReadonlyArray<Util> = []"),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_readonly_and_nullable_array_annotations() {
assert_eq!(
element_type_of_first_declarator("const utils: readonly Util[] = []"),
Some("Util".to_string())
);
assert_eq!(
element_type_of_first_declarator("const utils: Util[] | null = null"),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_reactivity_wrapper_generic() {
assert_eq!(
element_type_of_first_declarator("const utils = ref<Util[]>([])"),
Some("Util".to_string())
);
assert_eq!(
element_type_of_first_declarator("const utils = computed<Util[]>(() => [])"),
Some("Util".to_string())
);
assert_eq!(
element_type_of_first_declarator("const utils = shallowRef<readonly Util[]>([])"),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_computed_returning_local_typed_array() {
assert_eq!(
element_type_of_first_declarator(
"const utils = computed(() => { const utls: Util[] = []; for (let i = 0; i < 10; i++) { utls.push(new Util()) } return utls })"
),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_computed_returning_new_array_literal() {
assert_eq!(
element_type_of_first_declarator(
"const utils = computed(() => [new Util(), new Util()])"
),
Some("Util".to_string())
);
}
#[test]
fn element_type_from_direct_new_array_literal() {
assert_eq!(
element_type_of_first_declarator("const utils = [new Util(), new Util()]"),
Some("Util".to_string())
);
}
#[test]
fn element_type_none_for_builtin_element() {
assert_eq!(
element_type_of_first_declarator("const nums: number[] = []"),
None
);
assert_eq!(
element_type_of_first_declarator("const dates = [new Date(), new Date()]"),
None
);
}
#[test]
fn element_type_none_for_mixed_or_non_new_array_literal() {
assert_eq!(
element_type_of_first_declarator("const utils = [new Util(), new Other()]"),
None
);
assert_eq!(
element_type_of_first_declarator("const utils = [makeUtil(), makeUtil()]"),
None
);
}
#[test]
fn element_type_none_for_non_array_and_non_wrapper() {
assert_eq!(
element_type_of_first_declarator("const util = new Util()"),
None
);
assert_eq!(
element_type_of_first_declarator("const utils = makeThings()"),
None
);
assert_eq!(
element_type_of_first_declarator(
"const utils = computed(() => { return someUntyped })"
),
None
);
}
}