use swc_ecma_ast::{
Class, ClassMember, Expr, IdentName, MemberExpr, MemberProp, MethodKind, PropName, Stmt,
};
use swc_ecma_visit::{Visit, VisitWith};
use crate::core::ast::parser::ParsedModule;
use crate::core::recipe::{FileAnalysis, FileClassification};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReactClassPattern {
ClassComponent,
ComponentDidMount,
ComponentDidUpdate,
ComponentWillUnmount,
Unsupported,
}
impl ReactClassPattern {
fn label(self) -> &'static str {
match self {
Self::ClassComponent => "react class component",
Self::ComponentDidMount => "componentDidMount",
Self::ComponentDidUpdate => "componentDidUpdate",
Self::ComponentWillUnmount => "componentWillUnmount",
Self::Unsupported => "unsupported class component",
}
}
}
#[derive(Default)]
struct ReactClassVisitor {
components: Vec<ComponentAnalysis>,
}
#[derive(Debug, Default)]
struct ComponentAnalysis {
lifecycle_methods: Vec<ReactClassPattern>,
unsupported: bool,
}
impl ComponentAnalysis {
fn push_lifecycle(&mut self, pattern: ReactClassPattern) {
if !self.lifecycle_methods.contains(&pattern) {
self.lifecycle_methods.push(pattern);
}
}
}
impl Visit for ReactClassVisitor {
fn visit_class(&mut self, class: &Class) {
if is_react_component_class(class) {
self.components.push(analyze_component_class(class));
}
class.visit_children_with(self);
}
}
pub fn analyze_parsed_module(parsed: &ParsedModule) -> Option<FileAnalysis> {
let mut visitor = ReactClassVisitor::default();
parsed.module.visit_with(&mut visitor);
if visitor.components.is_empty() {
return None;
}
let unsupported = visitor
.components
.iter()
.any(|component| component.unsupported);
let mut patterns = vec![ReactClassPattern::ClassComponent];
for component in &visitor.components {
for lifecycle in &component.lifecycle_methods {
if !patterns.contains(lifecycle) {
patterns.push(*lifecycle);
}
}
}
if unsupported {
patterns.push(ReactClassPattern::Unsupported);
}
let classification = if unsupported {
FileClassification::Risky
} else {
FileClassification::Safe
};
Some(FileAnalysis {
path: parsed.path.clone(),
detected_patterns: patterns
.iter()
.map(|pattern| pattern.label().to_string())
.collect(),
confidence_score: confidence_score(&patterns),
classification,
is_transform_safe: classification == FileClassification::Safe,
tags: Default::default(),
})
}
fn analyze_component_class(class: &Class) -> ComponentAnalysis {
let mut analysis = ComponentAnalysis::default();
for member in &class.body {
match member {
ClassMember::Method(method) => {
if method.kind != MethodKind::Method {
analysis.unsupported = true;
continue;
}
match prop_name(&method.key).as_deref() {
Some("componentDidMount") => {
analysis.push_lifecycle(ReactClassPattern::ComponentDidMount);
}
Some("componentDidUpdate") => {
analysis.push_lifecycle(ReactClassPattern::ComponentDidUpdate);
}
Some("componentWillUnmount") => {
analysis.push_lifecycle(ReactClassPattern::ComponentWillUnmount);
}
Some("render") => {}
Some(_) => {
analysis.unsupported = true;
}
None => {
analysis.unsupported = true;
}
}
}
ClassMember::ClassProp(prop) => {
let name = prop_name(&prop.key);
if name.as_deref() == Some("state") {
continue;
}
let mut is_arrow_fn = false;
if let Some(ref val) = prop.value {
if let Expr::Arrow(_) = &**val {
is_arrow_fn = true;
}
}
if !is_arrow_fn {
analysis.unsupported = true;
}
}
ClassMember::Constructor(ctor) => {
let mut safe = true;
if let Some(body) = &ctor.body {
for stmt in &body.stmts {
if let Stmt::Expr(expr_stmt) = stmt {
if let Expr::Call(call_expr) = &*expr_stmt.expr {
if let swc_ecma_ast::Callee::Super(_) = &call_expr.callee {
continue;
}
}
}
let mut is_this_state = false;
if let Stmt::Expr(expr_stmt) = stmt {
if let Expr::Assign(assign_expr) = &*expr_stmt.expr {
if let swc_ecma_ast::AssignTarget::Simple(simple_target) = &assign_expr.left {
if let swc_ecma_ast::SimpleAssignTarget::Member(member_expr) = simple_target {
if let Expr::This(_) = &*member_expr.obj {
if let MemberProp::Ident(id) = &member_expr.prop {
if id.sym == *"state" {
is_this_state = true;
}
}
}
}
}
}
}
if is_this_state {
continue;
}
safe = false;
break;
}
}
if !safe {
analysis.unsupported = true;
}
}
_ => {
analysis.unsupported = true;
}
}
}
analysis
}
fn confidence_score(patterns: &[ReactClassPattern]) -> u8 {
let mut score = 45u8;
for pattern in patterns {
score = score.saturating_add(match pattern {
ReactClassPattern::ClassComponent => 10,
ReactClassPattern::ComponentDidMount => 15,
ReactClassPattern::ComponentDidUpdate => 15,
ReactClassPattern::ComponentWillUnmount => 15,
ReactClassPattern::Unsupported => 0,
});
}
score.min(100)
}
fn is_react_component_class(class: &Class) -> bool {
class
.super_class
.as_ref()
.is_some_and(|super_class| is_react_component_expr(super_class))
}
fn is_react_component_expr(expr: &Expr) -> bool {
match expr {
Expr::Ident(ident) => matches!(ident.sym.as_ref(), "Component" | "PureComponent"),
Expr::Member(member) => is_react_component_member(member),
_ => false,
}
}
fn is_react_component_member(member: &MemberExpr) -> bool {
match (&*member.obj, &member.prop) {
(Expr::Ident(object), MemberProp::Ident(IdentName { sym, .. }))
if object.sym == *"React" =>
{
matches!(sym.as_ref(), "Component" | "PureComponent")
}
_ => false,
}
}
fn prop_name(name: &PropName) -> Option<String> {
match name {
PropName::Ident(ident) => Some(ident.sym.to_string()),
PropName::Str(string) => Some(string.value.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::analyze_parsed_module;
use crate::core::ast::parser::parse_source;
use crate::core::recipe::FileClassification;
use std::path::Path;
fn analyze(source: &str) -> crate::core::recipe::FileAnalysis {
let parsed = parse_source(Path::new("fixture.jsx"), source).expect("source should parse");
analyze_parsed_module(&parsed).expect("analysis should detect react class component")
}
#[test]
fn detects_simple_react_class_component() {
let analysis = analyze(
"class App extends React.Component { render() { return <div />; } }",
);
assert_eq!(analysis.classification, FileClassification::Safe);
assert!(analysis.is_transform_safe);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "react class component")
);
}
#[test]
fn detects_lifecycle_methods() {
let analysis = analyze(
"class App extends Component {
componentDidMount() {}
componentDidUpdate() {}
componentWillUnmount() {}
render() { return <div />; }
}",
);
assert_eq!(analysis.classification, FileClassification::Safe);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "componentDidMount")
);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "componentDidUpdate")
);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "componentWillUnmount")
);
}
#[test]
fn classifies_unsupported_members_as_risky() {
let analysis = analyze(
"class App extends React.Component {
handleClick() {}
render() { return <button />; }
}",
);
assert_eq!(analysis.classification, FileClassification::Risky);
assert!(!analysis.is_transform_safe);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "unsupported class component")
);
}
#[test]
fn skips_non_react_classes() {
let parsed = parse_source(Path::new("fixture.jsx"), "class App { render() { return null; } }")
.expect("source should parse");
assert!(analyze_parsed_module(&parsed).is_none());
}
}