Optique: CLI 解析器组合器

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
最近我制作了一个名为 Optique 的实验性 CLI 解析器库。截至撰写本文的 2025 年 8 月 21 日,它还没有发布 0.1.0 版本,但我认为这是一个相当有趣的想法,所以通过这篇文章来介绍它。
Optique 主要受到两个不同库的影响。一个是 Haskell 的 optparse-applicative 库,从中得到的教训是 CLI 解析器也可以成为解析器组合器,而且这样设计时非常有用。另一个是 TypeScript 用户已经熟悉的 Zod。虽然主要思想来源于 optparse-applicative,但由于 Haskell 和 TypeScript 是截然不同的语言,API 的构建方式有很大差异。因此,在 API 设计方面,我参考了 Zod 等多个验证库。
Optique 像乐高积木一样组合多个小型解析器和解析器组合器,以表达 CLI 应该具有的形态。例如,最基本的组件之一是 option()
:
const parser = option("-a", "--allow", url());
要执行这个解析器,可以使用 run()
API:(注意,run()
函数会隐式读取 process.argv.slice(2)
)
const allow: URL = run(parser);
在上面的代码中,我特意指定了 URL
类型,但即使不这样做,它也会自动推断为 URL
类型。上述解析器只接受 -a
/--allow=URL
选项。如果提供其他选项或参数,将会报错。如果没有提供 -a
/--allow=URL
选项,也会报错。
如果想将 -a
/--allow=URL
选项设为可选而非必需,该怎么做呢?这时可以使用 optional()
组合器来包装 option()
解析器。
const parser = optional(option("-a", "--allow", url()));
执行这个解析器会得到什么类型的结果呢?
const allow: URL | undefined = run(parser);
是的,结果类型为 URL | undefined
。
或者,我们可以允许多次使用 -a
/--allow=URL
选项。比如这样使用:
prog -a https://example.com/ -a https://hackers.pub/
要允许多次使用选项,我们使用 multiple()
组合器代替 optional()
组合器:
const parser = multiple(option("-a", "--allow", url()));
现在您可能已经能猜到结果类型会是什么了:
const allowList: readonly URL[] = run(parser);
没错,结果类型是 readonly URL[]
。
那么,如果我们想添加一个与 -a
/--allow=URL
选项互斥的 -d
/--disallow=URL
选项,应该怎么做呢?这两个选项不能同时使用。这种情况下,我们可以使用 or()
组合器:
const parser = or(
multiple(option("-a", "--allow", url())),
multiple(option("-d", "--disallow", url())),
);
这个解析器能够正确接受以下命令:
prog -a https://example.com/ --allow https://hackers.pub/
prog -d https://example.com/ --disallow https://hackers.pub/
但是当 -a
/--allow=URL
选项和 -d
/--disallow=URL
选项混合使用时,会产生错误:
prog -a https://example.com/ --disallow https://hackers.pub/
那么,这个解析器的结果类型会是什么呢?
const result: readonly URL[] = run(parser);
哎呀,由于 or()
组合器包装的两个解析器都产生 readonly URL[]
类型的值,所以结果类型变成了 readonly URL[] | readonly URL[]
,最终简化为 readonly URL[]
。我们希望将其改为适当的可辨识联合类型(discriminated union)。下面这样的类型会更好:
const Result =
| { mode: "allowList"; allowList: readonly URL[] }
| { mode: "blockList"; blockList: readonly URL[] };
当我们想创建这种对象结构时,可以使用 object()
组合器:
const parser = or(
object({
mode: constant("allowList"),
allowList: multiple(option("-a", "--allow", url())),
}),
object({
mode: constant("blockList"),
blockList: multiple(option("-d", "--disallow", url())),
}),
);
为了添加辨识符(discriminator),我们还使用了 constant()
解析器。这是一个特殊的解析器,它不读取任何内容,只生成给定的值。也就是说,它是一个始终成功的解析器。它主要用于构建可辨识联合类型,但也可以以其他创造性方式使用。
现在这个解析器会生成我们想要的类型的结果值:
const result:
| { readonly mode: "allowList"; readonly allowList: readonly URL[] }
| { readonly mode: "blockList"; readonly blockList: readonly URL[] }
= run(parser);
or()
组合器和 object()
组合器不仅可以用于互斥选项。我们也可以用相同的原理实现子命令。下面介绍匹配单个命令的 command()
解析器和匹配位置参数的 argument()
解析器:
const parser = command(
"download",
object({
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
)
上面的解析器匹配以下命令:
prog download --target=out/ https://example.com/ https://example.net/
解析器的结果类型如下:
const result: {
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
} = run(parser);
如果要添加 upload
子命令,该怎么做呢?没错,使用 or()
组合器将它们连接起来:
const parser = or(
command(
"download",
object({
action: constant("download"),
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
),
command(
"upload",
object({
action: constant("upload"),
url: option("-d", "--dest", "--destination", url()),
files: multiple(
argument(file({ metavar: "FILE", type: "file" })),
{ min: 1 },
),
})
),
);
现在这个解析器可以接受以下命令:
prog upload ./a.txt ./b.txt -d https://example.com/
prog download -t ./out/ https://example.com/ https://hackers.pub/
这个解析器的结果类型如下:
const result:
| {
readonly action: "download";
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
}
| {
readonly action: "upload";
readonly url: URL;
readonly files: readonly string[];
}
= run(parser);
使用相同的方法,我们也可以实现嵌套子命令,对吧?
好了,我已经展示了 Optique 表达 CLI 的方式。您觉得怎么样?您是否认为 Optique 的方法适合表达复杂的 CLI?
当然,Optique 的方法也不是完美的。对于非常典型和简单的 CLI 定义,它可能需要更多的代码。此外,Optique 仅仅扮演 CLI 解析器的角色,所以它不提供一般 CLI 应用框架所提供的各种功能。(不过我们计划在未来为 Optique 添加更多功能...)