【テクニカル・上級編】VS Codeで「独自の言語サーバー(LSP)」を自作する:マイナー言語や設定ファイルの補完を爆速化する方法 – 軽量・高機能テキストエディタ生産性向上バイブル

VS Codeを極限まで掌握せよ:独自LSP(Language Server Protocol)の自作と、開発体験の自動化アーキテクチャ

開発現場において「既存のツール群にはない、我々独自のドメイン特化型設定ファイル」や「社内製マイナーDSL(ドメイン固有言語)」の記述に苦しめられた経験はないだろうか。文法エラーはCI/CDパイプラインを回して初めて発覚し、コード補完は効かず、リファクタリングは手作業の置換に頼る——。これはエンジニアの認知負荷を無駄に増大させ、デリバリー速度を致命的に低下させるアンチパターンだ。

ネットの海を漂えば、「VS Codeの拡張機能の作り方」や「既存LSPの導入手順」といった薄っぺらいチュートリアルは溢れかえっている。しかし、本稿で目指すのはそんな次元ではない。

言語仕様のパースから、Language Server Protocol(LSP)の低レイヤなJSON-RPC通信のハンドリング、VS Codeクライアント側での動的ライフサイクル管理、さらにはDockerコンテナ環境やCI/CDパイプラインへの完全自動統合まで、開発環境のすべてを掌中に収めるための「完全無欠のLSP自作・運用アーキテクチャ」を解説する。

プロトコルの内側でデータがどう流れるのか、メモリ消費をどう極限まで削るのか。生粋のDevOpsアーキテクトの視点から、その真髄を紐解いていこう。

—

1. 内部アーキテクチャ解剖:LSPはクライアントとサーバーの間で何を行っているのか

LSP(Language Server Protocol)は、マイクロソフトが提唱した、言語の解析機能(構文解析、補完、ホバー、定義ジャンプなど)をエディタ(クライアント)と独立したプロセス(サーバー)に分離するためのオープン標準規格だ。

伝統的アプローチ vs LSPアプローチ

  • 多対多の呪縛(従来): $M$ 個のエディタに対して $N$ 個の言語サポートを提供する場合、$M \times N$ 個のプラグインをそれぞれ独自の実装で作る必要があった。
  • LSPによる抽象化: エディタ側は共通のLSPクライアントを持ち、言語サーバー側は標準入出力(stdio)やTCP経由でJSON-RPCを話すだけで、あらゆるエディタ(VS Code, Neovim, Emacsなど)で同一の高度な機能が即座に利用可能になる。

+——————-+ +———————–+
| VS Code (Client) | — (JSON-RPC / stdio) –> | Custom Language Server|
| | <--- (Diagnostics) ----- | (Node.js / Python) | +-------------------+ +-----------------------+ この通信の基盤にあるのは JSON-RPC 2.0 だ。
例えば、ユーザーがエディタでファイルを開いた瞬間、クライアントはサーバーへ `textDocument/didOpen` を送信し、サーバー側はAST(抽象構文木)をメモリ上に構築して、型エラーや構文エラーを `textDocument/publishDiagnostics` として非同期に送り返す。このイベント駆動の非同期パイプラインこそが、巨大なコードベースでもUIスレッドをブロックしない高パフォーマンスの源泉である。

—

2. 独自LSPサーバーの設計と実装(TypeScript / Node.js)

ここでは、社内独自のデプロイ設定ファイル(拡張子 `.deployment`)を想定し、特定のキー(例: `target_env: production` など)に対して爆速の入力補完(Completion)とバリデーションを返す最小限にして堅牢なLSPサーバーを実装する。

プロジェクトの初期化と依存関係

Node.js環境を前提とする。LSPのボイラープレートを自前で書く愚は避け、公式の `vscode-languageserver` ライブラリを使用する。

