首页
归档
友情链接
关于
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
菜谱
翻译
页面
归档
友情链接
关于
搜索到
442
篇与
的结果
2026-08-23
《elisp 每日一练》——interactive 交互式命令
在前面我们写的所有能在命令行执行的函数,第一个语句都是 (interactive)。 过去我们仅仅机械性的加上这么一个语句,似乎没有对它进行深入的了解。本节我们将探讨有关 interactive 的内容背景知识在Emacs中,普通函数仅仅能被其他elisp代码调用,而交互式函数既能够被用户通过 M-x 的方式调用,也可以被其他lisp 代码调用。我们如果要判断一个函数是否是交互式命令可以使用 commandp。想要一个函数成为交互式函数, 可以在函数中包含 interactive 的声明。interactive 的定义如下:(interactive &optional ARG-DESCRIPTOR &rest MODES)其中 ARG-DESCRIPTOR 参数定义了当用户交互式地调用该命令时,如何获取其参数。它主要有三种形式:nil 或者省略时表示该函数不需要参数使用字符串:交互式读取参数使用 Lisp 表达式:完全自定义第二种方式可能使用的频率最高。它包含了一组有特殊字符组成的代码字符和对应的提示信息。Emacs 会根据代码字符的类型,自动读取用户输入,进行类型的转化并传递给命令。常见的一些代码字符有:r: 自动提取当前 Region 的起点与终点 Point(无需用户手动输入!)s: 读取用户输入的字符串n: 读取用户输入的数字f: 交互式选择已有文件F: 选择一个文件,文件可以不存在p: 自动获取前缀参数(例如按下 C-u 或 M-4)B: 一个已经存在的buffer的名称b: buffer的名称,buffer可以不存在其他的参数还有很多,读者可以自行查阅对应的文档信息。我们可以在每一个代码字符后面加上一个提示信息的字符串,多个代码字符的话使用\n作为分隔符,下面我们看一个具体的例子我们可以利用上述内容实现一个简单的打开文件的功能,如果文件不存在则创建新文件。并且允许用户提供一个需要写入的字符串,如果我们带上对应的提示信息,就可以有下面一个简单的实现(defun my/open-file(file input-str) (interactive "F输入文件名称:\ns输入文件中的字符串:") (let ((buffer (find-file file))) (goto-char (point-max)) (newline) (insert input-str)))因为我们需要接受用户输入的文件名和字符串,所以函数需要两个参数。而文件可以不存在所以我们采用 F 作为代码字符,后面接上提示信息,然后利用 \n 分隔符写上另一个参数的信息。如果你觉得以上这些代码字符不能满足你的需求,我们可以采用第三种方式,也就是使用list来自定义表达式。list 中可以包含多个表达式,它会依次对表达式进行求值并将求值的结果按函数参数定义的顺序进行赋值。如果我们将上面的函数使用列表的方式重新组织可以得到如下的代码(defun my/open-file(file input-str) (interactive (list (read-file-name "输入文件名称:") (read-string "输入字符串:"))) (let ((buffer (find-file file))) (goto-char (point-max)) (newline) (insert input-str)))练习我们来实现一个C/C++ 语言版本的快速注释函数。可以有一下几种情况:如果用户激活了一个 Region,则采用 /**/ 注释区间的代码如果用户通过 C-u 输入了数字,那么就通过 // 注释当前行以及后续的n行如果用户未进行上述操作,则通过 // 注释当前行(defun my/comment-region (start end) "激活区间的情况下,通过 /**/注释区间的代码" (let ((text (buffer-substring-no-properties start end))) (delete-region start end) (insert (format "/* %s */" text)))) (defun my/comment-lines (arg-lines) "当前行开始,逐行添加 // 注释,总共添加 arg-lines 行" (dotimes (_ (or arg-lines 1)) (save-excursion (beginning-of-line) (insert "// ")) (forward-line 1))) (defun my/quick-comment(start end arg-lines) "C/C++语言版本的快速注释函数" (interactive (if (use-region-p) (list (region-beginning) (1+ (region-end)) nil) (list nil nil (prefix-numeric-value current-prefix-arg)))) (cond ;;优先处理选区的情况 ((and start end) (my/comment-region start end)) (t (my/comment-lines arg-lines))))为了实现上述功能,我们提供三个函数,其中两个工具函数,一个用来做交互的主函数。我们一个个来分析:my/comment-region,在激活 Region 的情况下调用该函数。它有两个参数分别是当前 region 的起始位置和结束位置。它主要是提取region 区域内的文本,并使用 /**/ 将文本包括,然后先delete区域内的文本然后在区域开始位置插入新文本实现注释。这里其实也可以在开始和结束位置执行两次insert 函数分别插入 /* 和 */my/comment-lines,该函数用来注释多行。它接受一个代表行数的整数参数。我们通过 dotimes 宏进行一个循环。因为它不需要迭代参数,所以迭代结果那块使用 _ 进行忽略。而迭代次数我这里使用了 or。or 后面可以跟任意个表达式,它会依次往后求值,直到得到一个不为空的值。所以这里表示如果arg-lines 的值不为空则采用它的值,否则我只在当前行执行该操作。插入注释时我们先通过 beginning-of-line 将光标移动到行首位置,然后在行首插入 //,接着移动到下一行准备进行插入操作最后我们来看看主函数 my/quick-comment 的实现。在 interactive 参数部分,我们考虑了两种情况,如果当前region激活了,则采用region的范围给 start 和 end 进行赋值。但是因为 region 的范围是一个前开后闭的区间,所以想要包含区间的后一个字符,需要在此基础上加1如果当前没有区间,我们通过 prefix-numeric-value 来获取输入的前置数字。然后通过list依次将每个表达式的求值结果赋值到函数参数。最后我们进行判断,如果 start 和 end 都不是空,则表明用户激活了一个选区,此时就需要调用 my/comment-region 对选区进行处理。否则调用 my/comment-lines arg-lines 进行行注释总结本篇我们了解了 interactive 的交互机制,并详细介绍了它接受的三种参数模式:nil 或者空参、提示的字符串以及自定义的list形式。
2026年08月23日
2 阅读
0 评论
0 点赞
2026-08-22
《elisp 每日一练》—— 综合练习
到此为止我们已经对处理文本有了一个新的认识。回顾之前的内容,我们主要介绍了针对文本的普通特性,通过正则表达式进行搜索和捕获,以及关于属性的内容。针对编程语言的类型的文本,我们可以通过syntax-table 和 tree-sitter相关机制进行处理。知识回顾针对正则表达式的晦涩以及emacs中特有的连续 \的处理,我们可以使用 rx 宏处理在有关文本属性方面,我们介绍了如下一些函数add-text-propertiesput-text-propertyremove-text-propertiesset-text-propertiesadd-face-text-propertyget-text-property后面我们介绍了emacs 中的撤销与反撤销,以及如何进行文本变动方面的现场保护和还原,有以下几个函数:undoundo-redoundo-boundaryatomic-change-group如果我们将文本视为一个编程语言的源码或者配置文件这种有一定语法规则的文本,emacs 为我们提供了基本的词法分析的 Syntax-Table, 操作的相关函数有:forward-sexp 和 backward-sexpscan-sexpssyntax-ppss如果我们想从语法层面区分每个部分,可以通过新引入的 treesitter机制。有关的函数比较多,主要有:treesit-available-ptreesit-language-available-ptreesit-parser-createtreesit-parser-listtreesit-parser-buffer`treesit-parser-languagetreesit-buffer-root-nodetreesit-node-typetreesit-node-texttreesit-node-start 和 treesit-node-endtreesit-node-childtreesit-node-parenttreesit-node-prev-sibling 和 treesit-node-next-siblingtreesit-node-checktreesit-node-ptreesit-node-eqtreesit-query-capturetreesit-query-stringtreesit-query-validate综合练习本次的练习,我们结合前面的内容,来完成一个简单的显示当前buffer函数定义的面板。它的主要功能有:搜集当前buffer中的所有elisp函数定义将这些函数定义集中到新的面板中进行显示在新的面板中,我们将光标放到对应的函数上可以回到函数定义的位置我们将上一节的代码拿过来做一个修改(defun my/get-all-functions-define () "遍历当前buffer中的所有函数定义,并将它们一一记录到对应面板中" (interactive) (unless (cl-some (lambda (p) (eq (treesit-parser-language p) 'elisp)) (treesit-parser-list)) (treesit-parser-create 'elisp)) (let* ((root (treesit-buffer-root-node)) ;; 捕获 defun 的根节点 @def_node 以获取整个函数的起始位置 (captures (treesit-query-capture root "(function_definition name: (symbol) @func_name) @def_node"))) (dolist (capture captures) ;; captures 包含 @func_name 和 @def_node,只需处理 @func_name 匹配项 (when (eq (car capture) 'func_name) (let* ((node (cdr capture)) (name (treesit-node-text node)) ;; 拿到该函数所在 defun 节点的起点位置 (def-node (treesit-node-parent node)) (start (treesit-node-start def-node)) (buffer (current-buffer))) (my/add-function-to-function-panel name start buffer))))))这部分的代码,我们大部分都延续上一节的内容,不过在存储数据时我们调用一个额外的函数将数据存储到一个新的buffer中。存储buffer的部分,我们主要是通过创建新的buffer,并且将这些数据以属性的方式加入。它的实现如下:(defun my/add-function-to-function-panel (name start buffer) "将函数信息加入新的面板中" (let ((panel-buffer (get-buffer-create "*function-panel*"))) (with-current-buffer panel-buffer (let ((inhibit-read-only t)) (goto-char (point-max)) ;; 1. 记录插入前的位置 (let ((start-point (point))) (insert name "\n") ;; 2. 使用反引号 `(...) 允许 ,buffer 和 ,start 动态求值 (add-text-properties start-point (1- (point)) `(source-buf ,buffer function-point ,start)))))))有了这些信息,在我们打开面板之后,执行跳转的函数,我们读取当前位置的属性,根据事先存储的属性信息,找到原始的buffer和对应的point值,根据这些值执行跳转(defun my/jump-to-source-buffer () "跳转回到源码位置" (interactive) (let ((source-buffer (get-text-property (point) 'source-buf)) (func-point (get-text-property (point) 'function-point))) (when (and source-buffer func-point) (pop-to-buffer source-buffer) ;; 修正变量引用与位置跳转 (goto-char func-point))))总结到此我们关于文本部分的学习就都结束了。上述的练习只实现了基础功能,很多地方考虑的不完善,例如多次执行有重复信息加入。没有考虑后续源码buffer被清理掉的问题。这些问题读者如果有兴趣可以自行完善。
2026年08月22日
5 阅读
0 评论
0 点赞
2026-08-20
《elisp 每日一练》——tree-sitter
前面一篇文章中,我们提到 Syntax-Table。它相当于一张巨大的表,通过字符串索引到它的属性信息。但是它仅仅停留在词法层面,例如下面的一段c代码int main(){return 0;}它仅仅能识别它们是 单词 单词 () {单词 数字 分号} 也就是给每个语法单元大了一个 Token,进行了简单的词法分析。但是它没有办法将这段代码解析成有意义的语法单元。导致我们没办法利用它进行高级的语法单元的解构。要实现类似IDE那种跳转或者语法高亮,需要借助第三方库,或者自己根据词法单元写一套语法分析器。但是从Emacs 29开始,引入了 tree-sitter,针对所有编程语言提供了统一的接口,让我们能基于语法形成的AST,做一些高级的功能背景知识Emacs中使用 tree-sitter 围绕着 解析器 、语法节点 和 查询 展开的。这么说有些抽象,我们换一种说法就是调用函数解析当前buffer中的内容形成抽象语法树,遍历树节点获得每个节点中的属性,例如它是一个function或者是一个变量等等。最后对于一些提取数据的功能我们不需要遍历整个树,而是可以采用类似SQL的查询语句来找到想要的内容。解析器在执行语法分析前,我们需要检查环境以及定义使用的解析器。这方面的函数有:treesit-available-p : 检查当前Emacs是否支持 tree-sittertreesit-language-available-p : 检查特定语言的语法定义是否已安装treesit-parser-create : 为当前缓冲区创建一个新的解析器treesit-parser-list : 获取当前缓冲区中的解析器列表treesit-parser-buffer : 获取解析器关联的缓冲区treesit-parser-language : 获取解析器所使用的语言例如我们使用 (treesit-language-available-p 'elisp) 来判断当前elisp的解析器是否安装,对于一个裸Emacs来说,它会返回nil。我们可以手动的通过 treesit-install-language-grammar 命令安装对应的语法解释器。或者我希望我的elisp配置帮我安装我需要的解析器这样即使我换了一台新的电脑,只要执行这些配置它也能帮我准备对应的环境。我们可以先往列表 treesit-language-source-alist 中添加成员。它的每一项都是一个 (LANG . (URL REVISION SOURCE-DIR CC C++)) 结构,默认只需要填写LANG 和 URL,它们分别是对应的语言和解析器的github地址。后面几个参数: REVISION 表示要下载哪个版本,SOURCE-DIR 下载的源码在哪个目录、C 和 C++ 表示编译时使用的C/C++编译器。针对elisp我们可以这样指定(add-to-list 'treesit-language-source-alist)之后我们可以通过下面的代码来一次安装 treesit-language-source-alist 中定义的所有的语言解析器(mapc #'treesit-install-language-grammar (mapcar #'car treesit-language-source-alist))如果你使用的操作系统是Windows,因为Windows没有原生的gcc,我们可以使用MinGW-w64。如果你不想折腾这些,可以直接从GitHub上找到一些预编译项目例如 casouri/tree-sitter-module ,下载已经编译好的Windows版本,将它们拷贝到 ~/.emacs.d/tree-sitter/ 目录中。因为tree-sitter 解析器的名称一般为 libtree-sitter-<LANGUAGE>.so 所以在Windows上,如果我们要启动elisp的,需要将它改名为 libtree-sitter-elisp.dll。 如果你不想改名,我们可以设置往列表 treesit-load-name-override-list 中添加一条对应的语言和dll的映射。每条记录的格式为 (LANG LIB-NAME [BASE-NAME])。其中 LANG 是语言名称,而 LIB-NAME 是对应的dll名称,BASE_NAME 是库道出的函数名称。默认来讲,每个对应的dll都有一个可以调用的导出函数,命名规则为 tree_sitter_<LANGUAGE> 但是极少数可能出现名称不匹配的规则,所以我们可以添加一条记录。(add-to-list 'treesit-load-name-override-list '(elisp "elisp" "tree_sitter_elisp"))之后我们就可以为当前的elisp缓冲区创建一个新的解析器 (treesit-parser-create 'elisp)语法节点创建了语法解析器之后,我们就可以利用一些API函数来获取树上节点的属性了。例如可以使用 treesit-node-at 可以获取当前光标位置在树上的节点。有了节点,我们可以获取节点属性或者进行节点的遍历,对应的函数有:treesit-buffer-root-node 获取当前树结构的根节点treesit-node-type 获取节点类型,例如function_definition或者 if_statement 等等treesit-node-text 获取节点对应的源码文本treesit-node-start 和 treesit-node-end 获取节点在缓冲区的起始或者结束位置treesit-node-child 获取节点的第N个子节点treesit-node-parent 获取节点的父节点treesit-node-prev-sibling 和 treesit-node-next-sibling 获取节点的前一个或者后一个兄弟节点treesit-node-check 检查节点的属性treesit-node-p 检查对象是否是一个treesitter 节点treesit-node-eq 比较两个节点是否代表树中的同一个节点有了这些函数,我们简单实现的实现一个高亮当前光标所在语法单元的功能:(defun my/mark-current-node() (interactive) (let* ((parser (treesit-parser-create 'elisp)) ;;创建解析器 (node (treesit-node-at (point))) ;; 获取当前位置所在的节点 (start (treesit-node-start node)) ;;获取节点对应文本的起始位置 (end (treesit-node-end node))) ;;获取结尾位置 ;; 在起点和节点间高亮一个 region (goto-char start) (push-mark end t t)))查询如果我们想要快速的扫描,提取出类似于类定义、类中的属性、方法等内容,依靠遍历整个树还是显得太繁琐了,对于这种查询的需求,我们可以使用 treesitter 提供的查询功能。它提供了基于树和节点的查询功能:treesit-search-forward: 从指定节点开始,向前(或向后) 搜索第一个匹配特定条件的节点treesit-search-subtree : 在节点的子树中搜索匹配条件的后代节点treesit-parent-until: 从当前节点向上遍历,直到找到满足条件的祖先节点treesit-induce-sparse-tree: 根据条件在语法树上构建一棵稀疏树,用于快速概览代码结构虽然上述函数提供了一些搜索节点的方法,但是仍然不够直观。就像数据库,虽然MySQL、或者 Oracle 底层采用的树的结构来提高查找速度,但是在实际操作数据库查找数据时没有人用树的操作来查找。treesitter 在处理诸如查找这一类需求时也提供了对应的高级查找接口:treesit-query-capture: 利用特定的语法,查找符合条件的接口, 可以接收list列表或者字符串treesit-query-string 直接对字符串进行解析和查询treesit-query-validate 验证查询语法是否正确想要使用 tree-query-capture ,需要先了解一下查询的语法。一个查询由一个或者多个模式组成,每个模式都是一个S-表达式,其结构类似于 (type (child) (child) ...)type 表示要查找的节点类型如 function_definition 或者 if_statementchild 嵌套的子节点模式,用于进一步限定父节点的结构例如我们要捕获函数定义所在节点可以这么写(tree-query-capture node ;;查询开始的节点 '((function_definition (function_name) @func-name)))上述代码表示,我们捕获函数定义的节点,并将它的节点名称命名为 func-name你可能觉得上述内容比较抽象,看不懂,没关系。实际上它就是treesitter结构中的一个变种,我们了解一段代码在treesitter眼中的样子,也就知道如何编写对应查询条件。在早期的学习使用过程中,我们可以使用命令, treesit-explore-mode,它会将我们当前buffer中在treesitter中解析的内容给列出来,我们只需要点击其中的某项,它就会将对应的代码进行高亮显示。这样我们就可以很容易找到某段代码在treesitter眼中的实际模样。或者开启 treesit-inspect-mode 它会显示当前光标所在位置的节点信息,帮助我们在一堆treesitter 信息中快速找到对应的位置例如上述高亮当前光标所在语法单元的示例在treesiter眼中,它是这样的(function_definition ( defun name: (symbol) parameters: (list ( )) (special_form ( interactive )) (special_form ( let* (list ( (list ( (symbol) (list ( (symbol) (quote ' (symbol)) )) )) (comment) (list ( (symbol) (list ( (symbol) (list ( (symbol) )) )) )) (comment) (list ( (symbol) (list ( (symbol) (symbol) )) )) (comment) (list ( (symbol) (list ( (symbol) (symbol) )) )) ))所以根据上述内容,我们如果想获取提取定义的函数名,就可以找 function_definition 类型的节点。根据节点,如果我们想获取对应的函数定义,可以这么写查询条件(treesit-query-capture root "(function_definition name: (symbol) @func_name)")这段代码查询之后的返回值是 (func_name. node) 结构的列表。因为我们给定的名称就是 func_name。后面还有一些高级的匹配模式和用法,这里就不介绍了。读者可以自行查阅 treesit-query-capture root 的文档。练习本节我们的练习基于上述内容,我们实现一个获取当前buffer中所有函数定义的功能。我们尝试一下多使用一下相关的函数,以便加深印象。所以本次的实现思路是:函数开始之前先调用 treesit-parser-list 遍历一下当前buffer中加载的解析器然后调用 treesit-parser-language 获取当前解析器对应的语言如果所有的语言都没有elisp,那么就创建elisp语言的解析器接着通过 treesit-buffer-root-node 获取根节点然后通过 treesit-query-captur 从根节点往下找,找到所有函数的定义我们遍历返回的结果,取每项的第二个成员,它就是对应的节点对象调用 treesit-node-text 获取节点中文本,也就是函数的定义最后打印函数定义,或者存储到一个列表中最终实现如下:(defun my/get-all-function-define() (interactive) ;; 先判断当前是否创建了 elisp 的解析器 (unless (cl-some (lambda(p) (eq (treesit-parser-language p) 'elisp)) (treesit-parser-list)) (treesit-parser-create 'elisp)) (let* ((root (treesit-buffer-root-node)) (captures (treesit-query-capture root "(function_definition name: (symbol) @func_name)")) (func-names '())) (if (not captures) (message "未找到对应的内容") (progn (dolist (capture captures) (let* ((node (cdr capture)) (name (treesit-node-text node))) (message "找到函数定义:%s" name) (push name func-names)))))))总结本篇我们介绍了treesit 在Emacs中的一些用法,包括与解析器相关的操作、遍历节点、以及在此之上的简单的查询功能。查询还有很多高级的功能,各位读者如果感兴趣可以阅读一下对应的文档。本篇介绍的函数如下:treesit-available-p : 检查当前Emacs是否支持 tree-sittertreesit-language-available-p : 检查特定语言的语法定义是否已安装treesit-parser-create : 为当前缓冲区创建一个新的解析器treesit-parser-list : 获取当前缓冲区中的解析器列表treesit-parser-buffer : 获取解析器关联的缓冲区treesit-parser-language : 获取解析器所使用的语言treesit-buffer-root-node 获取当前树结构的根节点treesit-node-type 获取节点类型,例如function_definition或者 if_statement 等等treesit-node-text 获取节点对应的源码文本treesit-node-start 和 treesit-node-end 获取节点在缓冲区的起始或者结束位置treesit-node-child 获取节点的第N个子节点treesit-node-parent 获取节点的父节点treesit-node-prev-sibling 和 treesit-node-next-sibling 获取节点的前一个或者后一个兄弟节点treesit-node-check 检查节点的属性treesit-node-p 检查对象是否是一个treesitter 节点treesit-node-eq 比较两个节点是否代表树中的同一个节点treesit-query-capture: 利用特定的语法,查找符合条件的接口, 可以接收list列表或者字符串treesit-query-string 直接对字符串进行解析和查询treesit-query-validate 验证查询语法是否正确
2026年08月20日
4 阅读
0 评论
0 点赞
2026-08-20
《elisp 每日一练》——syntax-table
前面我们都是将buffer中的文本作为一个普通的字符串进行处理,讨论的是普通字符串的固有操作方式。但是我们还有另一类特殊文本,编程语言和配置文件的文本。那么在这个领域Emacs又该如何处理它们呢?背景知识对于编程语言或者通用配置文件来说,如果使用传统的正则表达式不但难以理解,而且容易出错,后续的维护成本一定是巨大的。针对这种情况,Emacs通过为不同类型的文件配置了不同的 Major Mode 来支持类似语法解析、高亮还有一些高级的功能。Emacs 中每一个Major Mode 都有一个自己的 Syntax Table,它是一个巨大的 char table。它你可以把它看作一个巨大的查找表。以字符编码为索引,通过查询这个表 Emacs 就能知道如何处理每个字符。我们可以使用 describe-variable 函数搜索 syntax-table。我们可以看到Emacs中有非常多的 syntax-table。例如对于编程语言来说有 prog-mode-syntax-table、emacs-lisp-mode-syntax-table。对于文本有 text-mode-syntax-table。通常在不同的mode下,syntax-table 的内容不同,所以在不同的编程语言中,同一个符号的含义可能不同,例如C/C++ 等一些语言中,; 表示语句结束,而 elisp 中表示注释。一般来讲,我们不直接操作这个表。而是通过它提供的配套函数来完成这项工作。首先要介绍的就是 forward-sexp 、backward-sexp 和 scan-sexpsforward-sexp 和 backward-sexp 是向前或者向后移动一个平衡表达式。它们接收一个 ARG 数字类型的参数。表示向前或者向后移动多少个单元,而这个数可以是负数,表示反向移动。而 scan-sexps 则是它们背后的函数。它不移动点,而是计算并返回一个位置。它的定义如下:(scan-sexps FROM COUNT)它从 FROM 位置开始,向前(COUNT为正)或者向后(COUNT 为负)扫描 COUNT 个平衡表达式。然后返回扫描停止时的位置。我们可以实现一个简单的标记当前光标所在语法单元的小例子看看怎么使用(defun my/mark-sexp() (interactive) (let* ((cur (point)) (start (scan-sexps cur -1)) (end (scan-sexps start 1))) (push-mark start) (goto-char start) (push-mark end t t)))上述代码比较简单,单纯的通过 scan-sexps 传入不同的参数让它从当前位置往前或者往后进行扫描以确定对应语法单元的起始位置。然后利用mark 和 当前光标位置组成的一个region来实现选中。在实际进行词汇分析更常用的函数可能是 syntax-ppss ,它主要用来扫描从当前位置到指定位置范围内的文本,并确定其上下文。它底层调用 parse-partial-sexp ,但是比 parse-partial-sexp 多了增加了内部缓存,使得重复调用变得高效且开销极低。一般在开发Emacs lisp相关程序时会推荐使用 syntax-ppss 而避免使用 parse-partial-sexp它返回一个包含十个元素的列表用来表示当前的状态,按顺序依次为:括号的嵌套深度最内层包含括号表达式的起始字符位置解析的最后一个完整表达式(S-表达式)起始处的字符位置是否在字符串内?如果在字符串内,则返回字符串终止符字符(例如 ?\" );对于通用字符串,则返回 t是否在注释内?如果在注释内则返回 t ,或返回特殊注释类型的整数表示如果点恰好位于转义下一个字符的引号字符之后,则返回 t本次扫描期间达到的最小括号深度。注释的样式当前字符串或注释起始处的字符位置当前未闭合括号位置的内部列表练习之前的练习,我们通过正则表达式来匹配注释中的todo或者fixme 信息,并加入到minibuffer 中,我们今天改一个写法,采用rx表达式找到todo、fixme 这样的字眼,然后通过 syntax-ppss 来判断是否处于注释中。(defvar my/todo-mark-list '() "保存全局的 todo 列表,格式为 (SUMMARY-TEXT . marker)") (defun my/code-todo-list-collect () "收集当前 buffer 中位于注释里的 TODO/FIXME。" (interactive) (setq my/todo-mark-list '()) (save-excursion (save-restriction (widen) (goto-char (point-min)) (let ((todo-regexp (rx (or "todo" "fixme" "TODO" "FIXME")))) (while (re-search-forward todo-regexp nil t) (let ((beg (match-beginning 0))) ;; 只保留位于注释中的匹配 (when (nth 4 (syntax-ppss beg)) ;; re-search-forward 在找到之后跳转光标到匹配位置的尾部,这里我们扫描尾部到开头位置范围内的词是否在注释内 (let* ((raw-content (match-string 0)) (m (copy-marker beg))) (push (cons raw-content m) my/todo-mark-list)))))))))其他位置的代码我就不放了,读者可以尝试一下在之前练习的基础上进行简单修改即可总结本篇我们介绍了Emacs中原始的 Syntax-Table 以及一些词法扫描的API函数。它们是forward-sexp 和 backward-sexpscan-sexpssyntax-ppss
2026年08月20日
4 阅读
0 评论
0 点赞
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-18
《elisp 每日一练》——文本属性
在前面的例子中,我们已经见识过文本属性。我们通过将buffer、file-name、line-number 等信息作为属性附加到字符串上,在用户选择了这个字符串执行跳转时又读取它的属性,找到之前保存的信息,从而完成了跳转。背景知识从上面的例子中我们知道,属性实际上是一个标签,也可以是一个信息存储的媒介。既可以存储官方支持的属性,也可以存储自定义的属性。我们可以通过 add-text-properties 来添加一个属性。它的定义如下:(add-text-properties START END PROPERTIES &optional OBJECT)各个参数的含义如下:START和 END :表示一个范围,给这个范围内的字符串添加属性PROPERTIES: 是一个表示各种属性的列表OBJECT: 是一个buffer,表示往哪个buffer中的字符串添加属性,默认不填的话表示当前bufferPROPERTIES 是一个属性列表,它的形式为 (prop1 value1 prop2 value2 ...) 。在Emacs中,可以通过设置buffer中字符串的相关属性实现例如文本高亮、交互效果等操作。这些都可以通过设置官方提供的属性来支持。官方提供的常用效果有:文本显示效果的有:属性名作用常用值示例face控制文本外观(颜色、粗体、斜体等)'bold、'font-lock-keyword-face、'(:foreground "red" :weight bold)font-lock-face专门给 Font Lock 使用的 face(推荐用于语法高亮)同 facemouse-face鼠标悬停时显示的 face'highlightcursor-face光标位于该文本时显示的 face(需开启 cursor-face-highlight-mode)'highlight交互与提示的属性有:属性名作用常用值示例help-echo鼠标悬停时在 echo area 或 tooltip 显示提示"""点击查看详情"" 或函数"keymap为这段文本添加局部按键映射(优先级高于 buffer 本地 map)一个 keymap 对象local-map替换 buffer 本地 map(较少用,一般优先用 keymap)一个 keymap 对象显示控制的属性属性名作用常用值示例display改变文本显示方式(替换为图片、字符串、调整高度/宽度等)"'(image :type png :file ""icon.png"") 或 ""替换文本"""invisible使文本不可见t 或某个符号(配合 buffer-invisibility-spec)composition字符组合显示(用于复杂脚本或特殊效果)较少直接使用编译与移动控制的属性属性名作用常用值示例read-only禁止修改该字符t 或错误提示字符串inhibit-read-only即使在只读 buffer 中也允许编辑tcursor-intangible禁止光标停留(配合 cursor-intangible-mode)tfield定义“字段”某些移动命令(如 forward-word)会停在字段边界任意符号或值其他常用属性属性名作用category属性分类符号,该符号上的属性会作为默认值继承syntax-table覆盖该字符的语法表属性fontified标记文本是否已完成字体化(Font Lock 内部使用)inhibit-isearch让 isearch 跳过这段文本cursor控制光标在 overlay / display 字符串中的显示位置add-text-properties 可以一次性设置多个属性,而且前面设置的属性,后面可以通过该函数修改。如果调用该函数发生了属性修改,则函数返回 t 否则返回 nil除了该函数,关于属性设置的函数还有:put-text-property 只设置一个属性remove-text-properties 删除指定属性set-text-properties 完全替换整段文本的所有属性,如果为 nil 则表示清空所有属性add-face-text-property 专门用来添加 face 属性另外我们可以通过 get-text-property 来获取属性值。练习不知道各位读者是否还记得,关于buffer的综合练习中的 App-Log 的内容。今天的练习我尝试对log函数进行一些改进,根据log等级来用不同的颜色显示,我这里规定:红色表示error、黄色表示warning、绿色表示debug、灰色表示info。(defun my/log(level message) (let* ((level-sym (if (stringp level) (intern (upcase level)) level)) (src-buf (current-buffer)) (file-or-buf (or (buffer-file-name) (buffer-name))) (line-num (line-number-at-pos)) (log-buf (get-buffer-create "*App-Log*")) ;; 定义不同等级日志的字体颜色 (level-face (pcase level-sym ('ERROR '(:foreground "red" :weight bold)) ('WARNING '(:foreground "orange" :weight bold)) ('DEBUG '(:foreground "green")) ('INFO '(:foreground "gray50"))))) (with-current-buffer log-buf (let ((inhibit-read-only t)) (goto-char (point-max)) (let ((start-pos (point))) ;; 格式 [LEVEL][buffer-name][line-number]:message (insert (format "[%s][%s][%d]: %s\n" level-sym file-or-buf line-num message)) ;;为了便于后续跳转回来,这里我们保存一些隐藏信息 (add-text-properties start-pos (point) `(log-file ,file-or-buf face ,level-face log-line ,line-num log-buf ,src-buf)))))))这里使用了一个 pcase 宏用来作为条件判断,与 cond 相比它更为灵活,写出来的代码更加简洁。我们通过 pcase 判断当前等级,根据等级进行匹配,最终找到合适的颜色。在设置属性的时候我们将属性添加进来,同样的需要使用 , 对属性值的变量进行求值并替换。总结本篇我们了解了属性的相关概念和应用,下面是今天提到的一些api函数add-text-propertiesput-text-propertyremove-text-propertiesset-text-propertiesadd-face-text-propertyget-text-property
2026年08月18日
6 阅读
0 评论
0 点赞
2026-08-17
《elisp 每日一练》正则搜索与 rx 宏
看到这个标题我想读者可能有这样的疑问,前面已经介绍了正则表达式了,这次又来,不会是冷饭重炒没什么新东西了吧。实际上Emacs的正则表达式算是比较高级,操作相对复杂的部分了,一篇简单的函数介绍还不足以覆盖。所以在关于文本操作相关的主题中值得专门拿出一篇介绍一些新鲜内容。各位读者请放心往下看,绝不浪费时间。背景知识铺垫在前面我们介绍过,想要使用Emacs中的正则表达式,有下面一个函数:re-search-forward 和 re-search-backward 用于搜索match-string 用于提取搜索到的文本replace-match 用于替换但是Emacs 中使用正则表达式有一个比较坑的点是转义字符必须使用 \\ 来表示一个 \ , 这个细节如果不熟悉Emacs正则表达式的读者可能经常发生明明写了 \\([0-9]+\\) 进行查找结果就是什么都没有,你只能写下 \\\\([0-9]+\\\\) 。 这也太反人类了。好在Emacs解决了这个问题,它提供了 rx 宏。rx宏使我们可以用S 表达式来组织正则表达式。本节我们的重点是介绍rx宏,以便摆脱正则表达式的纠缠。它可以使我们像写elisp代码一样来描述我们的匹配需求。例如看到下面这个Emacs中的正则表达式:"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]\\{2,\\}"我们看到它前面匹配任意字符,中间加一个@符号,后面又是任意字符表示域名,而最后一个 . 后面接至少两个由A到Z组成的后缀。而使用 rx 表达式则可以写成:;; 使用 rx:可读性极大提升 (rx (one-or-more (any "a-z" "A-Z" "0-9" "._%+-")) ;; 第一部分这些任意字符,至少一个 "@" ;; 第二部分是一个@固定字符 (one-or-more (any "a-z" "A-Z" "0-9" ".-")) ;; 第三个部分又是这些字符里面任意字符, 至少一个 "." ;; 第四部分是一个 . (repeat 2 nil (any "a-z" "A-Z"))) ;; 最后一个部分是英文字母但是至少两个我们看到rx 表达式表示的是否更清楚呢。如果你的答案是肯定的,那么本篇的内容值得你继续往下阅读。rx的定义如下(rx REGEXPS...)rx 接收一个或者多个 rx 形式的s表达式,这些形式最终会被翻译成普通的正则表达式字符串,下面按类别介绍最常用的构造。字面量字面量主要有字符串和字符,它们没有特殊含义就是表示字符本身。例如(rx "hello") ; → "hello" (rx ?a) ; → "a" (rx "a.b") ; → "a\\.b"(点被自动转义)序列与选择我们可以通过 (seq FORM...) 或 (sequence FORM...)、(: FORM...)、(and FORM...):按顺序匹配。也可以通过 (or FORM...) 或者 (| FORM...) 表示匹配其中的任意一个。就像上面的例子那样,通过多个基础的rx 单元进行组合,拼接成一个完整的匹配项。又或者如下面的例子:(rx (seq "http" (opt "s") "://")) ; → "https?://" (rx (or "cat" "dog" "bird")) ; → "cat\\|dog\\|bird"重复有下列方式表示重复:zero-or-more 表示0次或者多次one-or-more 表示至少出现一次zero-or-one 表示至多一次,或者说出现0次或1次repeat N 或者 = N 恰好出现N次>= N 至少N次** N M N次到M次rx 中重复默认都采用贪婪模式,如果想要使用非贪婪模式,可以加上 ?字符匹配如果我们想要表示匹配对应集合中任意一个字符,那么可以提供集合,并且通过 (any SET...) / (in SET...) / (char SET...) 表示匹配集合中的任意一个字符。使用 (not CHARSPEC) 匹配不在集合中的字符集合我们可以采用正则表达式中的表现方式例如: a-z 表示小写英文字母,0-9 表示数字。在rx中也可以使用预定义的集合:digit、numeric、num 表示数字alpha、alphabetic、 letter 表示字母alnum 、alphanumeric 表示数字+大写字母+小写字母的序列xdigit 、hex-digit 表示16进制数blank、space / whitespace 表示空白字符word 、 wordchar 表示单词位置匹配如果我们想在上述匹配基础上加上位置,可以考虑使用下面列举的一些内容:line-start、bol:行首(^)line-end、 eol:行尾($)string-start、 bos、 buffer-start:字符串/缓冲区开头(\`)string-end、 eos、 buffer-end:结尾(\')word-start、bow:单词开头(\<)word-end、eow:单词结尾(\>)word-boundary:单词边界(\b)not-word-boundary:非单词边界(\B)symbol-start、symbol-end:符号边界point:当前 point 位置(\=)捕获组对于捕获组,有如下方式:(group FORM...) 、 (submatch FORM...) : 对应着正则表达式中的捕获组 \(...\)(group-n N FORM...) :命名编号的捕获组(backref N):反向引用第 N 个组对于 rx 更加详细的内容,读者可以通过 describe-function 查阅有关 rx 的内容。另外我们也可以通过 rx-to-string 来将一个rx 表达式转化为正则表达式.练习本节的练习,我们利用rx表达式来实现一个将 markdown 中的标题修改为 org mode 中标题的小工具。为了实现这个转换,我们大致需要经历这么几步:通过正则表达式找到 markdown 的标题行获取匹配到的 # 数量以便确定是几级的标题根据#数量替换成等数量的 *根据上面步骤,我写下如下的代码:(defun my/convert-md-tilte-to-org() (interactive) (save-excursion (save-restriction (widen) (goto-char (point-min)) (let ((count 0) (header-regexp (rx line-start (group (repeat 1 6 "#")) (one-or-more space))) ;;位于行首,捕获组1的特征是,# 重复1到6次,后面跟一个或者多个空格 ) (while (re-search-forward header-regexp nil t) (let* ((hashes (match-string 1)) ;; 从捕获组1当中提取匹配到的#字符串 (has-count (length hashes)) ;; 计算#数量 (starts (make-string has-count ?*)) ;; 生成对应数量的 * 字符串 (replacement (concat starts " ")) ;; 组装org mode 标题行 ) (replace-match replacement t t) (setq 1+ count))) (if (> count 0) (message "共找到%d处并替换" count) (message "未找到对应的标题行"))))))小结本节内容主要介绍了通过 rx 来生成生成正则表达式,采用这种方式的好处在于正则表达式代码容易理解,不容易出错,而且还可以带上注释,更进一步降低正则表达式的阅读门槛
2026年08月17日
3 阅读
0 评论
0 点赞
2026-08-16
《elisp 每日一练》—— 专题综合实战
到此我们关于Buffer 和window 的内容就全部介绍完了。按照惯例本节对之前内容进行总结并完成一个综合性的实战练习知识回顾首先我们学习关于buffer管理的一些函数get-buffer-create 和 get-bufferkill-bufferset-bufferwitch-current-bufferbuffer-namebuffer-live-p接着我们学习了一些控制buffer属性相关的函数buffer-namerename-bufferbuffer-file-name 和 set-visited-file-namebuffer-modified-p 和 set-buffer-modified-pbuffer-read-onlyinhibit-read-only后面我们进入关于Window的内容,window有下列函数split-windowset-window-bufferwindow-bufferselect-windowselected-windowwalk-windowsdelete-window 和 quit-windowdisplay-buffer 和 pop-to-buffer之后介绍了如何在buffer中贴上一个标识的标签,通过 overlay机制make-overlayoverlay-put 和 overlay-getoverlay-start 和 overlay-endoverlays-at 和 overlays-in最后我们介绍了如何跨buffer进行文本提取,主要是一个函数 insert-buffer-substring综合练习今天的综合练习,我们尝试将这些内容结合起来,我们完成一个简单的交互式的日志buffer。它具有下面的功能:对外提供一个简单的 log 函数,它支持 ERROR、WARNING、DEBUG、INFO 四种类型的日志代码种每调用一次 log 函数都会往 *App-Log* buffer种记录一条日志日志的格式为 ERROR[line-num]: message我们可以通过日志中记录的信息快速回到对应写日志的位置我们先实现一个对外的日志函数, 主要用来记录日志,并保存一些隐藏信息(defun my/log(level message) (let* ((level-sym (if (stringp level) (intern (upcase level)) level)) (src-buf (current-buffer)) (file-or-buf (or (buffer-file-name) (buffer-name))) (line-num (line-number-at-pos)) (log-buf (get-buffer-create "*App-Log*"))) (with-current-buffer log-buf (let ((inhibit-read-only t)) (goto-char (point-max)) (let ((start-pos (point))) ;; 格式 [LEVEL][buffer-name][line-number]:message (insert (format "[%s][%s][%d]: %s\n" level-sym file-or-buf line-num message)) ;;为了便于后续跳转回来,这里我们保存一些隐藏信息 (add-text-properties start-pos (point) `(log-file ,file-or-buf log-line ,line-num log-buf ,src-buf)))))))文本属性我们目前没有介绍到,属于朝纲的内容,但是相对来放在这里并不难理解。另外注意这里我们使用的是反引号而不是列表中常用的单引号。单引号的作用是禁止对表达式求值,但是我们实际需要对后面的 file-or-buf这些变量进行求值。所以我们使用反引号表示将这个作为列表的模版,对逗号后面的表达式求值。然后我们添加一个从log 信息中跳转到对应位置的函数,该函数主要作用是从日志文件中读取隐藏数据,并跳转(defun my/log-jump-to-source() (interactive) (let ((file (get-text-property (point) 'log-file)) (line (get-text-property (point) 'log-line)) (src-buf (get-text-property (point) 'log-buf))) (message "%s %s %d" file (buffer-name src-buf) line) (if (not (and file line)) (message "没有有效的日志源信息") (if (and src-buf (buffer-live-p src-buf)) ;;弹出或切换到源buffer (pop-to-buffer src-buf) (find-file file)) ;;移动到对应行 (goto-char (point-min)) (forward-line (1- line)))))在跳转的时候,我认为在跳转回源buffer时,应该重点仍然在日志上,我们只是简单的看看报错的日志在哪里。也就是要保留日志buffer、短暂的显示源buffer。所以就排除了使用 split-window 的想法,但是我们可能需要跳转之后短暂的修改代码,所以我认为使用 pop-to-buffer 比较合适。到此我们简单的日志工具就完成了。虽然它不那么完美,例如我们记录的其实并不是调用log函数时的行,和buffer,而是执行时的当前buffer和光标所在位置。实际上我们可以通过宏来解决,就像c/c++ 中的 __FILE__、__LINE__ 一样。读者可以查阅相关资料尝试解决这些问题。
2026年08月16日
7 阅读
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-14
《elisp 每日一练》—— overlay 机制
在之前的文章中,我们利用 walk-windows 和 completing-read 实现了一个可以跳转到任意窗口的程序。但是我们通过 buffer + window 的方式进行显示,如果buffer不重名还好,用户可以根据buffer名称来选择,而当buffer有重名,用户没办法直观的看出哪个项对应哪个窗口。如果读者尝试过配置自己的Emacs,在管理窗口方面一定绕不开 ace-window这个插件,它提供了一个非常优雅高效的方式。今天将介绍overlay机制,并且利用该机制实现一个类似的跳转功能。背景知识介绍对于buffer的显示效果,Emacs提供了两种改变的机制,一种是通过设置 Text properties 来改变文本的字号、字体大小等属性,是 buffer 文本的一部分,会随文本一起复制、保存。另一个就是 Overlay 机制,它只影响显示,不进入 undo 列表,也不随文本复制。对于 Overlay 官方给它的定位是:You can use overlays to alter the appearance of a buffer’s text on the screen, for the sake of presentation features. An overlay is an object that belongs to a particular buffer, and has a specified beginning and end.你可以将它想象成一个贴在buffer上的便利贴。如果要使用的话,我们首先要创建一个便利贴、然后决定便利贴的位置,最后在不需要时销毁它。并且Emacs还提供了一些操作它的函数。下面我们来列举一些重要的函数。创建可以使用函数 make-overlay,它的原形如下:(make-overlay BEG END &optional BUFFER FRONT-ADVANCE REAR-ADVANCE)函数的相关参数如下:BEG 和 END : 指定overlay起始位置和终点位置BUFFER : 指定叠加层属于哪个缓冲区, 默认为当前缓冲区FRONT-ADVANCE 和 REAR-ADVANCE 表示当开头或者结尾被插入新字符时,overlay 该如何变化我们来了解一下最后两个参数的含义。我们还是将它想象成一个便利贴。假设便利贴我们从头到尾写满了文字,这个时候我们想要在便利贴开始位置或者结束位置写一个新的内容,该怎么办? FRONT-ADVANCE 和 REAR-ADVANCE 就是来处理这种情况的。一种办法是丢失这些内容,纸张的大小不变,这个时候对应着 FRONT-ADVANCE 为非 nil 而 REAR-ADVANCE 为 nil。还有一种办法就是纸张大小改变,我们在前面和后面插入内容,此时对应着 FRONT-ADVANCE 为 nil 而 REAR-ADVANCE 为非 nil关于属性,我们有 overlay-put 和 overlay-get 来设置或者获取属性。面对一个陌生的,不是我们创建的 overlay,我们可以通过 overlay-start 和 overlay-end 来获取它的起始和结束位置另外我们可以通过 overlays-at 和 overlays-in 来获取区域内的所有 overlay。我们常用的属性有:face 设置样式,包括背景色、前景色、字体等display : 替换overlay中的的显示内容priority : 重叠时的 z序window : 限定只在指定窗口生效overlay 实际是对于 buffer 的修改,所以默认情况下,如果多个窗口都显示同一个buffer,那么每个窗口都会显示这样的内容,我们可以通过 window 属性,限定只在指定的窗口中显示例如我们可以尝试写下如下代码,来创造一个显示内容为 "标记" 的黄低红色的标记,并且通过 window 属性指定只在当前窗口显示(let ((overlay (make-overlay (point-min) (+ 10 (point-min))))) (overlay-put overlay 'window (selected-window)) (overlay-put overlay 'face '(:background "red" :foreground "yellow")) (overlay-put overlay 'display "A"))练习到此我们差不多已经掌握了 overlay 的绝大部分用法了。可以开始我们的改造行动了。我们的思路仍然是先遍历,然后给每个窗口打上标签,并且记录到一个列表中,列表中每项的内容为 (LABLE . window) 后续根据用户输入的字母或者数字来找到窗口并跳转。(defvar my/all-window-list '() "保存窗口字典的列表") (defun my/show-overlay(label win) "在左上角创建显示字母的 overlay" (with-selected-window win (let* ((pos (window-start)) (ol (make-overlay pos (+ 10 pos) (window-buffer win)))) (overlay-put ol 'display label) (overlay-put ol 'face '(:foreground "red" :weight bold :background "yellow")) (overlay-put ol 'window win) ol))) ;; 返回 ol 用于清理 (defun my/jump-to-selected-window() "根据用户按下的字母标签进行跳转" (interactive) (let*((chars (mapcar #'identity "abcdefghijklmnopqrstuvwxyz")) ;; 构建一个字母的字典 (overlays '()) ;; 保存所有的overlay 方便后面删除 (index 0)) ;; 存储当前使用的字母的索引 (walk-windows (lambda(win) (when (< index (length chars)) (let* ((ch (nth index chars)) (ol (my/show-overlay (string ch) win))) (when ol (push ol overlays) (push (cons ch win) my/all-window-list))) (setq index (1+ index))))) ;; 读取用户按键并跳转 (unwind-protect (let* ((char (read-char "选择窗口: ")) (win (cdr (assoc char my/all-window-list)))) (if win (select-window win) (message "没有选择对应的窗口"))) ;;清理资源 (mapc #'delete-overlay overlays) (setq overlays nil))))上述代码中使用了一个新的宏 unwind-protect。它的作用与try...catch 中的 finally 类似,它的定义如下:(unwind-protect body-form cleanup-forms...)它主要执行body-form 中的内容,并且保证会执行 cleanup-forms 中的清理代码。总结本节我们学习了如何给buffer 加上 overlay ,用来展示一些信息。本节介绍的函数有make-overlayoverlay-put 和 overlay-getoverlay-start 和 overlay-endoverlays-at 和 overlays-in
2026年08月14日
5 阅读
0 评论
0 点赞
1
...
3
4
5
...
45