2024-06-19

xrefでメソッドへジャンプできなくなる問題に対処する

Emacs Lispをいじっているときに、cl-defmethodで定義したメソッドへM-.(xref-find-definitions)でジャンプできなくなることがたまにあったので次のように対処しました。(Emacs29時点)

;; xrefでメソッドに飛べなくなるのを回避するハック。
;; defgenericを使わずにdefmethodをloadし直すと起きる問題に対処する。
;; `elisp--xref-find-definitions:around'から`find-lisp-object-file-name'を
;; 呼び出したときの挙動を変更する。
(defun my-elisp--xref-find-definitions:around (old-fun &rest args)
  (cl-letf* ((flofn-old (symbol-function 'find-lisp-object-file-name))
             ((symbol-function 'find-lisp-object-file-name)
              (lambda (object &rest flofn-args)
                (or (apply flofn-old object flofn-args)
                    ;; 本来のfind-lisp-object-file-nameの結果がnullでかつ
                    ;; OBJECTがgeneric関数シンボルなら
                    ;; 空のファイル名を返すことで
                    ;; elisp--xref-find-definitions内の
                    ;; メソッド列挙部に到達させるハック。
                    ;; 本来ならその部分のFIXMEにも書いてある通り
                    ;; その部分を`elisp-xref-find-def-functions'に分離
                    ;; すべきだと思われる。
                    (when (and (symbolp object) (cl--generic object))
                      "")))))
    (apply old-fun args)))
(advice-add 'elisp--xref-find-definitions :around
            'my-elisp--xref-find-definitions:around)

根本的な原因は私がcl-defgenericせずにcl-defmethodしているからなのですが、まぁ、そこは良いんです。かったるくてやってられないから省いているだけので。それがダメというならmy-defmethodでも作らにゃなりません。しかし、まぁ、そのツケが回ってきたというだけの話ではあります。

xref等はsymbol-file関数を使ってシンボルを定義したファイルを特定しますが、そのsymbol-fileload-history変数に記録されたファイルとシンボル定義の対応表(alist)を参照します。elファイルをloadするとその過程で定義されたシンボルがこの変数に記録されるわけですが、cl-defmethodは既にgeneric関数が定義されているときには新たにgeneric関数を定義しないので、同じelファイルを2回ロードすると暗黙的に作成されたgeneric関数のファイル名が消えてしまいます。elisp--xref-find-definitionsはgeneric関数のファイル名が取得できないとメソッドのファイル名も列挙しないので、結果定義されているはずのメソッドに飛べなくなるわけです。

上の変更では、generic関数のファイル名が特定できなかった場合に空文字列のファイル名を返すことで特定できたことにして、メソッドの列挙部分に無理矢理処理を通します。幸いなことにxrefは空文字列のファイル名を無視してくれる(ジャンプ先候補に出さない)ようです。

ろくなもんじゃありませんけど、とりあえずはこれで。

ちゃんとやるならelisp--xref-find-definitionsのFIXMEコメントに書かれているようにgeneric&method列挙部分をelisp-xref-find-def-functions変数に登録する関数としてくくりだした上で、その中でgeneric関数のファイル名が特定できなくてもメソッドのファイル名を列挙するようにするのが良さそうです。

今回はシンボルと定義ファイル名の割り出し方法に関するお勉強でした。

2024-06-08 ,

org-inline-image-fixのorg-mode 9.7対応

org-modeでインライン画像化する画像形式を限定するの続き。

org-mode 9.7がリリースされたので関係する変更点を調べてorg-inline-image-fixに必要な修正を加えました。

9.7のインライン画像周辺の変更点としては次のものが見つかりました:

いくつかは前回対応しましたし、取りこむ必要が無いものもあります。

  • org-image-max-widthの追加
  • org-image-alignの追加

の二つは一応取りこんでおくことにしました。

org-image-max-width は org-limit-image-size と機能が被りますが、それぞれ独立して機能するのでどちらを使っても問題ありません。私は高さの制限が出来る後者を使い続けます。まれに縦長の画像に出くわすことがあるので。

org-image-align の方は、私は中央寄せや右寄せの指定を普段しないので多分使わないと思います。一応試したらこんな感じになりました:

Org9.7のインライン画像alignプレビュー機能を使ってみたところ
図1: Org9.7のインライン画像alignプレビュー機能を使ってみたところ

一箇所、インライン画像の右側にある空白を画像のオーバーレイに含めてしまう(空白を消して表示する)修正に疑問があったので、本家とは違う修正をしました。おそらく中央寄せや右寄せにするときに右側に空白があると完全な位置に寄らないのでそれを解消する意図があるのだと思いますが、個人的には空白が(存在するのに)勝手に消えて表示されるのはあまり好ましくないような気がします。とりあえず左寄せの時は従来通りに必ず空白を残すようにしておきました。

2024-06-08 ,

インポートとジェネレータ

el-easydraw(以下edraw)の最近の変更の中で比較的大きかったのはインポート機能でしょうか。

これは元々edrawが一部のSVGを正しく読み込むことが出来ないという問題を指摘されたことから開発に至った機能です。

元々edrawは膨大なSVG仕様の全てに対応する気はさらさら無く、edrawによって作成・出力したSVGのみが再編集可能です。edrawが出力したSVGには <g id="edraw-body">...</g> という形のグループ要素がありその中が唯一編集可能な領域になっています。idがedraw-bodyなグループ要素(つまりg#edraw-body)の中にはedrawが対応しているものしか入っていない前提で作られているため、無理矢理そこに何かを入れたとしても動作は保証されません(いや、まぁ、どのみち誰も何も保証しませんが)。とは言えその方は他のツールで作成したSVGを持ってきたかったらしく、そのための仕組みとして最終的にインポート機能を作ることになったわけです。

特にedraw-modeを使うと任意のSVGファイル(を開いたバッファ)で作図エディタを起動することが可能だったのですが、そんなことをしても当然そのSVGの中にあった図形を編集できるわけも無く、せいぜいその上に新しい図形を乗せていくことくらいしか出来ませんでした。

現在では、他で作った(g#edraw-bodyが存在しない)SVGでM-x edraw-modeを実行するとエラーが出て、諦めるか自己責任で M-x edraw-convert-buffer-to-edraw-svg-xml を実行して変換するよう勧めるメッセージが表示されます。実際に edraw-convert-buffer-to-edraw-svg-xml を使用すると、バッファ内のSVGがedrawが扱えそうな形に変換されます。対応していない要素は除去されたり警告が出たりします。そして再度 M-x edraw-mode を実行すると、運が良ければ編集できることでしょう。

作図エディタの中からでもメインメニューの「ドキュメント」→「ファイルからインポート...」を選択して任意のSVGファイルを選べばそれを変換・取りこむことができます。

試しにInkscapeで作成したSVGを読み込んでみましたが、まぁ、思ったよりは取りこめる感じでした。他にもGraphvizで作成したSVGを取りこんだりも出来ました(ただしグラフ構造を手軽に再編集できるようなものではありません)。その方はdvisvgmの出力を取りこみたかったようですが、それも最終的にはうまく取りこめるようになりました。

dvisvgmを使用すればlatexで生成したものをSVGに変換できます。つまり、例えば数式をSVGに変換することが可能です。そしてそれを取りこめるようになったわけです。

しかしこのままでは数式を図の中に取りこむには手間がかかりすぎます。まずlatexのソースを書いて、latexでdviを作成し、それをdvisvgmでsvgへ変換し、edrawでインポートしなければなりません。もっと効率よく出来る仕組みが必要です。

私も数式を図に取りこめたら良いだろうなと思ったことはあったので、そのための仕組みを作ることにしました。

「数式ツール」のようなものを作っても良かったのですが、それでは直接的すぎて面白くありません。そこで考えたのが「ジェネレータ」です。

ジェネレータは何らかの設定から図形を生成するような(図形として配置可能な)オブジェクトです。ジェネレータツール(生成ツール)で配置できます。

ジェネレータにはプロパティとして生成の元(ソース)となるような情報を指定出来ます。まず第一に生成タイプがあり、今のところlatexかgridが指定出来ます。次に生成ソースを指定するプロパティがあり、タイプがlatexの時はそこにLaTeXのソースコードを記述できます。最後に生成オプションがあり、これはタイプによって異なる追加のプロパティを色々設定できます。

そして生成ボタンを押すとそれらのソース情報を元に図形が生成されるという寸法です。latexの場合は、preamble部分(カスタマイズ変数で変更可能)などと合成されてlatex、dvisvgmコマンドが呼び出され、生成されたSVGがedraw用に変換されてジェネレータの子要素として挿入されます(もちろんlatexとdvisvgmが必要です。私はTeX Liveでインストールしましたが、MSYS2にPATHが通っていると色々ハマるのでご注意を)。

latexジェネレータで数式を生成したところ
図1: latexジェネレータで数式を生成したところ

生成タイプのgridは格子状の線を生成するものです。生成タイプがlatexだけでは格好が付かないので適当に作ってみました。線が太くて気に入らないという方は位置を0.5ピクセルずらしてみて下さい。「これもうソースコード要らないじゃん!」ということが発覚してちょっと後悔しています。

数式をSVGに入れるならMathMLを使うという手もあります。実際latexからMathMLに変換してforeignObject要素として取りこむような生成タイプを作ることもできるでしょう。しかし一番のネックはlibrsvgが対応していないのでEmacsの中で表示されないことでしょうね。

latexの中で(tikzで)グラフを生成することもできるみたいです。

latexの図を生成したところ
図2: latexの図を生成したところ

これらの機能で簡単に複雑な図を表現できるようになったのは良いところですが、反面データサイズが膨らみがちなことが困りそうなところでしょうか。特に [[edraw:data= 形式のリンクはデータがOrgファイル内(特に一行に)埋め込まれるので、あまり大きいと何か問題を引き起こすかもしれません。単純にインライン画像をoffにしたときに見栄えが悪いというのもあります(今更ですが)。そういう場合はインライン画像を右クリックして [[edraw:file= 形式へ変換して下さい。

2024-06-07 ,

複数サブパス(複合パス)への対応

別のプロジェクトで簡単な絵が必要になったので自作の作図ツールを使って描いていたら色々不満があったので最近はちょくちょくいじっていました。そのプロジェクトはそっちのけで(笑)。

misohena/el-easydraw: Embedded drawing tool for Emacs

特に複数のサブパスを含むパスへの対応をいい加減やらなきゃな、と。

複数のサブパスとは

複数のサブパスというのは、一つのパスデータ(path要素のd属性:<path d=>)の中に複数の内部的なパス(サブパス)が表現されているような状況のことです。例えば <path d="M0,-100 L100,100 L-100,100 Z M0,-50 L-50,50 L50,50 Z" /> と書くと一つのpath要素で大小二つの三角形が表現できます。実際に表示してみるとこんな感じになります。

図1: d="M0,-100 L100,100 L-100,100 Z M0,-50 L-50,50 L50,50 Z"

複数のパスを表現したいなら複数のpath要素を使えばいいじゃないかと思うかもしれませんが、上のような「穴あき」を表現するには一つのパスデータの中の複数のパス(サブパス)が必要になります。複数のpath要素を重ねて配置しただけでは塗りつぶした大きな三角形の上に小さな三角形を塗りつぶすことしか出来ません。一つのパスの中に大小の三角形が一緒に入っていてはじめてこのような「穴あき」が作れます。

一つのパスの中に二つのパスが並んでいるだけでなぜ中が抜けるんだろうかと疑問に思う方もいると思いますが、これはこの図形をレンダリングすることを考えれば分かります。レンダリングの基本は画像を上から下へ一行ずつ、そして一行の中を左から右へ1ピクセルずつ色を決めて点を打っていくことです。例えばこの図形の真ん中らへんの一行(1ライン)をレンダリングするとします。画像の左端から右端へ向かって1ピクセルごと処理していきます。このとき各線分(segment)との交差判定をしながら進んでいくことがポイントです。一番最初は図形の外なので点を打ちません。右へ向かっていくと、最初の線分を跨ぎます。なので、それ以降は灰色のfill色を打ちます。さらに進むとまた線分を跨ぎます。なので、それ以降は図形の外にあると認識して点を打ちません。さらに進むとまた線分を跨ぐ(奇数回目)ので色を塗り始めます。そしてまた線分を跨いだら(偶数回目)塗るのを止めます。と、このように図形の内外を判定しながらレンダリングしていくのですが、このアルゴリズムで真ん中に穴あきがあるドーナツ形を作るには外側の線も内側の線も同じ判定対象(線分集合)の中に存在していなければなりません。凹形なら一つのパスでも作れるのでその上側をくっつけてしまうという回避策もありますが、strokeを指定するとボロが出たりと問題もあります。

ということで、一つのパスの中に複数のパス(サブパス)が必要になるわけです。

対応してこなかった理由

これまで対応してこなかった理由はひとえにデータ構造の悪さからでした。SVGのd=属性を解析して内部的な表現に変換してから編集するのですが、その内部的な表現の構造が悪すぎました。その表現は、d属性を解析したほぼそのままのコマンドリストでした。なので、一つアンカーを打てばMコマンド(最初の点を指定する)、Lコマンド(直線)、Cコマンド(カーブ)、Zコマンド(閉じる)を前後の状況に応じて判別して追加するような複雑なことをしなければなりませんでした。もちろんその複雑さを緩和するような層があって、上の層は下の層に処理を投げるだけなのですが、複数のサブパスに対応するには下の層を改善しなければならず、そしてそれは複雑なのでやりたくなかったわけです。

この構造のまま複数のサブパスにも(ユーザーからの様々な操作に対してちゃんと)対応しようとすると細かい条件分けが複雑すぎて大変でした。実は実際最後まで書き切ったのですが、どこかにバグがあっても不思議ではない、これからの改善も全くしたくないようなコードにしかなりませんでした。ウンザリしてすぐにその辺りのを構造(edraw-path.el)を書き直すことにしました。

新しい構造と内部・外部表現の変換

新しい構造はパス(パスデータ)、サブパス、アンカー、ハンドルを素直に表現したものとなりました。これによって様々な処理が驚くほどシンプルに素直に書けるようになりました。

そして新しい構造とSVGのd属性(コマンド文字列)との間の変換処理も用意して、必要に応じて変換します。これは従来の構造(コマンドリスト)でもそのような作りになっていました。

ただし、内部的な構造と実際のd属性の文字列(外部表現)との変換は必要最小限にする必要があります。それはどちらの方向でも失う情報があるからです。

SVGのd属性で表現できないもの

内→外で失うものとしては、開サブパスの端点の外側のハンドルがあります。何を言っているのかよく分からないかもしれませんが、次図の通りです。

閉(サブ)パス開(サブ)パス端点端点の外側のハンドル端点を持たない
図2: 開サブパスの端点の外側のハンドル

つまり、線の端っこのさらに外にあるハンドルです。実際のところこれは描画には全く影響を及ぼしません。なのでSVGのd属性で表す方法がありません。しかし編集においては、その端点の次にアンカー点を打ったときに、その端点と新しい端点とを結ぶ曲線の曲がり具合に影響します。描画側から見たら「まだ存在しない曲線の属性なんて知らん」ですが、編集側から見たら「いやいや、線がその端点を通過したときの出て行く先を示すんだからその端点の属性だろ」というわけです。というわけで、編集用の内部表現をd属性へ変換するとそのハンドルは失われます。まぁ、独自の属性に持たせるといった方法もありますが。

新しい構造で表現できないもの

外→内で失うものとしては、各コマンドの細かいニュアンスだと思います。

SVGのパスデータには沢山のコマンドが用意されています。

M m Z z L l H h V v C c S s Q q T t A a

これだけのコマンドがあります(Paths ― SVG 2を参照のこと)。

これだけあると同じ形を描画するのにも沢山の表現方法があります。これらのコマンドの使い分けは、ほとんどの場合データサイズを削減することが目的だと思われますが、誰かがそこにそれ以外の意図を込めていないと言い切れるでしょうか。相対表現(小文字)は前の点からの相対関係を維持してほしいという意図があったり、水平線や垂直線はその性質を維持してほしいと考えてはいないでしょうか。Aコマンドは特に意図が現れやすいです。しかし内部表現に変換すれば、それは全て無味乾燥な三次ベジェ曲線のアンカーとハンドルに集約されてしまいます。

私が一番頭を悩ませていたのは一つのMコマンドが複数のサブパスで共有されうることです。例えば次のようなd属性があった場合:

M0,0 L40,-20 L40,20 Z L20,40 L-20,40 Z L-40,20 L-40,-20 Z L0,-40

(ちなみにこれは分かりやすくするためにあえて無駄な書き方をしていますが、実際には次のように書けます)

M0 0 40-20V20ZL20 40H-20ZL-40 20V-20ZV-40
図3: 一つのMコマンドが複数のサブパスで共有されている例

これは上のような図形ですが、中央の点、つまり0,0の表記はd属性中に一つしか現れていません。このd属性の中には4つのサブパスが含まれています。しかしその開始点は最初に現れる M0,0 ただ一つにまとめられています。

これは単なるケチ表現なのかもしれません。しかし同じ点になっていることには何か必然的な理由があるのかもしれません。その点をドラッグしたとき、4つ全てのサブパスの開始点が動いた方が親切かもしれません。

まぁ、そんなことを全てのコマンドに対してやっていたら大変なので諦めることにしたわけです。

そして今

今では上のようなデータも正しく編集できるようになりました。もちろんバラバラの4つのサブパスとしてです。

私が間違った設計判断をしてしまった原因はこの割り切りをためらったからかもしれません。SVGパスデータの元々の表現の性質をできるだけ残しておきたかったわけです。しかしすでに相対表現やら二次ベジェ、水平線、垂直線などは絶対表現のLとCに変換してしまっていましたから、今更なわけですが。

実はこの問題は作り始めのかなり初期から気がついていました。でも頑張れば何とかなるだろうと思っていたわけです。

私は近年山を歩くことがありますが、登山道を歩いていると不意に道がおかしいな? と気がつくことがあります。妙に道が荒れている、障害となる草木や岩石が多い、越えられないことはないが一般登山道のレベルとは思えない、など。もちろんそこまで至るまでには「分岐など見当たらなかった」「正しい道を進んでいる」と思っているものです。しかしそういった異変に気がついたら地図を確認してすぐに引き返すことです。頑張れば行ける。そう思って先に行くと取り返しの付かないことになるかもしれません。

2024-03-23

Emacs Lispで文字列内の特定の位置が正規表現にマッチしているか判定する

Emacs Lispで、文字列の中の特定の位置が正規表現とマッチしているか判定するにはどうしたらよいでしょうか。

例えば "{ name: 'Taro' }" という文字列があったとして、3文字目の位置が正規表現 "\\([a-z]+\\)" にマッチしているか判定するにはどうしたら良いでしょうか(もちろんこの文字列や位置は色々と変わるものとします)。

Emacs Lispの正規表現マッチングの関数には大きく分けてバッファ用と文字列用があります。POSIX版ではない標準的なマッチング関数だと次のものがあります(Emacs 29.2時点でのRegexp Search (GNU Emacs Lisp Reference Manual)より):

  • バッファ用
    • re-search-forward
    • re-search-backward
    • looking-at
    • looking-at-p
    • looking-back
  • 文字列用
    • string-match
    • string-match-p

バッファ上のテキストであれば、現在のポイントから続くテキストが正規表現とマッチしているかを判定するにはlooking-atやlooking-at-pが使えます。他にも正規表現にポイントの位置とマッチするバックスラッシュ記法 \= があるので、それを使って (re-search-forward "\\=[a-z]+") などと書くことも出来ます(計測してみるとre-search-forwardの方が若干遅いようです)。

しかしながら文字列版の方にはそのような関数がありません。string-matchは文字列版のre-search-forwardです。検索を開始する位置こそ指定出来ますが、末尾までの間に正規表現がマッチする部分を探し、見つかったらその先頭位置を返します。例えば (string-match "\\([a-z]+\\)" "{ },{name: 'Taro' }" 2) を評価したら、 },{ の部分を飛ばして6文字目にマッチしてしまうわけです。指定した位置そのものがマッチしているかを判定するようなstring-looking-atのような関数はありません。 \= もバッファのポイントに対するもので、文字列には効きません。文字列の先頭を示す \` は本当に文字列の先頭(index=0)にしかマッチしませんし、 ^ も文字列の先頭か行頭(\nの直後)にしかマッチしません。

Emacs Lispというのは基本的にはバッファ用の関数の方が充実していて文字列用の関数が貧弱な傾向にあるような気がします。

仕方ないのでこれまで私はどうしていたかというと、

(eq (string-match "\\([a-z]+\\)" text pos) pos)

のようにしてマッチした位置が検索を開始した位置と同じであることを確認したり、

(string-match "\\`\\([a-z]+\\)" (substring text pos))

substringを使って検索開始位置を文字列の先頭に持ってきた上で \\` を使うなどしていたわけです(実際には前者ばかりで後者はほとんどしていないと思います)。

後はまぁ、

(string-match (format "\\`.\\{%d\\}\\([a-z]+\\)" pos) text)

みたいな手はあるかもしれません。

しかし、最近edraw-dom-svg.elでstyle属性の解析をしていたときに次のようなコードを書きました。

(defconst edraw-css-re-token
  (concat
   edraw-css-re-comment "*"
   "\\(?:\\(" edraw-css-re-ws "\\)"
   "\\|\\(" edraw-css-re-string "\\)"  ;; " '
   "\\|\\(" edraw-css-re-hash "\\)"  ;; #
   "\\|\\(" edraw-css-re-at-keyword "\\)"  ;; @
   "\\|\\(" edraw-css-re-dimension "\\)"
   "\\|\\(" edraw-css-re-percentage "\\)"
   "\\|\\(" edraw-css-re-number "\\)"
   "\\|\\(" edraw-css-re-function "\\)"
   "\\|\\(" edraw-css-re-ident "\\)"
   "\\|\\(" "[]({}),:;[]" "\\)"
   "\\|\\(" "." "\\)" ;; delim
   "\\)"))

(defun edraw-css-token (str pos)
  (unless (eq (string-match edraw-css-re-token str pos) pos)
    (error "CSS Syntax Error: %s `%s'" pos str))
  (cond
   ((match-beginning 1) ... )
   ((match-beginning 2) ... )
   ((match-beginning 3) ... )
   ...))

そのときに、「あれ、これって最後に . が入ってるんだから(末尾以外)必ずposの位置でマッチするんじゃないの?」と思ったわけです。

正規表現の最後が . ではなく空文字列の "略\\|" で終わっていれば、本当に必ずposの位置でマッチすることになります。

なので、最初の問いの答えは、

(progn
  (string-match "\\([a-z]+\\)\\|" text pos)
  (match-beginning 1))

でいいじゃないかということになったわけです。要するに必ずマッチさせてしまうわけです。これなら末尾まで無駄な検索は起こりませんし、substringで新しい文字列を作る必要もありません(substringがCopy-on-Writeだったりはしませんよね?)。で、実際に肝心の部分がマッチしているかは (match-beginning 1) が非nil(この場合はpos)を返すかどうかで確かめればいいわけです。正規表現に括弧が無いなら (match-end 0) がpos(つまり (match-beginning 0))と同じかどうかで確認しても良いでしょう。

分かっている人から見ればなんだ当たり前じゃ無いか、こんなことが分からないなんてバカなんじゃないか? と思われるかもしれませんが、何だかキツネにつままれたような不思議な気分になってしまいました。

大丈夫ですよね? 一応計測もしていてパフォーマンスも悪くは無さそうです。

ちなみにEmacsの \| は左がダメだったら右を試すという意味です。実際の実装がどうなってるのかは知りませんので、左が優先されると言った方が良いでしょうか。どちらか長い方という意味ではないのでご注意ください。

ところでel-easydrawの方は現在インポートまわりの作業をしていて、dvisvgmやdot、Inkscapeが出力したSVGを取りこんで遊んだりしています。org-babelのような仕組みが出来ると良いのですが……。

2024-03-23 ,

org-elisp-linkでcl-defmethodへのリンクを書けるようにする

以前書いたorg-elisp-linkで、cl-defmethodで定義したメソッドへのリンクを書けるようにしました。

misohena/org-elisp-link: Org-mode Link Types for Emacs Lisp Elements

例えばlistを引数に取るseq-takeへのリンクは [[elisp-function:seq-take;method-args=(nil list t)]] のように書きます。

method-args= オプションの書式は ( qualifiers . specializers ) になります。 qualifiers はあまり指定されないのでnilの場合が多いかと思います。 specializers はcl-defmethodで指定する引数列の引数名を除いたものと考えれば良いでしょう。

qualifiers を使用する例としては、例えばelp.elの中にあるloadhist-unload-elementへのリンクなんてどうでしょう(lispディレクトリでgrepして探しました)。

(cl-defmethod loadhist-unload-element :extra "elp" :before ((x (head defun)))
  "Un-instrument before unloading a function."
  (elp-restore-function (cdr x)))

elp.elの中に上のような定義があるのですが、そこへのリンクは次のように書くことになります。

[[elisp-function:loadhist-unload-element;method-args=((:extra "elp" :before) (head defun));library=elp]]

実装はelisp-mode.elのxrefバックエンド、特にelisp--xref-find-definitionsあたりを利用しています。

xrefを使ってelispの関数やら変数やらの定義へジャンプするには、次のようにすればできます。

(let ((xref-backend-functions '(elisp--xref-backend))) ;; 強制的にelispバックエンドを使うようにする
  (xref-find-definitions "find-file"))

cl-defmethodによって複数の選択肢がある場合はxrefのメニューが出ます。

(let ((xref-backend-functions '(elisp--xref-backend)))
  (xref-find-definitions "seq-take"))

今回リンクの上でC-c C-o(org-open-at-point)したときはこの方法で定義位置へジャンプするようにしてみました。従来はこのような場合に必ずcl-defgenericの方へジャンプしてしまったり、それが無ければジャンプできなかったりしていました。

エクスポートの時はジャンプせずに定義の場所を取得する必要があります。

ジャンプせずに定義の候補を取得するには次のようにすればできます。

(elisp--xref-find-definitions 'seq-take)
(#s(xref-item
    #("(cl-defgeneric seq-take)" 1 14 (face font-lock-keyword-face) 15 23 (face font-lock-function-name-face))
    #s(xref-elisp-location
       seq-take
       cl-defgeneric "c:/...path-to-emacs.../share/emacs/29.2/lisp/emacs-lisp/seq.el"))
 #s(xref-item
    #("(cl-defmethod seq-take ((list list) n))" 1 13 (face font-lock-keyword-face) 14 22 (face font-lock-function-name-face))
    #s(xref-elisp-location
       (seq-take nil list t)
       cl-defmethod "c:/...path-to-emacs.../share/emacs/29.2/lisp/emacs-lisp/seq.el")))

結果はxref-itemというレコードになります。xref-itemはsummaryとlocationという二つの要素から成ります。

summaryの方は単なる文字列(テキストプロパティ付き)です。

locationの方は場所を特定するための情報で、elisp--xref-find-definitionsが返す場合はxref-elisp-locationというレコードになります。xref-elisp-locationはsymbol、type、fileから成ります。

試しにseq-takeの2番目の候補の場所を取得してみましょう。

(let ((loc (xref-item-location (nth 1 (elisp--xref-find-definitions 'seq-take)))))
  (list
   (xref-elisp-location-type loc)
   (xref-elisp-location-symbol loc)
   (xref-elisp-location-file loc)))
(cl-defmethod ; type
 (seq-take nil list t) ; symbol
 "c:/...path-to-emacs.../share/emacs/29.2/lisp/emacs-lisp/seq.el" ; file
 )

定義位置へジャンプするとき、これらの情報はそのままfind-function-search-for-symbolに引き渡されます。

typeの cl-defmethodfind-function-regexp-alistのキーです。このalistからcl--generic-search-method関数が求められ、symbolがそのcl--generic-search-method関数に引き渡されて実際の検索が行われます。symbolは (seq-take nil list t) なので、メソッド名が seq-take 、qualifier無し、引数の型がlistであるようなcl-defmethodが(re-search-forwardで)検索されます。

今回追加したmethod-args=オプションの形式は、このsymbolのメソッド名を除いた残りの部分と一致するようになっています。

2024-03-03

shell-modeでgitを使うたびにブラウザが開きまくるのを直す

先日Corfuの自動補完の設定を変えてからだと思うのですが、M-x shellでgitのコマンドを打つたびにブラウザでヘルプが開いて困っていたので重い腰を上げて直しました。

原因は pcomplete/git 関数内で git help コマンドを実行しているところにあります。

;; pcmpl-git.elより
(defun pcomplete/git ()
  "Completion for the `git' command."
  (let ((subcommands (pcomplete-from-help `(,vc-git-program "help" "-a")
                                          :margin "^\\( +\\)[a-z]"
                                          :argument "[[:alnum:]-]+")))
    (while (not (member (pcomplete-arg 1) subcommands))
      (if (string-prefix-p "-" (pcomplete-arg))
          (pcomplete-here (pcomplete-from-help `(,vc-git-program "help")
                                               :margin "\\(\\[\\)-"
                                               :separator " | "
                                               :description "\\`"))
        (pcomplete-here (completion-table-merge
                         subcommands
                         (when (string-prefix-p "-" (pcomplete-arg 1))
                           (pcomplete-entries))))))
    (let ((subcmd (pcomplete-arg 1)))
      (while (pcase subcmd
               ((guard (string-prefix-p "-" (pcomplete-arg)))
                (pcomplete-here
                 (pcmpl-git--expand-flags
                  (pcomplete-from-help `(,vc-git-program "help" ,subcmd) ;; ★ここ★
                                       :argument
                                       "-+\\(?:\\[no-\\]\\)?[a-z-]+=?"))))

これはgitコマンドの引数を補完するためのコードです。 git help コマンドは指定可能なコマンドやオプションを列挙するために呼び出しているようです。

git help コマンドの呼び出しは三つ存在するのですが、 git help -agit help はコンソールから試してみても標準出力にテキストが表示されるので問題ありません。しかし git help status などとサブコマンド名を入れてみるとブラウザが開きます。

現在私が使っているGitはMSYS2やCygwinのものではなく、Git for Windowsなのでそれが原因なのかもしれません。Git Bashから git help --man status などと打っても No manual entry for git-status などと出るだけです。manやinfoが入っていないのでブラウザでhtmlを開くのでしょう。

試しに `(,vc-git-program "help" ,subcmd) の部分を `(,vc-git-program ,subcmd "-h") に直したら問題は解消しました。

といっても直接ファイル(pcmpl-git.el)を書き替えるのも何ですし、どうしましょうね。pcomplete/git関数は少々内容が込み入っていて、全体をコピーして一部を書き替えて置き換えるのも気が引けます。

問題のリスト `(,vc-git-program "help" ,subcmd)pcomplete-from-help関数に引き渡されています。なので、pcomplete-from-help関数の第一引数(command)に `(,vc-git-program "help" ,subcmd) が渡されたときに `(,vc-git-program ,subcmd "-h") へ差し替えてしまいましょう。pcomplete-from-help関数はあちこちで利用されているでしょうから、安全のためにpcomplete/gitの中から呼び出される時だけ、この変換処理をすることにしてみます。

;; shell-modeでgitコマンドの引数を補完させるとブラウザが開くのを抑制する。
;; 次の三つのパターンがあるが、git help <subcmd>のときだけブラウザが開くので
;; それをgit <subcmd> -hに置き換える。他はそのまま。
;;  git help -a        => そのまま
;;  git help           => そのまま
;;  git help <subcmd>  => git <subcmd> -h
(with-eval-after-load "pcmpl-git"
  (defun my-pcomplete/git:around (oldfun)
    (cl-letf* ((old-pcomplete-from-help
                (symbol-function 'pcomplete-from-help))
               ((symbol-function 'pcomplete-from-help)
                (lambda (command &rest args)
                  (apply old-pcomplete-from-help
                         ;; Replace git help <subcmd>
                         (if (and (listp command)
                                  (equal (car command) vc-git-program)
                                  (equal (cadr command) "help")
                                  (cddr command)
                                  (not (string-prefix-p "-" (caddr command))))
                             ;; => git <subcmd> -h
                             `(,(car command) ,(caddr command) "-h")
                           command)
                         args))))
      (funcall oldfun)))
  (advice-add 'pcomplete/git :around 'my-pcomplete/git:around))

cl-letfでsymbol-functionを書き替えるコードはやっぱり少し鬱陶しいですね。

pcomplete-from-helpgit help <subcmd> というコマンドを渡すところは他に無いでしょうし、もしあったとしても意図的にブラウザを開くために使うことは無いでしょうから、pcomplete/gitの中だけに限定する必要は無かったかもしれません。それなら:

(with-eval-after-load "pcmpl-git"
  (defun my-pcomplete-from-help:filter-args (args)
    ;; Replace git help <subcmd>
    (let ((command (car args)))
      (if (and (listp command)
               (equal (car command) vc-git-program)
               (equal (cadr command) "help")
               (cddr command)
               (not (string-prefix-p "-" (caddr command))))
          ;; => git <subcmd> -h
          `((,(car command) ,(caddr command) "-h") ,@(cdr args))
        args)))
  (advice-add 'pcomplete-from-help :filter-args 'my-pcomplete-from-help:filter-args))

程度でも良いかもしれません。

2024-02-25 ,

org-link-completion.elによるリンクの補完とorg-modeのリンク構文

先日から作っているorg-link-completion.elですが、一応org-modeのリンク表記の内部のうち、ほとんど全ての場所で補完ができるようになりました。

misohena/org-link-completion: Complete the link type, path and description part of links at point in org-mode buffer.

次の場所で補完できるはずです。

  • [[ link-type:
  • [[ searchtarget
  • [[# custom-id
  • [[# custom-id ][ description
  • [[* heading
  • [[* heading ][ description
  • [[( coderef)
  • [[(coderef)][ description
  • [[ Search Target
  • [[ Search Target ][ description
  • [[ /dir/file
  • [[ ./dir/file
  • [[ /dir/file
  • [[ c:/dir/file
  • [[ /dir/file ][ description (上記 / ./ / c:/ を含む)
  • [[file: file
  • [[file+sys: file
  • [[file+emacs: file
  • [[file: file ][ description (上記 file+sys: file+emacs: を含む)
  • [[ unknown-type: path
  • [[ unknown-type: path ][ description

補完できないところは[と[、]と[、]と]の間くらいじゃないでしょうか。

そもそもorg-modeでどんなリンクが書けるのかというのがあやふやだったんですよね。仕方ないのでちゃんとおさらいしました。

org-modeのリンクで一番重要なコンセプトは、URLや(ディレクトリを含む)ファイルパスのように見えるもの以外は内部リンクだということでしょう。つまり、 file: とか https: とか付いているものや ./screenshot.png のようなもの以外は現在のorg-modeバッファ内へのリンクです。

ただ、私は解析の都合上 file: とか https: のようなリンクタイプが付いているかそうでないかによって大きく二通りに分けました。その上で、付いていないもののうち、ファイルパスは外部リンク、そうでないものは内部リンクということになります。

それを踏まえて全ての形式を列挙すると次のようなります。

  • リンクタイプ無し
    • 内部リンク
      • [[ # カスタムID ]
      • [[ * 見出し ]
      • [[ ( コード行参照 ) ]
      • [[ 色々検索 ]
    • 外部リンク
      • ディレクトリ始まりファイルパス

        • 相対パス
          • [[ ./ ファイルパス
          • [[ ../ ファイルパス (追記:漏れていたので追加)
        • 絶対パス

          • [[ / ファイルパス
          • [[ ~/ ファイルパス
          • [[ ~ ユーザ名 ファイルパス (追記:漏れていたので追加)
          • [[ \ ファイルパス (MS-Win) (追記:漏れていたので追加)
          • [[ ドライブレター : /または\ ファイルパス (MS-Win) (追記:コロンの後には/か\が必要)

          (追記:絶対パスはfile-name-absolute-pがtを返す形式でなければならない。つまり ~ユーザ名 なんてのも許容される(!)し c:\ とバックスラッシュが続くのも許容される。c:の後にパス区切りが無いドライブ相対指定は許容されない。当然プラットフォームによって異なる。面白いのが相対パスにバックスラッシュを使った .\file は許容されないが絶対パス \file は許容される点)

        (注: file: と同じように 後ろに :: を付けられるが省略)

  • リンクタイプ付き
    • 省略記法(org-link-abbrev-alist, org-link-abbrev-alist-local)
    • リンクタイプ(org-link-parameters)
      • file: (file+sys:file+emacs: も形式は同じ。 ファイルパス は空でも良い)
        • [[file: ファイルパス ]
        • [[file: ファイルパス :: 行番号 ]
        • [[file: ファイルパス :: 色々検索 ]
        • [[file: ファイルパス :: * 見出し ]
        • [[file: ファイルパス :: # カスタムID ]
        • [[file: ファイルパス :: ( コード行参照 ) ]
        • [[file: ファイルパス :: / 正規表現 / ]
      • その他沢山(標準的なもの: attachment:, bbdb:, docview:, doi:, elisp:, gnus:, rmail:, mhe:, help:, http:, https:, id:, info:, irc:, mailto:, news:, shell:)

……漏れがあったらすみません。

いろんなリンクの例としてexamples/links.orgというのも作っておきました。

# の後で補完すれば全カスタムIDが候補として出ますし、 ( の後なら全(ref:)表記、 * の後なら全見出しが出ます(見出しだけはorg-modeの標準でも補完してくれます)。

色々検索 の部分ですが、概ね次のように検索されるようです。

  1. dedicated target (<< と >> で囲んだ文字列がリンクターゲットになります。文字列はエクスポートされません)
  2. 要素(ブロック)の名前 (#+NAME:で指定する)
  3. 見出し
  4. 全文検索 (org-link-search-must-match-exact-headlineがnilの時またはorg以外のファイルの時のみ)

概ね <<My Target>>#+NAME: table1 のようなものへのリンクと考えた方が良さそうなので、それだけを補完候補として出しています。

ファイルの :: 以降はまだ未実装です。現時点では補完されません。

file以外のリンクタイプについては手を付けていません。 org-link-parametersから :capf-path:capf-desc という非標準のプロパティを取得してそれを呼び出すようにしてあるので自分でいくらでも追加できます。org-elisp-link.elもその方法で補完関数を追加しているので、関数名や変数名をorg-modeバッファ内で補完できます。

いずれの種類においても有効なのが他の同種のリンクから候補を集めるという方法です。リンクの説明部分を補完するときに、既に書かれている同じタイプとパスを持つリンクから説明部分を拝借してくることが出来ます。パスについても同じタイプを持つリンクから候補を集めます。

色々やると大きなファイルで処理が遅くなる場合もあるかもしれません。全ての補完関数は個別に取り除く(無効化する)ことが出来るようになっています。

というわけで大枠は出来たかと思います。細かいところがまだ残っていますが、まぁ、そのうちやったりやらなかったりするでしょう。

ここまでやっておいてこの投稿を書いている間に何回C-c C-lでミニバッファにパスや説明部を入力してリンクを作成したことか。まぁ、今後は両方使えるということで(笑)

参考資料:

2024-02-23 ,

org-link-completion.el

先日からorg-elisp-link.elに書いていた補完用のコードのうち、elispリンクに限らない一般的な枠組みの部分をorg-link-completion.elへ移動しました。

ブログリンクなどelispリンクと関係ないものを書いているのに org-elisp-link- で始まる関数やらマクロやらを使わないといけないのが何だか気持ち悪く感じてきたので。

基本的な考え方はorg-elisp-link.elにあった時とほとんど同じです。

より強くorg-modeのリンク部分をサポートするという意味で、リンクタイプ部分の補完機能も入れてあります。つまりorg-modeでの入力補完に書いた(pcomplete/org-mode/link関数を修正した)ような [[ の直後部分でリンクタイプを補完することもできるようになっています。ポイントの周辺を解析する関数もそれに合わせてリンクタイプ部分にいることを理解できるように修正しました。解析結果のリストの構造も少しだけ変わっています。

そのリストにアクセスするために以前はnthを連発していたのですが、それを改善するマクロも追加しました。

例えば以前次のようなコードを書きました。

(defun my-blog-link-capf-path ()
  "[[blog:2024-02-20-hello-emacs]]のようなリンクの補完候補を返します。
my-blog-dirにorgファイルがあるものとします。"
  (when-let ((pos (or org-elisp-link-capf-pos (org-elisp-link-capf-path-parse))))
    (let* ((type-beg (nth 0 pos))
           (type-end (nth 1 pos))
           (path-beg (nth 2 pos))
           (path-end (nth 3 pos))
           (type (buffer-substring-no-properties type-beg type-end)))
      (when (string= type "blog")
        (list
         path-beg path-end
         (cl-loop for file in (directory-files my-blog-dir)
                  when (string-match "\\`\\(.+\\)\\.org\\'" file)
                  collect (match-string 1 file))
         :company-kind (lambda (_) 'file))))))

これを次のようにアクセッサで書けるようにし……

(defun my-blog-link-capf-path ()
  "[[blog:2024-02-20-hello-emacs]]のようなリンクの補完候補を返します。
my-blog-dirにorgファイルがあるものとします。"
  (when-let ((pos (or org-link-completion-pos (org-link-completion-parse-at-point))))
    (let* ((path-beg (org-link-completion-pos-ref pos path-beg))
           (path-end (org-link-completion-pos-ref pos path-end))
           (type (org-link-completion-pos-ref pos type)))
      (when (string= type "blog")
        (list
         path-beg path-end
         (cl-loop for file in (directory-files my-blog-dir)
                  when (string-match "\\`\\(.+\\)\\.org\\'" file)
                  collect (match-string 1 file))
         :company-kind (lambda (_) 'file))))))

最終的に次のようになりました。

(defun my-blog-link-capf-path ()
  "[[blog:2024-02-20-hello-emacs]]のようなリンクの補完候補を返します。
my-blog-dirにorgファイルがあるものとします。"
  (org-link-completion-parse-let :path (type path-beg path-end)
    (when (string= type "blog")
      (list
       path-beg path-end
       (cl-loop for file in (directory-files my-blog-dir)
                when (string-match "\\`\\(.+\\)\\.org\\'" file)
                collect (match-string 1 file))
       :company-kind (lambda (_) 'file)))))

最初は分割束縛(この用語もどう日本語にするか悩ましいですが、今回はとりあえずこう書きます)で書こうと思ってpcasecl-destructuring-bindseq-letの三つを理解しやすさ、コード量、意味論、速度、letの入れ子の数など多角的に検討したのですが、実際に使って書いてみたときの全体的なコードが過剰に複雑に見える気がしてnth羅列の方がまだマシという気がしたので最終的に上のようなマクロに落ち着きました。

我々はコード中のマジックナンバーに不快な匂いを感じるよう訓練されているわけですが、それが分割束縛になったところで明示的なインデックス番号が非明示的な語順に変わっただけだということで汚いことには変わりがないんですよね。汚いものは汚いように見えるべきじゃないかと私はよく思います。上のようなマクロを作ってそれだけを使うようにしておけば汚い部分は大分局所化されますね。

で、三つの分割束縛の方法について。

pcaseは総じて優秀だなという印象。少ないコード量で複雑なマッチングと束縛をこなせます。マクロ展開の速度?がこれだけやけに速いみたいなのですが何だろう。チェックが過剰になりがちなのでバイトコンパイルすると最終的な速度はcl-destructuring-bindに追い越されますが。一番の問題点はコードが記号だらけで見づらくなるところですね。それと展開されたコードがかなりの数のletの入れ子になっていました。これ再帰で使ったらすぐにmax-lisp-eval-depthmax-specpdl-sizeの制限に引っかかるのでは? まぁバイトコンパイルすれば緩和されますが。値がnil、4要素のリスト、6要素のリストのいずれかという状況に対してパターンの指定は意味的にやや過剰という面があるでしょう。ちなみにpcase-letというのもありますが、パターンに一致しなかったときの動作は未定義と書かれています。まぁ、最初に全体をwhen-letで受けてから長さをチェックしてpcase-letを使えば良いのでしょうが、大人しくpcaseを使いました。

cl-destructuring-bindの引数リストってあのcl-defunと同じなんですね。ビックリしました。 (&whole pos &optional type-beg type-end path-beg path-end desc-beg (desc-end nil desc-p)) みたいに書いて遊んでました。やべぇなこれ(笑) 面白いから使いたかったのですが、結局どれも使わなかったのと、記述が長くなりがちなのもマイナスでしょうか。というかあまり遊んでると目的外使用感が出てきてしまいますね。あ、plistをこれで受けるなんて使い方もできるのか! 展開されたコードは一番美しかったです。順番にpopしていくだけですね。

seq-letはコードの見た目がシンプルでとても気持ちが良いです。でもシーケンスの長さが一致しないときの挙動が文書化されていません。docstringにもEmacs Lispのマニュアルにも書かれていません。中身はpcase-letを使っているみたいなのですが……。いや、最初の例で4要素のvectorを2つの変数で受けているから大丈夫ということなのかな? もし足りないのはnilになり過剰なのは単に捨てられることが保障されているなら積極的に使っていきたいです。リスト用ではなくシーケンス用なので速度は若干遅いです。まぁ、ほとんどの場合気にする差ではありません。基本的にseq--elt-safeの羅列が生成されるようです。

まぁ、細けぇことはいいんだよ、動けばいいんだ! ということで。

2024-02-20 ,

org-modeリンクの説明部分でcompletion-at-pointできるようにする

昨日の続き。

昨日の投稿の最後に blog: リンクのpath部分([[blog:<ここ>)を補完するコードを書きましたが、今度は説明(description)部分([[blog:2024-02-20-hello-emacs][<ここ>))を補完するコードを書いてみました。

(2024-02-23追記: 補完関数を定義する一般的な枠組み部分をorg-link-completion.elへ移動しました。それに伴い以下のコード等を書き直しました)

org-elisp-link.el org-link-completion.elの方に必要となる機能をすでに追加してあります。org-link-properties:completion-at-point プロパティはやめて :capf-path:capf-desc を使うようにしました。これによって、path部分と説明部分を補完する関数を別々に指定することが出来ます。

これを使うとブログリンクの説明部分を補完するコードを次のように書くことが出来ます。

(require 'org-link-completion)

(defun my-org-blog-link-capf-desc ()
  "ポイント上のblogリンクの説明部分を補完します。"
  (org-link-completion-parse-let :desc (type path desc-beg desc-end)
    (when-let ((blog (my-blog-from-link-type type)))
      (let* ((title (let* ((dir (plist-get blog :local-dir))
                           (file (expand-file-name (concat path ".org") dir)))
                      (my-org-blog-org-file-title file))))
        (list
         desc-beg desc-end
         (append
          (when title
            (list title
                  (concat title " | " (plist-get blog :title))))
          (list path)))))))

(defun my-org-blog-org-file-title (file)
  "org-modeで記述されているFILEからタイトルを取得します。"
  (when (file-regular-p file)
    (with-temp-buffer
      (insert-file-contents file nil nil 16384) ;; きっと先頭の方にあるでしょう。
      (goto-char (point-min))
      (let ((case-fold-search t))
        (when (re-search-forward
               "^#\\+TITLE: *\\(.*\\)$" nil t)
          (match-string-no-properties 1))))))

(org-link-set-parameters "blog"
                         :capf-desc 'my-org-blog-link-capf-desc)

実際に使うと次のようになります。

blogリンクのdescriptionを補完しているところ
図1: blogリンクのdescriptionを補完しているところ

ファイルの先頭部分にある「#+TITLE:」を見て自動的にタイトルを候補にしてくれます。

blog: リンクに留まらず、普通の file: リンクでも同じ事が出来そうです。.orgファイルへのリンクは同じ方法でタイトルが取得できますから、それを補完候補にすることができます。他にも何らかの方法でタイトルを取得できるファイルへのリンクはそれを補完候補にすることもできるでしょうね。といってもあまり思いつきませんが。pdfとか? http、httpsリンクタイプでhtmlからtitleを取ってくるのはやり過ぎでしょうか?