simple-web-system technology

Webに関する技術をシンプルに扱うブログ

ts-morphを使ってファイル名をcamel caseからsnake caseにリファクタリング(typescript)

typescriptでファイル名をcamel caseからsnake caseに変換する必要があったので調べたところ、ts-morphがとても良かった

ts-morph の概要

ts-morph は、TypeScript コードの静的解析、操作、生成をプログラム的に行うためのライブラリである。

AST (Abstract Syntax Tree) レベルでの操作を可能にすることで、単純なテキスト置換ベースのリファクタリングと比較して、構文的な安全性を確保しつつ高度なコード変換を実現する。

主な機能として、変数や関数の識別子変更、未使用 import の除去、コードパターンの検索・置換、そして本稿の主題であるファイルリネームとそれに伴う import/export 宣言の自動更新などが挙げられる.。

ts-morph による変換スクリプト

① 依存関係の追加ts-morph をプロジェクトの devDependencies に追加する。

npm install ts-morph --save-dev
# or
yarn add ts-morph --dev
# or
pnpm add ts-morph --save-dev

② 変換スクリプトの作成プロジェクト内に、ファイル名変換ロジックを含む TypeScript スクリプト (例: renameFiles.ts) を配置する。

import { Project, SourceFile } from 'ts-morph';
import * as path from 'path';

// camelCase または PascalCase を snake_case に変換するヘルパー関数
// 例: myFile -> my_file, MyComponent -> my_component, AnotherFile -> another_file
function camelToSnake(str: string): string {
  // 最初の文字を小文字にする (PascalCase 対応)
  const initialLower = str.charAt(0).toLowerCase() + str.slice(1);
  // 大文字の前にアンダースコアを挿入し、全体を小文字にする
  return initialLower
    .replace(/([A-Z])/g, '_$1')
    .toLowerCase();
}

// ファイル名が camelCase または PascalCase かどうかを判定する簡易的な関数
// (先頭が小文字で大文字を含むか、先頭が大文字)
function isCamelOrPascalCase(fileName: string): boolean {
    // 拡張子を除いたファイル名を取得
    const nameWithoutExt = path.parse(fileName).name;
    // 数字や特殊文字で始まる場合は対象外とする (任意)
    if (!/^[a-zA-Z]/.test(nameWithoutExt)) {
        return false;
    }
    // アンダースコアが含まれていたら snake_case とみなし対象外
    if (nameWithoutExt.includes('_')) {
        return false;
    }
    // 全て小文字または全て大文字の場合は対象外 (任意)
    if (nameWithoutExt.toLowerCase() === nameWithoutExt || nameWithoutExt.toUpperCase() === nameWithoutExt) {
        return false;
    }
    // 先頭が小文字で、途中に大文字が含まれる (camelCase)
    // または先頭が大文字 (PascalCase)
    return (nameWithoutExt.charAt(0) === nameWithoutExt.charAt(0).toLowerCase() && /[A-Z]/.test(nameWithoutExt))
        || (nameWithoutExt.charAt(0) === nameWithoutExt.charAt(0).toUpperCase());
}

async function renameFilesToSnakeCase() {
  // tsconfig.json を読み込んでプロジェクトを初期化
  // プロジェクトのルートディレクトリに合わせてパスを調整してください
  const project = new Project({
    tsConfigFilePath: 'tsconfig.json', // tsconfig.json のパス
    // 必要に応じて skipAddingFilesFromTsConfig: true を設定し、
    // project.addSourceFilesAtPaths() で対象ファイルを明示的に指定することもできます
  });

  console.log('プロジェクトのソースファイルを解析中...');

  // 対象ディレクトリ配下のすべての TypeScript ファイル (.ts, .tsx) を取得
  // 必要に応じてパスパターンを調整してください (例: 'src/**/*.ts')
  const sourceFiles = project.getSourceFiles('src/**/*.{ts,tsx}'); // 'src' ディレクトリ配下を対象とする例

  console.log(`${sourceFiles.length} 個のソースファイルが見つかりました。`);

  let renamedCount = 0;

  for (const sourceFile of sourceFiles) {
    const filePath = sourceFile.getFilePath();
    const dirPath = sourceFile.getDirectoryPath();
    const baseName = sourceFile.getBaseName(); // ファイル名 + 拡張子 (例: myComponent.tsx)
    const baseNameWithoutExt = sourceFile.getBaseNameWithoutExtension(); // ファイル名のみ (例: myComponent)
    const extension = sourceFile.getExtension(); // 拡張子 (例: .tsx)

    try {
      // ファイル名が camelCase または PascalCase かどうかをチェック
      if (isCamelOrPascalCase(baseNameWithoutExt)) {
        const newBaseNameWithoutExt = camelToSnake(baseNameWithoutExt);
        const newBaseName = `${newBaseNameWithoutExt}${extension}`;
        const newFilePath = path.join(dirPath, newBaseName);

        // 新しいファイル名が現在のファイル名と異なる場合のみリネームを実行
        if (newFilePath !== filePath) {
          console.log(`リネーム: ${filePath} -> ${newFilePath}`);

          // ファイルを移動 (リネーム)
          // この操作により、他のファイル内の関連する import/export 宣言も更新されます
          await sourceFile.move(newFilePath);
          renamedCount++;
        }
      }
    } catch (error) {
      console.error(`ファイルのリネーム中にエラーが発生しました: ${filePath}`, error);
      // エラーが発生しても処理を続行する場合は、ここに continue 文を追加
    }
  }

  // 変更をファイルシステムに保存
  if (renamedCount > 0) {
    console.log('\nファイルシステムの変更を保存中...');
    await project.save();
    console.log(`${renamedCount} 個のファイルがリネームされました。`);
  } else {
    console.log('\nリネーム対象のファイルはありませんでした。');
  }

  console.log('処理が完了しました。');
}

// スクリプトを実行
renameFilesToSnakeCase().catch(err => {
  console.error('スクリプトの実行中に予期せぬエラーが発生しました:', err);
  process.exit(1);
});

スクリプトの実行作成したスクリプトtsx 等を用いて実行する。

// 失敗したときに備えて、gitで戻せるようにしておくこと
npx tsx renameFiles.ts

問題点

tsconfig.json の paths オプション等で定義されたパスエイリアス (例: @/components/some_component) を含む import 宣言は、ts-morph による自動更新が正しく機能しない。