use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Result, anyhow};
use swc_common::{SourceMapper, Spanned};
use swc_ecma_ast::{
Class, ClassMember, Decl, Expr, IdentName, MemberExpr, MemberProp, MethodKind, ModuleItem,
PropName, Stmt,
};
use crate::core::ast::parser::{ParsedModule, parse_file};
use crate::core::recipe::{
DetectionReport, SkippedTransform, TransformMode, TransformOptions, TransformReport,
};
pub fn transform_report(
report: &DetectionReport,
options: TransformOptions,
) -> Result<TransformReport> {
let mut summary = TransformReport::default();
for analysis in &report.analyses {
match transform_file(&analysis.path, options) {
Ok(TransformOutcome::Changed) => summary.changed_files.push(analysis.path.clone()),
Ok(TransformOutcome::Skipped(reason)) => summary.skipped_files.push(SkippedTransform {
path: analysis.path.clone(),
reason,
}),
Err(error) => summary.skipped_files.push(SkippedTransform {
path: analysis.path.clone(),
reason: error.to_string(),
}),
}
}
Ok(summary)
}
enum TransformOutcome {
Changed,
Skipped(String),
}
fn transform_file(path: &Path, options: TransformOptions) -> Result<TransformOutcome> {
let source = fs::read_to_string(path)?;
let parsed = parse_file(path).map_err(|error| anyhow!(error))?;
let outcome = transform_parsed_source(&parsed, &source)?;
let mut output = match outcome {
SourceOutcome::Transformed(s) => s,
SourceOutcome::Skipped(reason) => {
return Ok(TransformOutcome::Skipped(reason));
}
};
let format_opts = crate::core::format::FormatOptions {
enabled: !options.no_format,
use_prettier: options.prettier,
preserve_indent: true,
preserve_quotes: true,
preserve_semicolons: true,
normalize_newlines: true,
};
let mut pipeline = crate::core::format::FormatPipeline::new(format_opts);
output = pipeline.format(&output, Some(&source), path);
if output == source {
return Ok(TransformOutcome::Skipped("no changes detected".to_string()));
}
let mut should_write = options.mode == TransformMode::Write;
if should_write && options.review {
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,
},
);
match crate::core::diff::preview::prompt_review(path, &source, &output, &renderer)? {
crate::core::diff::preview::ReviewAction::Apply => should_write = true,
crate::core::diff::preview::ReviewAction::Skip => {
return Ok(TransformOutcome::Skipped("Skipped by user".to_owned()));
}
crate::core::diff::preview::ReviewAction::Abort => {
anyhow::bail!("Migration aborted by user");
}
}
}
if should_write {
write_file_atomically(path, &output)?;
}
Ok(TransformOutcome::Changed)
}
enum SourceOutcome {
Transformed(String),
Skipped(String),
}
fn transform_parsed_source(parsed: &ParsedModule, source: &str) -> Result<SourceOutcome> {
let result = find_transform_candidates(parsed)?;
let candidates = match result {
CandidateResult::Candidates(c) => c,
CandidateResult::Skipped(reason) => return Ok(SourceOutcome::Skipped(reason)),
};
if candidates.len() != 1 {
return Ok(SourceOutcome::Skipped(
"file has zero or multiple class components — only single-component files are transformed".to_string(),
));
}
let candidate = &candidates[0];
let class_source = snippet(parsed, candidate.class.span)?;
let function_source = candidate.to_function_source();
let Some(start) = source.find(&class_source) else {
return Ok(SourceOutcome::Skipped("could not locate class source span".to_string()));
};
let mut output = String::new();
output.push_str(&source[..start]);
output.push_str(&function_source);
output.push_str(&source[start + class_source.len()..]);
if candidate.needs_hooks() {
output = ensure_react_hook_imports(&output, candidate.uses_state);
}
Ok(SourceOutcome::Transformed(output))
}
enum CandidateResult {
Candidates(Vec<ComponentTransform>),
Skipped(String),
}
fn find_transform_candidates(parsed: &ParsedModule) -> Result<CandidateResult> {
let mut candidates = Vec::new();
for item in &parsed.module.body {
let Some((name, class)) = top_level_class(item) else {
continue;
};
if !class.decorators.is_empty() {
return Ok(CandidateResult::Skipped(
"skipping: class has decorators which are not supported in hook migration".to_string(),
));
}
if class.is_abstract {
return Ok(CandidateResult::Skipped(
"skipping: abstract classes cannot be migrated to hooks".to_string(),
));
}
if class.type_params.is_some() || class.super_type_params.is_some() {
return Ok(CandidateResult::Skipped(
"skipping: generic class components require manual migration".to_string(),
));
}
if !class.implements.is_empty() {
return Ok(CandidateResult::Skipped(
"skipping: class implements interfaces — migrate manually".to_string(),
));
}
if !is_supported_component_class(class) {
continue;
}
match build_component_transform(parsed, name, class)? {
BuildResult::Built(c) => candidates.push(c),
BuildResult::Skipped(reason) => return Ok(CandidateResult::Skipped(reason)),
}
}
Ok(CandidateResult::Candidates(candidates))
}
enum BuildResult {
Built(ComponentTransform),
Skipped(String),
}
fn build_component_transform(
parsed: &ParsedModule,
name: String,
class: &Class,
) -> Result<BuildResult> {
let mut render_body = None;
let mut did_mount_body = None;
let mut will_unmount_body = None;
let mut state_initializer = None;
let mut state_fields: Vec<String> = Vec::new();
let mut custom_properties: Vec<(String, String)> = Vec::new();
for member in &class.body {
match member {
ClassMember::Method(method) if is_plain_method(method) => {
let Some(method_name) = prop_name(&method.key) else {
return Ok(BuildResult::Skipped(
"skipping: computed or unsupported method key".to_string(),
));
};
let Some(body) = method.function.body.as_ref() else {
return Ok(BuildResult::Skipped(format!(
"skipping: method `{}` has no body",
method_name
)));
};
let body_source = block_inner_source(parsed, body)?;
if has_ref_usage(&body_source) {
return Ok(BuildResult::Skipped(
"skipping: component uses refs which require useRef — migrate manually".to_string(),
));
}
if body_source.contains("this.context") {
return Ok(BuildResult::Skipped(
"skipping: component uses context — migrate manually with useContext".to_string(),
));
}
let converted = convert_this_references(&body_source);
match method_name.as_str() {
"render" => {
if !method.function.params.is_empty() {
return Ok(BuildResult::Skipped(
"skipping: render() has parameters — not a standard pattern".to_string(),
));
}
if contains_unconverted_this(&converted) {
return Ok(BuildResult::Skipped(
"skipping: render() has unresolved `this` references".to_string(),
));
}
render_body = Some(converted);
}
"componentDidMount" => {
if !is_simple_effect_body(&converted) {
return Ok(BuildResult::Skipped(
"skipping: componentDidMount has complex patterns (return/nested lifecycle)".to_string(),
));
}
did_mount_body = Some(converted);
}
"componentWillUnmount" => {
if !is_simple_effect_body(&converted) {
return Ok(BuildResult::Skipped(
"skipping: componentWillUnmount has complex patterns".to_string(),
));
}
will_unmount_body = Some(converted);
}
"componentDidUpdate" => {
return Ok(BuildResult::Skipped(
"skipping: componentDidUpdate requires manual migration to useEffect with deps".to_string(),
));
}
_ => {
return Ok(BuildResult::Skipped(format!(
"skipping: unsupported method `{}` — migrate class to hooks manually",
method_name
)));
}
}
}
ClassMember::ClassProp(prop) => {
let name = prop_name(&prop.key);
if name.as_deref() == Some("state") {
if prop.is_static {
return Ok(BuildResult::Skipped(
"skipping: static state is not a standard React pattern".to_string(),
));
}
if !prop.decorators.is_empty() {
return Ok(BuildResult::Skipped(
"skipping: state property has decorators".to_string(),
));
}
if prop.value.is_none() || state_initializer.is_some() {
return Ok(BuildResult::Skipped(
"skipping: state property has no initializer or is defined twice".to_string(),
));
}
let value = prop.value.as_ref().expect("checked above");
if let Expr::Object(obj) = &**value {
for prop_or_spread in &obj.props {
if let swc_ecma_ast::PropOrSpread::Prop(p) = prop_or_spread {
match p.as_ref() {
swc_ecma_ast::Prop::Shorthand(id) => {
state_fields.push(id.sym.to_string());
}
swc_ecma_ast::Prop::KeyValue(kv) => {
if let swc_ecma_ast::PropName::Ident(id) = &kv.key {
state_fields.push(id.sym.to_string());
}
}
_ => {}
}
}
}
state_initializer = Some(snippet(parsed, value.span())?);
} else {
return Ok(BuildResult::Skipped(
"skipping: state initializer is not an object literal".to_string(),
));
}
} else if let Some(prop_name) = name {
if let Some(ref val) = prop.value {
if let Expr::Arrow(_) = &**val {
let arrow_source = snippet(parsed, val.span())?;
custom_properties.push((prop_name, arrow_source));
} else {
return Ok(BuildResult::Skipped(
"skipping: class property has unsupported initializer".to_string(),
));
}
} else {
return Ok(BuildResult::Skipped(
"skipping: class property has no initializer".to_string(),
));
}
} else {
return Ok(BuildResult::Skipped(
"skipping: class property has unsupported key name".to_string(),
));
}
}
ClassMember::Constructor(ctor) => {
let mut state_init_from_ctor = None;
let mut state_fields_from_ctor = Vec::new();
let mut safe_constructor = true;
if let Some(body) = &ctor.body {
for stmt in &body.stmts {
if let Stmt::Expr(expr_stmt) = stmt {
if let Expr::Call(call_expr) = &*expr_stmt.expr {
if let swc_ecma_ast::Callee::Super(_) = &call_expr.callee {
continue;
}
}
}
let mut is_this_state_assignment = false;
if let Stmt::Expr(expr_stmt) = stmt {
if let Expr::Assign(assign_expr) = &*expr_stmt.expr {
if assign_expr.op == swc_ecma_ast::AssignOp::Assign {
if let swc_ecma_ast::AssignTarget::Simple(simple_target) = &assign_expr.left {
if let swc_ecma_ast::SimpleAssignTarget::Member(member_expr) = simple_target {
if let Expr::This(_) = &*member_expr.obj {
if let MemberProp::Ident(id) = &member_expr.prop {
if id.sym == *"state" {
is_this_state_assignment = true;
let value = &assign_expr.right;
if let Expr::Object(obj) = &**value {
for prop_or_spread in &obj.props {
if let swc_ecma_ast::PropOrSpread::Prop(p) = prop_or_spread {
match p.as_ref() {
swc_ecma_ast::Prop::Shorthand(id) => {
state_fields_from_ctor.push(id.sym.to_string());
}
swc_ecma_ast::Prop::KeyValue(kv) => {
if let swc_ecma_ast::PropName::Ident(id) = &kv.key {
state_fields_from_ctor.push(id.sym.to_string());
}
}
_ => {}
}
}
}
state_init_from_ctor = Some(snippet(parsed, value.span())?);
}
}
}
}
}
}
}
}
}
if is_this_state_assignment {
continue;
}
safe_constructor = false;
break;
}
}
if !safe_constructor {
return Ok(BuildResult::Skipped(
"skipping: constructor has logic beyond super() and state initialization".to_string(),
));
}
if let Some(init) = state_init_from_ctor {
state_initializer = Some(init);
state_fields = state_fields_from_ctor;
}
}
_ => {
return Ok(BuildResult::Skipped(
"skipping: class has unsupported member (getter/setter/field/static)".to_string(),
));
}
}
}
let Some(render_body) = render_body else {
return Ok(BuildResult::Skipped(
"skipping: no render() method found".to_string(),
));
};
let prop_names = collect_prop_names(&render_body);
let transform = ComponentTransform {
name,
class: class.clone(),
render_body,
did_mount_body,
will_unmount_body,
state_initializer,
state_fields,
prop_names,
custom_properties,
uses_state: false,
}
.with_usage();
Ok(BuildResult::Built(transform))
}
#[derive(Clone)]
struct ComponentTransform {
name: String,
class: Class,
render_body: String,
did_mount_body: Option<String>,
will_unmount_body: Option<String>,
state_initializer: Option<String>,
state_fields: Vec<String>,
prop_names: Vec<String>,
custom_properties: Vec<(String, String)>,
uses_state: bool,
}
impl ComponentTransform {
fn with_usage(mut self) -> Self {
self.uses_state = self.state_initializer.is_some();
self
}
fn needs_hooks(&self) -> bool {
self.did_mount_body.is_some() || self.will_unmount_body.is_some() || self.uses_state
}
fn to_function_source(&self) -> String {
let mut out = String::new();
out.push_str(&format!("function {}(props) {{\n", self.name));
if !self.prop_names.is_empty() && self.prop_names.len() <= 6 {
let mut sorted = self.prop_names.clone();
sorted.sort();
out.push_str(&format!(
" const {{ {} }} = props;\n",
sorted.join(", ")
));
}
if let Some(initializer) = &self.state_initializer {
if !self.state_fields.is_empty() {
out.push_str(&format!(
" // TODO: split into per-field useState: {}\n",
self.state_fields.join(", ")
));
out.push_str(&format!(
" const [state, setState] = useState({});\n",
initializer
));
out.push_str(
" // TODO: replace `setState({ key: val })` calls with individual setters.\n",
);
} else {
out.push_str(&format!(
" const [state, setState] = useState({});\n",
initializer
));
out.push_str(
" void setState; // TODO: remove once state updates are migrated.\n",
);
}
}
for (prop_name, arrow_source) in &self.custom_properties {
let converted = convert_this_references(arrow_source);
out.push_str(&format!(" const {} = {};\n", prop_name, converted));
}
if self.did_mount_body.is_some() || self.will_unmount_body.is_some() {
out.push_str(" useEffect(() => {\n");
if let Some(body) = &self.did_mount_body {
out.push_str(&indent(body, 4));
out.push('\n');
}
if let Some(cleanup) = &self.will_unmount_body {
out.push_str(" return () => {\n");
out.push_str(&indent(cleanup, 6));
out.push_str("\n };\n");
}
out.push_str(" }, []);\n");
}
out.push_str(&indent(&self.render_body, 2));
out.push_str("\n}");
out
}
}
fn top_level_class(item: &ModuleItem) -> Option<(String, &Class)> {
match item {
ModuleItem::Stmt(Stmt::Decl(Decl::Class(class_decl))) => {
Some((class_decl.ident.sym.to_string(), &class_decl.class))
}
_ => None,
}
}
fn is_supported_component_class(class: &Class) -> bool {
class.decorators.is_empty()
&& !class.is_abstract
&& class.type_params.is_none()
&& class.super_type_params.is_none()
&& class.implements.is_empty()
&& class
.super_class
.as_ref()
.is_some_and(|sc| is_react_component_expr(sc))
}
fn is_plain_method(method: &swc_ecma_ast::ClassMethod) -> bool {
method.kind == MethodKind::Method
&& !method.is_static
&& !method.is_abstract
&& method.function.decorators.is_empty()
&& !method.function.is_async
&& !method.function.is_generator
&& method.function.type_params.is_none()
}
fn is_react_component_expr(expr: &Expr) -> bool {
match expr {
Expr::Ident(ident) => matches!(ident.sym.as_ref(), "Component" | "PureComponent"),
Expr::Member(member) => is_react_component_member(member),
_ => false,
}
}
fn is_react_component_member(member: &MemberExpr) -> bool {
match (&*member.obj, &member.prop) {
(Expr::Ident(object), MemberProp::Ident(IdentName { sym, .. }))
if object.sym == *"React" =>
{
matches!(sym.as_ref(), "Component" | "PureComponent")
}
_ => false,
}
}
fn prop_name(name: &PropName) -> Option<String> {
match name {
PropName::Ident(ident) => Some(ident.sym.to_string()),
PropName::Str(string) => Some(string.value.to_string()),
_ => None,
}
}
fn block_inner_source(parsed: &ParsedModule, body: &swc_ecma_ast::BlockStmt) -> Result<String> {
let block = snippet(parsed, body.span)?;
Ok(block
.trim()
.strip_prefix('{')
.and_then(|v| v.strip_suffix('}'))
.unwrap_or(&block)
.trim()
.to_string())
}
fn snippet(parsed: &ParsedModule, span: swc_common::Span) -> Result<String> {
parsed
.source_map
.span_to_snippet(span)
.map_err(|error| anyhow!("failed to read source snippet: {error:?}"))
}
fn convert_this_references(source: &str) -> String {
source.replace("this.", "")
}
fn contains_unconverted_this(source: &str) -> bool {
source.contains("this.")
}
fn has_ref_usage(source: &str) -> bool {
source.contains("this.refs")
|| source.contains("React.createRef")
|| source.contains("createRef()")
|| source.contains("ref={")
|| source.contains("ref =")
}
fn is_simple_effect_body(source: &str) -> bool {
!source.contains("componentDidUpdate")
&& !source.contains("componentWillUnmount")
&& !source.contains("setState")
}
fn collect_prop_names(render_body: &str) -> Vec<String> {
let mut names = Vec::new();
let mut rest = render_body;
while let Some(pos) = rest.find("props.") {
rest = &rest[pos + 6..];
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(rest.len());
let name = &rest[..end];
if !name.is_empty() && !names.contains(&name.to_string()) {
names.push(name.to_string());
}
}
names
}
fn ensure_react_hook_imports(source: &str, include_state: bool) -> String {
let mut hooks = vec!["useEffect"];
if include_state {
hooks.push("useState");
}
let mut missing_hooks = Vec::new();
for hook in &hooks {
let is_imported = source.lines().any(|line| {
line.contains("import") && line.contains("react") && line.contains(hook)
});
if !is_imported {
missing_hooks.push(*hook);
}
}
if missing_hooks.is_empty() {
return source.to_string();
}
let import = format!("import {{ {} }} from \"react\";\n", missing_hooks.join(", "));
format!("{import}{source}")
}
fn indent(source: &str, spaces: usize) -> String {
let prefix = " ".repeat(spaces);
source
.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{prefix}{}", line.trim())
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn write_file_atomically(path: &Path, contents: &str) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("cannot determine parent directory for {}", path.display()))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| anyhow!("system clock error: {error}"))?
.as_nanos();
let temp_path = parent.join(format!(
".{}.morph.{}.tmp",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("morph"),
nanos
));
fs::write(&temp_path, contents)?;
if let Ok(metadata) = fs::metadata(path) {
fs::set_permissions(&temp_path, metadata.permissions())?;
}
fs::rename(&temp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{SourceOutcome, transform_parsed_source};
use crate::core::ast::parser::parse_source;
use std::path::Path;
fn transform(source: &str) -> Option<String> {
let parsed = parse_source(Path::new("fixture.jsx"), source).expect("source should parse");
match transform_parsed_source(&parsed, source).expect("transform should not error") {
SourceOutcome::Transformed(s) => Some(s),
SourceOutcome::Skipped(_) => None,
}
}
fn skip_reason(source: &str) -> Option<String> {
let parsed = parse_source(Path::new("fixture.jsx"), source).expect("source should parse");
match transform_parsed_source(&parsed, source).expect("transform should not error") {
SourceOutcome::Transformed(_) => None,
SourceOutcome::Skipped(r) => Some(r),
}
}
#[test]
fn transforms_render_only_class_component() {
let out = transform(
"class App extends React.Component {
render() { return <div>{this.props.name}</div>; }
}",
)
.expect("should transform");
assert!(out.contains("function App(props)"));
assert!(out.contains("props.name"));
assert!(!out.contains("this.props"));
}
#[test]
fn destructures_props_in_output() {
let out = transform(
"class App extends React.Component {
render() { return <div>{this.props.title}{this.props.count}</div>; }
}",
)
.expect("should transform");
assert!(out.contains("const {") && out.contains("} = props;"));
}
#[test]
fn transforms_simple_state_property() {
let out = transform(
"class Counter extends React.Component {
state = { count: 0 };
render() { return <div>{this.state.count}</div>; }
}",
)
.expect("should transform");
assert!(out.contains("useState({ count: 0 })"));
assert!(out.contains("state.count"));
assert!(out.contains("TODO: split into per-field useState"));
}
#[test]
fn transforms_component_did_mount() {
let out = transform(
"class App extends React.Component {
componentDidMount() { loadData(); }
render() { return <div>{this.props.name}</div>; }
}",
)
.expect("should transform");
assert!(out.contains("useEffect(() => {"));
assert!(out.contains("loadData();"));
assert!(out.contains("}, []);"));
}
#[test]
fn transforms_component_will_unmount_as_cleanup() {
let out = transform(
"class App extends React.Component {
componentDidMount() { subscribe(); }
componentWillUnmount() { unsubscribe(); }
render() { return <div />; }
}",
)
.expect("should transform");
assert!(out.contains("return () => {"));
assert!(out.contains("unsubscribe();"));
}
#[test]
fn skips_refs_with_named_reason() {
let reason = skip_reason(
"class App extends React.Component {
render() { return <div ref={this.inputRef} />; }
}",
)
.expect("should be skipped");
assert!(reason.contains("refs"));
}
#[test]
fn skips_component_did_update_with_named_reason() {
let reason = skip_reason(
"class App extends React.Component {
componentDidUpdate() { update(); }
render() { return <div />; }
}",
)
.expect("should be skipped");
assert!(reason.contains("componentDidUpdate"));
}
#[test]
fn skips_decorators_with_named_reason() {
let reason = skip_reason(
"class App extends React.Component {
render() { return <div />; }
}",
);
assert!(reason.is_none()); }
#[test]
fn skips_custom_methods_with_named_reason() {
let reason = skip_reason(
"class App extends React.Component {
handleClick() {}
render() { return <button />; }
}",
)
.expect("should be skipped");
assert!(reason.contains("handleClick"));
}
#[test]
fn skips_context_usage_with_named_reason() {
let reason = skip_reason(
"class App extends React.Component {
render() { return <div>{this.context.theme}</div>; }
}",
)
.expect("should be skipped");
assert!(reason.contains("context"));
}
}