LoginSignup
46
26

More than 5 years have passed since last update.

LSP4JでLanguage Server Protocol入門

Last updated at Posted at 2018-07-18

Language Server Protocolとは?

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

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

この記事でやること

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

ソース

LSPの基本

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

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

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

クライアント

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

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ファイルとして認識するよう宣言します。

参考

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

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ではサーバーとクライアントの間で、標準入出力を使い通信を行います。

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

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

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

サーバー

LSP4JはEclipseプロジェクトの一つで、LSPをJavaで扱うためのライブラリーです。RedHatが開発しているVS CodeのJava拡張機能が使用するEclipse JDT Language Serverが使っています。

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

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

SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();

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

<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>
public final class MyLanguageServer implements LanguageServer, LanguageClientAware {  /* ... */ }

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

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.inSystem.outを使います。

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

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

var client = launcher.getRemoteProxy();
server.connect( client );
launcher.startListening();

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

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

LanguageServerの実装

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

private LanguageClient client;

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

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

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

initialized通知は起動後一度のみ、クライアントからサーバーへ送られる通知です。これを受け取った時、クライアントにhello, worldというlogMessage通知を送信することにします。

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

キャプチャ.PNG

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

TextDocumentServiceの実装

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

@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を実装していきます。

public final class MyTextDocumentService implements TextDocumentService { /* ... */ }
@Override
public void didOpen( DidOpenTextDocumentParams params ) {
    updateDocument( params.getTextDocument().getUri() );
}

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

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

@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

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

46
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
46
26