ts-morph で学ぶ AST 入門

作成日: 2025-01-02 /

過去の現場でフレームワークやライブラリの API がバージョンアップからの breaking change で、なかなかの書き換え作業を行なったことがある。そこで AST を使っての一括置換の手段があることを知った。2024 年は中規模以上のプロジェクトを 1 つ 1 つ手動で書き換えていくことの大変さを非常に痛感したので、しっかり身につけたいと感じる。

AST を利用してのソースコード書き換えは TypeScript Complier API で可能になるが、今回はそのラッパーライブラリの ts-morph を使っていきたい。ファイル内のソースコード置き換えをゴールにし、それまでの過程をまとめる。書き換え対象のソースコードは、TypeScript でのスクリプトの内容だけではなく、Vue.js SFC の script 内のコードの一部も含める。

実行環境

  • TypeScript: 5.7.2
  • Deno: 2.1.4

そもそも AST とは

そもそも AST とは、 Abstract Syntax Tree の略で、ソースコードを構造化して表現した木構造のデータを指す。解析や加工ツールなどで使用され、コードを機械的に解釈・操作するために機能し、自分で手を加えることなくハックが可能となる。AST を利用している具体的ツールとしては、静的解析の eslint やフォーマッターの prettier などがある。

例えば、こんなコードを AST で表現したときに、

source_file_example.ts
const x = 10;
if (x > 5) {
  console.log("Hello");
}

こう表せる。解析した結果、枝分かれを作り、各々それぞれの枝のノードを SyntaxKind のどれかを当てはめている。対象の文言で検索をかけたり、置換対象のコードを探すときは、該当する SyntaxKind に当てはまるか否かの処理を書いていく。

source_file_abstract_syntax_tree.txt
Program
├── VariableDeclaration (変数宣言)
│   ├── Identifier ("x") (識別子)
│   ├── NumericLiteral (10) (数字リテラル)
├── IfStatement (if 構文)
│   ├── BinaryExpression (x > 5) (バイナリ式)
│   │   ├── Identifier ("x") (識別子)
│   │   ├── NumericLiteral (5) (数字リテラル)
│   ├── BlockStatement (ブロック構文)
│       ├── ExpressionStatement (式構文)
│           ├── CallExpression (console.log) (式呼び出し)
│               ├── MemberExpression (メンバー呼び出し)
│               │   ├── Identifier ("console") (識別子)
│               │   ├── Identifier ("log") (識別子)
│               ├── StringLiteral ("Hello") (文字列リテラル)

ソースコードを AST へ変換した結果は、このサイトで確認できる。

https://ts-ast-viewer.com

https://ts-ast-viewer.com

あとは用語として覚えておいくといいのが、 parser / traverser, vistor or walker / generator の 3 つになる。ソースを解析して AST に当てはめるのと、AST を再起的に参照と変更を加え、そこからソースコードへ変換するまでの一連の流れになる。

余談だが、以前 textlint の既存ルールからのエラーを回避しようと、ソースコードの中を見ていくと、下のライブラリを使っていることがわかった。上で AST は静的解析ツールとして使われると言及したが、textlint もそれに該当する。用語を覚えることで、このライブラリが再起的にコードを参照する visitor を提供するライブラリであることがわかった。

unist-util-visit

unist-util-visit

https://www.npmjs.com

unist-util-visit
visit.ts
import { fromMarkdown } from 'mdast-util-from-markdown'
import { visit } from 'unist-util-visit'

const tree = fromMarkdown('Some *emphasis*, **strong**, and `code`.')

visit(tree, 'text', function (node, index, parent) {
  console.log([node.value, parent ? parent.type : index])
})

TypeScript Compiler API を使う

AST がどんなものを知れた後は、実際に各ノードを取得して、置換や生成していくのをどうやって行うかを見ていく。(ちなみにサンプルコードは Deno で動かしています。TypeScript で書ける node 開発環境をすぐ用意できました。Deno 便利。)

各ノードの種類を出力する

AST へ変換したときにどんなノードたちで構成されているかを知れるコードです。

print_syntax_kind.ts
import {
  createSourceFile,
  forEachChild,
  Node,
  ScriptKind,
  ScriptTarget,
  SourceFile,
  SyntaxKind,
} from "typescript";

// ソースコードを解析
const sourceCode = `
  const x = 10;
  if (x > 5) {
    console.log("Hello");
  }
`;

// ソースコードを AST に変換
const sourceFile = createSourceFile(
  "example.ts", // ファイル名 (仮の名前)
  sourceCode,   // 解析対象のコード
  ScriptTarget.Latest, // 言語レベル (最新を指定)
  true           // トークン解析を有効化
);

// ノードを巡回して出力する関数
function visit(node: Node) {
  console.log(`${SyntaxKind[node.kind]}: ${node.getText()}`);
  forEachChild(node, visit); // 子ノードを再帰的に巡回
}

// AST を巡回してノード情報を出力
visit(sourceFile);
result.txt
SourceFile: const x = 10;
  if (x > 5) {
    console.log("Hello");
  }