mkdir custom-deployment-ls
cd custom-deployment-ls
npm init -y
LSPのコアライブラリと、Node.js用IPCランタイムをインストール
npm install vscode-languageserver vscode-languageserver-textdocument
npm install -D typescript @types/node
npx tsc –init

サーバー実装コード (`src/server.ts`)

以下のコードは、標準入出力(stdio)を介してVS Codeからのリクエストを受け取り、JSON-RPCメッセージを処理するLSPサーバーのコアだ。

import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
InitializeResult,
TextDocumentPositionParams,
CompletionItem,
CompletionItemKind,
TextDocumentSyncKind
} from ‘vscode-languageserver/node’;

import { TextDocument } from ‘vscode-languageserver-textdocument’;

// 標準入出力(stdio)を用いてVS Codeクライアントと接続を確立する
// ProposedFeaturesにより、実験的または高度なLSP機能へのアクセスを有効化
const connection = createConnection(ProposedFeatures.all);

// ドキュメント管理用のマネージャーを初期化(バッファの同期を自動管理)
const documents: TextDocuments = new TextDocuments(TextDocument);

connection.onInitialize((params: InitializeParams): InitializeResult => {
connection.console.log(‘【LSP】Custom Deployment Language Server が初期化されました。’);

return {
capabilities: {
// テキストドキュメントの同期方式を設定(Incremental: 差分のみ同期してメモリとCPU負荷を最小化)
textDocumentSync: TextDocumentSyncKind.Incremental,
// 入力補完機能(Completion)の有効化とトリガー文字の定義
completionProvider: {
resolveProvider: true,
triggerCharacters: [‘:’, ‘ ‘]
}
}
};
});

// 補完リクエスト(Ctrl+Space や文字入力時)を受け取った際のハンドラ
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
// ここでは静的な補完候補を返す(実運用ではASTやスキーマ定義から動的生成する)
return [
{
label: ‘target_env’,
kind: CompletionItemKind.Property,
data: 1,
detail: ‘デプロイ先の環境を指定します。’,
documentation: ‘指定可能な値: development, staging, production’
},
{
label: ‘replicas’,
kind: CompletionItemKind.Value,
data: 2,
detail: ‘コンテナのレプリカ数を指定します(数値)。’,
documentation: ‘例: 3’
},
{
label: ‘enable_cache’,
kind: CompletionItemKind.Keyword,
data: 3,
detail: ‘キャッシュ層の有効化フラグ。’,
documentation: ‘指定可能な値: true, false’
}
];
}
);

// 補完アイテムの詳細情報(Hover時や選択時)が要求された場合のハンドラ
connection.onCompletionResolve(
(item: CompletionItem): CompletionItem => {
if (item.data === 1) {
item.detail = ‘target_env (必須パラメータ)’;
item.documentation = ‘プロダクション環境では必ず staging または production を指定してください。’;
}
return item;
}
);

// ドキュメント管理をコネクションに紐付け
documents.listen(connection);

// サーバープロセスのリスニング開始
connection.listen();

—

3. VS Codeクライアント(拡張機能)側の実装とLSPプロセスの起動

サーバーが単体で動作しても、VS Codeがそれを認識しなければ意味がない。次は、VS Codeからサーバープロセスを起動・制御する「クライアント拡張機能」を構築する。

拡張機能プロジェクトの構造

vscode-custom-ls-client/
├── package.json
├── tsconfig.json
└── client/
└── src/
└── extension.ts

クライアント実装 (`client/src/extension.ts`)

VS Codeの拡張機能APIを用い、バックグラウンドでNode.js製LSPサーバープロセスを安全に起動し、特定の言語ID(`custom-deployment`)に紐付ける。

import as path from ‘path’;
import { ExtensionContext, window } from ‘vscode’;
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from ‘vscode-languageclient/node’;

let client: LanguageClient;

