Skip to main content

morph_cli/recipes/js_to_ts/
mod.rs

1mod detect;
2mod inference;
3mod transform;
4mod types;
5
6use std::path::Path;
7
8use anyhow::Result;
9use indicatif::ProgressBar;
10
11use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
12use crate::core::recipe::{
13    DetectionFailure, DetectionReport, FileClassification, Recipe, RecipeMetadata,
14    TransformOptions, TransformReport,
15};
16use crate::recipes::js_to_ts::transform::{JsToTsTransform, rename_file};
17use crate::recipes::js_to_ts::types::MigrationMode;
18
19const METADATA: RecipeMetadata = RecipeMetadata {
20    name: "js-to-ts",
21    description: "Migrate JavaScript files to TypeScript with extension renaming and type inference scaffolding.",
22    supported_extensions: &["js", "jsx", "mjs", "ts", "tsx", "mts"],
23    required_recipes: &[],
24    incompatible_recipes: &[],
25    should_run_before: &["react-class-to-hooks"],
26    should_run_after: &["commonjs-to-esm"],
27    maturity: crate::core::recipe::RecipeMaturity::Stable,
28    category: crate::core::recipe::RecipeCategory::Modernization,
29    tags: &["risky", "typescript", "js-to-ts", "type-safety", "modernize"],
30};
31
32pub struct JsToTsRecipe {
33    mode: MigrationMode,
34    rename_extensions: bool,
35    infer_types: bool,
36}
37
38impl JsToTsRecipe {
39    pub fn new(mode: MigrationMode, rename_extensions: bool, infer_types: bool) -> Self {
40        Self {
41            mode,
42            rename_extensions,
43            infer_types,
44        }
45    }
46
47    pub fn conservative() -> Self {
48        Self::new(MigrationMode::Conservative, true, false)
49    }
50}
51
52impl Recipe for JsToTsRecipe {
53    fn metadata(&self) -> &'static RecipeMetadata {
54        &METADATA
55    }
56
57    fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
58        let mut report = DetectionReport::default();
59
60        for entry in walkdir::WalkDir::new(root)
61            .into_iter()
62            .filter_entry(|e| {
63                let name = e.file_name().to_string_lossy();
64                name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
65            })
66            .filter_map(|entry| entry.ok())
67        {
68            let path = entry.path();
69            progress.set_message(format!("Scanning {}", path.display()));
70
71            if !entry.file_type().is_file() {
72                continue;
73            }
74
75            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
76            if !METADATA.supported_extensions.contains(&ext) {
77                continue;
78            }
79
80            report.total_files += 1;
81
82            if let Some(outcome) = load_detection(METADATA.name, path) {
83                apply_cached_outcome(&mut report, outcome);
84                continue;
85            }
86            record_miss();
87
88            match std::fs::read_to_string(path) {
89                Ok(source) => {
90                    report.parseable_files += 1;
91
92                    if let Some(analysis) = detect::analyze_js_file(path, &source, self.mode) {
93                        let _ = save_detection(
94                            METADATA.name,
95                            path,
96                            &CachedDetectionOutcome::Analysis(analysis.clone()),
97                        );
98                        report.analyses.push(analysis);
99                    } else {
100                        let skipped = path.to_path_buf();
101                        let _ = save_detection(
102                            METADATA.name,
103                            path,
104                            &CachedDetectionOutcome::Skipped(skipped.clone()),
105                        );
106                        report.skipped_files.push(skipped);
107                    }
108                }
109                Err(error) => {
110                    let failure = DetectionFailure {
111                        path: path.to_path_buf(),
112                        error: format!("failed to read file: {}", error),
113                    };
114                    let _ = save_detection(
115                        METADATA.name,
116                        path,
117                        &CachedDetectionOutcome::Failure(failure.clone()),
118                    );
119                    report.failed_files.push(failure);
120                }
121            }
122        }
123
124        Ok(report)
125    }
126
127    fn transform(
128        &self,
129        report: &DetectionReport,
130        options: TransformOptions,
131    ) -> Result<TransformReport> {
132        let mut transform_report = TransformReport::default();
133        let transformer = JsToTsTransform::new(self.mode, self.rename_extensions, self.infer_types);
134
135        for analysis in &report.analyses {
136            if !transformer.should_transform(analysis) {
137                transform_report
138                    .skipped_files
139                    .push(crate::core::recipe::SkippedTransform {
140                        path: analysis.path.clone(),
141                        reason: "File not eligible for transformation".to_string(),
142                    });
143                continue;
144            }
145
146            if analysis.classification == FileClassification::Risky
147                && options.mode == crate::core::recipe::TransformMode::DryRun
148            {
149                transform_report.unsupported_patterns.push(
150                    crate::core::recipe::UnsupportedPatternReport {
151                        path: analysis.path.clone(),
152                        patterns: analysis.detected_patterns.clone(),
153                    },
154                );
155                continue;
156            }
157
158            match transformer.transform_file(analysis) {
159                Ok(result) => {
160                    let mut should_write = result.has_changes() && options.mode == crate::core::recipe::TransformMode::Write;
161
162                    if should_write && options.review {
163                        let original_content = std::fs::read_to_string(&result.original_path).unwrap_or_default();
164                        let renderer = crate::core::diff::renderer::DiffRenderer::new(
165                            crate::core::diff::preview::PreviewConfig {
166                                max_lines: 100,
167                                show_line_numbers: true,
168                                summary_only: false,
169                                verbose: false,
170                            },
171                        );
172                        println!("\nRename {} to {}", result.original_path.display(), result.target_path.display());
173                        match crate::core::diff::preview::prompt_review(&result.original_path, &original_content, &original_content, &renderer) {
174                            Ok(crate::core::diff::preview::ReviewAction::Apply) => should_write = true,
175                            Ok(crate::core::diff::preview::ReviewAction::Skip) => {
176                                transform_report.skipped_files.push(crate::core::recipe::SkippedTransform {
177                                    path: analysis.path.clone(),
178                                    reason: "Skipped by user".to_string(),
179                                });
180                                continue;
181                            }
182                            Ok(crate::core::diff::preview::ReviewAction::Abort) => {
183                                anyhow::bail!("Migration aborted by user");
184                            }
185                            Err(e) => anyhow::bail!("Review error: {}", e),
186                        }
187                    }
188
189                    if should_write
190                        && rename_file(&result.original_path, &result.target_path).is_err()
191                    {
192                        let err = std::io::Error::other("rename failed");
193                        eprintln!(
194                            "Warning: Failed to rename {}: {}",
195                            result.original_path.display(),
196                            err
197                        );
198                        transform_report.unsupported_patterns.push(
199                            crate::core::recipe::UnsupportedPatternReport {
200                                path: analysis.path.clone(),
201                                patterns: vec![err.to_string()],
202                            },
203                        );
204                        continue;
205                    }
206
207                    if result.has_changes() {
208                        transform_report.changed_files.push(result.target_path);
209                    }
210
211                    for warning in &result.warnings {
212                        eprintln!("Warning for {}: {}", analysis.path.display(), warning);
213                    }
214                }
215                Err(e) => {
216                    transform_report.unsupported_patterns.push(
217                        crate::core::recipe::UnsupportedPatternReport {
218                            path: analysis.path.clone(),
219                            patterns: vec![e.to_string()],
220                        },
221                    );
222                }
223            }
224        }
225
226        Ok(transform_report)
227    }
228}
229
230fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
231    match outcome {
232        CachedDetectionOutcome::Analysis(analysis) => {
233            report.parseable_files += 1;
234            report.analyses.push(analysis);
235        }
236        CachedDetectionOutcome::Skipped(path) => {
237            report.parseable_files += 1;
238            report.skipped_files.push(path);
239        }
240        CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_metadata() {
250        let recipe = JsToTsRecipe::conservative();
251        let metadata = recipe.metadata();
252        assert_eq!(metadata.name, "js-to-ts");
253        assert!(!metadata.supported_extensions.is_empty());
254    }
255
256    #[test]
257    fn test_conservative_mode() {
258        let recipe = JsToTsRecipe::conservative();
259        assert!(matches!(recipe.mode, MigrationMode::Conservative));
260    }
261}