rust 2024年12月28日

Command Line Rust

使用Rust开发命令行程序
书籍封面

Command Line Rust

作者:Ken Youens-Clark 出版日期:2021-09-30 出版社:O'Reilly Media, Inc.
本书是一本基于项目的Rust命令行工具编写入门教程。它指导读者使用Rust编写多个Unix命令行工具的克隆版本,例如head、cal和grep等,从而学习Rust语言的核心概念和Unix命令行编程的技巧。书中涵盖了处理命令行参数、读取和写入文件、解析文本、使用正则表达式等方面的内容,并通过单元测试和集成测试来确保程序的正确性。 此外,书中还介绍了Rust的一些高级特性,例如错误处理、生命周期管理和并发编程。 通过完成这些项目,读者可以快速掌握Rust语言并提升实际编程能力。

书中配套代码:kyclark/command-line-rust

前言

本书作者在前言中分享了他学习编程的经验,强调了实践的重要性。作者认为,仅仅阅读参考书籍是不足以掌握一门编程语言的,需要通过实际编写程序来应用所学知识。作者还提到,学习如何学习一门语言是程序员最重要的技能。Rust 语言在语法上与 C 语言类似,例如 for 循环、分号结尾的语句和用花括号表示的代码块。Rust 通过 borrow checker 来保证内存安全,同时不会牺牲性能。Rust 程序可以编译成原生二进制代码,其运行速度通常可以与 C 或 C++ 编写的程序相媲美。Rust 从函数式语言如 Haskell 中借鉴了一些概念,例如,变量默认是不可变的,函数是一等公民,可以使用枚举和 sum 类型表示函数可以返回 OkErr。书中还提供了代码示例下载链接和技术问题反馈邮箱。作者感谢了 Rust 社区的贡献,以及家人和朋友的支持。

第一章:真相或后果

本章介绍了 Rust 编程的基础知识,并以经典的 Hello, world! 程序为例,展示了如何创建和运行一个 Rust 项目。

首先,介绍了如何组织 Rust 项目目录,使用 Cargo 创建和运行项目。还演示了如何编写和运行集成测试,以验证程序的正确性。例如,可以通过 Command::cargo_bin("hello").unwrap() 创建一个命令,并使用 cmd.assert().success().stdout("Hello, world!\n") 来断言命令成功执行且标准输出为 “Hello, world!\n”。如果修改 src/main.rs 中的输出,测试将会失败,并显示实际输出与期望输出之间的差异。

此外,还介绍了如何使用 std::process::exit 函数显式退出并返回一个代码,以及 std::process::abort 函数,用于以非零错误代码退出。

本章还解释了 Result 类型,它是一种可以成功或失败的方式,具有 OkErr 两种变体

第二章:回声测试

本章介绍了如何使用 Rust 创建一个类似于 echo 的程序,它可以将参数打印到标准输出。本章演示了如何使用 clap crate 来解析命令行参数,例如,如何创建一个名为 text 的位置参数,以及一个名为 omit_newline 的 flag 参数。 使用 values_of_lossy 获取参数值,并使用 is_present 判断 flag 是否存在。代码示例:

use clap::{App, Arg};
fn main() {
    let matches = App::new("echo")
        .arg(Arg::with_name("text").multiple(true).required(true))
        .arg(Arg::with_name("omit_newline").short("n"))
        .get_matches();
    let text = matches.values_of_lossy("text").unwrap();
    let omit_newline = matches.is_present("omit_newline");
    print!("{}{}", text.join(" "), if omit_newline { "" } else { "\n" });
}

本章还介绍了如何编写测试用例,例如使用 assert_eq! 来验证程序的输出是否与预期一致。书中使用了 Result 类型来处理测试中的错误,其中 Ok 表示成功,Err 表示错误。 使用 Box 指示错误将存在于堆上的指针中,dyn 表示对 std::error::Error trait 的方法调用是动态分发的。

第三章:走猫步

