LSPでEclipseの「importの編成」コマンドをたたいてみる


■ 概要

大量のJavaファイルに対してEclipseの「importの編成」コマンドを実行したいというニーズがあり、それをLSPで実現できるか試してみました。

LSPとは

LSP=Language Server Protocolです。普段コーディングにIntelliJやEclipseなどのIDEを使っていらっしゃる方も多いと思いますが、IDEを使用するメリットはコード補完やコード整形といったさまざまな機能が利用できる点にあります。LSPを利用すると、そういったコーディングサポート機能をVimなど任意のテキストエディタに追加することが可能になります。

ただしエディタ側のLSPサポートと、コーディングサポート機能を提供するLanguage Serverが別途必要になります。LSPはエディタとLanguage Serverの間を取り持つプロトコルという位置付けです。

参考:

language server protocolについて (前編)

「importの編成」

例えば以下のような内容のJavaファイルがあったとします。

package test.sample;

import java.io.*;
import java.math.*;

public class App {
    public static void main(String[] args) {
        BigDecimal bd = BigDecimal.valueOf(1);
        System.out.println(bd);
    }
}

Eclipse上ではこのApp.javaを選択し「importの編成」コマンドを実行すると、以下のように未使用のimport文の削除(import java.io.*;)やアスタリスク表記のクラス名への展開(import java.math.*;import java.math.BigDecimal;)が行われます。

package test.sample;

import java.math.BigDecimal;

public class App {
    public static void main(String[] args) {
        BigDecimal bd = BigDecimal.valueOf(1);
        System.out.println(bd);
    }
}

仕事で大量のCOBOLファイルをJavaソースコードへ自動変換するツールの開発を行っているのですが、変換処理をシンプルにするために変換後Javaファイル中のimport文はスタリスク表記にしているものがあり、一度Javaファイルへ変換した後にimport文の編成を行っています。
現在はその処理にEclimを利用しています。EclimはEclipseの機能をVim などのエディタから使うためのインターフェースを提供する目的で開発されたソフトウェアです(LSPに似ていますがLSP登場前から存在していました)が、このインターフェイスを介してEclipseの「importの編成」を実行することで大量のJavaファイルの処理を自動化しています。

ただ、本稿執筆時点で最新のEclimはEclipseの最新版には対応していません。JDK11以降への対応を念頭にEclimに代わる候補を探していましたが、その中で今回LSPを試してみた次第です。どうせならユニバーサルな仕様を利用したいですよね。

■ やったこと

Language Serverの実装の1つである「Eclipse JDT Language Server」へLSPでアクセスするクライアントを作り、Eclipseの「importの編成」コマンドの実行を試してみました。

ソースコードはGitHubLSPClient.javaです。
実行したい方はREADME.mdを参照下さい。

クライアント実装のポイント

○ LSPメッセージの分類

LSPはJSON-RPCベースです。また、メッセージをその性質で分けると以下の3つに分類されます。

(1) Request Message
多くはクライアント(エディタ)側からの要求ですが、LSPの場合はLanguage ServerからクライアントへのRequest Messageも存在します(Applies a WorkspaceEditなど)。
(2) Response Message
Request Messageに対する応答です。こちらもサーバ・クライアント双方から送信の可能性があります。
Request Messageには"id"属性を含めることが規定されており、Response Messageには対となるRequest Messageと同じ値の"id"属性を含める必要があります。
(3) Notification
通知はレスポンスを必要としない一方向のメッセージです。通知もサーバまたはクライアントから相手側に送信の可能性があります。
Notificationには"id"属性は含まれません。

このようにクライアントの実装にあたっては、Language Serverからのリクエストや通知も考慮する必要があります。(ちなみにLanguage Server Protocol SpecificationではRequest/Response、Notificationおよびそれらの向きを矢印で示しています。)

○ 今回利用したLSPメッセージ

Initialize Request

これはクライアントからサーバへ最初に1度だけ送信の必要があるメッセージです。エディタとしてのクライアントが備える機能等をサーバに通知するもので、エディタの能力に合わせた処理をサーバが行うようになります。

Execute a command

コード整形(Document Formatting Request)などはLSPの仕様で規定されていますが「importの編成」はLSPでは規定されていません。ではEclipseの「importの編成」を実行するにはどうすれば良いでしょうか。これにはLSPのExecute a commandを利用します。Execute a commandはLanguage Serverの実装が提供するカスタムコマンドを実行するためのメッセージです。「Eclipse JDT Language Server」では「java.edit.organizeImports」コマンドにより「importの編成」機能を提供しています。今回はこれを利用します。

