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); 

使用相同的方法,我們也可以實現嵌套子命令(nested subcommands),對吧?

以上就是 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