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