Language Service と tsserver:IDE 体験を支える仕組み
前提知識
- ›第1〜5回:コンパイラパイプラインの全体理解
- ›Language Server Protocol(LSP)の概念への理解
- ›エディタと Language Server の通信方法についての知識
Language Service と tsserver:IDE 体験を支える仕組み
エディタに赤い波線が表示されたり、補完候補が出てきたり、定義元にジャンプしたりするたびに、私たちはこの最終回で取り上げるコードを使っています。TypeScript の Language Service と tsserver は、コンパイラコアとエディタ体験をつなぐ橋渡し役です。パース・バインド・型チェックのパイプラインを IDE 向けの API でラップし、JSON プロトコルを通じて外部に公開します。
このレイヤーはコンパイラコアの上に約 40,000 行のコードを追加しており、LanguageService(プログラマティック API)と tsserver プロセス(ワイヤープロトコルサーバー)という2つの明確な層で構成されています。このアーキテクチャを理解すると、TypeScript がフルの型チェッカーを動かしながら、なぜエディタ上でほぼ瞬時のレスポンスを実現できるのかが見えてきます。
LanguageService インターフェースと createLanguageService
LanguageService インターフェースは、エディタ機能のための包括的な API サーフェスを定義しています。
export interface LanguageService {
getSyntacticDiagnostics(fileName: string): DiagnosticWithLocation[];
getSemanticDiagnostics(fileName: string): Diagnostic[];
getSuggestionDiagnostics(fileName: string): DiagnosticWithLocation[];
getCompletionsAtPosition(fileName, position, options?): CompletionInfo;
getCompletionEntryDetails(fileName, position, name, ...): CompletionEntryDetails;
getDefinitionAtPosition(fileName, position): DefinitionInfo[];
getTypeDefinitionAtPosition(fileName, position): DefinitionInfo[];
findReferences(fileName, position): ReferencedSymbol[];
getRenameInfo(fileName, position, preferences?): RenameInfo;
getSignatureHelpItems(fileName, position, options?): SignatureHelpItems;
getQuickInfoAtPosition(fileName, position): QuickInfo;
getApplicableRefactors(fileName, positionOrRange, ...): ApplicableRefactorInfo[];
getCodeFixesAtPosition(fileName, start, end, errorCodes, ...): CodeFixAction[];
// ... 50+ more methods
}
createLanguageService() は、ファイルシステムとプロジェクト設定を抽象化した LanguageServiceHost をラップし、ホスト側のファイルバージョンと同期した Program を生成します。Language Service のメソッドを呼び出すたびに synchronizeHostData() が実行され、内部の Program が最新のファイル変更を反映した状態に保たれます。
classDiagram
class LanguageServiceHost {
+getScriptFileNames(): string[]
+getScriptVersion(fileName): string
+getScriptSnapshot(fileName): IScriptSnapshot
+getCompilationSettings(): CompilerOptions
+getCurrentDirectory(): string
}
class LanguageService {
+getCompletionsAtPosition()
+getSemanticDiagnostics()
+getDefinitionAtPosition()
+findReferences()
+getCodeFixesAtPosition()
}
class Program {
+getSourceFiles()
+getTypeChecker()
+emit()
}
LanguageServiceHost <-- LanguageService : reads from
LanguageService --> Program : creates/manages
Program --> TypeChecker : creates on demand
LanguageServiceHost の設計こそが、TypeScript が複数の環境への組み込みをサポートできる鍵です。VS Code の TypeScript 拡張機能・tsserver プロセス・webpack の ts-loader・プログラマティックな利用者はそれぞれ異なる形でホストを実装していますが、どれも同じ Language Service の機能を利用できます。
ヒント: TypeScript の型情報を必要とするツール(リンター、コードジェネレーター、ドキュメント生成ツールなど)を開発する場合は、
LanguageServiceHostを実装してcreateLanguageService()を呼び出しましょう。tsserver のプロトコルを気にすることなく、型チェッカーのフル機能を活用できます。
補完・診断・ナビゲーション
補完エンジン
src/services/completions.ts にある自動補完エンジンは 6,173 行に及び、services レイヤーの中で最も大きな機能実装です。カーソル位置が与えられると、以下の点を判断する必要があります。
- 補完コンテキストの種類は何か? — メンバーアクセス(
foo.)、文字列リテラル(import パス)、JSX 属性、型の位置、ステートメントの位置など - スコープ内のシンボルは何か? — 識別子補完には checker の
getSymbolsInScope()、メンバーアクセスにはgetPropertiesOfType()を使用 - 結果のランク付けとフィルタリング — ファジーマッチング、型に基づく関連度、利用頻度、ラベルの詳細情報
sequenceDiagram
participant Editor
participant LS as LanguageService
participant Comp as completions.ts
participant TC as TypeChecker
Editor->>LS: getCompletionsAtPosition(file, pos)
LS->>Comp: getCompletions(sourceFile, position, ...)
Comp->>Comp: Determine completion context
alt Member access (foo.|)
Comp->>TC: getTypeAtLocation(foo)
Comp->>TC: getPropertiesOfType(type)
else Identifier position
Comp->>TC: getSymbolsInScope(location)
else Import path
Comp->>Comp: Search file system / package.json
end
Comp->>Comp: Filter, rank, format entries
Comp-->>Editor: CompletionInfo { entries: [...] }
Declaration Merging による Node の拡張
コードベースの中でも特に巧みなパターンのひとつが、services レイヤーが AST ノードに便利なメソッドを追加する仕組みです。src/services/types.ts には次のようなコードがあります。
declare module "../compiler/types.js" {
export interface Node {
getSourceFile(): SourceFile;
getChildCount(sourceFile?: SourceFile): number;
getChildAt(index: number, sourceFile?: SourceFile): Node;
getChildren(sourceFile?: SourceFile): readonly Node[];
getStart(sourceFile?: SourceFile, includeJsDocComment?: boolean): number;
getFullStart(): number;
getEnd(): number;
getWidth(sourceFile?: SourceFileLike): number;
getFullWidth(): number;
getText(sourceFile?: SourceFile): string;
getFirstToken(sourceFile?: SourceFile): Node | undefined;
getLastToken(sourceFile?: SourceFile): Node | undefined;
forEachChild<T>(cbNode: (node: Node) => T | undefined, ...): T | undefined;
}
}
これは TypeScript 自身の declaration merging 機能を使って、コンパイラの Node インターフェースにメソッドを後付けで追加しています。実装は services レイヤーの実行時に Node.prototype へ紐付けられます。コンパイラコア自体はこれらのメソッドに依存していません。あくまでも services レイヤーのコードと、公開 API の外部利用者のための利便性として存在しています。
診断の流れ
診断情報は service レイヤーを3段階に分けて流れます。
- 構文的な診断(Syntactic diagnostics) — パーサーから生成される高速な診断で、型チェックは不要
- 意味的な診断(Semantic diagnostics) — checker から生成され、フルの型チェックが必要
- 提案的な診断(Suggestion diagnostics) — エラーではなく、改善の可能性を示すヒント
service レイヤーはファイル単位で診断情報をキャッシュし、ファイルのバージョンが変わると無効化します。エディタのシナリオでは、まず構文的な診断が返され(即時フィードバック)、その後に意味的な診断が続きます。checker が処理を行う場合は時間がかかることもあります。
コードフィックスとリファクタリング
services レイヤーには、自動コードアクションの拡張可能なレジストリが2種類含まれています。
コードフィックス(70以上の実装)
src/services/codefixes/ ディレクトリには70以上の個別のコードフィックスプロバイダーが含まれており、それぞれ特定のエラーコードに対応しています。
| コードフィックス | トリガー条件 |
|---|---|
importFixes.ts |
"Cannot find name 'X'" |
fixSpelling.ts |
"Property 'X' does not exist. Did you mean 'Y'?" |
fixClassIncorrectlyImplementsInterface.ts |
"Class incorrectly implements interface" |
fixMissingAsync.ts |
async でない関数内での await の使用 |
addMissingAwait.ts |
値が期待される場所での Promise の使用 |
fixStrictClassInitialization.ts |
コンストラクタで初期化されていないプロパティ |
convertToTypeOnlyImport.ts |
型としてのみ使用されている import |
各プロバイダーは registerCodeFix() を呼び出して自身を登録し、対応するエラーコードを指定します。エディタが診断に対するコードフィックスを要求すると、レジストリが対応するプロバイダーにディスパッチし、プロバイダーはエラーのコンテキストを分析して、修正内容を表す TextChange オブジェクトを生成します。
リファクタリング(16のプロバイダー)
src/services/refactors/ ディレクトリにはリファクタリングプロバイダーが含まれています。
extractSymbol.ts— 関数の抽出 / 定数の抽出moveToFile.ts/moveToNewFile.ts— ファイル間での宣言の移動convertParamsToDestructuredObject.ts— 位置パラメータを名前付きオブジェクトに変換convertImport.ts— 名前付き import と namespace import の切り替えinlineVariable.ts— すべての参照箇所での変数のインライン化convertToOptionalChainExpression.ts—&&チェーンを?.に変換
flowchart TD
Editor["Editor: 'Refactor...'"] --> LS["getApplicableRefactors(file, range)"]
LS --> Registry["Refactoring Registry"]
Registry --> R1["extractSymbol"]
Registry --> R2["moveToFile"]
Registry --> R3["convertImport"]
Registry --> RN["...16 total"]
R1 --> Check["Is refactoring applicable<br/>at this position?"]
Check -->|Yes| Available["Return refactoring info"]
Check -->|No| Skip["Skip"]
Available --> Editor2["User selects refactoring"]
Editor2 --> Apply["getEditsForRefactor()"]
Apply --> Edits["TextChange[] across files"]
tsserver:プロトコル・Session・プロジェクト管理
エディタが実際に通信する相手が tsserver プロセスです。これは stdin から JSON リクエストを読み取り、JSON レスポンスを stdout に書き出す Node.js プロセスです。
ワイヤープロトコル
src/server/protocol.ts では CommandTypes enum が定義されており、サーバーが処理するすべてのリクエスト型が列挙されています。
export const enum CommandTypes {
CompletionInfo = "completionInfo",
CompletionDetails = "completionEntryDetails",
Definition = "definition",
DefinitionAndBoundSpan = "definitionAndBoundSpan",
Implementation = "implementation",
References = "references",
Rename = "rename",
Geterr = "geterr",
Format = "format",
Configure = "configure",
Open = "open",
Close = "close",
Change = "change",
NavBar = "navbar",
// ... 50+ more
}
これは LSP(Language Server Protocol)ではなく、LSP が登場する以前から存在する TypeScript 独自のプロトコルです。VS Code の TypeScript 拡張機能はこのプロトコルと LSP の間の変換を行っています。一部のエディタ(旧バージョンの Visual Studio など)はこのプロトコルを直接使用しています。
Session クラス
src/server/session.ts には、受信したリクエストをディスパッチする Session クラスが含まれています。コマンド名からハンドラ関数へのマップを管理しており、各ハンドラは以下の処理を行います。
- リクエストからパラメータを取り出す
- 行・列の位置情報を内部オフセットに変換する
- 適切な
LanguageServiceメソッドを呼び出す - 内部の結果をプロトコルのレスポンス形式に変換する
ProjectService
src/server/editorServices.ts には、ワークスペースマネージャーである ProjectService が含まれています。1つの tsserver プロセスが複数のプロジェクトを同時に管理できます。
flowchart TD
PS["ProjectService"] --> CP["ConfiguredProject<br/>(tsconfig.json based)"]
PS --> IP["InferredProject<br/>(loose files)"]
PS --> EP["ExternalProject<br/>(build tool managed)"]
CP --> LS1["LanguageService"]
IP --> LS2["LanguageService"]
EP --> LS3["LanguageService"]
LS1 --> P1["Program"]
LS2 --> P2["Program"]
LS3 --> P3["Program"]
- ConfiguredProject:
tsconfig.jsonファイルに基づいて作成されます。.tsファイルを開くと、サーバーは上位ディレクトリに向かってtsconfig.jsonを検索し、見つかったらそのプロジェクトを作成します。 - InferredProject:どの
tsconfig.jsonにも属さないファイルのために作成されます。サーバーはデフォルト設定でプロジェクトを作成し、開いているすべてのルーズファイルを含めます。 - ExternalProject:使用するファイルとオプションをサーバーに明示的に伝える外部ビルドツールによって作成されます。
各プロジェクトは独自の LanguageService と Program を持ちます。ProjectService はライフサイクルを管理し、ファイルが開かれるとプロジェクトを作成し、ファイルが変更されると更新し、不要になったら破棄します。
サーバーのエントリーポイント
src/tsserver/server.ts のエントリーポイントは、Node.js のシステムを初期化し、ロガーを作成し、キャンセル処理を設定してから start() を呼び出します。注目すべき点として、console.log・console.warn・console.error をオーバーライドしてロガーを通じてリダイレクトしています。これにより、console.log を使用する可能性がある Language Service プラグインが stdout の JSON プロトコルを壊してしまうのを防いでいます。
console.log = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Info);
console.warn = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);
console.error = (...args) =>
logger.msg(args.length === 1 ? args[0] : args.join(", "), ts.server.Msg.Err);
ヒント: tsserver をデバッグするには、環境変数
TSS_LOGにファイルパスを設定しましょう。サーバーはすべてのリクエスト・レスポンス・内部イベントの詳細なログをそのファイルに書き出します。エディタ連携の問題を診断する際に非常に役立ちます。
全体像
6つの記事を通じて TypeScript コンパイラの全体を追ってきました。ここで、すべての要素がどのように組み合わさるかをまとめます。
flowchart TD
subgraph "User Interface"
Editor["VS Code / Editor"]
end
subgraph "Server Layer (src/server)"
tsserver["tsserver process"]
Session["Session"]
ProjectService["ProjectService"]
end
subgraph "Service Layer (src/services)"
LS["LanguageService"]
Completions["Completions"]
Diagnostics["Diagnostics"]
CodeFixes["CodeFixes (70+)"]
Refactors["Refactors (16)"]
end
subgraph "Compiler Core (src/compiler)"
Program["Program"]
Scanner["Scanner"]
Parser["Parser"]
Binder["Binder"]
Checker["Checker (54K lines)"]
Emitter["Emitter + Transformers"]
end
Editor <-->|"JSON protocol"| tsserver
tsserver --> Session
Session --> ProjectService
ProjectService --> LS
LS --> Completions
LS --> Diagnostics
LS --> CodeFixes
LS --> Refactors
LS --> Program
Program --> Scanner
Program --> Parser
Program --> Binder
Program --> Checker
Program --> Emitter
このアーキテクチャは10年以上にわたって TypeScript を支えてきました。薄いエントリーポイント、5フェーズのコンパイルパイプライン、3つのコアデータ構造(Node・Symbol・Type)、そしてコンパイラコアから Language Service・サーバーへと続くクリーンなレイヤー構造です。チームがパフォーマンスを10倍にするため Go でコンパイラを書き直すにあたっても、根本的なアーキテクチャは変わらないでしょう。この JavaScript 実装を深く理解しておくことは、新しい実装を読み解くための強固な土台になります。
TypeScript コンパイラは、JavaScript エコシステムの中でも最も印象的なオープンソースソフトウェアのひとつです。TypeScript 自体にコントリビュートする方も、その API を活用したツールを開発する方も、あるいは単純にお気に入りの言語が内部でどう動いているか気になる方も、このシリーズが自信を持って読み進めるための「地図」として役立てば幸いです。