morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::error::Error;
use std::fmt;
use std::path::{Path, PathBuf};

use swc_common::comments::SingleThreadedComments;
use swc_common::sync::Lrc;
use swc_common::{FileName, SourceMap};
use swc_ecma_ast::Module;
use swc_ecma_parser::lexer::Lexer;
use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax, TsSyntax};

pub struct ParsedModule {
    pub path: PathBuf,
    pub module: Module,
    pub source_map: Lrc<SourceMap>,
    pub comments: SingleThreadedComments,
}

#[derive(Debug, Clone)]
pub struct ParseFileError {
    path: PathBuf,
    details: String,
}

impl ParseFileError {
    pub(crate) fn new(path: &Path, details: impl Into<String>) -> Self {
        Self {
            path: path.to_path_buf(),
            details: details.into(),
        }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl fmt::Display for ParseFileError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}: {}", self.path.display(), self.details)
    }
}

impl Error for ParseFileError {}

pub fn parse_file(path: &Path) -> Result<ParsedModule, ParseFileError> {
    let source = std::fs::read_to_string(path)
        .map_err(|error| ParseFileError::new(path, format!("failed to read file: {error}")))?;

    parse_source(path, &source)
}

pub(crate) fn parse_source(path: &Path, source: &str) -> Result<ParsedModule, ParseFileError> {
    let syntax = syntax_for_path(path)?;
    let source_map: Lrc<SourceMap> = Lrc::new(SourceMap::default());
    let comments = SingleThreadedComments::default();
    let source_file =
        source_map.new_source_file(FileName::Real(path.to_path_buf()).into(), source.to_owned());

    let lexer = Lexer::new(
        syntax,
        Default::default(),
        StringInput::from(&*source_file),
        Some(&comments),
    );
    let mut parser = Parser::new_from(lexer);
    let mut diagnostics = Vec::new();

    for error in parser.take_errors() {
        diagnostics.push(error.kind().msg().to_string());
    }

    let module = parser
        .parse_module()
        .map_err(|error| ParseFileError::new(path, error.kind().msg().to_string()))?;

    for error in parser.take_errors() {
        diagnostics.push(error.kind().msg().to_string());
    }

    if !diagnostics.is_empty() {
        return Err(ParseFileError::new(path, diagnostics.join("; ")));
    }

    Ok(ParsedModule {
        path: path.to_path_buf(),
        module,
        source_map,
        comments,
    })
}

fn syntax_for_path(path: &Path) -> Result<Syntax, ParseFileError> {
    let extension = path
        .extension()
        .and_then(|extension| extension.to_str())
        .ok_or_else(|| ParseFileError::new(path, "missing file extension"))?;

    let syntax = match extension {
        "js" | "mjs" | "cjs" | "jsx" => Syntax::Es(EsSyntax {
            jsx: true,
            ..Default::default()
        }),
        "ts" => Syntax::Typescript(TsSyntax {
            tsx: false,
            decorators: true,
            ..Default::default()
        }),
        "tsx" => Syntax::Typescript(TsSyntax {
            tsx: true,
            decorators: true,
            ..Default::default()
        }),
        _ => {
            return Err(ParseFileError::new(
                path,
                format!("unsupported source extension `{extension}`"),
            ));
        }
    };

    Ok(syntax)
}