2024-01-16 ,

Emacs Lisp要素へのorg-modeリンクをエクスポートする

以前Emacs Lisp要素へのリンクをorg-modeに追加するというのを書きました。[[elisp-function:create-image][create-image]]のような形式でEmacs Lispの要素(関数・変数・face)へのリンクを書けるようにするものです。そしてC-c C-oでその要素の定義箇所へジャンプできるようにしました。

しかしエクスポートには対応していませんでした。そのようなリンクを含むOrg文書をHTMLとしてエクスポートしても、意味のあるリンクにはなりません。

元々Emacs内でソースコードを追いかけるときのメモのために用意したのでエクスポートする必要は無かったのですが、こうしたブログに書くときに正しくエクスポートできると便利なことが多々あります。

シンボルから定義の場所を探す

まず要素のシンボルから定義の場所(ファイルと行番号)を割り出します。次のようなコードでできるようです。

(defun my-elisp-element-file-line (symbol finder)
  (ignore-errors
    (let* ((buf-point (funcall finder symbol))
           ;;@todo バッファやポイントの状態を元に戻す。ただし、開きっぱなしの方が連続して変換するときは効率が良い。
           (buffer (car buf-point))
           (point (cdr buf-point)))
      (with-current-buffer buffer
        (cons (expand-file-name (buffer-file-name)) ;;abs-file
              (line-number-at-pos point t)))))) ;;line

