こんにちは、こちらは2025年12月アドベントカレンダー5日目です。
ミツモアエンジニアのteradonburiです。
YAMLとかJSONの独自拡張を文法チェックする.
VSCode LSP extension pluginの作り方について解説します。
https://github.com/teradonburi/example-lsp
はじめに
プロジェクトで独自のYAML/JSON設定ファイル形式を使っていると、「スキーマに従っているか」「必須フィールドが抜けていないか」などをエディタ上でリアルタイムにチェックしたくなることがあります。
この記事では、LSP(Language Server Protocol) を使って、VSCodeで独自ファイル形式の文法チェックを行うextensionを作る方法を解説します。
この記事で作るもの
.myconfigという独自拡張子のYAMLファイルを対象とするLSPサーバー- 必須フィールドのチェック
- 型のバリデーション
- リアルタイムのエラー表示
LSPとは
Language Server Protocol (LSP) は、エディタ/IDEと言語サーバー間の通信プロトコルです。Microsoftが提唱し、現在は多くのエディタで採用されています。
LSPのメリット
- エディタ非依存: 一度LSPサーバーを作れば、VSCode、Vim、Emacs、Sublime Textなど様々なエディタで使える
- 言語機能の分離: 言語固有のロジックをサーバー側に集約できる
- 豊富な機能: 補完、定義ジャンプ、リファレンス検索、リネーム、診断(エラー表示)など
LSPの仕組み
┌─────────────┐ JSON-RPC ┌─────────────┐ │ VSCode │ ◄──────────────────► │ LSP Server │ │ (Client) │ │ (Node.js) │ └─────────────┘ └─────────────┘
クライアント(エディタ)とサーバーはJSON-RPCで通信します。ファイルを開いたり編集したりすると、クライアントがサーバーに通知を送り、サーバーは診断結果(エラーや警告)を返します。
プロジェクトのセットアップ
前提条件
- Node.js 18以上
- pnpm(npmやyarnでも可)
- VSCode
ディレクトリ構造
my-lsp-extension/
├── package.json
├── tsconfig.json
├── client/ # VSCode extension(クライアント)
│ └── src/
│ └── extension.ts
├── server/ # LSPサーバー
│ └── src/
│ └── server.ts
└── sample/ # テスト用ファイル
└── test.myconfig
初期化
mkdir my-lsp-extension && cd my-lsp-extension pnpm init -y
依存パッケージのインストール
# LSPサーバー用 pnpm add vscode-languageserver vscode-languageserver-textdocument yaml # クライアント用 pnpm add vscode-languageclient # 開発用 pnpm add -D typescript @types/node @types/vscode esbuild
package.json の設定
VSCode extensionとして認識させるための設定を追加します。
{ "name": "my-lsp-extension", "displayName": "My Config LSP", "description": "LSP server for .myconfig files", "version": "0.0.1", "engines": { "vscode": "^1.75.0" }, "categories": ["Programming Languages"], "activationEvents": [ "onLanguage:myconfig" ], "main": "./client/out/extension.js", "contributes": { "languages": [ { "id": "myconfig", "aliases": ["My Config"], "extensions": [".myconfig"], "configuration": "./language-configuration.json" } ] }, "scripts": { "compile": "tsc -b", "watch": "tsc -b -w", "package": "vsce package" } }
言語設定ファイル
language-configuration.json を作成します:
{ "comments": { "lineComment": "#" }, "brackets": [ ["{", "}"], ["[", "]"] ], "autoClosingPairs": [ { "open": "{", "close": "}" }, { "open": "[", "close": "]" }, { "open": "\"", "close": "\"" }, { "open": "'", "close": "'" } ] }
LSPサーバーの実装
server/src/server.ts にサーバーのメインロジックを実装します。
import { createConnection, TextDocuments, Diagnostic, DiagnosticSeverity, ProposedFeatures, InitializeParams, TextDocumentSyncKind, InitializeResult, } from 'vscode-languageserver/node' import { TextDocument } from 'vscode-languageserver-textdocument' import { parse as parseYaml, YAMLParseError } from 'yaml' // サーバー接続を作成 const connection = createConnection(ProposedFeatures.all) // ドキュメント管理 const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument) // 初期化ハンドラ connection.onInitialize((params: InitializeParams): InitializeResult => { return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, // 他の機能(補完、定義ジャンプなど)もここで宣言 }, } }) // ドキュメント変更時のバリデーション documents.onDidChangeContent(change => { validateDocument(change.document) }) // スキーマ定義(独自形式) interface MyConfigSchema { name: string // 必須 version: string // 必須 enabled?: boolean // オプション settings?: { timeout?: number retries?: number } } // 必須フィールドの定義 const REQUIRED_FIELDS = ['name', 'version'] as const async function validateDocument(textDocument: TextDocument): Promise<void> { const text = textDocument.getText() const diagnostics: Diagnostic[] = [] // YAML構文チェック let parsed: unknown try { parsed = parseYaml(text) } catch (e) { if (e instanceof YAMLParseError) { // YAML構文エラー const pos = e.linePos?.[0] diagnostics.push({ severity: DiagnosticSeverity.Error, range: { start: { line: (pos?.line ?? 1) - 1, character: (pos?.col ?? 1) - 1 }, end: { line: (pos?.line ?? 1) - 1, character: 1000 }, }, message: `YAML構文エラー: ${e.message}`, source: 'myconfig-lsp', }) } connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) return } // オブジェクトでない場合 if (typeof parsed !== 'object' || parsed === null) { diagnostics.push({ severity: DiagnosticSeverity.Error, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1000 }, }, message: 'ルート要素はオブジェクトである必要があります', source: 'myconfig-lsp', }) connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) return } const config = parsed as Record<string, unknown> // 必須フィールドチェック for (const field of REQUIRED_FIELDS) { if (!(field in config)) { diagnostics.push({ severity: DiagnosticSeverity.Error, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1000 }, }, message: `必須フィールド "${field}" がありません`, source: 'myconfig-lsp', }) } } // 型チェック if ('name' in config && typeof [config.name](http://config.name) !== 'string') { const line = findLineForKey(text, 'name') diagnostics.push({ severity: DiagnosticSeverity.Error, range: { start: { line, character: 0 }, end: { line, character: 1000 }, }, message: '"name" は文字列である必要があります', source: 'myconfig-lsp', }) } if ('version' in config && typeof config.version !== 'string') { const line = findLineForKey(text, 'version') diagnostics.push({ severity: DiagnosticSeverity.Error, range: { start: { line, character: 0 }, end: { line, character: 1000 }, }, message: '"version" は文字列である必要があります', source: 'myconfig-lsp', }) } if ('enabled' in config && typeof config.enabled !== 'boolean') { const line = findLineForKey(text, 'enabled') diagnostics.push({ severity: DiagnosticSeverity.Warning, range: { start: { line, character: 0 }, end: { line, character: 1000 }, }, message: '"enabled" は真偽値である必要があります', source: 'myconfig-lsp', }) } // settingsのネストされたバリデーション if ('settings' in config) { const settings = config.settings if (typeof settings === 'object' && settings !== null) { const s = settings as Record<string, unknown> if ('timeout' in s && typeof s.timeout !== 'number') { const line = findLineForKey(text, 'timeout') diagnostics.push({ severity: DiagnosticSeverity.Warning, range: { start: { line, character: 0 }, end: { line, character: 1000 }, }, message: '"settings.timeout" は数値である必要があります', source: 'myconfig-lsp', }) } } } // 診断結果を送信 connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) } // キーの行番号を検索するヘルパー function findLineForKey(text: string, key: string): number { const lines = text.split('\n') const regex = new RegExp(`^\\s*${key}\\s*:`) for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { return i } } return 0 } // ドキュメント管理をサーバーに接続 documents.listen(connection) // サーバーを開始 connection.listen()
クライアント(VSCode Extension)の実装
client/src/extension.ts にクライアント側のコードを実装します。
import * as path from 'path' import { workspace, ExtensionContext } from 'vscode' import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, } from 'vscode-languageclient/node' let client: LanguageClient export function activate(context: 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 = { // .myconfig ファイルを対象 documentSelector: [{ scheme: 'file', language: 'myconfig' }], synchronize: { // 設定ファイルの変更を監視 fileEvents: workspace.createFileSystemWatcher('**/.myconfig'), }, } // クライアントを作成して起動 client = new LanguageClient( 'myconfigLsp', 'My Config LSP', serverOptions, clientOptions ) client.start() } export function deactivate(): Thenable<void> | undefined { if (!client) { return undefined } return client.stop() }
TypeScript設定
ルートの tsconfig.json
{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "lib": ["ES2020"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "out", "rootDir": "." }, "references": [ { "path": "./client" }, { "path": "./server" } ] }
client/tsconfig.json
{ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "out", "rootDir": "src" }, "include": ["src"] }
server/tsconfig.json
{ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "out", "rootDir": "src" }, "include": ["src"] }
ビルドと実行
ビルド
pnpm compile
デバッグ実行
- VSCodeでこのプロジェクトを開く
- 以下のいずれかの方法でデバッグを開始:
- Mac:
fn + F5または メニューからRun > Start Debugging - Windows/Linux:
F5 - または:
Cmd/Ctrl + Shift + Dでデバッグビューを開き、再生ボタン(▶)をクリック
- Mac:
- 新しいVSCodeウィンドウ(Extension Development Host)が開く
sample/フォルダ内の.myconfigファイルを開いて動作確認
テスト用ファイル
sample/test.myconfig を作成:
# 正常なケース name: my-app version: "1.0.0" enabled: true settings: timeout: 30 retries: 3
エラーを確認するケース:
# nameが欠けている → エラー version: "1.0.0" enabled: "yes" # booleanでない → 警告
コマンドラインから起動
# 依存関係のインストール npm install # ビルド npm run compile # 拡張機能の開発モードで起動 code --extensionDevelopmentPath=. ./sample 確認ポイント | ファイル | 期待される結果 | |-------------------------|--------------| | sample/valid.myconfig | エラーなし | | sample/invalid.myconfig | 3件の警告/エラーを検出 | - name フィールドが欠けている → エラー - enabled: "yes" → boolean型ではない(警告) - timeout: "30秒" → 数値型ではない(警告)
機能の拡張
補完機能の追加
サーバーに補完機能を追加できます:
connection.onInitialize((params: InitializeParams): InitializeResult => { return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, // 補完機能を有効化 completionProvider: { triggerCharacters: [':'] } }, } }) // 補完ハンドラ connection.onCompletion(() => { return [ { label: 'name', kind: [CompletionItemKind.Property](http://CompletionItemKind.Property), detail: '必須: 名前' }, { label: 'version', kind: [CompletionItemKind.Property](http://CompletionItemKind.Property), detail: '必須: バージョン' }, { label: 'enabled', kind: [CompletionItemKind.Property](http://CompletionItemKind.Property), detail: '有効/無効フラグ' }, { label: 'settings', kind: [CompletionItemKind.Property](http://CompletionItemKind.Property), detail: '設定オブジェクト' }, ] })
Hover情報の追加
connection.onInitialize((params: InitializeParams): InitializeResult => { return { capabilities: { // ... 既存の設定 hoverProvider: true }, } }) connection.onHover((params) => { const document = documents.get(params.textDocument.uri) if (!document) return null const text = document.getText() const position = params.position const line = text.split('\n')[position.line] // キーに対する説明を返す if (/^\s*name\s*:/.test(line)) { return { contents: { kind: 'markdown', value: '**name** (必須)\n\nアプリケーションの名前を指定します。' } } } return null })
JSON Schemaとの連携
より堅牢なバリデーションには、JSON Schemaを使う方法もあります:
import Ajv from 'ajv' const ajv = new Ajv({ allErrors: true }) const schema = { type: 'object', required: ['name', 'version'], properties: { name: { type: 'string' }, version: { type: 'string', pattern: '^\\d+\\.\\d+\\.\\d+$' }, enabled: { type: 'boolean' }, settings: { type: 'object', properties: { timeout: { type: 'number', minimum: 0 }, retries: { type: 'integer', minimum: 0 } } } }, additionalProperties: false } const validate = ajv.compile(schema) function validateWithSchema(config: unknown): Diagnostic[] { const valid = validate(config) if (valid) return [] return (validate.errors ?? []).map(error => ({ severity: DiagnosticSeverity.Error, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 100 } }, message: `${error.instancePath} ${error.message}`, source: 'myconfig-lsp' })) }
パッケージングと配布
vsce のインストール
pnpm add -D @vscode/vsce
.vscodeignore
.git .gitignore node_modules *.ts tsconfig.json
パッケージング
pnpm vsce package
これで .vsix ファイルが生成され、VSCodeにプラグインとしてLSPを手動インストールできます。
スクリーンショット
正常系

異常系およびLSPプラグインでの文法チェック




まとめ
LSPを使うことで、以下のメリットがあります:
- 再利用性: 一度作ったサーバーは他のエディタでも使える
- メンテナンス性: 言語ロジックがサーバーに集約される
- 拡張性: 補完、ホバー、定義ジャンプなど様々な機能を追加できる
独自の設定ファイル形式を使っているプロジェクトでは、LSPサーバーを作ることで開発体験を大幅に向上させることができます。
参考リンク
- Language Server Protocol Specification
- VSCode Language Server Extension Guide
- vscode-languageserver-node
最後に
ミツモアではソフトウェアエンジニア、プロダクトマネージャーなどを募集中です。
エンジニア60人規模でCTO経験者が5人以上在籍し、15か国以上の国籍のメンバーが集まる多様なチームで働いてみませんか?
日本語でも英語でも仕事がしたい、急成長中のSaaSを開発したい、AIエージェントの開発がしたいといった方はぜひ応募をお待ちしております!
ミツモア採用ページ: https://corp.meetsmore.com/