org roam 风格的系统配置文件管理

2021-11-02 二 00:00 2024-02-03 六 01:42
make_init.gif

修改历史

  • [2024-02-03 六 00:17] 添加 org-tangle 一节,介绍如何从 tangle 的代码反向跳转回到 org 链接

本文分享一种利用 org-babel 在 emacs 中分布式地管理系统配置文件的方法,所谓分布式, 就是说一份配置文件里的内容可以根据功能分散在不同的 org 文件中,再通过 org-babel 跨文件 tangle 的功能汇总到一个能够被加载的配置文件里。

在这种方式下,可以把软件的配置和笔记记录在同一个 org 文件或 header 下,与 org-roam 融合。

Emacs 的常见配置方式

假设当前只用 evil, org-mode, python-mode 三个功能,一般有以下几种组织配置文件的方式:

1. All in init.el

将所有 elisp 全部写在 emacs.d/init.el 中, 这种方式适合包比较少的情况,如果配置代码多,维护起来比较吃力。

2. 结构化 emacs.d/

在 emacs.d/ 下新建子目录,其中保存各个模块的单独配置,如 evil.el, org.el, python.el, 而 init.el 只是单纯的 require 它们。

大部分 emacs 的 starter kit 都是用这样的方式来结构化的,比如 Centaur Emacs

3. All in init.org

将所有配置都以文学编程的方式写在一个 org 文件中,例如 init.org

根据导出时间不同,又可以大概分成两种模式:

  • 一是在 emacs 启动时去自动 tangle init.org, 生成 init.el 然后 emacs 再加载 init.el

    这种方式的缺点是 tangle 本身需要时间,使得 emacs 启动时间变得比较长

  • 二是手动执行 tangle 或者设置在文件保存时自动 tangle

这种方式的好处是可以比较方便把配置发布成 blog 或者 github readme, 在 org 里能用文字进行注释,链接等等,可读性很好. 比如 M-EMACS 以及令人惊叹的 tecosaur Doom Emacs Configuration

还有哪些可能?

我个人使用过第一种和第三种方式,这都是 all-in-one 的风格,把所有配置都放在一个文件里。

尽管第三种方式可以利用 org mode 把不同包放在不同层级的 header 下, 一定程度上减缓因配置增长带来的混乱,但当 header 太多或层级过深时,无论阅读或者编辑都会变得更麻烦(个人习惯)。

把代码和对代码的解释穿插着的 "文学编程" 范式又很对我的胃口,因此我跳过第二种方式,希望找到更加"结构化"的基于 org 的方法。此外,由于配置已经 org 化了,作为 org-roam 的使用者,把它与 org-roam 更好地融合也是一个比较自然的想法。

跨文件 noweb 和 tangle

正如第一种方法使用久了自然就会开始考虑把 init.el 拆分,最终发展出第二种方式。

同样的,当 init.org 过于臃肿时,就可以考虑把 init.org 拆分成如 evil.org, python.org 多个文件, init.org 只是去 "require/include/import" 包含特定 package 配置的 org 文件。

可以在单个 org 文件里利用 org-babel 来把一个代码块的内容 "include" 到另外一个代码块

如下:

#+name: abcd
(setq org-abcd 1)
#+name: print
<<abcd>>
(prin1 org-abcd)
1

在 org 文档里源代码是:

#+name: abcd
#+begin_src emacs-lisp
(setq org-abcd 1)
#+end_src


#+name: print
#+begin_src emacs-lisp :noweb yes
<<abcd>>
(prin1 org-abcd)
#+end_src

#+RESULTS:
: 1

以上名为 print 的代码块把名为 abcd 代码块里的内容引入,因此可以打印出 org-abcd 的值,注意第二个代码的 header 中需要声明 :noweb yes

如果对 print 进行 tangle, 例如在其 header 中加上 :tangle /tmp/print.el 然后执行 c-u org-babel-tangle, 那么 /tmp/print.el 的结果中就会包含以下两行代码:

(setq org-abcd 1)
(prin1 org-abcd)

假设 abcd 代码块是在另外一个文件中,例如 /tmp/variable.org ,那么就需要用跨文件 noweb 来调用, 经尝试发现不能直接在 print 代码块里用 <</tmp/variable.org:abcd>> 引用, 参考 emacs - In Org-mode how to call code block to evaluate from other org file? - Stack Overflow , 需要用 org block 进行一次包装后再调用,如下:

