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の拡張機能を作ります。
{
"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.in
とSystem.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
の実装
LanguageServer
、LanguageClientAware
インターフェースの各メソッドを実装していくことで、クライアントから特定の処理が要求されたときのコールバックを記述していきます。
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上にログが表示されるはずです。
なお、出ている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
を受け取った時、補完の処理を行います。今回実装は重要でないので、適当です。参考にしないでください。
入力補完がきちんと動きました。