morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::collections::{HashMap, HashSet};
use swc_common::DUMMY_SP;
use swc_ecma_ast::*;

use crate::core::ast::semantic::SemanticModel;

pub fn cleanup_imports_exports(module: &mut Module) {
    let semantic = SemanticModel::new(module);
    let unused_imports: HashSet<String> = semantic.get_unused_imports().into_iter().collect();

    let mut import_groups: HashMap<String, Vec<ImportDecl>> = HashMap::new();
    let mut sources_order: Vec<String> = Vec::new();
    let mut new_body = Vec::new();

    // Pass 1: Extract and filter imports
    for item in std::mem::take(&mut module.body) {
        if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item {
            let mut used_specifiers = Vec::new();
            for spec in import_decl.specifiers {
                let local_name = match &spec {
                    ImportSpecifier::Named(named) => named.local.sym.to_string(),
                    ImportSpecifier::Default(def) => def.local.sym.to_string(),
                    ImportSpecifier::Namespace(ns) => ns.local.sym.to_string(),
                };
                if !unused_imports.contains(&local_name) {
                    used_specifiers.push(spec);
                }
            }

            if !used_specifiers.is_empty() {
                let src = import_decl.src.value.to_string();
                if !import_groups.contains_key(&src) {
                    sources_order.push(src.clone());
                }
                let decl = ImportDecl {
                    specifiers: used_specifiers,
                    ..import_decl
                };
                import_groups.entry(src).or_default().push(decl);
            }
        } else {
            new_body.push(item);
        }
    }

    // Pass 2: Merge imports per source in insertion order
    let mut merged_imports = Vec::new();
    for src in sources_order {
        let decls = import_groups.remove(&src).unwrap_or_default();
        if decls.is_empty() {
            continue;
        }

        let mut default_spec = None;
        let mut ns_spec = None;
        let mut named_specs = HashMap::new();

        for decl in decls {
            for spec in decl.specifiers {
                match spec {
                    ImportSpecifier::Default(def) => {
                        if default_spec.is_none() {
                            default_spec = Some(def);
                        }
                    }
                    ImportSpecifier::Namespace(ns) => {
                        if ns_spec.is_none() {
                            ns_spec = Some(ns);
                        }
                    }
                    ImportSpecifier::Named(named) => {
                        named_specs.insert(named.local.sym.clone(), named);
                    }
                }
            }
        }

        let mut final_specifiers = Vec::new();
        if let Some(def) = default_spec {
            final_specifiers.push(ImportSpecifier::Default(def));
        }
        if let Some(ns) = ns_spec {
            final_specifiers.push(ImportSpecifier::Namespace(ns));
        }
        if !named_specs.is_empty() {
            let mut specs: Vec<_> = named_specs.into_values().collect();
            specs.sort_by(|a, b| a.local.sym.cmp(&b.local.sym));
            final_specifiers.extend(specs.into_iter().map(ImportSpecifier::Named));
        }

        merged_imports.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
            span: DUMMY_SP,
            specifiers: final_specifiers,
            src: Box::new(Str {
                span: DUMMY_SP,
                value: src.into(),
                raw: None,
            }),
            type_only: false,
            with: None,
            phase: Default::default(),
        })));
    }

    let mut final_body = merged_imports;
    final_body.extend(new_body);

    // Pass 3: Normalize bare exports
    let mut named_exports: Vec<ExportSpecifier> = Vec::new();
    let mut other_items = Vec::new();
    for item in final_body {
        if let ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(ref export)) = item {
            if export.src.is_none() && export.with.is_none() && !export.type_only {
                named_exports.extend(export.specifiers.clone());
                continue;
            }
        }
        other_items.push(item);
    }

    if !named_exports.is_empty() {
        // Dedup exports based on local name
        let mut unique_exports = Vec::new();
        let mut seen = HashSet::new();
        for spec in named_exports {
            if let ExportSpecifier::Named(named) = &spec {
                let name = match &named.orig {
                    ModuleExportName::Ident(id) => id.sym.to_string(),
                    ModuleExportName::Str(s) => s.value.to_string(),
                };
                if seen.insert(name) {
                    unique_exports.push(spec);
                }
            } else {
                unique_exports.push(spec);
            }
        }

        // Sort unique exports deterministically to avoid churn on repeated executions
        unique_exports.sort_by(|a, b| {
            let a_name = match a {
                ExportSpecifier::Named(n) => match &n.orig {
                    ModuleExportName::Ident(id) => id.sym.to_string(),
                    ModuleExportName::Str(s) => s.value.to_string(),
                },
                _ => "".to_string(),
            };
            let b_name = match b {
                ExportSpecifier::Named(n) => match &n.orig {
                    ModuleExportName::Ident(id) => id.sym.to_string(),
                    ModuleExportName::Str(s) => s.value.to_string(),
                },
                _ => "".to_string(),
            };
            a_name.cmp(&b_name)
        });

        other_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(
            NamedExport {
                span: DUMMY_SP,
                specifiers: unique_exports,
                src: None,
                type_only: false,
                with: None,
            },
        )));
    }

    module.body = other_items;
}

pub fn run_autofix(path: &std::path::Path) -> anyhow::Result<()> {
    use crate::core::ast::parser::parse_file;
    use crate::core::ast::printer::print_module;
    use crate::core::format::{FormatOptions, FormatPipeline};

    let original_content = std::fs::read_to_string(path)?;
    let mut parsed = parse_file(path).map_err(|e| anyhow::anyhow!(e))?;
    cleanup_imports_exports(&mut parsed.module);

    let mut output = print_module(&parsed, &parsed.module).map_err(|e| anyhow::anyhow!(e))?;

    // Apply formatting preservation for autofix mode
    let format_opts = FormatOptions {
        enabled: true,
        use_prettier: false,
        preserve_indent: true,
        preserve_quotes: true,
        preserve_semicolons: true,
        normalize_newlines: true,
    };
    let mut pipeline = FormatPipeline::new(format_opts);
    output = pipeline.format(&output, Some(&original_content), path);
    output = crate::core::format::normalize::insert_newline_after_imports(&output);

    if output != original_content {
        std::fs::write(path, output)?;
    }
    Ok(())
}