ExpressionStatement: console.log("Hello");
CallExpression: console.log("Hello")
PropertyAccessExpression: console.log
Identifier: console
Identifier: log
StringLiteral: "Hello"
EndOfFileToken:

特定ノードのテキストを変換する

transformer を自ら作成し、対象のソースコードを変換できる。ポイントは再起的にツリー構造を見ていること。このスクリプトを実行すると、x の部分を y に変更できる。

transform.ts
// ソースコード
const sourceCode = `
  const x: number = 42;
`;

// ソースコードを AST に変換
const sourceFile = createSourceFile(
  "example.ts",
  sourceCode,
  ScriptTarget.Latest,
  true,
);

const renameTransformer = (): TransformerFactory<SourceFile> => {
  return (context) => (sourceFile) => {
    const visitNode = (node: Node): Node => {
      if (isIdentifier(node) && node.text === "x") {
        return factory.createIdentifier("y");
      }

      return visitEachChild(node, visitNode, context);
    };

    return visitEachChild(sourceFile, visitNode, context);
  };
};

// トランスフォーマーを適用
const result = transform(hogehoge, [renameTransformer()]);

// 新しい AST をコードとして出力
const printer = createPrinter();
console.log(printer.printFile(result.transformed[0]));
transform_result.txt
const y: number = 42;

ts-morph を使う

ここで TypeScript Compiler API のラッパーである ts-morph を紹介する。

https://ts-morph.com/

https://ts-morph.com

TypeScript Compiler API に比べて、AST に詳しくなる学習コストが必要ないのと、単純な操作でも少ないコードで、コードの参照、改修ができる。 以下は、参照系のスクリプトだが、試しに TypeScript Compiler APIts-morph で同じ動作をするスクリプトを用意したところ、簡略されているのがわかる。

compiler_api.ts
import * as ts from "typescript";

const sourceCode = "const x = 10;";
const sourceFile = ts.createSourceFile("example.ts", sourceCode, ts.ScriptTarget.Latest, true);

// AST を巡回して変数名を取得
const visit = (node: ts.Node): void => {
  if (ts.isVariableDeclaration(node)) {
    console.log(node.name.getText());
  }

  ts.forEachChild(node, visit);
}

visit(sourceFile);
ts_morph.ts
import { Project } from "ts-morph";

const project = new Project();
const sourceFile = project.createSourceFile("example.ts", "const x = 10;");
const variable = sourceFile.getVariableDeclaration("x");
console.log(variable?.getName());

ユーティリティ関数も用意されている。例えば、型オブジェクトを取得する getType や、さらにそこから該当の型の名前を取得できる getText の関数がある。

ts_morph_util.ts
import { Project } from "https://deno.land/x/ts_morph/mod.ts";

const project = new Project();
const sourceFile = project.createSourceFile(
  "example.ts",
  "const x: number = 10;",
);
const variable = sourceFile.getVariableDeclarationOrThrow("x");
console.log(variable.getType().getText()); // -> number
console.log(variable.getType().isNumber()); // -> true

Vue.js SFC の script を改修する

ts-morph でできることを見たので、本題の Vue.js SFC の script 部分を改修していく。今回は defineProps のプロパティ名を prefix から suffix へ変更することを目標にする。

1. Vue.js SFC の script を取得する

sfc の script の部分を取得するには、@vue/compiler-sfc を使う。下の例を見ると、提供されている parse 関数を使って、置換対象のスクリプトを取得できる。

@vue/compiler-sfc

@vue/compiler-sfc

https://www.npmjs.com

@vue/compiler-sfc
before.vue
<script lang="ts" setup>
import { ref } from '#imports'

defineProps<{ prefix: string }>()

const hoge = ref('hogehoge')
</script>

<template>
  <div class="fugafuga">
    <p>{{ hoge }}</p>
  </div>
</template>

<style lang="scss" scoped>
.fugafuga {
  font-size: 16px;
}
</style>
convert.ts
import { parse } from "https://deno.land/x/vue_sfc_compiler/mod.ts";

const sfc = await Deno.readTextFile("./before.vue");
const { descriptor: { script, scriptSetup } } = parse(sfc);

console.log({ script });
console.log({ scriptSetup: scriptSetup.content });
convert.txt
{ script: null }
{
  scriptSetup: {
    type: "script",
    content: "\n" +
      "import { ref } from '#imports'\n" +
      "\n" +
      "defineProps<{ prefix: string }>()\n" +
      "\n" +
      "const hoge = ref('hogehoge')\n",
    loc: {
      source: "\n" +
        "import { ref } from '#imports'\n" +
        "\n" +
        "defineProps<{ prefix: string }>()\n" +
        "\n" +
        "const hoge = ref('hogehoge')\n",
      start: { column: 25, line: 1, offset: 24 },
      end: { column: 1, line: 7, offset: 121 }
    },
    attrs: { lang: "ts", setup: true },
    lang: "ts",
    setup: true
  }
}

2. Props のプロパティ名を書き換える

対象の script 部分を取得できたら、今度は対象ノードを探し、プロパティ名を書き換えていきます。

