トップ 最新

ワタタツの日記!

2022 年 12 月 9 日 (金)

consult.el の非同期コマンドの仕組み

このエントリはEmacsのAdvent Calendar 2022の9日目用です。

以前の記事「はてなブックマーク用のconsultコマンドを作っている」で書いた consult-hatena-bookmark を改良するにあたり、今年夏から秋にかけて、(自分としては)かなり consult.el のコードを読みました。

そのコードリーディングの中で、consult.el が非同期な候補の読み込みをどうやっているのか、だいぶわかったのでご紹介します。

公式で非同期で読んでいる consult コマンドのまねをしたい

公式で用意されている consult コマンドの中で、候補を非同期で読んでいるタイプのものは次などがあります。

  • consult-grep
  • consult-ripgrep
  • consult-man
  • consult-find

コマンドを実行してからプロンプトを表示しながら、入力するたびに、検索的なことが非同期に実行されて、その結果を受けとりながら、インクリメンタルに候補を表示します。

はてなブックマークの検索でも、同様の非同期検索体験が実現できるととても快適になると思うので、どうしても非同期に読み込む方法を理解して実装したかったです。 (2021年の初期バージョンでは、同期的に検索して結果を表示していました)

これらの非同期な consult コマンドは全部 consult--async-command を使っていました。これが鍵です。

consult--async-command

consult.el は基本的に consult--read 関数に候補のリストを渡して使います。

静的な候補を絞り込みたい場合は、候補のリストをそのまま consult--read の第1引数に渡します。

一方、非同期に候補のリストがどんどん更新されるようにしたいときのために consult--async-command マクロが用意されています。

consult--async-command を使えば作れそう……と思ったのですが、これは、さらに consult--command-builder なる、(UNIX的な意味での)コマンドのビルダを渡して、またさらに consult--async-process で非同期に(そのコマンドの)プロセスを起動するという使い方をされています。

今使いたいはてなブックマークの検索はweb APIであって、ローカルのコマンドではないので、これではだめです。(前回の初期バージョンでは w3m コマンドを使ってブックマーク検索結果を取得していたので、この外部コマンドを使う方法でいけたかも知れませんが、もう w3m ではログインできなくなったのでweb APIにしました。) (UNIX的な意味での外部コマンドの結果を consult.el で絞り込みたい非同期 consult コマンドを作る場合は、そのまま consult--async-command を使えばできると思います。)

consult--async-command みたいなのの web API を叩く版を作る!

というわけで consult--async-command の中身のコードちゃんと読んで、外部コマンドを叩こうとしているところを、必要な処理に置き換えることにしました。

というわけでコードリーディングはまだまだ続きます。

つまり consult--async-command の中身です。ここが今回わしが 一番面白さを感じたところ です。

コードは https://github.com/minad/consult/blob/93091590b2a2029bcecce3fa355f8857a8775836/consult.el#L2169-L2184 です。

thread-first から始まって、(consult--async-sink)(consult--async-refresh-timer)、終わりの方では (consult--async-throttle)(consult--async-split) などと、consult--async-系の単独の関数がどんどん並んでいます。

thread-first マクロは、「前の結果(出力)を次の関数の第1引数に入れる」ことを繰り返して1本のスレッドのようにつなげていく書き方ができるマクロです。ちなみに thread は英語の単語で、糸という意味です。

「次の関数」は第1引数を省略した形で書いておきます。そこだけ見ると不完全なように思えますが、thread-first の中ではその書き方でいいわけです。オードリーの春日が「春日のここ、空いてますよ。」と言うような感じです。(「春日のここ」が第1引数ではなく、最終引数になったバージョンの thread-last マクロもあります。)

ヘルプに載っている例がわかりやすいです。

(thread-first
  5
  (+ 20)
  (/ 25)
  -
  (+ 40))

(+ (- (/ (+ 5 20) 25)) 40) と同じことだと書かれています。 最初の 5(+ 20) の第1引数に入力されます。第1引数は省略された形として評価されるので 20 は第1引数ではなく第2引数です。つまり春日のここは (+ 春日のここ 20) です。

(thread-first
  (foo)
  (bar a b)
  (baz c d e)
  (qux f)))

を図解してみます。

これがこの非同期のためにぴったりなんです!

まず (consult--async-sink) で非同期用のジェネレータ、いわゆる非同期 sink 関数を作って返します。

