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