FsAutoComplete & ionide-vscode-fsharp (F# のインテリセンス機能の調査)
2023-01-02
概要:
VSCode上で F# を使うときは ionide-vscode-fsharp (以下IVF) という拡張機能を使う
F# のLSPサーバーの本体は FsAutoComplete (以下FSAC) というプロジェクトにある
ionide-vscode-fsharpは基本的にFsAutoCompleteのVSCode用のアダプタになっている
通信プロトコル:
IVFがFSACをバックグラウンドプロセスとして生成する
IVFとFSACは LanguageServerProtocol (LSP) というプロトコルにしたがって通信する
FSACのLSPサーバーの部分は https://github.com/ionide/LanguageServerProtocol にライブラリ化されている
IVFのLSPクライアントはおそらくvscode-languageclientパッケージを使っている
LSPの拡張:
標準LSPに加えてリクエストのmethodが fsharp/* であるものが存在して、プロトコルが部分的に拡張されているようだ
ログメッセージを見る:
デバッグ用のログ出力はVSCodeの設定で以下をつけると出る:
{
...
"FSharp.trace.server": "verbose",
"FSharp.verboseLogging": true
}
出力は2箇所に出る
- 出力/Outputパネルの F# タブ
- 開発者ツールのコンソール (メニューのHelp>Toggle Developer Tools または Ctrl+P→Toggle Developer Tools)
開発環境の構築:
(以下はUbuntu 20.04の場合。最新の情報や詳細はリンク先のリポジトリを参照すること)
もちろんVSCodeと .NET, F# のSDKがいる。上記2つのリポジトリをGitでクローンしてくる
.NET SDK は dotnet-sdk-6.0=6.0.302-1
(apt) を使っている (6.0.400や7.x.xはまだサポートしていないらしい 参照)
ビルドする前に注意: リポジトリのルートに global.json
がある場合、そこに書かれているSDKのバージョン番号を、インストールしたものと合わせる必要がある
上記の例では 6.0.302
にする
FSACはビルドはできるが F# のインテリセンスが動作しない
私の環境では以下でいったん解決した:
-
./build.sh
で依存関係等を解決する (必要なファイルがダウンロードされる) -
FsAutoComplete.Logging.fsproj
のTargetFrameworkをnetstandard2.0からnet6.0
に変更する - VSCodeを閉じて.NETの一時ディレクトリを消す (
rm -rf build/{bin,obj} src/*/{bin,obj}
) -
./build.sh
を行い、完了してからVSCodeを開く
デバッグ版のFSACを使うにはVSCodeの設定で以下のように追加する
これをユーザー設定に追加してしまうとDLLがロックされて困るので動作確認用の一部のワークスペースにだけ設定したほうがよい
(ファイルパスは環境による)
{
<snip>,
"FSharp.fsac.netCoreDllPath": "/home/vain0x/FsAutoComplete/src/FsAutoComplete/bin/Debug/net6.0/fsautocomplete.dll"
}
適当にログ出力を増やしてみると動くことが確認できた
ionide-vscode-fsharpは ./build.sh -t Watch
でビルド&ウォッチができる
その状態でリポジトリをVSCodeで開き、デバッグメニューから LaunchOnly
をするとデバッグ版の拡張機能がついた状態でVSCodeが起動する
これも適当にログ出力を増やしてみると動くことが確認できた
FSACをリビルドするときは開発用のVSCodeが開いているリポジトリの設定から netCoreDllPath
の設定をコメントアウトする
これによってDLLのロックが解除されるのでビルドが完了するのをみとどける
その後コメントアウトから戻して、念のため Reload Window を行うとよさそう
そういうわけで準備ができたので課題をみていく
現時点で分かりやすい課題としては以下がある:
- コンソール (developer toolsのconsole) に頻繁にエラーが出る:
Cannot find ident for tooltip
- inlayHintsのリクエストのレスポンスが
No typecheck results available
というエラーになってしまうことがある - Gitの過去のファイルの差分を閲覧するとそのファイルの解析が一時的に動作しなくなる IVF#1810
- ワークスペースを開いたとき最初に引かれているテキストエディタにsemanticTokensが有効にならない
-
Format Selection
でインデントが考慮されない
分かりにくい課題としては以下がある:
- ワークスペースを開いたときにプロジェクトがロードされず、拡張機能が動作しない (起動ガチャ。再現方法不明)
- Goto DefinitionやFind Referencesが動作したりしなかったりする
- renameしたときリネームの対象となる範囲が誤っている (何度かファイルを変更・保存しても直らない。しかもdocumentHighlightの結果は正常なのでreameだけおかしい。再現方法不明)
- open宣言が必要なのに不要なものとしてグレーアウトされることがある (unusedAnalyzerの問題)
- 起動直後の解析処理がエラーになってしまう: System.Array Not Found Error · Issue #1006 · fsharp/FsAutoComplete
次にコードを見ていく
IVF:
F# からJavaScriptに変換するFableというコンパイラを利用していて、拡張機能も F# で書かれている
そのため F# と微妙にセマンティクスが違ったり、実行環境であるNode.jsのAPIが呼べたりする
src/fsharp.fs
にあるactivate関数が拡張機能のエントリーポイントになっている
LSPサーバーに加えてその他のコンポーネント (ステータスバーにあるQuickInfoとか) を起動している
LSPサーバーとの通信はLSPクライアントのライブラリがやるのであまり書かれていない
(メソッドが fsharp/*
である)拡張されたリクエストをサーバーに送りつける関数が列記されている
通信まわりでいえばJSONとの変換はThoth.Jsonというのが使われている
(F# の型から自動でエンコーダー・デコーダーを生成するようだ)
ロガーも気になる
Core/Logging.fsにある
コンソール(developer toolsに出る)とOutputChannel (出力/Outputパネルに出る)の両方に書くためのロガーを取得して使うという感じになっている
標準出力に書けばコンソールに出るっぽい (最終的に console.log
等になっている)
コードを読んでいるうちに気づいたのでissueを立てた
(ここはこのスクラップを作る前の話)
とりあえず目につくので "Cannot find ident for tooltip" の問題から追っていく
メッセージで検索するとFSACの ParseAndCheckResult
クラスにあるメソッドがヒットした: TryGetToolTip
, TryGetToolTipEnhanced
(いったん後者は忘れる)
関数の呼び出しの流れを見るために関数名で検索していくとこういう流れになっていそうだ
FSharpLspServer.FSharpSignature
(→ Commands.Typesig)
→ Commands.typesig
→ Commands.ToolTip
→ TryGetToolTip
note: AdaptiveLspServerは新しいLSPサーバーの実装っぽいが特定のフラグを有効にしたときのみ動くようなので無視する
FSharpSignatureは fsharp/signature
リクエストのハンドラなので次は送信元を探す
fsharp/signature
はIVFの LanguageService.signature 関数から送信 (sendRequest) されている
呼び出し元をたどる
QuickInfo.update → QuickInfo.getOverloadSignature
QuickInfo.updateの本体は promise {..} |> ignore
になっていてたしかにこのpromiseがrejectしたときハンドルされない
(unhandled promise rejectionが起こる) ということが分かった
デバッグログを追加してこのあたりのコードに実際行き着いていることを確認できた
"Cannot find..." 以外のエラーが起こったときはコンソールにエラーがでないので他の場所は適切に処理されているのだろうと推測ができる
他の promise {} がどうなっているのかをみたらPipelineHints.documentParsedHandlerに promise {...} |> logger.ErrorOnFailed
という記述があって、プロミスのrejectされたエラーをロガーに送り込む処理があった
QuickInfo.updateのほうも同様にするとunhandled rejectionのエラーはなくなって、代わりにロガーからerrorが出るようになった
部分的に解決したが根本的にコンソールに不必要なエラーが出るのは直っていない
QuickInfoはカーソルの下にある識別子の情報をステータスバーに出すやつ
QuickInfo.updateはカーソル位置 (厳密には選択範囲の集合) が変わったときに呼ばれてその情報を更新するというものっぽい
入力された位置に対する getOverloadSignature がデータを取得できないのは通常のこと (ドメインエラー) なのでfsharp/signatureはエラーレスポンスを返すべきではないと思う
私の解釈ではカーソル位置に識別子がなかったり、その識別子に対してquickInfoに出すべき情報がない場合に、fsharp/signatureは正常レスポンスを返し、その中身が空 (Noneか""
) であるというAPIのほうが自然に思える
(このあたりはプロジェクトの設計方針なのでissueを開いて相談したほうがよいだろう)
LSPのhoverリクエストで指定位置に識別子がないときどうなるかと思ったら、これもエラーレスポンスが返っていた
エラーレスポンスがコンソールに出ないだけ
[Error - 21:26:32] Request textDocument/hover failed.
Message: Cannot find ident for tooltip
Code: -32603
起動直後に開かれたファイルのセマンティックハイライトが適用されない問題を見ていく
セマンティックハイライトはLSPではsemanticTokensリクエストによってなされる
(/fullと/rangeの2つがあるがおそらく/fullのほうをみればよい)
出力パネルを見たところトークン列が空の成功レスポンスが返されてしまっている
[Trace - 22:39:06] Sending request 'textDocument/semanticTokens/full - (6)'.
Params: {
"textDocument": {
"uri": "file:///home/owner/repo/fs-playground/Program.fs"
}
}
<snip>
[Trace - 22:39:07] Received response 'textDocument/semanticTokens/full - (6)' in 847ms.
No result returned.
セマンティックトークンとは別件だが "Cached typecheck results not yet available" というエラーがコンソールに出ているので、おそらく初期化の処理が完了するのをまたずにリクエストのハンドラーが動いてしまっているせいで不正な結果が出ているような気がする (推測)
呼び出しチェーンを辿る
(meta: オーバーロードされている同名のメソッドは引数の個数をnとしてF/nと書く。引数の個数だけで区別できないときは追加で補足する)
"textDocument/semanticTokens/full"
(LanguageServerProtocol側で抽象メソッドの呼び出しにバインディングされている)
→ FSharpLspServer.TextDocumentSemanticTokensFull
→ Commands.GetHighlighting
→ Commands.TryGetRecentTypeCheckResultsForFile/1
このTryGetRecentTypeCheckResultsForFile/1 (以下GetRecent) がNoneを返し、それをasyncOption.Bindで開いているので全体の結果もNoneになってしまっているようだ
方針としてはGetHighlightingの冒頭部分で必要な処理が終わるのを待機させたい
次はいつ必要な処理が終わるのか特定したい
GetRecentから呼ばれている2つの関数をみていく:
- State.TryGetFileCheckerOptionsWithLines,
- Commands.TryGetRecentTypeCheckResultsForFile/3
前者はFilesとProjectControllerというフィールドを触っている
Filesはソースファイルが入った並行ハッシュマップ (ConcurrentDictionary) で、これへの書き込みは5箇所ある
(すべて .Files.[file] <-
というかたちをしていた)
呼び出し元を辿っていくと以下の関数がある
- (Commands.SetFileContent: didOpen/didChangeのヘルパー)
- FSharpLspServer.TextDocumentDidOpen
- FSharpLspServer.TextDocumentDidChange
- Commands.EnsureProjectOptionsForFile
セマンティックトークンが要求されているファイルはすでにdidOpenされているはずだからそれは待機しなくていいはず
EnsureProjectOptionsForFile (以下EnsurePOFF) にデバッグログをいろいろとつけて事項してみた
冒頭で呼ばれているState.GetProjectOptionsがすでにNoneだった
プロジェクトのロードが完了していないのがまずそう (ステータスバーで Loading... ってでているやつ)
次はGetProjectOptionsがいつSomeを返すか知りたい
State.GetProjectOptionsの本体は別のプロジェクトにあるのでクローンしてみる:
src/Ionide.ProjInfo.ProjectSystem/ProjectSystem.fs
にあるProjectControllerクラスをみる
fileCheckOptionsからの読み取りなのでそれの書き込み箇所を探す
- _.SetProjectOptions → fileCheckOptions.AddOrUpdate
- _.LoadProject → loaderLoop → loadProjects → updateState → fileCheckOptions.[]
(note: WorkspaceLoadイベントというのがあって、これを待機すればよさそうな気もする)
FSACに戻って上記のメソッドの呼び出しを探す
ProjectController.SetProjectOptionsはEnsurePOFFから間接的に呼ばれている
"fsharp/project"
→ FSharpLspServer.FSharpProject
→ Commands.Project
→ ProjectController.LoadProject
IVFに戻ってリクエストの呼び出し元を探す
MSBuild.activate (src/Components/MSBuild.fs)
→ restoreProjectAsync
→ Project.load (Project.fs)
→ LanguageService.project
→ "fsharp/project"
(手動でrestoreProjectAsyncを行うためのコマンドもあるけど略)
内容を詳しくはみていないがおそらく起動時にプロジェクトをすべてロードするという工程だろう
EnsurePOFFに戻る
パラメータで与えられるのがファイルで、それがどのプロジェクトに属するかは分からない
だから単一のプロジェクトのロードを待つということはできないかもしれない
すべてのプロジェクトのロードを待つことになりそう
かなり保守的な待機なので、そのころにはGetProjectOptionsがSomeを返すようになっているはず
次はEnsurePOFFがプロジェクトのロードを待機できるかどうか、どうやって待機するか
プロジェクトのロード中にEnsurePOFFが呼ばれたらデッドロックになるので、それの呼び出し箇所を探る
EnsurePOFFは _.CheckFile と同様の名前のメソッドから呼ばれていて、それらはdidOpen/didChangeから呼ばれているようにみえる
おそらくロード中には呼ばれないはず
ProjectController.WorkspaceReadyというイベントがあるので、EnsurePOFFの冒頭でこれを待機するようにした
試したかぎりではセマンティックトークンが出るようになって 解決した気がする
PRの準備をする
EDIT: 大きいプロジェクトだとセマンティックトークンが出ない (レスポンスが空で解決される) ことがまだあった。WorkspaceReadyはハッシュマップ等への書き込みより先行してしまうのかもしれない。"fsharp/workspaceLoad" を待機したほうがよいのかもしれない
ワークスペースの読み込み (fsharp/workspaceLoad) の完了を待ってみることにした
// Commandsクラスのフィールド
let mutable isWorkspaceReady = false
let workspaceReady = Event<unit>()
// _.WorkspaceLoadの終了時
if not isWorkspaceReady then
isWorkspaceReady <- true
workspaceReady.Trigger()
// EnsurePOFF
if not isWorkspaceReady then
do! Async.AwaitEvent(workspaceReady.Publish)
これによりEnsurePOFFはSomeを返すようになったが、セマンティックハイライトの問題はまだ解決していない
推測:
ParseAndCheckFileInProjectが失敗しているからかもしれない
System.Array
アセンブリがないという謎のエラーが出ている。このissueも参照:
System.Array Not Found Error #1006
ParseAndCheckFileInProjectが成功 (complete successfully) した後からGetRecentがsomeを返すようになっている
上記のエラーを解決したいが難しそう
次は上記のエラーの際にリトライを入れてみることと、プロジェクトの検査が少なくとも1回成功するまで待つ、というのを試す
System.Array
is requiredに関してはバックグラウンドにいるdotnetプロセスをキルすると治るような気がしてきた (詳細は不明。dotnet buildは成功するのがよく分からない)
課題: System.Array
is requiredの影響とは別にdiagnosticsがまったく更新されなくなることがある (原因は不明)
状況が分からないのでとりあえずいろいろ試すフェイズ
リーダーライターロックを試そうとしている
リクエストを読み取りクエリ (Hoverとか) と読み書きクエリ (didOpenとか) に分類して、読み書きクエリは排他的に実行し、読み取りクエリ同士は並行に実行するという感じ
読み取りクエリはそれ以前に発行された読み書きクエリによる変更操作の完了を待つことになるし、後続の読み書きクエリによる変更の影響を受けないのでより安定すると思う
(具体的に何が解決するというのもいまいち分からない)
読み書きクエリが変更されたファイルの検査の要求など
うまくいっていない。おそらく実装上の問題で、なぜかリクエストがキャンセルされたことをフックできない
Gitの差分を開くとおかしくなるやつの修正案をPRとして出したらマージしてもらえた: Restrict document filter schemes to file and untitled
doucmentHighlightとかがエラーレスポンスを返すやつ、VSCodeだとログにノイズが乗るだけだけどNeoVimだと深刻らしい (見てないけどいちいちメッセージとかが出るんだろうか)
FSACを使ってみようとしたら "System.Runtime" にアクセスできないみたいなエラーが出てプロジェクトのロードができなかった
おそらく自分の開発マシンに.NET 7をインストールしていたからだった
次の手順で直った:
- FSACの
global.json
に書かれているバージョンを7.0.200
に上げる - FSACを
dotnet build -p:BuildNet7:true
でビルド- (FSACのプロジェクト設定でターゲットフレームワークが
BuildNet7
フラグがtrueのときだけnet7.0
になるというふうになっているため)
- (FSACのプロジェクト設定でターゲットフレームワークが
-
src/FsAutoComplete/bin/Debug/net7.0/fsautocomplete.dll
を使う (net7.0を使うということ)
なおアナライズ対象のプロジェクトはnet6.0をターゲットにしている。その場合もSDKは7.0のやつが使われているんだろうか? .NETの事情はよく分かっていない
PRを送る前にissueを立てて変更の方向性をプロジェクトメンバーに確認するべきか
IVFのCONTRIBUTINGのPull Requestsのところ に次のようにある:
If you have any large pull request in mind (e.g. implementing features, refactoring code, etc), please ask first otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project.
(機能の実装やリファクタリングなど大きいPRを送るつもりなら先にたずねてください、そうでないとマージされない作業に多くの時間を費やすことになるリスクがあります)
(FSACにはCONTRIBUTING自体なさそう)
(手続きが過度に冗長だと思われたらどうしようという心配、聞いて反応がなかったらどうしようという心配)
"Too many errors..." のissue、複数の問題が複合していそうだけど、とdocumentHighlightの件に関しては対処できそうだからやってみようかな
- "documentHighlight"で過去のissueをみる
- FSAC: 他に関係ありそうなものはなし
- IVFもなし
原因の推測:
- "No symbol information" で検索したら数箇所出てきた
- documentHighlightから呼ばれているのは arseAndCheckResults の
TryGetSymbolUse
っぽい - Commandsの
SymbolUse
経由でLSPサーバーのTextDocumentDocumentHighlight
から呼ばれている - LSPサーバーでErrorから
LspResult.internalError
になっている
- documentHighlightから呼ばれているのは arseAndCheckResults の
再現:
-
let main _ =...
の_
の位置にカーソルを置くと出る
前に開いたissueに反応がなかったのでとりあえずPRにした:
いったん小さい変更だけでPRを送ろうと思ったけど、メソッドのシグネチャを変えると型の整合性を保つためにかなり変更しないといけなさそうなのでやめた
F# のコンパイラガイドがあった: F# compiler guide
signatureHelp
が例外を投げるやつを調査している
- getSignatureHelpFor という関数に指定された位置からさかのぼってスペースでない文字を探すという処理があって、ここで例外が投げられている
- 前の位置をとる関数 (PrevPos) は指定位置の「前の位置」を返すが、これは「行の末尾」のような終端の位置を返すことがある。また、lineは1-basedだがcolumnは0-basedになっている
- 指定位置の文字を取得する関数 (GetCharUnsafe) にその位置が渡されるが、こっちの関数はline, columnともに1-basedを前提としていて、しかもその指す位置に文字があることを前提としているので、エラーになっている
Position型の不変条件(invariant)がどうなっているのかをみたところ Position (F# Compiler Guide) によると0-indexedか1-indexedかは場合によるらしい
プルリクを送ってマージしてもらえた. いま思うとこのテストケースだけで解決なのかは分からないけど