回上级页面

zhe 的设计与实现

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

RSTARTmatch 函数从 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)
=> 无参数宏的处理过程

完整的 AWK 脚本

@ 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 脚本,性能很低,因为它没有词法分析和语法分析,亦即没有将文本组织成一种高效的数据结构,从而可以进行局部修改。上述脚本的做法是野蛮的,粗暴的,文本局部的每一次变动,都会波及整体。

zhe 命令

宏的定义与调用是分离的。可将宏的定义放在单独的 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 发挥作用 @

参考

  1. 让这世界再多一份 GNU m4 教程
  2. AWK 小传
  3. 写给高年级小学生的《Bash 指南》