本章讲解如何使用 Rust 实现一个类似 cat 的程序,它可以将多个文件的内容连接到一个文件中。本章介绍了如何使用 std::error::Error trait 来表示错误值,并创建自定义的 MyResult 类型。run 函数接受一个 Config 参数,该参数包含文件列表和用于行编号的选项。本章还演示了如何使用 clap crate 定义命令行参数,例如使用 Arg::with_name 函数来定义输入文件参数和行号选项。还使用了 const 关键字来定义全局常量。 本章还介绍了如何使用 Iterator::enumerate 方法获取元素的索引和值,以及如何使用 Box 指向文件句柄以读取标准输入或常规文件。

#[derive(Debug, Parser)]
#[command(author, version, about)]
/// Rust version of `cat`
struct Args {
    /// Input file(s)
    #[arg(value_name = "FILE", default_value = "-")]
    files: Vec<String>,

    /// Number lines
    #[arg(
        short('n'),
        long("number"),
        conflicts_with("number_nonblank_lines")
    )]
    number_lines: bool,

    /// Number non-blank lines
    #[arg(short('b'), long("number-nonblank"))]
    number_nonblank_lines: bool,
}

第四章:头痛

本章介绍了如何使用 Rust 实现一个类似 head 的程序,它可以显示文件的前几行或前几个字节。本章首先介绍了如何编写单元测试来检查 parse_positive_int 函数,该函数用于将字符串解析为正整数。使用了 unimplemented! macro 来标记尚未实现的功能,以及 panic! macro 来终止程序。书中还使用了 match 表达式来进行模式匹配。本章还演示了如何使用 App::newArg::with_name 定义命令行参数,包括 linesbytes 参数,以及如何使用 conflicts_with 来指定参数之间的冲突关系。代码示例:

#[derive(Parser, Debug)]
#[command(author, version, about)]
/// Rust version of `head`
struct Args {
    /// Input file(s)
    #[arg(default_value = "-", value_name = "FILE")]
    files: Vec<String>,

    /// Number of lines
    #[arg(
        short('n'),
        long,
        default_value = "10",
        value_name = "LINES",
        value_parser = clap::value_parser!(u64).range(1..)
    )]
    lines: u64,

    /// Number of bytes
    #[arg(
        short('c'),
        long,
        value_name = "BYTES",
        conflicts_with("lines"),
        value_parser = clap::value_parser!(u64).range(1..)
    )]
    bytes: Option<u64>,
}

书中介绍了如何使用下划线 _ 来表示不使用的变量,如何在类型注解中使用下划线来让编译器推断类型,以及如何使用turbofish ::<> 操作符来显式指定类型。本章还介绍了如何使用 BufRead::read_line 来读取文件行,以及如何使用 take 方法来限制选择的元素数量。

第五章:对妈妈说的话

本章介绍了如何使用 Rust 实现一个类似 wc 的程序,它可以统计文件或标准输入的行数、字数和字节数。本章介绍了如何使用 if let 表达式来处理 Result 类型,以及如何使用 std::process::exit 来终止程序并返回错误代码。定义了 Config 结构体来表示命令行参数。还演示了如何使用 App::newArg::with_name 定义命令行参数,包括 fileswordsbytescharslines 参数,以及如何使用 multipledefault_value 来指定参数的属性。 本章还介绍了如何使用 Iterator::all 方法来检查所有值是否都为 false,以及如何使用闭包来作为参数传递给其他函数。代码示例:

if [lines, words, bytes, chars].iter().all(|v| !v) {
    lines = true;
    words = true;
    bytes = true;
}

本章还介绍了如何使用 BufRead trait 来读取文件,以及如何使用 impl BufRead 来指定函数参数的 trait bound。还介绍了如何使用 std::io::Cursor 来创建用于测试的虚拟文件句柄。

第六章:独一无二

