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 添加更多功能...)

如果您对 Optique 的方法感兴趣,请查看介绍文档教程

12
2
2

2 comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0198c81f-6b6b-71e0-af6a-96c1d65af83f on your instance and reply to it.

3

@hongminhee洪 民憙 (Hong Minhee) optparse-applicative엔 여러 커맨드의 겹치는 인자를 처리하는데에 애로사항이 있잖아요? 그러니까 foo bar --baz=1foo qux --baz=1이 둘다 가능할때 baz를 필드로 갖는 레코드를 두개 만들어아야 하는 문제 말이에요. 근데 optique는 서브타이핑으로 이 문제를 좀더 잘 다룰수 있어 보이네요. 맞나요?

1