export function activate(context: ExtensionContext) {
// サーバー側のエントリポイント(ビルド済みのJSファイルを指す)
const serverModule = context.asAbsolutePath(
path.join(‘server’, ‘out’, ‘server.js’)
);

// デバッグ時と本番稼働時のランタイムオプション設定
// 稼働時のプロセスを完全に切り離し、IPC(標準入出力)で安全に通信させる
const debugOptions = { execArgv: [‘–nolazy’, ‘–inspect=6009’] };

const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions
}
};

// クライアント側の動作オプション
const clientOptions: LanguageClientOptions = {
// このLSPサーバーを適用するファイル種別(Language ID)
documentSelector: [{ scheme: ‘file’, language: ‘custom-deployment’ }],
synchronize: {
// 設定ファイルの変更を監視対象に含める
fileEvents: window.createFileSystemWatcher(‘/.clientrc’)
}
};

// LanguageClientのインスタンス化と起動
client = new LanguageClient(
‘customDeploymentLanguageServer’,
‘Custom Deployment Language Server’,
serverOptions,
clientOptions
);

window.showInformationMessage(‘Custom Deployment LSP Client が起動しました。’);

// サーバーの起動とJSON-RPC通信の開始
client.start();
}

export function deactivate(): Thenable | undefined {
if (!client) {
return undefined;
}
// 拡張機能終了時にLSPサーバープロセスを安全に停止(ゾンビプロセス化を防止)
return client.stop();
}

—

4. CI/CDパイプラインとの高度な連携:ヘッドレスLSPによる静的解析の自動化

ここで視点をDevOpsの最前線、CI/CDパイプラインへと移行しよう。
「エディタ上で補完が効く」だけではプロのインフラストラクチャとは言えない。エディタ上で動いているLSPサーバーの検証ロジックを、そのままCI(GitHub Actions等)のヘッドレス環境で実行し、構文ミスやスキーマ違反のあるコードを絶対にマージさせない仕組みを構築する。

LSPサーバーの本質は「テキストを受け取り、診断(Diagnostics)を返す単なる関数」であるため、専用のCLIラッパー(Linter)を1枚噛ませるだけで、エディタなしでCIから完全再利用できる。

CI用バリデーションスクリプト (`scripts/ci-lint.ts`)

VS Codeを介さず、LSPのコアロジックや独自パーサーを直接呼び出して、PR内の設定ファイルを全件検査するスクリプトの実装例だ。

import as fs from ‘fs’;
import as path from ‘path’;

// 独自設定ファイルのバリデーションロジック(LSPサーバーと共通化可能)
function validateDeploymentFile(filePath: string): boolean {
const content = fs.readFileSync(filePath, ‘utf-8’);
let hasError = false;

console.log(`[CI Linter] 検査中: ${filePath}`);

// 簡易的な構文チェックルール(実業務ではASTパースやJSON Schema検証に置き換える)
if (!content.includes(‘target_env:’)) {
console.error(` -> エラー: 必須プロパティ ‘target_env’ が定義されていません。`);
hasError = true;
}

if (content.includes(‘replicas: 0’)) {
console.error(` -> エラー: ‘replicas’ に 0 を指定することは許可されていません。`);
hasError = true;
}

return hasError;
}

// ターゲットディレクトリを走査して検証を実行
function runCiLint(targetDir: string) {
const files = fs.readdirSync(targetDir);
let errorCount = 0;

for (const file of files) {
if (file.endsWith(‘.deployment’)) {
const fullPath = path.join(targetDir, file);
if (validateDeploymentFile(fullPath)) {
errorCount++;
}
}
}

if (errorCount > 0) {
console.error(`\n[CI Linter] 失敗: ${errorCount} 件のエラーが検出されました。`);
process.exit(1); // 異常終了させてCIパイプラインをブロック
} else {
console.log(`\n[CI Linter] 成功: すべてのファイルが検証を通過しました。`);
process.exit(0);
}
}

// 実行時の引数からディレクトリを取得
const targetDirectory = process.argv[2] || ‘.’;
runCiLint(targetDirectory);

GitHub Actionsワークフローへの統合 (`.github/workflows/lsp-lint.yml`)

