ESLintとPrettierのコードリーディングでASTベースの静的解析を理解する

- 1 はじめに
- 2 本連載の振り返り
- 2.1 【第1回】「ASTの基礎知識とTypeScriptの環境構築から始めるCLIツール開発」
- 2.2 【第2回】「TypeScript×Node.jsでCLIを開発してnpmで公開する基本手順を学ぶ」
- 2.3 【第3回】「ASTの解析とCLIの実装によるTypeScript製Lintツール開発」
- 2.4 【第4回】「TypeScript Compiler APIでASTからコード生成しFormatter CLIを作る」
- 3 静的解析ツールのコードリーディング
- 3.1 ESLintのコードリーディング
- 3.2 Prettierのコードリーディング
- 4 おわりに
はじめに
こんにちは。サイバーエージェントのソフトウェアエンジニアの平井柊太(@did0es)です。
前回は、ASTからTypeScriptのソースコードを生成しました。また、この生成の仕組みを利用して、第3回で実装したLinterをFormatterに作り変えました。
今回は、本連載の内容を振り返るとともに、開発者に広く用いられているLinterのESLintとFormatterのPrettierのコードを読み、その仕組みを深堀りしていきます。
本連載の振り返り
まず、各回の内容をかいつまんで振り返ります。
【第1回】「ASTの基礎知識とTypeScriptの環境構築から始めるCLIツール開発」
開発の準備として、本連載に登場する用語の整理と環境構築を行いました。前半では、以下の用語の指す意味を確認しました。
- Linter:ソースコード上の問題を検出するツール
- Formatter:ソースコードを整形するツール
- ESLint:Linterの一種
- Prettier:Formatterの一種
- Biome:Linter・ Formatterの一種。ESLintやPrettierと部分的に互換性がある
- Abstract Syntax Tree(AST):ソースコードを変換して得られる中間表現の一種。木構造のデータ
- 静的解析:ソースコードを実行することなく解析する技術。LinterやFormatterに用いられている
特に、ASTについて「Acorn」というJSパーサーを用いてconsole.log関数をパースしたものからデータの構造を深堀りしました。以下のようなJSON形式で表現しました。
{
"type": "Program",
"start": 0,
"end": 11,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 11,
"expression": {
"type": "MemberExpression",
"start": 0,
"end": 11,
"object": {
"type": "Identifier",
"start": 0,
"end": 7,
"name": "console"
},
"property": {
"type": "Identifier",
"start": 8,
"end": 11,
"name": "log"
},
"computed": false,
"optional": false
}
}
],
"sourceType": "module"
}
以下の画像のような、入れ子の構造でも表現しました。
このASTを用いて行われる処理が静的解析でした。応用例として、ESLintのアーキテクチャを元に、どういった手順で静的解析によってソースコードが検査されるかを概観しました。
ESLintでは「Espree」というJSパーサーにより生成されたASTをEstraverseで走査し、Node.jsのEvent emitterで発火したイベント駆動で検査が行われていました。
後半では、asdfを用いた環境構築の方法を紹介しました。asdfによりNode.jsのバージョン管理を行いました。asdf経由でインストールしたv23.10.0のNode.jsでは、外部ライブラリなしでのTypeScriptの実行や実験的機能のNode.jsのコンフィグファイルが利用できました。併せて、macOS以外のユーザー向けにDockerを用いた環境構築の方法ご紹介しました。
以上の詳細は、第1回の記事からご覧ください。
【第2回】「TypeScript×Node.jsでCLIを開発してnpmで公開する基本手順を学ぶ」
第1回の環境を用いて、CLIの開発と公開を行いました。前半では、入力された文字列を反転して出力するCLIを実装しました。CLIの開発にはcommanderやyargsのような非対話型CLI向けのライブラリ、enquirerやpromptsのような対話型CLI向けのライブラリが広く用いられていました。
本連載ではreadlineなどのNode.jsの標準APIを用いて、これらのようなライブラリに依存しない形で非対話型CLIと対話型CLIを再現しました。実装の詳細はGitHubリポジトリの第2回のディレクトリをご覧ください。
また、開発時にTypeScriptのコンパイラ(tsc)で行っていた型チェックをtsgoで代替して速度が向上するか検証しました。結果は以下の通りで、3倍ほど実行速度が向上しました。
# tsc $ npx tsc --noEmit --extendedDiagnostics Files: 186 ~~~ I/O Read time: 0.01s Parse time: 0.09s ResolveModule time: 0.01s ResolveTypeReference time: 0.00s ResolveLibrary time: 0.00s Program time: 0.13s Bind time: 0.05s Check time: 0.45s printTime time: 0.00s Emit time: 0.00s Total time: 0.62s # tsgo $ npx tsgo --noEmit --extendedDiagnostics Files: 186 ~~~ Config time: 0.001s Parse time: 0.025s Bind time: 0.006s Check time: 0.136s Emit time: 0.000s Total time: 0.169s
後半では、実装したCLIをnpmに公開しました。npmのアカウント作成しCLIをtscでビルドした後に、npm CLIのpublishコマンドを用いてnpmのレジストリに反映しました。
以上の詳細は、第2回の記事からご覧ください。【第3回】「ASTの解析とCLIの実装によるTypeScript製Lintツール開発」
第2回で実装したCLIを元にLinterを実装しました。前半では、Linterの実装の準備としてTypeScriptのASTを解析しました。ASTの生成にはTypeScript Compiler API(Compiler API)を利用しました。
入力されたソースコードを元に、Compiler APIで生成したASTから不要なプロパティを除き、以下のデータを出力するCLIを実装しました。
CLIでASTを解析した結果は、以下のように出力されました。
$ npm run dev -- ./analyzeAstCli.ts ./__mocks__/validInput.ts --debug
> 003-analyze-ast@1.0.0 dev
> node --experimental-default-config-file ./analyzeAstCli.ts ./__mocks__/validInput.ts
Welcome to the Analyze AST CLI!
# ~~~
# その他のdebugログ
# ~~~
[DEBUG] Kind: SourceFile, Text: class Animal { spe
[DEBUG] Kind: ClassDeclaration, Text: class Animal { spe
[DEBUG] Kind: Identifier, Text: Animal
[DEBUG] Kind: PropertyDeclaration, Text: species: string;
[DEBUG] Kind: Identifier, Text: species
[DEBUG] Kind: StringKeyword, Text: string
# 続く...
後半では、前半の解析向けCLIを応用してLinterを実装しました。解析向けCLIにおけるASTノードの詳細を出力する処理について、ASTノードを順に検査する処理に置き換えました。検査には、以下の独自のルールを用いました。
"call-super-in-constructor": "error": 継承した先のクラスのconstructorでsuper()を呼び出していない場合エラーにする(ESLintのconstructor-superのようなルール)"use-this-in-method": "error": クラスのメソッド内でthisを使っていない場合エラーにする(ESLintのclass-methods-use-thisのようなルール)
ルールは以下のようなJSON形式でファイルに記入し、CLIから読み取るようにしました。
{
"call-super-in-constructor": "error",
"use-this-in-method": "error"
}
CLIにinvalidInput.tsのような不正なファイルを渡すと、以下のようなエラーが出力されました。
CLIの実行結果:
$ npm run dev -- ./lintCli.ts ./__mocks__/invalidInput.ts > 003-analyze-ast@1.0.0 dev > node --experimental-default-config-file ./lintCli.ts ./__mocks__/invalidInput.ts Welcome to the Lint CLI! [ERROR] Constructor should call super() but does not [ERROR] Method should use `this` but does not
invalidInput.ts:
class Animal {
species: string;
constructor(species: string) {
this.species = species;
}
showSpecies() {
console.log(`この動物は ${this.species} です。`);
}
eat(food: string) {
console.log(`${this.species} は ${food} を食べています。`);
}
sleep(hours: number) {
console.log(`${this.species} は ${hours} 時間眠ります。`);
}
move(distance: number) {
console.log(`${this.species} は ${distance} メートル移動しました。`);
}
}
class Dog extends Animal {
name: string;
age: number;
constructor(
name: string,
age: number
) {
this.name = name;
this.age = age;
}
showInfo() {
console.log(`名前: ${this.name}, 年齢: ${this.age}`);
}
bark() {
console.log(`${this.name} が吠えました: ワンワン!`);
}
noThisMethod() {
console.log('This method should not be called.');
}
}
const pochi = new Dog('ポチ', 3);
pochi.showSpecies(); // この動物は Dog です。
pochi.eat('ドッグフード'); // Dog は ドッグフード を食べています。
pochi.sleep(8); // Dog は 8 時間眠ります。
pochi.move(10); // Dog は 10 メートル移動しました。
pochi.showInfo(); // 名前: ポチ, 年齢: 3
pochi.bark(); // ポチ が吠えました: ワンワン!
エラーがない場合は、以下のように出力されました。
$ npm run dev -- ./lintCli.ts ./__mocks__/validInput.ts > 003-analyze-ast@1.0.0 dev > node --experimental-default-config-file ./lintCli.ts ./__mocks__/validInput.ts Welcome to the Lint CLI! [INFO] No issues found
以上の内容の詳細は第3回の記事を、実装の詳細はGitHubリポジトリの第3回のディレクトリをご覧ください。
【第4回】「TypeScript Compiler APIでASTからコード生成しFormatter CLIを作る」
第3回で実装したLinterを元にFormatterを実装しました。前半では、Formatter実装の準備としてASTからTypeScriptのソースコードを生成しました。第3回に実装した解析処理の逆の処理に相当します。
ASTノードのkindごとに対応するTypeScriptの構文を構築し、Compiler APIのcreatePrinter関数でソースコードを生成するCLIを実装しました。
このCLIに以下のJSON形式のASTを入力すると、const greeting: string = "Hello, World!";が出力されます。
{
"kind": "SourceFile",
"children": [
{
"kind": "FirstStatement",
"children": [
{
"kind": "VariableDeclarationList",
"children": [
{
"kind": "VariableDeclaration",
"children": [
{
"kind": "Identifier",
"text": "greeting",
"children": []
},
{
"kind": "StringKeyword",
"text": "string",
"children": []
},
{
"kind": "StringLiteral",
"text": "\\"Hello, World!\\"",
"children": []
}
]
}
]
}
]
},
{
"kind": "EndOfFileToken",
"children": []
}
]
}
後半では、以下のフローでソースコードを整形するFormatterを実装しました。
ソースコードからASTに落とし込み、各ASTノードを検査するまではLinterと同じです。その後、前半に実装したASTからソースコードを生成する処理を組み合わせ、ソースコードの整形を実現しました。整形には以下の独自ルールを用いました。
use-let-never-reassigned: 再代入されていないletをconstに変換する(ESLintのprefer-constのようなルール)double-quotes: ダブルクオートはシングルクオートに変換する(Prettierのquotesのようなルール)
ルールはLinterと同様、以下のようなJSON形式でファイルに記入し、CLIから読み取るようにしました。
{
"use-let-never-reassigned": "error",
"double-quotes": "error"
}
CLIにinvalidInput.tsのような不正なファイルを渡すと、以下のように整形されて出力されました。
CLIの実行結果:
$ npm run dev -- ./formatterCli.ts ./__mocks__/invalidInput.ts > basic-project@1.0.0 dev > node --experimental-default-config-file ./formatterCli.ts ./__mocks__/invalidInput.ts Welcome to the Formatter CLI! [INFO] [use-let-never-reassigned] letをconstに置換しました [INFO] [double-quotes] ダブルクオートをシングルクオートに修正しました [INFO] Formatted source: const message: string = 'This method has fixable errors.'; console.log(message);
invalidInput.ts:
let message: string = "This method has fixable errors."; console.log(message);
エラーがない場合は、以下のように整形されずに出力されました。
$ npm run dev -- ./formatterCli.ts ./__mocks__/validInput1.ts > basic-project@1.0.0 dev > node --experimental-default-config-file ./formatterCli.ts ./__mocks__/validInput1.ts Welcome to the Formatter CLI! [INFO] Formatted source: const message: string = 'This method has fixable errors.'; console.log(message);
以上の内容の詳細は第4回の記事を、実装の詳細はGitHubリポジトリの第4回のディレクトリをご覧ください。
静的解析ツールのコードリーディング
これまでは簡易なLinterとFormatterを自らの手で再現し、その仕組みを概観してきました。本項では、広く用いられているLinterのESLintとFormatterのPrettierのコードから、さらに仕組みを深堀りしていきます。どちらもJavaScriptで実装されているので、JavaScriptの基本的な知識があれば読み進められます。
ESLintのコードリーディング
ESLintのコードリーディングを通して、一般的なLinterの処理のフローを理解します。コードはGitHubに公開されています。コミットID31d9392の実装を読み進めます。eslintコマンドが実行されると、以下の流れで処理が行われます。
図の番号ごとに処理を深堀ります。なお、今回はFlat Configに対応したESLintの処理を読み進めます。
【①bin/eslint.js】実装箇所
eslintコマンドで読み込まれ、実行されるファイルです。lib/cli.jsにあるCLIの処理を呼び出します。
initやmcpのようなオプションが渡された場合、npxで外部のeslint関連のCLIを呼び出します。
【②lib/cli.js】実装箇所
ESLintクラスの処理を呼び出し、Lintを開始するファイルです。fixオプションのようなCLIに渡されているオプション固有の処理も、このファイルで行っています。
【③lib/eslint/eslint.js】実装箇所
ESLintクラスの実装が含まれているファイルです。ESLintクラスではコンストラクタでlinterを初期化し、これを元にクラスのメソッドでLintします。
【④lib/eslint/eslint.js lintFilesメソッド】実装箇所
ESLintクラスのメソッドのうち、ファイルごとにLintするlintFilesメソッドの実装を見てみましょう。
このメソッドでは、以下の順で処理が行われます。
calculateWorkerCount関数(実装箇所)でスレッド数を決定- スレッドが複数ある場合とない場合でそれぞれLintの処理を分岐
【⑤lib/eslint/eslint.js lintFilesWithoutMultithreading関数】実装箇所
スレッドが単一のときの処理を見てみましょう。④の2.の後にlintFilesWithoutMultithreading関数が実行されます。この関数でPromise.allでファイルごとに並行処理でlintFile関数が実行されます。
【⑥lib/eslint/eslint-helper.js lintFile関数】実装箇所
lintFile関数の処理を見てみましょう。この関数は、ファイルにverifyText関数(実装箇所)を実行してLintした結果を返します。
【⑦lib/linter/linter.js Linterクラス】実装箇所
⑥のverifyText関数では、あらかじめESLintクラスのコンストラクタで初期化されたlinterでLintします。linterの初期化にはLinterクラスから生成したインスタンスが用いられます(実装箇所)。
このクラスのverifyAndFixメソッド(実装箇所)で修正すべき対象がなくなるまでLintします。このメソッドでは、以下の順で処理が行われます。
_verifyWithFlatConfigArrayメソッド(実装箇所): Flat Configの内容を読み込んで適用#flatVerifyWithoutProcessorsメソッド(実装箇所): Configにカスタムのプロセッサーがセットされていない場合、ESLint標準のJS向けのプロセッサーで入力ファイルを処理
【⑧lib/linter/linter.js runRules関数】
Lintのコアの処理がrunRules関数です。⑥の2.のメソッドで実行されます。この関数では、以下の順で処理が行われます。
SourceCodeVisitorクラス(実装箇所): ソースコードの走査。addメソッドでセレクタ(ESLintがASTの操作に用いているESQueryの用語。ノードの識別子)に対してイベントリスナー登録FileContextクラス(実装箇所): Configのルールに基づいて問題を報告する用のContext作成SourceCodeクラス(実装箇所):traverseメソッドでASTを走査する手順(step)を設定SourceCodeTraverserクラス(実装箇所):traverseSyncメソッドで3.のstepを元にイベント発火・ルールの実行・検証を行う
特に4.では、ESLintの特徴であるイベント駆動のLintで効率の良いASTの走査とルールによる検証を実現しています。
以上の①〜⑧の工程で、ESLintはソースコードをLintします。
Prettierのコードリーディング
ESLintのコードリーディングと同様に、Prettierのコードリーディングを通して、一般的なFormatterの処理のフローを理解します。コードはGitHubに公開されています。コミットID5067313の実装を読み進めます。prettierコマンドが実行されると、以下の流れで処理が行われます。
図の番号ごとに処理を深堀ります。
【①bin/prettier.cjs】実装箇所
prettierコマンドで読み込まれ実行されるファイルです。src/cli/index.jsにあるCLIの処理(実装箇所)を実行します。
experimental-cliフラグが渡された場合は、src/experimental-cli/index.jsにあるCLIの処理(実装箇所)を実行します。
【②src/cli/index.js run関数】実装箇所
CLIのオプションを引数として渡す形で、同ファイル内のmain関数を呼び出す関数です。
【③src/cli/index.js main関数】実装箇所
Formatを実行するformatFiles関数を実行する関数です。オプションの検証を行い、不正な場合エラーを投げる処理もこの関数で行います。
【④src/cli/format.js formatFiles関数】実装箇所
入力ファイルの内容やオプションを元に、ファイルごとにFormatを実行する関数です。この関数でFormatした結果のキャッシュも行います。
【⑤src/index.js format関数】実装箇所
④のformatFiles関数から呼び出される関数です。渡されたオプションごとの処理を行い、formatWithCursor関数を実行した結果を返します。
【⑥src/main/core.js formatWithCursor関数】実装箇所
coreFormat関数を実行しFormatする関数です。エディタ上でFormatした際にカーソルの位置を維持する処理も行います。
【⑦src/main/core.js coreFormat関数】実装箇所
Formatのコアの処理がcoreFormat関数です。この関数では、以下の順で処理が行われます。
mainPrint関数とcallPluginPrintFunction関数が再帰状に実行される
printDocToStringWithoutNormalizedOptions関数(実装箇所): Docを元にフォーマットし、文字列に変換
- 以下のDocのTYPEで分岐し、TYPEごとに決まったカテゴリの処理を適用(「カテゴリ」: DocのTYPE の形式で例示)
- 「標準」:
STRING,ARRAY,CURSOR - 「インデント」:
INDENT,ALIGN,TRIM - 「レイアウト」:
GROUP,FILL - 「条件分岐」:
IF_BREAK,INDENT_IF_BREAK - 「改行」:
LINE - 「行末」:
LINE_SUFFIX,LINE_SUFFIX_BOUNDARY - 「メタ情報」:
LABEL,BREAK_PARENT
- 「標準」:
以上の①〜⑦の工程で、PrettierはソースコードをFormatします。
おわりに
本連載では、JavaScriptやTypeScript、ASTなどの技術を活用し、Linter・Formatterを自らの手で再現しました。また、広く用いられているLinterのESLintとFormatterのPrettierの内部実装を読み、仕組みを深堀りしました。
本連載を通して、静的解析向けのツールがどういった技術を活用し、どのような仕組みで動くのか少しでも興味を持っていただけたら幸いです。最後までご精読いただき、ありがとうございました。
連載バックナンバー
Think ITメルマガ会員登録受付中
全文検索エンジンによるおすすめ記事
- ASTの解析とCLIの実装によるTypeScript製Lintツール開発
- ASTの基礎知識とTypeScriptの環境構築から始めるCLIツール開発
- TypeScript Compiler APIでASTからコード生成しFormatter CLIを作る
- TypeScript×Node.jsでCLIを開発してnpmで公開する基本手順を学ぶ
- CI環境を構築して「ESLint」で静的解析を実行してみよう
- 「Visual Studio Code」でチーム開発するなら「共通設定」を整えよう
- ECMAScript
- 「K8sGPT」の未来と生成AIを用いたKubernetes運用の最前線
- 「Visual Studio Code」の「Dev Containers」でコンテナ実行環境を構築してみよう
- ES2015に追加、拡張された機能







