Monthly Archives: 3月 2024

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))

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