Lesson 2 of 12
grep-Style Search Tool
We build seek, a small version of grep: it searches files, directories, or standard input for lines matching a regular expression.
$ seek -i -n todo src
src/main.rs:2: // TODO: parse arguments
src/net.rs:1:// todo: retry on timeout
$ seek -c -i todo src
src/main.rs:1
src/net.rs:1What it supports:
| Option | Meaning |
|---|---|
PATTERN |
a regular expression (with the regex crate) |
PATHS... |
files and directories to search; directories are searched recursively; none means stdin |
-F, --fixed-strings |
treat the pattern as plain text |
-i, --ignore-case |
ignore upper/lower case |
-v, --invert-match |
show lines that don't match |
-n, --line-number |
show line numbers |
-c, --count |
show only the number of matching lines per file |
Like grep, it skips hidden files and binary files, keeps going when a file can't be read, and exits with 0 if something matched, 1 if nothing matched, and 2 if there was an error.
The design follows the advice from Files, I/O & the Command Line: each piece takes its inputs as parameters (impl BufRead, impl Write, paths) so it can be tested without a terminal, and main only connects the pieces to the real world.
The project layout
cargo new seek
cd seek
cargo add clap --features derive
cargo add regex[package]
name = "seek"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"The logic goes into a library crate, src/lib.rs, and src/main.rs stays a few lines long. That split, from Crates, Packages & Workspaces, lets integration tests call the library directly.
seek/
├── Cargo.toml
└── src/
├── main.rs # parse arguments, call seek::run, set the exit code
├── lib.rs # run(): ties the pieces together
├── matcher.rs # step 1
├── search.rs # step 2
└── files.rs # steps 3 and 4While developing, the steps below are self-contained so each can be compiled and tested on its own. Step 6 assembles them into one runnable program.
Step 1: The matcher
The matcher decides whether a line matches. It compiles the pattern once with regex::RegexBuilder; for -F, regex::escape turns the text into a pattern that matches it literally. --invert-match flips the answer, and != with a bool is exactly "flip if invert is true".
use regex::{Regex, RegexBuilder};
pub struct MatchOptions {
pub fixed_string: bool,
pub ignore_case: bool,
pub invert: bool,
}
pub struct Matcher {
regex: Regex,
invert: bool,
}
impl Matcher {
pub fn new(pattern: &str, opts: &MatchOptions) -> Result<Matcher, regex::Error> {
let pattern = if opts.fixed_string { regex::escape(pattern) } else { pattern.to_string() };
let regex = RegexBuilder::new(&pattern).case_insensitive(opts.ignore_case).build()?;
Ok(Matcher { regex, invert: opts.invert })
}
pub fn is_match(&self, line: &str) -> bool {
self.regex.is_match(line) != self.invert
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(fixed_string: bool, ignore_case: bool, invert: bool) -> MatchOptions {
MatchOptions { fixed_string, ignore_case, invert }
}
#[test]
fn patterns_are_regexes_by_default() {
let m = Matcher::new(r"err(or)?\b", &opts(false, false, false)).unwrap();
assert!(m.is_match("error: disk full"));
assert!(m.is_match("err 42"));
assert!(!m.is_match("errand"));
}
#[test]
fn fixed_strings_match_literally() {
let m = Matcher::new("a.b", &opts(true, false, false)).unwrap();
assert!(m.is_match("see a.b here"));
assert!(!m.is_match("axb"));
}
#[test]
fn ignore_case_and_invert() {
let m = Matcher::new("todo", &opts(false, true, true)).unwrap();
assert!(!m.is_match("// TODO: fix"));
assert!(m.is_match("let x = 1;"));
}
#[test]
fn invalid_patterns_are_errors() {
assert!(Matcher::new("(unclosed", &opts(false, false, false)).is_err());
}
}running 4 tests
test tests::fixed_strings_match_literally ... ok
test tests::ignore_case_and_invert ... ok
test tests::invalid_patterns_are_errors ... ok
test tests::patterns_are_regexes_by_default ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sStep 2: Searching one input
search reads lines from any BufRead, writes the matches to any Write, and returns how many lines matched. It takes the matching rule as a closure rather than a Matcher, so it doesn't depend on step 1 at all, and the tests can use a simple contains.
use std::io::{self, BufRead, Write};
pub struct OutputOptions {
pub line_numbers: bool,
pub count_only: bool,
}
/// Writes the lines of `input` that match to `out`, and returns how many matched.
pub fn search(
input: impl BufRead,
label: Option<&str>,
is_match: impl Fn(&str) -> bool,
opts: &OutputOptions,
out: &mut impl Write,
) -> io::Result<usize> {
let mut matches = 0;
for (index, line) in input.lines().enumerate() {
let line = line?;
if !is_match(&line) {
continue;
}
matches += 1;
if opts.count_only {
continue;
}
if let Some(label) = label {
write!(out, "{label}:")?;
}
if opts.line_numbers {
write!(out, "{}:", index + 1)?;
}
writeln!(out, "{line}")?;
}
if opts.count_only {
match label {
Some(label) => writeln!(out, "{label}:{matches}")?,
None => writeln!(out, "{matches}")?,
}
}
Ok(matches)
}
#[cfg(test)]
mod tests {
use super::*;
const CODE: &str = "fn main() {\n // TODO: parse args\n run();\n // TODO: exit code\n}\n";
fn run_search(label: Option<&str>, opts: OutputOptions) -> (usize, String) {
let mut out = Vec::new();
let n = search(CODE.as_bytes(), label, |line| line.contains("TODO"), &opts, &mut out).unwrap();
(n, String::from_utf8(out).unwrap())
}
#[test]
fn prints_matching_lines() {
let (n, out) = run_search(None, OutputOptions { line_numbers: false, count_only: false });
assert_eq!(n, 2);
assert_eq!(out, " // TODO: parse args\n // TODO: exit code\n");
}
#[test]
fn adds_labels_and_line_numbers() {
let (_, out) = run_search(Some("src/main.rs"), OutputOptions { line_numbers: true, count_only: false });
assert_eq!(out, "src/main.rs:2: // TODO: parse args\nsrc/main.rs:4: // TODO: exit code\n");
}
#[test]
fn counts_instead_of_printing() {
let (n, out) = run_search(Some("main.rs"), OutputOptions { line_numbers: true, count_only: true });
assert_eq!((n, out.as_str()), (2, "main.rs:2\n"));
}
}running 3 tests
test tests::adds_labels_and_line_numbers ... ok
test tests::counts_instead_of_printing ... ok
test tests::prints_matching_lines ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sStep 3: Collecting the files
The arguments can be files or directories. collect_files expands directories recursively into the files inside them, sorted so the output is stable, and skips hidden entries like .git. A path that can't be read doesn't stop the search: the problem is recorded and reported at the end, as grep does.
use std::fs;
use std::path::{Path, PathBuf};
fn is_hidden(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
}
/// Expands directories into the files inside them, skipping hidden entries.
pub fn collect_files(paths: &[PathBuf], files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
for path in paths {
match fs::metadata(path) {
Ok(meta) if meta.is_dir() => walk(path, files, problems),
Ok(_) => files.push(path.clone()),
Err(e) => problems.push(format!("{}: {e}", path.display())),
}
}
}
fn walk(dir: &Path, files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
problems.push(format!("{}: {e}", dir.display()));
return;
}
};
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for entry in entries.flatten() {
let is_dir = entry.file_type().is_ok_and(|t| t.is_dir()); // doesn't follow symlinks
if !is_hidden(&entry.path()) {
children.push((entry.path(), is_dir));
}
}
children.sort();
for (path, is_dir) in children {
if is_dir {
walk(&path, files, problems);
} else {
files.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn walks_directories_and_skips_hidden_entries() {
let root = std::env::temp_dir().join(format!("seek-walk-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for file in ["src/main.rs", "src/net/http.rs", "src/.env", ".git/config", "README.md"] {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, "x").unwrap();
}
let (mut files, mut problems) = (Vec::new(), Vec::new());
collect_files(&[root.clone(), root.join("missing.txt")], &mut files, &mut problems);
let relative: Vec<String> = files.iter().map(|f| f.strip_prefix(&root).unwrap().display().to_string()).collect();
assert_eq!(relative, ["README.md", "src/main.rs", "src/net/http.rs"]);
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("missing.txt"));
fs::remove_dir_all(&root).unwrap();
}
}running 1 test
test tests::walks_directories_and_skips_hidden_entries ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sentries.flatten() skips entries that fail to read. file_type() reports a symbolic link as a link rather than following it, so a link pointing back up the tree can't make the walk run forever.
Step 4: Skipping binary files
Searching an image or a compiled binary prints garbage. grep treats a file as binary if its beginning contains a zero byte, which text files practically never do. The same check is a one-liner:
/// Treats data as binary if its first 8 KB contain a zero byte, as grep does.
pub fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8 * 1024).any(|&b| b == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_is_not_binary() {
assert!(!looks_binary(b"fn main() {}\n"));
assert!(!looks_binary("naïve café 🦀".as_bytes()));
assert!(!looks_binary(b""));
}
#[test]
fn a_zero_byte_means_binary() {
assert!(looks_binary(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"));
}
}running 2 tests
test tests::a_zero_byte_means_binary ... ok
test tests::text_is_not_binary ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sFiles that aren't valid UTF-8 but also aren't binary, such as old Latin-1 text, are searched after String::from_utf8_lossy, which replaces the invalid bytes instead of failing.
Step 5: The command-line interface
The options become a clap struct. Short flags can be combined, as in -in, and --help is generated from the doc comments.
use clap::Parser;
use std::path::PathBuf;
/// Search files for lines that match a pattern.
#[derive(Parser, Debug)]
#[command(name = "seek")]
pub struct Args {
/// The regular expression to search for
pub pattern: String,
/// Files or directories to search (default: standard input)
pub paths: Vec<PathBuf>,
/// Treat the pattern as plain text, not a regular expression
#[arg(short = 'F', long)]
pub fixed_strings: bool,
/// Ignore the difference between upper and lower case
#[arg(short, long)]
pub ignore_case: bool,
/// Show the lines that do not match
#[arg(short = 'v', long)]
pub invert_match: bool,
/// Show line numbers
#[arg(short = 'n', long)]
pub line_number: bool,
/// Only print how many lines matched in each file
#[arg(short, long)]
pub count: bool,
}
fn main() {
println!("{:?}\n", Args::parse_from(["seek", "-in", "todo", "src", "tests"]));
if let Err(e) = Args::try_parse_from(["seek", "--help"]) {
print!("{e}");
}
}Args { pattern: "todo", paths: ["src", "tests"], fixed_strings: false, ignore_case: true, invert_match: false, line_number: true, count: false }
Search files for lines that match a pattern
Usage: seek [OPTIONS] <PATTERN> [PATHS]...
Arguments:
<PATTERN> The regular expression to search for
[PATHS]... Files or directories to search (default: standard input)
Options:
-F, --fixed-strings Treat the pattern as plain text, not a regular expression
-i, --ignore-case Ignore the difference between upper and lower case
-v, --invert-match Show the lines that do not match
-n, --line-number Show line numbers
-c, --count Only print how many lines matched in each file
-h, --help Print helpStep 6: The complete program
run connects the pieces: it builds the matcher, searches standard input when no paths are given, and otherwise collects the files, skips binary ones, and searches each. File names are shown when the command names more than one path or a directory, as grep does. At the end it reports problems to the error stream and picks the exit code.
The main below sets up a small demo project and runs several commands against it, with the error stream sent to stdout so this page can show everything.
use clap::Parser;
use regex::{Regex, RegexBuilder};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
// ---- matcher.rs (step 1) ----
pub struct MatchOptions {
pub fixed_string: bool,
pub ignore_case: bool,
pub invert: bool,
}
pub struct Matcher {
regex: Regex,
invert: bool,
}
impl Matcher {
pub fn new(pattern: &str, opts: &MatchOptions) -> Result<Matcher, regex::Error> {
let pattern = if opts.fixed_string { regex::escape(pattern) } else { pattern.to_string() };
let regex = RegexBuilder::new(&pattern).case_insensitive(opts.ignore_case).build()?;
Ok(Matcher { regex, invert: opts.invert })
}
pub fn is_match(&self, line: &str) -> bool {
self.regex.is_match(line) != self.invert
}
}
// ---- search.rs (step 2) ----
pub struct OutputOptions {
pub line_numbers: bool,
pub count_only: bool,
}
pub fn search(
input: impl BufRead,
label: Option<&str>,
is_match: impl Fn(&str) -> bool,
opts: &OutputOptions,
out: &mut impl Write,
) -> io::Result<usize> {
let mut matches = 0;
for (index, line) in input.lines().enumerate() {
let line = line?;
if !is_match(&line) {
continue;
}
matches += 1;
if opts.count_only {
continue;
}
if let Some(label) = label {
write!(out, "{label}:")?;
}
if opts.line_numbers {
write!(out, "{}:", index + 1)?;
}
writeln!(out, "{line}")?;
}
if opts.count_only {
match label {
Some(label) => writeln!(out, "{label}:{matches}")?,
None => writeln!(out, "{matches}")?,
}
}
Ok(matches)
}
// ---- files.rs (steps 3 and 4) ----
fn is_hidden(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
}
pub fn collect_files(paths: &[PathBuf], files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
for path in paths {
match fs::metadata(path) {
Ok(meta) if meta.is_dir() => walk(path, files, problems),
Ok(_) => files.push(path.clone()),
Err(e) => problems.push(format!("{}: {e}", path.display())),
}
}
}
fn walk(dir: &Path, files: &mut Vec<PathBuf>, problems: &mut Vec<String>) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
problems.push(format!("{}: {e}", dir.display()));
return;
}
};
let mut children: Vec<(PathBuf, bool)> = Vec::new();
for entry in entries.flatten() {
let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
if !is_hidden(&entry.path()) {
children.push((entry.path(), is_dir));
}
}
children.sort();
for (path, is_dir) in children {
if is_dir {
walk(&path, files, problems);
} else {
files.push(path);
}
}
}
pub fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8 * 1024).any(|&b| b == 0)
}
// ---- lib.rs: the command-line interface (step 5) and run ----
/// Search files for lines that match a pattern.
#[derive(Parser, Debug)]
#[command(name = "seek")]
pub struct Args {
/// The regular expression to search for
pub pattern: String,
/// Files or directories to search (default: standard input)
pub paths: Vec<PathBuf>,
/// Treat the pattern as plain text, not a regular expression
#[arg(short = 'F', long)]
pub fixed_strings: bool,
/// Ignore the difference between upper and lower case
#[arg(short, long)]
pub ignore_case: bool,
/// Show the lines that do not match
#[arg(short = 'v', long)]
pub invert_match: bool,
/// Show line numbers
#[arg(short = 'n', long)]
pub line_number: bool,
/// Only print how many lines matched in each file
#[arg(short, long)]
pub count: bool,
}
/// Runs a search and returns the exit code: 0 = found, 1 = not found, 2 = error.
pub fn run(args: &Args, out: &mut impl Write, err: &mut impl Write) -> u8 {
let match_opts = MatchOptions { fixed_string: args.fixed_strings, ignore_case: args.ignore_case, invert: args.invert_match };
let matcher = match Matcher::new(&args.pattern, &match_opts) {
Ok(m) => m,
Err(e) => {
let _ = writeln!(err, "seek: invalid pattern: {e}");
return 2;
}
};
let is_match = |line: &str| matcher.is_match(line);
let output = OutputOptions { line_numbers: args.line_number, count_only: args.count };
// No paths: search standard input, like grep.
if args.paths.is_empty() {
return match search(io::stdin().lock(), None, &is_match, &output, out) {
Ok(0) => 1,
Ok(_) => 0,
Err(e) => {
let _ = writeln!(err, "seek: {e}");
2
}
};
}
let (mut files, mut problems) = (Vec::new(), Vec::new());
collect_files(&args.paths, &mut files, &mut problems);
let show_names = args.paths.len() > 1 || args.paths.iter().any(|p| p.is_dir());
let mut total = 0;
for path in &files {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(e) => {
problems.push(format!("{}: {e}", path.display()));
continue;
}
};
if looks_binary(&bytes) {
continue;
}
let text = String::from_utf8_lossy(&bytes);
let label = path.display().to_string();
match search(text.as_bytes(), show_names.then_some(label.as_str()), &is_match, &output, out) {
Ok(n) => total += n,
Err(e) => problems.push(format!("{label}: {e}")),
}
}
for problem in &problems {
let _ = writeln!(err, "seek: {problem}");
}
match (problems.is_empty(), total) {
(false, _) => 2,
(true, 0) => 1,
(true, _) => 0,
}
}
// ---- main.rs, set up for this page ----
fn main() -> io::Result<()> {
let _ = fs::remove_dir_all("demo");
for (file, text) in [
("demo/src/main.rs", "fn main() {\n // TODO: parse arguments\n run();\n}\n"),
("demo/src/net.rs", "// todo: retry on timeout\npub fn fetch() {}\n"),
("demo/README.md", "# Demo\nBuild with `cargo build`.\n"),
("demo/.git/notes", "TODO: hidden, so never searched\n"),
] {
fs::create_dir_all(Path::new(file).parent().unwrap())?;
fs::write(file, text)?;
}
fs::write("demo/logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR TODO")?;
let mut stdout = io::stdout();
for command in [
"seek TODO demo",
"seek -in todo demo/src",
"seek -ci todo demo",
"seek -F `cargo demo/README.md",
"seek -v fn demo/src/main.rs",
"seek xyz demo/README.md",
"seek -i todo demo/missing.rs demo/src/net.rs",
"seek (unclosed demo",
] {
println!("$ {command}");
// A shell would also handle quotes; splitting on spaces is enough here.
let code = match Args::try_parse_from(command.split_whitespace()) {
Ok(args) => run(&args, &mut stdout, &mut io::stdout()),
Err(e) => {
print!("{e}");
2
}
};
println!("(exit code {code})\n");
}
Ok(())
}$ seek TODO demo
demo/src/main.rs: // TODO: parse arguments
(exit code 0)
$ seek -in todo demo/src
demo/src/main.rs:2: // TODO: parse arguments
demo/src/net.rs:1:// todo: retry on timeout
(exit code 0)
$ seek -ci todo demo
demo/README.md:0
demo/src/main.rs:1
demo/src/net.rs:1
(exit code 0)
$ seek -F `cargo demo/README.md
Build with `cargo build`.
(exit code 0)
$ seek -v fn demo/src/main.rs
// TODO: parse arguments
run();
}
(exit code 0)
$ seek xyz demo/README.md
(exit code 1)
$ seek -i todo demo/missing.rs demo/src/net.rs
demo/src/net.rs:// todo: retry on timeout
seek: demo/missing.rs: No such file or directory (os error 2)
(exit code 2)
$ seek (unclosed demo
seek: invalid pattern: regex parse error:
(unclosed
^
error: unclosed group
(exit code 2)The first command finds TODO in main.rs only: net.rs has a lowercase todo, the hidden .git directory is skipped, and so is logo.png, which contains TODO but is binary. With a missing file, the other file is still searched, and the exit code is 2 because of the error.
Step 7: The real main
In the actual project, main.rs is the only part that touches the real process: it parses env::args(), passes the real streams, and turns the code into an ExitCode. Everything it calls is in the library and already tested.
use clap::Parser;
use std::io;
use std::process::ExitCode;
fn main() -> ExitCode {
let args = seek::Args::parse(); // prints help or an error and exits with code 2 on bad arguments
let code = seek::run(&args, &mut io::stdout().lock(), &mut io::stderr().lock());
ExitCode::from(code)
}cargo run -- -in todo src
cargo install --path . # installs `seek` into ~/.cargo/bin
seek -c fn srcAn integration test in tests/cli.rs can call seek::run with a Vec<u8> for each stream, exactly as the demo main above does, and check both the output and the exit code.
Ideas for extending it
- Colors: highlight the match with ANSI colors when
io::stdout().is_terminal(), using a crate such asanstreamorowo-colors. - Context lines:
-A 2and-B 2to print lines around each match, keeping the previous lines in aVecDeque. .gitignoresupport: replacecollect_fileswith theignorecrate, which ripgrep uses.- Speed: search several files at once with
rayon, and search bytes instead ofStrings withregex::bytes.
Summary
- The logic lives in small functions that take
impl BufRead,impl Write, closures, and paths as parameters, each with its own unit tests. - Errors on individual files are collected and reported without stopping the run; the exit code tells scripts what happened.
maindoes nothing but connect the library toenv::args(), the real streams, and the exit code.
Next: Expression Calculator.
