- CLI
- 数据分析
【免费下载链接】miller
Miller is like awk, sed, cut, join, and sort for name-indexed data such as CSV, TSV, and tabular JSON
导读
本文围绕 Miller 开源项目中关于DSL 显式 r-string(r"...")的一份实现计划文档展开,深入剖析"正则位置字符串隐式 raw 化"这一历史机制为何无法跟随变量传递,以及如何通过新增一种独立字面量类型,让r"\*"既能直接用作正则参数、又能赋值给变量后再用于gsub/sub/=~等正则场景。读完本文,你将掌握 Miller DSL 中正则字面量的语法设计与编译管线(词法 → AST → CST → 运行期求值)、r"..."与r"..."i的语义与边界,以及如何在 pkg/parsing/mlr.bnf、pkg/dsl/cst/leaves.go 中落地这一能力并配套回归测试。
背景:Issue #297 遗留的最后一步
Miller 6 已经解决了 Issue #297("匹配正则元字符")的大部分问题,唯一的遗留诉求是显式 r-string:一种类似 Python raw string 的 DSL 字面量r"...",它不做任何反斜杠转义处理,并且关键的是——它必须能够赋值给变量、稍后在正则位置使用:
rstar = r"\*"; $y = gsub($x, rstar, "star");这正是 issue 讨论串中 @torbiak 提出、@johnkerl 认可"今天无法工作"的场景。@johnkerl 倾向的设计(issue 中的 Option 1)是增量式的:保留今天"正则位置的普通字符串字面量"的隐式 r-string 行为,同时在其之上新增独立可用的显式r"..."字面量类型。
为什么变量无法携带"raw 性"
隐式 raw 是一个"解析期 AST 技巧",而非运行期值属性
普通字符串字面量之所以在"正则位置"(sub/gsub/regextract/regextract_or_else的第 2 个参数,以及=~/!=~的右侧)获得 raw(不转义)处理,靠的是一套parse-time AST 预处理,而不是运行期值的某种属性:
regexProtectPrePassAux(pkg/dsl/cst/root.go#L234-L262)在 CST 构建前遍历 AST,把sub/gsub/regextract/regextract_or_else调用点以及=~/!=~操作符的第 2 个子节点,从NodeTypeStringLiteral改标为NodeTypeRegex——但仅当该子节点本身就是一个字符串字面量 AST 节点时才会这样做。BuildLeafNode(pkg/dsl/cst/leaves.go#L16-L135)随后分派:NodeTypeStringLiteral走BuildStringLiteralNode(调用lib.UnbackslashStringLiteral,见 pkg/lib/unbackslash.go#L38-L97,把\t→TAB、\\→\等);而NodeTypeRegex走BuildRegexLiteralNode(pkg/dsl/cst/leaves.go#L270-L274),不做任何 unescape,直接把原始 lexeme 包进Mlrval。
问题在于:位于同一参数槽位的局部变量或字段不会被预处理触碰(它不是字符串字面量 AST 节点),所以它的运行期字符串值早已是普通 unescape 之后的结果——正则编译阶段已经没有信息可供"撤销"了。例如:
star_re = "\*";由于\*不是被识别的通用转义序列,UnbackslashStringLiteral的"未识别转义直接丢弃反斜杠"行为(见 pkg/lib/unbackslash.go#L89-L93)会把\*变成单独的*字节——它本身就是一个无效/误导的正则片段。这也解释了为什么隐式 raw 只能作用于"字面上的字符串字面量",而无法跟随值穿过变量。
正则编译的单一咽喉:CompileMillerRegex
pkg/lib/regex.go#L97-L125 的CompileMillerRegex是sub/gsub/regextract/regextract_or_else/strmatch/strmatchx/=~/!=~全部走到的唯一编译入口(由 pkg/bifs/regex.go 中的BIF_sub、BIF_gsub等调用)。其行为:
- 若字符串以
"开头并以"结尾,剥掉两端引号后交给 Goregexp.Compile; - 若以
"..."i结尾,则转换为 Go 的(?i)前缀; - 若字符串完全没有包围引号,则原样直接传给
regexp.Compile(第 124 行的 "bare" 回退分支)。
这个 bare 回退分支正是整个设计的承重事实:一个完全不带引号的 raw 字符串值(反斜杠保持原样、无外围引号),今天就能正确编译,regex.go和bifs/regex.go一行都不用改。因此只要显式 r-string 字面量在解析期构建出一个干净、不带引号的Mlrval,无论直接用作正则参数、还是通过变量传递,都能正确工作,编译路径完全无需改动。
可供参照的既有先例
设计文档在语法层面找到了三个现成模板:
bytes_literal(b"..."):语法中已存在单字母前缀带引号字面量(pkg/parsing/mlr.bnf#L98-L99,注释明确说明"must precede non_sigil_name since 'b' is an idchar"——r前缀同样需要这个顺序约束,因为r也是合法标识符字符)。其 AST/CST 节点类型与构建器BuildBytesLiteralNode(pkg/dsl/cst/leaves.go#L342-L357)会剥掉前缀b和包围引号,然后——与 r-string 期望相反——仍然调用UnbackslashStringLiteral(字节语义的 unescape,如\xff变成单字节 0xff)。RegexCaseInsensitive(pkg/parsing/mlr.bnf#L703-L705、pkg/dsl/cst/leaves.go#L58-L67):可选尾部i后缀的先例。语法规则为string_literal non_sigil_name;CST 分派若发现字面量文本没有以i结尾就追加上"i",然后委托给正则字面量构建器,后者保留 Mlrval 上的包围引号,这样CompileMillerRegex的"..."i分支稍后能找到它们。
前车之鉴:旧提交不可复用
Git 提交1230553eb("regex r-string feature",2021 年 8 月)曾添加过regex_r_string ::= 'r' '"' {...} '"'并直接映射到NodeTypeRegex——但那是 Miller 6 重构之前的go/src/parsing/...树,该树在pkg/...重写中被删除、从未被带过来。其单元测试和文档当时就留作 TODO 且未勾选。虽然代码不可复用,但它确认了'r' '"' {...} '"'这一语法形状是站得住脚的。
文档已描述的隐式 r 行为
参考文档-正则表达式(约第 219-227 行)已经写明了隐式 r-string 现状:
"……(如果你熟悉 Python 的 r-string)所有处于正则位置的字符串都是隐式 r-string。一般来说这是正确且不易引起混淆的。但注意
"\t"."\t"作为sub的第二个参数并不等同于"\t\t"。"
这里是补充显式r"..."文档最自然的位置。
钉死的回归测试:不要动
test/cases/dsl-regex-matching/0016(输入为 test/input/regex-metacharacters.dkvp,内容input=abc[.o*o.]def)精确测试了 issue 中的隐式 r 场景(gsub($input, "\[", "LEFT")等),由提交b20a5ccd3/55209bfc5("Test case for #297")加入。显式 r-string 的工作绝不能改变它的行为,它将继续作为隐式行为的回归护栏。
设计方案:从文法到 CST 构建
1. 文法(pkg/parsing/mlr.bnf)
在bytes_literal旁边、non_sigil_name之前新增r_string_literal词法规则(同样的顺序要求,因为r是合法标识符字符):
# Raw/r-strings r"..." (must precede non_sigil_name since 'r' is an idchar) r_string_literal ::= 'r' '"' { _string_char | _escape } '"' ;紧挨RegexCaseInsensitive/StringLiteral(pkg/parsing/mlr.bnf#L703-L709)新增RStringCaseInsensitive与RStringLiteral两个产生式,大小写不敏感变体声明在前(与现有配对同约定):
# r"a.*b" (raw, case-sensitive) or r"a.*b"i (raw, case-insensitive). Must precede RStringLiteral. RStringCaseInsensitive ::= r_string_literal non_sigil_name -> { "parent": 0, "children": [0], "type": "RStringCaseInsensitive" } ; RStringLiteral ::= r_string_literal -> { "parent": 0, "children": [], "type": "r_string_literal" } ;并在MlrvalOrFunction(pkg/parsing/mlr.bnf#L639-L667)中、紧挨现有RegexCaseInsensitive/StringLiteral/BytesLiteral行添加两个备选项(CI 变体在前):
| RStringCaseInsensitive | RStringLiteral2. AST 节点类型
pkg/dsl/ast_types.go(靠近NodeTypeBytesLiteral/NodeTypeRegex,约第 11-13 行)新增两个 TNodeType 常量:
NodeTypeRStringLiteral TNodeType = "raw string literal" NodeTypeRStringCaseInsensitive TNodeType = "case-insensitive raw string literal"pkg/dsl/cst/ast_types.go(普通形式靠近第 85 行、与文法"type"字符串一致;CI 形式靠近第 115 行、与NodeTypeRegexCaseInsensitive并列)新增两个 CST 常量:
NodeTypeRStringLiteral = "r_string_literal" NodeTypeRStringCaseInsensitive = "RStringCaseInsensitive"3. CST 构建器(pkg/dsl/cst/leaves.go)
新增节点类型与构建器,仿照BuildBytesLiteralNode的前缀/引号剥离,但不调用UnbackslashStringLiteral:
// RStringLiteralNode is for explicit raw string literals r"..." (issue #297). // Unlike StringLiteralNode, no backslash processing is applied: r"\*" evaluates // to the two characters backslash-asterisk. This makes the value usable directly // as a regex-engine pattern fragment regardless of where it later travels -- as a // literal regex argument, or via a variable -- unlike the implicit-r-string trick // used for plain string literals in regex position (see regexProtectPrePassAux), // which only works at parse time and can't follow a value through a variable. type RStringLiteralNode struct { literal *mlrval.Mlrval } func (root *RootNode) BuildRStringLiteralNode(literal string) IEvaluable { // The PGPG lexer produces r_string_literal token with leading 'r' in the lexeme. if len(literal) >= 1 && literal[0] == 'r' { literal = literal[1:] } // Case-insensitive form r"..."i: leave the quotes and trailing 'i' intact, // matching BuildRegexLiteralNode's representation, so CompileMillerRegex's // existing "\"...\"i" handling applies unchanged. Case-insensitivity is only // meaningful once compiled as a regex, so this form is not intended to double // as a plain string value the way the non-CI form is. if len(literal) >= 3 && literal[0] == '"' && strings.HasSuffix(literal, "\"i") { return &RStringLiteralNode{literal: mlrval.FromString(literal)} } // Plain form r"...": strip the surrounding quotes for a clean raw-string value, // usable both as a regex argument (via CompileMillerRegex's bare-string // fallback) and as an ordinary string value. if len(literal) >= 2 && literal[0] == '"' && literal[len(literal)-1] == '"' { literal = literal[1 : len(literal)-1] } return &RStringLiteralNode{literal: mlrval.FromString(literal)} } func (node *RStringLiteralNode) Evaluate( state *runtime.State, ) *mlrval.Mlrval { return node.literal }在BuildLeafNode的现有分派(pkg/dsl/cst/leaves.go#L42-L67)中新增两个 case,CI 分支镜像NodeTypeRegexCaseInsensitive的"缺少i则追加"模式:
case asts.NodeType(NodeTypeRStringLiteral): return root.BuildRStringLiteralNode(sval), nil case asts.NodeType(NodeTypeRStringCaseInsensitive): if sval == "" && astNode.Children != nil && len(astNode.Children) > 0 { sval = tokenLit(astNode.Children[0]) } if sval != "" && !strings.HasSuffix(sval, "i") { sval = sval + "i" } return root.BuildRStringLiteralNode(sval), nilregexProtectPrePassAux无需任何改动——它只改标已经是NodeTypeStringLiteral的节点;语法原生的r"..."节点到达时已自带NodeTypeRStringLiteral/NodeTypeRStringCaseInsensitive标签,会原样穿过预处理。
4. 其他地方无需改动
pkg/lib/regex.go 与 pkg/bifs/regex.go 都不需要修改——正如上文"正则编译的单一咽喉"所分析的,bare 回退分支天然支持无引号 raw 值。
5. 重新生成解析器
pkg/parsing/mlr.bnf 经由 Miller 自己的 PGPG 生成器(github.com/johnkerl/pgpg,非 goyacc/lex),通过tools/build-dsl(需几分钟)重新生成 pkg/parsing/lexer/lexer.go 和 pkg/parsing/parser/parser.go。按 pkg/parsing/README.md 与 README-dev.md 的说明,这些生成文件是提交到版本库的——因此编辑mlr.bnf后要运行tools/build-dsl,并把重新生成的产物与文法改动一起提交。
6. 测试
在test/cases/dsl-regex-matching/下新增用例(沿用既有0016的结构),覆盖:
- (a) 直接以
r"\["风格字面量作为正则参数使用; - (b) 头条场景"变量携带 raw":
rstar = r"\*"; $y = gsub($x, rstar, "star"); - (c)
r"..."作为普通非正则值使用,确认按 raw 打印/存储——例如r"a\tb"是 4 个字符(a、\、t、b),而不是一个 tab; - (d)
r"..."i大小写不敏感匹配,例如r"abc"i =~ "ABC"。
test/cases/dsl-regex-matching/0016保持不动,作为既有隐式 r 行为的回归护栏。
7. 文档
扩展 docs/src/reference-main-regular-expressions.md.in 中现有的隐式 r-string 段落(约第 219-227 行),补充:
- 新的显式
r"..."语法; - issue 中的"变量携带 raw"示例(
rstar = r"\*"; gsub($x, rstar, "star")); - 关于 CI 形式保留引号的不对称性的说明(有文档记载的权衡,不是 bug):
r"..."i面向正则位置;当作普通值使用时它会保留"..."i外壳,与今天隐式"..."i正则字面量的行为一致。
随后通过make -C docs/src forcebuild重建文档。
验证清单
make build编译通过;- 手动检查:
echo 'a=[' | mlr put '$a = gsub($a, r"\[", "left_square")' echo 'a=*' | mlr put 'rstar = r"\*"; $a = gsub($a, rstar, "STAR")' mlr -n put 'end { print r"\t" }' # 打印两个字符,而不是一个 tabmake check(单元 + 回归测试),确认test/cases/dsl-regex-matching/0016与新增的 r-string 用例同时通过;- 推送前执行
make lint。
小结:一处"只读"的加法设计
这个设计的精髓在于零侵入:隐式 r-string 继续作为正则位置的默认行为(受0016回归测试保护),显式r"..."作为新的独立字面量类型叠加其上。由于CompileMillerRegex的 bare 回退分支早已支持无引号 raw 值,整个实现只需要"文法规则 + 两个 AST/CST 节点类型 + 一个不调用UnbackslashStringLiteral的构建器",而正则编译路径、BIF 层和既有预处理全部原样保留——这正是 Issue #297 最后一步最稳妥的落地方式。
- CLI
- 数据分析
【免费下载链接】miller
Miller is like awk, sed, cut, join, and sort for name-indexed data such as CSV, TSV, and tabular JSON
相关推荐
PRQL 字符串完全指南:String 字面量、转义序列与 F/R/S 三种字符串格式详解
PRQL 字符串完全指南:String 字面量、转义序列与 F/R/S 三种字符串格式详解 PRQL 作为一门管道的 SQL 替代语言,其字符串系统涵盖普通字符
后端Miller 正则表达式完全指南:`=~`、`strmatch`/`strmatchx` 与 DSL 正则捕获机制
Miller 正则表达式完全指南: =~ 、 strmatch / strmatchx 与 DSL 正则捕获机制 Miller(一款面向 CSV、TSV、DKV
CLI数据分析LocalSend元数据保留:文件属性与创建时间保持原样
LocalSend元数据保留:文件属性与创建时间保持原样 痛点:文件传输中的元数据丢失问题 在日常工作中,你是否遇到过这样的困扰:通过传统方式传输文件后,文件的
即时通讯网络/通信
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考