Open30

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 等になっている)

ベインベイン

(ここはこのスクラップを作る前の話)

とりあえず目につくので "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の本体は別のプロジェクトにあるのでクローンしてみる:

Ionide.ProjInfo

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とか) に分類して、読み書きクエリは排他的に実行し、読み取りクエリ同士は並行に実行するという感じ
読み取りクエリはそれ以前に発行された読み書きクエリによる変更操作の完了を待つことになるし、後続の読み書きクエリによる変更の影響を受けないのでより安定すると思う
(具体的に何が解決するというのもいまいち分からない)
読み書きクエリが変更されたファイルの検査の要求など

うまくいっていない。おそらく実装上の問題で、なぜかリクエストがキャンセルされたことをフックできない

ベインベイン

FSACを使ってみようとしたら "System.Runtime" にアクセスできないみたいなエラーが出てプロジェクトのロードができなかった
おそらく自分の開発マシンに.NET 7をインストールしていたからだった
次の手順で直った:

  • FSACの global.json に書かれているバージョンを 7.0.200 に上げる
  • FSACを dotnet build -p:BuildNet7:true でビルド
    • (FSACのプロジェクト設定でターゲットフレームワークが BuildNet7 フラグがtrueのときだけ net7.0 になるというふうになっているため)
  • 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 になっている

再現:

  • let main _ =..._ の位置にカーソルを置くと出る
ベインベイン

signatureHelp が例外を投げるやつを調査している

  • getSignatureHelpFor という関数に指定された位置からさかのぼってスペースでない文字を探すという処理があって、ここで例外が投げられている
  • 前の位置をとる関数 (PrevPos) は指定位置の「前の位置」を返すが、これは「行の末尾」のような終端の位置を返すことがある。また、lineは1-basedだがcolumnは0-basedになっている
  • 指定位置の文字を取得する関数 (GetCharUnsafe) にその位置が渡されるが、こっちの関数はline, columnともに1-basedを前提としていて、しかもその指す位置に文字があることを前提としているので、エラーになっている

Position型の不変条件(invariant)がどうなっているのかをみたところ Position (F# Compiler Guide) によると0-indexedか1-indexedかは場合によるらしい