ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

独自拡張ファイル文法チェックLSPサーバの作り方(VSCode)

こんにちは、こちらは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

デバッグ実行

  1. VSCodeでこのプロジェクトを開く
  2. 以下のいずれかの方法でデバッグを開始:
    • Mac: fn + F5 または メニューから Run > Start Debugging
    • Windows/Linux: F5
    • または: Cmd/Ctrl + Shift + D でデバッグビューを開き、再生ボタン(▶)をクリック
  3. 新しいVSCodeウィンドウ(Extension Development Host)が開く
  4. 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を使うことで、以下のメリットがあります:

  1. 再利用性: 一度作ったサーバーは他のエディタでも使える
  2. メンテナンス性: 言語ロジックがサーバーに集約される
  3. 拡張性: 補完、ホバー、定義ジャンプなど様々な機能を追加できる

独自の設定ファイル形式を使っているプロジェクトでは、LSPサーバーを作ることで開発体験を大幅に向上させることができます。

参考リンク

最後に

ミツモアではソフトウェアエンジニア、プロダクトマネージャーなどを募集中です。

エンジニア60人規模でCTO経験者が5人以上在籍し、15か国以上の国籍のメンバーが集まる多様なチームで働いてみませんか?

日本語でも英語でも仕事がしたい、急成長中のSaaSを開発したい、AIエージェントの開発がしたいといった方はぜひ応募をお待ちしております!

ミツモア採用ページ: https://corp.meetsmore.com/