4 个版本 (稳定版)

1.6.0 2024年7月30日
1.1.0 2023年10月31日
1.0.0 2023年10月6日
1.0.0-β2023年8月4日

#346 in 解析器实现

Download history 4313/week @ 2024-04-26 4370/week @ 2024-05-03 3778/week @ 2024-05-10 3970/week @ 2024-05-17 3661/week @ 2024-05-24 4587/week @ 2024-05-31 4568/week @ 2024-06-07 4992/week @ 2024-06-14 4847/week @ 2024-06-21 5842/week @ 2024-06-28 6241/week @ 2024-07-05 6460/week @ 2024-07-12 6170/week @ 2024-07-19 6720/week @ 2024-07-26 5618/week @ 2024-08-02 6710/week @ 2024-08-09

26,318 每月下载量
14 软件包中使用 (7 直接使用)

MIT 许可证

92KB
1.5K SLoC

jaq

Build status Crates.io Documentation Rust 1.64+

jaq(发音类似于 Jacques)是 JSON 数据处理工具 jq 的克隆版。jaq 旨在支持 jq 的语法和操作的很大一部分。

您可以在 jaq 演示平台 上在线尝试 jaq。演示平台的说明可以在 此处 找到。

jaq 关注三个目标

  • 正确性:jaq 旨在提供比 jq 更正确、更可预测的实现,同时在大多数情况下保持与 jq 的兼容性。

    jq 意外的行为示例
    • nan > nan 是错误的,而 nan < nan 是正确的。
    • [[]] | implode 导致 jq 崩溃,而这个问题在撰写本文时仍未解决,尽管 已知已有五年
    • 《jq 手册》声称 limit(n; exp) "从 exp 中提取最多 n 个输出"。这在 n > 1 的情况下成立,例如 jq -n '[limit(2; 1, 2, 3)]' 产生 [1, 2],但当 n == 0 时,jq -n '[limit(0; 1, 2, 3)]' 产生 [1] 而不是 []。而且可能更糟糕的是,当 n < 0 时,limit 会产生 所有 来自 exp 的输出,这并未在文档中说明。
  • 性能:我最初创建 jaq 是因为我被 jq 的长时间启动时间所困扰,在我的机器上约为 50ms。这在处理大量小文件时尤为明显。jaq 的启动速度比 jq 1.6 快约 30 倍,并且在许多其他基准测试中也优于 jq。

  • 简洁性:jaq 致力于实现简单而小巧的实现,以减少潜在的错误并便于贡献。

我从另一个 Rust 程序中获得了灵感,即 jql。然而,与 jql 不同,jaq 努力模仿 jq 的语法和语义。这应该使用户能够轻松地使用 jaq。

[^jacques]:我想创建一个像好服务员一样默默无闻、乐于助人的工具。当我想到一个典型的(法国)服务员的名字时,我的脑海中浮现出“Jacques”。后来,我了解到古老的法语单词 jacquet 的意思是“松鼠”,这为这个名称提供了一个很好的 ex post 灵感。

安装

二进制文件

您可以在 发布页面 下载 Linux、Mac 和 Windows 的二进制文件。

您还可以在 macOS 或 Linux 上使用 homebrew 安装 jaq

$ brew install jaq
$ brew install --HEAD jaq # latest development version

或在 Windows 上使用 scoop

$ scoop install main/jaq

从源代码

要编译 jaq,您需要一个 Rust 工具链。有关说明,请参阅 https://rustup.rs/。(请注意,随 Linux 发行版提供的 Rust 编译器可能过于过时,无法编译 jaq。)

以下任何命令都可以安装 jaq

$ cargo install --locked jaq
$ cargo install --locked --git https://github.com/01mf02/jaq # latest development version

在我的系统上,这两个命令都将可执行文件放置在 ~/.cargo/bin/jaq

如果您已经克隆了这个仓库,您也可以通过在克隆的仓库中执行其中一个命令来构建jaq。

$ cargo build --release # places binary into target/release/jaq
$ cargo install --locked --path jaq # installs binary

jaq应该可以在Rust支持的所有系统上运行。如果不行,请提交一个问题。