consult--async-sink のドキュメント文字列に書かれているように、これが返す sink 関数は、次のように引数 action を受け取って動くようになっています。

  • 'setup が引数の場合、セットアップして nil を返す。
  • 'destroy が引数の場合、状態を壊して(やり直す感じ)、nil を返す。
  • 'flush が引数の場合は、ためていた候補をまっさらにして (flush) nil を返す。
  • 'refresh が引数場合、(consultの)UIをリフレッシュして nil を返す。
  • nil が引数の場合、Return the list of candidates.
  • リストが引数に来たら、今ある候補のリストに追加してそのリストを返す。
  • 文字列が引数に来たら、現在のユーザが入力している文字列を更新して nil を返す。

この仕様に沿っている関数を、thread-first でどんどん次の関数に渡しながら、「ご所望」の関数になるようにちょっとずついじっていき ます。面白いです。

例えば consult--async-refresh-immediate は、候補が追加されたらすぐにUIをリフレッシュするように、いじります。 consult--async-throttle は、スロットルします。web APIを使うときなんかは特にそうですが、同じ入力文字列(プロンプトで入力中の文字列です)が入っているときに、何度も同じ検索を流したくないです。そういうときに使えます。 consult--async-split は後述しますが、‘#’ 記号を挟んで検索絞り込みを外とローカルで分けてくれる超便利関数変更関数です。

このように consult.el では、自分のちょっとの仕事だけをうまくやる小さい関数を組み合わせて全体の機能を作り上げていく書き方がされていることがわかりました。UNIX哲学〜ですね。

今回は、 consult-hatena-bookmark--search-generator という関数を作ってみました。 https://github.com/Nyoho/consult-hatena-bookmark/blob/b85484b11705ebd896878d3ac7fdb12bc8c9637a/consult-hatena-bookmark.el#L205-L211

コードがかなり consult--async-command と似ていますが、外部コマンドを呼ぶ処理ではなく (consult-hatena-bookmark---async-search) が真ん中あたりに挟まっています。

ここで、必要な引数が来たときだけ、特に、文字列が来たときには、実際のはてなブックマーク検索をする consult-hatena-bookmark--search-all 関数が走るようにしました。

さらに工夫したところ、工夫できなかったところ

さらに今回はその中で async/await, promise が使える async-await を使っています。web API の1回分の問い合わせの結果を await して受け取ることに使いました。(追記: async-awaitパッケージという名前ですが、今回のconsult.el自体の非同期の仕組みのことではありません。)

さらに、念願のページネーション的な取得も実装しました。つまり最初は20件だけ取得しておいて、検索結果が20件より多い場合は、オフセットをずらしながら何度もAPIを発行して全件を取得するというものです。

このままでは、途中で検索ワードを変更した場合や、consult-hatena-bookmark コマンドを終了/キャンセルしたときに、全件取得のルーチンが動きっぱなしなってしまうので、 consult-hatena-bookmark--stopping という変数を作って、これが nil のときには検索を止めるようにしました。グローバル変数っぽくてかっこわるい実装なのかも知れませんが、どうしてもいい方法が思いつかなかったので、この変数を用いることにしました。いい方法がありましたらお教え下さい。または pull request をいただければ嬉しいです。

consult での絞り込みの好きなところ

ここで consult での絞り込みの好きなところをご紹介します。 consult--async-split による機能です。

それは ‘#’ 記号です。

M-x consult-hatena-bookmark してプロンプトが出てくると、キーワードを打ちます。emacs とか。ここでマイはてなブックマークの検索の外部通信が走ります。

それに続けて ‘#’ 記号を1発入れて、さらに文字列を打ち込みます。29 とか。合わせて「emacs#29」が打ち込まれたところです。

そうすると、

  • ‘#’ の前は、web APIで取得する外部のデータへの問い合わせ
  • ‘#’ の後は、前者で得られたデータを絞り込むための文字列に!

これです! これがすごくいいです。

APIで検索する文字列(この段階で自分のブックマークから絞り込まれています)、さらにそれをローカルで絞り込むという、2段階の絞り込みが1行でできてしまいます!

ここ好き

コードリーディングの感想

Lisp万年初心者としてはかなり頑張ってコードリーディングし(て自分のコードを書き)ました。感想としましては、非同期処理のために、何か特別な別プロセスなどがあるわけではなくて、とにかく関数を関数に食わせてどんどん関数をいじって関数だけでなんとかしようとしているところが、とても関数型プログラミングっぽいなあと思ったりしてグッときました。以前の自分よりはちょっとだけLispが読めるようになった気もしました。この調子でもっとLispの面白さを学んでいきたいです。コナミ環。

Tags: Emacs lisp