(defun my-elisp-function-file-line (symbol)
  (my-elisp-element-file-line symbol #'find-function-noselect))

(defun my-elisp-variable-file-line (symbol)
  (my-elisp-element-file-line symbol #'find-variable-noselect))

(defun my-elisp-face-file-line (symbol)
  (my-elisp-element-file-line symbol
                              (lambda (symbol)
                                (find-definition-noselect symbol 'defface))))

例えば次のように使います。

(my-elisp-function-file-line 'create-image)
("c:/略/share/emacs/29.1/lisp/image.el" . 484)

find-function-noselect関数等を使うとその副作用でバッファが開きっぱなしになったり、もし既に開いていた場合はポイントが動いてしまったりするのですが、とりあえず放っておきます。開きっぱなしの方が連続的に沢山のリンクをエクスポートするときに速いという面はあると思うので。

ファイルと行番号からURLを求める

次にその場所を指すURLを求めるのですが、そのURLはローカルファイルへのURLではなく、世界中から参照できるWorld Wide Web上のURLでなければなりません。でなければこういったブログで使うことはできません。

幸いほとんどのソースコードはWeb上に存在するものです。

EmacsにバンドルされているファイルへのURLを求める

Emacsのソースコードは https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/ から参照できます。

例えばEmacs 29.1時点でのimage.el内の484行目は https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/image.el?h=emacs-29.1#n484 で参照できます。

従って次のような関数を作れば:

(defun my-path-globalize-emacs (abs-file line)
  "Create a URL to a file bundled with Emacs."
  (let ((dirs `((,lisp-directory
                 . "https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/")
                (,find-function-C-source-directory
                 . "https://git.savannah.gnu.org/cgit/emacs.git/tree/src/"))))
    (cl-loop for (dir . url) in dirs
             when (and dir (string-prefix-p (expand-file-name dir) abs-file))
             return
             (concat
              url
              (file-relative-name abs-file dir)
              "?h=emacs-" emacs-version
              (when line (format "#n%d" line))))))

次のような式でローカルファイルへのパスと行番号からWeb上でのURLを作成できます。

(my-path-globalize "c:/略/share/emacs/29.1/lisp/image.el" 484)
"https://git.savannah.gnu.org/cgit/emacs.git/tree/lisp/image.el?h=emacs-29.1#n484"

package.elで管理されているファイルへのURLを求める

package.elで管理しているファイルについてもある程度URLを求めることが出来るようです。

(defvar my-path-globalize-package-globalizers
  '(my-path-globalize-package-org
    my-path-globalize-package-github))

(defun my-path-globalize-package (abs-file line)
  "Create a URL to the file managed by package.el."
  (when-let* ((pkg-name-desc (my-path-globalize-package-find abs-file))
              (pkg-name (car pkg-name-desc))
              (pkg-desc (cdr pkg-name-desc))
              (pkg-dir (package-desc-dir pkg-desc))
              (rel-file (file-relative-name abs-file pkg-dir)))
    (seq-some (lambda (globalizer)
                (funcall globalizer rel-file line pkg-name pkg-desc))
              my-path-globalize-package-globalizers)))

(defun my-path-globalize-package-find (abs-file)
  ;;@todo package-user-dirで足切りする? package-directory-listも?
  (cl-loop for (name . descs) in package-alist
           for desc = (cl-loop for desc in descs
                               for dir = (package-desc-dir desc)
                               when (and dir
                                         (string-prefix-p
                                          (expand-file-name dir) abs-file))
                               return desc)
           when desc
           return (cons name desc)))

(defun my-path-globalize-package-org (rel-file line pkg-name pkg-desc)
  (when (eq pkg-name 'org)
    (when-let* ((pkg-extras (package-desc-extras pkg-desc))
                (pkg-commit (alist-get :commit pkg-extras)))
      (concat
       "https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/"
       rel-file
       "?id=" pkg-commit
       (when line (format "#n%d" line))))))

(defun my-path-globalize-package-github (rel-file line _pkg-name pkg-desc)
  (when-let* ((pkg-extras (package-desc-extras pkg-desc))
              (pkg-url (alist-get :url pkg-extras))
              (pkg-commit (alist-get :commit pkg-extras)))
    (when (string-match "\\(https?://github\\.com/[^/]+/[^/]+\\)" pkg-url)
      (concat
       (match-string 1 pkg-url) "/blob/" pkg-commit "/" rel-file
       (when line (format "#L%d" line))))))

パッケージの紹介Webサイト(?)がGitHubの場合は、GitHubにあるソースコードを参照するようにしてみました。また、Org-modeは特別に処理しています。他にも必要に応じてルールを追加する必要があるでしょう。

例えば vertico-mode の場所:

(my-path-globalize-package (expand-file-name "~/.emacs.d/elpa/vertico-20231229.1740/vertico.el") 741)
"https://github.com/minad/vertico/blob/93f709d71e8908617a21ca469fd60123f5037ae4/vertico.el#L741"

org-link-parametersの場所:

(my-path-globalize-package (expand-file-name "~/.emacs.d/elpa/org-9.6.14/ol.el") 92)
"https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ol.el?id=58c91cbf9f2510700fbbdaaa166efcb1a5582cf7#n92"

両方まとめる

とりあえずこの二つをまとめて次のようにすることで:

(defvar my-path-globalizers
  '(my-path-globalize-emacs
    my-path-globalize-package))

(defun my-path-globalize (file line)
  "Return a URL on the World Wide Web that points to the LINE of the FILE.

FILE is a path to a local file that has the same content on the WWW.

LINE is a line number starting from 1."
  (let ((abs-file (expand-file-name file)))
    (seq-some (lambda (globalizer) (funcall globalizer abs-file line))
              my-path-globalizers)))

(my-path-globalize ファイル名 行番号)でローカルにあるEmacs Lispファイルの場所をWeb上のURLへ変換できるようになりました。

これを使ってシンボルから直接Web URLを求める関数も作成できます。

(defun my-elisp-face-web-url (symbol)
  (my-elisp-element-web-url symbol
                            (lambda (symbol)
                              (find-definition-noselect symbol 'defface))))

(defun my-elisp-variable-web-url (symbol)
  (my-elisp-element-web-url symbol #'find-variable-noselect))

(defun my-elisp-function-web-url (symbol)
  (my-elisp-element-web-url symbol #'find-function-noselect))

(defun my-elisp-element-web-url (symbol finder)
  (when-let ((file-line (my-elisp-element-file-line symbol finder)))
    (my-path-globalize (car file-line) (cdr file-line))))

org-modeでエクスポートする

後はorg-modeのリンクタイプに設定してやるだけです。

エクスポート用の関数を用意し:

(defun my-org-elisp-export-function (path desc format)
  (my-org-elisp-export-element path desc format #'my-elisp-function-web-url))

(defun my-org-elisp-export-variable (path desc format)
  (my-org-elisp-export-element path desc format #'my-elisp-variable-web-url))

(defun my-org-elisp-export-face (path desc format)
  (my-org-elisp-export-element path desc format #'my-elisp-face-web-url))

(defun my-org-elisp-export-element (path desc format path-converter)
  (or (when (eq format 'html)
        (when-let ((url (funcall path-converter (intern path))))
          (format "<a href=\"%s\">%s</a>" url (or desc path))))
      ;; pathが解決できないときはリンクにしない。
      (or desc path)))

org-link-parametersへ登録してやります:

(org-link-set-parameters
 "elisp-function"
 :follow (lambda (name) (find-function (intern name)))
 :export #'my-org-elisp-export-function)

(org-link-set-parameters
 "elisp-variable"
 :follow (lambda (name) (find-variable (intern name)))
 :export #'my-org-elisp-export-variable)

(org-link-set-parameters
 "elisp-face"
 :follow (lambda (name) (find-face-definition (intern name)))
 :export #'my-org-elisp-export-face))

テスト

試しに次にいろんな要素へのリンクを書いてみました。

Orgソース:

- Emacsバンドル
  - [[elisp-function:create-image]] (lisp/image.el)
  - [[elisp-function:url-retrieve]] (lisp/url/url.el ←階層あり)
  - [[elisp-function:widget-get]] (src/fns.c)
  - [[elisp-variable:truncate-lines]] (src/buffer.c)
  - [[elisp-face:font-lock-comment-face]] (lisp/font-lock.el)
- package.el
  - GitHub
    - [[elisp-function:vertico-mode][vertico-mode]]
  - org-mode
    - [[elisp-function:org-mode][org-mode]]
    - [[elisp-variable:org-link-parameters][org-link-parameters]]

注意点:再エクスポートで同じURLが生成されない問題

URLは現在実行している環境で使われているバージョンのソースコードを参照しています。文書を書いている時点のソースコードを指し示すのが最も無難だろうとの判断によるものですが、不都合なこともあるかもしれません。特に文書を書いてからしばらく経った後、Emacsやパッケージをバージョンアップした後に再度エクスポートした場合、同じURLが生成されないという問題があります。これが嫌なのであれば、大人しく通常のhttp(https)リンクを使うべきでしょう。上の仕組みを流用してそのようなhttpリンクを生成するようなコマンドも容易に作成できることでしょう。