示例

以下示例应能给人一个关于jaq目前能做什么的印象。您应该用jq替换jaq得到相同的输出。如果不是这样,您的反馈将受到欢迎。 :) 语法在jq手册中有记录。

访问字段

$ echo '{"a": 1, "b": 2}' | jaq '.a'
1

添加值

$ echo '{"a": 1, "b": 2}' | jaq 'add'
3

以两种方式从对象构建一个数组,并显示它们是相等的

$ echo '{"a": 1, "b": 2}' | jaq '[.a, .b] == [.[]]'
true

对所有数组元素应用过滤器,并过滤结果

$ echo '[0, 1, 2, 3]' | jaq 'map(.*2) | [.[] | select(. < 5)]'
[0, 2, 4]

将输入值读入数组,并获取其元素的平均值

$ echo '1 2 3 4' | jaq -s 'add / length'
2.5

重复应用过滤器到自身,并输出中间结果

$ echo '0' | jaq '[recurse(.+1; . < 3)]'
[0, 1, 2]

懒折叠输入并输出中间结果

$ seq 1000 | jaq -n 'foreach inputs as $x (0; . + $x)'
1 3 6 10 15 [...]

性能

以下评估包括几个基准测试,可以比较jaq、jq和gojq的性能。空(empty)基准测试运行n次带有null输入的过滤器,以测量启动时间。bf-fib基准测试运行一个用jq编写的Brainfuck解释器,解释产生n个斐波那契数的Brainfuck脚本。其他基准测试使用n作为输入评估各种过滤器;有关详细信息,请参阅bench.sh

我在Linux系统上使用AMD Ryzen 5 5500U,使用bench.sh target/release/jaq jq-1.7.1 gojq-0.12.15 jq-1.6 | tee bench.json生成了基准测试数据。[^binaries]。然后,我使用一个“单行命令”(稍微拉伸了这个术语和行)处理了结果

jq -rs '.[] | "|`\(.name)`|\(.n)|" + ([.time[] | min | (.*1000|round)? // "N/A"] | min as $total_min | map(if . == $total_min then "**\(.)**" else "\(.)" end) | join("|"))' bench.json

(当然,您也可以在这里使用jaq代替jq。)最后,我将表头与输出连接,并通过pandoc -t gfm传递。

[^binaries]:jq-1.7.1和gojq-0.12.15的二进制文件是从它们的GitHub发布页面检索的,jq-1.6的二进制文件是从标准的Ubuntu仓库安装的。

表:以毫秒为单位的评估结果(超过10秒为“N/A”)。

基准测试 n jaq-1.4 jq-1.7.1 gojq-0.12.15 jq-1.6
empty 512 610 660 740 8310
bf-fib 13 470 1220 570 1440
reverse 1048576 50 680 270 650
sort 1048576 140 550 580 680
group-by 1048576 400 1890 1550 2860
min-max 1048576 210 320 250 350
add 1048576 520 640 1310 730
kv 131072 170 140 220 190
kv-update 131072 190 540 440 N/A
kv-entries 131072 630 1150 830 1120
ex-implode 1048576 510 1100 610 1090
reduce 1048576 820 890 N/A 860
try-catch 1048576 180 320 370 670
tree-flatten 17 730 360 10 480
tree-update 17 560 970 1330 1190
tree-paths 17 470 250 880 460
to-fromjson 65536 30 370 120 390
ack 7 530 700 1230 620
range-prop 128 280 310 210 590

结果显示jaq-1.4在15个基准测试中是最快的,而jq-1.7.1在2个基准测试中是最快的,gojq-0.12.15在2个基准测试中是最快的。gojq在tree-flatten上要快得多,因为它将过滤器flatten原生化实现,而不是通过定义实现。

功能

以下是一个概述,总结了

  • 已经实现的功能,以及
  • 尚未实现的功能。

为扩展jaq做出的贡献将受到高度欢迎。