ts-ast-viewer で該当スクリプトの AST を確認する。 definePropsSyntaxKind は、ExpressionStatement というのが分かり、そこから PropertySignature に辿りづくまで、下へ階層を掘り下げていく。毎回置換対象の AST がどんなものなのかを確認するのがおすすめ。

ast.txt
Program
├── ImportDeclaration (インポート文)
│   ├── NamedImports ("ref") (名前付きインポート)
│   ├── StringLiteral ("#imports") (文字列リテラル)
├── ExpressionStatement (式文)
│   ├── CallExpression (defineProps) (関数呼び出し)
│       ├── Identifier ("defineProps") (識別子)
│       ├── TypeLiteral (型リテラル)
│           ├── PropertySignature ("prefix") (プロパティ定義)
│               ├── StringKeyword (string) (型キーワード)
├── VariableDeclaration (変数宣言)
│   ├── Identifier ("hoge") (識別子)
│   ├── CallExpression (ref) (関数呼び出し)
│       ├── Identifier ("ref") (識別子)
│       ├── StringLiteral ("hogehoge") (文字列リテラル)

置換が完成するまでのスクリプトはこちら。ノードやプロパティを取得していく処理はドキュメントを読んで、そのときに該当するものを使用する。

convert.ts
const oldPropName = "prefix";
const newPropName = "suffix";

const project = new Project({
  compilerOptions: {
    target: ScriptTarget.Latest,
  },
  useInMemoryFileSystem: true,
});

if (!scriptSetup || !scriptSetup.content) {
  console.log("no script setup is found.");
  Deno.exit(1);
}

const sourceFile = project.createSourceFile(
  "convert.ts",
  scriptSetup.content ?? "",
);

const exprStatement = sourceFile.getStatementByKindOrThrow(
  SyntaxKind.ExpressionStatement,
);
// getExpression だと返り値のアサーションをしないといけないので、 `getExpressionIfKind` を使う。
const callExpr = exprStatement.getExpressionIfKind(SyntaxKind.CallExpression);
if (!callExpr) {
  console.log("no called function is found");
  Deno.exit(1);
}

const firstTypeArg = callExpr.getTypeArguments()[0];
if (!firstTypeArg) {
  console.log("no type is found");
  Deno.exit(1);
}

const typeLiteralNode = firstTypeArg.asKindOrThrow(SyntaxKind.TypeLiteral);
const properties = typeLiteralNode.getProperties();
properties.forEach((property) => {
  if (property.getName() === oldPropName) {
    property.rename(newPropName);
  }
});

3. ファイルへ書き換える

後は最初取得した SFC の script 部分を置換していく。

convert.ts
const replaced = sfc.replace(
  /<script[\s\S]*?<\/script>/,
  `<script>${sourceFile.getText()}</script>`,
);
await Deno.writeTextFile("./after.vue", replaced);

console.log("convert done");

まとめ

今回は AST の基本から ts-morph を使って Vue.js SFC の script 部分の書き換えまでを一通りまとめた。やはりファイルの内容を手動で置き換える必要がないのは、非常に魅力的であった。今回はファイル書き換えを目標に少し内容が薄く広いものになってしまったが、この記事にまとめるのをきっかけに AST の違う利用先を知っていきたい。Language Server Protocol を使い IDE とエディタがどう修飾子に色をつけているかなどやカスタムの eslint のルールを作成するときに役に立つことを期待している。そのときに学んだことは随時追記していくので、この記事がアウトプットの場として質を向上していく。

参考にしたサイト

https://rabbit-house.tokyo/ast-book-sample.pdf

https://rabbit-house.tokyo

TSKaigi 2024 TypeScript ASTを利用したコードジェネレーターの実装入門 | ドクセル

TSKaigi 2024 TypeScript ASTを利用したコードジェネレーターの実装入門 | ドクセル

https://www.docswell.com

TSKaigi 2024 TypeScript ASTを利用したコードジェネレーターの実装入門 | ドクセル

AST の基礎を理解:TypeScript でコードを解析し操作する🔰 - 弁護士ドットコム株式会社 Creators’ blog

AST の基礎を理解:TypeScript でコードを解析し操作する🔰 - 弁護士ドットコム株式会社 Creators’ blog

https://creators.bengo4.com

AST の基礎を理解:TypeScript でコードを解析し操作する🔰 - 弁護士ドットコム株式会社 Creators’ blog

flutter_ast | Dart package

flutter_ast | Dart package

https://pub.dev

flutter_ast | Dart package

大量のコードを TypeScript の AST で一気に置き換えよう

大量のコードを TypeScript の AST で一気に置き換えよう

https://zenn.dev

大量のコードを TypeScript の AST で一気に置き換えよう

TypeScriptのコード分析を楽にする ts-morph入門

TypeScriptのコード分析を楽にする ts-morph入門

https://zenn.dev

TypeScriptのコード分析を楽にする ts-morph入門

ts-morph を使って大量の TypeScript コードを機械的に書き換えた - Qiita

ts-morph を使って大量の TypeScript コードを機械的に書き換えた - Qiita

https://qiita.com

ts-morph を使って大量の TypeScript コードを機械的に書き換えた - Qiita