第 16 章:命令行解析(Command-Line Parsing)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 16。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
你编写的许多 OCaml 程序最终都会成为需要从命令提示符运行的二进制程序。任何非平凡的命令行都应该支持一组基本功能:
- 解析命令行参数
- 在输入不正确时生成错误消息
- 为所有可用选项提供帮助
- 交互式自动补全
为你写的每个程序手工编写这些功能既繁琐又容易出错。Core 提供了 Command 库,它允许你在一个地方声明命令行选项,并根据这些声明派生出上述全部功能,从而简化这项工作。
对于简单应用,Command 使用起来很简单;当需求逐渐复杂时,它也能很好地扩展。特别是,Command 提供了复杂的子命令模式,可以随着用户界面复杂度增加,把相关命令组织在一起。你可能已经从 Git 或 Mercurial 这类版本控制系统中熟悉这种命令行风格。
本章会:
- 学习如何使用
Command构造基础命令行接口和分组命令行接口 - 构建类似加密工具
md5和shasum的简单程序 - 展示如何以类型安全且优雅的方式声明复杂命令行接口
16.1 基础命令行解析(Basic Command-Line Parsing)
先克隆一个大多数 Linux 安装中都有的 md5sum 命令。在 macOS 上,对应命令叫 md5。下面定义的函数会读取文件内容,把 MD5 单向加密哈希函数应用到这些数据上,并输出结果的 ASCII 十六进制表示:
open Core
let do_hash file =
Md5.digest_file_blocking file |> Md5.to_hex |> print_endline
do_hash 函数接收一个 filename 参数,并把人可读的 MD5 字符串打印到控制台标准输出。把这个函数变成命令行程序的第一步,是为命令行参数创建解析器。Command.Param 模块提供了一组组合器,可以把它们组合起来,为可选标志和位置参数定义参数解析器,包括文档、它们应该映射到的类型,以及在遇到某些输入时是否采取特殊动作,例如暂停并等待交互式输入。
16.1.1 定义匿名参数(Defining an Anonymous Argument)
我们来为只有一个匿名参数的命令行 UI 构建解析器。这里的匿名参数指的是不带标志传入的参数。
let filename_param =
let open Command.Param in
anon ("filename" %: string)
这里用 anon 表示要解析一个匿名参数,而表达式 ("filename" %: string) 指出参数的文本名称以及描述期望值种类的规格。文本名称用于生成帮助文本;规格的类型是 Command.Arg_type.t,它既用于固定返回值的 OCaml 类型,这里是 string,也用于指导输入校验等功能。anon、string 和 %: 都来自 Command.Param 模块。
16.1.2 定义基础命令(Defining Basic Commands)
定义好规格后,需要把它用于真实输入。最简单的方式是用 Command.basic 直接创建一个命令行接口。
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(Command.Param.map filename_param ~f:(fun filename () ->
do_hash filename))
summary 参数是一行说明,会放在帮助页面顶部;可选的 readme 参数用于提供更详细的说明,并按需展示。
最后一个参数最有意思,它是参数解析器。要理解它,最好先了解一下我们已经用到的各个组件的类型签名。我们在顶层中重新创建一些代码来观察这一点。
# let filename_param = Command.Param.(anon ("filename" %: string));;
val filename_param : string Command.Spec.param = <abstr>
filename_param 的类型参数用于指出该解析器返回值的类型;在这里就是 string。
不过,Command.basic 需要的是返回 unit -> unit 类型值的参数解析器。可以用 #show 查看类型:
# #show Command.basic;;
val basic : unit Command.basic_command
# #show Command.basic_command;;
type nonrec 'result basic_command =
summary:string ->
?readme:(unit -> string) ->
(unit -> 'result) Command.Spec.param -> Command.t
注意,对于 Command.basic 的类型,类型别名 basic_command 的 'result 参数被实例化为 unit。
Command.basic 需要一个返回函数的解析器是合理的。毕竟,最终它需要一个可以运行的函数,作为程序的执行主体。但我们已有的解析器只返回文件名,该如何得到这样的解析器呢?
答案是用 map 函数改变解析器返回的值。如下所示,Command.Param.map 的类型和 List.map 很相似。
# #show Command.Param.map;;
val map : 'a Command.Spec.param -> f:('a -> 'b) -> 'b Command.Spec.param
在程序中,我们用 map 把返回文件名字符串的 filename_param 解析器,转换为返回 unit -> unit 函数的解析器,而这个函数包含命令主体。传给 map 的函数会返回另一个函数,这一点可能不明显,但要记住,因为柯里化,上面的 map 调用也可以等价写成:
Command.Param.map filename_param ~f:(fun filename ->
fun () -> do_hash filename)
16.1.3 运行命令(Running Commands)
定义好基础命令后,只需要一个函数调用就能运行它。
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
Command_unix.run 接收几个可选参数,在生产环境中识别当前运行的二进制版本时很有用。你需要下面这个 dune 文件:
(executable
(name md5)
(libraries core core_unix.command_unix)
(preprocess (pps ppx_jane)))
接着就可以用 dune exec 构建并执行程序。先用它查询二进制的版本信息:
$ dune exec -- ./md5.exe -version
1.0
$ dune exec -- ./md5.exe -build-info
RWO
输出中看到的版本由传给 Command_unix.run 的可选参数定义。在你自己的程序中,可以把这些留空,也可以让构建系统直接从版本控制系统生成它们。Dune 提供了 dune-build-info 库,可以为多数常见工作流自动化这个过程。
可以用 -help 调用二进制,查看自动生成的帮助:
$ dune exec -- ./md5.exe -help
Generate an MD5 hash of the input data
md5.exe FILENAME
More detailed information
=== flags ===
[-build-info] . print info about this build and exit
[-version] . print the version of this build and exit
[-help], -? . print this help text and exit
如果提供 filename 参数,就会用这个参数调用 do_hash,并把 MD5 输出显示到标准输出:
$ dune exec -- ./md5.exe md5.ml
045215e7e7891779b261851a8458b656
构建这个小小的 MD5 工具所需的全部工作就是这些。下面是刚刚走读过的完整示例,去掉中间变量后稍微更简洁:
open Core
let do_hash file =
Md5.digest_file_blocking file |> Md5.to_hex |> print_endline
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
Command.Param.(
map
(anon ("filename" %: string))
~f:(fun filename () -> do_hash filename))
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
16.1.4 多参数命令(Multi-Argument Commands)
到目前为止,所有例子都只涉及一个参数,但当然也可以创建多参数命令。可以用函数 Command.Param.both 把较简单的解析器绑定在一起,为多个参数创建解析器。下面是它的类型:
# #show Command.Param.both;;
val both :
'a Command.Spec.param ->
'b Command.Spec.param -> ('a * 'b) Command.Spec.param
both 允许我们接收两个参数解析器,并把它们组合成单个解析器,返回一对参数。下面把 md5 程序改写成接收两个匿名参数:第一个是整数,表示要打印哈希的多少个字符;第二个是文件名。
open Core
let do_hash hash_length filename =
Md5.digest_file_blocking filename
|> Md5.to_hex
|> (fun s -> String.prefix s hash_length)
|> print_endline
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
Command.Param.(
map
(both
(anon ("hash_length" %: int))
(anon ("filename" %: string)))
~f:(fun (hash_length, filename) () ->
do_hash hash_length filename))
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
构建并运行这个命令,可以看到它现在确实期待两个参数:
$ dune exec -- ./md5.exe 5 md5.ml
bc879
对两个参数来说,这种写法还不错;但如果参数列表更长,这种方式很快就会显得乏味。更好的做法是使用 let 语法,它已经在第 8 章“错误处理(Error Handling)”中讨论过。
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let open Command.Let_syntax in
let open Command.Param in
let%map hash_length = anon ("hash_length" %: int)
and filename = anon ("filename" %: string) in
fun () -> do_hash hash_length filename)
这里利用了 let 语法对并行 let 绑定的支持,用 and 把这些定义连接在一起。该语法会被转换成前面展示的基于 both 的相同模式,但更容易阅读和使用,也更适合扩展到更多参数。
必须同时打开两个模块有点笨拙,而且特别是 Param 模块其实只需要在等号右侧使用。使用 let%map_open 语法可以自动做到这一点,如下所示。我们还会去掉对 Command.Let_syntax 的打开,改用显式的 let%map_open.Command 来标记这个 let 语法来自 Command 模块。
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command hash_length = anon ("hash_length" %: int)
and filename = anon ("filename" %: string) in
fun () -> do_hash hash_length filename)
let 语法是编写 Command 解析器最常见的方式,从现在开始我们会使用这个惯用法。
基础已经就绪,接下来本章会考察 Command 的一些更高级功能。
16.2 参数类型(Argument Types)
你并不限于解析由字符串和整数构成的命令行。Command.Param 中还定义了一些其他参数类型,例如 date 和 percent。不过,多数时候,特定于 Core 或相关库中某个类型的参数类型,会定义在定义该类型的模块中。
例如,可以把命令规格收紧到 Filename.arg_type,以表达这个参数必须是合法文件名,而不只是任意字符串。
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command file =
anon ("filename" %: Filename_unix.arg_type)
in
fun () -> do_hash file)
这不会改变对所提供值的校验,但会启用交互式命令行补全。本章稍后会解释如何启用它。
16.2.1 定义自定义参数类型(Defining Custom Argument Types)
如果预定义参数类型不够用,也可以定义自己的参数类型。例如,我们来创建一个 regular_file 参数类型,确保输入文件不是字符设备或其他无法完整读取的奇怪 UNIX 文件类型。
open Core
let do_hash file =
Md5.digest_file_blocking file |> Md5.to_hex |> print_endline
let regular_file =
Command.Arg_type.create (fun filename ->
match Sys_unix.is_file filename with
| `Yes -> filename
| `No -> failwith "Not a regular file"
| `Unknown ->
failwith "Could not determine if this was a regular file")
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command filename =
anon ("filename" %: regular_file)
in
fun () -> do_hash filename)
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
regular_file 函数会把 filename 字符串参数转换成同一个字符串,但会先检查该文件是否存在且是否为普通文件类型。构建并运行这段代码时,如果尝试打开 /dev/null 这样的特殊设备,就会看到新的错误消息:
$ dune exec -- ./md5.exe md5.ml
6a6c128cdc8e75f3b174559316e49a5d
$ dune exec -- ./md5.exe /dev/null
Error parsing command line:
failed to parse FILENAME value "/dev/null"
(Failure "Not a regular file")
For usage information, run
md5.exe -help
[1]
16.2.2 可选参数与默认参数(Optional and Default Arguments)
一个更现实的 md5 二进制程序,也应该在没有指定 filename 时从标准输入读取。为此,需要把文件名参数声明为可选参数,这可以用 maybe 运算符完成。
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command filename =
anon (maybe ("filename" %: string))
in
fun () -> do_hash filename)
但构建这段代码会得到一个编译期错误:
$ dune build md5.exe
File "md5.ml", line 15, characters 23-31:
15 | fun () -> do_hash filename)
^^^^^^^^
Error: This expression has type string option
but an expression was expected of type string
[1]
这是因为修改参数类型也改变了解析器返回值的类型。现在它生成 string option 而不是 string,反映了该参数的可选性。可以调整示例来利用这条新信息:如果没有指定文件,就从标准输入读取。
open Core
let get_contents = function
| None | Some "-" -> In_channel.input_all In_channel.stdin
| Some filename -> In_channel.read_all filename
let do_hash filename =
get_contents filename
|> Md5.digest_string
|> Md5.to_hex
|> print_endline
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command filename =
anon (maybe ("filename" %: Filename_unix.arg_type))
in
fun () -> do_hash filename)
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
传给 do_hash 的 filename 参数现在是 string option 类型。它会通过 get_contents 解析成字符串,以决定从标准输入还是从文件读取;之后命令的其余部分与前面的例子类似。
$ cat md5.ml | dune exec -- ./md5.exe
068f02a29536dbcf111e7adebb085aa1
另一种处理方式是:如果没有指定文件名,就提供短横线作为默认文件名。maybe_with_default 正好可以做到这一点,而且好处是不必改变回调参数的类型。
下面这个例子的行为与前一个例子完全相同,只是把 maybe 替换成了 maybe_with_default:
open Core
let get_contents = function
| "-" -> In_channel.input_all In_channel.stdin
| filename -> In_channel.read_all filename
let do_hash filename =
get_contents filename
|> Md5.digest_string
|> Md5.to_hex
|> print_endline
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command filename =
anon (maybe_with_default "-" ("filename" %: Filename_unix.arg_type))
in
fun () -> do_hash filename)
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
构建并运行可以确认它和之前行为相同:
$ cat md5.ml | dune exec -- ./md5.exe
370616ab5fad3dd995c136f09d0adb29
16.2.3 参数序列(Sequences of Arguments)
解析匿名参数的另一种常见方式,是把它们解析成长度可变的列表。例如,我们把 MD5 代码改成接收一组要在命令行上处理的文件:
open Core
let get_contents = function
| "-" -> In_channel.input_all In_channel.stdin
| filename -> In_channel.read_all filename
let do_hash filename =
get_contents filename
|> Md5.digest_string
|> Md5.to_hex
|> fun md5 -> printf "MD5 (%s) = %s\n" filename md5
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
~readme:(fun () -> "More detailed information")
(let%map_open.Command files =
anon (sequence ("filename" %: Filename_unix.arg_type))
in
fun () ->
match files with
| [] -> do_hash "-"
| _ -> List.iter files ~f:do_hash)
let () = Command_unix.run ~version:"1.0" ~build_info:"RWO" command
回调函数现在稍微复杂一些,需要处理更多选项。files 现在是 string list,而空列表会回退到使用标准输入,就像前面的 maybe 和 maybe_with_default 示例一样。如果文件列表非空,就会依次打开每个文件,并让它们通过 do_hash 处理。
$ dune exec -- ./md5.exe /etc/services ./_build/default/md5.exe
MD5 (/etc/services) = 6501e9c7bf20b1dc56f015e341f79833
MD5 (./_build/default/md5.exe) = 6602408aa98478ba5617494f7460d3d9
16.3 添加带标签标志(Adding Labeled Flags)
命令行上并不只能使用匿名参数。标志(flag)是一个具名字段,后面可以跟一个可选参数。根据它们在规格中的声明方式,这些标志可以以任意顺序出现在命令行上,也可以出现多次。
我们给 md5 命令添加两个参数,模拟 macOS 版本。-s 标志用于直接在命令行上指定要哈希的字符串,-t 则运行自测试。完整示例如下:
open Core
let checksum_from_string buf =
Md5.digest_string buf |> Md5.to_hex |> print_endline
let checksum_from_file filename =
let contents =
match filename with
| "-" -> In_channel.input_all In_channel.stdin
| filename -> In_channel.read_all filename
in
Md5.digest_string contents |> Md5.to_hex |> print_endline
let command =
Command.basic
~summary:"Generate an MD5 hash of the input data"
(let%map_open.Command use_string =
flag
"-s"
(optional string)
~doc:"string Checksum the given string"
and trial = flag "-t" no_arg ~doc:" run a built-in time trial"
and filename =
anon (maybe_with_default "-" ("filename" %: Filename_unix.arg_type))
in
fun () ->
if trial
then printf "Running time trial\n"
else (
match use_string with
| Some buf -> checksum_from_string buf
| None -> checksum_from_file filename))
let () = Command_unix.run command
现在这个规格使用 flag 函数定义两个新的带标签命令行参数。doc 字符串的格式是:第一个单词是用法文本中显示的短名称,剩余部分是完整帮助文本。注意,-t 标志没有参数,因此我们在它的 doc 文本前面放了一个空格。前面代码的帮助文本如下:
$ dune exec -- ./md5.exe -help
Generate an MD5 hash of the input data
md5.exe [FILENAME]
=== flags ===
[-s string] . Checksum the given string
[-t] . run a built-in time trial
[-build-info] . print info about this build and exit
[-version] . print the version of this build and exit
[-help], -? . print this help text and exit
$ dune exec -- ./md5.exe -s "ocaml rocks"
5a118fe92ac3b6c7854c595ecf6419cb
规格中的 -s 标志需要一个 string 参数,而且不是可选的。和前面例子中的匿名参数一样,如果没有提供它,Command 解析器会输出错误消息。还有一些其他函数可以包裹标志,用来控制它们的解析方式:
required <arg>会返回<arg>,如果不存在则报错optional <arg>会返回<arg> optionoptional_with_default <val> <arg>会返回<arg>,如果不存在则使用默认值<val>listed <arg>会返回<arg> list,该标志可以出现多次no_arg会返回一个bool,如果该标志存在则为 true
标志对回调函数类型的影响方式,与匿名参数完全相同。这让你可以修改规格,并确保所有回调函数都被相应更新,而不会产生运行时错误。
16.4 把子命令分组到一起(Grouping Subcommands Together)
使用标志和匿名参数可以走得很远,足以组装复杂的命令行接口。不过一段时间后,过多选项会让刚接触应用的用户非常困惑。一种解决办法是把常见操作分组,并为命令行接口添加一些层次结构。
使用 opam 包管理器时,你已经见过这种风格;在非 OCaml 世界里,Git 或 Mercurial 命令也是这样。opam 以这种形式暴露命令:
$ opam env
$ opam remote list -k git
$ opam install --help
$ opam install core --verbose
config、remote 和 install 这些关键字形成了一组命令的逻辑分组,把一组标志和参数分解出来。这能防止特定于某个子命令的标志泄漏进全局配置空间。
通常,只有当应用自然增长出更多功能时,这才会成为问题。幸运的是,在 Command 中扩展应用来支持这种结构很简单:只需使用 Command.group,它允许你把一组 Command.t 合并成一个。
# Command.group;;
- : summary:string ->
?readme:(unit -> string) ->
?preserve_subcommand_order:unit ->
?body:(path:string list -> unit) ->
(string * Command.t) list -> Command.t
= <fun>
group 签名接收一个基础 Command.t 值列表,以及它们对应的名称。执行时,它会根据名称列表查找合适的子命令,并分派到正确的命令处理器。
我们来构建一个日历工具的轮廓,它可以在命令行上对日期执行几种操作。首先需要定义一个命令,把若干天加到输入日期上,并打印结果日期:
open Core
let add =
Command.basic
~summary:"Add [days] to the [base] date and print day"
(let%map_open.Command base = anon ("base" %: date)
and days = anon ("days" %: int) in
fun () ->
Date.add_days base days |> Date.to_string |> print_endline)
let () = Command_unix.run add
这个命令中的一切现在应该都很熟悉,而且它的工作方式也符合预期。
$ dune exec -- ./cal.exe -help
Add [days] to the [base] date and print day
cal.exe BASE DAYS
=== flags ===
[-build-info] . print info about this build and exit
[-version] . print the version of this build and exit
[-help], -? . print this help text and exit
$ dune exec -- ./cal.exe 2012-12-25 40
2013-02-03
现在再添加计算两个日期之差的能力。不过我们不创建一个新的二进制,而是使用 Command.group 把两个操作分组为子命令。
open Core
let add =
Command.basic
~summary:"Add [days] to the [base] date"
Command.Let_syntax.(
let%map_open base = anon ("base" %: date)
and days = anon ("days" %: int) in
fun () ->
Date.add_days base days |> Date.to_string |> print_endline)
let diff =
Command.basic
~summary:"Show days between [date1] and [date2]"
(let%map_open.Command date1 = anon ("date1" %: date)
and date2 = anon ("date2" %: date) in
fun () -> Date.diff date1 date2 |> printf "%d days\n")
let command =
Command.group
~summary:"Manipulate dates"
[ "add", add; "diff", diff ]
let () = Command_unix.run command
这就是添加子命令支持真正需要做的全部工作。先按通常方式构建示例,并查看帮助输出;它现在反映了刚刚添加的子命令。
(executable
(name cal)
(libraries core core_unix.command_unix)
(preprocess (pps ppx_jane)))
$ dune exec -- ./cal.exe -help
Manipulate dates
cal.exe SUBCOMMAND
=== subcommands ===
add . Add [days] to the [base] date
diff . Show days between [date1] and [date2]
version . print version information
help . explain a given subcommand (perhaps recursively)
可以调用刚刚定义的两个命令,验证它们能够工作,并观察日期解析的效果:
$ dune exec -- ./cal.exe add 2012-12-25 40
2013-02-03
$ dune exec -- ./cal.exe diff 2012-12-25 2012-11-01
54 days
16.5 提示交互式输入(Prompting for Interactive Input)
有时,如果命令行没有提供某个值,你会希望改为提示用户输入。让我们回到前面构建的日历工具。
open Core
let add =
Command.basic
~summary:"Add [days] to the [base] date and print day"
(let%map_open.Command base = anon ("base" %: date)
and days = anon ("days" %: int) in
fun () ->
Date.add_days base days |> Date.to_string |> print_endline)
let () = Command_unix.run add
这个程序要求你同时指定 base 日期和要添加的 days 数量。如果命令行上没有提供 days,就会输出错误。现在把它改成:如果只提供了 base 日期,就交互式地提示用户输入天数。
open Core
let add_days base days =
Date.add_days base days |> Date.to_string |> print_endline
let prompt_for_string name of_string =
printf "enter %s: %!" name;
match In_channel.input_line In_channel.stdin with
| None -> failwith "no value entered. aborting."
| Some line -> of_string line
let add =
Command.basic
~summary:"Add [days] to the [base] date and print day"
(let%map_open.Command base = anon ("base" %: date)
and days = anon (maybe ("days" %: int)) in
let days =
match days with
| Some x -> x
| None -> prompt_for_string "days" Int.of_string
in
fun () -> add_days base days)
let () = Command_unix.run add
days 匿名参数现在在规格中是一个可选整数;当它不存在时,我们只是在程序普通执行过程中提示用户输入该值。
有时,把提示行为打包到解析器本身中也很方便。这样做的一个好处是,可以轻松在多个命令之间共享提示行为。添加一个新函数 anon_prompt 就能做到这一点;它会创建一个解析器,如果没有提供值,就自动提示用户输入。
let anon_prompt name of_string =
let arg = Command.Arg_type.create of_string in
let%map_open.Command value = anon (maybe (name %: arg)) in
match value with
| Some v -> v
| None -> prompt_for_string name of_string
let add =
Command.basic
~summary:"Add [days] to the [base] date and print day"
(let%map_open.Command base = anon ("base" %: date)
and days = anon_prompt "days" Int.of_string in
fun () -> add_days base days)
如果不提供第二个参数运行程序,就能看到提示行为:
$ echo 35 | dune exec -- ./cal.exe 2013-12-01
enter days: 2014-01-05
16.6 使用 bash 进行命令行自动补全(Command-Line Autocompletion with bash)
现代 UNIX shell 通常有 tab 补全功能,可以交互式地帮助你弄清楚如何构造命令行。其工作方式是在输入命令中途按下 Tab 键,然后观察弹出的选项。你很可能最常用它查找当前目录中的文件,但它实际上也可以扩展到命令的其他部分。
自动补全的具体机制取决于使用的 shell,但这里假设你使用最常见的 bash。这是大多数 Linux 发行版和 macOS 上的默认交互式 shell,但在 *BSD 或 Windows 上使用 Cygwin 时,你可能需要切换到它。本节剩余部分都假设你使用的是 bash。
Bash 自动补全并不总是默认安装,所以请检查你的操作系统包管理器,确认它是否可用。
- 在 Debian Linux 上,执行
apt install bash-completion - 在 macOS Homebrew 上,执行
brew install bash-completion - 在 FreeBSD 上,执行
pkg install bash-completion
安装并配置好 bash 补全后,可以输入 ssh 命令并按 Tab 键检查它是否工作。这应该会显示来自 ~/.ssh/known_hosts 文件的已知主机列表。如果它列出了你最近连接过的一些主机,就可以继续。如果它列出的是当前目录中的文件,那么请查看操作系统文档,正确配置补全。
还需要找到的最后一点信息,是 bash_completion.d 目录的位置。这个目录保存包含补全逻辑的所有 shell 片段。在 Linux 上,它通常位于 /etc/bash_completion.d;在 Homebrew 管理的 macOS 上,默认通常是 /usr/local/etc/bash_completion.d。
16.6.1 从 Command 生成补全片段(Generating Completion Fragments from Command)
Command 库拥有所有可能有效选项的声明式描述,因此可以使用这些信息生成一个提供该命令补全支持的 shell 脚本。要生成片段,只需在运行命令时把环境变量 COMMAND_OUTPUT_INSTALLATION_BASH 设为任意值。
例如,在前面的 MD5 示例上试一下,假设当前目录中的二进制叫 md5:
$ env COMMAND_OUTPUT_INSTALLATION_BASH=1 dune exec -- ./md5.exe
function _jsautocom_32087 {
export COMP_CWORD
COMP_WORDS[0]=./md5.exe
if type readarray > /dev/null
then readarray -t COMPREPLY < <("${COMP_WORDS[@]}")
else IFS="
" read -d "" -A COMPREPLY < <("${COMP_WORDS[@]}")
fi
}
complete -F _jsautocom_32087 ./md5.exe
回想一下,我们用 Arg_type.file 指定了参数类型。它也提供补全逻辑,因此只需按 Tab 就能补全当前目录中的文件。
16.6.2 安装补全片段(Installing the Completion Fragment)
你不需要关心前面输出的脚本实际做了什么,除非你对 shell 脚本内部细节有一种不太健康的迷恋。相反,把输出重定向到当前目录中的一个文件,并把它 source 到当前 shell 中即可:
$ env COMMAND_OUTPUT_INSTALLATION_BASH=1 ./cal_add_sub_days.native > cal.cmd
$ . cal.cmd
$ ./cal_add_sub_days.native <tab>
add diff help version
Command 补全支持适用于标志和分组命令;在构建更大的命令行接口时,它非常有用。如果希望它在所有登录 shell 中加载,别忘了把 shell 片段安装到全局 bash_completion.d 目录。
安装通用补全处理器(Installing a Generic Completion Handler)
遗憾的是,bash 不支持为所有基于 Command 的应用安装通用处理器。这意味着你必须为每个应用安装补全脚本,但应该可以在应用的构建和打包系统中自动完成这件事。
查看其他应用如何安装 tab 补全脚本并照着做会有帮助,因为这些细节非常依赖具体操作系统。
16.7 其他命令行解析器(Alternative Command-Line Parsers)
到这里,我们对 Command 库的巡览就结束了。当然,这并不是解析命令行参数的唯一方式;opam 上还有几个替代方案。下面列出其中最突出的三个:
Arg 模块
: Arg 模块来自 OCaml 标准库,编译器自身就用它处理命令行接口。Command 构建在 Arg 之上,但你也可以直接使用 Arg。可以用 Command.Spec.flags_of_args_exn 函数把 Arg 规格转换成与 Command 兼容的规格,这是一种把基于 Arg 的命令行接口迁移到 Command 的简单方式。
ocaml-getopt
: ocaml-getopt 提供 GNU getopt 和 getopt_long 的通用命令行语法。GNU 约定在开源世界中被广泛使用,这个库让你的 OCaml 程序可以遵守同样的规则。
Cmdliner
: Cmdliner 是 Command 与 Getopt 两类库的混合体。它允许以声明式方式定义命令行接口,但暴露了更接近 getopt 的接口。它还会把 UNIX man 页面生成自动纳入规格的一部分。Cmdliner 是 opam 用来管理其命令行的解析器。