01 月 29 日
理解本文的一切,需要对 M4、AWK 以及 Bash 语言有所了解。若需补充这些知识,可阅读文献 [1-3]。
M4
语言是一种宏语言,其语法与英文习惯相符,在视觉上很容易分辨普通文字和宏语句,但是对于中文宏,便显得甚为违和。例如,以下
M4 代码定义了中文宏 宏一
:
define(宏一, 你好,我是宏一)
在 M4 语言中,中文宏名是非法的,只能以间接的形式调用:
indir(宏一)!
本文基于 AWK 和 Bash 语言,为 M4 中文宏实现更为直观方便的调用形式,作为 M4 语言的非侵入式扩展,只是该扩展缺乏完备性——可能不支持 M4 语言的全部内涵。
理论上,M4 宏可以随时定义随时调用,甚至可以永远在定义和调用,但是从通常用途的角度,定义通常放在负的空间,在零空间调用。例如
@ foo.m4 #
divert(-1)
define(宏一, 你好,我是宏一)
divert(0)dnl
宏一!
我将上述 M4 代码中的
divert(-1)
define(宏一, 你好,我是宏一)
divert(0)dnl
视为宏的定义空间,将剩下的部分,称为宏的应用空间。如此划分空间,便于收集已定义的宏,同时也便于为中文宏构造间接调用。另外需要注意,在
M4 语言中,中文宏名的非法性,会导致上述代码中定义的 宏一
并不会被 m4 处理器无限展开,若是西文环境,则不然,例如:
divert(-1)
define(foo, `i am foo')
divert(0)dnl
! expanding foo
结果会导致 m4 处理器对 foo
递归展开,得到的是一个永远都没法结束的结果:
expanding i am i am i am i am i am i am ...
以下 AWK 脚本可以区分宏的定义空间和应用空间:
@ foo.awk # /divert\(-1\)/ { action = 0 print $0 next } /divert\(0\)dnl/ { action = 1 print $0 next } { if (!action) { print $0 # 收集已定义的宏 @ } else { # 为中文宏构造间接调用 @ print $0 } }
在宏的定义空间中,可以用一个数组,收集 define
语句定义的宏名:
@ 收集已定义的宏 # x = $0 while(match(x, /define\((.*),/, s)) { sub(/^[` \t]*/, "", s[1]) sub(/[' \t]*$/, "", s[1]) macros[s[1]] = s[1] x = substr(x, RSTART + RLENGTH) } => foo.awk
RSTART
是 match
函数从 x
中获得与正则表达式 /define\((.*),/
匹配部分的起始位置,RLENGTH
是匹配长度。s
用于存储正则表达式中捕获的内容,即 /define\((.*),/
中的
(.*)
部分,后者是正则表达式的捕获语法。s[1]
表示捕获到的第一个字符串,随后两次调用 sub
函数,可去除
s[1]
两侧可能存在的 m4 引号。
不过,能够支持正则表达式捕获语法的 awk,并非标准 awk,因为在标准 AWK 语言中,没有正则表达式捕获语法。GNU awk 所支持的 AWK 语言是扩展的 AWK 语言。对于一些仅实现了标准 AWK 语言的 awk 程序,它们不兼容 GNU awk 脚本,例如某些近年来发行的 Ubuntu 系统及其衍生系统,它们默认安装的 awk 程序是 mawk。若仅使用标准 AWK 语言实现与上述代码同等功能,会略微复杂一些:
@ 收集已定义的宏(标准 AWK 代码) # x = $0 while(match(x, /define\(/)) { s = substr(x, RSTART + RLENGTH) x = s sub(/,.*$/, "", s) sub(/^[` \t]*/, "", s) sub(/[' \t]*$/, "", s) macros[s] = s } => zhe.awk
收集到所有已存在定义的宏后,在宏的应用空间,需要反复搜索文本里出现的宏名,包括但不限于中文宏名,然后为其构造间接调用:
@ 为中文宏构造间接调用 # for (i in macros) { x = $0 before = "" while ((j = index(x, i)) > 0) { n = length(i) before = before substr(x, 1, j - 1) "`'indir(" i ")" after = substr(x, j + n) x = after } $0 = before x } => foo.awk
将 foo.awk 作用于上一节的 foo.m4 文件:
$ awk -f foo.awk foo.m4
结果为
divert(-1)
define(宏一, 你好,我是宏一)
divert(0)dnl
(宏一)! `'indir
若使用 GNU m4 处理上述 M4 文件,即
$ awk -f foo.awk foo.m4 | m4
结果为
你好,我是宏一!
现在,对中文宏的调用形式加以明确。
首先,对于无参数的宏,可以直接调用,或者用括号的形式。例如对于中文宏
define(宏一, 你好,我是宏一)
调用形式可以是
宏一
也可以是
(宏一)
亦即可用半角或全角的圆括号将宏名囊括起来。
对于有参数宏,例如
define(宏二, `$1,我是宏一')
调用形式只能是全角圆括号形式,宏名和参数都在括号内,以全角的逗号予以间隔:
(宏二,你好)
若参数为多个,参数之间也是以全角逗号予以间隔。
还需要考虑宏的逃逸问题,亦即有些文字,虽然是宏的调用形式,但希望它们被当成普通文本。M4 可通过引号对宏进行逃逸。我也选择使用中文全角单引号作为中文宏的逃逸标识。例如
define(宏二, `$1,我是宏一')
用引号,对宏进行逃逸,‘(宏二,你好)’。
为中文宏构造间接调用形式以及宏的逃逸处理,必定是在遍历一行文本的过程中进行:
@ 有参数宏的处理过程 # i = 1; n = length($0) esc = 0; zhe = 0 while (i <= n) { # 判断是否进入逃逸状态 -> esc @ if (esc) { # 判断是否退出逃逸状态 -> esc @ } else { # 判断是否遇到中文宏 -> zhe @ if (zhe) { # 构造中文宏的间接调用形式 @ n = length($0) zhe = 0 } } i++ } => zhe.awk
上述代码之所以用 while
,而非 for
循环结构,是因为上述遍历文本的过程是变动性的,即文本的长度可能会发生变化。还需要注意,上述代码的循环变量
i
,在 gawk 中是每个字符的下标,而在先天残疾的 mawk
中,它只是每个字节的下标,亦即 gawk 支持 utf-8 编码,mawk
不支持,而且上述代码之所以是这种遍历形式,也要拜 mawk 所赐,我想让这个
AWK 脚本能与之兼容。
判断是否进入逃逸状态,只需检测当前字符(对于 mawk 而言,是当前字节)是否为中文或西文的左引号:
@ 判断是否进入逃逸状态 -> esc # x = substr($0, i) if (index(x, "`") == 1 || index(x, "‘") == 1) esc++ => 有参数宏的处理过程 => 无参数宏的处理过程
是否退出逃逸状态,需要检测与左引号配对的右引号:
@ 判断是否退出逃逸状态 -> esc # if (index(x, "'") == 1 || index(x, "’") == 1) esc-- => 有参数宏的处理过程 => 无参数宏的处理过程
判断是否遇到中文宏,只需检测是否遇到左括号:
@ 判断是否遇到中文宏 -> zhe # if (index(x, "(") == 1) zhe = 1 => 有参数宏的处理过程
遇到中文宏,首先将其提取出来,并保存其前后文本,然后为中文宏构造间接调用形式,完成后再与宏的前后文本拼接,并更新文本的长度。一念之动,终归于寂,整个过程如下:
@ 构造中文宏的间接调用形式 # # 将文本分为三个部分:before,macro 和 after @ # macro 变换 @ $0 = before macro after => 有参数宏的处理过程
遇到中文宏的指标是发现全角左括号,只需在其后续文本中寻找与之匹配的右括号,便可获得该中文宏的全部。以中文宏为中间点,可将文本分为三个部分:
@ 将文本分为三个部分:before,macro 和 after # depth = 1 for (j = 1; j < n; j++) { y = substr(x, j + 1) if (index(y, "(") == 1) { depth++ } if (index(y, ")") == 1) { depth-- } if (depth == 0) break } if (depth != 0) { print "错误:在 <" substr(x, 1, length(x) / 10) "...> 存在非封闭括号" } else { m = length(")") before = substr($0, 1, i - 1) macro = substr($0, i, j + m) after = substr($0, i + j + m) } => 构造中文宏的间接调用形式
对 macro
进行变换,是将其首尾中文全角括号转换为西文半角括号,将隔离参数的中文全角逗号换成西文半角逗号,然后在左侧增加
indir
前缀,还需要将指向当前字符的下标 i
跳过该前缀:
@ macro 变换 # sub(/^(/, "(", macro) sub(/)$/, ")", macro) gsub(/,/, ",", macro) prefix = "`'indir" macro = prefix macro i = i + length(prefix) => 构造中文宏的间接调用形式
无参数中文宏的调用可以不使用括号形式,需要根据从宏的定义空间获取的宏集为其构造间接调用:
@ 无参数宏的处理过程 # for (a in macros) { i = 1; n = length($0) esc = 0; zhe = 0 while (i <= n) { # 判断是否进入逃逸状态 -> esc @ if (esc) { # 判断是否退出逃逸状态 -> esc @ } else { # 判断是否遇到无参数宏 -> zhe @ if (zhe) { # 构造无参数宏的间接调用形式 @ n = length($0) zhe = 0 } } i++ } } => zhe.awk
判断是否遇到无参数宏,只需要比较当前下标 i
所指位置为起始的文本是否为宏名,且 i
位置之前的文本不存在宏的间接调用,即 indir(
:
@ 判断是否遇到无参数宏 -> zhe # if (index(x, a) == 1) { y = substr($0, 1, i - 1) if (!match(y, /indir\($/)) { zhe = 1 } } => 无参数宏的处理过程
构造无参数宏的间接调用形式,也是将文本分为三个部分,before,macro 和
after,对 macro 部分进行变换,之后再将三个部分拼接为
$0
:
@ 构造无参数宏的间接调用形式 # prefix = "`'indir(" before = substr($0, 1, i - 1) macro = prefix a ")" after = substr($0, i + length(a)) $0 = before macro after i = i + length(prefix) => 无参数宏的处理过程
@ zhe.awk # /divert\(-1\)/ { action = 0 print $0 next } /divert\(0\)dnl/ { action = 1 print $0 next } { if (!action) { print $0 # 收集已定义的宏(标准 AWK 代码) @ } else { # 有参数宏的处理过程 @ # 无参数宏的处理过程 @ gsub(/‘/, "`", $0) gsub(/’/, "'", $0) print $0 } }
注意,上述代码的最后部分是引号替换,将用于宏名逃逸的中文全角单引号替换为 M4 的英文半角引号。
还需要注意的是,上述 AWK 脚本,性能很低,因为它没有词法分析和语法分析,亦即没有将文本组织成一种高效的数据结构,从而可以进行局部修改。上述脚本的做法是野蛮的,粗暴的,文本局部的每一次变动,都会波及整体。
宏的定义与调用是分离的。可将宏的定义放在单独的 M4 文件,但是开头的
divert(-1)
和末尾的 divert(0)dnl
可以省略,在
bash 脚本中可以悄悄加上去。该 bash 脚本的第一个参数(即
$1
)便是宏定义文件,以下代码可为该文件增加首部和尾部,并将文件内容保存为临时文件:
@ 加载宏的定义文件 # ZHE_TMP=$(mktemp) awk 'BEGIN{print "divert(-1)"} {print $0} END{print "divert(0)dnl"}' "$1" > $ZHE_TMP => zhe
以下代码,将第 2 个参数(即 $2
)——宏调用文件合并到
$ZHE_TMP
文件:
@ 合并宏调用文件 # awk '{print $0}' "$2" >> $ZHE_TMP => zhe
用于构造中文宏间接调用的 AWK 脚本 zhe.awk,假设它位于上述 bash
脚本所在目录的 helper 目录,则 bash
脚本通过自身所在路径获取该脚本,用它处理 $ZHE_TMP
文件,并将结果转交于 m4,并将结果输出到
$3
,假如它存在:
@ 让 zhe.awk 发挥作用 # ZHE_SELF_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ $3 = "" ]]; then awk -f "$ZHE_SELF_PATH/helper/zhe.awk" $ZHE_TMP | m4 else awk -f "$ZHE_SELF_PATH/helper/zhe.awk" $ZHE_TMP | m4 > "$3" fi => zhe
完整的 bash 脚本如下:
@ zhe # #!/bin/bash # 加载宏的定义文件 @ # 合并宏调用文件 @ # 让 zhe.awk 发挥作用 @