morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
#![allow(clippy::all)]
use std::path::Path;
use swc_common::{FileName, SourceMap, Spanned, sync::Lrc};
use swc_ecma_ast::*;
use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax, lexer::Lexer};
use swc_ecma_visit::{Visit, VisitWith};

use crate::recipes::express_to_fastify::analysis::{MigrationAnalysis, RouteInfo};

pub struct ExpressDetector {
    analysis: MigrationAnalysis,
}

impl ExpressDetector {
    pub fn new() -> Self {
        Self {
            analysis: MigrationAnalysis::default(),
        }
    }

    pub fn detect(&mut self, path: &Path) -> Option<MigrationAnalysis> {
        let source = std::fs::read_to_string(path).ok()?;
        let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
        let fm = cm.new_source_file(FileName::Real(path.to_path_buf()).into(), source);

        let lexer = Lexer::new(
            Syntax::Es(EsSyntax::default()),
            Default::default(),
            StringInput::from(&*fm),
            None,
        );
        let mut parser = Parser::new_from(lexer);
        let module = parser.parse_module().ok()?;

        self.analyze_module(&module);
        Some(self.analysis.clone())
    }

    fn analyze_module(&mut self, module: &Module) {
        let mut visitor = ExpressVisitor::new();
        visitor.visit_module(module);

        self.analysis.routes = visitor.routes;
        self.analysis.middleware_count = visitor.middleware_count;
        self.analysis.has_body_parser = visitor.has_body_parser;
        self.analysis.has_auth_middleware = visitor.has_auth_middleware;
        self.analysis.has_error_handler = visitor.has_error_handler;
        self.analysis.async_handlers = visitor.async_handlers;
        self.analysis.express_apps = visitor.express_apps;

        self.analysis.classify_complexity();
        self.analysis.detect_risky_patterns();
        self.analysis.suggest_recipes();
    }
}

impl Default for ExpressDetector {
    fn default() -> Self {
        Self::new()
    }
}

struct ExpressVisitor {
    routes: Vec<RouteInfo>,
    middleware_count: usize,
    has_body_parser: bool,
    has_auth_middleware: bool,
    has_error_handler: bool,
    async_handlers: usize,
    express_apps: Vec<usize>,
}

impl ExpressVisitor {
    fn new() -> Self {
        Self {
            routes: Vec::new(),
            middleware_count: 0,
            has_body_parser: false,
            has_auth_middleware: false,
            has_error_handler: false,
            async_handlers: 0,
            express_apps: Vec::new(),
        }
    }
}

impl Visit for ExpressVisitor {
    #[allow(clippy::collapsible_if)]
    fn visit_call_expr(&mut self, call: &CallExpr) {
        if let Callee::Expr(expr) = &call.callee {
            if let Expr::Member(MemberExpr { obj, prop, .. }) = expr.as_ref() {
                if let Expr::Ident(i) = obj.as_ref() {
                    if let MemberProp::Ident(p) = prop {
                        let _obj_name = i.sym.as_ref();
                        let method = p.sym.as_ref();
                        let http_methods =
                            ["get", "post", "put", "delete", "patch", "head", "options"];

                        if http_methods.contains(&method) {
                            let path = self.extract_path(call);
                            let is_async = self.is_async_handler(call);
                            if is_async {
                                self.async_handlers += 1;
                            }
                            self.routes.push(RouteInfo {
                                method: method.to_string(),
                                path: path.unwrap_or_else(|| "unknown".to_string()),
                                handler_type: if is_async {
                                    "async".to_string()
                                } else {
                                    "sync".to_string()
                                },
                                middleware_count: 0,
                                estimated_complexity: 1,
                            });
                        }

                        if method == "use" {
                            self.middleware_count += 1;
                            if let Some(arg) = call.args.first() {
                                if let Expr::Ident(ident) = arg.expr.as_ref() {
                                    let name = ident.sym.as_ref();
                                    if name.contains("body") || name.contains("parser") {
                                        self.has_body_parser = true;
                                    }
                                    if name.contains("auth") || name.contains("jwt") {
                                        self.has_auth_middleware = true;
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        call.visit_children_with(self);
    }

    fn visit_var_decl(&mut self, decl: &VarDecl) {
        for declarator in &decl.decls {
            if let Some(init) = &declarator.init {
                if let Expr::Call(call) = init.as_ref() {
                    if let Callee::Expr(expr) = &call.callee {
                        if let Expr::Ident(i) = expr.as_ref() {
                            let name = i.sym.as_ref();
                            if name == "express" || name == "Router" || name.contains("express") {
                                self.express_apps.push(declarator.name.span().lo.0 as usize);
                            }
                        }
                    }
                }
            }
        }
        decl.visit_children_with(self);
    }
}

impl ExpressVisitor {
    fn extract_path(&self, call: &CallExpr) -> Option<String> {
        if let Some(first) = call.args.first() {
            if let Expr::Lit(Lit::Str(s)) = first.expr.as_ref() {
                return Some(s.value.to_string());
            }
        }
        None
    }

    fn is_async_handler(&self, call: &CallExpr) -> bool {
        if let Some(last) = call.args.last() {
            if let Expr::Fn(f) = last.expr.as_ref() {
                if let Some(body) = &f.function.body {
                    for stmt in body.stmts.iter().rev() {
                        if let Stmt::Return(ret) = stmt {
                            if let Some(arg) = &ret.arg {
                                if matches!(arg.as_ref(), Expr::Await(_)) {
                                    return true;
                                }
                            }
                        }
                    }
                }
            }
        }
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_routes() {
        let source = r#"
            const express = require('express');
            const app = express();
            app.get('/users', (req, res) => res.json([]));
            app.post('/users', (req, res) => res.json(req.body));
        "#;
        let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
        let source_str = source.to_string();
        let fm = cm.new_source_file(FileName::Custom("test.js".into()).into(), source_str);
        let lexer = Lexer::new(
            Syntax::Es(EsSyntax::default()),
            Default::default(),
            StringInput::from(&*fm),
            None,
        );
        let mut parser = Parser::new_from(lexer);
        let module = parser.parse_module().unwrap();

        let mut detector = ExpressDetector::new();
        detector.analyze_module(&module);
        assert!(detector.analysis.routes.len() >= 2);
    }
}