morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
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());
    }
}