# /tmp/variable.org 文件

#+name: abcd
#+begin_src emacs-lisp
(setq org-abcd 1)
#+end_src

#+name: abcd-api
#+begin_src org :noweb yes
<<abcd>>
#+end_src

print 的写法是:

# init.org 文件

#+name: print
#+begin_src emacs-lisp :noweb yes
<</tmp/variable.org:abcd-api()>>
(prin1 org-abcd)
#+end_src

注意 abcd-api() 是函数调用的语法,执行以上代码就能正常打印出定义在 /tmp/variable.org 文件里名称为 abcd 的 src block 中的 org-abcd 变量(经过了 abcd-api 封装,可以把用 org 封装看作是类似 elisp 中的 provide 语法,对外提供 接口)。tangle 以上代码得到的结果与在上文里同一个文件中进行 tangle 的结果是一致的。

用这个方法,就可以把配置放到对应的笔记中了,例如以下是我个人的 init.org 的部分配置,做简单说明:

  • python 是从 elpy.org 里 include 的,elpy.org 里还有对 elpy 以及 python 开发环境知识的笔记(例如什么是 jedi, lsp, lint 等)
  • racket 用的不多,配置也少,因此没有分到单独文件
  • org-mode 由于配置比较多,拆分成了多个文件,例如 orgmode_workflow.org 里 org-basic-setting 是对 org 的基本设置(字体,icon 等)的接口, 此外 roam, rss, babel 都有不同的 roam 笔记,因此从不同的文件中 noweb 调用。
* python 编程环境配置
#+begin_src emacs-lisp :results none :noweb yes
<<~/org/logical/elpy.org:setting()>>
#+end_src

* racket mode
#+begin_src emacs-lisp :results none
(use-package racket-mode)
#+end_src

* org-mode environment

#+begin_src emacs-lisp :results none :noweb yes
<<~/org/logical/orgmode_workflow.org:org-basic-setting()>>

<<~/org/logical/orgmode_workflow.org:org-agenda-setting()>>

<<~/org/logical/org_babel.org:setting()>>

<<~/org/logical/org_roam_eco.org:setting()>>

<<~/org/logical/rss.org:elfeed-org-setting()>>
#+end_src

不在当前 emacs 中 tangle

以上一节提到的 init.org 为例,如果在 init.org 里直接执行 org-babel-tangle, 由于 init.org 调用了 org_roam_eco.org , elpy.org 等, 而 elpy.org 可能继续又调用了 python.org, lsp.org , 那么 org-babel 为了从这些文件读取代码块,会把依赖路径上的所有文件都打开成 buffer, 使得 buffer list 一片混乱。

解决方法是用外部脚本执行 tangle,核心代码如下:

(org-babel-tangle-file "~/org/init.org" "~/emacs.d/init.el")

另外,可以将以上方法推广到管理所有的配置文件,如 i3, tmux, bashrc, vimrc 等等,这样的话,需要为每一个配置文件都写一个类似的 tangle 脚本,为了控制这类复杂度,可以借用 makefile 来处理

Makefile 文件放在 roam 的主目录下,例如 ~/org, 以下是部分内容

.SILENT:
.SUFFIXES:
.PHONY: clean

wemacs:
	emacs --batch -l ~/metaesc/make_tangle.el --eval "(tangle-config-file \"init\")"
	emacs

tmux:
	emacs --batch -l ~/metaesc/make_tangle.el --eval "(tangle-config-file \"tmux\")"
	tmux source-file ~/myconf/.tmux.conf

hugchange:
	emacs --batch -l ~/metaesc/publish.el

其中 ~/metaesc/make_tangle.el 文件部分内容如下:

(require 'package)
(setq package-user-dir "~/.emacs.d/elpa")
(package-initialize)
(setq org-id-locations-file "~/.emacs.d/.org-id-locations")
(setq org-confirm-babel-evaluate nil
      org-src-preserve-indentation t)
(org-babel-do-load-languages
 'org-babel-load-languages '((org . t) (shell . t))
 )
(require 'org)
(require 'ob-tangle)
(setq file-map '(("init" "~/org/init.org" "~/myconf/.emacs.d/init.el")
                 ("tmux" "~/org/logical/tmux.org")))

(defun tangle-config-file (name)
  (let ((prog-mode-hook nil)) ;; Avoid running hooks when tangling.
    (let ((args (cdr (assoc name file-map))))
         (apply #'org-babel-tangle-file args))
    (message (format "Tangled %s completed." name))))

Makefile 中三个入口的功能说明:

  • wemacs 是 emacs 配置的入口,只要在 ~/org 下执行 make wemacs, 就会把 init.org 里的所有内容 tangle 到 ~/metaesc.d/init.el 下( ~/.emacs.d 再软链接到 ~/metaesc.d ),为了避免在 terminal 中手动执行 make 命令,可以安装 emacs helm-make 插件。

    本文开始的 gif 展示的就是调用 helm-make 后选择并导出 emacs 配置的过程, 动画最后出现的图片重叠是因为此时启动了另外一个 emacs (makefile 里 wemacs 入口第二行),导致当前 emacs 窗口变小了(i3 自动平铺 ),这样可以马上检查刚修改后的 emacs 配置是否有 bug, 以便快速修复。

    我当前对 emacs 配置修改的工作流就是,用 org-roam-find 直接找到要修改的模块的 org 文件或 header ,修改后马上执行一次 helm-make.

  • tmux 则把 ~/org/logical/tmux.org 中明确写有 :tangle 目标的 src block 进行 tangle,并且马上执行 tmux reload 验证是否生效。例如 tmux.org 里以下 block 会被 tangle ( ~/.tmux.conf~/myconf/.tmux.conf 的软链接)

    #+begin_src conf :tangle ~/myconf/.tmux.conf :noweb yes 
    <<basic-setting>>
    <<window-pane>>
    <<style>>
    <<copy-mode>>
    <<plugins>>
    #+end_src
    
  • hugchange 是本博客的发布命令,这里想说明的是,只要是与 org 有关的一次性的操作都可以考虑用 make 来管理,不单单是配置管理

在 tangle 代码和 org 之间跳转

orgmode 默认提供了 org-babel-tangle-jump-to-org 函数,支持从源码跳转到 org src block, 这个函数在快速实验新配置时很方便,比如在 init.el 中加入了一段新的代码,然后马上启动 emacs 测试效果,如果发现满意,可以调用该函数直接跳转到 org block 位置,将这段代码复制回去,这样就省去了重新 tangle 的操作。不过这需要在 tangle 时添加一些源 org 文件的位置信息,通过以下语句设置

(setq org-babel-default-header-args '((:comments . "noweb")))

这样 tangle 时会在代码块上下加入包含 org 路径+heading 名的注释, org-babel-tangle-jump-to-org 通过这个信息可以直接找到路径并且通过正则匹配定位到 src block 所在的 org section.

但 org 没有提供从 org src 跳转到源码的功能,这可能是考虑到一个 src block 代码可以 tangle 到多个地方,并且可以在 org-babel-tangle 中指定文件名,因此从 org 里无法确定当前 tangle 的 ,不过对于本文所介绍的场景,一般只考虑把 org block tangle 到一个源码文件,因此我们就可以手动设置几个目标文件路径,比如 ~/.emacs.d/init.el, ~/.config/i3/config 然后编写一个函数,读取当前光标所在的 org 的文件路径和 heading 名称构造成搜索关键字,到对应文件里去搜索即可

然而,在 tangle 嵌套的 block 时,注释中的文件路径也会嵌套,导出出现类似以下格式的注释(这是 org9.5 之前 org-id-link-to-org-use-id 设置为 t 时导出的注释,以 id 链接形式而不是文件链接,但 9.6.6 后取消了,如果时 org9.5 之前,可以把以上变量设置为 nil):

;; # [[[[id:b23db33b-f6e0-4f02-bf14-b6e173669830][evil wrapper]]][evil-custom]]

这种方式会使得 org-babel-tangle-jump-to-org 识别路径失败,因此要在 tangle 后加一个 hook, 把以上链接转成标准的 org 链接,如下形式:

;; [[id:b23db33b-f6e0-4f02-bf14-b6e173669830][evil-custom]]

不过这里又有注意事项: org-babel-post-tangle-hook 是在 tangle 结束后,读取对应源文件到一个临时的 buffer 中,然后执行其中的处理函数,接着默认 kill 掉这个 buffer, 所以必须在 hook 的函数中对该 buffer 内容修改后明确调用 save-buffer 才行,否则修改不会默认写回到文件。因此这个 hook 更多是用来执行系统命令的,例如 tangle 完后执行编译。最终的 make_tangle.el 脚本核心设置为:

(require 'find-lisp)
(require 'org)
(require 'org-id)
(require 'ob-tangle)
(setq org-id-locations-file "~/.wemacs/.org-id-locations")
(setq org-confirm-babel-evaluate nil
      org-src-preserve-indentation t)
(org-babel-do-load-languages
 'org-babel-load-languages '((org . t) (shell . t))
 )
(setq org-babel-default-header-args '((:comments . "noweb")))

;; org-id-link-to-org-use-id 为 t 会对每个 tangle src block 所在 subtree 生成 id(如果没有)
;; not work after org9.5, so the comments is always file link, not ids
(setq org-id-link-to-org-use-id nil) 

;; 用绝对路径,因为相对路径经常算错
(setq org-babel-tangle-use-relative-file-links nil)


(defun my/org-babel-comment-out-link-lines ()
  "Comment out all lines starting with # [[."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "^[[:space:]]*# \\[\\[" nil t)
      (comment-region (line-beginning-position) (line-end-position))))
  (save-buffer))


(defun my/org-babel-comment-out-ends-here-lines ()
  "Comment out all lines starting with # and ending with ends here."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "^[[:space:]]*#.*ends here$" nil t)
      (comment-region (line-beginning-position) (line-end-position))))
  (save-buffer))

(defun my/fix-nested-link-format ()
  "Modify link format in the tangled FILE."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "\\[\\[\\[\\[\\(id:\\|file:\\)\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]\\]\\[\\([^]]+\\)\\]\\]"
                              nil t)
      (replace-match "[[\\1\\2][\\4]]"))
    (save-buffer)))

(add-hook 'org-babel-post-tangle-hook #'my/org-babel-comment-out-link-lines)
(add-hook 'org-babel-post-tangle-hook #'my/org-babel-comment-out-ends-here-lines)

完整见 metaesc/make_tangle.el at main · metaescape/metaesc

跳转函数定义如下(这部分在 init.el 中):

#+name: tangle-block
(defun tangle-current-block()
  (interactive)
  (let ((current-prefix-arg '(4)))
     (call-interactively 'org-babel-tangle)
))

(defun my/get-org-heading-id ()
  "Get the Org ID of the current heading."
  (save-excursion
    (org-back-to-heading t)
    (org-id-get)))

(defun my/jump-to-tangled-target-block-by-id ()
  "Jump to the tangle target block in the target files."
  (interactive)
  (let ((id (my/get-org-heading-id))
        (files '("~/.wemacs/init.el" "~/metaesc/.config/i3/config")))
    (unless id
      (error "No Org ID found for the current heading"))
    (catch 'found
      (dolist (file files)
        (let ((buffer (find-file-noselect (expand-file-name file))))
          (with-current-buffer buffer
            (message (concat "search " file))
            (goto-char (point-min))
            (when (re-search-forward (concat ";; .*\\[\\[id:" id "\\]\\[") nil t)
              (switch-to-buffer buffer)
              (goto-char (point-min))
              (re-search-forward (concat ";; .*\\[\\[id:" id "\\]\\[") nil t)
              (throw 'found t)))))
      (error "ID not found in target files"))))

(defun my/get-org-heading-name ()
  "Get the name of the current Org heading."
  (save-excursion
    (org-back-to-heading t)
    (let ((heading (org-get-heading t t t t)))
      ;; 提取纯文本标题部分,不包括 TODO 关键字、优先级和标签。
      (concat "*" (org-element-property :raw-value (org-element-at-point))))))

(defun my/get-org-src-block-name ()
  "Get the name of the current Org source block."
  (save-excursion
    (unless (org-babel-where-is-src-block-head)
      (error "Not in a source block"))
    (org-babel-get-src-block-info 'light)
    (nth 4 (org-babel-get-src-block-info 'light))))

(defun my/jump-to-tangled-target-block-by-name ()
  "Jump to the tangle target block by its name in the target files."
  (interactive)
  (let* ((name (or (my/get-org-src-block-name) (my/get-org-heading-name)))
         (file-path (abbreviate-file-name (or (buffer-file-name) "")))
         (search-key (concat file-path "::" name))
         (files '("~/.wemacs/init.el" "~/metaesc/.config/i3/config")))
    (unless name
      (error "No name found for the current source block or heading"))
    (catch 'found
      (dolist (file files)
        (let ((buffer (find-file-noselect (expand-file-name file))))
          (with-current-buffer buffer
            (goto-char (point-min))
            (when (re-search-forward (regexp-quote search-key) nil t)
              (switch-to-buffer buffer)
              (throw 'found t)))))
      (error "Name not found in target files"))))

(defun jump-between-tangled-src-and-org-block ()
  "Jump to the corresponding block depending on the current mode."
  (interactive)
  (cond
   ((derived-mode-p 'org-mode)
    (my/jump-to-tangled-target-block-by-name))
   ((derived-mode-p 'prog-mode)
    (org-babel-tangle-jump-to-org))
   (t
    (message "Not in Org mode or a programming mode."))))

把代码当作知识?

将 config 代码放到 org-roam 节点下的心理动机可能来源于对一切信息进行知识化的倾向,初衷和 org-roam 是一样的:对知识、想法进行卡片式记录以及双向链接或许可以在比较细的粒度层面建立和发掘知识之间的关系,只是这里把配置代码也看作知识或想法。

emacs.d 中的所有配置虽然只属于 emacs 这一个软件, 在使用时,必须把它们放在 emacs 约定的配置加载目录下,但为了编写它们,可能涉及对整个系统各方面的理解和使用习惯,配置的知识层面不应该被它的使用方式束缚。从这个角度看,本文的方法像是通过 noweb 语法在 config 的理解与使用之间加上间接引用层,从而对这两个属性进行了解耦。

比如 evil 的相关配置在内容上更接近 vim 而不是 emacs, 因此 evil.org 中不单记录了 evil 配置,也有指向 vim.org 的链接。这样在修改了 evil 的某个改键后,链接自然会提醒你是否要跳转到 vim 的结点中去修改 vimrc, 使得 emacs 和 vim 的按键保持同步。(evil 本身又可以继续拆分,例如 evil 中的 text object 实际是一种特殊的交互模式,它可以和鼠标右键,embark 等建立联系)

又比如,emacs-rime 可以看作是 rime 的前端,后端是 librime 动态链接库, 因此我可以把 emacs-rime 的配置直接放在 rime.org 的子节点中,配置旁还可以添加 librime 或者 C 语言动态链接库的节点链接,这样对某个知识的理解可能会更加立体,当然也会增加负担,这取决于梳理知识所能投入的时间、方法和积极性等。

"把代码也当作知识?" 或许可以从卡片式知识管理或者文学编程的讨论里找一点线索, 但这不是个很快会有确切答案的问题,只能说,以我目前的使用经验来看,这种方式给我带来的清晰是多于混乱的。

得与失

具体来说,这个方法带来的好处有:

  1. 可以很好地与 org-roam 结合,配置不单单是面向 emacs 或 vim 等程序的,也是面向人的,在知识层面,可以借助 roam 发散式地看到更多功能之间的关系。
  2. 可以在 init.org 中添加对各个子模块的说明,然后单独发布成 blog 分享,因此集合了方法三的优势
  3. 通过 helm-make 获得了一个控制各类与 org 有关的配置 tangle 以及 blog 发布的入口,有一种统筹感

代价:

  1. 维护的成本,这属于知识管理的通用代价。
  2. emacs 本来是直接对 init.el 进行解释,但把配置代码放在 org 后,org 是无法直接被 emacs 解释的,这就相当于把 org 当作源代码,tangle/make 是编译构建的过程。因此每次修改某个 org 里的配置后,需要执行 tangle 再启动 emacs, tangle 大概要 2-3s。这类似从解释型语言换成编译型的代价。

    一种缓和方式: 如果只是尝试新的包或者实验新的功能,可以直接在 init.el 末尾添加配置,然后启动 emacs 验证,一旦发现新配置不适合自己, 把修改内容删除或者重新执行一次 make 把 init.el 覆盖,这样就可以跳过 tangle 步骤, 只有真的要长期使用再把代码移动到 org 中,然后再执行 tangle。

    在积累配置的早期,由于频繁增加代码并验证,tangle 的时间成本会很明显,可以先用 init.el 方式,待配置趋于稳定后整理阶段再考虑这种方法。


如对本文有任何疑问,欢迎通过 github issue 邮件 metaescape at foxmail dot com 进行反馈