本章介绍了如何使用 Rust 实现一个类似 uniq 的程序,它可以查找文件或标准输入中的唯一行。 本章讨论了如何写入文件或标准输出,如何使用闭包捕获变量,以及如何应用DRY(不要重复自己)原则。本章还介绍了如何使用 Write trait 和 write!writeln! macros,以及如何使用临时文件。 使用 match 表达式来处理输入文件是标准输入还是文件。 本章还使用了 loop 表达式来进行循环读取。 本章还介绍了闭包和函数的区别,以及如何使用闭包来捕获变量。还介绍了如何使用 Box<dyn Write> 来表示可写入类型,并根据命令行参数来决定是写入文件还是标准输出。代码示例:

    let mut print = |num: u64, text: &str| -> Result<()> {
        if num > 0 {
            if args.count {
                write!(out_file, "{num:>4} {text}")?;
            } else {
                write!(out_file, "{text}")?;
            }
        };
        Ok(())
    };

    let mut line = String::new();
    let mut previous = String::new();
    let mut count: u64 = 0;
    loop {
        let bytes = file.read_line(&mut line)?;
        if bytes == 0 {
            break;
        }

        if line.trim_end() != previous.trim_end() {
            print(count, &previous)?;
            previous = line.clone();
            count = 0;
        }

        count += 1;
        line.clear();
    }
    print(count, &previous)?;

第七章:见者有份

本章介绍了如何使用 Rust 实现一个类似 find 的程序,它可以递归搜索目录并查找符合特定条件的文件。本章介绍了如何使用 enum 类型来表示不同的条目类型,例如目录、文件和链接。 还定义了 Config 结构体来表示命令行参数,包括搜索路径,名称模式和条目类型。本章还介绍了如何在文件 glob 和正则表达式中使用点号 (.) 和星号 (*) 的不同含义。本章使用了 Regex::new 函数来创建正则表达式,并介绍了**^$** 的作用,分别用于匹配字符串的开头和结尾。 本章还介绍了如何使用 WalkDir 来递归搜索目录结构,并使用 DirEntry 值来查找文件,目录和链接。 还演示了如何使用链式调用,如 anyfiltermapfilter_map 来处理迭代器。 代码示例:

    for path in &args.paths {
        let entries = WalkDir::new(path)
            .into_iter()
            .filter_map(|e| match e {
                Err(e) => {
                    eprintln!("{e}");
                    None
                }
                Ok(entry) => Some(entry),
            })
            .filter(type_filter)
            .filter(name_filter)
            .map(|entry| entry.path().display().to_string())
            .collect::<Vec<_>>();

        println!("{}", entries.join("\n"));
    }

第八章:削发

本章介绍了如何使用 Rust 实现一个类似 cut 的程序,它可以从文件或标准输入中提取文本。 本章介绍了如何使用单元测试来验证 parse_pos 函数,该函数用于解析文本位置,并支持逗号分隔的列表以及范围。定义了多个测试用例来涵盖不同的情况,包括错误和正确的情况,还使用了 assert!assert_eq! 来验证测试结果。 本章还介绍了如何使用正则表达式来匹配数字和范围,并使用 collect 方法来将结果收集到向量中。 本章介绍了如何使用 Arg::conflicts_with_all 来指定参数之间的冲突关系,例如 fields, byteschars 参数。还使用了 Vec::first 方法来选择向量的第一个元素。 本章还提供了如何实现 extract_charsextract_bytes 函数的示例,用于提取字符串中的字符和字节。还介绍了如何使用 csv crate 来解析和创建分隔文本。

    for filename in &args.files {
        match open(filename) {
            Err(err) => eprintln!("{filename}: {err}"),
            Ok(file) => match &extract {
                Extract::Fields(field_pos) => {
                    let mut reader = ReaderBuilder::new()
                        .delimiter(delimiter)
                        .has_headers(false)
                        .from_reader(file);

                    let mut wtr = WriterBuilder::new()
                        .delimiter(delimiter)
                        .from_writer(io::stdout());

                    for record in reader.records() {
                        wtr.write_record(extract_fields(
                            &record?, field_pos,
                        ))?;
                    }
                }
                Extract::Bytes(byte_pos) => {
                    for line in file.lines() {
                        println!("{}", extract_bytes(&line?, byte_pos));
                    }
                }
                Extract::Chars(char_pos) => {
                    for line in file.lines() {
                        println!("{}", extract_chars(&line?, char_pos));
                    }
                }
            },
        }
    }