このCIジョブにより、開発者がローカルのVS Codeで恩恵を受けているLSPの品質保証基準が、リモートリポジトリ側でも完全に担保される。

name: LSP-Based Configuration Lint

on:
pull_request:
branches: [ main ]

jobs:
lint-configs:
runs-on: ubuntu-latest
steps:
# リポジトリのチェックアウト

  • name: Checkout Repository

uses: actions/checkout@v4

# Node.js環境のセットアップ

  • name: Set up Node.js

uses: actions/setup-node@v4
with:
node-version: ’20’
cache: ‘npm’

# 依存関係のインストール

  • name: Install Dependencies

run: npm ci

# LSPベースのカスタムリンターをヘッドレス実行

  • name: Run Custom LSP Lint

run: npx ts-node scripts/ci-lint.ts ./configs

—

5. Dockerコンテナ環境(Dev Containers)での完全自動構成

開発チーム全員のローカルマシン環境を完全に統一するため、Dev Containers(Docker)の中にVS Code拡張機能とLSPサーバーを事前ビルド・同梱させるアプローチをとる。これにより、「私のローカル環境では動くが、コンテナ上では動かない」というインフラ起因の不具合を根絶する。

`.devcontainer/devcontainer.json` の設計

{
“name”: “Custom LSP Development Environment”,
“image”: “mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye”,

// コンテナ起動時に自動インストールするVS Code拡張機能
“customizations”: {
“vscode”: {
“extensions”: [
“dbaeumer.vscode-eslint”,
// 自作した拡張機能をコンテナ内でビルドして自動読み込みさせる設定
“my-org.custom-deployment-tools”
]
}
},

// コンテナ起動完了後に実行する初期化コマンド
“postCreateCommand”: “npm install && npm run build:lsp”,

// 開発中のファイル監視のパフォーマンスを維持するための設定
“remoteUser”: “node”
}

パフォーマンス・メモリ消費の最適化ハック

LSPサーバーをNode.jsで実装する際、巨大なモノリスリポジトリ(数万ファイルのDSL)を扱うと、デフォルトのV8ヒープメモリ制限(約1.4GB〜2GB)に抵触し、ガベージコレクション(GC)が頻発してエディタがカクつく原因になる。

これを防ぐため、拡張機能の起動オプション(`serverOptions`)にV8のメモリ制限拡張とGC最適化フラグを明示的に注入せよ。

const serverOptions: ServerOptions = {
run: {
module: serverModule,
transport: TransportKind.ipc,
// V8エンジンのメモリ上限を4GBに引き上げ、世代別GCを最適化
options: { execArgv: [‘–max-old-space-size=4096’, ‘–expose-gc’] }
},
// 同様の設定…
};

さらに、ASTのパース結果をメモリ上に保持する際は、`Map`ではなくWeakMapを活用して不要になったドキュメントの参照を速やかに解放し、メモリリークを物理的にシャットアウトする設計思想が求められる。

—

6. アーキテクトの結論:開発体験(DX)はインフラストラクチャのコード化によってのみ極まる

単に「便利なVS Codeの拡張機能を入れる」というフェーズは、今日のシニアエンジニアにとって通過点に過ぎない。

  • LSPによる言語機能の抽象化
  • JSON-RPCによる非同期・スレッドセーフな通信
  • ローカルエディタとCI/CDパイプライン間でのバリデーションロジックの完全共有
  • Dev Containersによる環境の完全な不変性(Immutability)の担保

これらすべてのレイヤを垂直統合(Vertical Integration)させたとき、はじめて開発チームの生産性は爆発的な跳ね上がりを見せる。ツールに振り回されるな。ツールを自ら設計し、開発環境そのものをインフラとしてコード化せよ。その先にある「一切のストレスがない極限のコーディング体験」こそが、アーキテクトが組織にもたらすべき最大の果実である。

タイトルとURLをコピーしました