morph_cli/recipes/commonjs_to_esm/
mod.rs1mod detect;
2pub(crate) mod safety;
3mod transform;
4
5use std::path::Path;
6
7use anyhow::Result;
8use indicatif::ProgressBar;
9use walkdir::WalkDir;
10
11use crate::core::ast::parser::parse_file;
12use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
13use crate::core::recipe::{
14 DetectionFailure, DetectionReport, Recipe, RecipeMetadata, TransformOptions, TransformReport,
15};
16
17use self::detect::analyze_parsed_module;
18use self::transform::transform_report;
19
20pub struct CommonJsToEsmRecipe;
21
22const METADATA: RecipeMetadata = RecipeMetadata {
23 name: "commonjs-to-esm",
24 description: "Scans JavaScript CommonJS candidates and prepares a future ESM migration.",
25 supported_extensions: &["js", "cjs", "mjs"],
26 required_recipes: &[],
27 incompatible_recipes: &[],
28 should_run_before: &["js-to-ts"],
29 should_run_after: &[],
30 maturity: crate::core::recipe::RecipeMaturity::Stable,
31 category: crate::core::recipe::RecipeCategory::Migration,
32 tags: &["safe", "fast", "backend", "commonjs", "esm", "require", "import", "exports"],
33};
34
35impl Recipe for CommonJsToEsmRecipe {
36 fn metadata(&self) -> &'static RecipeMetadata {
37 &METADATA
38 }
39
40 fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
41 let mut report = DetectionReport::default();
42
43 for entry in WalkDir::new(root)
44 .into_iter()
45 .filter_entry(|e| {
46 let name = e.file_name().to_string_lossy();
47 name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
48 })
49 .filter_map(|entry| entry.ok())
50 {
51 let path = entry.path();
52 progress.set_message(format!("Scanning {}", path.display()));
53
54 if entry.file_type().is_file() && has_supported_extension(path) {
55 report.total_files += 1;
56
57 if let Some(outcome) = load_detection(METADATA.name, path) {
58 apply_cached_outcome(&mut report, outcome);
59 continue;
60 }
61 record_miss();
62
63 match parse_file(path) {
64 Ok(parsed) => {
65 report.parseable_files += 1;
66
67 if let Some(analysis) = analyze_parsed_module(&parsed) {
68 let _ = save_detection(
69 METADATA.name,
70 path,
71 &CachedDetectionOutcome::Analysis(analysis.clone()),
72 );
73 report.analyses.push(analysis);
74 } else {
75 let skipped = path.to_path_buf();
76 let _ = save_detection(
77 METADATA.name,
78 path,
79 &CachedDetectionOutcome::Skipped(skipped.clone()),
80 );
81 report.skipped_files.push(path.to_path_buf());
82 }
83 }
84 Err(error) => {
85 let failure = DetectionFailure {
86 path: error.path().to_path_buf(),
87 error: error.to_string(),
88 };
89 let _ = save_detection(
90 METADATA.name,
91 path,
92 &CachedDetectionOutcome::Failure(failure.clone()),
93 );
94 report.failed_files.push(failure);
95 }
96 }
97 }
98 }
99
100 Ok(report)
101 }
102
103 fn transform(
104 &self,
105 report: &DetectionReport,
106 options: TransformOptions,
107 ) -> Result<TransformReport> {
108 transform_report(report, options)
109 }
110}
111
112fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
113 match outcome {
114 CachedDetectionOutcome::Analysis(analysis) => {
115 report.parseable_files += 1;
116 report.analyses.push(analysis);
117 }
118 CachedDetectionOutcome::Skipped(path) => {
119 report.parseable_files += 1;
120 report.skipped_files.push(path);
121 }
122 CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
123 }
124}
125
126fn has_supported_extension(path: &Path) -> bool {
127 path.extension()
128 .and_then(|extension| extension.to_str())
129 .is_some_and(|extension| METADATA.supported_extensions.contains(&extension))
130}