- Published on
自分で作る VS Code 拡張と Language Server — 2026 年 LSP 実戦ガイド
- Authors

- Name
- Youngju Kim
- @fjvbn20031
プロローグ — エディタに「読ませる」ことを教えた人たち
「エディタが自分のコードを理解している」と初めて感じたのは 2016 年の VS Code だった。それまで自動補完は識別子辞書と正規表現の見栄えのよい合作で、「定義へ移動」は ctags が守る最後の砦だった。ところが VS Code で TypeScript ファイルを開くと、関数の上に型が浮かび、F12 で定義へ飛び、誤った文字には赤い波線が引かれた。
その裏には二つの仕組みが並んで存在していた。Extension API がエディタとコードをつなぐ表面で、その向こうに Language Server Protocol (LSP) が「コードを理解する仕事」を分離・標準化していた。
2026 年 5 月時点で LSP の仕様は 3.18、VS Code は stable チャンネルで毎月新しいマイナーバージョンを出荷する。vscode-languageserver-node 9.x が正典の TypeScript 実装、Rust 側では tower-lsp が事実上のデフォルト。同じ LSP サーバが Neovim・Helix・Emacs・Sublime・Zed・JetBrains で動く。「エディタに言語サポートを足す」という言い回しは今や違和感がある — 我々は「言語サーバを書く」と言い、エディタの側が勝手に繋がってくる。
この記事はその一連の流れを一度通してやってみるガイドだ。小さな TODO ハイライター拡張から始め、架空の設定言語のための本物の LSP を作る。両者をつなぎ、マーケットプレイスへ公開するところまで進める。Cursor と Continue で同じコードが動く理由も、Tree-sitter が LSP と一緒に歩いている理由も、ついでに見る。
第 1 章 · VS Code Extension API の地形
まずいちばん薄い表面、拡張そのものから。LSP はその上に重ねる一層だ。
1.1 拡張とは何か
VS Code 拡張は一言で言えば「エディタのホストプロセスで動く Node.js モジュール」である。すべての拡張は別の Extension Host プロセスに隔離されて動作する。メイン UI スレッド (レンダラ) とは IPC で隔てられていて、拡張が無限ループに入ってもエディタは生き残る。
拡張ができることをざっくり並べる。
- コマンド (Commands) の登録 — コマンドパレットとキーバインディングに露出。
- 言語機能プロバイダ — Completion、Hover、Definition、References、Diagnostics などを直接実装するか、LSP に委譲。
- デコレーションとツリービュー — エディタ本文に色を塗り、サイドバーにツリーを描く。
- ステータスバー・通知・WebView — UI 表面。
- ワークスペース/ファイルシステムプロバイダ — 仮想ファイルシステム、リモートファイルシステム。
- デバッグアダプタ (DAP) — デバッガ統合。
- タスクとターミナル。
この記事では上の二つ — コマンドと言語機能 — に絞る。
1.2 package.json マニフェスト
拡張のアイデンティティはすべて package.json が決める。次は TODO ハイライターの最小マニフェストだ。
{
"name": "todo-highlighter",
"displayName": "TODO Highlighter",
"version": "0.1.0",
"publisher": "yj",
"engines": { "vscode": "^1.95.0" },
"main": "./out/extension.js",
"activationEvents": ["onStartupFinished"],
"categories": ["Other"],
"contributes": {
"commands": [
{
"command": "todoHighlighter.scanWorkspace",
"title": "TODO: Scan Workspace"
}
],
"configuration": {
"title": "TODO Highlighter",
"properties": {
"todoHighlighter.keywords": {
"type": "array",
"default": ["TODO", "FIXME", "HACK"],
"description": "Keywords to highlight"
}
}
}
}
}
注目すべきフィールドは三つ。
engines.vscode— 互換性のある最小 VS Code バージョン。低すぎると新しい API が使えない。main— エントリファイル。CommonJS である必要がある (2026 年時点で ESM エントリはまだ実験的)。activationEvents— いつ拡張を起こすか。設定を誤ると全ユーザの VS Code 起動が遅くなる。
activationEvents がいちばんよく踏む落とし穴だ。onStartupFinished は「エディタの起動が終わってから一度」で、最も無難なデフォルト。古い * は非推奨 — すべての起動で拡張を起こすからだ。さらに良いのは 発火条件を絞る こと。
"activationEvents": [
"onLanguage:tomlplus",
"onCommand:tomlplus.format",
"workspaceContains:**/.tomlplus"
]
これは「tomlplus 言語のファイルが開かれたとき」「特定のコマンドが呼ばれたとき」「ワークスペースにそのファイルが存在するとき」だけ拡張を起こす。VS Code 1.74 から contributes.commands で宣言したコマンドは自動的に起動トリガになるため、onCommand:* は通常省略可能。
1.3 Hello World 拡張
extension.ts の定型はこうだ。
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
console.log('TODO Highlighter activated');
const disposable = vscode.commands.registerCommand(
'todoHighlighter.scanWorkspace',
async () => {
const files = await vscode.workspace.findFiles('**/*.{ts,js,md}', '**/node_modules/**');
vscode.window.showInformationMessage(`Scanned ${files.length} files`);
}
);
context.subscriptions.push(disposable);
}
export function deactivate() {}
context.subscriptions に push したすべての Disposable は拡張の deactivate 時に自動で片付く。メモリリーク回避のいちばん重要なルールだ。
1.4 デコレーションで TODO を彩る
本物の TODO ハイライターを作るならデコレーション API を使う。
const todoDecoration = vscode.window.createTextEditorDecorationType({
backgroundColor: 'rgba(255, 200, 0, 0.2)',
border: '1px dashed orange',
overviewRulerColor: 'orange',
overviewRulerLane: vscode.OverviewRulerLane.Right,
});
function updateDecorations(editor: vscode.TextEditor) {
const text = editor.document.getText();
const regex = /\b(TODO|FIXME|HACK)\b/g;
const ranges: vscode.Range[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const start = editor.document.positionAt(match.index);
const end = editor.document.positionAt(match.index + match[0].length);
ranges.push(new vscode.Range(start, end));
}
editor.setDecorations(todoDecoration, ranges);
}
vscode.window.onDidChangeActiveTextEditor(
(editor) => editor && updateDecorations(editor),
null,
context.subscriptions
);
vscode.workspace.onDidChangeTextDocument(
(event) => {
const editor = vscode.window.activeTextEditor;
if (editor && event.document === editor.document) updateDecorations(editor);
},
null,
context.subscriptions
);
ここまでに LSP は不要だ。正規表現一行とデコレーション API で足りる作業に、わざわざサーバを立てる理由はない。しかし 自動補完、定義へジャンプ、シンボルインデックス、インクリメンタルなパース — そうした要求が出てきた瞬間、サーバが本領を発揮する。
1.5 ツリービュー、ステータスバー、WebView、ウォークスルー
拡張が扱える UI 表面の一覧:
- ツリービュー (TreeDataProvider) — サイドバーのツリー。ビルド成果物、テスト一覧、TODO 集約などに合う。
- ステータスバーアイテム (StatusBarItem) — 左右のエッジに小さなテキスト。進捗や即時アクション。
- WebView (Webview) — 任意の HTML/JS を埋め込める iframe 風表面。フルスクリーンのパネル向け。
- ウォークスルー (Walkthroughs) — 拡張機能の使い方を段階的に案内するオンボーディング UI。
package.jsonに宣言するだけ。 - Notebook コントローラ — Jupyter 風のセル UI。
- インレイヒント (Inlay Hints) — 型や仮引数名をトークン間にインラインで表示。
どの API も同じパターン — 登録 (register)、Disposable を返す、モデル変化で更新。
第 2 章 · なぜ LSP か — 一つのサーバ、たくさんのエディタ
ここまでは VS Code の内側の話だった。LSP はもう一歩外に出る。
2.1 M × N 問題
LSP 以前、エディタごとに同じ言語機能を別々に実装していた。M 個のエディタが N 個の言語を支援するには M × N の統合が必要で、Python の補完を Emacs に入れた人が Vim 用にまた書き直し、その両方が VS Code の挙動と違う、という状況だった。
Microsoft は 2016 年、VS Code の出荷直前に LSP 仕様を公開した。アイデアは単純で、言語機能をサーバに分離する。エディタはクライアントになり、JSON-RPC でメッセージをやり取りする。問題が M × N から M + N に縮む。
2.2 仕様の現在地
2026 年 5 月時点:
- LSP 3.18 — 最新安定版。semantic tokens v2、inlay hints、type hierarchy などを含む。
- DAP (Debug Adapter Protocol) — デバッガ用の姉妹プロトコル。
- Open VSX — VSCodium・Cursor・Gitpod など非 MS ビルドが使うマーケットプレイス代替。
LSP は突き詰めればメッセージ契約だ。サーバ実装は自由に選べる。Rust なら tower-lsp (またはより新しい tower-lsp-server)、TypeScript なら vscode-languageserver、Go なら go.lsp.dev/protocol、Python なら pygls、Haskell や OCaml にも実装がある。
2.3 LSP の使い手たち
実戦で活躍している主要サーバを一行ずつ:
- rust-analyzer — 公式 Rust。tower-lsp は使わず独自実装。
- gopls — 公式 Go。Go チーム直管理。
- typescript-language-server — Microsoft の tsserver を LSP でラップ。
- pyright / pylance — Python の型チェッカ兼 LSP。
- ruff-lsp — Rust で書かれた超高速 Python リンタ + LSP。
- clangd — 公式 C/C++。
- Astro Language Server —
.astroファイル対応。 - svelte-language-server, vue-language-server — 各フレームワーク向け。
- lsp-bridge, copilot-language-server — Copilot さえ結局は LSP メッセージを模す。
同じサーバが Neovim で nvim-lspconfig、Emacs で lsp-mode、Helix でネイティブ、JetBrains で LSP4IJ プラグイン経由で動く。
第 3 章 · LSP プロトコルの解剖
ここでメッセージの形を見る。
3.1 トランスポート — JSON-RPC over stdio (通常)
LSP の標準トランスポートは stdin/stdout 上の JSON-RPC 2.0。HTTP ではなく、単純なバイトストリーム。各メッセージはヘッダと本文に分かれる。
Content-Length: 152\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":12345,...}}
代替トランスポート: ソケット、IPC、WebSocket。VS Code クライアントは全部対応するが、stdio が一番書きやすい。
3.2 ライフサイクル
client ──▶ initialize ──▶ server
client ◀── initialize result ─ server
client ──▶ initialized ──▶ (notification)
... (通常稼働) ...
client ──▶ shutdown ──▶ server
client ──▶ exit ──▶ server
initialize 要求でクライアントは自分が対応する機能を、サーバは自分が提供する機能を宣言する。この合意が ServerCapabilities だ。例: サーバが hoverProvider: true を宣言しなければ、クライアントは hover を一度も要求しない。
3.3 メッセージの三種
- Request — id があり、必ず response が返る。例:
textDocument/hover。 - Response — request への返答。
idで紐付け。 - Notification — id なし、返答なし。例:
textDocument/didChange、textDocument/publishDiagnostics。
向きは双方向。サーバはクライアントに window/showMessage を送り、クライアントはサーバに workspace/configuration を問い合わせる。
3.4 テキストドキュメント同期
サーバはファイル内容を知る必要がある。同期方式は三つのうち一つ。
- None (0) — 同期しない。
- Full (1) — 変更ごとに全体を送る。小さいファイルなら単純。
- Incremental (2) — 変更範囲だけ。大きいファイルで効率的。
textDocument/didOpen、didChange、didClose、didSave が核となる通知だ。
3.5 機能別メソッド
サーバが実装する主要要求:
| 機能 | メソッド |
|---|---|
| 診断 (赤波線) | サーバ → クライアント textDocument/publishDiagnostics |
| 自動補完 | textDocument/completion |
| シグネチャヘルプ | textDocument/signatureHelp |
| ホバー | textDocument/hover |
| 定義へ移動 | textDocument/definition |
| 参照検索 | textDocument/references |
| シンボル検索 | textDocument/documentSymbol, workspace/symbol |
| リネーム | textDocument/rename, prepareRename |
| コードアクション | textDocument/codeAction |
| フォーマット | textDocument/formatting, rangeFormatting |
| セマンティックトークン | textDocument/semanticTokens/full |
| インレイヒント | textDocument/inlayHint |
| コール階層 | textDocument/prepareCallHierarchy |
第 4 章 · ミニ言語 — tomlplus を定義する
コードを書く前に架空の言語を決める。名前は tomlplus。TOML に似ているが二つの追加機能がある。
- 参照 —
link = $other_section.key形式で別セクションの値を指す。 - 型注釈 —
port: int = 8080のようにキーの隣に型を書ける。
文法は短い。
[server]
host: string = "localhost"
port: int = 8080
[client]
target = $server.host
timeout: int = 30
LSP がやるべきこと:
- パースして、誤りに診断を出す。
- キーや値にホバーで型を出す。
$refから F12 で元のキーへジャンプ。- 自動補完でセクション・キー・
$ref候補を提示。 - リネームでキーと全参照を同時に変える。
これを最初から最後まで作る。
第 5 章 · TypeScript で LSP サーバを書く
定番の出発点は vscode-languageserver-node。VS Code チームが直接管理し、クライアントライブラリも同じモノレポにいる。
5.1 プロジェクト構成
tomlplus-lsp/
client/
src/extension.ts # VS Code 拡張 (クライアント)
package.json
server/
src/server.ts # LSP サーバ (Node として実行)
package.json
package.json # ワークスペースルート
サーバは別 Node プロセスで動き、クライアントが子プロセスとして起動する。
5.2 サーバ初期化
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
InitializeResult,
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams): InitializeResult => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { triggerCharacters: ['.', '$'] },
hoverProvider: true,
definitionProvider: true,
referencesProvider: true,
renameProvider: { prepareProvider: true },
codeActionProvider: true,
documentFormattingProvider: true,
semanticTokensProvider: {
legend: {
tokenTypes: ['keyword', 'string', 'number', 'variable', 'property'],
tokenModifiers: ['declaration', 'readonly'],
},
range: false,
full: true,
},
},
};
});
documents.listen(connection);
connection.listen();
TextDocuments マネージャが didOpen・didChange 通知を自動処理してくれて、常に最新のテキストを手元に置ける。我々はその上に機能を載せるだけ。
5.3 パーサ
本物の LSP はインクリメンタルパーサを使うが、まずは行ベースの素朴なパーサで十分。
interface TomlPlusKey {
section: string;
key: string;
type: string | null;
value: string;
line: number;
range: { start: number; end: number };
}
function parse(text: string): { keys: TomlPlusKey[]; errors: ParseError[] } {
const lines = text.split('\n');
const keys: TomlPlusKey[] = [];
const errors: ParseError[] = [];
let section = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].replace(/#.*$/, '').trim();
if (!line) continue;
const sectionMatch = /^\[([a-zA-Z_][\w]*)\]$/.exec(line);
if (sectionMatch) { section = sectionMatch[1]; continue; }
const kvMatch = /^([a-zA-Z_][\w]*)\s*(?::\s*(int|string|bool))?\s*=\s*(.+)$/.exec(line);
if (!kvMatch) {
errors.push({ line: i, message: `Invalid syntax: "${line}"` });
continue;
}
keys.push({
section,
key: kvMatch[1],
type: kvMatch[2] ?? null,
value: kvMatch[3],
line: i,
range: { start: 0, end: lines[i].length },
});
}
return { keys, errors };
}
実戦の LSP では Tree-sitter や chevrotain のようなライブラリで本物のインクリメンタルパーサを使う。次章で扱う。
5.4 診断 — publishDiagnostics
文書が変わるたびにパースして、誤りを赤波線として送る。
import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node';
documents.onDidChangeContent((change) => {
validateDocument(change.document);
});
function validateDocument(doc: TextDocument) {
const text = doc.getText();
const { keys, errors } = parse(text);
const diagnostics: Diagnostic[] = [];
for (const err of errors) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: {
start: { line: err.line, character: 0 },
end: { line: err.line, character: 100 },
},
message: err.message,
source: 'tomlplus',
});
}
// 型ミスマッチ — 簡単な例
for (const k of keys) {
if (k.type === 'int' && !/^\d+$/.test(k.value.trim())) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: {
start: { line: k.line, character: 0 },
end: { line: k.line, character: k.range.end },
},
message: `Expected int, got "${k.value}"`,
source: 'tomlplus',
});
}
}
connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}
押さえるべき二点:
- 常にその URI の診断集合全体を送る。 部分更新はない。空配列なら「問題なし」の意。
sourceフィールドはどのツールがその診断を出したかを伝える — Problems パネルで "tomlplus(eslint)" のようなラベルとして表示される。
5.5 自動補完
import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
connection.onCompletion(({ textDocument, position }): CompletionItem[] => {
const doc = documents.get(textDocument.uri);
if (!doc) return [];
const text = doc.getText();
const { keys } = parse(text);
const linePrefix = doc
.getText({ start: { line: position.line, character: 0 }, end: position })
.trim();
// $ で始まったら別セクションのキーを候補に。
if (linePrefix.endsWith('$')) {
return keys.map((k) => ({
label: `${k.section}.${k.key}`,
kind: CompletionItemKind.Variable,
detail: `${k.type ?? 'any'} = ${k.value}`,
}));
}
// 行頭ならキーの雛形を提示。
return [
{ label: 'host: string = ', kind: CompletionItemKind.Snippet },
{ label: 'port: int = ', kind: CompletionItemKind.Snippet },
{ label: 'enabled: bool = ', kind: CompletionItemKind.Snippet },
];
});
connection.onCompletionResolve((item) => {
// 重い情報を遅延ロードする枠。今は素通し。
return item;
});
onCompletionResolve は重要な最適化ポイントだ。候補が 100 個あるとき、全項目の documentation を事前計算するのは無駄。代わりに、ユーザが一項目をホバーした瞬間に resolve が走り、そこで埋める。
5.6 ホバー
import { Hover } from 'vscode-languageserver/node';
connection.onHover(({ textDocument, position }): Hover | null => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const { keys } = parse(doc.getText());
const lineText = doc.getText({
start: { line: position.line, character: 0 },
end: { line: position.line, character: 1000 },
});
const word = wordAt(lineText, position.character);
if (!word) return null;
const found = keys.find((k) => k.key === word);
if (!found) return null;
return {
contents: {
kind: 'markdown',
value: `**${found.section}.${found.key}**\n\nType: \`${found.type ?? 'any'}\`\nValue: \`${found.value}\``,
},
};
});
5.7 定義へ移動と参照検索
$server.host で F12 を押したら元のキーに飛ぶようにする。
connection.onDefinition(({ textDocument, position }) => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const { keys } = parse(doc.getText());
const lineText = doc.getText({
start: { line: position.line, character: 0 },
end: { line: position.line, character: 1000 },
});
const ref = parseRef(lineText, position.character); // 例: {section:'server', key:'host'}
if (!ref) return null;
const target = keys.find((k) => k.section === ref.section && k.key === ref.key);
if (!target) return null;
return {
uri: doc.uri,
range: {
start: { line: target.line, character: 0 },
end: { line: target.line, character: target.range.end },
},
};
});
connection.onReferences(({ textDocument, position }) => {
// 参照しているすべての行を見つけて Location[] を返す。onDefinition の逆。
return findAllReferences(textDocument.uri, position);
});
5.8 リネーム
機能の中でも難物のひとつ。prepareProvider: true を有効にすると 2 段階で動く。
prepareRename— 「ここでリネーム可能か?」を尋ね、可能ならリネーム範囲を返す。rename— 新しい名前を受け取り、WorkspaceEditを組み立てて返す。
import { WorkspaceEdit, TextEdit } from 'vscode-languageserver/node';
connection.onPrepareRename(({ textDocument, position }) => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const range = findIdentifierRange(doc, position);
return range ?? null;
});
connection.onRenameRequest(({ textDocument, position, newName }): WorkspaceEdit => {
const doc = documents.get(textDocument.uri)!;
const locations = findAllOccurrences(doc, position);
return {
changes: {
[doc.uri]: locations.map((loc) => TextEdit.replace(loc.range, newName)),
},
};
});
複数ファイルに跨るリネームは changes に URI ごとの配列を入れる。クライアントが全編集を一括適用する。
5.9 コードアクション
診断の横に出る電球がコードアクション。「この警告を無視」「型注釈を追加」「このキーを抽出」のような操作。
import { CodeAction, CodeActionKind } from 'vscode-languageserver/node';
connection.onCodeAction((params): CodeAction[] => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return [];
const actions: CodeAction[] = [];
for (const diag of params.context.diagnostics) {
if (diag.message.startsWith('Expected int')) {
actions.push({
title: 'Change type to "string"',
kind: CodeActionKind.QuickFix,
diagnostics: [diag],
edit: {
changes: {
[doc.uri]: [TextEdit.replace(diag.range, fixTypeToString(doc, diag.range))],
},
},
});
}
}
return actions;
});
5.10 セマンティックトークン
シンタックスハイライトを正規表現ベースの TextMate 文法でやると限界がある。セマンティックトークン はサーバが「この単語は変数、あの単語はキーワード」とクライアントへ直接伝える仕組みだ。
サーバはトークンを圧縮整数配列として送る — [deltaLine, deltaStart, length, tokenType, tokenModifiers] を 5 個ずつ。インデックスは legend を指す。
import { SemanticTokensBuilder } from 'vscode-languageserver/node';
connection.languages.semanticTokens.on(({ textDocument }) => {
const doc = documents.get(textDocument.uri)!;
const builder = new SemanticTokensBuilder();
const { keys } = parse(doc.getText());
for (const k of keys) {
// [line, startChar, length, tokenType, tokenModifiers]
builder.push(k.line, 0, k.key.length, /*property*/ 4, /*declaration*/ 1);
}
return builder.build();
});
セマンティックトークンは TextMate ベースハイライトの上に重ねられる — 未定義のトークンはデフォルトのまま。この振る舞いがエレガントだ。
5.11 フォーマット
connection.onDocumentFormatting(({ textDocument, options }) => {
const doc = documents.get(textDocument.uri)!;
const formatted = formatTomlPlus(doc.getText(), options);
return [TextEdit.replace(
{
start: { line: 0, character: 0 },
end: doc.positionAt(doc.getText().length),
},
formatted
)];
});
大型フォーマッタは普通、外部ツール (black、prettier、gofmt) を spawn して結果を受け取る薄いラッパだ。
第 6 章 · クライアント — 拡張からサーバを起動する
VS Code 拡張側に戻る。サーバを子プロセスとして起動し、クライアントを接続する。
import * as path from 'path';
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: vscode.ExtensionContext) {
const serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: { execArgv: ['--nolazy', '--inspect=6009'] },
},
};
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'tomlplus' }],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.tomlplus'),
},
};
client = new LanguageClient('tomlplus', 'TOML+ Language Server', serverOptions, clientOptions);
client.start();
}
export function deactivate(): Thenable\<void\> | undefined {
return client?.stop();
}
TransportKind.ipc は Node 専用の IPC チャネルで、stdio より速い。stdio も使えるが IPC が標準推奨。
package.json に言語定義を追加する。
"contributes": {
"languages": [{
"id": "tomlplus",
"extensions": [".tomlplus", ".tpl"],
"configuration": "./language-configuration.json"
}],
"grammars": [{
"language": "tomlplus",
"scopeName": "source.tomlplus",
"path": "./syntaxes/tomlplus.tmLanguage.json"
}]
}
tmLanguage.json が正規表現ベースの基本ハイライトを提供し、その上に LSP のセマンティックトークンが重なる。
第 7 章 · Rust で LSP を書く — tower-lsp
同じ LSP を Rust でも作ってみる。Rust を選ぶ理由は:
- Tree-sitter との自然な相性。
- Tokio ベースの非同期処理。
- rust-analyzer が証明したパターン。
tower-lsp の核は trait LanguageServer で、async trait メソッドが LSP 要求を受ける。
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
#[derive(Debug)]
struct Backend {
client: Client,
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> Result\<InitializeResult\> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".into(), "$".into()]),
..Default::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
..Default::default()
},
server_info: Some(ServerInfo {
name: "tomlplus-lsp".into(),
version: Some("0.1.0".into()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.client.log_message(MessageType::INFO, "server ready").await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri.clone();
let diags = validate(¶ms.content_changes);
self.client.publish_diagnostics(uri, diags, None).await;
}
async fn completion(&self, _: CompletionParams) -> Result\<Option\<CompletionResponse\>\> {
Ok(Some(CompletionResponse::Array(vec![
CompletionItem::new_simple("host".into(), "hostname".into()),
CompletionItem::new_simple("port".into(), "TCP port".into()),
])))
}
async fn shutdown(&self) -> Result\<()\> { Ok(()) }
}
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend { client });
Server::new(stdin, stdout, socket).serve(service).await;
}
Cargo.toml:
[dependencies]
tower-lsp = "0.20"
tokio = { version = "1", features = ["full"] }
VS Code 拡張から Rust サーバを起動するなら ServerOptions の command 形式を使う。
const serverOptions: ServerOptions = {
command: context.asAbsolutePath('bin/tomlplus-lsp'),
args: [],
transport: TransportKind.stdio,
};
プラットフォーム別バイナリを同梱する (推奨)、npm postinstall でダウンロードする、Cargo でユーザ環境でビルドさせる、のいずれか。
第 8 章 · Tree-sitter — 自分でパーサを作る自由
ここまでの行ベースパーサはおもちゃだ。本物の LSP は インクリメンタルパーサ を要求する — ユーザが 1 文字打つたびにファイル全体を再パースしていたら、大きなファイルで詰まる。
Tree-sitter は GitHub が Atom 時代に作ったインクリメンタルパーサジェネレータだ。主な特徴:
- インクリメンタル — 変更領域だけ再パース。
- エラー回復 — 壊れたコードでも木を作る (LSP には決定的に重要)。
- 多言語 — 200 以上の文法が既に存在。
- C ランタイム — TS、Rust、Python、Go から呼べる。
8.1 文法定義
grammar.js で文法を書く。
module.exports = grammar({
name: 'tomlplus',
rules: {
source_file: ($) => repeat(choice($.section, $.assignment)),
section: ($) => seq('[', $.identifier, ']'),
assignment: ($) =>
seq($.identifier, optional($.type_annotation), '=', $.value),
type_annotation: ($) => seq(':', choice('int', 'string', 'bool')),
value: ($) => choice($.string, $.number, $.reference),
reference: ($) => seq('$', $.identifier, '.', $.identifier),
identifier: ($) => /[a-zA-Z_][\w]*/,
string: ($) => /"[^"]*"/,
number: ($) => /\d+/,
},
});
tree-sitter generate が C のパーサを吐く。それを LSP からロードする。
8.2 TypeScript で Tree-sitter を使う
import Parser from 'tree-sitter';
import TomlPlus from 'tree-sitter-tomlplus';
const parser = new Parser();
parser.setLanguage(TomlPlus);
function parseDoc(text: string) {
return parser.parse(text);
}
function reparse(oldTree: Parser.Tree, edit: Parser.Edit, newText: string) {
oldTree.edit(edit);
return parser.parse(newText, oldTree); // インクリメンタル
}
oldTree.edit() で変更箇所を伝えると、次の parse は影響を受けたサブツリーだけを作り直す。1000 行ファイルで 1 文字打ってもほぼ 0ms。
8.3 クエリでノードを探す
Tree-sitter クエリは S 式風のパターン言語だ。
(section (identifier) @section.name)
(assignment
(identifier) @key.name
(type_annotation (_) @key.type)?
(value) @key.value)
(reference
(identifier) @ref.section
(identifier) @ref.key)
@name キャプチャがマッチしたノードを掴む。LSP では定義・参照・シンボル抽出が全部このクエリで終わる。
8.4 Tree-sitter と LSP の分業
Tree-sitter は 構文 を、LSP は 意味とユーザ対話 を担う。典型的な絵はこうだ。
[ユーザのキー入力]
↓
[VS Code クライアント] ── didChange ──▶ [LSP サーバ]
↓
[Tree-sitter インクリメンタル parse]
↓
[シンボルインデックス更新]
↓
publishDiagnostics、その他
Helix と Zed は Tree-sitter をエディタ自体に組み込む — クライアントが直接ツリーを保持し、LSP は意味情報だけを扱う。VS Code にも非公式の Tree-sitter 統合はあるが、通常は LSP サーバの内部で使う。
第 9 章 · Marketplace と OpenVSX への公開
コードが動いたら、いよいよ公開だ。
9.1 VSIX パッケージ
VS Code 拡張は .vsix という zip ファイル。vsce で作る。
npm install -g @vscode/vsce
cd tomlplus
vsce package
# → tomlplus-0.1.0.vsix
ローカル動作確認: code --install-extension tomlplus-0.1.0.vsix。
9.2 マーケットプレイス公開
先に Azure DevOps で Publisher アカウントを作り、Personal Access Token を取得する。
vsce login yj
vsce publish
# または: vsce publish minor / patch / major
公開ポリシーの注意点:
LICENSEがないと拒否される可能性がある。README.mdの画像は絶対 URL でなければならない。- 審査は 24 時間ほど — 静的チェックとセキュリティスキャン。
- 他拡張に依存する場合は
extensionDependenciesで明示する。
9.3 OpenVSX — 非 MS ビルド向けのマーケット
VSCodium、Cursor、Continue、Theia、Gitpod、code-server は MS マーケットプレイスにアクセスできない (またはライセンス上アクセスすべきでない)。彼らの代替が Open VSX Registry (Eclipse Foundation 運営、open-vsx.org) だ。
npm install -g ovsx
ovsx create-namespace yj -p <ovsx-token>
ovsx publish tomlplus-0.1.0.vsix -p <ovsx-token>
同じ VSIX を両方のレジストリにアップするのが標準的な作法。自動化は GitHub Actions で:
- name: Publish to VS Marketplace
run: vsce publish -p $\{\{ secrets.VSCE_PAT \}\}
- name: Publish to OpenVSX
run: ovsx publish *.vsix -p $\{\{ secrets.OVSX_TOKEN \}\}
9.4 Cursor・Continue との互換性
Cursor は VS Code のフォーク。デフォルトマーケットプレイスは Open VSX を採用し、MS 独占拡張 (C/C++、Pylance、Live Share など) はライセンス上直接インストールできない。我々が作るような汎用拡張はそのまま動く — クライアントは同じ VS Code API 表面を公開している。
Continue や Cody のような AI 拡張も同じ LSP クライアントコードを再利用していることが多い。サーバが標準 LSP を素直に話せば、互換性は自然に得られる。
9.5 テレメトリ
VS Code は @vscode/extension-telemetry パッケージを提供する。注意点:
- ユーザが
telemetry.telemetryLevelをoffにすると自動で抑止される。 - PII (個人情報) は絶対送らない。匿名化されたイベントのみ。
- 初回起動時にユーザへ知らせる (マーケットプレイスポリシー)。
import TelemetryReporter from '@vscode/extension-telemetry';
const reporter = new TelemetryReporter('<connection-string>');
context.subscriptions.push(reporter);
reporter.sendTelemetryEvent('command.scanWorkspace');
第 10 章 · 本番 LSP のパターン
最終章は実在の巨人たちがどう動いているかのスケッチ。
10.1 rust-analyzer — 原理に忠実な LSP 設計の教科書
- Salsa (デマンド駆動のインクリメンタル計算を行うクエリシステム) であらゆる解析を裏付ける。
- VFS 抽象がファイルシステムと LSP 通知を単一モデルに統合。
- サブクレート 100 以上の巨大ワークスペースでもホットパスが速いままだ。
10.2 gopls — Go チームの正攻法
- 独自のキャッシュ層が、変更で影響を受けた依存グラフだけを再計算する。
- "view" 抽象で同じコードの複数ビルドコンテキスト (GOOS、GOARCH など) を並行管理。
10.3 typescript-language-server — アダプタパターン
- 実際の解析は
tsserver(TypeScript コンパイラ) が行う。 - LSP と tsserver 独自プロトコルを翻訳する薄い層を挟む。
- これによって LSP を知らない TS コンパイラを、すべてのエディタに露出させた。
10.4 ruff-lsp — Rust で書いた超高速 Python ツール
- Ruff そのものは Python 用リンタだが Rust 実装で、ESLint より約 100 倍速い。
- LSP はその上の薄いアダプタ。
- pyright や pylance の型チェックと共存できる — 双方有効でも衝突しない。
10.5 Astro Language Server — 多言語ファイル
.astroファイル内には frontmatter (TS)、マークアップ (HTML)、インラインスクリプト (JS) が混在する。- 「埋め込み言語」パターン — Astro LS が領域ごとに仮想ドキュメントを作り、TS LS や HTML LS に委譲する。
- 同じパターンが Vue Volar や Svelte LS でも使われている。
10.6 全体に共通するパターン
- ワークスペースインデックスを常にメモリに — ディスクの再読み込みを避ける。
- デマンド駆動でインクリメンタル — ホバーされたときに初めて計算し、キャッシュする。
- エラー回復が UX を決める — 壊れたファイルで補完が死んではいけない。
- キャンセル可能性 — ユーザが続けて入力したら、前の要求を即座にキャンセル。
エピローグ — 2 時間で始めて、2 か月で磨く
LSP は最初の 30 分が一番難しい。JSON-RPC メッセージの向きが分かりにくく、capabilities ネゴシエーションは抽象的で、didChange の三つの同期モードはどれも似て見える。その 30 分を越えれば — 機能ひとつひとつは結局、同じ形のバリエーションだと分かる。テキスト位置を受け取り、自分のモデルから意味を引き出し、応答を返す。それで全部だ。
開始チェックリスト
vscode-languageserver-nodeのサンプル LSP (server-and-client-sample) をまず動かして、メッセージの流れを目で見る。--inspect=6009でサーバにデバッガを繋ぐ方法を最初に覚える。package.jsonのactivationEventsは可能な限り狭く。全ユーザの起動時間を背負っていることを忘れない。- 基本は
TextDocumentSyncKind.Incremental。Full は小さいファイル専用。 - 診断は
sourceフィールドを必ず埋める — 警告の出所を分かるように。 - 重い補完情報は
onCompletionResolveで遅延ロード。 - 数百行を超える前に Tree-sitter またはインクリメンタルパーサに切り替える。
- 公開は OpenVSX とマーケットプレイスの両方へ。
アンチパターン
activationEvents: ["*"]— 全ユーザの起動が遅くなる。クレームの最大の源。- キー入力ごとに全パース — 大きなファイルで詰まる。Tree-sitter か debounce が必須。
- 診断を即時に発火 — 300ms 程度 debounce する。1 文字ごとに赤波線がちらつく体験は最悪。
- 手作りのテキスト同期 —
TextDocumentsマネージャをバイパスしてインクリメンタル同期のオフセット計算を自前でやると、ほぼ確実にバグる。 - 前景での解析 — 同期的に重い処理を走らせると、その間ほかの要求が全部止まる。async またはワーカーへ。
- メモリリーク — Disposable を
context.subscriptionsに必ず入れる。さもないと deactivate 後もハンドラが残る。 prepareRenameの省略 — コメントや文字列の上で F2 を押された場合、確実にブロックすべき。- MS マーケットプレイスのみへの公開 — Cursor や VSCodium のユーザがインストールできない。OpenVSX も忘れずに。
次回予告
- インクリメンタルコンパイラを LSP として露出する — Salsa、Adapton、デマンド駆動解析。
- DAP 深堀り — LSP のデバッグ姉妹。同じ JSON-RPC、違うメソッド集。
- Copilot が LSP のふりをする方法 —
completionProviderとinlineCompletionItemProviderの微妙な境界。 - 独自エディタを作るときの LSP クライアント — Helix と Zed がどう統合したか。
参考 / References
- VS Code Extension API — 公式ドキュメント
- Language Server Protocol 3.18 仕様
- vscode-languageserver-node — GitHub
- tower-lsp — GitHub
- tower-lsp-server (フォーク)
- pygls — Python LSP フレームワーク
- Tree-sitter 公式ドキュメント
- Open VSX Registry
- VS Code Marketplace — 公開ガイド
- vsce — パッケージング CLI
- ovsx — OpenVSX CLI
- rust-analyzer — GitHub
- gopls ドキュメント
- typescript-language-server
- ruff-lsp
- Astro Language Server
- LSP はどう始まったか — Microsoft Blog
- Salsa — incremental computation
- matklad の LSP 設計ノート
- @vscode/extension-telemetry