2024-01-27

複数行にわたるコメントの中のS式を評価する

Emacs Lispで次のようなコードを書いたとします。

;; 使用例:
;; (my-hogehoge-function
;;   1
;;   2
;;   3)

(defun my-hogehoge-function (a b c)
  (+ a b c))

複数行あるコメントの最後、 ) の直後でeval-last-sexp (C-x C-e)を実行すると……

Debugger entered--Lisp error: (scan-error "Unbalanced parentheses" 313 1)

などと出てコメント内のS式を評価できません。

いちいちuncommentしてから評価して元に戻すのも面倒です。

Googleで検索して見ると kensanata/eval-sexp-in-comments: eval sexp in comments, for Emacs というのを見つけました。ソースコードを見るとwith-temp-bufferで別バッファへ移してからコメントを外し、その後eval-last-sexpを実行していました。それだと現在のバッファの中で評価したい場合に困ります。

eval-last-sexpの中身を見てみると、elisp--preceding-sexpという関数でポイントの前にあるS式テキストをlispオブジェクトの形で取り出してから、評価しているようでした。

なので、このelisp--preceding-sexpに細工をしてコメントの中にいるときは別バッファにコピーしてコメントを外し、そこでelisp--preceding-sexpを呼び出してS式を返せば良いと考えました。

;; my-elisp.el

(defun my-elisp-beginning-of-continuous-comments ()
  "現在の連続コメントの先頭を返す。

連続コメントとは、連続改行(空行)を除く空白文字のみで区切られた複
数のコメントのまとまりを指す。そのまとまりの最初の;の位置を返す。

例:
123 ;; line-1
    ;; line-2

    ;; line-3

「line-3」の末尾の場合「;; line-3」の先頭、「line-2」の末尾の場合
「;; line-1」の先頭の位置を返す。

各行;;の先頭はコメントに含まれない。

現在のポイントがコメント内ではない場合nilを返す。

文字列の中の;;には反応しない。
例:
\"
;; line-1 ←ここで実行してもnilを返す。コメントでは無く文字列の中なので。
\"

(以下追記)
同じコメントスタートに限定するかは迷うところ。
 ;; (+
;;     2
;;;    3)
みたいなのも現状では受け入れる。

    ;; line-1
123 ;; line-2
    ;; line-3
みたいなのは無理(;; line-2が先頭になる)。
対応できないことは無いだろうけど
そもそもコメントの前に何かある場合も対応する必要があるかは疑問。

理想的にはコメント開始の水平位置と;の数が揃っている連続行を
抽出すべきなのだと思う。"
  (cond
   ((derived-mode-p 'emacs-lisp-mode)
    (save-excursion
      (let (beginning-of-comment)
        (while (and (comment-beginning)
                    (progn
                      (skip-chars-backward " \t")
                      (skip-chars-backward ";")
                      (setq beginning-of-comment (point))
                      (skip-chars-backward " \t")
                      (bolp))
                    (not (bobp)))
          (backward-char))
        beginning-of-comment)))
   (t
    (save-excursion
      ;; TODO: 現在のメジャーモードでのコメントが先頭にある場合はそれも無視すべき?
      ;; TODO: 「123 ;; (if」から複数行に続く形式に対応していない。123の部分にコメント以外のセミコロン(文字列等)があることを考慮しなければならない。
      (let (beginning-of-comment)
        (forward-line 0)
        (while (looking-at "[ \t]*;+[ \t]*")
          (setq beginning-of-comment (match-end 0))
          (forward-line -1))
        beginning-of-comment)))))

(defun my-elisp-sexp-in-comment (beg end)
  "BEGからENDの中にあるコメントの中にあるS式を返す。"
  (when (and beg end (< beg end))
    (let ((original-buf (current-buffer)))
      (with-temp-buffer
        (emacs-lisp-mode)
        (insert-buffer-substring original-buf beg end)
        (goto-char (point-min))
        ;; ↓これだと最終行のコメントが空でかつEOBの時になぜか「Beginning of buffer」のエラーが出る。
        ;; (uncomment-region (point-min) (point-max))
        (while (re-search-forward "^\\s-*;+" nil t)
          (replace-match "")
          ;; 次の行へ(現在の行にある残りのコメント中コメントは残す)。
          (forward-line 1))
        (goto-char (point-max))
        ;; 1段階コメントを外した後の状態からS式を取り出す。
        ;; コメントの中にコメントがある場合は、
        ;; 再帰的にこの関数が呼び出されることもある。
        ;; ;; ;; (+
        ;; ;; ;;  ;; コメント
        ;; ;; ;;  1 2)
        ;; みたいなのも正しく処理する。
        (elisp--preceding-sexp)))))

(defun my-elisp-preceding-sexp-in-comment ()
  "ポイントがコメント内にあるとき、複数行にわたるコメントを考慮して
ポイントの直前にあるS式を読み取る。"
  (my-elisp-sexp-in-comment (my-elisp-beginning-of-continuous-comments) (point)))

(defun my-elisp-preceding-sexp-around (orig-fun &rest args)
  "elisp--preceding-sexpの:around advice。"
  (let ((end (point))
        (beg (my-elisp-beginning-of-continuous-comments)))
    (if (and beg (< beg end))
        (my-elisp-sexp-in-comment beg end)
      (apply orig-fun args))))

(provide 'my-elisp)

(2024-01-30修正: コメントの中のコメントをうまく処理できるようにしました)

設定方法:

(when (version<= "25.1" emacs-version) ;; Require #'elisp--preceding-sexp
  (autoload 'my-elisp-preceding-sexp-around "my-elisp")
  (advice-add 'elisp--preceding-sexp :around 'my-elisp-preceding-sexp-around))

これで無事複数行にわたるコメント内のS式を評価できるようになりました。

ただ、メジャーモードがemacs-lisp-modeではない場合のことも考えると、少々煮え切らないコードになってしまいました。例えばorg-modeのソースコードブロックの中にコメントがあって、その中のS式を評価したい場合など。C言語やシェルスクリプトのコメント(//や#)の中に複数行にわたってS式を書いてそれを評価したい、なんてケースはあるでしょうか?(実際、別言語のソースコードのコメントの中にelispのコードを書いて、その別言語のコードを生成したことは度々あった気がします) 考え出すと切りがないです。文字列などクォートされたセミコロンを考慮しなければならないので単純な正規表現で処理するのもためらわれたりと色々面倒なところもありました。