coc-metals がなくなってもなんとかcocで生きている

経緯

しばらく前から、Scalaを書く機会が訪れた。NeoVimのLanguage Clientプラグインとしてはcocを使っているので、Scalaでもcoc-metalsを入れて使うことにした。

github.com

https://github.com/scalameta/coc-metals

2022年3月に coc-metalsの開発終了が宣言された 。

github.com

なんとかしなきゃなと思いつつも、ひとまず現在のプロジェクトでは問題なく動いていたので、そのまま使い続けていた。

2022年10月、現在のプロジェクトのScalaバージョンアップに伴って、ついにいくつかの機能が動かなくなってしまった。具体的には補完と定義ジャンプあたりができなくなった。

試行錯誤

補完と定義ジャンプくらいは動かないと開発しづらいので、これらをできるように色々試した。

試行錯誤の方向性

coc-metals のREADMEでは「 nvim-metals でNeoVim組み込みのLanguage Clientを変わりに使おう」と書いてある。しかし、他のcocプラグインなどもあったり一度慣れた操作を全て捨てなくてはいけなかったりで、これを理由に乗り換えるのは現実的ではないなと思った。

よって、「coc上でmetalsを使う方法」を模索することにした。

公式の推奨は手動インストール(だが、なるべく使いたくない……)

とりあえずcocとmetalsの公式を見てみた。coc向けのmetalsの導入方法はどちらも手動でのインストールを推奨していた。

github.com

scalameta.org

しかし、手動でのインストールは腰が引けた。私の開発環境は複数あって、そのうち特定の環境でしかScalaを利用しないし、ビルドツールの導入から必要なようだったので単純にめんどくさいなと思った。これまで coc-metals にmetalsの管理を任せていたので、余計に手間だと感じたのかもしれない。

いったんmetalsをそのまま使える方向を模索しようと思った。

coc-metals が参照するmetalsのバージョンアップ

正直今までREADMEに従ってただ導入していただけだったので、なぜ動かなくなったかの調査から始めた。metals起動時にバージョンに関する警告が出ており、ここに 0.11.2 を利用していると書かれていた。調査時点でリリースされているのは 0.11.9 まであったが、 coc-metals の最新版が参照しているのは 0.11.2 であるようだ。そこでこのバージョンを上げれば直るのではと考えた。

coc-metals のREDMEに設定項目について記載されており、 metals.serverVersion0.11.9 などを入れれば良さそうだ。

github.com

設定してみたところ、metalsのダウンロードができなくなり、起動に失敗するようになってしまった。

coc-metalsmetals-languageclient0.4.2 を利用している。metalsのダウンロードも metals-languageclient の機能だ。そして metals-languageclient@0.4.2 では、metalsをダウンロードするScalaのバージョンは 2.12 に固定されていた。

github.com

metalsのリリースノートを見ると、metalsの 0.11.3 からビルドするScalaのバージョンが 2.13 になったっぽい。なので org.scalameta:metals_2.12:0.11.9 をダウンロード出来ずに失敗してしまうらしい。

coc-metals の依存する metals-languageclient のバージョンアップ

それならということで、 coc-metals の依存する metals-languageclient のバージョンアップをやってみることにした。いわゆるモンキーパッチだ。

coc-metals はこれまで :CocInstall でインストールしていた。これはnpmから取得しているので、これにパッチを当てるのは無理だ。cocのドキュメントを見ると、gitでのインストール方法が書いてあった(ただし、依存ライブラリをビルドする関係でディスクスペースを多く使うので非推奨らしい)。

github.com

そこで、以下のようにとりあえず coc-metals をcloneしてNeoVimで読み込めるようにした( yarn は適当に準備)。

# 適当なディレクトリ
cd /path/to/directory
git clone https://github.com/scalameta/coc-metals.git
cd coc-metals
yarn install

# NeoVimの設定ファイルにて
:set runtimepath^=/path/to/directory/coc-metals
# 読み込めると以下コマンドの結果に `coc-metals` が増える
:echo map(CocAction('extensionStats'), {k, v -> v['id']})

