DAPを使ってmrubyスクリプトをデバッグする話

masahino123.hatenablog.com

の続き。 久しぶりにデバッガを動かしてみたら、意図した通りに動かず悩んだのだが、 既に過去に遭遇していた現象で、当然の挙動だった。 すっかり忘れてしまっていたのが情けなく、メモしておく。

前回の記事は、.rbファイルではなくバイナリ化したファイルをいかにデバッグするかというお話だった。 機能的には、.rbファイルもこのやりかたでデバッグできるようになっている(ハズ)。 具体的には次のように実行した場合のtest.rb部分のデバッグ

mruby test.rb

それ、mrdbでできるよ、なんだけど、まぁそれはそれで。

この場合、前回の記事で書いた方法ではブレークポイントが設定できない。 読み直したら前回の記事でも書いてあった。

ブレークポイントを指定する.rbファイルの判定が絶対パス指定のため、パスのマッピングが必要な場合があるかもしれない。

我ながら、なんのこっちゃよくわからんな。 ブレークポイントの設定は絶対パスで指定している。 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に関するメモのつづき。

masahino123.hatenablog.com

別のサーバだと、そもそも挙動が異なるという話。

なにか正解があるのか、実装依存なのか、手元の環境の問題なのか、設定によるものなのか、 分らないけど。

typescript-language-server

github.com

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ではなくinsertreplaceが使われている。 と思ったら、ClientCapabilitiesinsertReplaceSupporttrueとしていたからだった。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を置き換える形で、putcputsを入力することができる。

付加情報は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
                        }
                    }
                }
            }
        ]
    }
}

長いので一部のみ。

detailintとあるのは、戻り値か?

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
}

数が多いので、途中省略。

isIncompletetrueだった。

編集テキストはtextEditのみ。

documentationに詳しい情報。

sortTextには数字が入っている。

今日のまとめ

と、ここまで3つのサーバで試してみたが、それぞれの個性が表われている。 completion responseで必須なのはlabelだけなので、あとはあったりなかったり。 kindは3つともあったが。

clangdとpylsはlabelの内容に実際に補完する文字列以上の情報を入れているが、solargraphは挿入する文字列情報のみと見えた。

detailの内容は、それぞれバラバラのようで、使いづらい。

documentationの内容の詳細具合も違っているようなので、一律で扱うのはしんどそう。

世の中のIDEやらテキストエディタは結構大変なことやってるんだなぁという感想。

LSPのテキスト編集と同期についてもメモ

自作のLSP(Language Server Protocol)クライアントを作って遊んでいる。

github.com

補完とか整形とか幾らか動いていて、それなりに使えているのだが、 普段使っている言語以外の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 and SciTE

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のデバッグに挑戦する。

masahino123.hatenablog.com

やりたいのはmrubyで作った実行ファイルのデバッグスクリプト(.rb)やバイナリファイル(.mrb)ファイルのデバッグはmrdbでできるけど、 実行ファイルやmrbgemの部分をデバッグしたい。

nomitoryというものがあって、これでやっていることそのものなんだけど、DAPを使ってもやってみようということを考えた。

magazine.rubyist.net

どうやればよいかは、nomitoryの記事で書いてくれているので、その通りに進める。

やりたいこと

ユーザからは、元となる○○.rbの△行目にブレークポイントを設定したり、ステップ実行したりする。 それをC言語用のデバッガに変換してやりとりする。そのあいだのやりとりにはDAPを使う。

できたこと

つくったものその1

GitHub - masahino/mruby-debug

  • 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の場合

VSCodeでのmrubyデバッグ

その他の環境

DAPなので、呼び出す側で少し頑張れば、いろいろな環境で対応できるのではないか。

拙作のテキストエディタでの実行例が以下。

mrbmacs

できてないこと

  • 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に方針転換した。