🐶

ts-morphを使ってJSX向けの小さなリファクタリングツールを作成する

2024/09/08に公開

こんにちはhiro08です。
最近はASTやパーサに興味があって色々コードを書いたり調べたりしています。便利なツールを作りたい!!

概要

早速本題になりますが、ライブラリの破壊的変更や選定技術のリアーキテクトなどで、コードベースが大きく変更する機会は多々あると思います。その際に、膨大なコードを一つ一つ手作業で変更して心が折れそうになった経験は誰しもあるのではないでしょうか。

私もその一人で、今では出来るだけ手動でやらずに機械的に変更する方法を考えます。

以前に、VueのDOMをReactに移行する作業の一部で、classからclassNameに移行する作業がありました。その際に使った小さなツールをts-morphに置き換えたのですが、ts-morphはリファクタリングを機械的に行う際にコードを書きやすいと感じました。
https://github.com/hiro08gh/classname-converter

なので、今回はts-morphを使ってJSX向けの小さなリファクタリングツールを作成してみます。

ts-morphの実践

作成するツールの例として、JSXを解析して、classからclassNameに変換するツールを取り上げます。上記リポジトリのclassname-cnoverterと同じ内容です。

ts-morphとはTypeScript Compiler APIを扱いやすくするラッパーライブラリです。TypeScript Compiler APIを利用するとASTの解析やコードの生成など幅広く活用することができます。しかし、そのまま使うと記述量が大きくなったり、APIが難しかったりしますが、ts-morphを使うと少ない記述量で目的のコードを実現できます。

https://github.com/dsherret/ts-morph

説明よりコードを書いた方が早いので実践してみましょう。

まずは、必要なライブラリをインストールしてコードを解析してみます。TypeScript Execute (tsx)を使うと、コンパイルせずに即時実行できるので便利です。また、CLIとして扱いやすくしたいので、commanderもインストールします。

npm install --save-dev ts-morph tsx commander @types/node typescript

tsconfig.jsonのファイルがない場合は作成します。こちらは任意です。

npx tsc --init

package.jsonにtsxコマンドを追加します。

package.json
"scripts": {
 "tsx": "tsx ./index.ts"
}

そして、ts-morphを扱う準備をします。index.tsを作成して以下のコードを追加してみましょう。

index.ts
#!/usr/bin/env node
import { Project, ts } from "ts-morph";

const project = new Project({
  tsConfigFilePath: "./tsconfig.json",
});
const sourceFile = project.addSourceFileAtPath("./App.tsx");

projectは、ts-morphを使ってTypeScriptコードを操作するためのエントリーポイントとなるオブジェクトです。Projectオブジェクトを作成すると、プロジェクト全体のファイルを管理することができます。

sourceFileは、TypeScriptやJavaScriptのファイル1つを表すオブジェクトで、その中の構文を解析・変更するためのインターフェースを提供します。コード全体の構造(クラス、関数、インポート文など)にアクセスしたり、特定のコードブロックを変更したり、新しいコードを挿入したりすることができます。

ファイルの解析

以下は解析するファイルの例です。リファクタリングをする対象は、class="red"になります。どのASTが対象か探すために、TypeScript AST Viewerを使うと便利です。

App.tsx
const App: React.FC = () => {
  return (
    <div class="red">
      <h1>Hello World!</h1>
    </div>
  );
}

上記コードを貼り付けると対象となるのはJsxAttributeになることがわかりました。

TypeScript AST Viwerの画像

ts-morphでJsxAttributeを機会的に解析してみましょう。sourceFile.getDescendantsOfKindts.SyntaxKind.JsxAttributeを指定します。

index.ts
// tsを追加
import { Project, ts } from "ts-morph";

// Projectの初期化ロジックは省略

// 解析ロジックを追加
for (const attr of sourceFile.getDescendantsOfKind(
  ts.SyntaxKind.JsxAttribute,
)) {
  if (attr.getNameNode().getText() === "class") {
    console.log(attr.getText())
  }
}

上記をファイルを実行してみましょう。すると、リファクタリング対象の値が表示されます。

npm run tsx

// コマンドラインに表示される
class="red"

小さなリファクタリングツールの作成

コードの解析ができたので、実際にリファクタリングツールを作成していきます。commandarでCLIのコマンドを扱いやすくなります。詳細な説明は省略します。

program
  .version("1.0.0")
  .argument("<target>", "File or folder to refactor")
  .description("class to className converter for jsx")
  .usage("classname-converte [FileName|FolderName]")
  .action((target) => {
    main(target);
  })
  .parse(process.argv);

こちらはmainのロジックになります。先ほど説明したprojectの初期化処理と、パスはファイルとフォルダに対応させたいためロジックを分岐する処理を書いています。

