Build a grep CLI app in Rust
- Introduction
- Building the main function
- Reading piped input from stdin mode 1
- Reading a file and searching thru it mode 2
- Wrapping up
- Build with Naz video series on developerlife.com YouTube channel
Introduction #
This article illustrates how we can build a CLI app in Rust that is a very basic
implementation of grep. This app will have 2 modes of operation: piping lines in from
stdin
and searching them, and reading a file and searching thru it. The output of the
program will be lines that match the search term w/ the term being highlighted. Topics
like stdin
manipulation in a terminal, detecting when the terminal is in tty
mode vs
piped
mode, doing simple file I/O, creating non consuming builders, and managing
Result
s, along w/ building a simple CLI interface are covered in this article.
The app we are building is very simple by design so we can get a handle on command line arguments, stdin, stdout, and piping.
๐ The source code for the finished app named
rust-grep-cli
can be found here.
For more information on general Rust type system design (functional approach rather than object oriented), please take a look at this paper by Will Crichton demonstrating Typed Design Patterns with Rust.
Building the main function #
The first thing we need to do is build the main function. This is where we will be routing
our app to be in mode 1 or mode 2. We have to detect whether the terminal is in tty
mode
or piped
mode. In order to do this we have to use a crate
is_terminal
. Helper methods based on this crate
are available in the r3bl_tuify
crate, which is
what we will be using in our app.
Hereโs the main
function, w/ the most important thing being the call to
is_stdin_piped()
. This uses the r3bl_rs_utils
crate which itself uses atty
to
determine whether the terminal is currently accepting input piped to stdin
.
๐ You can find the source for
is_stdin_piped()
(inr3bl_tuify
crate) interm.rs
. You will also find these other functions that are related:is_tty()
,is_stdout_piped
.๐ Please star the
r3bl-open-core
repo on github if you like it ๐. If you would like to contribute to it, please click here.
fn main() {
let args = args().collect::<Vec<String>>();
with(run(args), |it| match it {
Ok(()) => exit(0),
Err(err) => {
eprintln!("{}: {}", style_error("Problem encountered"), err);
exit(1);
}
});
}
fn run(args: Vec<String>) -> Result<(), Box<dyn Error>> {
match is_stdin_piped() {
true => piped_grep(PipedGrepOptionsBuilder::parse(args)?)?,
false => grep(GrepOptionsBuilder::parse(args)?)?,
}
Ok(())
}
Please note the following things in the run()
function:
- It returns a
Result
and all the functions that it calls also returnResult
:
PipedGrepOptionsBuilder::parse()
piped_grep()
GrepOptionsBuilder::parse()
grep()
- It uses the
?
operator to unwrap theResult
(produced by each of the four functions that it calls) to propagate anyError
that may have been produced to the caller (main()
function).
Reading piped input from stdin (mode 1) #
The arguments that our app will take are different for each mode. In this mode, we need to know what our search term is, and whether to apply case-sensitive or insensitive search. We donโt need a file path like we do in mode 2.
We want our user to be able to run our app like this:
$ cat README.md | cargo run SEARCH_TERM CASE_SENSITIVE
- The left hand side of the pipe
|
is just a call tocat
command w/ a file nameREADME.md
, but you can replace it w/ whatever command pipes its output into the terminal, likeecho
, etc. - The user will have to supply the
SEARCH_TERM
andCASE_SENSITIVE
arguments.- Note these are just placeholders for the actual arguments that will be passed in the terminal.
- Another note is that the
CASE_SENSITIVE
argument can be any string, we will just look for the existence of a 2nd argument and if it exists, we will assume that the user wants case-sensitive search, otherwise we default to case-insensitive search.
The main()
function will provide the std::env::args
that contain the arguments the user passes
in the terminal, as an iterator, which we turn into a Vec<String>
.
This Vec<String>
will be passed to the PipedGrepOptionsBuilder
which will parse the arguments
into this struct.
pub struct PipedGrepOptions {
pub search: String,
pub case_sensitive: bool,
}
โก
PipedGrepOptionsBuilder
is a non-consuming builder, which makes it a little easier to work w/ (than a consuming builder).
Once these steps are complete, now we can step into the piped_grep()
function that actually parses
each line from stdin
and checks to see whether it contains the search term. Hereโs the full
source file.
stdin()
.lock()
.lines()
.filter(|line| {
let line = line.as_ref().unwrap();
if options.case_sensitive {
line.contains(&options.search)
} else {
line.to_lowercase().contains(&options.search.to_lowercase())
}
})
.map(|line| line.unwrap())
.for_each(|line| {
let from = &options.search;
let to = format!("{}", style_primary(&options.search));
let line = line.replace(from, &to);
println!("{}", line);
});
Letโs break down each line of the above code:
- We first get the
stdin
handle by callingstd::io::stdin()
. And we call.lock()
on it to get aStdinLock
handle which implements theBufRead
trait. This allows us to calllines()
on it in order to get an iterator over every single line that is read fromstdin
. - We then call
.filter()
on thelines()
iterator to filter out any lines that donโt contain the search term. And this is also where we implement case-sensitive vs case-insensitive search. - We then call
.map()
on thefilter()
iterator to map each line to aString
fromResult
by unwrapping it. - We then call
.for_each()
on themap()
iterator to iterate over each line and find every instance of the search term and replace it with the formatted version. Then print it to the console.
Next, we will look at implementing mode 2.
Reading a file and searching thru it (mode 2) #
The arguments that our app will take in this mode are the search term, the file path, and whether to apply case-sensitive or case-insensitive search. These are different than the arguments for mode 1.
We want our use to be able to run our app like this:
cargo run SEARCH_TERM FILENAME CASE_SENSITIVE
- The first argument is the search term.
- The second argument is the file path.
- The third argument is whether to apply case-sensitive or case-insensitive search.
The main()
function will provide the std::env::args
that contain the arguments the user passes
in the terminal, as an iterator, which we turn into a Vec<String>
.
This Vec<String>
will be passed to the GrepOptionsBuilder
which will parse the arguments into
this struct. This builder is also a non-consuming builder.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GrepOptions {
pub search: String,
pub file_path: String,
pub case_sensitive: bool,
}
We can then pass this struct into the grep()
function which does the following.
let content = fs::read_to_string(options.file_path)?;
let filtered_content = content
.lines()
.filter(|line| {
if options.case_sensitive {
line.contains(&options.search)
} else {
line.to_lowercase().contains(&options.search.to_lowercase())
}
})
.map(|line| {
let from = &options.search;
let to = format!("{}", style_primary(&options.search));
line.replace(from, &to)
})
.collect::<Vec<String>>();
println!("{}", filtered_content.join("\n"));
Similar to what happened in mode 1, we first read the contents of the file into a string, and then we filter out any lines that donโt contain the search, and finally replace the search term with the formatted version. Then print the result to the console.
Wrapping up #
You can get the code for this app here.
Here are a list of crates that are used in this app.
r3bl_rs_utils
- https://crates.io/crates/r3bl_rs_utilsatty
- https://crates.io/crates/atty
We will explore more complex TUIs built w/ Rust in the future.
Build with Naz video series on developerlife.com YouTube channel #
If you have comments and feedback on this content, or would like to request new content (articles & videos) on developerlife.com, please join our discord server.
You can watch a video series on building this crate with Naz on the developerlife.com YouTube channel.
- YT channel
- Playlists
๐ Watch Rust ๐ฆ live coding videos on our YouTube Channel.
๐ฆ Install our useful Rust command line apps usingcargo install r3bl-cmdr
(they are from the r3bl-open-core project):
- ๐ฑ
giti
: run interactive git commands with confidence in your terminal- ๐ฆ
edi
: edit Markdown with style in your terminalgiti in action
edi in action