インストールは出来たので、 yarn upgrade --latest metals-languageclient にて更新した。その結果、 yarn build が通らなくなってしまった。インターフェースが色々変わったようだ……

metals-languageclient の最新は 0.5.17 だったので、バージョン飛ばし過ぎかなと思って 0.5.1 (0.5系の最初)まで戻したのだけど、ビルドできない状況は変わらなかった。これまでコントリビュートしてこれなかった身としてはエラーをすぐに解決することは無理そうだった。ついでに、これに関するマイグレートイシューが立っていて、すでに試されていることであると知った。

github.com

metalsを手動インストールして使う

ここまできて coc-metals を使い続けるのは無理だなと判断した。そのため、metalsを手動で入れて利用する方向にかじを切った。metalsの手動インストール方法については、ドキュメントに載っていたため、これを参考にビルドしていった。

scalameta.org

Coursierをインストール

私の環境にはJavaは導入済みだったので、あまりそれについては考えずに、未インストールのCoursierからインストールした。

get-coursier.io

上記Coursierのドキュメントに従って、

brew install coursier/formulas/coursier

とした。すでに sbt 等は sbtenv などで用意していたため、 cs setup は行わなかった。

metalsのビルド

前掲のmetalsのドキュメントに従って、以下のようにした。オプションは吟味していない。

cs bootstrap \
    --java-opt -Xss4m \
    --java-opt -Xms100m \
    --java-opt -Dmetals.client=coc.nvim \
    org.scalameta:metals_2.13:0.11.9 \
    -r bintray:scalacenter/releases \
    -r sonatype:snapshots \
    -o ~/bin/metals-vim -f

変更点は以下の通り。

  • coursier コマンドを適宜インストールして使うのではなく、前節で brew install したものを使う
    • これによりCoursierは cs で起動する
  • 出力先を /usr/local/bin ではなく、 ~/bin にする

-------- (2023/07/20 追記) --------

metals.client はnvim-lsp以外にも設定可能な値があるようだった。以下を参考に coc.nvim にした。設定値が変わる模様。 github.com


パスを通す

前節で ~/bin へ配置したが、普段自分でビルドしないので、ここにはパスが通っていなかった。私はzshなので、 ~/.zprofile に以下を追記した。

[ -d ~/bin ] && PATH="$HOME/bin:$PATH"

cocと連携させる

coc-nvimwikiに設定があるので、そのまま coc-settings.json へコピペした。

github.com

"languageserver": {
  "metals": {
    "command": "metals-vim",
    "rootPatterns": ["build.sbt"],
    "filetypes": ["scala", "sbt"]
  }
}

動作確認

ここまでの設定でNeoVimでScalaファイルを開き動作確認をした。

  • できたこと
    • ファイル内のdocumentHover
  • できなかったこと
    • ファイルをまたぐdocumentHover
    • 定義ジャンプ
    • 補完
    • coc-metals にあった metals.build-import などのコマンド

感触としては、動いてはいるけど十分ではない、という感じだ。とはいえ、スタートラインには立ててはいるようだ(ちなみに、この段階だと「この先試行錯誤してもだめ」な可能性はあって、戦々恐々としながら進めていった……)。

まともに動くようにする

追加の動作確認

動いている気はするものの裏付けがなかったので、これを確認した。coc-nvimwikiにDebug language serverという節があった。これを参考に試していった。

github.com

まず、 :CocList services を見てみたところ、 languageserver.metals[running] となっていた。問題なく起動は出来ているようだ。

次に、metalsのログを見てみた。 :CocCommand workspace.showOutput から、 languageserver.metals を選ぶ……が、ほとんどログが出ていなかった。タイミングによっては(ログがないからか)選ぶことすらできない。

ドキュメントを見直すと、 "trace.server": "verbose" を追加するとLSP通信の内容が全部出るようだったので、これを追加してやり直したところ、詳細なログを得ることができた。

