続 EmacsでJavaを書くという話 @ 2018


この記事はEmacs Advent Calandar 2018の24日目の記事です。

どうも僕です。今回は以前に書いたEmacsでJavaを書く話のその後を書いていきたいと思います。
以前のEmacsでJavaを書く話は以下を参考。
http://qiita.com/mopemope/items/d1658a4ac72d85db9ccf
また以前書ききれなかった部分なども少し書いてみたいと思います。

EmacsでJava を書くにはカロンと共にアケローン川を渡り、幾つかの地獄の階層を旅しなければなりません。
とその前に lsp-mode について少し書いてみたいと思います。

lsp-mode と lsp-java

最近、EmacsでもLSPを使用する流れが来ています。
LSP、Language Server Protocolはエディタに依存せず
バックエンドサーバーとフロントエンドのエディタをつなぐAPI群を標準化したようなものです。
つまり好みのエディタをIDE化することができるようになるのです。
Emacsではlsp-modeegolotが使えます。
その中でも lsp-mode は各言語用にさらにカスタイズしたパッケージが提供しています。
Javaではlsp-javaというパッケージが提供されています。
以前まではバックエンドサーバーのインストールなどが自動化されていなかったり、導入コストが高めでしたが、現在では自動インストール
もサポートされ導入も簡易になりました。
そして、lsp-javaのバックエンドはEclipseです。EclipseにLSPをしゃべる部分を追加したものなのでEclipseの機能、実績などをそのまま利用できるため
非常に強力です。
そのため、EmacsでJavaを書くとなるとlsp-modeが使われることが多いかと思います。
ですが、バックエンドも含めて開発しているかといえばそうではありません。
そしてlsp-modeはUIも含めVSCodeの影響を非常に受けており、Emacs Way に従っているかと言うとそうでもないように見えます。
私が開発しているmeghanada-modeはなるべくEmacs Wayに沿うような形で開発をしています。
そのため、違和感なくEmacsでも使えるようなものになっています。バックエンドも自前で開発している分柔軟な対応もできます。

では地獄の話をしていきましょう。

型解析地獄

さて以前紹介していたバージョンでは型解析をすべて自前で実装していました。
ソース上のシンボルの型がどんなクラスであるか?っていう情報をひたすら解析していたのですが速度的な問題やGeneriscの壁にぶち当たりなかなか難航していました。
単純なシンボルだけではなく、補完を効かせるためには各メソッド呼び出しの返り値も解析しなければいけません。
難航する原因の多くは lambda そしてStream APIの collect がややこしいことです。

collect の定義は以下です。

<R,A> R collect(Collector<? super T,A,R> collector)

よくあるパターンはこれに Collectors クラスのメソッドを当てはめてくのですが中に入るメソッドの型を先に解決しないと左辺がわからない状態です。単純なものならよいかも知れませんが場合によっては groupingBy などさらに引数に lambda をとるようなものもあり簡単に地獄門が開いてしまいます。

static <T,K,A,D> Collector<T,?,Map<K,D>> groupingBy(Function<? super T,? extends K> classifier, Collector<? super T,A,D> downstream)

4つもTypeParameterが出てくると辛みがあがります。
また lambda で辛いケースと言えば以下のようなパターンです。


.map(m -> {
    if (m.isActive()) {
        return b;
    }
    return a;
})
.filter(s -> s.enable())

ここで ab が別の型であった場合、map の返り値は a, b の共通項であるクラスを導出しなければいけません。また block の有無、method refernce も考慮しないといけません。
これを解析するのは正直なかなかしんどい話です。

型を知っているのは誰か?

さてこの問題を解決するにはどうするのが良いでしょうか?
答えは簡単です。コンパイラは型を全部知っているはずなのでコンパイラから型解析結果を頂いてしまえばよいのです。
meghanada では型解析をコンパイラにやらせています。
コンパイラが吐いている AST にアクセスし、そこにぶら下がる type symbol を読み取りそこから各シンボルの型、メソッドの返り値の型などを取得します。
多くの開発環境の場合、ファイルの保存時には解析、コンパイルと2つの処理を行うを行いますがこれを同時に行うようにすることで効率化、高速化しています。
(解析分だけコンパイル速度は落ちるものの個別で解析するより圧倒的に速い)
これにより flycheck のチェックでもスムーズに型チェック、コンパイルエラーの表示を行うことができています。
解析時間は200ms程度かかりますが非同期で実行されているのであまり気にならないようになっています。
型を知ることにより、左辺の型が読み取れる場合には補完候補も左辺の型にあうものを優先表示されるようになっています。

起動速度地獄

