Elisp 08:文本跨行提取

码影逐风家
• 阅读 2632

前言:不知多久能学会 Elisp

上一章:命令行程序界面

在上一章的结语里,我说这个教程是否会有第二部分,取决于我是否遇到了新的文本处理问题。结果很快如愿以偿。

问题

下面是 XML 文件 foo.xml 的内容:

<bib>
  <title>foo</title>
</bib>
<attachment>
  <resource src="files/foo.html"/>
  <title>foo</title>
</attachment>
<bib>
  <title>bar</title>
</bib>
<attachment>
  <resource src="files/bar.html"/>
  <title>bar</title>
</attachment>

我需要从 <attachment>...<attachment> 块里提取以下条目:

<resource src="files/foo.html"/>
<title>foo</title>
<resource src="files/bar.html"/>
<title>bar</title>

文本跨行匹配

现在假设已用 Elisp 函数 find-file 将 foo.xml 文件内容全部载入了缓冲区,即

(find-file "foo.xml")

然后发现,之前学过的 Elisp 知识几乎派不上用场了。之前学过的文本匹配和提取方法仅适用于单行文本,而现在面临的问题是多行文本的匹配和提取,即从当前缓冲区内提取

<attachment>
  <resource src="files/foo.html"/>
  <title>foo</title>
</attachment>
<attachment>
  <resource src="files/bar.html"/>
  <title>bar</title>
</attachment>

莫说提取,仅仅是如何匹配 <attachment>...</attachment> 块就已经不好解决了。例如,以下程序

(find-file "foo.xml")

