DAPを使ってmrubyスクリプトをデバッグする話
の続き。 久しぶりにデバッガを動かしてみたら、意図した通りに動かず悩んだのだが、 既に過去に遭遇していた現象で、当然の挙動だった。 すっかり忘れてしまっていたのが情けなく、メモしておく。
前回の記事は、.rbファイルではなくバイナリ化したファイルをいかにデバッグするかというお話だった。 機能的には、.rbファイルもこのやりかたでデバッグできるようになっている(ハズ)。 具体的には次のように実行した場合のtest.rb部分のデバッグ。
mruby test.rb
それ、mrdbでできるよ、なんだけど、まぁそれはそれで。
この場合、前回の記事で書いた方法ではブレークポイントが設定できない。 読み直したら前回の記事でも書いてあった。
我ながら、なんのこっちゃよくわからんな。
ブレークポイントの設定は絶対パスで指定している。
mrubyで実行中のファイル名とブレークポイント設定したファイル名を比較して、(行番号も含め)一致したら実行停止しているけど、
mruby test.rb
と実行した場合、mrubyでのファイル名はtest.rb
なので、単純に文字列比較しても一致しない。
mruby /foo/bar/test.rb
などと絶対パス指定で起動していれば、一致するという話。
絶対パス指定じゃなくてもマッチさせる方法あるかな? 文字列比較じゃなく、文字列を含むかどうかで判定するとか?
NASの容量拡張の検討
「NASのボリュームがほぼ一杯です」という警告を受けている。 いつから出ているのか、覚えていない。確実に1年以上は経っているのではないだろうか。
「ディスクを追加するか、大きなものに交換するか」を迫られている。
他の選択肢としては「空き容量を増やす」かな。
NASの利用方法のうち、容量圧迫に直結しているのが、TimeMachineのディスクとしての利用。 本当は、TimeMachine用のユーザーを作るなりなんなりして、クォータとかで使用する上限を設定するという方法を取りたいのだが、何故かそれだと上手くバックアップされず、上限設定を諦めている。あ、これも選択肢か。
ディスクを追加するとしたら、外付けHDDを買ってNASにUSB接続が簡単そう。そちらをTimeMachine用のボリュームにすれば良いんじゃなかろうか。 であれば、RAIDを組まず、HDD1つで、ほどほどの容量で済みそう。 問題は、電源が必要になり、単純にHDDが増え、置き場所も必要になりというところ。 また、以前余ったHDDをNASの外付けHDDとして使っていたとき、接続エラーが頻発して使いものにならなかった。そのときは、外付けHDD側の問題と片付けたが、同じようなことが再現しないとも限らない。アクセス速度の問題もでるかもしれない。
HDDの交換は、最も単純な方法だが、最もコストがかかると思われる。 今NASには2台のHDDを入れている。交換するとしたら、どのぐらいの容量UPが適切か? TimeMachineは結局あるだけ使いきるだろう。 交換したとしたら、古いHDDの次の利用方法を考えたくなる。外付けHDDにする? そしたら、最初から外付けHDD変えば良いではないか。 1つはTV録画用に回すか?
空き容量を増やすというか、もうクラウドに置くようにするのが良いんだろとは思う。 ただ、保存先を考えるのが面倒で面倒で。置き場所が1つ決まれば良いんだけど、どうなんだろうか。 TimeMachineも、それぞれクラウドにデータ置いてればいらないよね?
LSPのcompletionのメモ その2
この前のLSPのcompletionに関するメモのつづき。
別のサーバだと、そもそも挙動が異なるという話。
なにか正解があるのか、実装依存なのか、手元の環境の問題なのか、設定によるものなのか、 分らないけど。
typescript-language-server
3.3.1というバージョン。
1エントリ分を以下に。新規ファイルにp
とだけ入力したときの最初の項目。
{ "label": "__dirname", "kind": 6, "sortText": "15", "data": { "file": "/Users/masahino/Program/mrbmacs/mruby/test.js", "line": 1, "offset": 2, "entryNames": [ "__dirname" ] }, "textEdit": { "newText": "__dirname", "insert": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "replace": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } } } }
入力したp
とは関係なさそうな候補が入っていた。
回答内容の形式は、textEdit
による編集で、detail
とかは無い。
全部で915個の補完候補が返ってきた。多すぎ。
続けて、pr
と入力を進めても同じ候補が返ってきたので、どうやらそういう挙動らしい。
前回試したいくつかのサーバだと、そこまでに入力した文字に関係する候補が返ってきたのだが・・・
なにか、設定やオプションで挙動を変えられるのかと、いろいろ試してみたものの、変わらず。
これは、クライアント側で絞れということなのだろうか。
rust-analyer
rust-analyzer 0.0.0 (b99d5eb97 2023-03-26)
{ "label": "self::", "kind": 14, "deprecated": false, "preselect": true, "sortText": "ffffffef", "filterText": "self::", "textEdit": { "newText": "self::", "insert": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 1 } }, "replace": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 1 } } }, "additionalTextEdits": [] }
deprecated
とかpreselect
とかfilterText
とか情報が多い。
textEdit
の中身が、range
ではなくinsert
とreplace
が使われている。
と思ったら、ClientCapabilities
のinsertReplaceSupport
をtrue
としていたからだった。false
にしてみると、range
で指定されてきた。
別の項目にはdocumentation
もあった。
{ "label": "Ordering", "kind": 13, "documentation": { "kind": "markdown", "value": "An `Ordering` is the result of a comparison between two values.\n\n# Examples\n\n```rust\nuse std::cmp::Ordering;\n\nlet result = 1.cmp(&2);\nassert_eq!(Ordering::Less, result);\n\nlet result = 1.cmp(&1);\nassert_eq!(Ordering::Equal, result);\n\nlet result = 2.cmp(&1);\nassert_eq!(Ordering::Greater, result);\n```" }, "deprecated": false, "preselect": true, "sortText": "ffffffef", "filterText": "Ordering", "textEdit": { "newText": "Ordering", "insert": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 1 } }, "replace": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 1 } } }, "additionalTextEdits": [] }
設定やオプションはまだ見ていないが、こちらもクライアント側で候補を絞り込んで表示しないと駄目なのかな。
LSPのcompletionメモ
LSPを使ううえで、最も嬉しいことのひとつに、補完機能があるだろう。 LSPをサポートした各種IDEやテキストエディタのサンプル画面や動画では、補完機能を利用してサクサクと入力していく例が示されている。
ただ、実際のLSPサーバから送られる補完情報(Completion Response)は、ややこしいという感想。 現時点での各種サーバのレスポンスをメモってみる。
Solargraph
rubyのLSPサーバのSolargraph。個人的に一番使っている。 Rubocopによるメッセージ(Diagnostics)をがしがし送ってくる。 formattingリクエストを出すと、整形後のテキスト全部を一気に送り返してくる。 あとは、onTypeFormatting機能があればなぁ。
0.48.0というバージョンでの動作。
put
という文字を入力したときのcompletion response。
{ "jsonrpc": "2.0", "id": 9, "result": { "isIncomplete": false, "items": [ { "label": "putc", "kind": 2, "detail": "(ch)", "data": { "path": "Kernel#putc", "return_type": "undefined", "location": null, "deprecated": false, "uri": "file:///Users/masahino/Program/mrbmacs/mruby/test.rb" }, "textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, "newText": "putc" }, "sortText": "0000putc" }, { "label": "puts", "kind": 2, "detail": "(*args)", "data": { "path": "Kernel#puts", "return_type": "undefined", "location": { "filename": "/Users/masahino/Program/mrbmacs/mruby/mrbgems/mruby-io/mrblib/kernel.rb", "range": { "start": { "line": 19, "character": 2 }, "end": { "line": 21, "character": 5 } } }, "deprecated": false, "uri": "file:///Users/masahino/Program/mrbmacs/mruby/test.rb" }, "textEdit": { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 9 } }, "newText": "puts" }, "sortText": "0000puts" } ] } }
label
は必須項目。
detail
には、この場合引数の情報を入れているのかな?
選択したときのテキストはtextEdit
で指定している。
textEdit
で送ってもらえると、入力途中の文字も含めて編集できるのでありがたい。
この場合は、すでに1文字put
を入力しているが、そのput
を置き換える形で、putc
やputs
を入力することができる。
付加情報はdata
で送られる。
sortText
は補完候補に出すソート順だが、0000
を文字列の前に付けているようだ。
clangd
次はclangd。C/C++用のLSP。機能豊富なイメージ。onTypeFormattingも対応。
バージョンは、15.0.7。
こちらもput
という文字を入力したときのレスポンス。
{ "id": 5, "jsonrpc": "2.0", "result": { "isIncomplete": false, "items": [ { "documentation": { "kind": "plaintext", "value": "From <stdio.h>" }, "filterText": "putc_unlocked", "insertText": "putc_unlocked", "insertTextFormat": 1, "kind": 1, "label": " putc_unlocked(x, fp)", "score": 1.119854211807251, "sortText": "4070a89eputc_unlocked", "textEdit": { "newText": "putc_unlocked", "range": { "end": { "character": 5, "line": 3 }, "start": { "character": 2, "line": 3 } } } }, { "detail": "int", "documentation": { "kind": "plaintext", "value": "From <stdio.h>" }, "filterText": "putc", "insertText": "putc", "insertTextFormat": 1, "kind": 3, "label": " putc(int, FILE *)", "score": 0.5933540463447571, "sortText": "40e819f3putc", "textEdit": { "newText": "putc", "range": { "end": { "character": 5, "line": 3 }, "start": { "character": 2, "line": 3 } } } }, { "detail": "int", "documentation": { "kind": "plaintext", "value": "From <stdio.h>" }, "filterText": "puts", "insertText": "puts", "insertTextFormat": 1, "kind": 3, "label": " puts(const char *)", "score": 0.5933540463447571, "sortText": "40e819f3puts", "textEdit": { "newText": "puts", "range": { "end": { "character": 5, "line": 3 }, "start": { "character": 2, "line": 3 } } } } ] } }
長いので一部のみ。
detail
にint
とあるのは、戻り値か?
textEdit
もあるが、さらにinsertText
も付いている。
付加情報はdocumentation
にあるようだ。
sortText
にはなにやら個別の数値が16進数で付いている。
そのほかにscore
なるものもある。
pyls
python用のpyls。バージョンは0.36.2。
print
まで入力した。
{ "jsonrpc": "2.0", "id": 14, "result": { "isIncomplete": false, "items": [ { "label": "print(values, sep, end, file, flush)", "kind": 3, "detail": "builtins", "documentation": "print(*values: object, sep: Optional[Text]=..., end: Optional[Text]=..., file: Optional[_Writer]=..., flush: bool=...) -> None\n\nprint(value, ..., sep=' ', end='\\n', file=sys.stdout, flush=False)\n\nPrints the values to a stream, or to sys.stdout by default.\nOptional keyword arguments:\nfile: a file-like object (stream); defaults to the current sys.stdout.\nsep: string inserted between values, default a space.\nend: string appended after the last value, default a newline.\nflush: whether to forcibly flush the stream.", "sortText": "aprint", "insertText": "print" } ] } }
textEdit
は無い。なので、選択された場合には、insertText
を挿入することになる。
この場合、既に入力済み文字列の扱いが面倒。
detail
にはbuiltins
とある。
付加情報はdocumentation
にたっぷりと。
sortText
は挿入する文字列の先頭にa
を付けている?
gopls
{ "jsonrpc": "2.0", "result": { "isIncomplete": true, "items": [ { "label": "print", "kind": 3, "detail": "func(args ...Type)", "preselect": true, "sortText": "00000", "filterText": "print", "insertTextFormat": 1, "textEdit": { "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 5 } }, "newText": "print" } }, { "label": "println", "kind": 3, "detail": "func(args ...Type)", "sortText": "00001", "filterText": "println", "insertTextFormat": 1, "textEdit": { "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 5 } }, "newText": "println" } }, { "label": "fmt.Print", "kind": 3, "detail": "func(a ...any) (n int, err error)", "documentation": "Print formats using the default formats for its operands and writes to standard output.\nSpaces are added between operands when neither is a string.\nIt returns the number of bytes written and any write error encountered.\n", "sortText": "00004", "filterText": "fmt.Print", "insertTextFormat": 1, "textEdit": { "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 5 } }, "newText": "fmt.Print" } } ] }, "id": 6 }
数が多いので、途中省略。
isIncomplete
がtrue
だった。
編集テキストはtextEdit
のみ。
documentation
に詳しい情報。
sortText
には数字が入っている。
今日のまとめ
と、ここまで3つのサーバで試してみたが、それぞれの個性が表われている。
completion responseで必須なのはlabel
だけなので、あとはあったりなかったり。
kind
は3つともあったが。
clangdとpylsはlabel
の内容に実際に補完する文字列以上の情報を入れているが、solargraphは挿入する文字列情報のみと見えた。
detail
の内容は、それぞれバラバラのようで、使いづらい。
documentation
の内容の詳細具合も違っているようなので、一律で扱うのはしんどそう。
LSPのテキスト編集と同期についてもメモ
自作のLSP(Language Server Protocol)クライアントを作って遊んでいる。
補完とか整形とか幾らか動いていて、それなりに使えているのだが、 普段使っている言語以外のLSPサーバを動かすと、 途端に怪しい挙動に遭遇する。
今回はテキストの編集(textEdit)と同期(textDocumentSync)に悩んだ。 まだ、解決しきった訳ではないけど、ここまでのところをメモとして残しておく。
同期をどう実装するか?
エディタやIDEでの編集状況は、LSPクライアントからサーバに送られ、お互いテキストの同期をとる。 同期の方法(TextDocumentSyncKind)として、None,Full,Incrementalの3つが定義されている。
多くのサーバはIncrementalを指定しているようだが、面倒なのでFullでやってみたら、 それで動くLSPサーバがあったので、ラッキーと思って、済ませていた。
Fullだと変更の通知(didChange)のたびに全テキストを送りつけることになる(のだろう)。 サイズが大きいと大変そうだが、同期のズレはなさそう。
ただ、Incrementalしか対応しないLSPサーバもあるようで(なんだったか忘れた)、 しょうがないのでIncrementalも実装しようとした。
最初思いついたのは、Incrementalとはいえ、全部送るという雑な方法。 Incrementalでは、変更のあった箇所(range)と新しいテキスト(newText)を送る。 同時に複数送ることもできる。 ファイル全体をrangeで指定して、newTextで全部送れば良いじゃん、と。 細かな問題は、変更前の状態を覚えておく必要があること。
ただ、流石に格好悪い気がしたので、地道に差分だけ送る方法を検討した。 LSPクライアントを実装している自作テキストエディタは、テキストエディタフレームワークのScintillaを使っている。
Scintillaの持っている機能で編集があったら通知(SCN_MODIFIED)してくれて、 通知には変更のあった場所、変更文字列と長さが含まれているので、それをそのままdidChangeで送ればよさそうだった。 一箇所の変更であれば、これで良かったのだが、複数同時変更で困ることになる。 テキストエディタの機能としては、一括置換など。
複数の変更通知(例えばC1とC2、変更箇所はC1<C2)を順次didChangeで送ると、変更C1の通知でテキストの状態が変り、 C2で送るrangeと最新の位置にズレが生じてしまう。 これは、C1とC2を個別のdidChangeで送っても、1回のdidChangeにC1とC2を含めても同じ。 ズレなくするためには、単純にはファイルの後方の変更から通知すれば良い。 C2を先に送ってからC1を送るということ。
didChangeの通知タイミングも問題で、複数箇所の同時編集後に、didChangeで通知しようとすると、 ScintillaのSCN_MODIFIEDの通知に含まれる場所は、イベント発生時の先頭からの位置(position)なので、 複数変更があると、イベントが発生した時点での位置と、最新の状態の位置にズレが生じている可能性がある。
なので、複数箇所同時変更であっても、個々の編集の単位でdidChangeを送るのが安全そう(に思われる)。 なんか良い方法ないものか?
サーバからのテキスト編集
サーバからもtextEditという形で変更が送られてくるので、クライアント側でも編集の対応を行うことになる。 サーバからの変更は、クライアントからの要求への応答という形になる。 整形(formatting)、名称変更(rename)、補完(completion)など。
いずれも複数箇所同時変更があるので、変更順に注意が必要。 これも、ファイルの後ろから変更すれば基本的にはOKなハズ。
そして、この変更に対してもdidChangeで同期する必要があるのだが、これも後ろから個々に対応していれば、 問題なく同期できるだろう(と思われる)。
ただ、ここで補完(completion)が曲者であった。
completionのadditionalTextEditsの扱い
まず、completionの場合、サーバから送られてくるCompletionItemの中身がLSPサーバによってバラバラ。 クライアントの選択によって挿入されるテキストの指定方法に複数あるし、 良くあるパターンとして途中まで入力して、続きを補完するということなので、そこも対処が必要。
例えば、prまで入力して、補完候補からprintfを選択した場合、実際に挿入するのはintfである、とか。
その次に悩ましいのが、additionalTextEditsの扱い。 これは例えば、補完する関数名や定数名が定義されているヘッダをincludeするとか、そういうパターン。
便利だけど、やっかい。あったりなかったり(あんまりない)だし。
あと、完全にテキストエディタとしての実装の問題だが、編集後のカーソル位置はどこが良いか?とか面倒。
今後にむけて
やってみると、以外と面倒なテキストの同期。 サーバ側で認識しているファイルの状態を知る方法とか、 なにかのタイミングで完全同期しなおす方法がないかなぁ・・・。
Scintilla 5.3.0
Scintillaのバージョンが5.3.0に上がっていた。
https://www.scintilla.org/index.html
主な変更はchange history機能の追加。 確かに、IDEには付いている機能だものね。
mrbmacs-termbox(https://github.com/masahino/mruby-bin-mrbmacs-termbox/)で使おうと思ったので、 scintilla-termbox(https://github.com/masahino/scintilla-termbox/)に 新たなSC_MARK_BARを実装。 色は自分で設定してあげないと駄目みたいだ。
そういえばと、scinterm(https://orbitalquark.github.io/scinterm/)を見にいったらバージョンが4.0に変わっていた。
scintilla-termboxはscintermを参考に作っているので、ときどき見てるんだけど、 scinterm 4.0からはファイル構成から変わっていた。どうするか・・・
scintilla-termboxではfoldingを表示するmarginのバックグラウンドが指定できないという問題があった。 もとの処理では、AllocatePixMapで作ったpixmapにパターン?を指定して、それを表示している感じだったが、 scintermではAllocatePixMap関連の処理はスキップしており、foldingのmargin塗り潰し処理で呼ばれる FillRectangle(PRectangle rc, Surface &surfacePattern) では黒色を決めうちで塗り潰していた。
scintilla-termboxでは、今回、パターンを用意する際に指定される色を保存しておき、 FillRectangleでsurfacePatternを指定された際には、保存しておいた色で塗り潰す処理を加えてみた。
動いてそうなので、とりあえずこれで。
DAPを使ってmrubyで作ったバイナリをデバッグする話
DAP(Debug Adapter Protocol)のクライアントを作ったので、これを使って今度はmrubyのデバッグに挑戦する。
やりたいのはmrubyで作った実行ファイルのデバッグ。 スクリプト(.rb)やバイナリファイル(.mrb)ファイルのデバッグはmrdbでできるけど、 実行ファイルやmrbgemの部分をデバッグしたい。
nomitoryというものがあって、これでやっていることそのものなんだけど、DAPを使ってもやってみようということを考えた。
どうやればよいかは、nomitoryの記事で書いてくれているので、その通りに進める。
やりたいこと
ユーザからは、元となる○○.rbの△行目にブレークポイントを設定したり、ステップ実行したりする。 それをC言語用のデバッガに変換してやりとりする。そのあいだのやりとりにはDAPを使う。
できたこと
つくったものその1
- code_fetch_hookから呼び出す関数で、実行しているrubyファイルと行数を保存。
- 変数を返す関数を用意。
つくったものその2
GitHub - masahino/mruby-bin-dap-proxy: DAP(Debug Adapter Protocol) proxy for debugging mruby
- IDEやエディタからのリクエストが.rbに関するものなら、対応するC言語に変換してデバッガに渡す。
- mruby-debugでブレイクポイント設定用の関数を用意しておき、そこに対してsetFunctionBreakpoints。conditionでファイル名と行数を指定。
- C言語のデバッガはlldb限定。lldbとのやりとりもDAPを使うので、使うのはlldb-vscodeコマンド。
- デバッガからの応答やイベントも.rbに関するものと判断したら、対応するrubyファイルの情報に変換してIDEやエディタに戻す。
IDEやエディタ側の準備
VScodeの場合
- ubuntuでしか試せていない
- それ用のextensionを用意する。
- デバッグしたいプログラムにmruby-debugを組込む。
- VScodeで.rbファイルにブレークポイントを設定。
- VScodeでlaunch.jsonを作成し、launchかattach。
その他の環境
DAPなので、呼び出す側で少し頑張れば、いろいろな環境で対応できるのではないか。
拙作のテキストエディタでの実行例が以下。
できてないこと
- stepやnextの実行は、ブレークポイントに変換して実行しているため、stopイベントのreasonがbreakpointとなっている。本来はstepの筈。lldbからのeventの中身を見て、stepに書き換えるべきだと思うが、lldb-vscodeからの全部breakpoint 1.1でイベントが来るので、breakpointの場合との区別がつかない。
- 行指定でしかブレークポイントを設定できない。method指定できない。
- 遅い。
- ブレークポイントを指定する.rbファイルの判定が絶対パス指定のため、パスのマッピングが必要な場合があるかもしれない。
- 想定外の動作でイロイロ落ちる。
- デバッグできるようにするための諸々の準備が大変。
- lldb-vscodeのみ対応。
- などなど
準備したけど使わなかったもの
当初は、mruby-gdbというmrbgemを使おうとした。 3.1.0でコンパイルできるようにしたものがこちら。 エラーが出ないようにしただけなので、修正が適切かどうかは不明。
変数の値をパースするのが面倒になったためmruby-debugに方針転換した。