ファイル内のdocumentHoverは思ったとおりうまくいっているらしい。

(ファイル内のdocumentHover)
[Trace - 1:50:37] Sending request 'textDocument/hover - (4)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 10,
        "character": 25
    }
}

[Trace - 1:50:38] Received response 'textDocument/hover - (4)' in 485ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    }
}

ファイルをまたぐdocumentHoverは思ったとおり何も返ってきていなかった。

(ファイルをまたぐdocumentHover)
[Trace - 1:52:43] Sending request 'textDocument/hover - (5)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 14,
        "character": 23
    }
}

[Trace - 1:52:43] Received response 'textDocument/hover - (5)' in 14ms.
No result returned.

ところで、ここまでで出たログを眺めていて、 coc-metals を使っていたときには出ていたプロジェクトのビルドのログがないことに気がついた。 coc-metals はログをechoする仕組みだったのに対して、cocから直接languageserverを使った場合はechoされない。このことは知っていたので、ビルドのログが無いことに疑問を持たなかったのだ。

metalsが有効になった後に build-import をかける

ビルドするために何を行えばいいかはおよそ見当がついていて、metalsの調子悪いときに :CocCommand の中から実行していた build-import をすれば良さそうだろうと考えていた。しかし、今までは coc-metals:CocCommand に追加してくれていたので、 coc-metals を使わなくなった今どうやって実行すれば良いのかが分からなかった。

とりあえず coc-metals のコードからなんとなく command などで検索してみると、起動時にコマンド登録しているっぽいコード が見つかった。このコマンド群の定義は metals-languageclientServerCommands に定義されていた。

github.com

github.com

実際に叩いているコマンドの文字列はわかった。次はコマンドの実行方法だ。先の metals-languageclient のところにserver commandsと書いてある。関係がありそうなものをcocのhelpから探していると、 :call CocAction("runCommand", "name") とすることでコマンドが実行できるらしい。

試しに :call CocAction("runCommand", "build-import") を実行したところ、30秒ぐらい制御を取られ、何かが実行された。その後、documentHoverの情報を改めて確認すると以下の変化があった。

フイル内のdocumentHoverは元々出ていたが、 range が追加された。

(ファイル内のdocumentHover)
[Trace - 2:07:21] Sending request 'textDocument/hover - (11)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 10,
        "character": 20
    }
}

[Trace - 2:07:21] Received response 'textDocument/hover - (11)' in 99ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    },
    "range": {
        "start": {
            "line": 10,
            "character": 20
        },
        "end": {
            "line": 10,
            "character": 29
        }
    }
}

また、ファイルをまたぐdocumentHoverの情報も以下のように出るようになった。

(ファイルをまたぐdocumentHover)
[Trace - 2:09:35] Sending request 'textDocument/hover - (12)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 14,
        "character": 21
    }
}

[Trace - 2:09:35] Received response 'textDocument/hover - (12)' in 22ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    },
    "range": {
        "start": {
            "line": 14,
            "character": 21
        },
        "end": {
            "line": 14,
            "character": 24
        }
    }
}

これで一旦の目的を果たした。しかし、使いにくい点があるのでもう少し改善したいと思う。

cocのコマンドを非同期に実行する

直前のコマンド( :call CocAction(...) )は、完了するまで制御を返さない。 build-import はプロジェクトサイズによっては結構時間がかかるので、長い時間NeoVimが操作を受け付けなくなる。これは非常に使いにくい。

この解は簡単で、 :call CocActionAsync(...) という非同期版を使えば良い。この2つの関数は、アクションの実行結果の扱いの違いがある。

  • 同期版: 戻り値で返す(例: :echo CocAction("commands") などを叩いてみると結果が表示される)
  • 非同期版: 引数にcallbackを与えて扱う( call CocAction("commands", {err, result -> ...}) みたくするらしいが試してない……ちなみに非同期なのでechoしても当然何も表示されない)

cocのコマンドにmetalsのコマンドを追加する

:call CocActionAsync("runCommand", "build-import") を打てばいいことはわかったが、毎回打つにはつらすぎる。