(let ((x (buffer-string)))
  (string-match "<attachment>\\(.+\\)</attachment>" x)
  (princ\' (match-string 1 x)))

输出 nil,意味着 string-match 在当前缓冲区内容中匹配 <attachment>...</attachment> 块失败。导致失败的原因也很简单,因为正则表达式 . 虽然可以匹配任意一个字符,但它不包括换行符。

瞒天过海

实现文本的跨行匹配,并非不可行,但是需要比现在更多的 Elisp 的正则表达式知识 1。但是,我想说的是,对于上述问题,现有的 Elisp 知识其实也是足够用,只需要转换一下思路。

文本为什么是多行的?是因为在输入文本的时候,每一行末尾由人或程序添加了换行符。倘若能将这些换行符临时替换为一个很特殊的记号,那么多行文本就变成了单行文本。在文本匹配和处理结束后,再将这个特殊记号再替换为换行符,单行文本又复原为多行文本。此为瞒天过海之计。

将当前缓冲区内所有的换行符替换为一个特殊记号,可基于第 6 章所讲的缓冲区变换方法予以实现。本章给出一个更快捷的方法。Elisp 函数 replace-string 可在当前缓冲区内使用指定字串替换所有目标字串,例如

(let ((x "")
      (y "")
      (one-line (generate-new-buffer "one-line")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "<linebreak/>")
    (setq y (buffer-string)))
  (princ\' y))

执行上述程序后,在新创建的缓冲区 one-line 里存放的便是 foo.xml 缓冲区的单行化结果。倘若将上述代码里的 (princ\' y) 语句替换为

(string-match "<attachment>\\(.+\\)</attachment>" y)
(princ\' (match-string 1 y))

便可提取 <attachment>...</attachment> 块,尽管提取的结果是错的。

为了更方便观察错误,需要构造一个简单的例子:

(setq x "abcabcabc")
(string-match "a\\(.+\\)a" x)
(princ\' (match-string 1 x))

这个例子会输出什么呢?虽然我很期望它输出 bc,但事实上它输出的是 bcabc。这是因为 + 是很贪婪的,它总是希望能匹配最长的结果,而不是最短的。* 也是如此。在 Elisp 的正则表达式里,在它们的后面加一个 ?,便可以抑制它们的贪婪,例如

(setq x "abcabcabc")
(string-match "a\\(.+?\\)a" x)
(princ\' (match-string 1 x))

此时,程序的输出结果便是 bc 了。

递增搜索

Elisp 函数 re-search-forward 可以在缓冲区内搜索与正则表达式匹配的文本的同时,将插入点移动到缓冲区的匹配位置。基于该函数,再借助 Elisp 正则表达式的文本捕获功能,便可从上一节构造的 one-line 缓冲区内提取多个 <attachment>...</attaqchment> 块了。

为了演示 re-search-forward 的用法,我将上一节的那段示例代码改造为以下代码:

(let ((x "")
      (one-line (generate-new-buffer "one-line"))
      (output (generate-new-buffer "output")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "<linebreak/>")
    (goto-char (point-min))
    (while t
      (if (re-search-forward "\\(<attachment>.+?</attachment>\\)" nil t 1)
          程序分支 1
        程序分支 2))))

re-search-forward 是迄今为止我用过的最为复杂的 Elisp 函数了,它有 4 个参数,但只有第 1 个参数是必须的,其他 3 个参数皆为可选——倘若不设定它们的值,re-search-forward 会使用它们的默认值。这 4 个参数释义如下:

  • 第一个参数,是用于文本匹配的 Elisp 正则表达式。
  • 第二个参数,用于设定最大搜索范围。由于 re-search-forward 是在当前缓冲区内进行文本匹配搜索,搜索的起始位置是插入点所在位置,终止位置可通过它的第二个参数设定,若该参数值为 nil,则将当前缓冲区的尽头作为搜索范围的终止位置。
  • 第三个参数值若为 nil,在未搜索到匹配文本时,re-search-forward 便会报错。若该参数值为 tre-search-forward 会返回 nil。若该参数值即不是 nil,也不是 t,则 re-search-forward 函数将插入点移动到搜索区域的尽头,然后返回 nil
  • 第四个参数 COUNT,可令 re-search-forward 的搜索过程维持到第 COUNT 次匹配后结束,倘若未设定这个参数,其值默认为 1。

若充分理解了 re-search-forward 函数的用法,则上述代码虚设的程序分支 1 对应的代码便可写出来了,不再需要新的 Elisp 知识,即

(let ((y (match-string 1)))
  (with-current-buffer output
    (insert (concat y "\n"))))

就是将 re-search-forward 捕获的文本用 match-string 函数取出后插入 output 缓冲区。在此需要注意,若正则表达式捕获的文本属于当前缓冲区,match-string 函数无需写第 2 个参数。

对于程序分支 2,即 re-search-forward 匹配失败情况的处理,现有的 Elisp 知识是真的不够用了。因为该程序分支属于一个无限迭代过程,要从后者跳出,需要像其他编程语言那样,需要有 returnbreak 语法,可提前终止迭代过程。

catch/throw

Elisp 语言没有 returnbreak,但是它有 catch/throw 表达式。

下面的示例

(catch 'foo
  (princ\' "foo")
  (princ\' "bar"))

可输出

foo
bar

现在,倘若我将上述代码修改为

(catch 'foo
  (princ\' "foo")
  (throw 'foo nil)
  (princ\' "bar"))

那么位于 throw 表达式之后的代码便会被 Elisp 解释器忽略,因而现在的代码只能输出

foo

倘若将上述代码修改为

(princ\' (catch 'foo
           (princ\' "foo")
           (throw 'foo nil)
           (princ\' "bar")))

输出结果则变为

foo
nil

因为 throw 的第 2 个参数 nil 会被 Elisp 作为 catch 表达式的求值结果。

catch/throw 在 Elisp 语言里称为「非本地退出」,基于它们便可模拟其他编程语言里的 returnbreak 以及异常机制。

基于 catch/throw,便可实现上一节所述的程序分支 2 了,例如

(throw 'break nil)

然后只需将 while 表达式放在 catch 块里,由后者捕捉 throw 抛出的 'break,即

(catch 'break
  (while t
    (if (re-search-forward "\\(<attachment>.+?</attachment>\\)" nil t 1)
        程序分支 1
      (throw 'break nil))))

恢复多行文本

现在,以下代码

(let ((x "")
      (one-line (generate-new-buffer "one-line"))
      (output (generate-new-buffer "output")))
  (find-file "foo.xml")
  (setq x (buffer-string))
  (with-current-buffer one-line
    (insert x)
    (goto-char (point-min))
    (replace-string "\n" "<linebreak/>")
    (goto-char (point-min))
    (catch 'break
        (while t
          (if (re-search-forward "\\(<attachment>.+?</attachment>\\)" nil t 1)
              (let ((y (match-string 1)))
                (with-current-buffer output
                  (insert (concat y "\n"))))
            (throw 'break nil))))))

已基本解决本章开始所提出的问题了,因为 output 缓冲区内存放着从 foo.xml 文件里提取的两个 <attachment>...</attachment> 块,接下来,我只需将其中的 <linebreak/> 替换为 \n,问题便完全解决了。但是,我觉得这个任务可以留作本章习题。

save-excursion

在当前缓冲区内,insertreplace-string 以及 re-search-forward 等函数,皆有副作用,它们会移动插入点。在文本处理时,要记住当前的插入点所在的位置,然后调用这些函数之后,需要再将插入点恢复原位。这是前面几节代码多次出现

(goto-char (point-min))

的主要原因。Elisp 提供了 save-excursion 语法,它可以自动将插入点的位置保存下来,然后执行一些可能会移动插入点的运算,最后再将插入点恢复原位。例如

(save-excursion
  (insert x))

(let ((p (point)))
  (insert x)
  (goto-char p))

等价。

因此,本章第二个习题是,基于 save-excursion 语法修改上一节习题的答案。

结语

本章介绍了 Elisp 缓冲区里更多的运算以及非本地退出语法。掌握了这些知识,可从任何文本文档内提取符合模式的由多行文本构成的文本块。

下一章:


  1. https://www.emacswiki.org/ema...
点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
7个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Jacquelyn38 Jacquelyn38
4年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
Python Challenge Level 18
初学Python,挑战一下流行的PythonChallenge,很不幸,卡在了18关~~被字符字节码之间的转换搞得焦头烂额,不过终于搞定了还是很happy的~~~主要的问题就是16进制形式的字符如何转成字节码(注意:不是encoding)如:\'89','50','4e','47','0d','0a','1a','0a','00
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
Uber准备放弃自动驾驶,转手卖给前谷歌无人车CTO,估值曾被孙正义炒到72.5亿美元
!(https://oscimg.oschina.net/oscnet/0fe7cb00a0cf4872b022342d1e21d47e.png)杨净发自凹非寺量子位报道|公众号QbitAI最新消息,Uber要出售无人驾驶部门(ATG)了。据TechCrunch报道,Uber有意向出售,而也有人愿意买。
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这