Language Server Protocol開発チュートリアル

# はじめに

プログラマの仕事道具といえばソースコードエディタ（以下，エディタ）です．エディタ開発はプログラマの能力を最大化するための有効な手段と言えます．本記事はエディタ開発の第一歩して拡張機能，またはプラグインの開発を行います．特に[Eclipse登場以降のプログラミングエディタ](https://gist.github.com/Ikuyadeu/a0e9503a98d1f0f22bc42ead2fa5e316)の多くは拡張機能の集合体として設計されているため，拡張機能開発の経験はエディタ本体の開発にも応用可能です．

本記事では[**L**anguage **S**erver **P**rotocol](https://microsoft.github.io/language-server-protocol/) (以下，**LSP**)を用いたエディタの拡張機能開発を行います．LSPとは，コード補完や，変数参照，スタイル修正といった機能実装を[あらゆるエディタ/IDE](https://microsoft.github.io/language-server-protocol/implementors/tools/) へ提供するプロトコルです．本記事では[Visual Studio Code](https://code.visualstudio.com/)を用いて拡張機能開発を行いますが，作成した機能は[Vim](https://www.vim.org/)や[Emacs](https://www.gnu.org/software/emacs/)でも使えます．

一応[公式チュートリアル](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide)もありますが，拡張機能の開発経験を前提とした高度な内容なため，ここではその内容を簡単にした例でエディタ開発を学んでいきます．

> 注意: 本記事はLanguage Server Protocolの**開発方法**を紹介する記事であり，**利用方法**については他に詳しいサイトを[記事終盤](#エディタごとの利用方法リンク)で紹介しています　


# 対象読者

* エディタ開発に興味がある人
* エディタの構造を知りたい人
* 色んなエディタをいじってみたい人

もしこの記事の内容が難しすぎる場合，以下のリンクから基礎的な学習を行えます．

* [TypeScript](http://js.studio-kingdom.com/typescript/)
* [Npm パッケージ](https://qiita.com/dondoko-susumu/items/cf252bd6494412ed7847)

この記事ではTypeScriptを利用していますが，あまり高度な機能は利用しないため，JavaScript，もしくは他の言語にふれたことがあれば大丈夫です．

# 今回行うこと

* [Hello Worldを表示](#hello-world)
* [特定の文章に対して警告を出す](#警告機能の実装)
* [警告に対して自動修正を行う](#自動修正機能の実装)
* [Vim, Emacs, AtomでもLSPを利用](#他エディタでもlspを利用)


## 行わないこと

* 言語依存の機能(補完, 参照)開発
* リモート環境・マルチルートワークスペースを考慮した機能開発

# 開発環境

* [Visual Studio Code](https://code.visualstudio.com/)
* [Node JS](https://nodejs.org/) x64, version >= 10.x, <= 12.x

# Hello World

コンピュータープログラミングにおける古来の伝統に従い，最初に作るアプリケーションは「Hello World」を表示するプログラムにしましょう．

[簡素なテンプレート](https://github.com/Ikuyadeu/vscode-language-server-template)を用意しましたのでそれをクローンしてVS Codeで開きましょう．

```sh
git clone https://github.com/Ikuyadeu/vscode-language-server-template.git
code vscode-language-server-template
```

## ビルド

1: ターミナルを起動
[表示] -> [統合ターミナル]
または，`Control` + \` (Macなら `^` + \`)

2: 以下のコマンドを実行し，必要なパッケージのインストールを行います．

```sh
npm install
```
3: `F5` キーを入力することで拡張機能をインストールしたVS Codeを立ち上げます．
4: 開いたエディタ上で`.txt`ファイル，もしくは`.md`ファイルを開いてみましょう．
画像では`test.txt`の１行目に波線を表示させ，その上にマウスを置くと`Hello World`と表示させています．
<img width="518" alt="Screen Shot 2019-12-22 at 0.27.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/ece4c4c2-158b-c36a-9376-a025f80c1b73.png">


## ファイル構成

以下がVS CodeでLSPを実装するときの一般的なファイルの構成です．
本記事ではサーバー側のメインである`server/src/server.ts`を編集していきます．

```
.
├── client               // Language Serverのクライアントサイド
│   ├── src
│   │   └── extension.ts // クライアント側拡張機能のソースコード
│   └── package.json     // クライアント側として利用するパッケージ情報
│
├── server               // Language Serverのサーバーサイド
│   │── src
│   │   └── server.ts    // Language Serverのメインソースコード
│   │── package.json     // クライアント側として利用するパッケージ情報
│   └── README.md        // サーバーとしてのREADME.md (説明書)
│
├── package.json         // VS Codeプラグインとしてのパッケージ情報 
└── README.md            // VS CodeプラグインとしてのREADME.md(説明書)
```


## ソースコード解説

<details><summary>サーバー側のソースコード</summary><div>

```ts:server/src/server.ts
"use strict";

import {
    CodeActionKind,
    createConnection,
    Diagnostic,
    DiagnosticSeverity,
    Range,
    TextDocuments,
    TextDocumentSyncKind,
} from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";

namespace CommandIDs {
    export const fix = "sample.fix";
}
// Create a connection for the server. The connection uses Node's IPC as a transport.
// Also include all preview / proposed LSP features.
const connection = createConnection();
connection.console.info(`Sample server running in node ${process.version}`);
let documents!: TextDocuments<TextDocument>;

// 初期化
connection.onInitialize(() => {
    documents = new TextDocuments(TextDocument);
    setupDocumentsListeners();

    // サーバー全体の機能を記述する
    return {
        capabilities: {
            // ファイルを保存したときや変更，閉じたときに実行
            textDocumentSync: {
                openClose: true,
                change: TextDocumentSyncKind.Incremental,
                willSaveWaitUntil: false,
                save: {
                    includeText: false,
                },
            },
            // 自動修正を実装よてい
            codeActionProvider: {
                codeActionKinds: [CodeActionKind.QuickFix],
            },
            executeCommandProvider: {
                commands: [CommandIDs.fix],
            },
        },
    };
});

/**
 * ソースコードの一行目に対して警告を表示する
 */
function validate(doc: TextDocument) {
    // 警告などファイルの状態を管理するリスト
    const diagnostics: Diagnostic[] = [];
    // 警告を出す範囲（今回は0行目の0文字目から0行目の最終文字目まで）
    const range: Range = {start: {line: 0, character: 0},
                          end: {line: 0, character: Number.MAX_VALUE}};
    // 警告を追加，引数は順番に範囲，メッセージ，警告強度，警告id，警告のソース
    diagnostics.push(Diagnostic.create(range, "Hello world", DiagnosticSeverity.Warning, "", "sample"));
    connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}

function setupDocumentsListeners() {
    documents.listen(connection);

    // ファイルを開いたとき，変更したときにファイルを検証し，警告を表示する
    documents.onDidOpen((event) => {
        validate(event.document);
    });

    documents.onDidChangeContent((change) => {
        validate(change.document);
    });

    // ファイルを閉じたとき，警告表示を消す
    documents.onDidClose((close) => {
        connection.sendDiagnostics({ uri: close.document.uri, diagnostics: []});
    });

}

// Listen on the connection
connection.listen();
```
</div></details>
<details><summary>クライアント側のソースコード</summary><div>

```ts:client/src/extension.ts
"use strict";

import * as path from "path";
import { ExtensionContext, window as Window } from "vscode";
import { LanguageClient, LanguageClientOptions, RevealOutputChannelOn, ServerOptions, TransportKind } from "vscode-languageclient";

// 拡張機能を立ち上げたときに呼び出す関数
export function activate(context: ExtensionContext): void {
    const serverModule = context.asAbsolutePath(path.join("server", "out", "server.js"));
    const debugOptions = { execArgv: ["--nolazy", "--inspect=6009"], cwd: process.cwd() };
    const serverOptions: ServerOptions = {
        run: { module: serverModule, transport: TransportKind.ipc, options: { cwd: process.cwd() } },
        debug: {
            module: serverModule,
            transport: TransportKind.ipc,
            options: debugOptions,
        },
    };
    // 対象とする言語．今回は.txtファイルと.mdファイル
    const clientOptions: LanguageClientOptions = {
        documentSelector: [
            {
                scheme: "file",
                language: "plaintext",
            },
            {
                scheme: "file",
                language: "markdown",
            }],
        diagnosticCollectionName: "sample",
        revealOutputChannelOn: RevealOutputChannelOn.Never,
    };

    let client: LanguageClient;
    try {
        client = new LanguageClient("Sample LSP Server", serverOptions, clientOptions);
    } catch (err) {
        Window.showErrorMessage("The extension couldn't be started. See the output channel for details.");

        return;
    }
    client.registerProposedFeatures();

    context.subscriptions.push(
        client.start(),
    );
}
```
</div></details>

# 警告機能の実装

次に，大文字に対して警告文を出す実装を行います．
サーバーのソースコード`server/src/server.ts`の関数`validate`を以下の通りに書き換えてみましょう．

```ts:server/src/server.ts
/**
 * 大文字に対して警告を表示する
 */
function validate(doc: TextDocument) {
    // ２つ以上並んでいるアルファベット大文字を検出
    const text = doc.getText();
    // 検出するための正規表現 (正規表現テスト: https://regex101.com/r/wXZbr9/1)
    const pattern = /\b[A-Z]{2,}\b/g;
    let m: RegExpExecArray | null;
    
    // 警告などの状態を管理するリスト
    const diagnostics: Diagnostic[] = [];
    // 正規表現に引っかかった文字列すべてを対象にする
    while ((m = pattern.exec(text)) !== null) {
        // 対象の位置から正規表現に引っかかった文字列までを対象にする
        const range: Range = {start: doc.positionAt(m.index),
                              end: doc.positionAt(m.index + m[0].length),
        };
        // 警告内容を作成，上から範囲，メッセージ，重要度，ID，警告原因
        const diagnostic: Diagnostic = Diagnostic.create(
            range,
            `${m[0]} is all uppercase.`,
            DiagnosticSeverity.Warning,
            "",
            "sample",
        );
        // 警告リストに警告内容を追加
        diagnostics.push(diagnostic);
    }

    // Send the computed diagnostics to VSCode.
    connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}
```

もう一度`F5`キーで実行し以下のファイルを作ってみましょう

```txt:README.md
TypeScript lets you write JavaScript the way you really want to.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.
ANY browser. ANY shost. ANY OS. Open Source.
```

ファイル中の大文字`ANY`や`OS`に対して以下のように波線で警告が出るはずです．

![Screen Shot 2019-12-21 at 18.32.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/6af5e459-0452-cd32-7107-98a16930843a.png)

# 自動修正機能の実装

警告だけに終わらず，自動的に修正する機能を実装していきます．今回は大文字を小文字にしていきます．

引き続き，`server/src/server.ts`を編集します．`import`以下と関数`setupDocumentsListeners`を以下のコードに編集してみましょう．ここまでの過程で，つまづいた場合は[出来上がったもの](https://github.com/Ikuyadeu/vscode-language-server-template/tree/completed)を`completed`ブランチとして用意してありますのでそちらをご利用ください．

```ts:import文
import {
    CodeAction,
    CodeActionKind,
    createConnection,
    Diagnostic,
    DiagnosticSeverity,
    Range,
    TextDocumentEdit,
    TextDocuments,
    TextDocumentSyncKind,
    TextEdit,
} from "vscode-languageserver";
```

```ts:関数setupDocumentsListeners
function setupDocumentsListeners() {
    documents.listen(connection);

    documents.onDidOpen((event) => {
        validate(event.document);
    });

    documents.onDidChangeContent((change) => {
        validate(change.document);
    });

    documents.onDidClose((close) => {
        connection.sendDiagnostics({ uri: close.document.uri, diagnostics: []});
    });

    // Code Actionを追加する
    connection.onCodeAction((params) => {
        // sampleから生成した渓谷のみを対象とする
        const diagnostics = params.context.diagnostics.filter((diag) => diag.source === "sample");
        // 対象ファイルを取得する
        const textDocument = documents.get(params.textDocument.uri);
        if (textDocument === undefined || diagnostics.length === 0) {
            return [];
        }
        const codeActions: CodeAction[] = [];
        // 各警告に対してアクションを生成する
        diagnostics.forEach((diag) => {
            // アクションの目的
            const title = "Fix to lower case";
            // 警告範囲の文字列取得
            const originalText = textDocument.getText(diag.range);
            // 該当箇所を小文字に変更
            const edits = [TextEdit.replace(diag.range, originalText.toLowerCase())];
            const editPattern = { documentChanges: [
                TextDocumentEdit.create({uri: textDocument.uri,
                                         version: textDocument.version},
                                        edits)] };
            // コードアクションを生成
            const fixAction = CodeAction.create(title,
                                                editPattern,
                                                CodeActionKind.QuickFix);
            // コードアクションと警告を関連付ける
            fixAction.diagnostics = [diag];
            codeActions.push(fixAction);
        });

        return codeActions;
    });

}
```

警告に電球マークが付き，該当箇所を小文字に修正します．
![Screen Shot 2019-12-21 at 18.37.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221488/a8748309-ad2f-3459-9e68-ed65c11c1478.png)

# 公開方法

以下はそれぞれ各リンクを参照ください．

## VS Code 拡張機能の公開

[公式ガイド（英語）](https://code.visualstudio.com/api/working-with-extensions/publishing-extension)
[日本語記事](https://qiita.com/567000/items/c4cf825fb30e3e247285#%E5%85%AC%E9%96%8B%E3%81%99%E3%82%8B)

まとめると以下のコマンドです．

```sh
vsce login
vsce publish
```

## Language Serverの公開

npmパッケージとして公開します．

[公式ガイド（英語）](https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages)
[日本語記事](https://qiita.com/TsutomuNakamura/items/f943e0490d509f128ae2)

まとめると以下のコマンドです．

```sh
cd server
npm login
npm publish
```

# 他エディタでもLSPを利用

以下のコマンドでLanguage Serverのnpmパッケージをインストールすることで他のエディタでも開発したLSPを利用できます．

```sh
npm install -g {公開したLanguage Server}
```

## エディタごとの利用方法リンク

* [neovim/Vim](https://qiita.com/succi0303/items/cd30d0ea40d419d4431c#javascript)
* [Emacs](https://qiita.com/blue0513/items/acc962738c7f4da26656)
* [Sublime Text](https://tukumemo.com/language-server-protocol-sublimetext/)
* [Atom (英語ドキュメント)](https://github.com/atom/atom-languageclient)

# 発展

公式が提供しているLSPの実装例は以下のとおりです．
更に新しい機能を作りたい場合はこれらを参考に発展させていきましょう．

| サンプル | 内容 |
| ------ | --- |
| [LSP Sample](https://github.com/Microsoft/vscode-extension-samples/tree/master/lsp-sample)  | 小文字のアルファベットを見つけると注意する．本記事の内容に加え，設定ファイルへのアクセスなども行う |
| [LSP Multi Root Server Sample](https://github.com/Microsoft/vscode-extension-samples/tree/master/lsp-multi-server-sample)  | [マルチルートワークスペース](https://code.visualstudio.com/docs/editor/multi-root-workspaces)ごとにLSPを立ち上げる，LSP Sampleの拡張版 |
| [LSP Log Streaming Sample](https://github.com/Microsoft/vscode-extension-samples/tree/master/lsp-log-streaming-sample)  |LSP実行時にログを出力する, LSP Sampleの拡張版|
| [LSP UI Example](https://github.com/microsoft/vscode-extension-samples/tree/master/lsp-user-input-sample) |ファイルの１行目に文字の挿入を行うアクションを追加する|


より高度な開発を体験するには[各言語サーバーのOSS](https://microsoft.github.io/language-server-protocol/implementors/servers/)に参加するのが一番だと思います．

# 参考文献
* [Ruby on Rails チュートリアル](https://railstutorial.jp/)
* [Eclipse プラグイン開発](https://www.amazon.co.jp/Eclipse%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E9%96%8B%E7%99%BA-%E3%82%A8%E3%83%AA%E3%83%83%E3%82%AF-%E3%82%AC%E3%83%B3%E3%83%9E/dp/4797324899)
* [Language Server Extension Guide](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide)


宣伝

* GitHubアプリ作成チュートリアルの[翻訳記事](https://github.com/Ikuyadeu/probot-tutorial-japanese/wiki)を公開しています．本記事と合わせて読むことでオフライン・オンライン両方で開発を効率化できます．
* LSPを使った[VS Codeの拡張機能]([リンク](https://marketplace.visualstudio.com/items?itemName=Ikuyadeu.devreplay))を公開しています．本記事で紹介したソースコードの発展版となります．よければ使ってみてください．