過去の現場でフレームワークやライブラリの API がバージョンアップからの breaking change で、なかなかの書き換え作業を行なったことがある。そこで AST を使っての一括置換の手段があることを知った。2024 年は中規模以上のプロジェクトを 1 つ 1 つ手動で書き換えていくことの大変さを非常に痛感したので、しっかり身につけたいと感じる。
AST を利用してのソースコード書き換えは TypeScript Complier API で可能になるが、今回はそのラッパーライブラリの ts-morph
を使っていきたい。ファイル内のソースコード置き換えをゴールにし、それまでの過程をまとめる。書き換え対象のソースコードは、TypeScript でのスクリプトの内容だけではなく、Vue.js SFC の script 内のコードの一部も含める。
そもそも AST とは、 Abstract Syntax Tree
の略で、ソースコードを構造化して表現した木構造のデータを指す。解析や加工ツールなどで使用され、コードを機械的に解釈・操作するために機能し、自分で手を加えることなくハックが可能となる。AST を利用している具体的ツールとしては、静的解析の eslint やフォーマッターの prettier などがある。
例えば、こんなコードを AST で表現したときに、
const x = 10;
if (x > 5) {
console.log("Hello");
}
こう表せる。解析した結果、枝分かれを作り、各々それぞれの枝のノードを SyntaxKind のどれかを当てはめている。対象の文言で検索をかけたり、置換対象のコードを探すときは、該当する SyntaxKind に当てはまるか否かの処理を書いていく。
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
https://www.npmjs.com
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])
})
AST がどんなものを知れた後は、実際に各ノードを取得して、置換や生成していくのをどうやって行うかを見ていく。(ちなみにサンプルコードは Deno で動かしています。TypeScript で書ける node 開発環境をすぐ用意できました。Deno 便利。)
AST へ変換したときにどんなノードたちで構成されているかを知れるコードです。
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);
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
に変更できる。
// ソースコード
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]));
const y: number = 42;
ここで TypeScript Compiler API のラッパーである ts-morph
を紹介する。
https://ts-morph.com/
https://ts-morph.com
TypeScript Compiler API に比べて、AST に詳しくなる学習コストが必要ないのと、単純な操作でも少ないコードで、コードの参照、改修ができる。
以下は、参照系のスクリプトだが、試しに TypeScript Compiler API
と ts-morph
で同じ動作をするスクリプトを用意したところ、簡略されているのがわかる。
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);
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
の関数がある。
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
ts-morph
でできることを見たので、本題の Vue.js SFC の script 部分を改修していく。今回は defineProps
のプロパティ名を prefix
から suffix
へ変更することを目標にする。
sfc の script の部分を取得するには、@vue/compiler-sfc
を使う。下の例を見ると、提供されている parse
関数を使って、置換対象のスクリプトを取得できる。
@vue/compiler-sfc
https://www.npmjs.com
<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>
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 });
{ 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
}
}
対象の script
部分を取得できたら、今度は対象ノードを探し、プロパティ名を書き換えていきます。
ts-ast-viewer で該当スクリプトの AST を確認する。
defineProps
の SyntaxKind
は、ExpressionStatement
というのが分かり、そこから PropertySignature
に辿りづくまで、下へ階層を掘り下げていく。毎回置換対象の AST がどんなものなのかを確認するのがおすすめ。
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") (文字列リテラル)
置換が完成するまでのスクリプトはこちら。ノードやプロパティを取得していく処理はドキュメントを読んで、そのときに該当するものを使用する。
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);
}
});
後は最初取得した SFC の script 部分を置換していく。
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を利用したコードジェネレーターの実装入門 | ドクセル
https://www.docswell.com
AST の基礎を理解:TypeScript でコードを解析し操作する🔰 - 弁護士ドットコム株式会社 Creators’ blog
https://creators.bengo4.com
flutter_ast | Dart package
https://pub.dev
大量のコードを TypeScript の AST で一気に置き換えよう
https://zenn.dev
TypeScriptのコード分析を楽にする ts-morph入門
https://zenn.dev
ts-morph を使って大量の TypeScript コードを機械的に書き換えた - Qiita
https://qiita.com