首页
归档
友情链接
关于
Search
1
在wsl2中安装archlinux
287 阅读
2
从零开始配置 vim(15)——状态栏配置
169 阅读
3
nvim番外之将配置的插件管理器更新为lazy
159 阅读
4
2018总结与2019规划
145 阅读
5
PDF标准详解(五)——图形状态
121 阅读
软件与环境配置
读书笔记
编程
Thinking
FIRE
菜谱
翻译
登录
Search
标签搜索
c++
c
学习笔记
emacs
windows
elisp
文本操作术
编辑器
NeoVim
Vim
win32
读书笔记
VimScript
linux
文本编辑器
投资理财
Java
反汇编
OLEDB
数据库编程
Masimaro
累计撰写
442
篇文章
累计收到
32
条评论
首页
栏目
软件与环境配置
读书笔记
编程
Thinking
FIRE
菜谱
翻译
页面
归档
友情链接
关于
搜索到
4
篇与
的结果
2026-08-19
《elisp 每日一练》——撤销树与原子化编辑
每个编辑器都有撤销与反撤销的操作。Emacs中也不例外。我们今天来介绍Emacs中撤销相关的操作知识铺垫buffer-undo-list 的理解在Emacs中,撤销操作使用undo、而反撤销使用的是undo-redo。它们比较容易理解,也没什么参数。但是今天我们深入理解建立在这两个函数背后的数据维护以及基于此的现场保护与还原。在Emacs的底层,为每个buffer都维护了一个 buffer-undo-list 的结构,用于记录该buffer的修改历史。Emacs将每一次的操作,包括文本内容的变化以及文本属性的变化作为一条记录插入这个结构中。我们想象一下,如果我们设计一个编辑器,用户有操作,例如当前有一个 hello 的字符,用户删除了了最后一个o,此时buffer变成了 hell。后面有加了一个 word 单词那么我们会怎么记录呢?有的编辑器会记录每一次改动之后buffer的内容。例如:第一次用户输入完了,此时保存一条 hello 记录第二次用户编辑,删除了 o,此时添加一条 hell 记录第三次用户编辑,插入 word 这个单词,此时添加一条 hellword 记录。后续每一次undo 和 redo 操作都是在向前或者向后遍历记录列表,恢复buffer。这是一个思路,但是Emacs采用的是另一个思路,它不记录当前buffer是什么样子,它只记录每次在哪里,做了哪些改动。我们查看 buffer-undo-list,它的每一条记录主要内容如下:(START . END) 用于记录一次插入操作产生的文本范围,undo 时删除这个范围string 类型的 undo record 主要用于记录被删除的文本。nil 中间可能会插入一个有nil的节点,表示一个 撤销边界 ,一次 undo 通常会处理一个 change group,也就是从当前 undo 位置向后处理 undo records,直到遇到下一个 boundary从上面的一些结构我们就可以看出一些执行undo 时的思路,还是以上面的例子为了,假设我们将 hello 改成了 hellworld,那么Emacs会记录一个类似于 ((5 . 9) "o") 的结构,下一次undo时,删除buffer中point从5到9这个位置的字符,然后在后面插入一个 "o" 字符串。但是节点只是有可能包含这些,并不一定每个节点都是一样的结构。根据操作的不同,可能每个节点会出现不同的数据。它比较复杂因为Emacs支持针对 undo 的操作千奇百怪,除了普通文本插入、删除和文本属性修改之外,undo 系统还需要处理 point、marker、文本属性以及 region 中的 selective undo 等复杂情况,因此 undo record 的结构并不是固定的。为什么我会将 undo 之后的 redo 作为它支持的一种 undo 操作呢?这与Emacs的另一种设计思路有关。常规的编辑器可能将 undo 和 redo 视为不同的操作,可能维护两个列表或者像上面那样undo、redo将当前状态在列表中往不同的方向移动。但是Emacs不这么认为,它认为redo是另一种形式的文本修改,与我们手动修改文本没什么两样,所以传统 Emacs undo 机制并没有把“undo”和“redo”设计成两个相互独立的历史栈。一次 undo 本身会修改 Buffer,因此它也会影响 undo 历史。例如,有三次修改记录 A->B->C。如果我们执行两次 undo 操作,回到了A状态,此时历史大致变成了A->B->C->(undo)B->(undo)A如果我们中间插入一些非undo 操作,哪怕只是移动光标,Emacs会将这些记录插入,变成固定的记录。如果我们再执行undo,那么就变成了再往回走,变成 undo A 的 undo,就能回到B,同理再次执行undo 就回到了初始状态的 C从某种程度上来说,几次undo实际上能回到redo上。所以Emacs早期并没有提供专门的 redo 函数,现代的Emacs(28+)虽然提供了 undo-redo 来执行反撤销,但是这个函数只是为了与其他编辑器有相同的体验,它的内部仍然采用的是 buffer-undo-list 的实现方式因为 buffer-redo-list 的结构比较复杂,每一项数据也都不固定,所以我们实际上无法通过像当初介绍 mark-ring 那样打印 mark-ring 来深入理解。如果你想要深入理解,可以参阅 undo、primitive-undo、undo-moreundo-boundary前面提到 nil 实际上组成了一个 boundary 边界,每一次执行撤销实际是以这个边界为准,回到了边界的位置。例如我们输入hello world 可能记录了第一次插入 h、第二次插入 e、第三次插入 l 等等操作。但是执行撤销之后它并没有每次删除一个字符,它可能直接把整个 hello world 字符串都给删除了。这个就是 boundary 在起作用从某种程度上来说,这个机制简化了多次输入undo进行撤销的尴尬。但是Emacs默认给的 boundary 并不一定好用,例如hello word 我想改成 hello emacs,我希望我通过 undo 撤销后面的单词,我直接输入 emacs 。但是Emacs把整个字符串都删除了。只是这种简单的错误还好,如果是输入了一大段文本,我发现最后一两个词用的不准确想要重写,结果 undo 之后,整段都没了,用户心态可能直接原地爆炸了。好在Emacs提供了 undo-boundary 让我们自己提供这个边界。我们可以尝试一下下面两个函数(defun my/insert-text() (interactive) (goto-char (point-max)) (insert "hello") (insert " world"))Emacs 默认会在一个命令执行前后插入一个 boundary,将执行前后的状态分开,所以上面一个函数执行之后,如果再执行 undo 会将整个插入的 hello world 都清空(defun my/insert-text() (interactive) (goto-char (point-max)) (insert "hello") (undo-boundary) (insert " world"))下面一个函数,我们手动调用 undo-boundary ,在两次插入字符串之间插入了一个 boundary,这样调用 undo 只会删除后面插入的 world原子化操作实际上,用户的编辑操作千变万化,不光是Emacs,甚至连我们自己都无法事先预料到,我下一次undo想要把buffer恢复成什么状态。那么Emacs提供 undo-boundary 有什么用呢?实际上它常用于我们上面的标题说的 原子化操作 这样一种模式。很多插件都会改变buffer的内容,比如各种mode的语法高亮、代码的格式化。它们本质上就是修改了buffer、产生了一条条的undo记录。但是如果插件中途报错,导致这个变化只变了一半,甚至有的变化来不及完全变更导致根本就是错的,就很影响用户体验。这个机制就像我们学习数据库的时候,提到的事务回滚。提到这个,我们至今为止了解了各种级别的保存与恢复现场。save-excursion、save-restriction、with-current-buffer。原子化操作的本质和要解决的问题与上面几个大致先当。同样的也有对应的宏进行处理 atomic-change-group。它主要做两件事:将宏包裹的buffer 操作打包成一个undo 单元,并且中间的操作不产生新的记录,用户事后反悔了可以一次undo回到之前的状态如果中途出错了,能回到原来的状态练习今天的练习,我们基于之前markdown标题转org mode标题的例子,在上面添加关于恢复现场的操作。(defun my/markdown-title-to-orgmode-title() (interactive) (save-excursion (save-restriction (widen) (goto-char (point-min)) (let ((header-regex (rx line-start (group (repeat 1 6 "#")) (one-or-more space)))) (atomic-change-group (while (re-search-forward header-regex nil t) (let* ((hashes (match-string 1)) (has-count (length hashes)) (start (make-string has-count ?*)) (replace (concat start " "))) (replace-match replace t t))))))))总结本节我们了解了Emacs的undo机制,同时介绍了如何进行原子化操作,本节介绍的api如下:undoundo-redoundo-boundaryatomic-change-group
2026年08月19日
4 阅读
0 评论
0 点赞
2026-08-15
《elisp 每日一练》——跨 Buffer 文本提取
在之前的文章中,我们通过with-current-buffer 和 buffer-substring 实现了一个从指定buffer中拷贝文本到本buffer的功能。这个算是我们入门了跨buffer操作。实际上Emacs为这种需求提供了专门的函数背景知识Emacs中的跨buffer文本操作的基础函数是 (insert-buffer-substring BUFFER &optional START END) 。它用于将参数中 BUFFER 指定的缓冲区中从 START 到 END 这个范围内的文本插入到当前缓冲区的光标所在位置。我们可以用下面的代码感受一下(defun my/copy-messages-buffer-to-this() (interactive) (let ((message-buffer (get-buffer "*Messages*"))) (when message-buffer (let ((start) (end)) (with-current-buffer message-buffer (save-restriction (widen) (setq start (point-min)) (setq end (point-max)))) (insert-buffer-substring message-buffer start end)))))因为 START 和 END 参数实际上是对应的是 BUFFER 中的范围。所以我们需要切入到对应的buffer中去获取这个坐标的最大和最小值。基于这个函数我们可以实现很多好用的跨buffer的文本操作,例如将当前buffer中的内容拷贝到目标buffer中,或者将目标buffer中的内容替换为我们想要的内容等等。因为各种跨buffer的文本操作实在是太频繁了,所以Emacs提供很多有用的函数,下面我列举一些常用的函数append-to-buffer: 从当前buffer中提取 START 和 END 范围内的文本并追加到参数 BUFFER 所指定的缓冲区的光标位置copy-to-buffer : 从当前 buffer 中提取 START 和 END 范围内的文本并对参数中 BUFFER 所指定的缓冲区进行替换上述这些函数提取的内容都带有文本属性,如果我们只想提取文本本身,可以使用 buffer-substring-no-properties 。查阅相关文档,我们发现 buffer-substring-no-properties 实际上对标的是 buffer-substring,都是提取当前buffer的文本,而不涉及到改变buffer。在跨buffer的函数中,只有 insert-buffer-substring 是使用底层C函数编写的代码,而其他两个都是依靠这个函数,在lisp层面实现的。所以说,关于跨buffer的文本操作,我们只需要掌握基本的 insert-buffer-substring 其他的函数我们可以自己实现。这有点像数学,上学的时候老师告诉我初中的几何只有三条基本公理,其他的定理和自己利用这三条推导出来。Emacs也是这样,只提供基本的功能,其余高级的功能可以自己利用基本函数实现出来。但是也提供了这些高级函数的官方实现。练习今天我们来实现一个日志收集的功能。我们将当前用户选中的行记录到一个名为 Log-Collection 的buffer中,该buffer平时不允许写入,只能通过调用特定的函数进行收集。如果用户选择并激活了一个区间,则提取区间的内容进行激活,否则提取当前所在行的文本。(defun my/collection-log() (interactive) (let ((log-buffer (get-buffer-create "*Log-Collection*")) (cur-buf (current-buffer)) (start) (end)) (if (use-region-p) ;; 激活了选区,则按照选取来决定范围 (progn (setq start (region-beginning)) (setq end (region-end))) ;; 未激活选取,则按照光标所在行来决定范围 (progn (setq start (line-beginning-position)) (setq end (line-end-position)))) (with-current-buffer log-buffer (let ((inhibit-read-only t)) (goto-char (point-max)) (insert-buffer-substring cur-buf start end)))))总结本节介绍的重点函数只有一个 insert-buffer-substring 因为功能导致它的参数比较割裂,BUFFER 是目标buffer、而START 和 END 对应着源buffer的位置。这也就意味着想要成功调用该函数,无论如何都得使用 with-current-buffer 进行buffer的切换,无法一步到位其余的函数我们可以自己实现,所以就不在这里列举了。
2026年08月15日
6 阅读
0 评论
0 点赞
2026-08-09
《elisp 每日一练》——阶段性总结与练习
在之前的一系列文章中,我们已经详细介绍了关于文本和光标的一些操作以及对应的API。我们先回顾一下之前介绍的内容前期内容回顾我们先介绍了有关光标位置和移动光标的一些函数:point-min 和 point-maxline-beginning-position 和 line-end-positiongoto-charforward-line以及拷贝对应区域文本的函数 buffer-substring接着又介绍了有关恢复光标位置相关的API函数markpush-mark 和 pop-marksave-excursion而Emacs内部维护 mark 的方式被称为 mark-ring,有关 mark-ring 的函数与变量如下:mark-ringmarker-positionline-number-at-posmarker-buffermark 与 Point 能构成一个 Region,关于 Region 的函数如下:region-beginning 和 region-endactivate-markuse-region-p因为 region 本身不具备区域保护的功能,它无法限制我们仅仅操作某个区间的文本,所以Emacs又提供了 Narrowing 和 Widen 机制。narrow-to-regionwidensave-restriction之后我们正式进入了对文本的增删改查的学习。相关API内容如下:insertdelete-regionsearch-forward 和 re-search-forward 以及反向的 search-backward 和 re-search-backwardmatch-stringsave-match-datastring-matchstring-match-pmatch-beginning 和 match-endreplace-match综合练习本次练习,我们结合之前介绍的相关API来写一个综合性的功能。我们平时在写代码时经常会写各种各样的注释,有些注释有一些固定的写法, 例如我经常写 TODO 表示这部分功能没有加,后面加上, FIXME 这部分实现的比较粗糙,后面在进行重构或者精细化处理。虽然未来大概率就不了了之了,但是留下这些注释显得比较专业(不是)我们现在要做的就是收集 TODO 和 FIXME 开头的这些注释,然后将它们加入到minibuffer 中,显示,在用户选择对应的注释之后跳转到buffer对应的位置(defvar my/todo-mark-list '() "保存全局的todo列表,格式为(SUMMARY-TEXT . marker)") ;; 收集todo/fixme 数据 (defun my/code-todo-list-collect() (interactive) (setq my/todo-mark-list '()) (save-excursion (save-restriction (widen) (goto-char (point-min)) ;;遍历并收集数据 (while (re-search-forward "//\\s-*\\(todo\\|fixme\\):?\\s-*\\(.*\\)$" nil t) (let* ((raw-content (match-string 2)) ;; 准备数据 (m (copy-marker (match-beginning 0)))) (push (cons raw-content m) my/todo-mark-list)))))) (defun my/jump-to-next-todo() (interactive) (save-restriction (widen) (if (null my/todo-mark-list) (message "当前没有待修正的任务") (let* ((selected (completing-read "请选择要跳转的位置: " my/todo-mark-list nil t))) (when selected (let ((target (cdr (assoc selected my/todo-mark-list)))) (push-mark) (switch-to-buffer (marker-buffer target)) (goto-char target)))))))本次的练习,我们分了两个函数,一个用于收集信息,另一个用于在minibuffer 进行交互。在收集信息时我们仍然采用之前的写法,将string 放在CELL的最外层,这样 completing-read 可以正常读取出对应的字符串提示信息并且显示。而后面我们仍然是通过二者的关联性快速执行跳转。虽然我将它称之为综合练习,但是目前我们还有有些内容没有涉及到,比如说修改注释,我们可以做的更复杂一些,将这种注释修改为更加标准的内容。后面我们学习了buffer、file相关的操作之后就可以做的更复杂一些,例如对搜索的结果进行持久化,又或者搜集整个工程中的待办事项。总结到此位置关于操作文本相关的内容我们都已经涉及到了,后面我们会开始进入有关 buffer 和 window 专题讨论相关的操作
2026年08月09日
10 阅读
0 评论
0 点赞
2026-08-08
《elisp 每日一练》——文本插入、删除、替换
对于文本操作的增删改查,我在前一天已经了解了关于查相关的操作。今天我们来了解增删改。背景知识文本增删比较简单,我们只需要确定光标位置,然后调用相关函数即可。文本的增加或者说插入我们通过函数 insert 来执行。它的函数原型如下:(insert &rest ARGS)它用来在光标所在位置之后插入对应文本,从函数定义上,我们知道这个函数后面可以跟一个或者多个参数。也是就是说我们实际上可以一次插入多个字符串。我们需要注意的是光标和mark的变化,插入结束之后,光标位于插入文本的末尾,而mark由Emacs维护,它会随着文本的变化而变化。删除我们使用函数 delete-region 它的函数原型如下:(delete-region START END)它用来删除从 START 到 END 区域内的文本。而光标位置会在原来的基础上减去被删除的字符数。而 mark 因为是由Emacs自己维护的,所以如果 mark 位于 START 之前,那么它的位置不会发生变化,在END 之后,它的位置会相应的减去 END - START 的值。如果在 START 和 END之间,那么它会被放到 START的位置。增加和删除比较简单,光标和 mark 位置的变化也跟我们平时使用编辑器的习惯一致。这个部分稍微复杂一些的操作是修改。一般来讲,修改之前会进行查找并且对匹配上的部分进行修改,所以它天然的就离不开正则表达式。Emacs 中,通过函数 replace-match 来进行。它的函数原型如下:(replace-match NEWTEXT &optional FIXEDCASE LITERAL STRING SUBEXP)它的核心参数为:NEWTEXT : 替换后的新文本FIXEDCASE : 如果为 nil Emacs在替换时采用智能大小写机制。(例如原词是大写,替换词也会自动转大写),如果为 t 会强制按照 NEWTEXT 的原始值LITERAL : 若为 t 则 NEXTTEXT 被视为纯文本,否则允许在 NEWTEXT 中使用 \1 、\2 来引用正则捕获组SUBEXP : 指定只替换第 N 个捕获组,而不是整个匹配项另外在上一篇我们知道,Emacs中匹配结果非常脆弱,它永远只保留最近一次匹配的结果。我们应该即时通过 match-string 获取并保存结果。但是如果我们确实需要在后续再重新获取怎么办?Emacs提供了一个保护现场的函数 save-match-data。到这里我们已经知道了三个保存现场的函数 save-excursion 、 save-restriction 、save-match-data 。练习到这里我们已经可以尝试对文本进行操作了。我们来尝试一下将所有 foo_ 为前缀的函数替换为 my/foo_ 。因为Emacs 中的函数定义以 (defun 开头,所以我们实际上匹配的就是 (defun foo_下面是我的实现(defun my/profix-internal-functions() (interactive) (let ((count 0)) (save-excursion (save-restriction (widen) (goto-char (point-min)) (while (re-search-forward "(defun \\(foo_[a-zA-Z0-9-]+\\)" nil t) (setq count (1+ count)) ;; 用 \1 引用捕获匹配到的函数名称, 参数t表示维持原来的大小写 参数3为nil表示替换的字符串中允许使用 \1 这种来匹配原来的捕获 (replace-match "(defun my/\\1" t nil)))) (message "重构完成,共修改 %d 个函数名" count)))到此我们已经掌握了移动光标和操作文本的大部分操作了。基于此我们就可以完成日常操作文本的任务了。后面我们将对之前的内容进行一个总结,并完成一个基础的小功能。敬请期待!
2026年08月08日
7 阅读
0 评论
0 点赞