暗无天日

=============>DarkSun的个人博客

Emacs 缓存文件大整理,用一个 alist 管住所有散落状态

打开你的 ~/.emacs.d ,看看里面有什么。

bookmarks、auto-saves、recentf、savehist、transient、tramp、url、multisession、image-dired、erc、rcirc……乱七八糟一堆文件。这些文件散落各处,想清不敢清,想管没头绪。

Rahul M. Juliato 在 Taming Emacs Cache and Temporary Files 一文中,给出了一套不依赖任何外部包的整理方案,把所有这些缓存文件集中管起来。

设计思路,一个 alist 驱动一切

Emacs 内部有一堆变量控制各类缓存文件的存放位置。比如 bookmark-default-file 管书签存哪, recentf-save-file 管最近打开的文件列表存哪, tramp-persistency-file-name 管 TRAMP 的连接信息存哪,等等。每个变量各自指向一个路径,默认散落在不同目录中。

为了解决这个问题,我们可以:

  1. 定义一个 alist,把变量名映射到相对路径
  2. 一个辅助函数把 key 解析成绝对路径
  3. 另一个辅助函数在Emacs启动时自动创建所有需要的目录,相当于 mkdir -p
  4. use-package :custom 把所有变量指向这些路径

这样一来, 我们可以把所有缓存文件放到一个目录中进行管理,想清空就 rm -rf ,想备份就整个目录打包。

一,先定义缓存文件存放的根目录

这里用 defcustom ,不是 defvar 来定义根目录。

defcustom 是 Emacs 中专门定义「用户可配置选项」的宏。它和 defvar 最大的区别,是 defcustom 定义的变量会出现在 M-x customize 界面里,你可以用图形化的方式进行修改,还能把值持久化到 custom.el 中。这样一来,你不需要手工修改配置文件就能切换缓存位置。

(defcustom my/cache-directory
  (expand-file-name "cache/" user-emacs-directory)
  "Base directory for Emacs cache files.
All entries in `my/cache-paths' are resolved relative to this
directory.  Choose one of the presets or supply any custom path.
Changes take effect after restarting Emacs."
  :type `(choice
          (const     :tag "Inside Emacs config  (cache/ in user-emacs-directory)"
                     ,(expand-file-name "cache/" user-emacs-directory))
          (const     :tag "System temp          (/tmp/emacs-cache/)" "/tmp/emacs-cache/")
          (directory :tag "Custom directory"))
  :group 'my)

三个预设选项:

  • config 内 (默认),缓存放 ~/.emacs.d/cache/ ,和配置放一起,持久化留存
  • /tmp ,缓存放 /tmp/emacs-cache/ ,重启即清空,适合录屏或测试
  • 自定义路径 ,随便指到哪

注意默认值用的是 user-emacs-directory 而不是硬编码 ~/.emacs.d/ 。原因是 Emacs 29 引入了 --init-directory 命令行参数,你可以 emacs --init-directory=/path/to/config 这样启动 Emacs 。用 user-emacs-directory 锚定路径,缓存会跟着当前启动的配置走,而不是死板的都往默认 ~/.emacs.d 里写。

最后还需要一个小补丁,需要在 init.el 中指明重新加载 custom-file

(when custom-file
  (load custom-file 'noerror 'nomessage))

二,alist 作为唯一配置源

第二个组件是一个 alist,用变量名作 key,相对路径作 value。整个方案里所有「什么东西该放哪」的信息都集中在这一处。

(defvar my/cache-paths
  '(
    ;; 文件类
    (bookmark-file               . "bookmarks")
    (ielm-history-file-name      . "ielm-history.eld")
    (project-list-file           . "projects")
    (recentf-save-file           . "recentf")
    (savehist-file               . "history")
    (save-place-file             . "saveplace")
    (transient-history-file      . "transient/history.el")
    (transient-levels-file       . "transient/levels.el")
    (transient-values-file       . "transient/values.el")
    (tramp-persistency-file-name . "tramp")
    (nsm-settings-file           . "network-security.data")
    ;; 目录类(注意尾部斜杠)
    (auto-saves                  . "auto-saves/")
    (auto-saves-sessions         . "auto-saves/sessions/")
    (multisession-directory      . "multisession/")
    (url-configuration-directory . "url/")
    (image-dired-dir             . "image-dired/")
    (erc-log-channels-directory  . "erc/logs/")
    (erc-image-cache-directory   . "erc/images/")
    (rcirc-log-directory         . "rcirc/logs/"))
  "Alist of (KEY . RELATIVE-PATH) for cache locations.
RELATIVE-PATH is resolved against `my/cache-directory'.
A trailing slash on RELATIVE-PATH marks the entry as a directory.")

这里遵照 函数的约定,value 以 / 结尾的是目录,否则是文件。后面的辅助函数会根据这个来区分要不要自动创建目录。

这里作者故意把 tree-sitter/eln-cache/eshell/ 排除在外。因为 tree-sittereln-cache 的内容来自耗时较长的编译过程(语法树编译、本地代码编译),重新生成成本太大。 eshell 里的是命令历史和别名,更接近 dotfile,算不上临时状态。大家的工作流可能不同,按需增减就行。

三,两个辅助函数

两个辅助函数,一个解析路径,一个创建目录。

(defun my/cache--path (key)
  "Return the absolute path for KEY in `my/cache-paths'."
  (let ((rel (cdr (assq key my/cache-paths))))
    (unless rel
      (error "my/cache--path: Unknown key %S" key))
    (expand-file-name rel my/cache-directory)))