NeoVimなので、必要なカスタマイズは自分ですればいい。キーバインドやコマンドを定義するのが普通だが、今まで :CocCommand から呼び出していたので、それと同じ操作感にしたい。

cocのhelpを見ると、 coc#add_command() という関数があった。これを使うと CocCommand で呼び出せるコマンドを追加できるようだ。これを使って coc#add_command("metals.build-import", ":call CocActionAsync('runCommand', 'metals.build-import')") とすることで、 :CocCommand の一覧に表示されるようになった。

うまくいくことがわかったので、サーバーコマンドとして一括で登録する define_metals_commands というlua関数を書いた。

local add_coc_command = function(name, command, description)
  vim.fn["coc#add_command"](name, command, description)
end

local define_metals_commands = function()
  local commands = {
    "ammonite-start",
    "ammonite-stop",
    "analyze-stacktrace",
    "bsp-switch",
    "build-connect",
    "build-disconnect",
    "build-import",
    "build-restart",
    "compile-cancel",
    "compile-cascade",
    "compile-clean",
    "copy-worksheet-output",
    "debug-adapter-start",
    "doctor-run",
    "file-decode",
    "list-build-targets",
    "generate-bsp-config",
    "goto",
    "goto-position",
    "goto-super-method",
    "new-scala-file",
    "new-java-file",
    "new-scala-project",
    "reset-choice",
    "scala-cli-start",
    "scala-cli-stop",
    "sources-scan",
    "super-method-hierarchy",
  }

  for _, v in pairs(commands) do
    local name = "metals." .. v
    local action = ":call CocActionAsync('runCommand', '" .. v .. "')"
    add_coc_command(name, action)
  end
end

これで一通りのコマンドを追加することが可能だ。私は全部使っているわけではないが、一旦全部登録したままにしている。コマンド名に metals. とつけているので、他のものと競合することはないはずだ。

metalsが実行可能なときだけcocに登録する

ここまでで一応使える状況にはなったが、別環境のことを考慮していなかった。 metals-vim がPATHにない状態でScalaファイルを開くとcocがエラーを出す。

前述のように coc-settings.json に書いていると、条件分岐を行う方法がない。ドキュメントを眺めると coc#config() という関数でも coc-settings.json と同等のことが行えるらしい。これなら、コードからの呼び出しなので分岐は自由だ。

coc-settings.json をやめてluaで書く

coc#config() を使っていこうと思ったのだが、 coc-settings.json との併用は非推奨らしい。

仕方がないので、全てをluaで置き換えた。文法は大きく違わないので、以下に注意してluaのtableに置き換え、登録していった。

  • keyの扱い
    • JSONではキーはダブルクォートで囲む必要がある
    • luaではキーは以下のようになる
      • 特殊文字を含まないなら識別子のようにベタ書き
      • 特殊文字を含むなら [”foo.bar”] のように囲む
        • cocの設定には一部ドットを含むものがあるので、私は統一の意味も込めて全てこちらで書くことにした
  • keyとvalueの区切り文字
  • 配列の記法
    • JSONでは []
    • luaでは {} (キーなしのtableとして扱う)
  • 配列の先頭INDEX

ちなみにこのあたりの書き方はいつもluaのマニュアルの(非公式)日本語訳を見ている。

milkpot.sakura.ne.jp

ただし、1箇所読んだだけだとわかりにくかったり、そもそもどこに書いてあるのか目次からわからなかったりする。 たとえば、前述のテーブルの記法の件は「3.4.9 - テーブルコンストラクタ」にある。変数や型の節ではない。3章までよめば文法はわかるので、NeoVimでluaを使うならここまで全部読んでしまってもいいかもしれない。文法以外の関数(たとえば for でテーブルを走査するときによく使う pairs() )などは、ググった方が早い。ググっても載ってない細かい仕様が必要な場合だけドキュメントに戻ってくるので十分だ。

github.com

