morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
mod detect;
mod transform;

use std::path::Path;

use anyhow::Result;
use indicatif::ProgressBar;
use walkdir::WalkDir;

use crate::core::ast::parser::parse_file;
use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
use crate::core::recipe::{
    DetectionFailure, DetectionReport, Recipe, RecipeMetadata, TransformOptions, TransformReport,
};

use self::detect::analyze_parsed_module;
use self::transform::transform_report;

pub struct ReactClassToHooksRecipe;

const METADATA: RecipeMetadata = RecipeMetadata {
    name: "react-class-to-hooks",
    description: "Detects React class components before a future hooks migration.",
    supported_extensions: &["js", "jsx", "ts", "tsx"],
    required_recipes: &[],
    incompatible_recipes: &[],
    should_run_before: &[],
    should_run_after: &["js-to-ts"],
    maturity: crate::core::recipe::RecipeMaturity::Experimental,
    category: crate::core::recipe::RecipeCategory::Experimental,
    tags: &["risky", "react", "frontend", "hooks", "components", "class-components"],
};

impl Recipe for ReactClassToHooksRecipe {
    fn metadata(&self) -> &'static RecipeMetadata {
        &METADATA
    }

    fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
        let mut report = DetectionReport::default();

        for entry in WalkDir::new(root)
            .into_iter()
            .filter_entry(|e| {
                let name = e.file_name().to_string_lossy();
                name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
            })
            .filter_map(|entry| entry.ok())
        {
            let path = entry.path();
            progress.set_message(format!("Scanning {}", path.display()));

            if entry.file_type().is_file() && has_supported_extension(path) {
                report.total_files += 1;

                if let Some(outcome) = load_detection(METADATA.name, path) {
                    apply_cached_outcome(&mut report, outcome);
                    continue;
                }
                record_miss();

                match parse_file(path) {
                    Ok(parsed) => {
                        report.parseable_files += 1;

                        if let Some(analysis) = analyze_parsed_module(&parsed) {
                            let _ = save_detection(
                                METADATA.name,
                                path,
                                &CachedDetectionOutcome::Analysis(analysis.clone()),
                            );
                            report.analyses.push(analysis);
                        } else {
                            let skipped = path.to_path_buf();
                            let _ = save_detection(
                                METADATA.name,
                                path,
                                &CachedDetectionOutcome::Skipped(skipped.clone()),
                            );
                            report.skipped_files.push(skipped);
                        }
                    }
                    Err(error) => {
                        let failure = DetectionFailure {
                            path: error.path().to_path_buf(),
                            error: error.to_string(),
                        };
                        let _ = save_detection(
                            METADATA.name,
                            path,
                            &CachedDetectionOutcome::Failure(failure.clone()),
                        );
                        report.failed_files.push(failure);
                    }
                }
            }
        }

        Ok(report)
    }

    fn transform(
        &self,
        report: &DetectionReport,
        options: TransformOptions,
    ) -> Result<TransformReport> {
        transform_report(report, options)
    }
}

fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
    match outcome {
        CachedDetectionOutcome::Analysis(analysis) => {
            report.parseable_files += 1;
            report.analyses.push(analysis);
        }
        CachedDetectionOutcome::Skipped(path) => {
            report.parseable_files += 1;
            report.skipped_files.push(path);
        }
        CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
    }
}

fn has_supported_extension(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| METADATA.supported_extensions.contains(&extension))
}