Exit Notification

Language Serverプロセスの終了を求める通知です。

○ その他

今回のクライアント実装は「importの編成」コマンドの実行検証にフォーカスしているためそれほど作りこんでいません。LSPで規定されている通信を省略していたり、送信したRequestに対するResponseの突合せをしていなかったりします。
これらの点をご了承ください。

クライアントの実行結果

クライアントを起動し、その後Language Server(以後LS)を起動します。
するとまずクライアントからinitializeメッセージが送信され、LSは初期化処理を開始します。LSは初期化処理を行いながら処理状況をクライアントへ通知します。LSからの通知を何件か受信しますが、準備が完了するとLSからの以下の通知を受信します。(通知は多少前後します。)

<< Notification
Content-Length: 90

{"jsonrpc":"2.0","method":"language/status","params":{"type":"Started","message":"Ready"}}
----

次にクライアントのコンソールへ「importの編成」の対象ファイル名を入力すると(以下の例では上で例示した内容(import編成前)のApp.javaを指定しています)

App.java
-- target file:App.java
>> Request
Content-Length: 196

{"jsonrpc":"2.0","method":"workspace/executeCommand","id":3,"params":{"command":"java.edit.organizeImports","arguments":["file:///C:/Eclipse/Workspace/sample/src/main/java/test/sample/App.java"]}}
----
<< Notification
Content-Length: 162

{"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"2019/03/31 12:33:31 \u003e\u003e workspace/executeCommand java.edit.organizeImports"}}
----
<< Response
Content-Length: 240

{"jsonrpc":"2.0","id":3,"result":{"changes":{"file:///C:/Eclipse/Workspace/sample/src/main/java/test/sample/App.java":[{"range":{"start":{"line":2,"character":0},"end":{"line":3,"character":19}},"newText":"import java.math.BigDecimal;"}]}}}
----

クライアントから対象ファイルの処理を要求する「workspace/executeCommand」を送信し、LSからは「workspace/executeCommand」を実行する旨の通知後に処理結果を受信しました。
この中の「>> Request」と「<< Response」のメッセージ内容を詳しく見ていきます。

「>> Request」= 「importの編成」要求

リクエストメッセージを整形すると以下のようになります。

Content-Length: 196

{
  "jsonrpc": "2.0",
  "method": "workspace/executeCommand",
  "id": 3,
  "params": {
    "command": "java.edit.organizeImports",
    "arguments": [
      "file:///C:/Eclipse/Workspace/sample/src/main/java/test/sample/App.java"
    ]
  }
}

"method"で要求の種類を指定します。また"method"の実行に必要な情報を"params"で指定します。"method"の種類とそれに応じた"params"の内容はLSPの仕様で規定されています。

「<< Response」= 「importの編成」要求に対する応答

同様にレスポンスメッセージを整形すると以下のようになります。

Content-Length: 240

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "changes": {
      "file:///C:/Eclipse/Workspace/sample/src/main/java/test/sample/App.java": [
        {
          "range": {
            "start": {
              "line": 2,
              "character": 0
            },
            "end": {
              "line": 3,
              "character": 19
            }
          },
          "newText": "import java.math.BigDecimal;"
        }
      ]
    }
  }
}

"result"がリクエストに対する結果を示しています。今回はファイルの変更("change")で、変更対象のファイルを示すURIとその編集方法が送信されています。(編集方法は編集箇所が複数の場合も考慮し[編集方法の配列]として送信されます。)
編集方法は"range"と"newText"の組で表現され、「"range"="start"から"end"までの範囲を、"newText"で置換せよ」という内容です。"line"と"character"はそれぞれ対象ファイルの行位置と列位置を示します。このフォーマットで削除("newText":"")や挿入("start"=="end")も表現されます。

実際に送信された内容を見るとApp.javaの2行目冒頭から3行目末尾までの範囲を「import java.math.BigDecimal;」で置換せよ、という内容になっています。期待通りですね。

ただ、App.javaの実体はこの通りに書き換わってはいませんでした。送られてきた編集方法を用いて実際にファイルを書き換えるのはクライアント(エディタ)側の責務、ということのようです。

■ まとめ

Eclipseの「importの編成」をLSPを通して実行することができました。
あとは受け取った編集方法に従ってファイルを書き換える処理を書けば、Eclimの代わりとして使えそうです。