TypeScript/TypeScript コンパイラの差分コンパイルの仕組み
#コードリーディング #typescript
注意: これは書きかけのメモです。途中で力尽きてます。
はじめに
最近趣味で honey-css-modules というものを作っている。これは*.module.cssに対応する*.module.css.d.tsを生成するコードジェネレーターなのだが、これに差分生成モードを導入しようとしてる。つまりキャッシュを導入して、b.module.cssが変わってなかったら、b.module.css.d.tsの生成をスキップしたい。
code:console
$ hcm 'src/**/*/module.css'
src/a.module.css (generated)
src/b.module.css (generated)
$ vim src/a.module.css
(Edit the file)
$ hcm 'src/**/*/module.css'
src/a.module.css (generated)
また、コード生成時に CSS Module ファイル同士の import の整合性のチェックも行いたい。例えば、存在しない CSS Module の import や、存在しない value の import を警告したい。
code:a.module.css
@import './non-existent.module.css';
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// Cannot find module './non-existent.module.css'.
@value b_1, b_2 from './b.module.css';
// ^^^
// Module './b.module.css' has no exported token b_2'.
code:b.module.css
.b_1 { color: red; }
これを実装するには、複数のファイルを横断した検査を行わないといけない。a.module.cssを検査するときは、non-existent.module.cssが存在しないことや、b.module.cssからb_2が export されてないことを知ってないといけない。1つのファイルの検査に、それ以外のファイルの情報が必要になる。
この検査についても、キャッシュを活用してファイルを更新した時だけ行う...としたいのだが、問題がある。あるファイルが変更されると、他のファイルの検査結果に影響が出てくるのだ。例えば、b.module.cssからb_2を export するよう変更すると、a.module.cssにエラーが出なくなる。
code:a.module.css
// ...
@value b_1, b_2 from './b.module.css';
// ^^^
// OK!
code:b.module.css
.b_1 { color: red; }
.b_2 { color: red; }
つまり、変更されたファイルだけでなく、そのファイルを import するファイルも再検査する必要がある。以下のように。
code:console
$ hcm 'src/**/*/module.css'
src/a.module.css (checked, generated)
src/b.module.css (checked, generated)
src/c.module.css (checked, generated)
$ vim src/b.module.css
(Edit the file)
$ hcm 'src/**/*/module.css'
src/a.module.css (checked)
src/b.module.css (checked, generated)
実装にあたって、似たようなことを世の中のツールがどうやってるのか、参考にしたかった。そこで、TypeScript コンパイラの差分コンパイル機能(--incremental)のコードを読んでみることにした。
知りたいこと
コンパイルキャッシュには何が入ってるのか
ファイルのハッシュ、AST、エラー情報など色々なデータがあるが、どれが含まれているのか
TypeScript プロジェクトのコンパイルがどういうフローで行われるか
パース、型検査、.js/.d.ts の出力、というざっくりとしたフェーズがあるのは想像できるが...実際どうなってるのか
どのフェーズが1ファイル単独で行われて、どのフェーズが複数ファイル横断で行われるのか
パースは1ファイルずつ単独で、型検査は複数ファイル横断でやってるだろうが...他のフェーズはどうか
変更されたファイルを import しているファイルをどう辿るか
モジュールグラフを逆に辿っていくことになるが、それをどうやっているのか
効率よく逆方向に辿るにはデータ構造を工夫する必要があるが、どう工夫しているのか
コンパイルキャッシュによって何がスキップされるのか
パース、型検査などのフェーズのうち、どれがスキップされるのか
逆に、キャッシュがあっても毎回行われるものは何か
エディタ上に型検査のエラーをどう表示しているのか
理想的には、開いているファイルだけ型検査して、そのエラーをエディタに表示したい
ただ型検査は、開いているファイルに加え、それが import している全てのファイルを解析しないと実行できない
開いているファイルと、その関連するファイルを取得して、それだけ型検査する仕組みがあるのだろうが、実際どうやってるのか
エディタ上でファイルを変更した時に、どう型検査を実行してるか
b.module.cssを変更したら、b.module.cssとそれを import するa.module.cssの再検査が必要
影響するファイルの取得と、その再検査をどうやっているか
コンパイルキャッシュの中身
とりあえず適当なプロジェクトを作ってコンパイルキャッシュを作成してみる。
code:package.json
{
"name": "tsc-incremental-test",
"type": "module",
"devDependencies": {
"typescript": "5.7.3"
}
}
code:tsconfig.json
{
"include": "src/**/*",
"compilerOptions": {
"target": "ES5",
"module": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedSideEffectImports": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist",
"incremental": true
}
}
code:src/a.ts
import './non-existent.js';
import { b_1, b_2 } from './b.js';
code:src/b.ts
export const b_1 = 'b_1';
これでnpx tscする。
code:console
$ npx tsc
src/a.ts:1:8 - error TS2307: Cannot find module './non-existent.js' or its corresponding type declarations.
1 import './non-existent.js';
~~~~~~~~~~~~~~~~~~~
src/a.ts:2:15 - error TS2305: Module '"./b.js"' has no exported member 'b_2'.
2 import { b_1, b_2 } from './b.js';
~~~
Found 2 errors in the same file, starting at: src/a.ts:1
npx tscした時に、tsconfig.tsbuildinfoというファイルが作成されている。これが TypeScript のコンパイルキャッシュだ。以下に一部省略したものを貼っておく。
code:json
{
"fileNames": [
"./node_modules/typescript/lib/lib.d.ts",
"./node_modules/typescript/lib/lib.es5.d.ts",
"./node_modules/typescript/lib/lib.dom.d.ts",
"./node_modules/typescript/lib/lib.webworker.importscripts.d.ts",
"./node_modules/typescript/lib/lib.scripthost.d.ts",
"./node_modules/typescript/lib/lib.decorators.d.ts",
"./node_modules/typescript/lib/lib.decorators.legacy.d.ts",
"./src/b.ts",
"./src/a.ts"
],
"fileIdsList": 8,
"fileInfos": [
{
"version": "a7297ff837fcdf174a9524925966429eb8e5feecc2cc55cc06574e6b092c1eaa",
"impliedFormat": 1
},
{
"version": "e41c290ef7dd7dab3493e6cbe5909e0148edf4a8dad0271be08edec368a0f7b9",
"affectsGlobalScope": true,
"impliedFormat": 1
},
{
"version": "4fd3f3422b2d2a3dfd5cdd0f387b3a8ec45f006c6ea896a4cb41264c2100bb2c",
"affectsGlobalScope": true,
"impliedFormat": 1
},
// ... (more 6 items) ...
],
"root": 8, 9,
"options": {
"esModuleInterop": true,
"module": 199,
"noUncheckedSideEffectImports": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": 1
},
"referencedMap": 9, 1,
"semanticDiagnosticsPerFile": [
[
9,
[
{
"start": 7,
"length": 27,
"messageText": "Cannot find module './non-existent.module.css' or its corresponding type declarations.",
"category": 1,
"code": 2307
},
{
"start": 50,
"length": 3,
"messageText": "Module '\"./b.js\"' has no exported member 'b_2'.",
"category": 1,
"code": 2305
}
]
]
],
"version": "5.7.3"
}
ここから、以下の情報がコンパイルキャッシュに含まれてることがわかる。
fileNames
プロジェクトに含まれる全てのファイルのパス
ユーザ定義のファイルと、 --libオプションで読み込まれる型定義の両方が含まれる
fileIdsList
ファイルの Id のリストのリスト。詳しくは後述。
[[8]]の8は 1-based index で、fileNames[8 - 1]の"./src/b.ts"を示してる (参考)
fileInfos
プロジェクトに含まれるファイルの詳細
version: ファイルのハッシュ
ファイルのテキストと、その diagnostics (エラーなどの診断情報) を含むテキストのハッシュを取ってる (参考1, 参考2)
impliedFormat: ファイルのモジュールタイプ
CommonJS を表す1か、ESM を表す99のどっちか (参考1, 参考2, 参考3)
affectsGlobalScope: ファイルがグローバルスコープを汚染するか
グローバルスコープにある API (Promise, windowとか) の型を定義したり、拡張したりするファイルはtrueになる模様 (参考1, 参考2)
root
tsconfig.jsonのincludeオプションにマッチするファイルのリスト
id は 1-based index。今回は[8, 9]だから、["./src/b.ts", "./src/a.ts"]。
このファイルを基点にモジュールグラフが構築されるので、root という名になってるっぽい
プロジェクトのエントリポイント、という見方もできる
options
コンパイルオプション
コンパイルオプションが変わるとコンパイル結果が変わるので、含めてあるのだろう
referencedMap
モジュール同士の依存関係を表したマップ
[[fileId, fileIdListId], ...]という構造で (id はそれぞれ 1-based index)、fileNames/fileIdsListと突き合わせて読み取る (参考)
[9, 1]は["./src/a.ts", ["./src/b.ts"]]という意味で、./src/a.tsが./src/b.tsに依存して (import して) いることを示してる
semanticDiagnosticsPerFile
「semantic diagnostics」と呼ばれる診断情報がファイルごとに格納されてる
補足: TypeScript には「syntactic diagnostics」と「semantic diagnostics」の2種類の診断情報がある
TypeScript/コンパイルエラーの取り扱い#67bc24dfc75fd30000804be3
version
tscコマンドのバージョン
ファイルのハッシュ、エラー情報は入ってたけど、AST は入ってなくてへえという気持ち。affectsGlobalScope/referencedMapなど、ファイルの影響範囲を表すデータはちゃんと入ってた。
そういえばimport "./non-existent.js"で import しようとしてるファイルがsrc/a.tsの依存に入ってなのは意外だった。まあ specifier がsrc/non-existent.js or src/non-existent.tsどちらに resolve されるのか、ファイルがない限りは確定しないし、依存に入れようがないのだろう。
コンパイルフロー
コンパイラのコードを読んでいたら、トレースで使っているPhaseという enum を発見した。
code:ts
// https://github.com/microsoft/TypeScript/blob/52eaa7b02fdeaa8969f88c7f5c0b252f5f0bd147/src/compiler/tracing.ts#L132-L140
export const enum Phase {
Parse = "parse",
Program = "program",
Bind = "bind",
Check = "check", // Before we get into checking types (e.g. checkSourceFile)
CheckTypes = "checkTypes",
Emit = "emit",
Session = "session",
}
ここに書いてあるものがコンパイルを構成するフェーズなのだろう。それぞれどういうものか見ていくと、
1. Parse
ファイルをパースして、SourceFileというオブジェクトを生成するフェーズ
このフェーズで、AST の構築と、syntactic diagnostics の収集を行う
SourceFileのプロパティにそれが保存される (参考)
createSourceFile(fileName, ...)がフェーズの開始点 (参考)
2. Program
複数のSoruceFileをまとめたProgramというオブジェクトを生成するフェーズ
createProgram(rootNames, options, ...)がフェーズの開始点 (参考)
プロジェクトのエントリポイント (先述のroot) と、コンパイルオプションなどを受け取って、エントリポイントを起点に再帰的に import 文を辿って、SourceFileをかき集めていく模様 (参考)
createProgram(...)の中からcreateSourceFile(...)が呼ばれてる (参考)
つまり Parse フェーズは Program フェーズに内包されてる
3. Bind
ソースコード内の識別子 (変数名, 関数名, クラス名など) をその宣言と結びつけるフェーズ
bind の結果、symbolという宣言を表すオブジェクトが作られ、AST の各 Node に紐づけられる (参考)
bindSourceFile(sourceFile, ...)がフェーズの開始点 (参考)
4. Check
SourceFile/Expression/DeferredNodeなどの型検査を行うフェーズ
SourceFileの型検査の場合、checkSourceFile(sourceFile, ...)がフェーズの開始点 (参考)
checkSourceFile関数を使うには、事前にcreateTypeChecker(host)で Checker を作成する必要がある (参考)
このcreateTypeChecker(...)を呼んだ時に、Programに含まれる全てのSourceFileに対して、bindSourceFileを実行してる (参考)
つまり Bind フェーズは Check フェーズに内包されてる
checker 自体は Program が保持している模様 (参考)
つまり Checker フェーズは Program フェーズに内包されてる
5. CheckTypes
型検査を行うのは Check と変わらないけど、サブタイプの検査とか、もうちょっと細かい型検査を行うフェーズっぽい
https://gyazo.com/811c52194344da69499177ecdfe659f9
ちゃんと追ってないけど、たぶん Check フェーズに内包されてるのでは
6. Emit
.jsや.d.tsを出力するフェーズ
Program#emit(souceFile, ...)がフェーズの開始点 (参考)
7. Session
tsserver (Language Server) 実行時にのみ存在するフェーズ
今回はあんまり関心ないので無視
この情報をもとにフェーズの関係性を示した図が以下になる。
https://scrapbox.io/files/67a755c1edf5c1aff9994b0d.svg
ちょっと毛色は違うけど、TypeScript チームの解説資料にコンパイラのレイヤを表した図がある。これと比較しながら見ると、より理解が捗るかも。
https://github.com/microsoft/TypeScript-Compiler-Notes/raw/main/intro/imgs/layers-2.png
https://github.com/microsoft/TypeScript-Compiler-Notes/tree/31425672133e15f4e61f52a0f541d060f239e915/intro#the-typescript-compiler
個々のフェーズやコンパイラを構成するレイヤについてもっと詳しく知りたい場合は、以下の公式の解説を読むと良い。
https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/GLOSSARY.md
https://github.com/microsoft/TypeScript-Compiler-Notes/tree/main/codebase/src/compiler
今回描いた図から分かることは、Parse/Bind フェーズは各ファイル単独で、Check/Emit は複数ファイル横断で実行しているということだ。
差分コンパイルの仕組み
いよいよ差分コンパイルのコードを読んでいく。だいぶまとめるのに力尽きつつあるので早足で...
差分コンパイルの起点となるコードは、src/compiler/builderPublic.tsのcreateEmitAndSemanticDiagnosticsBuilderProgramのようだった。
code:src/compiler/builder.ts
// from: https://github.com/microsoft/TypeScript/blob/b258429aaaa6206fd5f911360b094ca626544ce9/src/compiler/builderPublic.ts#L201-L234
export function createEmitAndSemanticDiagnosticsBuilderProgram(
newProgramOrRootNames: Program | readonly string[] | undefined,
hostOrOptions: BuilderProgramHost | CompilerOptions | undefined,
oldProgramOrHost?: CompilerHost | EmitAndSemanticDiagnosticsBuilderProgram,
configFileParsingDiagnosticsOrOldProgram?: readonly Diagnostic[] | EmitAndSemanticDiagnosticsBuilderProgram,
configFileParsingDiagnostics?: readonly Diagnostic[],
projectReferences?: readonly ProjectReference[],
) {
return createBuilderProgram(
BuilderProgramKind.EmitAndSemanticDiagnosticsBuilderProgram,
getBuilderCreationParameters(
newProgramOrRootNames,
hostOrOptions,
oldProgramOrHost,
configFileParsingDiagnosticsOrOldProgram,
configFileParsingDiagnostics,
projectReferences,
),
);
}
引数のbuildInfoにはtsconfig.tsbuildinfo相当の情報が入ってる。hostはDIに使うやつで、ファイルシステムにアクセスする API (readFile, getCurrentDirectory) が入ってるオブジェクトのこと。
重要なのは関数がProgramを返しているところ。
...ここでメモは尽きている。