写自:2022-08-16
作者:柳下川
前置知识: rust 语法基础
完整代码: rust-wc
注意:
本项目基于当前最新版本的 clap, 也就是 version 4 本项目使用 clap 中的 derive 特性, 而非 builder 特性 (其实差不多)
推荐读者别直接跟着敲代码, 先整体看一遍, 明白大致思路后再动手
官方教程: derive-tutorial
官方资料: derive-reference
🔗 成品展示
- 通过如下命令从 crates.io 上下载成品, 可执行文件的名称是 rwc:
cargo install rust-wc
以下是使用 asciinema 录制的展示:
🔗 基础概念
CLI, 是 Command Line Interface 的简称, 意思是命令行界面, 和 GUI/TUI 是一个道理
如在Linux下常见的 ls/cp/mv 等命令, 你传入参数, 它就会做些事情, 并可能会打印相应输出
GNU 项目提供了非常多的开源命令, 如 wc, 它可以统计文件的 bytes/char/line 的数量
我们将会使用 Rust 语言, 搭配一个叫 clap 的库, 写一个升级版的 wc
注意:
cli 可以代表抽象的界面, 也可以指代具体的某个程序
后文中的 cli , 一般情况下都指代某个具体的命令行程序, 比如 ls/cp 等
再写之前, 我们还应了解些基本概念 (直接跳过感觉也木得问题??):
- 参数(arguments):
传给命令的参数, 比如一个路径
- 选项(options):
通常以单/双横杠开头, 不同的 options 表示不同的行为
比如
, 表示以默认行为下进行输出, ls ./*
以长列表形式输出, ls -l ./*
输出所有隐藏文件ls -a ./*
单横杠开头的只有一个字母, 双横杠开头的可以有很多字母, 如
与 ls -i
ls --inode
- 子命令(subcommands):
一个命令的子命令, 通常情况下需要不同的 args, 有不同的 options
比如
与 cargo build
, 都是 cargo 的子命令cargo publish
- 双横杠(–):
在命令后面的某处位置, 加上
, 可以将 --
后面的内容当作 argument 传入, 而非 options--
举个例子, 我有个文件, 叫做
, 我想使用 --asd
来输出里面的内容cat --asd
如果你直接这样传参, 因为文件名以横杠开头, 将会被命令视作 options, 而
本身没 cat
这个option, 故失败--asd
在比如有个文件叫
, 使用 --help
将会打印其 help 信息cat --help
此时, 你应该使用
, 将 cat -- --asd
视作参数传入--asd
- 短/长帮助(short/long help):
有些命令,
与 -h
分别对应短帮助与长帮助, 后者比前者会显示更多提示信息--help
🔗 初始配置
以下是层次结构, 之后要生成自动补全文件时, 还会再增加一些文件, 请自行创建好目录:
./rust-wc ├── Cargo.lock ├── Cargo.toml └── src ├── wc_result.rs # 计算并存储结果 ├── cli.rs # 命令行的定义 ├── files.rs # 读取文件 ├── lib.rs # 声明模块, 类型别名 └── main.rs
让我们新建一个叫做 rust-wc 的项目:
# Cargo.toml # 包名为 `rust-wc` (因为我发布到 crates.io 的时候, `rwc` 已经被占了呜呜呜呜呜) [package] name = "rust-wc" authors = ["jedsek <jedsek@qq.com>"] version = "0.0.1" description = "A GNU/wc implementation written in rust, which is faster when reading a large of big files" edition = "2021" # 指定生成的可执行文件的名字, 此处是 `rwc`, 虽然包名是 `rust-wc`, 但命令是 `rwc` [[bin]] name = "rwc" path = "src/main.rs" # 指定依赖 [dependencies] clap = {version = "4.0.29", features = ["derive"]} # 解析参数 unicode-width = "0.1.10" # 计算 Unicode 字符宽度 indicatif = "0.17.2" # 进度条 prettytable-rs = "0.9.0" # 打印表格 rayon = "1.6.1" # 并行化
// src/lib.rs use std::{collections::HashMap, path::PathBuf}; pub mod cli; pub mod files; pub mod wc_result; pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; pub type Counts = Vec<usize>; pub type PathWithContent = HashMap<PathBuf, String>;
🔗 命令定义
注意:
clap-v3 时, 融进了另一个很强大的命令行编写库: structopt
因此以后看见 structopt 与 clap, 直接用 clap 就完事了, 前者也发过通知, 让别人直接用 clap
这给 clap 带来的巨大变化, 就是出现了derive宏, 以一种非常便利的声明式写法, 帮你生成与解析代码
让我们来想象下这个命令:
- 必须接受一个参数
- 参数必须是存在的路径, 或者是 -, 表示从标准输入读取内容
- 根据启用的 flag 来决定计算并打印哪些东西
多亏了 derive 宏, 我们可以这样定义它, 下面是
的完整代码:src/cli.rs
// src/cli.rs use clap::{ArgGroup, Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] // 这里的 derive(Parser) 表示下面这一坨都会被 `宏的黑魔法` 所洗礼 #[command( author, version, about, subcommand_negates_reqs = true, group( ArgGroup::new("options") .multiple(true) .required(true) .args(&[ "bytes", "chars", "words", "lines", "longest_line"]) ), )] pub struct Cli { /// The path(s) you should provide #[arg(value_parser = check_path, value_name = "PATH", required = true)] pub paths: Vec<PathBuf>, /// Print the byte counts #[arg(short, long)] pub bytes: bool, /// Print the character counts #[arg(short, long)] pub chars: bool, /// Print the word counts #[arg(short, long)] pub words: bool, /// Print the line counts #[arg(short, long)] pub lines: bool, /// Print the maximum line width (Unicode) #[arg(short = 'L', long)] pub longest_line: bool, #[command(subcommand)] pub sub_commands: Option<SubCommands>, } #[derive(Subcommand)] pub enum SubCommands { /// Enabled all available options All { /// The path(s) you should provide #[arg(value_parser = check_path, value_name = "PATH", required = true)] paths: Vec<PathBuf>, }, } // 自定义了一个解析器, 检测路径是否存在, 或者是否从标准输入读取内容 fn check_path(filename: &str) -> Result<PathBuf, String> { let path = PathBuf::from(filename); if filename == "-" || path.exists() { Ok(path) } else { Err(format!("No such path: `{}`", path.display())) } } impl Cli { // 开启所有的 options pub fn enable_all_options(&mut self) { self.bytes = true; self.chars = true; self.words = true; self.lines = true; self.longest_line = true; } // 返回启用的options, 类型是 Vec<&str>, 方便后面打印表格时, 作为表格的标题 pub fn get_enabled_options(&self) -> Vec<&'static str> { let mut enabled_options = vec![]; self.bytes.then(|| enabled_options.push("Bytes")); self.chars.then(|| enabled_options.push("Chars")); self.words.then(|| enabled_options.push("Words")); self.lines.then(|| enabled_options.push("Lines")); self.longest_line.then(|| enabled_options.push("Maximum line width (Unicode)")); enabled_options } }
以上的代码表示, 我们定义了一个
结构体, 表示对输入参数的建模Cli
得益于宏的黑魔法, clap 将生成一些代码, 使我们不用再耗费脑力, 考虑如何处理输入参数, 并将其解析为对应类型
也不用再考虑解析失败时, 应该如何编写一个用户友好的错误提示
clap 已经为我们做好了一切 :)
我们能调用 clap 为我们实现的 trait
中的 Parser
方法, 解析参数并进行转换:parse
> 帮助信息
现在先让我们运行
看看效果, 查看下 help 吧:cargo run -- -h
A GNU/wc clone written in rust, which is super faster when reading a large of big files Usage: rwc <--bytes|--chars|--words|--lines|--longest-line> <PATH>... rwc [PATH]... <COMMAND> Commands: all Enabled all available options help Print this message or the help of the given subcommand(s) Arguments: <PATH>... The path(s) you should provide Options: -b, --bytes Print the byte counts -c, --chars Print the character counts -w, --words Print the word counts -l, --lines Print the line counts -L, --longest-line Print the maximum line width (Unicode) -h, --help Print help information -V, --version Print version information
哇哦! 若你用这段文字, 对比下前面的 src/cli.rs, 会发现先前的文档注释, 在声明宏的威力下, 变成了 help 信息
没错! clap 能自动帮你做很多事情, 包括但不限于通过文本注释来生成 help 信息
如果你不想要 about 信息直接照搬 Cargo.toml 里的 description 怎么办? 没事, 直接覆写就行, 覆写的优先级更高:
#[derive(Parser)] #[command( about = "...", // ...... // ......
并且, 像 author/version/about 等信息, 是通过读取 Cargo.toml 来获取的, 但在 v4 版本, clap 默认不显示, 以保持简洁
你可以查看 help_template 知晓如何显示, 例子可能是 builder形式, 也就是非声明式, 但别慌张:
你可以像这样将 builder形式 的代码转化为 derive形式
实际上, 宏的黑魔法, 就是将这些声明式代码, 在编译期转化为 builder 代码
Command::new("myprog") .help_template("{bin} ({version}) - {usage}") #[derive(Parser)] #[command( help_template = "{bin} ({version}) - {usage}", // ...... // ......
> 选项与参数
clap 能非常方便地以声明的方式, 定义选项/参数
对于一个option, 比如
, 你只需要这样写:-b/--bytes
/// Print the byte counts #[arg(short, long)] pub bytes: bool,
它由三部分组成:
- 文档注释: help 中对该命令的解释
- arg(short, long): 该 option 具有短/长横杠的形式
- 类型为bool: 传入时默认的行为是将其设置为 true
Options: -b, --bytes Print the byte counts
当你传入该参数时, Cli 实例中的 bytes 属性将被设置为 true
你也可以自行指定 short/long 的名称, 不指定时, short取首字母, long取全部
比如
与 -l/--lines
, 不指定时都是 -l, 编译会报错, 需要自己指定:-L/--longest-line
/// Print the maximum line width (Unicode) #[arg(short = 'L', long)] pub longest_line: bool,
当你没有添加 short 或 long 时, 自然就代表这是个 argument
在这里, 我们唯一需要的参数, 是一个或多个路径, 因此我们使用
来表示它, clap 会自动将参数解析为路径Vec<PathBuf>
为了醒目, 我们将其显示在 help 中的名字, 改为大写的
, 同时指定该参数必选, 防止路径数为0:PATH
/// The path(s) you should provide #[arg(value_name = "PATH", required = true)] pub paths: Vec<PathBuf>,
如果你不输入参数, 命令行就会优雅地显示错误, 友善地来提醒你
当我们输入
:cargo run -- -b
error: The following required arguments were not provided: <PATH>... Usage: rwc <--bytes|--chars|--words|--lines|--longest-line> <PATH>... For more information try '--help'
但此时, 其实还有一个问题: 参数虽然被转化为
, 但不存在的路径也是路径啊! 此时就应该报错才行PathBuf
没错, clap 只是负责帮我们生成 进行转换的代码
但验证存在性等工作, 应该自己来完成, 毕竟 clap 又不知道这个参数会被拿去干啥 :)
因此, 我们来学学如何使用自定义的参数解析器吧
> 参数解析器
有些疑问或许会萦绕在你的心头:
- clap 是怎么进行解析的?
- clap 能否将传入的参数, 解析为自定义的类型呢?
- 我们能否在用户穿参时, 检查参数是否合法, 非法的直接报错, 来提醒用户呢?
实际上, 你需要通过向名为 value_parser 的函数, 传入一个解析器, 通过调用该解析器来对参数进行解析与验证
比如, 如果我们想验证传入的路径是否合法, 可以这样写:
// ...... // ...... /// The path(s) you should provide #[arg(value_parser = check_path, value_name = "PATH", required = true)] pub paths: Vec<PathBuf>, // ...... // ...... fn check_path(filename: &str) -> Result<PathBuf, String> { let path = PathBuf::from(filename); if filename == "-" || path.exists() { Ok(path) } else { Err(format!("No such path: `{}`", path.display())) } }
good, 现在当你传入路径时, 程序会对路径进行验证, 若路径不存在, 那就返回一个错误
该错误会在用户传入非法路径时, 作为报错信息出现
当我们输入
:cargo run -- -b asdxxx
error: Invalid value "asd" for '<PATH>...': No such path: `asdxxx` For more information try '--help'
于此同时, clap 已经为非常多的基本类型, 常用类型, 嵌套基本类型, 嵌套常用类型实现了非常多的 parser
得益于此, 你可以为任何类型定义对应的 parser
> 参数关系
有时候, 我们可能会面临这样或那样的问题:
- 当启用这个 option 时, 另外一个与其冲突的 option 不应该被启用
- 一个或多个指定的 option(s) 必须被启用
- 多个指定的 options 可以同时被启用
如何实现这些关系? 你可能会想自己手写, 但时间不应该浪费在这些事情上, 在 clap 中, 有着对应机制来处理这些事情
它叫做 参数关系(Argument Relations), 当参数不符合对应关系时, 会出现友善的报错信息, 提示用户应该如何修改
因此, 我们可以使用 Arg/ArgGroup (参数与参数组) 来声明这些关系
实际上, 你先前在
头上写的 paths
, 就是一种参数关系required = true
以我们的 rwc 举个例子:
能同时出现, 即支持类似-b/-c/-w/-l/-L
的形式-b -c -w 或 -bcw
至少出现其中一个, 防止只传路径不传 option-b/-c/-w/-l/-L
任何一个
类型 (被 Arg
所修饰的), 或者 #[arg]
, 都能够声明这种参数间的关系ArgGroup
我们可以新建一个
的实例, 然后把先前的一坨 ArgGroup
都放入其中:-b/-c/-w/-l/-L
// ...... // ...... #[derive(Parser)] #[command( author, version, about, subcommand_negates_reqs = true, group( ArgGroup::new("options") .multiple(true) .required(true) .args(&[ "bytes", "chars", "words", "lines", "longest_line"]) ), )] pub struct Cli { /// The path(s) you should provide #[arg(value_parser = check_path, value_name = "PATH", required = true)] pub paths: Vec<PathBuf>, // ...... // ......
表示可以同时出现参数组的成员, multiple(true)
表示至少传入该参数组中的其中一个成员required(true)
> 子命令
我们还可以定义一个 subcommand, 用来启用所有的 options, 它也要接受一个路径作为参数
pub struct Cli { // ...... // ...... #[command(subcommand)] pub sub_commands: Option<SubCommands>, } #[derive(Subcommand)] pub enum SubCommands { /// Enabled all available options All { /// The path(s) you should provide #[arg(value_parser = check_path, value_name = "PATH", required = true)] paths: Vec<PathBuf>, }, }
你可以会想, 能不能让子命令复用
中定义的 Cli
, 减少重复代码呢?paths
当然可以, 请在其成员
头上的 paths
中, 添加 #[arg]
, 表示该参数是全局性的, 相当于子命令中也添加了这么个参数global = true
但非常遗憾, 当设置
后, 就无法设置 global = true
了, 因此我们还是得定义一份相同的参数, 详见相关的 issuerequired = true
注意:
这里其实可以选择不定义 subcommand, 当没有传入 options 时默认开启所有 options, 来简化用户输入
但本文还是定义了 subcommand 以便读者了解, 起演示作用
🔗 逻辑实现
根据:
├── wc_result.rs # 计算并存储结果 ├── cli.rs # 命令行的定义 ├── files.rs # 读取文件
我们已经完成了对命令行的定义, 接下来要做的, 就是根据 Cli 的内容来实现逻辑了
为了避免你回到前面看 lib.rs 的内容, 下面再贴一遍:
// src/lib.rs use std::{collections::HashMap, path::PathBuf}; pub mod cli; pub mod files; pub mod wc_result; pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; pub type Counts = Vec<usize>; pub type PathWithContent = HashMap<PathBuf, String>;
下面是对应模块的逻辑实现, 在我的博客中是以tab的形式呈现, 比较清晰, 其他平台未知:
// src/files.rs // 声明依赖 // 我写的时候遇见没有导入的, 也是直接用 lsp 来自动导入, 直接与后面的代码对照看会比较好 use crate::{PathWithContent, Result}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rayon::prelude::*; use std::ffi::OsStr; use std::process; use std::sync::atomic::{AtomicUsize, Ordering}; use std::{ fs::File, io::{BufReader, Read}, path::PathBuf, }; // `INPUTTED_FILE_NUMBER` 表示 INPUT 的编号, 是本文开篇的 asciinema 展示中的效果 // `BUFFER_SIZR` 表示每次读取文件时缓冲区的大小 (实现进度条) static INPUTTED_FILE_NUMBER: AtomicUsize = AtomicUsize::new(0); const BUFFER_SIZR: usize = 16 * 1024; // 使用 trait 来扩展标准库中的 PathBuf 类型, 有两个函数, 一个检测是否是以点开头的, 一个加上点前缀 // 比如, 当你传入 `./build.rs` 与 `build.rs`, 前者输出时有点前缀, 后者没有, 因此统一下 // 并且当是从 stdin 读取的时候, 就显示无点前缀的 `Input/0` 会更清晰 trait PathExt { fn without_dotted_prefix(&self) -> bool; fn add_dotted_prefix(&mut self); } impl PathExt for PathBuf { fn without_dotted_prefix(&self) -> bool { self.is_relative() && !self.starts_with("../") && !self.starts_with("./") } fn add_dotted_prefix(&mut self) { *self = PathBuf::from_iter([OsStr::new("./"), self.as_os_str()]); } } // 读取文件的函数, 被暴露给其他模块, 参数是一个路径数组 pub fn read_files(paths: Vec<PathBuf>) -> Result<PathWithContent> { println!("Reading files / Getting content from stdin:"); // 其实这里的 filter 不太好, 还可以判断目录与递归读取, 但暂时就这样吧 let result = paths .into_par_iter() .filter(|path| path.is_file() || path.as_os_str() == "-") .map(|mut path| { let should_read_from_input = path.as_os_str() == "-"; let content = get_content(&path, should_read_from_input); if path.without_dotted_prefix() { path.add_dotted_prefix(); } if should_read_from_input { let inputted_file_number = INPUTTED_FILE_NUMBER.fetch_add(1, Ordering::SeqCst); path = PathBuf::from(format!("Input/{}", inputted_file_number)); } let content = content.unwrap_or_else(|err| { eprintln!("{}: {}", path.display(), err); process::exit(1); }); (path, content) }) .collect(); Ok(result) } // helper 函数, 针对单个路径 fn get_content(path: &PathBuf, should_read_from_input: bool) -> Result<String> { if should_read_from_input { read_from_stdin() } else { let bars = MultiProgress::new(); let style = ProgressStyle::with_template("[{elapsed}][{percent}%] {bar:45.cyan/blue} {bytes} {wide_msg}")? .progress_chars(">-"); read_file_with_progress(path, style, bars) } } // 读取对应路径的文件 fn read_file_with_progress(path: &PathBuf, style: ProgressStyle, bars: MultiProgress) -> Result<String> { let mut content = String::new(); let file = File::open(path)?; let size = file.metadata()?.len(); let bar = ProgressBar::new(size).with_message(format! {"Reading {}", path.display()}).with_style(style); let bar = bars.add(bar); let mut bufreader = BufReader::new(file); let mut buf = [0; BUFFER_SIZR]; while let Ok(n) = bufreader.read(&mut buf) { if n == 0 { break; } bar.inc(n as u64); content += &String::from_utf8_lossy(&buf[..n]); } bar.finish_with_message("Done!"); Ok(content) } // 从 stdin 中读取, 作为临时文件的内容 fn read_from_stdin() -> Result<String> { let mut content = vec![]; std::io::stdin().read_to_end(&mut content)?; Ok(String::from_utf8(content)?) }
// src/wc_result.rs // 声明依赖 use crate::{ cli::{Cli, SubCommands}, files::read_files, Counts, Result, }; use prettytable::{cell, format::consts::FORMAT_BOX_CHARS, Row, Table}; use rayon::prelude::*; use std::{collections::HashMap, path::PathBuf, str}; // 存放被启用的 options, 与键值对 pub struct WcResult { enabled_options: Vec<&'static str>, paths_with_counts: HashMap<PathBuf, Counts>, } // 实例化函数 pub fn get(mut cli: Cli) -> Result<WcResult> { println!("Please waiting...\n"); // 根据子命令进行相应操作 match cli.sub_commands { Some(SubCommands::All { ref paths }) => { cli.paths = paths.clone(); cli.enable_all_options(); } None => cli.enable_all_options(), }; // 进行计算 println!("Calculating..."); let wc_result = WcResult { enabled_options: cli.get_enabled_options(), paths_with_counts: { let contents = read_files(cli.paths.clone())?; contents.into_par_iter().map(|(path, content)| (path, calculate_counts(&cli, content))).collect() }, }; Ok(wc_result) } impl WcResult { // 将保存的信息转化为美化后的表格 pub fn to_pretty_table(self) -> Table { let titles = { let enabled_options = self.enabled_options; let mut titles = Row::new(enabled_options.into_iter().map(|x| cell!(Fybi -> x)).collect()); titles.insert_cell(0, cell!(Fybi -> "Path")); titles }; let mut table = Table::new(); table.set_titles(titles); table.set_format(*FORMAT_BOX_CHARS); for (path, counts) in self.paths_with_counts { let mut row = Row::new(counts.into_iter().map(|x| cell!(x)).collect()); let path_cell = if path.starts_with("Input") { cell!(Fbb -> path.display()) } else { cell!(Fmb -> path.display()) }; row.insert_cell(0, path_cell); table.add_row(row); } table } } // 不太懂 rayon, 暂时这样糊上去了 fn calculate_counts(cli: &Cli, content: String) -> Counts { let v: Vec<Option<usize>> = vec![None; 5]; v.into_par_iter() .enumerate() .map(|(idx, _)| match idx { 0 => cli.bytes.then_some(content.len()), 1 => cli.chars.then_some(content.chars().count()), 2 => cli.words.then_some(content.split_whitespace().count()), 3 => cli.lines.then_some(content.lines().count()), 4 => cli .longest_line .then_some(content.lines().map(unicode_width::UnicodeWidthStr::width).max().unwrap_or(0)), _ => None, }) .flatten() .collect() }
// src/mian.rs use clap::Parser; use rust_wc::{cli::Cli, wc_result, Result}; fn main() -> Result<()> { let cli = Cli::parse(); let pretty_table = wc_result::get(cli)?.to_pretty_table(); pretty_table.printstd(); Ok(()) }
🔗 自动补全
我们已经写好了命令行程序, 可以通过
查看帮助信息, 但能不能更方便地与 shell 集成呢?-h/--help
比如, 当你使用 bash/zsh/fish 时, 输入命令后点 Tab, 能帮你自动显示该命令的 flag/subcommand
我们将使用 clap_complete 这个库, 在编译器生成特定于 shell 的自动补全文件(也可以运行时生成, 看 clap_complete 的文档)
首先要修改 Cargo.toml, 在后面添加 build-dependencies:
# Cargo.toml [build-dependencies] clap = {version = "4.0.29", features = ["derive"]} clap_complete = "4.0.6"
// build.rs use clap::CommandFactory; use clap_complete::{generate_to, shells::*}; use std::error::Error; include!("src/cli.rs"); fn main() -> Result<(), Box<dyn Error>> { let outdir = "completions"; let app_name = "rwc"; let mut cmd = Cli::command(); generate_to(Bash, &mut cmd, app_name, outdir)?; generate_to(Zsh, &mut cmd, app_name, outdir)?; generate_to(Fish, &mut cmd, app_name, outdir)?; generate_to(Elvish, &mut cmd, app_name, outdir)?; generate_to(PowerShell, &mut cmd, app_name, outdir)?; Ok(()) }
目前, clap_complete 仅支持以上几种 shell, 更多的偏小众 shell, 一般以 clap_complete_xxx 的形式出现在 crates.io 上
比如 clap_complete_nushell, 但亲测质量不佳, 不建议使用
同时, 请确保项目根目录下存在 completions 目录, 随后运行 cargo build, 通过 tree 命令可以看到生成的补全文件:
completions ├── _rwc ├── rwc.bash ├── rwc.elv ├── rwc.fish └── _rwc.ps1
就酱, 本文结束啦!
希望本文能帮到你, 让你快速了解使用 clap 的流程 :)