基础知识

  • 身份(.
  • 递归(..
  • 基本数据类型(null、布尔值、数字、字符串、数组、对象)
  • if-then-else (代码if .a < .b then .a else .b end)
  • 折叠 (reduce .[] as $x (0; . + $x), foreach .[] as $x (0; . + $x; . + .))
  • 错误处理 (try ... catch ...) (参见与 jq 的差异)
  • 字符串插值 ("The successor of \(.) is \(.+1).")
  • 格式化字符串 (@json, @text, @csv, @tsv, @html, @sh, @base64, @base64d)

路径

  • 数组/对象的索引 (.[0], .a, .["a"])
  • 遍历数组/对象 (.[])
  • 可选索引/遍历 (.a?, .[]?)
  • 数组切片 (.[3:7], .[0:-1])
  • 字符串切片

运算符

  • 组合 (|)
  • 绑定 (. as $x | $x)
  • 连接 (,)
  • 普通赋值 (=)
  • 更新赋值 (|=, +=, -=)
  • 交替 (//)
  • 逻辑 (or, and)
  • 相等和比较 (.a == .b, .a < .b)
  • 算术运算(+-*/%
  • 取反(-
  • 错误抑制(?

定义

  • 基本定义(def map(f): [.[] | f];
  • 递归定义(def r: r; r

核心过滤器

  • 空(empty
  • 错误(error
  • 输入(inputs
  • 长度(lengthutf8bytelength
  • 舍入(floorroundceil
  • 字符串与JSON互转(fromjsontojson
  • 字符串与整数的转换(explodeimplode
  • 字符串规范化(ascii_downcaseascii_upcase
  • 字符串前缀/后缀(startswithendswithltrimstrrtrimstr
  • 字符串分割(split("foo")
  • 数组过滤器(reversesortsort_by(-.)group_bymin_bymax_by
  • 流消费者(firstlastrangefold
  • 流生成器(rangerecurse
  • 时间(nowfromdateiso8601todateiso8601
  • 更多数值过滤器(sqrtsinlogpow、...)(数值过滤器列表
  • 更多时间过滤器(strptimestrftimestrflocaltimemktimegmtimelocaltime

标准过滤器

这些过滤器是通过更基本的过滤器定义的。它们的定义在std.jq

  • 未定义(null
  • 布尔值(truefalsenot
  • 特殊数值(naninfiniteisnanisinfiniteisfiniteisnormal
  • 类型(type
  • 过滤(select(. >= 0)
  • 选择(valuesnullsbooleansnumbersstringsarraysobjectsiterablesscalars
  • 转换(tostringtonumber
  • 可迭代过滤器(map(.+1)map_values(.+1)addjoin("a")
  • 数组过滤器(transposefirstlastnth(10)flattenminmax
  • 对象-数组转换(to_entriesfrom_entrieswith_entries
  • 全/存在性(allany
  • 递归(walk
  • I/O(input
  • 正则表达式(testscanmatchcapturesplitssubgsub
  • 时间(fromdatetodate

数字过滤器

jaq从libm导入了许多过滤器,并遵循它们的类型签名。

jaq中定义的数字过滤器完整列表

零参数过滤器

  • acos
  • acosh
  • asin
  • asinh
  • atan
  • atanh
  • cbrt
  • cos
  • cosh
  • erf
  • erfc
  • exp
  • exp10
  • exp2
  • expm1
  • fabs
  • frexp,它返回(浮点数,整数)对。
  • ilogb,它返回整数。
  • j0
  • j1
  • lgamma
  • log
  • log10
  • log1p
  • log2
  • logb
  • modf,它返回(浮点数,浮点数)对。
  • nearbyint
  • pow10
  • rint
  • significand
  • sin
  • sinh
  • sqrt
  • tan
  • tanh
  • tgamma
  • trunc
  • y0
  • y1

忽略.的两个参数过滤器

  • atan2
  • copysign
  • drem
  • fdim
  • fmax
  • fmin
  • fmod
  • hypot
  • jn,它将整数作为第一个参数。
  • ldexp,它将整数作为第二个参数。
  • nextafter
  • nexttoward
  • pow
  • remainder
  • scalb
  • scalbln,它将整数作为第二个参数。
  • yn,它将整数作为第一个参数。

忽略.的三个参数过滤器

  • fma

高级功能

jaq目前不打算支持jq的几个功能,例如

  • 模块
  • SQL样式运算符
  • 流式传输

jq和jaq之间的差异

数字

jq使用64位浮点数(floats)来表示任何数字。相比之下,jaq将数字0或-42解释为机器大小的整数,将数字0.0或3e8解释为64位浮点数。在jaq中,许多操作,如数组索引,都会检查传入的数字是否为整数。这样做的原因是为了避免可能无声地导致错误结果的舍入误差。例如

$ jq  -n '[0, 1, 2] | .[1.0000000000000001]'
1
$ jaq -n '[0, 1, 2] | .[1.0000000000000001]'
Error: cannot use 1.0 as integer
$ jaq -n '[0, 1, 2] | .[1]'
1

jaq的规则如下

  • 两个整数的和、差、积和余数是整数。
  • 两个数字之间进行的任何其他操作都会得到浮点数。

示例

$ jaq -n '1 + 2'
3
$ jaq -n '10 / 2'
5.0
$ jaq -n '1.0 + 2'
3.0

您可以通过添加0.0、乘以1.0或除以1来将整数转换为浮点数。您可以通过roundfloorceil将浮点数转换为整数。

$ jaq -n '1.2 | [floor, round, ceil]'
[1, 1, 2]

NaN和无穷大

在jq中,除以0有一些令人惊讶的性质;例如,0 / 0会产生nan,而0 as $n | $n / 0会产生一个错误。在jaq中,n / 0n == 0时会产生nan,在n > 0时会产生infinite,在n < 0时会产生-infinite。jaq的行为更接近IEEE浮点算术标准(IEEE 754)。

jaq在浮点数上实现了一个全序,以便对值进行排序。因此,它不幸地必须强制执行nan == nan。 (jq通过强制执行nan < nan来解决这个问题,这违反了全序的基本定律。)

与jq一样,jaq在JSON中将naninfinite打印为null,因为JSON不支持将这些值编码为数字。

保留分数数字

jaq完美保留来自JSON数据的分数数字(只要它们不在某些算术操作中使用),而jq 1.6可能会静默转换为64位浮点数

$ echo '1e500' | jq '.'
1.7976931348623157e+308
$ echo '1e500' | jaq '.'
1e500

因此,与jq 1.6不同,jaq满足jq手册中的以下段落

关于身份过滤器的一个重要观点是它保证保留值的字面十进制表示。当处理无法无损转换为IEEE754双精度表示的数字时,这一点尤为重要。

请注意,jq的新版本,例如1.7,似乎也保留字面十进制表示。

赋值

与jq一样,jaq允许形式为p |= f的赋值。然而,jaq对这些赋值的解释不同。幸运的是,在大多数情况下,结果是相同的。

在jq中,赋值p |= f首先构建所有匹配p的值的路径。 然后,它将这些值应用于过滤器f

在jaq中,赋值p |= f立即将f应用于任何匹配p的值。与jq不同,赋值并不显式构建路径。

jaq 的实现可能带来更高的性能,因为它不构建路径。此外,这也避免了 jq 中的几个“设计上的”错误。例如,给定过滤器 [0, 1, 2, 3] | .[] |= empty,jq 输出 [1, 3],而 jaq 输出 []。这里发生了什么?

jq 首先构建与 .[] 对应的路径,它们是 .0, .1, .2, .3。然后,它删除这些路径上的每个元素。然而,这些删除中的每一个都会 改变 剩余路径引用的值。也就是说,在删除 .0(值 0)之后,.1 不再指向值 1,而是值 2!这也是为什么值 1(以及随之而来的值 3)没有被删除的原因。

在 jq 中还有更多奇怪的行为;例如,0 | 0 |= .+1 在 jq 中输出 1,尽管 0 不是一个有效的路径表达式。然而,1 | 0 |= .+1 会产生错误。在 jaq 中,任何这样的赋值都会产生错误。

jaq尝试使用右侧表达式的多个输出,而jq只使用第一个。例如,0 | (., .) |= (., .+1)在jaq中产生0 1 1 2,而在jq中只产生0。然而,{a: 1} | .a |= (2, 3)在jaq和jq中都会产生{"a": 2},因为一个对象只能与任何给定的键关联一个值,所以我们不能在这里以有意义的方式使用多个输出。

由于jaq不构建路径,它不允许在赋值的左侧使用某些过滤器,例如firstlastlimit:例如,[1, 2, 3] | first(.[]) |= .-1在jq中产生[0, 2, 3],但在jaq中是无效的。同样,[1, 2, 3] | limit(2; .[]) |= .-1在jq中产生[0, 1, 3],但在jaq中是无效的。(无关紧要的是,jq也不允许使用last。)

定义

与jq一样,jaq允许定义过滤器,例如

def map(f): [.[] | f];

参数也可以以的方式传递,例如

def cartesian($f; $g): [$f, $g];

过滤器定义可以是嵌套和递归的,即引用自身。也就是说,可以在jaq中定义如recurse这样的过滤器

def recurse(f): def r: ., (f | r); r;

自jaq 1.2以来,jaq像jq一样优化了尾调用。自jaq 1.1以来,递归过滤器也可以有非变量参数,如jq中一样。例如

def f(a): a, f(1+a);

使用非变量参数的递归过滤器可能产生令人惊讶的效果;例如,调用 f(0) 将构建形式为 f(1+(..(1+0)...)) 的调用,这会导致指数级的执行时间。

非变量参数的递归过滤器通常可以通过以下方式之一来实现

  • 嵌套过滤器:例如,而不是 def walk(f): (.[]? |= walk(f)) | f;,你可以使用 def walk(f): def rec: (.[]? |= rec) | f; rec;
  • 变量参数的过滤器:例如,而不是 def f(a): a, f(1+a);,你同样可以编写 def f($a): $a, f(1+$a);
  • 使用 recurse 的过滤器:例如,你可能编写 def f(a): a | recurse(1+.);。如果你期望你的过滤器递归深度较大,建议使用 recurse 来实现,因为 jaq 对 recurse 有优化的实现。

所有这些选项都由 jaq 支持。

参数

与 jq 类似,jaq 允许通过命令行定义参数,特别是通过选项 --arg--rawfile--slurpfile。这会将变量绑定到值上,并且对于以这种方式绑定到 v 的每个变量 $x$ARGS.named 包含一个键为 x、值为 v 的条目。例如

$ jaq -n --arg x 1 --arg y 2 '$x, $y, $ARGS.named'
"1"
"2"
{
  "x": "1",
  "y": "2"
}

折叠

jq 和 jaq 提供了过滤器 reduce xs as $x (init; f)foreach xs as $x (init; f)

在 jaq 中,这些过滤器的输出定义非常简单:假设 xs 计算结果为 x0x1,...,xnreduce xs as $x (init; f) 计算结果为

init
| x0 as $x | f
| ...
| xn as $x | f

以及 foreach xs as $x (init; f) 计算结果为

init
| x0 as $x | f | (.,
| ...
| xn as $x | f | (.,
empty)...)

此外,jaq 还提供了过滤器 for xs as $x (init; f),其计算结果为

init
| ., (x0 as $x | f
| ...
| ., (xn as $x | f
)...)

foreachfor 的区别在于,for 返回 init 的输出,而 foreach 则省略它。例如,foreach (1, 2, 3) as $x (0; .+$x) 返回 1, 3, 6,而 for (1, 2, 3) as $x (0; .+$x) 返回 0, 1, 3, 6

jaq 中 reduce/foreach 的解释相对于 jq 具有以下优势

  • 它非常自然地处理产生多个输出的过滤器。相比之下,jq 由于只对最后一个进行递归,因此会区分 f 的输出,尽管它输出所有这些输出。

    示例

    foreach (5, 10) as $x (1; .+$x, -.) 在 jq 中得到的结果是 6, -1, 9, 1,而在 jaq 中得到的结果是 6, 16, -6, -1, 9, 1。我们可以看到,jq 和 jaq 都产生了第一个迭代(其中 $x 是 5)的结果,即 6-1,如下所示 1 | 5 as $x | (.+$x, -.)。然而,jq 只在第一次迭代返回的最后一个值(即 $x 是 10)上执行第二次迭代,即 -1,得到的结果是 91,如下所示 -1 | 10 as $x | (.+$x, -.)。jaq 也会产生这些值,但它还会在第一次迭代返回的所有其他值上执行第二次迭代,即 6,得到的结果是 16-6,如下所示 6 | 10 as $x | (.+$x, -.)

  • 这使得 reduceforeach 成为相同的代码的特殊情况,从而减少了错误的可能性。

foreach ... 相比,过滤器 for ...(其中 ... 指的是 xs as $x (init; f))与 reduce 的关系更紧密。特别是,reduce ... 生成的值是 for ... 生成的值的子集。如果用 foreach 替换 for,则不成立。

示例

例如,如果我们设置 ...empty as $x (0; .+$x),那么 foreach ... 不产生任何值,而 for ...reduce ... 产生 0

此外,jq 提供了过滤器 foreach xs as $x (init; f; proj)foreach/3)并将 foreach xs as $x (init; f; .)foreach/2)解释为 foreach xs as $x (init; f; .),而 jaq 不提供 foreach/3,因为它需要在解析器和解释器中都提供与 foreach/2reduce 完全不同的逻辑。

错误处理

在 jq 中,表达式 try f catch g 在出现错误时立即退出 f 流,并在之后将控制权交给 g。这在其手册中被提及为退出循环的可能机制(这里)。然而,jaq 不会中断 f 流,而是将每个错误值发送到 g 过滤器;结果是,错误发生的地方插入从 f 发出的值和从 g 发出的值。

考虑以下示例:这个表达式在 jq 中是 true,因为第一个 error(2) 中断流

[try (1, error(2), 3, error(4)) catch .] == [1, 2]

然而,在 jaq 中,这成立

[try (1, error(2), 3, error(4)) catch .] == [1, 2, 3, 4]

杂项

  • 吞入:当使用(通过 -s / --slurp 选项)吞入文件时,jq 将所有文件的输入合并为一个单一的数组,而 jaq 为每个文件生成一个数组。这是由 -i / --in-place 选项驱动的,该选项无法与 jq 实现的行为配合使用。jaq 可以近似模拟 jq 的行为;例如,要实现 jq -s . a b 的输出,可以使用 jaq -s . <(cat a b)
  • 笛卡尔积:在 jq 中,[(1,2) * (3,4)] 返回 [3, 6, 4, 8],而 [{a: (1,2), b: (3,4)} | .a * .b] 返回 [3, 4, 6, 8]。jaq 在这两种情况下都返回 [3, 4, 6, 8]
  • 索引 null:在 jq 中,当给定 null 输入时,.["a"].[0] 返回 null,但 .[] 返回一个错误。jaq 在所有情况下都会返回错误,以防止意外索引 null 值。要在 jq 和 jaq 中获得相同的行为,可以使用 .["a"]? // null.[0]? // null 代替。
  • 列表更新:在jq中,[0, 1] | .[3] = 3的结果是[0, 1, null, 3];也就是说,如果更新超出了列表的大小,jq会用null填充列表。相比之下,在jaq中,如果遇到越界错误,会失败。
  • 读取输入:当没有更多的输入值时,在jq中,input会返回一个错误,而在jaq中,它不会返回任何输出值。
  • 连接:当给定一个数组[x0, x1, ..., xn]时,在jq中,join(x)将输入数组的所有元素转换为字符串,并用x将它们连接起来,而在jaq中,join(x)只是计算x0 + x + x1 + x + ... + xn。当输入数组的所有元素和x都是字符串时,jq和jaq会产生相同的输出。

贡献

欢迎为jaq做出贡献。请确保在您的更改之后,cargo test运行成功。

致谢

本项目NGI0 Entrust基金资助,该基金由NLnet设立,并由欧洲委员会的Next Generation Internet计划提供资金支持,在DG Communications Networks, Content and Technology的指导下,根据协议号No 101069594。

jaq还受益于

依赖关系

~0.3–1MB
~21K SLoC