gradle プロジェクトの場合、gradle tooling API から gradle のモデルを取得し、そこから依存関係、ソースパスなどを取得したりするのですが gradle tooling API を使うには gradle daemon を立ち上げる必要があります。
小さなプロジェクトでは気にならないかも知れませんが、elasticsearch など大きいプロジェクトではこのdaemon立ち上げ時のビルドファイルの読み込み、評価だけで相当な時間がかかってしまいます。またサブプロジェクト間の依存も考えてビルドする順番も解析、考慮しなくてはいけません。
daemonなので初回起動時のみ時間がかかると言えばそうなのですが起動毎に時間がかかってしまうとなかなか厳しいものがあります。
meghanada ではプロジェクトの依存関係などを独立した中間データとして保持しています。一度解析したビルドファイルの結果はこのデータクラスに変換されキャッシュされます。
(これは複数のビルドツールをサポートしているためでもあります)
初回起動で解析したあと、次回起動時には解析済みのキャッシュを使います。
gradle tooling API は必要になる時まで使わないので高速に起動することができます。
中間データを採用したおかげで現在はMaven, Gradle, Eclipse, 独自プロジェクトなど様々なプロジェクトをサポートできるようになっています。
Eclipseベースではないので実はmeghanadaではAndroidプロジェクトもサポートしています。

フォーマット地獄

フォーマットも悩ましい問題です。以前はGoogle Java Formatのみ対応していましたが、現在ではEclipse Formatもサポートしています。
フォーマッタのタイプもそうですが、もっと大事なのは編集しているバッファを保存時にいかに違和感なくフォーマットできるか?です。
コレに関しては go-mode がとても参考になりました。
diffで生成したパッチから元にカーソルをなるべくずらさないようにパッチをあてるような工夫をしています。
私はAndroid ThingsでGoogle Assistantアプリを書いていましたが、それもEmacsで書いていました。


(defun meghanada--apply-rcs-patch (patch-buffer)
  "Apply an RCS-formatted diff from PATCH-BUFFER to the current buffer."
  (let ((target-buffer (current-buffer))
        ;; Relative offset between buffer line numbers and line numbers
        ;; in patch.
        ;;
        ;; Line numbers in the patch are based on the source file, so
        ;; we have to keep an offset when making changes to the
        ;; buffer.
        ;;
        ;; Appending lines decrements the offset (possibly making it
        ;; negative), deleting lines increments it. This order
        ;; simplifies the forward-line invocations.
        (line-offset 0))
    (save-excursion
      (with-current-buffer patch-buffer
        (goto-char (point-min))
        (while (not (eobp))
          (unless (looking-at "^\\([ad]\\)\\([0-9]+\\) \\([0-9]+\\)")
            (error "Invalid rcs patch or internal error in apply-rcs-patch"))
          (forward-line)
          (let ((action (match-string 1))
                (from (string-to-number (match-string 2)))
                (len  (string-to-number (match-string 3))))
            (cond
             ((equal action "a")
              (let ((start (point)))
                (forward-line len)
                (let ((text (buffer-substring start (point))))
                  (with-current-buffer target-buffer
                    (cl-decf line-offset len)
                    (goto-char (point-min))
                    (forward-line (- from len line-offset))
                    (insert text)))))
             ((equal action "d")
              (with-current-buffer target-buffer
                (meghanada--goto-line (- from line-offset))
                (cl-incf line-offset len)
                (meghanada--delete-whole-line len)))
             (t
              (error "Invalid rcs patch or internal error in apply-rcs-patch")))))))))

Network process 地獄

ここが一番の問題です。EmacsをIDE化する際、全てを支えているのはバックエンドとのやりとりをどうさばくかです。
今ではvimも非同期通信サポートしていますが、Emacsは古くから非同期通信をサポートしています。
この点が私にEmacsを使わせている点です。
この非同期通信を活用することにより、編集処理を妨げることなくスムーズにバックエンドサーバーとやりとりし強力な編集処理を行うことができるのです。
この部分は全てironyからアイディアを得ています。irony-modeがなければ私もこのパッケージを作成していないでしょう。
大枠としては以下のような流れになっています。

  1. バックエンドサーバーとソケット接続する
  2. コマンドを発行する
  3. バックエンドサーバーへのリクエストを作成する
  4. リクエストに採番する
  5. バックエンドサーバーへリクエストしたコールバック処理をキューに登録する
  6. バックエンドサーバーへリクエストを送信する
  7. バックエンドサーバーで処理を行いレスポンスを返す
  8. 帰ってきたレスポンスとコールバックを突き合わせる
  9. コールバックで処理を実行する

2〜9はバックエンドで非同期で実行されます。
リクエスト時には採番を行っています。これは非同期処理で、前のリクエストを追い越し早くレスポンスを返すリクエストがあった場合にケースに対応するためです。
(一応meghanadaでは非同期処理もsynchronizedしてシンプルに処理できるようにはしています)
コマンドは逐次は非同期で実行され、エディタ上の編集を阻害しないようになっています。
場合によっては同期実行する方が良いケースもあるので両方サポートしています。

まとめ

とまあ2016年から開発を継続していますが、以前に比べ格段に処理速度、精度があがりました。
インストール、設定の容易さなどは以前からも評価されています。
lsp-javaが合わないなと思った方など使ってみてはいかがでしょうか?