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 Results, along w/ building a simple CLI interface are covered in this article.

The app we are building is very simple by design. It is getting us ready to building more complex TUI apps next using crates like termion and tui.

📜 The source code for the finished app named rust-grep-cli can be found here.

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 atty. Helper methods based on this crate are available in the r3bl_rs_utils 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 r3bl_rs_utils 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() (in r3bl_rs_utils crate) here.

  1. Here’s what the function returns: atty::isnt(atty::Stream::Stdin).
  2. You will also find these other functions that are related: is_tty(), is_stdout_piped.
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:

  1. It returns a Result and all the functions that it calls also return Result:
  • PipedGrepOptionsBuilder::parse()
  • piped_grep()
  • GrepOptionsBuilder::parse()
  • grep()
  1. It uses the ? operator to unwrap the Result (produced by each of the four functions that it calls) to propagate any Error 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

  1. The left hand side of the pipe | is just a call to cat command w/ a file name README.md, but you can replace it w/ whatever command pipes its output into the terminal, like echo, etc.
  2. The user will have to supply the SEARCH_TERM and CASE_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:

  1. We first get the stdin handle by calling std::io::stdin(). And we call .lock() on it to get a StdinLock handle which implements the BufRead trait. This allows us to call lines() on it in order to get an iterator over every single line that is read from stdin.
  2. We then call .filter() on the lines() 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.
  3. We then call .map() on the filter() iterator to map each line to a String from Result by unwrapping it.
  4. We then call .for_each() on the map() 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

  1. The first argument is the search term.
  2. The second argument is the file path.
  3. 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.

  1. r3bl_rs_utils - https://crates.io/crates/r3bl_rs_utils
  2. atty - https://crates.io/crates/atty

We will explore more complex TUIs built w/ Rust in the future.

Related Posts