LSP4JでLanguage Server Protocol入門

# Language Server Protocolとは?

[Language Server Protocl](https://microsoft.github.io/language-server-protocol/)(LSP)とは、IDEやテキストエディタ―と、プログラミング言語に関連したツール間でやり取りを定めたプロトコルです。これにより、一つの**言語サーバー**を実装すれば、エディターごとに別々の拡張機能を作ることなく、その言語のサポートを追加できるようになります。

歴史的にはTypeScriptのためにマイクロソフトが開発したものがもとになっており、現在では独立した仕様として公開されています。


## この記事でやること

ここでは題材として、エクセルのように、CSVファイルに同じ列の値を補完する機能を実装します(もっともVS Codeはデフォルトで、ファイル中の単語を補完してくれるので、実用性は皆無です)。なお目的はLSPとLSP4Jの基礎を学ぶことなので、補完機能の実装はいい加減です。クライアントはVisual Studio Code向けに作成します。サーバーは[LSP4J](https://github.com/eclipse/lsp4j)を使います。

[ソース](https://bitbucket.org/minebreaker_tf/teachyourselflsp4j)


## LSPの基本

先ほど述べたように、LSPは**クライアント**と**言語サーバー**の間でのやり取りを定めたプロトコルです。言語サーバーは、プログラミング言語の入力補完、シンタックスエラーの通知など、その言語に関する様々な機能を提供します。クライアントはテキストエディタ―が一般的ですが、それ以外にも使えます。

プロトコルは、HTTPもどきのヘッダー部と、JSON形式のボディー部に分かれています。ボディー部の中身はJSONP形式です。もっとも、この辺りはライブラリーが面倒を見てくれますし、仕様書を読めばそれで終わりなので、詳しくは解説しません。

やり取りされるメッセージには、**リクエスト**、**レスポンス**、**ノーティフィケーション**の3種類があります。リクエストは何らかの要求を送信し、レスポンスを送るよう求めるメッセージです。ノーティフィケーションは返答を必要としない、送りっぱなしのメッセージです。このメッセージのやり取りを通じて、相互に様々なやり取りをしていきます。


## クライアント

Visual Studio CodeはLSPに対応する代表的なエディターです。クライアントとして、VS Codeの拡張機能を作ります。

```json:package.json
{
    "activationEvents": [
        "onLanguage:csv"
    ],
    "contributes": {
        "languages": [
            {
                "id": "csv",
                "extensions": [
                    ".csv"
                ],
                "aliases": [
                    "CSV",
                    "Csv",
                    "csv"
                ]
            }
        ]
    },
    "dependencies": {
        "vscode": "^1.1.18",
        "vscode-languageclient": "^4.2.1"
    },
    "devDependencies": {
        "@types/node": "^10.3.1",
        "typescript": "^2.9.1"
    },
    "scripts": {
        "compile": "tsc -p ./",
        "update-vscode": "node ./node_modules/vscode/bin/install",
        "launch": "code --extensionDevelopmentPath=F:/work/TeachYourselfLsp4j/client/"
    }
}
```

*完全なソースはリポジトリを参照されたし*

VS Codeに、「この拡張機能はCSVファイルを扱う」ことを宣言する必要があります。`activationEvents:["onLanguage:csv"]`で、CSVファイルを編集するとき、この拡張機能を有効化するよう宣言できます。また、VS CodeはデフォルトでCSVファイルを認識しません。`contributes.languages`以下の部分で、`*.csv`ファイルをCSVファイルとして認識するよう宣言します。

[参考](https://code.visualstudio.com/docs/languages/identifiers)

`script`にVS Codeを拡張機能ホストとして動かすための設定をします。パスはハードコードされているので要変更。


```typescript
export function activate(context: ExtensionContext) {

    const serverOptions: Executable = {
        command: "C:/PROGRA~1/Zulu/zulu-10/bin/java",
        args: ["-jar", "F:/work/TeachYourselfLsp4j/server/build/libs/TeachYourselfLsp4j-1.0-SNAPSHOT.jar"]
    }

    const clientOptions: LanguageClientOptions = {
        documentSelector: [{ scheme: 'file', language: 'csv' }]
    }

    const disposable = new LanguageClient('myLanguageServer', 'MyLanguageServer', serverOptions, clientOptions).start();
    context.subscriptions.push(disposable);
}
```

JavaのパスやJARの場所はハードコーディングしていますが、本番ではコンフィグ経由で。

`LanguageClient`のコンストラクターの第三引数は`ServerOptions`を継承したオブジェクトを指定します。ここでは実際にプログラムを実行する`Executable`を使っています。
実はLSPは、通信のやり取りがどのような方法で行われるかは定めていません。`Executable`ではサーバーとクライアントの間で、標準入出力を使い通信を行います。

ソケット通信をしたい場合。

```typescript
function serverOptions(): Thenable<StreamInfo> {
    const client = require("net").connect(8080)
    return Promise.resolve({
        writer: client,
        reader: client
    })
}
```

(一応動くことは確認しましたが、node.jsには詳しくないので、「正しい」実装かは知らない)


## サーバー

[LSP4J](https://github.com/eclipse/lsp4j)はEclipseプロジェクトの一つで、LSPをJavaで扱うためのライブラリーです。RedHatが開発している[VS CodeのJava拡張機能](https://github.com/redhat-developer/vscode-java)が使用する[Eclipse JDT Language Server](https://github.com/eclipse/eclipse.jdt.ls)が使っています。

```gradle
dependencies {
    compile 'org.eclipse.lsp4j:org.eclipse.lsp4j:0.4.1'
}
```

まずメインメソッドを定義していきます。

```java
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
```

LSP4Jは`java.util.logging`を使っているので、SLF4Jを経由するように設定。また、VS Codeは標準エラー出力をデバッグ用に表示してくれるので、Logbackの出力先を`System.err`に設定します。

```xml
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
    <target>System.err</target>
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger {%mdc} - %msg%n</pattern>
    </encoder>
</appender>
```

```java
public final class MyLanguageServer implements LanguageServer, LanguageClientAware {  /* ... */ }
```

サーバーは`LanguageServer`インターフェースを実装して作成します。`LanguageClientAware`は、その実装クラスがクライアントにデータを送信したいということを示すインターフェースです。大抵の場合クライアントに送信もしたいので、`LanguageClientAware`も実装します。

```java
var server = new MyLanguageServer();
var launcher = LSPLauncher.createServerLauncher( server, System.in, System.out );

var client = launcher.getRemoteProxy();

server.connect( client );
launcher.startListening();
```

`LSPLauncher.createServerLauncher()`メソッドで、サーバーを起動します。今回通信には標準入出力を使うので、`System.in`と`System.out`を使います。

```java
var serverSocket = new ServerSocket( 8080 );
var socket = serverSocket.accept();
var launcher = LSPLauncher.createServerLauncher( server, socket.getInputStream(), socket.getOutputStream() );
```

もちろん、ソケットなどほかの手段も使えます。

```java
var client = launcher.getRemoteProxy();
server.connect( client );
launcher.startListening();
```
`getRemoteProxy()`メソッドで、クライアントを表すインスタンスを取得できます。`LanguageClientAware.connect(LanguageClient)`を呼んで、`LanguageServer`がクライアントを呼び出せるようにします。

最後に`startListening()`で、応答を開始します。このメソッドはブロックします。入力ストリームが終わりに達したとき(すなわち`InputStream.read()`が`-1`を返したとき)、サーバーは終了します。


### `LanguageServer`の実装

`LanguageServer`、`LanguageClientAware`インターフェースの各メソッドを実装していくことで、クライアントから特定の処理が要求されたときのコールバックを記述していきます。

```java
private LanguageClient client;

@Override
public void connect( LanguageClient client ) {
    this.client = client;
}
```

フィールドに`LanguageClient`を保持しておきます。
とりあえずHello Worldを実装してみます。

```java
@Override
public void initialized( InitializedParams params ) {
    client.logMessage( new MessageParams( MessageType.Info, "hello, world" ) );
}
```

[initialized通知](https://microsoft.github.io/language-server-protocol/specification#initialized)は起動後一度のみ、クライアントからサーバーへ送られる通知です。これを受け取った時、クライアントに`hello, world`という[logMessage通知](https://microsoft.github.io/language-server-protocol/specification#window_logMessage)を送信することにします。

上手くいけば、VS Code上にログが表示されるはずです。

![キャプチャ.PNG](https://qiita-image-store.s3.amazonaws.com/0/231669/436d9592-81b8-cb59-3f7a-fe03483015b8.png)

なお、出ているERRORは実装されていないサービスがあるという警告なので、気にする必要はありません。


### `TextDocumentService`の実装

実際にCSVファイルの補完機能を実装していきます。が、その前に`Capability`を登録し、「この言語サーバーは入力補完を扱える」とクライアントに伝える必要があります。

```java
@Override
public CompletableFuture<InitializeResult> initialize( InitializeParams params ) {
    var capabilities = new ServerCapabilities();
    capabilities.setTextDocumentSync( TextDocumentSyncKind.Full );

    var completionOptions = new CompletionOptions();
    completionOptions.setResolveProvider( true );
    capabilities.setCompletionProvider( completionOptions );

    var result = new InitializeResult( capabilities );
    return CompletableFuture.completedFuture( result );
}
```

`TextDocumentSyncKind.Full`は、サーバーがファイル全体を同期する必要があることを宣言します。`Incremental`で変更した個所のみ受け取ることも可能ですが、実装が面倒なので、今回は`Full`を選びます。

さて、`TextDocumentservice`を実装していきます。

```java
public final class MyTextDocumentService implements TextDocumentService { /* ... */ }
```

```java
@Override
public void didOpen( DidOpenTextDocumentParams params ) {
    updateDocument( params.getTextDocument().getUri() );
}

@Override
public void didChange( DidChangeTextDocumentParams params ) {
    updateDocument( params.getTextDocument().getUri() );
}
```

ファイルが開かれたり更新されたとき、サーバー側で同期します。

```java
@Override
public CompletableFuture<Either<List<CompletionItem>, CompletionList>> completion( CompletionParams position ) {

    return CompletableFutures.computeAsync( checker -> {
        var src = this.src.get();
        var currentLineIndex = position.getPosition().getLine();
        if ( src.size() <= currentLineIndex ) {  // ファイルの最後の新しい行はリストにいない
            return Either.forLeft( List.of() );
        }
        var currentRow = src.get( currentLineIndex );
        var currentRowString = currentRow.stream().collect( joining( "," ) );
        var currentRowBeforeCursor = currentRowString
                // 同期タイミングの問題で、ソースが更新されていない場合もある
                .substring( 0, Math.min( currentRowString.length(), position.getPosition().getCharacter() ) );
        var currentColumn = (int) currentRowBeforeCursor
                .chars()
                .filter( c -> c == ',' )
                .count();
        var wordsInSameColumn = src.stream()
                                   .filter( l -> l.size() > currentColumn )
                                   .map( l -> l.get( currentColumn ) )
                                   .filter( s -> !s.isEmpty() )
                                   .distinct()
                                   .collect( toList() );
        logger.debug( "{}", wordsInSameColumn );
        var response = wordsInSameColumn.stream()
                                        .map( CompletionItem::new )
                                        .collect( toList() );

        return Either.forLeft( response );
    } );
}
```

入力補完リクエストである`completion`を受け取った時、補完の処理を行います。今回実装は重要でないので、適当です。参考にしないでください。

![無題.png](https://qiita-image-store.s3.amazonaws.com/0/231669/8f548136-24dc-9355-a189-36c13f2a5ffa.png)

入力補完がきちんと動きました。