logo
Published on

Building a Unix-shell in Rust - Part 3

Authors

In the last post, we covered how to read user input, parse it into commands, and classify those commands for execution. Now, it’s time to refine our code, organize it better, and add some new features. Let’s dive into formatting our code, organizing files, handling errors, adding pipes, and using Termion for input.

Formatting Code

One of the key aspects of maintaining clean and readable code is formatting. Consistent formatting helps others (and your future self) understand the code more easily. While Rust has tools like rustfmt to help automate this process, it’s important to adopt good practices and keep your code neat.

Organizing Files and Adding Error Handling

To improve our shell, I reorganized the files and added better error handling. Here’s what I did:

  • Moved the content of cd.rs from commands/cd.rs to commands.rs.
  • Imported anyhow for better error handling and to use the convenient ? operator instead of the default Result object in Rust.

Here’s the updated main.rs with anyhow:

main.rs
use anyhow::Result;

fn main() -> Result<()> {
    //...Boring code...
    Ok(())
}

Adding Simple Pipes

Pipes are a fundamental feature in Unix shells, allowing the output of one command to be used as the input for another. I’ve added the possibility to use pipes in our shell. They are basic for now but will be expanded in the future.

Here’s the code snippet for handling pipes:

let mut commands = input.trim().split(" | ").peekable();

while let Some(command) = commands.next() {
    // everything after the first whitespace character
    //     is interpreted as args to the command
    let mut parts = command.trim().split_ascii_whitespace();
    let command = match parts.next() {
        Some(command) => command,
        None => { continue 'main_loop; },  // break if no command provided
    };
    let args = parts;

    //...code that handles the command execution...
}

Explanation

  • Splitting Commands: input.trim().split(" | ").peekable() splits the input string by the pipe (|) symbol and makes it an iterator.
  • Iterating Through Commands: The while let loop iterates through each command, splitting it into parts (command and arguments).

Using Termion for Input

Instead of using the standard stdin library for input, the shell now uses Termion in raw mode. This change sets us up to add keybindings and handle special characters more easily.

Here’s the updated code in prompt.rs:

prompt.rs
// termion
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::cursor::{self, DetectCursorPos};

pub fn read_input() -> Result<String> {
    let mut input = String::new();

    // read input
    let stdin = stdin();
    let mut stdout = stdout().into_raw_mode().unwrap();
    //^-- we go into raw mode to manage the terminal output and input manually
    let cursor_pos = stdout.cursor_pos()?;
    let mut index: usize = 0;

    write!(stdout, "{}", cursor::Hide)?;
    write!(stdout, "{}", cursor::Goto(0, cursor_pos.1))?;
    stdout.flush()?;

    for k in stdin.keys() {
        match k? {
            Key::Char('\n') => break,
            Key::Char(c) => {
                //...Receives a normal character, so we apply it to the input string...
            },
            Key::Backspace => {
                //...The user is trying to remove a character from the input, so we do it...
            },
            _ => () // Remains to add special characters: Control+C, Control+D, arrow keys, etc.
        }
        stdout.flush()?;
    }

    write!(stdout, "{}{}", cursor::Show, termion::clear::CurrentLine)?;
    stdout.flush()?;

    Ok(input)
}

Explanation

  • Entering Raw Mode: stdout().into_raw_mode().unwrap(); switches the terminal into raw mode, allowing us to handle input and output manually.
  • Hiding the Cursor: write!(stdout, "{}", cursor::Hide)?; hides the real cursor and positions it at the beginning.
  • Reading Input: The loop reads each key and handles characters and backspaces appropriately.

Why Termion?

I chose Termion over Crossterm because Termion is more Unix-focused, which aligns with our shell’s Unix base. Although Crossterm supports Windows, it is more complex to use. Since our shell won’t work on Windows regardless, Termion is the better choice.

Future Improvements

Due to some issues with Termion, the cursor has to be hidden and placed in the first column to ensure the input is written correctly. I added a “fake” cursor to mimic the real one while typing until I find a better solution. This workaround is temporary, but necessary for now.

What’s Next?

Next, we’ll finish the basic input handling with Termion to accept more commands (e.g., Control+C) and use the up and down arrow keys to navigate through the command history. We’ll also add a built-in command to manage the command history and write some tests to ensure everything works correctly, especially the command parser, which is crucial.

Stay tuned for the next post, where we’ll continue improving our shell!