morph-cli 0.1.0

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

use std::path::Path;

use anyhow::Result;
use indicatif::ProgressBar;

use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
use crate::core::recipe::{
    DetectionFailure, DetectionReport, FileClassification, Recipe, RecipeMetadata,
    TransformOptions, TransformReport,
};
use crate::recipes::js_to_ts::transform::{JsToTsTransform, rename_file};
use crate::recipes::js_to_ts::types::MigrationMode;

const METADATA: RecipeMetadata = RecipeMetadata {
    name: "js-to-ts",
    description: "Migrate JavaScript files to TypeScript with extension renaming and type inference scaffolding.",
    supported_extensions: &["js", "jsx", "mjs", "ts", "tsx", "mts"],
    required_recipes: &[],
    incompatible_recipes: &[],
    should_run_before: &["react-class-to-hooks"],
    should_run_after: &["commonjs-to-esm"],
    maturity: crate::core::recipe::RecipeMaturity::Stable,
    category: crate::core::recipe::RecipeCategory::Modernization,
    tags: &["risky", "typescript", "js-to-ts", "type-safety", "modernize"],
};

pub struct JsToTsRecipe {
    mode: MigrationMode,
    rename_extensions: bool,
    infer_types: bool,
}

impl JsToTsRecipe {
    pub fn new(mode: MigrationMode, rename_extensions: bool, infer_types: bool) -> Self {
        Self {
            mode,
            rename_extensions,
            infer_types,
        }
    }

    pub fn conservative() -> Self {
        Self::new(MigrationMode::Conservative, true, false)
    }
}

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

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

        for entry in walkdir::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() {
                continue;
            }

            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            if !METADATA.supported_extensions.contains(&ext) {
                continue;
            }

            report.total_files += 1;

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

            match std::fs::read_to_string(path) {
                Ok(source) => {
                    report.parseable_files += 1;

                    if let Some(analysis) = detect::analyze_js_file(path, &source, self.mode) {
                        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: path.to_path_buf(),
                        error: format!("failed to read file: {}", error),
                    };
                    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> {
        let mut transform_report = TransformReport::default();
        let transformer = JsToTsTransform::new(self.mode, self.rename_extensions, self.infer_types);

        for analysis in &report.analyses {
            if !transformer.should_transform(analysis) {
                transform_report
                    .skipped_files
                    .push(crate::core::recipe::SkippedTransform {
                        path: analysis.path.clone(),
                        reason: "File not eligible for transformation".to_string(),
                    });
                continue;
            }

            if analysis.classification == FileClassification::Risky
                && options.mode == crate::core::recipe::TransformMode::DryRun
            {
                transform_report.unsupported_patterns.push(
                    crate::core::recipe::UnsupportedPatternReport {
                        path: analysis.path.clone(),
                        patterns: analysis.detected_patterns.clone(),
                    },
                );
                continue;
            }

            match transformer.transform_file(analysis) {
                Ok(result) => {
                    let mut should_write = result.has_changes() && options.mode == crate::core::recipe::TransformMode::Write;

                    if should_write && options.review {
                        let original_content = std::fs::read_to_string(&result.original_path).unwrap_or_default();
                        let renderer = crate::core::diff::renderer::DiffRenderer::new(
                            crate::core::diff::preview::PreviewConfig {
                                max_lines: 100,
                                show_line_numbers: true,
                                summary_only: false,
                                verbose: false,
                            },
                        );
                        println!("\nRename {} to {}", result.original_path.display(), result.target_path.display());
                        match crate::core::diff::preview::prompt_review(&result.original_path, &original_content, &original_content, &renderer) {
                            Ok(crate::core::diff::preview::ReviewAction::Apply) => should_write = true,
                            Ok(crate::core::diff::preview::ReviewAction::Skip) => {
                                transform_report.skipped_files.push(crate::core::recipe::SkippedTransform {
                                    path: analysis.path.clone(),
                                    reason: "Skipped by user".to_string(),
                                });
                                continue;
                            }
                            Ok(crate::core::diff::preview::ReviewAction::Abort) => {
                                anyhow::bail!("Migration aborted by user");
                            }
                            Err(e) => anyhow::bail!("Review error: {}", e),
                        }
                    }

                    if should_write
                        && rename_file(&result.original_path, &result.target_path).is_err()
                    {
                        let err = std::io::Error::other("rename failed");
                        eprintln!(
                            "Warning: Failed to rename {}: {}",
                            result.original_path.display(),
                            err
                        );
                        transform_report.unsupported_patterns.push(
                            crate::core::recipe::UnsupportedPatternReport {
                                path: analysis.path.clone(),
                                patterns: vec![err.to_string()],
                            },
                        );
                        continue;
                    }

                    if result.has_changes() {
                        transform_report.changed_files.push(result.target_path);
                    }

                    for warning in &result.warnings {
                        eprintln!("Warning for {}: {}", analysis.path.display(), warning);
                    }
                }
                Err(e) => {
                    transform_report.unsupported_patterns.push(
                        crate::core::recipe::UnsupportedPatternReport {
                            path: analysis.path.clone(),
                            patterns: vec![e.to_string()],
                        },
                    );
                }
            }
        }

        Ok(transform_report)
    }
}

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),
    }
}

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

    #[test]
    fn test_metadata() {
        let recipe = JsToTsRecipe::conservative();
        let metadata = recipe.metadata();
        assert_eq!(metadata.name, "js-to-ts");
        assert!(!metadata.supported_extensions.is_empty());
    }

    #[test]
    fn test_conservative_mode() {
        let recipe = JsToTsRecipe::conservative();
        assert!(matches!(recipe.mode, MigrationMode::Conservative));
    }
}