経緯
しばらく前から、Scalaを書く機会が訪れた。NeoVimのLanguage Clientプラグインとしてはcocを使っているので、Scalaでもcoc-metals
を入れて使うことにした。
https://github.com/scalameta/coc-metals
2022年3月に coc-metals
の開発終了が宣言された 。
なんとかしなきゃなと思いつつも、ひとまず現在のプロジェクトでは問題なく動いていたので、そのまま使い続けていた。
2022年10月、現在のプロジェクトのScalaバージョンアップに伴って、ついにいくつかの機能が動かなくなってしまった。具体的には補完と定義ジャンプあたりができなくなった。
試行錯誤
補完と定義ジャンプくらいは動かないと開発しづらいので、これらをできるように色々試した。
試行錯誤の方向性
coc-metals
のREADMEでは「 nvim-metals
でNeoVim組み込みのLanguage Clientを変わりに使おう」と書いてある。しかし、他のcocプラグインなどもあったり一度慣れた操作を全て捨てなくてはいけなかったりで、これを理由に乗り換えるのは現実的ではないなと思った。
よって、「coc上でmetalsを使う方法」を模索することにした。
公式の推奨は手動インストール(だが、なるべく使いたくない……)
とりあえずcocとmetalsの公式を見てみた。coc向けのmetalsの導入方法はどちらも手動でのインストールを推奨していた。
しかし、手動でのインストールは腰が引けた。私の開発環境は複数あって、そのうち特定の環境でしか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.serverVersion
に 0.11.9
などを入れれば良さそうだ。
設定してみたところ、metalsのダウンロードができなくなり、起動に失敗するようになってしまった。
coc-metals
は metals-languageclient
の 0.4.2
を利用している。metalsのダウンロードも metals-languageclient
の機能だ。そして metals-languageclient@0.4.2
では、metalsをダウンロードするScalaのバージョンは 2.12
に固定されていた。
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でのインストール方法が書いてあった(ただし、依存ライブラリをビルドする関係でディスクスペースを多く使うので非推奨らしい)。
そこで、以下のようにとりあえず 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系の最初)まで戻したのだけど、ビルドできない状況は変わらなかった。これまでコントリビュートしてこれなかった身としてはエラーをすぐに解決することは無理そうだった。ついでに、これに関するマイグレートイシューが立っていて、すでに試されていることであると知った。
metalsを手動インストールして使う
ここまできて coc-metals
を使い続けるのは無理だなと判断した。そのため、metalsを手動で入れて利用する方向にかじを切った。metalsの手動インストール方法については、ドキュメントに載っていたため、これを参考にビルドしていった。
Coursierをインストール
私の環境にはJavaは導入済みだったので、あまりそれについては考えずに、未インストールのCoursierからインストールした。
上記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
で起動する
- これによりCoursierは
- 出力先を
/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-nvim
のwikiに設定があるので、そのまま coc-settings.json
へコピペした。
"languageserver": { "metals": { "command": "metals-vim", "rootPatterns": ["build.sbt"], "filetypes": ["scala", "sbt"] } }
動作確認
ここまでの設定でNeoVimでScalaファイルを開き動作確認をした。
- できたこと
- ファイル内のdocumentHover
- できなかったこと
- ファイルをまたぐdocumentHover
- 定義ジャンプ
- 補完
coc-metals
にあったmetals.build-import
などのコマンド
感触としては、動いてはいるけど十分ではない、という感じだ。とはいえ、スタートラインには立ててはいるようだ(ちなみに、この段階だと「この先試行錯誤してもだめ」な可能性はあって、戦々恐々としながら進めていった……)。
まともに動くようにする
追加の動作確認
動いている気はするものの裏付けがなかったので、これを確認した。coc-nvim
のwikiにDebug language serverという節があった。これを参考に試していった。
まず、 :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-languageclient
の ServerCommands
に定義されていた。
実際に叩いているコマンドの文字列はわかった。次はコマンドの実行方法だ。先の 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の扱い
- keyとvalueの区切り文字
- 配列の記法
- 配列の先頭INDEX
ちなみにこのあたりの書き方はいつもluaのマニュアルの(非公式)日本語訳を見ている。
ただし、1箇所読んだだけだとわかりにくかったり、そもそもどこに書いてあるのか目次からわからなかったりする。
たとえば、前述のテーブルの記法の件は「3.4.9 - テーブルコンストラクタ」にある。変数や型の節ではない。3章までよめば文法はわかるので、NeoVimでluaを使うならここまで全部読んでしまってもいいかもしれない。文法以外の関数(たとえば for
でテーブルを走査するときによく使う pairs()
)などは、ググった方が早い。ググっても載ってない細かい仕様が必要な場合だけドキュメントに戻ってくるので十分だ。
NeoVim側のluaのAPIについてはこのドキュメントを見ている。あとは 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起動後にサービスが立ち上がった場合(私は普段そうなることが多い)に対応できなかった。
まぁ、コマンドが一覧に出るだけで他のコマンドを叩きたいときはたいてい絞り込んで使うため、あまり邪魔にはなっていないのでよしとした。
metals開始時に、 build-import
コマンドを実行する
これも上記と同様の理由でトリガーできないため断念した。一旦手動で叩く手間を軽減したので、しばらくは様子見することにした。
metalsを自動でインストールする
頑張れば書ける……が、インストールする頻度が低いし、設定ファイルにインストールスクリプトが載っていればとりあえずはOKということにした。
以下が面倒だった。
- インストール先
- インストール方法
coursier
なのかcs
なのか、そもそもあるのかどうか- ないとパッケージマネージャーでいれるかそれ以外か、使い捨てかどうか……など
metalsの準備ができたかどうかわからない
metals(に限らないけどLanguageServer)は起動にちょっと時間がかかる。私の環境では1〜2分くらい。
その間にcocの機能でdocumentHoverすると Hover Not Found
になってしまう。
終わったかどうかのチェックは CocAction workspace.showOutput
で languageserver.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のAPIがluaでできないことを補完してくれている
まぁ、そもそもvimscriptはvimコマンドの集合だし、そりゃプログラミング言語で書いたほうが書きやすくて当然である。
気持ちとしては全部luaにしてもいいなぁと思っているが、ちょっとずつ移行していけばいいかなと思った。
おわりに
私のneovim config はこちらだ。
開発するためにエディタを整えているのか、エディタを整えるために開発しているのか分からなくならない程度にこれからも頑張っていきたい。