🥌

ParinferのDenopsプラグインを作った

2022/12/09に公開

この記事は Vim Advent Calendar 2022 の12月09日分に向けた記事です。

TL;DR

  • Denops で Parinfer による Lisp コーディングをサポートするプラグイン作った

Parinfer とは

Parinfer は Lisp のコーディングを支援してくれるツールです。

https://github.com/parinfer/parinfer.js

Lisp と言えば大量の括弧からその見た目だけで敬遠されることで有名(?)ですが、
同時に Lisper には閉じ括弧が見えない/閉じ括弧は意識する必要がないという意見がでることでも有名(?)です。

その秘密は Paredit や Parinfer などを使った Structural Editing です。

Structural Editing では括弧の対応は自動的に(強制的に)保たれるため、括弧の数などを意識する必要はなくなり Lisp のコードを安全に素早く書くことが可能となります。
そのため Lisper にとって Structural Editing は無くてはならない存在といっても過言ではありません。

その他、Parinfer の詳細については同時投稿した Clojure Advent Calendar の記事の方にまとめているので興味があればそちらの参照してください。

https://zenn.dev/uochan/articles/2022-12-09-road-to-parinfer

動機

Vim/Neovim 向けの Parinfer プラグインについては parinfer-rust がすでに存在しています。
というか現状では Vim/Neovim で Parinfer の支援を受けようと思ったらこれしか選択肢がない状況でした。

https://github.com/eraserhd/parinfer-rust

私もこの parinfer-rust をいくつか不満な点がありつつも代替えがないために使っていました。
具体的な不満点は以下のようなものです。

  • Vim を起動したままでずっと使っていると動作しなくなることがある
    • Vim の再起動で直りはするものの思考を中断させられるのが苦痛
  • 操作によっては smart モードではなく paren モードに切り替える必要があり面倒
    • e.g. Clojure における #_ によるコメントアウト
  • 都度バッファ全体を対象にチェックされるので、今変更していないところも強制的に適用される
    • またコード行数が大きいと変に適用されることが稀にある

これらの不満点について改善したい気持ちはありつつ、自由に使える時間があまり多くない状況からだましだまし使う日々を過ごしていたのですが Deno の npm サポートによって状況が一変しました。

Deno の npm サポート

Deno 1.28 から npm サポートが Stable になりました。
Parinfer の本家実装は JS で、当然 npm に登録されています。
ということは Denops を使って Parinfer.js が使えるということです。

これであれば少ない自由時間でも何とか個人的な不満点を解消した Parinfer サポートが実現できそうということで作ったのが dps-parinfer です。

dps-parinfer

dps-parinfer は前述の通り Denops による Vim/Neovim 向けのプラグインです。

https://github.com/liquidz/dps-parinfer

Rust 実装の parinfer-rust と比べると遅い部分もあるかもしれませんが、
parinfer-rust と異なり Vim script 上での処理は最小限にてメインは TypeScript で書いているので
dps-parinfer の方が速い部分もあるはずです。(比較はしてません)

ドッグフーディング中ではありますが業務でコードを書いている限りでは特別遅さは実感できていません。

ただ繰り返しにはなりますが、まだドッグフーディング中でおかしな挙動を見つけ次第修正している状況なので安定感はまだまだ parinfer-rust の方が上なことはご承知おきください。

実装における苦労話

npm 上の Parinfer.js を Denops から使っているだけなので、動かすだけであれば特に難しいことはなかったのですが Parinfer.js に期待通りの結果を出力させるために渡す情報をどう用意するかがこのプラグインにおける肝でした。

一番重要なのは編集された際の 差分 の情報です。

具体的には何行目/何列目に X という文字が追加された もしくは Y という文字が削除されたというリストの情報です。
これがないと Parinfer.js ではインデントの情報のみから括弧のバランスを取るだけなので期待した結果が返らなくなってしまいます。

例えば以下のような場合です。
ここでは foonew-name に変更したいとします。

(let [foo (str "hello"
               "world")]
  (do :something))

変更後の状態は以下の通りです。
ここでは (str"world" のインデントレベルが揃ってしまいます。

(let [new-name (str "hello"
               "world")]
  (do :something))

このとき Parinfer.js では差分の情報がないとインデントのみを参照するので以下ようなコードを結果として返してしまいます。

(let [new-name (str "hello")
               "world"]
  (do :something))

これでは元々のコードから括弧の対応がずれた状況になってしまうので、括弧の対応を気にしなくて済むように Parinfer を使うという目的から言うと本末転倒です。
しかし差分情報を適切に渡してあげれば以下のような結果になります。

(let [new-name (str "hello"
                    "world")]
  (do :something))

Parinfer のプラグインとしてはこの差分をどう取るかが一番重要なところで、 dps-parinfer でもそこはまだ試行錯誤中です。
parinfer-rust ではバッファ全体を window local な変数に保存しておいて、それと編集後のバッファ全体の内容と比較していますが、バッファ内のコード量が多いと無駄が多くパフォーマンスも悪くなってしまいます。
かといってカーソル配下の行(もしくはフォーム)だけを抜き出して部分適用しようとしても、その外側のフォームに影響のある編集だった場合に適切に結果が反映されません。

(do)
(str "foo") ;; この行の先頭にスペースをいれたら (do) の中に入って欲しいので
            ;; 差分がここだけでも Parinfer.js には (do) も含めたコードを渡したい

(str "foo"  ;; この行の先頭に `(` を入れたら `"bar")` の後に `)` が入って欲しいので
     "bar") ;; 差分が1行目だけでも Parinfer.js には `"bar")` も含めたコードを渡したい

最終的な目標は必要最小限の差分だけを使って必要最低限の処理で Parinfer の適用結果を反映させることですが、それはおいおい実現できたらなと思っています。

最後に

私は普段業務だけでなく OSS 活動としても Clojure のコードを書いていますが、見た目だけで敬遠されてしまうのは勿体ないくらい Lisp は魅力に溢れているので、
こういった括弧を気にする必要がなくなる Structural Editing によって Lisp 試してみようかなという人が増えたら何よりです。

Discussion