NeoVim側のluaAPIについてはこのドキュメントを見ている。あとは coc-sumneko-lua の補完に頼っている。更に情報がほしいときだけ、 :h nvim_echo() などで api.txt を読んでいる。関数の存在を和訳ドキュメントで見て、細かいオプションはhelpを見るのが丁度いいかなと思う。

luaに置き換えると、例えば先に示したmetalsの設定の場合は以下のようになる。

local languageserver_config = {}
languageserver_config.metals = {
  ["command"] = "metals-vim",
  ["rootPatterns"] = { "build.sbt" },
  ["filetypes"] = { "scala", "sbt" },
}
-- 実際に呼んでいるのは coc#config("languageserver", languageserver_config)
My.PutCocConfig("languageserver", languageserver_config)

これなら分岐でもなんでも仕込み放題だ。ついでにコメントも書ける。

metalsの登録を分岐する

前節のような感じで置き換えが出来たので、 metals-vim の実行ファイルがある時だけ上記の部分のコードを追加したい。

まずは実行可能ファイルがあるかのチェックをする。これはNeoVimの関数を利用するのが楽っぽいので、以下関数を定義した。ちなみに My というのは、変数や自作関数を作ったときに置いておくグローバル変数として自分で定義したものだ。

local function has_executable(name)
  return vim.fn.executable(name) == 1
end
My.has_executable = has_executable

これを使って、

if My.has_executable("metals-vim") then
  -- 前述のコード
end

とした。これでいい感じだ。

ここまででやっと使える域に達したと感じることができた。

できていないこと

とりあえず動くようになったが、出来ていないこともありそのうちなんとかしたい気持ちはあるので書き残しておく。

metals起動時まで CocCommand への登録を遅らせる

上記の方法だと、metalsがないときには CocCommand へ登録されないが、あるときにはcoc起動時に登録される。今まで coc-metals ではサービス起動時(≒Scalaファイルを開いたとき)に登録されていたので、そちらに近づけたいと思った。

調べた限りでは、サービスステータスの変更に伴ってCallbackが呼ばれるような仕組みが見当たらず、断念した。

以下が似た投稿で、 user CocNvimInit イベントを使っていたが、このイベントはcocの起動時に呼ばれるため、coc起動後にサービスが立ち上がった場合(私は普段そうなることが多い)に対応できなかった。

github.com

まぁ、コマンドが一覧に出るだけで他のコマンドを叩きたいときはたいてい絞り込んで使うため、あまり邪魔にはなっていないのでよしとした。

metals開始時に、 build-import コマンドを実行する

これも上記と同様の理由でトリガーできないため断念した。一旦手動で叩く手間を軽減したので、しばらくは様子見することにした。

metalsを自動でインストールする

頑張れば書ける……が、インストールする頻度が低いし、設定ファイルにインストールスクリプトが載っていればとりあえずはOKということにした。

以下が面倒だった。

  • インストール先
    • NeoVimだけが読める場所とかでもいいのだけれど、私はこれにあまり詳しくない
    • ホームディレクトリ以下に置く場合、PATHを通してあるディレクトリが何で、存在するのかどうかなどがあって手間&シェルの設定まで変える元気がなかった
  • インストール方法
    • coursier なのか cs なのか、そもそもあるのかどうか
    • ないとパッケージマネージャーでいれるかそれ以外か、使い捨てかどうか……など

metalsの準備ができたかどうかわからない

metals(に限らないけどLanguageServer)は起動にちょっと時間がかかる。私の環境では1〜2分くらい。 その間にcocの機能でdocumentHoverすると Hover Not Found になってしまう。 終わったかどうかのチェックは CocAction workspace.showOutputlanguageserver.metals を目視するか、「そろそろ大丈夫だろう」という勘でやっている。目視の方は、別のウィンドウが開かれるので鬱陶しいし、勘は間違っていることが度々ある。

lightlineを使っているので、ここに表示したいが、このあたりを調べきってない。今は基本勘で、ダメそうだったら showOutput 出しながら目視するくらいにしている。

