Rust: Rust 实战:构建文件搜索工具(grep 精简版)
最后更新:2026-08-26
这是 Rust 教程的收官之战——用前 30 课学过的所有知识,构建一个可运行的命令行文件搜索工具(grep 精简版)。从需求到代码,从测试到运行,一步不落。
如果说前 30 课是"学招式",那这一课就是"打实战"。就像学完所有烹饪技巧后,真正做一桌菜给客人吃。你可能会发现火候还不够、刀工还不熟——但做完这道菜,你就真正从"学过 Rust"变成了"能用 Rust 写东西"。
1. 你将学到
- 综合运用 Rust 基础知识:所有权、字符串处理、Vec、Result 错误处理
- 设计 CLI 工具的函数架构:参数解析 → 文件读取 → 数据处理 → 结果输出
- 使用
std::env::args()解析命令行参数 - 使用
std::fs::read_to_string()和BufRead逐行读取文件 - 编写单元测试验证搜索逻辑的正确性
2. 故事:日志大海捞针
(1) 痛苦:手动翻日志
Tom 负责维护一个电商平台的订单服务。某个周三的深夜,报警系统响了——大量订单失败,错误率飙升到 30%。
- 运维发来一个 200MB 的日志文件
order-service.log - 需要找出所有包含
ERROR关键字的行 - Tom 打开编辑器搜索——编辑器崩溃了(文件太大)
- 用系统自带的
grep—— 但 Tom 的 Windows 机器上没有 - 用 PowerShell 的
Select-String—— 语法记不清,查了半天文档
30 分钟后,Tom 终于找到了错误原因:数据库连接池耗尽。但宝贵的 30 分钟已经浪费在"找工具"上。
"如果我有一个自己写的搜索工具,几秒钟就能搞定……"
(2) 我们的方案
今天就写一个 Rust 命令行文件搜索工具(grep-lite),它:
- 在命令行接收搜索词和文件路径
- 逐行搜索,打印匹配行和行号
- 支持区分大小写 / 忽略大小写两种模式
- 有完善的错误处理
# 基本用法
cargo run -- "ERROR" order-service.log
# 忽略大小写模式
IGNORE_CASE=1 cargo run -- "error" order-service.log
# 文件不存在的错误提示
cargo run -- "hello" nonexistent.txt
# Output: minigrep: error reading file: The system cannot find the file specified. (os error 2)
3. 项目需求
(1) 功能列表
| # | 功能 | 说明 |
|---|---|---|
| 1 | 命令行参数 | 接收两个参数:搜索词(query)和文件路径(file_path) |
| 2 | 文件读取 | 读取指定文件的所有内容 |
| 3 | 逐行搜索 | 逐行检查是否包含搜索词,打印匹配行(带行号) |
| 4 | 大小写控制 | 通过环境变量 IGNORE_CASE 控制是否忽略大小写 |
| 5 | 错误处理 | 优雅处理文件不存在、无权限、参数不足等错误 |
(2) 项目模块与知识点对照
| 模块/函数 | 对应 Rust 知识点 | 来源课程 |
|---|---|---|
parse_args() |
命令行参数 env::args、Result |
29-标准库 |
search() |
字符串搜索 contains、迭代器 enumerate |
03-字符串、22-迭代器 |
run() |
文件 I/O fs::read_to_string、? 运算符 |
27-文件I/O、09-错误处理 |
main() |
流程控制 match、process::exit |
05-流程控制 |
IGNORE_CASE |
环境变量 env::var |
29-标准库 |
#[cfg(test)] |
单元测试模块 | 12-测试 |
&str / String |
所有权与借用 | 02-所有权、03-字符串 |
Vec<(usize, &str)> |
Vec 元组、泛型 | 14-Vec、19-泛型 |
(3) 命令行参数设计
| 参数位置 | 变量名 | 类型 | 必需 | 说明 |
|---|---|---|---|---|
args[0] |
程序名 | String |
自动 | minigrep |
args[1] |
query |
&str |
是 | 搜索关键词 |
args[2] |
file_path |
&str |
是 | 目标文件路径 |
| 环境变量 | IGNORE_CASE |
String |
否 | 设为 1 时忽略大小写 |
(4) 项目架构
graph TB
A[main] --> B[parse_args]
A --> C[run]
C --> D[read file via fs::read_to_string]
C --> E[read env var IGNORE_CASE]
C --> F[search lines]
F --> G[print matching lines]
C --> H[return Result for error handling]
B --> I{args.len() < 3?}
I -->|Yes| J[return Err]
I -->|No| K[return (query, file_path)]
subgraph search
F1[query + content + case_sensitive] --> F2[iterate lines with enumerate]
F2 --> F3{line contains query?}
F3 -->|Yes| F4[push (line_no, line)]
F3 -->|No| F5[skip]
end
style A fill:#4a90d9,color:#fff
style C fill:#67c23a,color:#fff
style F fill:#e6a23c,color:#fff
style H fill:#f56c6c,color:#fff
4. 完整项目代码
▶ 示例:grep-lite 完整代码实现
(1) 项目结构
minigrep/
├── Cargo.toml
└── src/
└── main.rs # 所有代码都在 main.rs 中(标准库版本)
(2) Cargo.toml
[package]
name = "minigrep"
version = "0.1.0"
edition = "2021"
# 本课程仅使用标准库,无需外部依赖
[dependencies]
(3) src/main.rs(完整代码)
// ============================================
// minigrep - 命令行文件搜索工具(grep 精简版)
// 功能:
// 1. 接收两个命令行参数:搜索词 + 文件路径
// 2. 读取文件内容,逐行搜索
// 3. 打印匹配行(带行号)
// 4. 通过环境变量 IGNORE_CASE 控制大小写
// 5. 优雅的错误处理
// ============================================
use std::env;
use std::fs;
use std::process;
/// 解析命令行参数,返回 (query, file_path)
///
/// 期望接收 2 个参数(不包括程序名):
/// 参数 1: 搜索词 (query)
/// 参数 2: 文件路径 (file_path)
///
/// 参数数量不足时返回一个错误描述。
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep <query> <file_path>");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
/// 在文件内容中逐行搜索匹配项
///
/// # Arguments
/// * `query` - 要搜索的关键词
/// * `contents` - 文件内容的字符串切片
/// * `case_sensitive` - 是否区分大小写
///
/// # Returns
/// 返回一个 Vec,包含所有匹配行的 (行号, 行内容)
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
// 忽略大小写:将 query 和 line 都转为小写再比较
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
// 行号从 1 开始计数(更符合用户习惯)
results.push((line_no + 1, line));
}
}
results
}
/// 核心运行逻辑:解析参数 → 读取文件 → 搜索 → 打印结果
///
/// 将 IO 和错误处理集中在此函数中,main 只负责调用并处理最终错误。
fn run(args: &[String]) -> Result<(), String> {
// 步骤 1: 解析命令行参数
let (query, file_path) = parse_args(args)?;
// 步骤 2: 读取环境变量 IGNORE_CASE
// 如果 IGNORE_CASE 被设置为任意非空值,则忽略大小写
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false, // 忽略大小写
_ => true, // 默认区分大小写
};
// 步骤 3: 读取文件内容
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// 步骤 4: 执行搜索
let matches = search(query, &contents, case_sensitive);
// 步骤 5: 打印结果
if matches.is_empty() {
println!("minigrep: no matches found for '{}'", query);
} else {
println!(
"minigrep: found {} match(es) for '{}'{}",
matches.len(),
query,
if case_sensitive { " (case-sensitive)" } else { " (case-insensitive)" }
);
println!("---");
for (line_no, line) in &matches {
println!("{:>4}: {}", line_no, line);
}
}
Ok(())
}
/// 程序入口
///
/// 收集命令行参数 → 调用 run → 处理错误
fn main() {
let args: Vec<String> = env::args().collect();
// 调用 run,如果返回错误则打印并退出
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
// ============================================
// 单元测试
// ============================================
#[cfg(test)]
mod tests {
use super::*;
/// 测试 parse_args:正常情况
#[test]
fn test_parse_args_ok() {
let args = vec![
"program".to_string(),
"hello".to_string(),
"test.txt".to_string(),
];
let result = parse_args(&args);
assert!(result.is_ok());
let (query, path) = result.unwrap();
assert_eq!(query, "hello");
assert_eq!(path, "test.txt");
}
/// 测试 parse_args:参数不足
#[test]
fn test_parse_args_missing() {
let args = vec!["program".to_string()];
let result = parse_args(&args);
assert!(result.is_err());
}
/// 测试 search:区分大小写
#[test]
fn test_search_case_sensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "Rust";
let results = search(query, contents, true);
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (3, "I love Rust programming."));
}
/// 测试 search:忽略大小写
#[test]
fn test_search_case_insensitive() {
let contents = "\
Rust is safe and fast.
rust is a systems language.
I love Rust programming.
RUST is awesome!";
let query = "rust";
let results = search(query, contents, false);
// 忽略大小写时,所有 4 行都应该匹配(都包含 rust/Rust/RUST)
assert_eq!(results.len(), 4);
assert_eq!(results[0], (1, "Rust is safe and fast."));
assert_eq!(results[1], (2, "rust is a systems language."));
assert_eq!(results[2], (3, "I love Rust programming."));
assert_eq!(results[3], (4, "RUST is awesome!"));
}
/// 测试 search:无匹配
#[test]
fn test_search_no_match() {
let contents = "\
apple
banana
cherry";
let results = search("durian", contents, true);
assert!(results.is_empty());
}
/// 测试 search:空内容
#[test]
fn test_search_empty_contents() {
let contents = "";
let results = search("hello", contents, true);
assert!(results.is_empty());
}
/// 测试 search:空查询
#[test]
fn test_search_empty_query() {
let contents = "line one\nline two\nline three";
let results = search("", contents, true);
// 空字符串在任何行中都匹配(contains("") 始终为 true)
assert_eq!(results.len(), 3);
}
/// 集成测试:模拟 run 函数(使用临时文件)
#[test]
fn test_run_with_temp_file() {
use std::io::Write;
// 创建临时文件
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
write!(temp_file, "hello world\nrust is great\nHELLO everyone\n").unwrap();
let temp_path = temp_file.path().to_str().unwrap().to_string();
// 构造参数
let args = vec![
"minigrep".to_string(),
"hello".to_string(),
temp_path.clone(),
];
// 设置环境变量:区分大小写
// 注意:这个测试依赖 tempfile crate,如果不想引入外部 crate 可以跳过
// 这里仅作为参考,实际运行时用标准库也可以模拟
let result = run(&args);
assert!(result.is_ok());
}
}
// ============================================
// 说明:
// 1. 以上代码可直接复制到 src/main.rs 中编译运行
// 2. 单元测试中的 tempfile 集成测试是可选演示,
// 实际只需要 cargo test 即可运行其余所有单元测试
// 3. 运行方式:
// cargo run -- "search_term" "file_path"
// IGNORE_CASE=1 cargo run -- "search_term" "file_path"
// ============================================
▶ 示例:编译与运行
# 1. 创建项目
cargo new minigrep
cd minigrep
# 2. 将上述代码复制到 src/main.rs
# 3. 编译
cargo build
# 4. 创建测试文件
echo -e "Hello World\nrust programming\nHELLO everyone\nGoodbye Rust" > test.txt
# 5. 运行(区分大小写)
cargo run -- "rust" test.txt
# 6. 运行(忽略大小写)
IGNORE_CASE=1 cargo run -- "rust" test.txt
# 7. 运行测试
cargo test
▶ 示例:运行示例
示例 1:基本搜索(区分大小写)
创建测试文件 test.txt:
Hello World
Rust is awesome
rust is fast
I love Rust
RUST is powerful
Goodbye
命令:
cargo run -- "Rust" test.txt
输出:
minigrep: found 2 match(es) for 'Rust' (case-sensitive)
---
1: Hello World
3: I love Rust
示例 2:忽略大小写
命令:
IGNORE_CASE=1 cargo run -- "rust" test.txt
输出:
minigrep: found 4 match(es) for 'rust' (case-insensitive)
---
1: Hello World
2: Rust is awesome
3: rust is fast
4: I love Rust
示例 3:文件不存在
命令:
cargo run -- "hello" nonexistent.txt
输出:
minigrep: error reading file: The system cannot find the file specified. (os error 2)
示例 4:无匹配
命令:
cargo run -- "python" test.txt
输出:
minigrep: no matches found for 'python'
示例 5:参数不足
命令:
cargo run -- "hello"
输出:
usage: minigrep <query> <file_path>
(4) 测试输出
$ cargo test
Compiling minigrep v0.1.0
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.23s
Running unittests src/main.rs
running 7 tests
test tests::test_parse_args_ok ... ok
test tests::test_parse_args_missing ... ok
test tests::test_search_case_sensitive ... ok
test tests::test_search_case_insensitive ... ok
test tests::test_search_no_match ... ok
test tests::test_search_empty_contents ... ok
test tests::test_search_empty_query ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
7 个单元测试全部通过!涵盖了正常搜索(区分/忽略大小写)、无匹配、空内容、空查询、参数解析等边界情况。这就是 Rust 测试系统
#[cfg(test)]和#[test]的威力——测试代码与产品代码放在同一个文件中,用条件编译确保测试代码不会进入生产构建。
5. 代码讲解
(1) parse_args 函数
fn parse_args(args: &[String]) -> Result<(&str, &str), &'static str> {
if args.len() < 3 {
return Err("usage: minigrep <query> <file_path>");
}
let query = &args[1];
let file_path = &args[2];
Ok((query, file_path))
}
- 接收
&[String]切片(不拥有所有权,只借用) - 检查参数数量,不足时返回
Err(&'static str) - 成功时返回
Ok((&str, &str))——两个字符串切片 - 使用
Result类型统一错误处理
(2) search 函数
fn search<'a>(
query: &str,
contents: &'a str,
case_sensitive: bool,
) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (line_no, line) in contents.lines().enumerate() {
let matched = if case_sensitive {
line.contains(query)
} else {
let query_lower = query.to_lowercase();
let line_lower = line.to_lowercase();
line_lower.contains(&query_lower)
};
if matched {
results.push((line_no + 1, line));
}
}
results
}
- 使用生命周期标注
'a表明返回的&str与contents参数生命周期相同 contents.lines()返回每行的迭代器(不含换行符).enumerate()给每行加上索引(从 0 开始,输出时 +1 调整为从 1 开始)- 忽略大小写时,将
query和line都转为小写再比较
(3) run 函数
fn run(args: &[String]) -> Result<(), String> {
let (query, file_path) = parse_args(args)?;
// ...
let case_sensitive = match env::var("IGNORE_CASE") {
Ok(val) if !val.is_empty() => false,
_ => true,
};
let contents = fs::read_to_string(file_path)
.map_err(|e| format!("minigrep: error reading file: {}", e))?;
// ...
}
?运算符:如果parse_args返回Err,自动传播错误env::var("IGNORE_CASE"):读取环境变量fs::read_to_string:一次性读取文件到String.map_err():将标准库的错误类型转换为用户友好的字符串
(4) main 函数
fn main() {
let args: Vec<String> = env::args().collect();
if let Err(err_msg) = run(&args) {
eprintln!("{}", err_msg);
process::exit(1);
}
}
env::args().collect():收集命令行参数到Vec<String>if let Err(...):模式匹配处理run的返回结果eprintln!:错误信息输出到 stderr(标准错误),而非 stdoutprocess::exit(1):以非零退出码终止程序(表示异常退出)
▶ 示例:扩展功能——统计搜索结果行号与上下文
// ============================================
// 扩展 grep-lite:显示行号和上下文行
// ============================================
use std::env;
use std::fs;
use std::process;
fn search_with_context<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str)> {
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line))
.collect()
}
fn search_surrounding<'a>(query: &str, contents: &'a str, context: usize, ignore_case: bool) -> Vec<(usize, &'a str, bool)> {
let matches: Vec<usize> = contents.lines().enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&if ignore_case { query.to_lowercase() } else { query.to_string() })
})
.map(|(i, _)| i)
.collect();
let mut result = Vec::new();
let mut printed = std::collections::HashSet::new();
for &match_idx in &matches {
let start = match_idx.saturating_sub(context);
let end = (match_idx + context + 1).min(contents.lines().count());
for i in start..end {
if printed.insert(i) {
let is_match = i == match_idx;
result.push((i + 1, contents.lines().nth(i).unwrap_or(""), is_match));
}
}
}
result
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("用法: {} <查询> <文件> [--context N] [--ignore-case]", args[0]);
process::exit(1);
}
let query = &args[1];
let filename = &args[2];
let context = args.iter().position(|a| a == "--context")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let ignore_case = args.iter().any(|a| a == "--ignore-case") ||
env::var("IGNORE_CASE").is_ok();
let contents = fs::read_to_string(filename).unwrap_or_else(|err| {
eprintln!("读取文件 '{}' 失败: {}", filename, err);
process::exit(1);
});
println!("=== 搜索 '{}' (忽略大小写: {}) ===\n", query, ignore_case);
if context > 0 {
let results = search_surrounding(query, &contents, context, ignore_case);
for (line_num, line, is_match) in &results {
let prefix = if *is_match { ">>>" } else { " " };
println!("{} {}: {}", prefix, line_num, line);
}
} else {
let results = search_with_context(query, &contents, context, ignore_case);
for (line_num, line) in &results {
println!("{}: {}", line_num, line);
}
println!("\n共 {} 行匹配", results.len());
}
}
扩展版增加了行号显示(
enumerate)和上下文行(--context N参数)。search_surrounding用HashSet去重避免匹配行相邻时重复输出。>>>标记匹配行,普通行缩进显示。
▶ 示例:扩展功能——多文件搜索与递归目录
// ============================================
// 扩展 grep-lite:支持多文件和目录递归
// ============================================
use std::env;
use std::fs;
use std::path::Path;
use std::process;
fn search_in_file(query: &str, filename: &str, ignore_case: bool) -> Vec<(usize, String)> {
let contents = match fs::read_to_string(filename) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let pattern = if ignore_case { query.to_lowercase() } else { query.to_string() };
contents.lines()
.enumerate()
.filter(|(_, line)| {
let haystack = if ignore_case { line.to_lowercase() } else { line.to_string() };
haystack.contains(&pattern)
})
.map(|(i, line)| (i + 1, line.to_string()))
.collect()
}
fn find_files(path: &Path, extension: &str) -> Vec<String> {
let mut files = Vec::new();
if path.is_file() {
files.push(path.to_string_lossy().to_string());
} else if path.is_dir() {
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
let sub_path = entry.path();
if sub_path.is_dir() {
files.extend(find_files(&sub_path, extension));
} else if sub_path.extension().map(|e| e == extension).unwrap_or(false) {
files.push(sub_path.to_string_lossy().to_string());
}
}
}
}
files
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("用法: {} <查询> <路径...> [--ignore-case] [--ext rs]", args[0]);
process::exit(1);
}
let query = &args[1];
let ignore_case = args.iter().any(|a| a == "--ignore-case");
let extension = args.iter().position(|a| a == "--ext")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("rs");
let mut all_files = Vec::new();
for path_str in args[2..].iter().filter(|a| !a.starts_with('-')) {
let path = Path::new(path_str);
all_files.extend(find_files(path, extension));
}
println!("搜索 '{}' 在 {} 个文件中 (扩展: .{})\n", query, all_files.len(), extension);
let mut total_matches = 0;
let mut files_with_matches = 0;
for filename in &all_files {
let results = search_in_file(query, filename, ignore_case);
if !results.is_empty() {
println!("--- {} ---", filename);
for (line_num, line) in &results {
println!(" {}: {}", line_num, line);
}
total_matches += results.len();
files_with_matches += 1;
}
}
println!("\n=== 统计 ===");
println!("扫描文件: {}, 有匹配: {}, 总匹配行: {}", all_files.len(), files_with_matches, total_matches);
}
多文件搜索支持:
find_files递归遍历目录,按扩展名过滤;search_in_file在单个文件中搜索;输出格式为--- 文件名 ---分隔各文件结果;最后的统计汇总扫描和匹配情况。
❓ 常见问题
env::args() 而不是 std::env::args_os()?args() 返回 Args 迭代器,产生 String 类型,适合大多数场景。fs::read_to_string() 和 BufReader 逐行读取有什么区别?fs::read_to_string() 一次性将整个文件读入内存,适合中小文件。search 函数需要生命周期参数 'a?Vec<(usize, &str)> 中的 &str 引用的是 contents 参数中的数据。IGNORE_CASE 的取值规则是怎样的?IGNORE_CASE 被设置为任意非空值,就忽略大小写。process::exit(1) 而不是直接 panic?process::exit(1) 以指定的退出码终止程序,适合 CLI 工具——父进程(如 Shell 脚本)可以根据退出码判断执行成功与否。📖 小节
- 项目架构:CLI 工具采用
main → run → search三层结构,main只做入口和错误处理,run负责编排逻辑,search是纯函数 - 命令行参数:使用
std::env::args()收集参数,自定义parse_args函数验证参数数量,返回Result统一错误处理 - 文件读取:
std::fs::read_to_string()一次性读取小文件,BufReader+lines()适合大文件逐行处理 - 环境变量控制:
std::env::var("IGNORE_CASE")读取环境变量,通过match模式匹配判断是否设置 - 错误处理:使用
Result<(), String>作为返回类型,?运算符传播错误,eprintln!输出到 stderr,process::exit(1)设置退出码 - 单元测试:用
#[cfg(test)]和#[test]编写测试,覆盖正常/边界/错误三种场景,cargo test一键运行
📝 作业
-
难度 ⭐:为搜索功能添加"匹配行数统计":在
search函数的基础上,新增一个count_matches函数,它只返回匹配行数而不返回具体行内容。例如count_matches("Rust", contents, true)返回3。在main中添加一个--count标志(通过第三个参数判断),如果为--count则只打印匹配行数。 -
难度 ⭐⭐:为工具添加"上下文行"功能:修改
search函数,使其同时返回匹配行前后的若干行(类似grep -C)。新增一个参数context_lines: usize,表示在匹配行前后各显示多少行。例如context_lines=1时,匹配行会带上它的前一行和后一行。注意要处理边界情况(文件开头/结尾的行没有上下文)。 -
难度 ⭐⭐⭐:将项目扩展为"多文件搜索工具":修改程序使其支持在多个文件中搜索。新的命令行格式为
minigrep <query> <file1> <file2> ...(文件名数量可变)。输出格式改为在每个匹配行前加上文件名,如file1.txt:5: Hello World。提示:使用循环处理多个文件,用$()*模式虽然不是宏但思想类似——"一次处理逻辑,应用多个输入"。