my/cache--pathassq 查找 key。 assq 对 symbol key 用 eq 做比较(比 assocequal 更快)。 unless 分支在你 :custom 块里打错了变量名的情况下给你错误提示。没有这个错误检查的话,Emacs 会静默地把值设成 nil ,然后很多包在 nil 路径上写文件,会出现莫名其妙的错误。

(defun my/cache--ensure-dirs ()
  "Create every directory referenced by `my/cache-paths'.
Entries ending in `/' are created directly; other entries have their
parent directory created."
  (dolist (entry my/cache-paths)
    (let* ((abs (my/cache--path (car entry)))
           (dir (if (directory-name-p abs)
                    abs
                  (file-name-directory abs))))
      (make-directory dir t))))

(my/cache--ensure-dirs)

my/cache--ensure-dirs 在启动时跑一遍,确保所有需要的目录都存在。 make-directory 的第二个参数 t 相当于 mkdir -p ,所以 transient/history.el 会自动创建 <cache>/transient/ 目录,哪怕 alist 里没单独列出它。

拼接起来

把三部分内容拼接起来就行了。

大部分缓存变量可以直接在 use-package emacs:custom 块里设:

(use-package emacs
  :ensure nil
  :custom
  (bookmark-file               (my/cache--path 'bookmark-file))
  (ielm-history-file-name      (my/cache--path 'ielm-history-file-name))
  (project-list-file           (my/cache--path 'project-list-file))
  (recentf-save-file           (my/cache--path 'recentf-save-file))
  (savehist-file               (my/cache--path 'savehist-file))
  (save-place-file             (my/cache--path 'save-place-file))
  (transient-history-file      (my/cache--path 'transient-history-file))
  (transient-levels-file       (my/cache--path 'transient-levels-file))
  (transient-values-file       (my/cache--path 'transient-values-file))
  (nsm-settings-file           (my/cache--path 'nsm-settings-file))
  (multisession-directory      (my/cache--path 'multisession-directory))
  (url-configuration-directory (my/cache--path 'url-configuration-directory))
  )

特殊变量处理

:custom 块适合大部分变量,但有几种情况需要单独处理。

auto-save

auto-save 用的不是单值变量,而是路径变换规则:

(setq auto-save-list-file-prefix (my/cache--path 'auto-saves-sessions)
      auto-save-file-name-transforms
      `((".*" ,(my/cache--path 'auto-saves) t)))

auto-save-list-file-prefix 控制「待恢复文件列表」的存放位置( M-x recover-session 读的就是它)。 auto-save-file-name-transforms 是一个 (REGEX REPLACEMENT UNIQUIFY) 三元组列表,第三个元素 t (uniquify 标志)会把原始路径编码进文件名,这样两个同名的文件就不会在 auto-save 目录里互相覆盖了。

TRAMP 等延迟加载变量

use-package emacs:custom 块在启动时就执行了,但有些变量的定义来自后续才加载的包。包还没加载就去设它的变量,轻则无效,重则报错。遇到这种,把 setopt 放进对应包的 :config 块:

(use-package tramp
  :config
  (setopt tramp-persistency-file-name (my/cache--path 'tramp-persistency-file-name)))

setoptsetq 的现代替代品,专门用于 defcustom 定义的变量。关键区别在于: setopt 会执行变量定义的 :set 函数(如果有的话), setq 直接赋值则会跳过这一步。 tramp-persistency-file-name 定义了 :set 函数来触发 TRAMP 内部重载,用 setq 会跳过这一步,导致 TRAMP 状态不一致。

完整代码 + 最终效果

所有组件拼在一起,就是这样:

(defcustom my/cache-directory
  (expand-file-name "cache/" user-emacs-directory)
  "Base directory for Emacs cache files."
  :type `(choice
          (const     :tag "Inside Emacs config  (cache/ in user-emacs-directory)"
                     ,(expand-file-name "cache/" user-emacs-directory))
          (const     :tag "System temp          (/tmp/emacs-cache/)" "/tmp/emacs-cache/")
          (directory :tag "Custom directory"))
  :group 'my)

(when custom-file
  (load custom-file 'noerror 'nomessage))

(defvar my/cache-paths
  '((bookmark-file               . "bookmarks")
    (ielm-history-file-name      . "ielm-history.eld")
    (project-list-file           . "projects")
    (recentf-save-file           . "recentf")
    (savehist-file               . "history")
    (save-place-file             . "saveplace")
    (transient-history-file      . "transient/history.el")
    (transient-levels-file       . "transient/levels.el")
    (transient-values-file       . "transient/values.el")
    (tramp-persistency-file-name . "tramp")
    (nsm-settings-file           . "network-security.data")
    (auto-saves                  . "auto-saves/")
    (auto-saves-sessions         . "auto-saves/sessions/")
    (multisession-directory      . "multisession/")
    (url-configuration-directory . "url/")
    (image-dired-dir             . "image-dired/")
    (erc-log-channels-directory  . "erc/logs/")
    (erc-image-cache-directory   . "erc/images/")
    (rcirc-log-directory         . "rcirc/logs/"))
  "Alist of (KEY . RELATIVE-PATH) for cache locations.")

(defun my/cache--path (key)
  "Return the absolute path for KEY in `my/cache-paths'."
  (let ((rel (cdr (assq key my/cache-paths))))
    (unless rel
      (error "my/cache--path: Unknown key %S" key))
    (expand-file-name rel my/cache-directory)))

(defun my/cache--ensure-dirs ()
  "Create every directory referenced by `my/cache-paths'."
  (dolist (entry my/cache-paths)
    (let* ((abs (my/cache--path (car entry)))
           (dir (if (directory-name-p abs) abs
                   (file-name-directory abs))))
      (make-directory dir t))))

(my/cache--ensure-dirs)

(use-package emacs
  :ensure nil
  :custom
  (bookmark-file               (my/cache--path 'bookmark-file))
  (ielm-history-file-name      (my/cache--path 'ielm-history-file-name))
  (project-list-file           (my/cache--path 'project-list-file))
  (recentf-save-file           (my/cache--path 'recentf-save-file))
  (savehist-file               (my/cache--path 'savehist-file))
  (save-place-file             (my/cache--path 'save-place-file))
  (transient-history-file      (my/cache--path 'transient-history-file))
  (transient-levels-file       (my/cache--path 'transient-levels-file))
  (transient-values-file       (my/cache--path 'transient-values-file))
  (nsm-settings-file           (my/cache--path 'nsm-settings-file))
  (multisession-directory      (my/cache--path 'multisession-directory))
  (url-configuration-directory (my/cache--path 'url-configuration-directory))
  (create-lockfiles            nil)
  (make-backup-files           nil)
  (auto-save-default           t)
  :config
  (setq auto-save-list-file-prefix (my/cache--path 'auto-saves-sessions)
        auto-save-file-name-transforms
        `((".*" ,(my/cache--path 'auto-saves) t))))

(use-package tramp
  :config
  (setopt tramp-persistency-file-name (my/cache--path 'tramp-persistency-file-name)))

启动后, ~/.emacs.d/ 的目录结构会变成这样:

.
├── cache
│   ├── auto-saves
│   │   └── sessions
│   ├── erc
│   │   ├── images
│   │   └── logs
│   ├── image-dired
│   ├── multisession
│   ├── rcirc
│   │   └── logs
│   ├── transient
│   │   ├── history.el
│   │   ├── levels.el
│   │   └── values.el
│   ├── url
│   ├── bookmarks
│   ├── history
│   ├── ielm-history.eld
│   ├── network-security.data
│   ├── projects
│   ├── recentf
│   ├── saveplace
│   └── tramp
├── eln-cache
│   └── 32_0_50-25c5b284
├── eshell
│   └── history
├── init.el
└── tree-sitter
    ├── libtree-sitter-markdown-inline.dylib
    └── libtree-sitter-markdown.dylib

所有缓存都在 cache/ 下一个目录,想清就清,想留就留。 eln-cacheeshelltree-sitter 是编译产出和 dotfile,不在 alist 管理范围内。

新包接入,两行配置

每当你开始用一个会产生缓存文件的新包,只需要两处改动:

  1. my/cache-paths 里加一条映射
  2. use-package :custom 里加一行

下次重启时,目录自动创建,变量自动指向新地址。

与 no-littering 的对比

原文提到,如果你不想自己维护这套配置,还可以用 no-littering 这个包。两种方案各有利弊:

方案 优点 缺点
自己维护 alist 完全可控,零外部依赖,知道每个文件在干什么 新增包时需手动加映射
no-littering 开箱即用,覆盖面广,社区维护 多一个依赖,定制不如自己写的灵活

如果你的包列表稳定、不经常增减,自己维护 alist 是更轻量的选择。如果经常尝试新包、不想费心记路径变量名,no-littering 省事。

Emacs之怒 : emacs : elisp : cache