第九章:抓手

本章介绍了如何使用 Rust 实现一个类似 grep 的程序,它可以查找匹配给定正则表达式的输入行。本章介绍了如何使用 RegexBuilder 来创建正则表达式,并使用 case_insensitive 方法来进行大小写不敏感的匹配。还演示了如何使用 Result::map_err 来创建错误消息,并介绍了 grep 的正则表达式语法, 包括基本正则表达式和扩展正则表达式。还介绍了如何使用 WalkDir 来递归查找文件,与第七章类似。 本章还介绍了如何使用**BitXor** 按位异或运算符来替换更复杂的逻辑 AND 和 OR 组合。代码示例:

if (pattern.is_match(&line) && !invert_match) || (!pattern.is_match(&line) && invert_match) {
    matches.push(line.clone());
}
// 等价于
if pattern.is_match(&line) ^ !invert_match {
    matches.push(line.clone());
}

第十章:天下为公

本章介绍了如何使用 Rust 实现一个类似 comm 的程序,它可以读取两个文件并报告两个文件中共有和独有的行。 本章介绍了 Config 结构体,其中包含两个输入文件名,以及控制输出列和比较敏感度的布尔值。还演示了如何使用 App::newArg::with_name 定义命令行参数,并使用 required 来指定参数是必需的。 代码示例:

pub fn get_args() -> MyResult<Config> {
    let matches = App::new("commr")
        .arg(Arg::with_name("file1").required(true))
        .arg(Arg::with_name("file2").required(true))
        .arg(Arg::with_name("suppress_col1").short("1"))
        .arg(Arg::with_name("suppress_col2").short("2"))
        .arg(Arg::with_name("suppress_col3").short("3"))
        .arg(Arg::with_name("insensitive").short("i"))
        .arg(Arg::with_name("delimiter").short("d").default_value("\t"))
        .get_matches();
    // ...
}

本章还使用了 if !config.suppress_col1 ,即双重否定在理解上更困难,建议使用更积极的命名方法,例如 do_something

第十一章:尾随其后

本章介绍了如何使用 Rust 实现一个类似 tail 的程序,它可以显示文件的最后几行或最后几个字节。本章介绍了如何使用 enum 类型来表示不同的取值,例如 PlusZeroTakeNum,以及如何使用 i64 类型来存储正数和负数。 本章使用了正则表达式来匹配数字和正负号,并使用了 map_or 方法来获取捕获组的值,以及 format! 方法来格式化字符串。 代码示例:

fn run(args: Args) -> Result<()> {
    let lines = parse_num(args.lines)
        .map_err(|e| anyhow!("illegal line count -- {e}"))?;

    let bytes = args
        .bytes
        .map(parse_num)
        .transpose()
        .map_err(|e| anyhow!("illegal byte count -- {e}"))?;

    let num_files = args.files.len();
    for (file_num, filename) in args.files.iter().enumerate() {
        match File::open(filename) {
            Err(err) => eprintln!("{filename}: {err}"),
            Ok(file) => {
                if !args.quiet && num_files > 1 {
                    println!(
                        "{}==> {filename} <==",
                        if file_num > 0 { "\n" } else { "" },
                    );
                }

                let (total_lines, total_bytes) = count_lines_bytes(filename)?;
                let file = BufReader::new(file);
                if let Some(num_bytes) = &bytes {
                    print_bytes(file, num_bytes, total_bytes)?;
                } else {
                    print_lines(file, &lines, total_lines)?;
                }
            }
        }
    }

    Ok(())
}