build-import しても別のファイルを開くとdocumentHoverできなかったりする

要するに完璧に動くわけじゃないのだけれど、症状を言語化できていない。今動く範囲でコーディングが可能なので放置している。

コマンドリストを手動で作っている

このあたりからどうでもいい話になってくる。

CocCommand に登録するコマンドの一覧を、何かと連携して作っているわけではなく、参考リポジトリからコピペして作っている。これだとコマンドが増えたときに気づけない。

ただ、そんなに新しいコマンドが必要なこともなさそうだし、追加するにしても配列に1行足す程度なので手間じゃない。そういうわけで放置することにした。

コマンド名にvimがついている

本当に軽微な話だけど、 coc#add_command("metals.build-import", ...) としているのに、コマンド名は vim.metals.build-import となる。

見た目が気になるだけだし直し方が分からないので放置している。

感想

なんとか開発できるくらいにはなった

全部完璧にできたわけじゃないが、コードを読んだり書いたりはできるようになった。また気が向いたら調整すると思うけど、一段落できて安心した。

フリーライディングと向き合った

一番精神的に大きな話。

coc-metals の開発終了は少し前から知っていたけれど、その時はまだ動いていたのもあって、メンテナには手を挙げなかった(挙げられなかった)。今回、実際動かなくなってから色々調べたりパッチを当てようとしたりして、最終的にメンテナに手を挙げることができなかった。

metals-languageclient についての知識不足でコードの修正方法が分からなかったのもある。英語のReading以外がとても苦手というのもある。だが自分の中で一番大きかったのは「Scalaを専門で書いているわけではないので時間を割くのが難しい」と思ってしまった点だ。

そう思ったときに「やっぱり自分もフリーライディングしてるし望んでるんだな」と気づいた。誰かがメンテしてくれる安定した無料のソフトウェアを使いたいだけだった。

これは結構へこんだし、しばらく向き合い方に悩んだが、今私にできる貢献としてはこういうブログを書いて同様に困る人を減らすことくらいだろうということに落ち着いた(あとはシンプルにお金を払うとかだろうか……これも追って検討したい)。英語がもうちょっと書けたら、cocにサービスステータス反応でアクションを仕込む方法を聞くか要望上げるかできるんだけど……

LSPはサーバー起動コマンドを設定するだけで使える……とはいかない

どこかで「LSPはプロトコルが整理されているので、クライアントではサーバーの参照方法だけ設定に書けば使えるしプラグイン入れるのは無駄!」みたいな論を見た気がするのだけれど(掘り起こせなかった……)、少なくとも今回はこうはならなかった。

今回で言えば、以下のことをしなければいけなかった。ただしmetals以外でも同様かどうかは調べてない。

  • サーバーのインストール管理
  • サーバーコマンドを簡単に呼び出せるようにする
  • 特定のタイミングでサーバーコマンドを呼び出す

luaでの設定が書きやすい

今まではほとんどの設定をvimscriptで書いていたが、今回初めて大掛かりな設定をluaで書いたところ、なんとなく書きやすいなと感じた。以下のようなあたりが理由だと思う。

  • トリッキーさがない
    • 関数の引数は a: としないといけないとか
    • augroup END とか
    • コマンドなのか関数なのかとか
    • 大きなハッシュテーブル等を作るときに、行末 \ が不要
  • 構文ノイズが少ない
    • let しなくても変数が書き換えられる
    • call がなくても関数を呼び出せる
  • NeoVimのAPIが使いやすい(少なくとも私が使った範囲では)
  • NeoVimのAPIluaでできないことを補完してくれている

まぁ、そもそもvimscriptはvimコマンドの集合だし、そりゃプログラミング言語で書いたほうが書きやすくて当然である。

気持ちとしては全部luaにしてもいいなぁと思っているが、ちょっとずつ移行していけばいいかなと思った。

おわりに

私のneovim config はこちらだ。

github.com

開発するためにエディタを整えているのか、エディタを整えるために開発しているのか分からなくならない程度にこれからも頑張っていきたい。