Read OSS

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 レイヤーの中で最も大きな機能実装です。カーソル位置が与えられると、以下の点を判断する必要があります。

  1. 補完コンテキストの種類は何か? — メンバーアクセス(foo.)、文字列リテラル(import パス)、JSX 属性、型の位置、ステートメントの位置など
  2. スコープ内のシンボルは何か? — 識別子補完には checker の getSymbolsInScope()、メンバーアクセスには getPropertiesOfType() を使用
  3. 結果のランク付けとフィルタリング — ファジーマッチング、型に基づく関連度、利用頻度、ラベルの詳細情報
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段階に分けて流れます。

  1. 構文的な診断(Syntactic diagnostics) — パーサーから生成される高速な診断で、型チェックは不要
  2. 意味的な診断(Semantic diagnostics) — checker から生成され、フルの型チェックが必要
  3. 提案的な診断(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 クラスが含まれています。コマンド名からハンドラ関数へのマップを管理しており、各ハンドラは以下の処理を行います。

  1. リクエストからパラメータを取り出す
  2. 行・列の位置情報を内部オフセットに変換する
  3. 適切な LanguageService メソッドを呼び出す
  4. 内部の結果をプロトコルのレスポンス形式に変換する

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"]
  • ConfiguredProjecttsconfig.json ファイルに基づいて作成されます。.ts ファイルを開くと、サーバーは上位ディレクトリに向かって tsconfig.json を検索し、見つかったらそのプロジェクトを作成します。
  • InferredProject:どの tsconfig.json にも属さないファイルのために作成されます。サーバーはデフォルト設定でプロジェクトを作成し、開いているすべてのルーズファイルを含めます。
  • ExternalProject:使用するファイルとオプションをサーバーに明示的に伝える外部ビルドツールによって作成されます。

各プロジェクトは独自の LanguageServiceProgram を持ちます。ProjectService はライフサイクルを管理し、ファイルが開かれるとプロジェクトを作成し、ファイルが変更されると更新し、不要になったら破棄します。

サーバーのエントリーポイント

src/tsserver/server.ts のエントリーポイントは、Node.js のシステムを初期化し、ロガーを作成し、キャンセル処理を設定してから start() を呼び出します。注目すべき点として、console.logconsole.warnconsole.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 を活用したツールを開発する方も、あるいは単純にお気に入りの言語が内部でどう動いているか気になる方も、このシリーズが自信を持って読み進めるための「地図」として役立てば幸いです。