morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
pub mod normalize;
pub mod prettier;

use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormatOptions {
    pub enabled: bool,
    pub use_prettier: bool,
    pub preserve_indent: bool,
    pub preserve_quotes: bool,
    pub preserve_semicolons: bool,
    pub normalize_newlines: bool,
}

impl FormatOptions {
    pub fn from_args(format: bool, prettier: bool, no_format: bool) -> Self {
        if no_format {
            Self::disabled()
        } else {
            Self {
                enabled: format || prettier,
                use_prettier: prettier,
                preserve_indent: true,
                preserve_quotes: true,
                preserve_semicolons: true,
                normalize_newlines: true,
            }
        }
    }

    pub fn disabled() -> Self {
        Self {
            enabled: false,
            use_prettier: false,
            preserve_indent: false,
            preserve_quotes: false,
            preserve_semicolons: false,
            normalize_newlines: false,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormatStats {
    pub files_formatted: usize,
    pub files_preserved: usize,
    pub changes_detected: usize,
    pub indentation_changes: usize,
    pub quote_style_changes: usize,
    pub newline_changes: usize,
}

impl FormatStats {
    pub fn summary(&self) -> String {
        format!(
            "Formatting: {} formatted, {} preserved, {} total changes",
            self.files_formatted, self.files_preserved, self.changes_detected
        )
    }
}

pub struct FormatPipeline {
    options: FormatOptions,
    stats: FormatStats,
}

impl FormatPipeline {
    pub fn new(options: FormatOptions) -> Self {
        Self {
            options,
            stats: FormatStats::default(),
        }
    }

    pub fn format(&mut self, source: &str, original_source: Option<&str>, path: &Path) -> String {
        if !self.options.enabled {
            return source.to_string();
        }

        let mut result = source.to_string();

        let profile = original_source.map(normalize::analyze_style);

        if self.options.normalize_newlines {
            result = normalize::normalize_newlines(&result);
            self.stats.newline_changes += 1;
        }

        if let Some(ref prof) = profile {
            if self.options.preserve_indent {
                result = normalize::adjust_indentation(&result, &prof.indent);
                self.stats.indentation_changes += 1;
            }

            if self.options.preserve_quotes {
                result = normalize::process_quotes(&result, prof.quote_style.clone(), prof.jsx_quote_style.clone());
                self.stats.quote_style_changes += 1;
            }

            if self.options.preserve_semicolons && !prof.semicolons {
                result = normalize::strip_trailing_semicolons(&result);
            }
        }

        if self.options.use_prettier {
            if let Ok(formatted) = prettier::format_with_prettier(&result, path) {
                result = formatted;
                self.stats.files_formatted += 1;
            } else {
                self.stats.files_preserved += 1;
                if let Some(orig) = original_source {
                    result = normalize::restore_blank_lines(&result, orig);
                }
            }
        } else {
            self.stats.files_preserved += 1;
            if let Some(orig) = original_source {
                result = normalize::restore_blank_lines(&result, orig);
            }
        }

        self.stats.changes_detected += 1;
        result
    }

    pub fn stats(&self) -> &FormatStats {
        &self.stats
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_disabled_options() {
        let opts = FormatOptions::disabled();
        assert!(!opts.enabled);
    }

    #[test]
    fn test_format_args() {
        let opts = FormatOptions::from_args(true, false, false);
        assert!(opts.enabled);
        assert!(!opts.use_prettier);
    }

    #[test]
    fn test_prettier_args() {
        let opts = FormatOptions::from_args(false, true, false);
        assert!(opts.enabled);
        assert!(opts.use_prettier);
    }

    #[test]
    fn test_no_format_args() {
        let opts = FormatOptions::from_args(true, true, true);
        assert!(!opts.enabled);
    }

    #[test]
    fn test_format_stats() {
        let stats = FormatStats {
            files_formatted: 5,
            files_preserved: 3,
            changes_detected: 8,
            indentation_changes: 2,
            quote_style_changes: 1,
            newline_changes: 3,
        };
        let summary = stats.summary();
        assert!(summary.contains("formatted"));
        assert!(summary.contains("preserved"));
    }

    #[test]
    fn test_pipeline_disabled() {
        let opts = FormatOptions::disabled();
        let mut pipeline = FormatPipeline::new(opts);
        let result = pipeline.format("const x = 1;", None, Path::new("test.js"));
        assert_eq!(result, "const x = 1;");
    }

    #[test]
    fn test_newline_normalization() {
        let opts = FormatOptions {
            enabled: true,
            use_prettier: false,
            preserve_indent: false,
            preserve_quotes: false,
            preserve_semicolons: false,
            normalize_newlines: true,
        };
        let mut pipeline = FormatPipeline::new(opts);
        let result = pipeline.format("line1\r\nline2\rline3", None, Path::new("test.js"));
        assert!(!result.contains("\r"));
    }
}