本章还介绍了如何使用 i64::wrapping_neg 来计算负数,并介绍了泛型函数和 trait bounds 的用法,例如 Read + Seek。 还使用了 OnceCell 来创建一个静态正则表达式。

第十二章:天选之子

本章介绍了如何使用 Rust 实现一个类似 fortune 的程序,它可以从文本数据库中随机选择引语或琐事。本章介绍了如何使用 strfile 程序来创建索引文件,以及如何使用 head 命令来查看文件的结构。 本章介绍了如何使用 RegexBuilder 创建正则表达式,并定义了 Config 结构体,包括资源列表,模式和随机种子。 还介绍了如何使用 maptranspose 方法来处理 OptionResult 类型,以及如何使用 unwrap 方法来获取 OptionResult 的值。
本章还介绍了如何使用 SliceRandom trait 来随机选择元素,并使用了 SeedableRng trait 来创建可重现的随机数生成器。代码示例:

use rand::prelude::SliceRandom;
use rand::{rngs::StdRng, SeedableRng};
fn pick_fortune(fortunes: &[Fortune], seed: Option<u64>) -> Option<String> {
     let mut rng = match seed {
         Some(seed) => StdRng::seed_from_u64(seed),
         _ => StdRng::from_entropy(),
     };
     fortunes.choose(&mut rng).map(|f| f.text.clone())
}

第十三章:岁月如歌

本章介绍了如何使用 Rust 实现一个类似 cal 的程序,它可以显示日历。 本章使用了 chrono crate 来处理日期和时间,包括 NaiveDate 类型。 本章还介绍了如何使用 App::conflicts_with_all 来指定参数之间的冲突关系,例如 monthyear 参数,以及 show_current_year 参数。 本章还使用了闭包来判断某天是否是今天,以及如何使用 Style::reverse 来高亮显示今天的日期。还使用了 format! 宏来格式化输出。

fn run(args: Args) -> Result<()> {
    let today = Local::now().date_naive();
    let mut month = args.month.map(parse_month).transpose()?;
    let mut year = args.year;

    if args.show_current_year {
        month = None;
        year = Some(today.year());
    } else if month.is_none() && year.is_none() {
        month = Some(today.month());
        year = Some(today.year());
    }
    let year = year.unwrap_or(today.year());

    match month {
        Some(month) => {
            let lines = format_month(year, month, true, today);
            println!("{}", lines.join("\n"));
        }
        None => {
            println!("{year:>32}");
            let months: Vec<_> = (1..=12)
                .map(|month| format_month(year, month, false, today))
                .collect();

            for (i, chunk) in months.chunks(3).enumerate() {
                if let [m1, m2, m3] = chunk {
                    for lines in izip!(m1, m2, m3) {
                        println!("{}{}{}", lines.0, lines.1, lines.2);
                    }
                    if i < 3 {
                        println!();
                    }
                }
            }
        }
    }

    Ok(())
}

第十四章:条分缕析

本章介绍了如何使用 Rust 实现一个类似 ls 的程序,它可以显示文件和目录的信息。本章介绍了如何使用 enum 类型来表示用户、组和其他所有者,以及如何使用位掩码来检查文件权限。还介绍了如何使用 metadata::mode 来获取文件的权限,并介绍了八进制表示法。 本章还介绍了如何使用 Owner 枚举来表示所有者,并使用 impl 块定义了 masks 方法来获取权限掩码。 代码示例:

fn run(args: Args) -> Result<()> {
    let paths = find_files(&args.paths, args.show_hidden)?;
    if args.long {
        println!("{}", format_output(&paths)?);
    } else {
        for path in paths {
            println!("{}", path.display());
        }
    }
    Ok(())
}

本章介绍了如何使用三斜杠 /// 来创建文档注释,以及如何使用 cargo doc 来生成代码文档。还介绍了如何使用 tabular::Table crate 来创建表格输出。

尾声

作者在尾声中鼓励读者继续学习和实践,并尝试使用不同的编程语言来编写和重写这些程序。作者还希望读者通过编写优秀的软件来让世界变得更美好。