index.ts
const main = (targetPath: string) => {
  if (!fs.existsSync(targetPath)) {
    console.log(`The path ${targetPath} does not exist.`);
    return;
  }

  const project = new Project({
    tsConfigFilePath: "./tsconfig.json",
  });
  const stat = fs.statSync(targetPath);

  if (stat.isFile()) {
    processFile(project, targetPath);
  } else if (stat.isDirectory()) {
    processDirectory(project, targetPath);
  } else {
    console.log("Invalid path provided.");
    return;
  }

  console.log('done');
};

以下は今回のリファクタリングロジックになります。対象の拡張子 (".jsx", ".tsx", ".js", ".ts") を持つファイルを対象とします。

  1. attr.getInitializer();で、classの値を取得
  2. attr.replaceWithText();で、classをclassNameに変換 (1で取得した値を上書き)
  3. sourceFile.saveSync();で変更を保存
const processFile = (project: Project, filePath: string) => {
  const targetExtensions = [".jsx", ".tsx", ".js", ".ts"];

  if (targetExtensions.includes(path.extname(filePath))) {
    const sourceFile = project.addSourceFileAtPath(filePath);

    for (const attr of sourceFile.getDescendantsOfKind(
      ts.SyntaxKind.JsxAttribute,
    )) {
      if (attr.getNameNode().getText() === "class") {
        const initializer = attr.getInitializer();
        attr.replaceWithText(`className=${initializer?.getText()}`);

        sourceFile.saveSync();
        console.log(`Formatted 'class' to 'className' in ${filePath}`);
      }
    }
  } else {
    console.log(`Skipping not JSX file: ${filePath}`);
  }
};

最後にフォルダのパスが指定されてもリファクタリングできるようにします。ファイルとフォルダで分岐させて、フォルダだったら再帰してファイルをリファクタリングできるようします。

index.ts
const processDirectory = (project: Project, folderPath: string) => {
  const files = fs.readdirSync(folderPath);

  for (const file of files) {
    const filePath = path.join(folderPath, file);
    const stat = fs.statSync(filePath);

    if (stat.isFile()) {
      processFile(project, filePath);
    } else if (stat.isDirectory()) {
      processDirectory(project, filePath);
    }
  }
};

実際に実行してみましょう。対象ファイルのJSXAttributeにclassが含まれていたら変換することができます。

npm run tsx ./App.tsx

> ts-morph-jsx-tool@1.0.0 tsx
> tsx ./index.ts ./App.tsx

Formatted 'class' to 'className' in ./App.tsx
done

こちらは最終的なコードになります。

index.ts
#!/usr/bin/env node
import { Project, ts } from "ts-morph";
import { program } from "commander";
import fs from "node:fs";
import path from "node:path";

const main = (targetPath: string) => {
  if (!fs.existsSync(targetPath)) {
    console.log(`The path ${targetPath} does not exist.`);
    return;
  }

  const project = new Project({
    tsConfigFilePath: "./tsconfig.json",
  });
  const stat = fs.statSync(targetPath);

  if (stat.isFile()) {
    processFile(project, targetPath);
  } else if (stat.isDirectory()) {
    processDirectory(project, targetPath);
  } else {
    console.log("Invalid path provided.");
    return;
  }

  console.log('done');
};

const processFile = (project: Project, filePath: string) => {
  const targetExtensions = [".jsx", ".tsx", ".js", ".ts"];

  if (targetExtensions.includes(path.extname(filePath))) {
    const sourceFile = project.addSourceFileAtPath(filePath);

    for (const attr of sourceFile.getDescendantsOfKind(
      ts.SyntaxKind.JsxAttribute,
    )) {
      if (attr.getNameNode().getText() === "class") {
        const initializer = attr.getInitializer();
        attr.replaceWithText(`className=${initializer?.getText()}`);

        sourceFile.saveSync();
        console.log(`Formatted 'class' to 'className' in ${filePath}`);
      }
    }
  } else {
    console.log(`Skipping not JSX file: ${filePath}`);
  }
};

const processDirectory = (project: Project, folderPath: string) => {
  const files = fs.readdirSync(folderPath);

  for (const file of files) {
    const filePath = path.join(folderPath, file);
    const stat = fs.statSync(filePath);

    if (stat.isFile()) {
      processFile(project, filePath);
    } else if (stat.isDirectory()) {
      processDirectory(project, filePath);
    }
  }
};

program
  .version("1.0.0")
  .argument("<target>", "File or folder to refactor")
  .description("class to className converter for jsx")
  .usage("classname-converter [FileName|FolderName]")
  .action((target) => {
    main(target);
  })
  .parse(process.argv);

最後に

簡単にでしたが、ts-morphを使ったJSX向けの小さなリファクタリングツールの紹介でした。

今回は小さなリファクタリングツールですが、ts-morphの前提知識があれば、色々場面で活用できそうです。ライブラリの破壊的変更や、大きなリファクタリングの際に自分も役立てたいです。

Discussion