haskell-language-server読んだメモ
lsp触ってみたいのでよむ。
コミットはこれ。
用語集
- HLS: haskell-language-server
- LSP: language-server-protocol
- LS: languager-server
ここに公式の解説があるが、現在の実装はちょっと違う。
まずはトップレベルのhaskell-language-server.cabal
とstack.yaml
を読みにいく。
stack.yaml
にはプラグイン一覧が書いてあるだけ。
haskell-language-server.cabal
を読むと、haskell-language-server-wrapper
とhaskell-language-server
というexecutableが定義されている。つまりhlsを起動するとこのexeが呼ばれている。
haskell-language-server-wrapper
の実装はexe/Wrapper.hs
にある。main関数を読んでくとコマンドライン引数のパースとかロガーの設定とかやってる。launchHaskellLanguageServer
という関数で実際の起動をやっている。
launchHaskellLanguageServer
ではプロジェクトで使ってるstackとかcabalの設定(これをCradleと呼んでいる)を読み込む。CradleからGHCのバージョンを特定して、それに対応したHLSのバイナリを探して起動する。
この後の処理はhaskell-language-server
に移る。
haskell-language-server
の実装はexe/Main.hs
にある。main関数みてみるとなんとかRecorderみたいな処理がいっぱいあるが、これは要するにloggerの抽象。Priorityというログの詳細度フラグ?みたいなものにしたがって、対応したloggerを作ってる。個人的にそういうのはReaderモナドでやらんかったんかと思わなくもないが、何か理由があるんだろう。つまりrecorderがなんちゃらみたいな処理は全部飛ばしていい。
実際にHLSを起動してるのはdefaultMain
関数になる。これはロガーと動作モードとプラグイン一式をうけとって、HLSを起動する。
動作モード(型としてはArguments)について補足する。HLSはバージョンの表示とか、設定したCradleとか、VsCodeのスキーマとかを生成することもできるので、その動作の切り替えを指定する。
プラグインはビルド時に指定する感じで、プラグインの実態はsrc/HlsPlugins.hs
のidePlugins
関数で実装している。idePluginsはビルドオプションで読みづらいが要するに長い長いプラグインのリストになっている。
ちなみにHLS自体はコア機能だけを提供していて、実際のlspの機能はPluginとして開発されている。なので自分で機能開発する場合は実際はPluginの開発をすることになる。
defaultMain
を読んでいく。場所はsrc/Ide/Main.hs
。HLSを起動するのはArguments
がGhcide
のとき。runLspMode
でHLSを起動する。
runLspMode
はやはりロガーと設定とプラグイン一覧を受け取る。ここではロガーの設定(何回やるんだよ)とはCWDの変更とかをやるだけで、HLSの実態はIDEMain.defaultMain
に移る。
IDEMain.defaultMain
を読んでいく。場所はghcide/src/Development/IDE/Main.hs
。GhcIdeというのはhaskell-language-serverのpredecessorのこと。そのまま?持ってきてるらしい。
IDEMain.defaultMain
で大事なのはargCommands
で分岐しているところ。これはGhcIdeの動作モードを切り替えるやつで、argCommand == LSP
の時にrunLanguageServer
でLSが起動する。いつになったら起動するんだよ。
withNumCapabilities
というのはLSPでいうcapabilityではなく、システムのスレッド数分並列にIOを走らせるやつらしい。
runLanguageServer
はghcide/src/Development/IDE/LSP/LanguageServer.hs
にある。
コード内にやたらとLSP
というモジュールが出てくるが、これはlspモジュールのLanguage.LSP.Serverのこと。Language.LSP.Server
はHLSとは別に開発されていて、LSPをしゃべるサーバーとか、それに使うVirtual File Systemとかが実装されてる。ちなみにlsp-typesというのもあって、これはLSPでやり取りするメッセージをHaskellのデータ型で定義したものになっている。
このrunLanguageServer
の中でLanguage.LSP.Server.runServerWithHandles
を呼び出してようやくLSの起動が完了する。
ということで、HLSの実態は結局Language.LSP.Server
だということがわかった。
HLSが提供するプラグインは巡り巡ってrunLanguageServer
の最後の引数のsetup
に含まれている。これをいい感じにさらに変形してLanguage.LSP.Server.runServerWithHandles
に渡している。
これ以上はLSPの仕様とLanguage.LSP
を真面目に理解してないと読み進められなさそうなので一旦お休み。
Pluginの実装を見ていく。
プラグインはPluginDescriptor IdeState
という型で表される。
基本的にPluginはdescriptor :: Recorder (WithPriority Log) -> PluginId -> PluginDescriptor IdeState
という関数を公開して、HlsPluginからPluginIdをもらう。
PluginDescriptor
はhls-plugin-apiで定義されていて、プラグインがどうやってlspを実装するのかが定義されている。
1年ぶりくらいに解読を再開してみる。lspパッケージを解読する。
lspパッケージはここ数か月で結構破壊的変更が入っているようで、参照するバージョンに注意。ここでは2.3.0.0をみる。
lspでLSを起動する関数はrunServer :: forall config. ServerDefinition config -> IO Int
で、このServerDefinition config
引数でLSPとしての動作を定義する。config型が型パラメータになっているのは、開発者がサーバーの設定というか状態を自由に定義できるから。Stateモナドの状態的な。とりあえず最初は()
でよい。
ServerDefinition
には色々プロパティがあるが、よくイメージされるようなLSPのRequestとResponseをハンドリングする処理を定義するのはstaticHandlers :: ClientCapabilities -> Handlers m
ぽい。これはそのままClient Capabilitiesを受け取って、初期化?されたLSPのハンドリング処理群Handlers m
を返す。
Handlers m
の作り方は結構複雑。
まずHandlersのコンストラクタをそのまま使ってはいけない。requestHandlerかnotificationHandlerで作ったHandlers m
をmconcat
で合成していく感じになる。
requestHandlerの使い方を見る。型定義は以下のようになっている。
requestHandler :: forall (m :: Method ClientToServer Request) f. SMethod m -> Handler f m -> Handlers f
型引数のm :: Method ClientToServer Request
はHoverとかGotoとかのLSPで定義されたメソッドを型レベルで区別するもの。第一引数のSMethod m
も似た感じで、LSPメソッドの種類を実引数として与えてる。幽霊型みたいなことがやりたいのかな?PureScriptだったらvisible type applicationで指定できるんだけどな。
で、第二引数のHandler f m
がハンドラ定義の実態になるんだけど、Handler f m
はType Familyなので型から型への対応を表す。具体的にはm
のなかに型レベルでRequestかNothificationかの情報が入っているので、それを見てハンドラの型を分岐させている。
RequestならハンドラはTRequestMessage m -> (Either ResponseError (MessageResult m) -> f ()) -> f ()
になる。第一引数のTRequestMessage m
はパースされたLSPのリクエストメッセージ。第一引数にLSPで定義されたparamsとかが入ってる。第二引数はresponderと呼ばれているやつで、このresponderにLSとしての応答を表すEither ResponseError (MessageResult m)
を渡すことで実際の応答を行ってくれる。
NothificationならハンドラはTNotificationMessage m -> f ()
になる。Notificationなので応答することはないためこれだけになる。
クライアントからサーバーへのNotificationにはファイルのリネームとかプログレスバーでのキャンセル押下があるらしい。Methodにこういった型が全部定義されているのがうれしい。
fとかmなんなのか問題
さっきからHandlers m
のm
とかHandler f m
のf
がやたら出てきたが、実際これはなんなのかみていく。以降この謎の型を”謎型”とよぶことにする。
”謎型”はHandlers m
からHandler f m
のf
へと渡っている。非常にわかりづらいが、Handlers
-> Handler
の過程で、型名がm
-> f
につけかわっている。Handler f m
のf
が謎型で、m
は多分Methodのmだと思う。
Handlerで定義された謎型のカインドをみると、Type -> Type
になっている。これはモナドと同じカインドになる。
結局謎型が定義されているのはServerDefinition
なのでそこまでもどる。謎型はmとしてServerDefinitionの
onConfigChange :: config -> m ()
staticHandlers :: ClientCapabilities -> Handlers m
interpretHandler :: a -> m <~> IO
に現れる。interpretHandlerの <~>
は同型(isomorphic)を表すらしい。つまり謎型mはIOと同型な型になる。
interpretHandler
のドキュメントによると、このパッケージではIOと同型な任意のモナドでサーバーを走らせることができるらしく、
interpretHandler = env -> Iso
(runLspT env) -- how to convert from IO ~> m
liftIO -- how to convert from m ~> IO
とすることが想定されている。このようにするとIOと同型なモナドmとして
type LspT a = ReaderT (LanguageContextEnv config) IO a
が使用される。