Shell Básico em Rust

Construa um shell interativo do zero em Rust com parsing de comandos, piping, redirecionamento de I/O e comandos embutidos como cd e history.

Um shell é uma das ferramentas mais fundamentais de um sistema operacional. Neste projeto, vamos construir um shell básico funcional em Rust que interpreta comandos, cria processos, conecta pipes entre programas e suporta redirecionamento de entrada/saída. É um exercício perfeito para entender como o sistema operacional gerencia processos e como o Rust interage com a API do sistema.

Ao final deste walkthrough, você terá um shell capaz de executar programas, encadear comandos com pipes, redirecionar saída para arquivos e manter um histórico de comandos — tudo construído do zero.

O Que Vamos Construir

Um shell interativo com as seguintes funcionalidades:

  • Parsing de linhas de comando com argumentos
  • Execução de programas externos
  • Pipes entre comandos (ls | grep .rs | wc -l)
  • Redirecionamento de saída (>) e entrada (<)
  • Comandos embutidos: cd, exit, echo, history, pwd
  • Prompt personalizado mostrando o diretório atual
  • Tratamento de erros com mensagens claras

Estrutura do Projeto

shell-basico/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── parser.rs
    ├── executor.rs
    └── builtins.rs

Configurando o Projeto

cargo new shell-basico
cd shell-basico
[package]
name = "shell-basico"
version = "0.1.0"
edition = "2021"

Passo 1: O Parser de Comandos

O parser transforma a linha digitada pelo usuário em uma estrutura que representa comandos, seus argumentos e operações de redirecionamento e piping.

Crie o arquivo src/parser.rs:

/// Representa um redirecionamento de I/O
#[derive(Debug, Clone)]
pub enum Redirecionamento {
    /// Redireciona stdout para arquivo (>)
    SaidaParaArquivo(String),
    /// Redireciona stdout para arquivo em modo append (>>)
    SaidaAppend(String),
    /// Redireciona stdin a partir de arquivo (<)
    EntradaDeArquivo(String),
}

/// Representa um único comando com seus argumentos
#[derive(Debug, Clone)]
pub struct Comando {
    pub programa: String,
    pub argumentos: Vec<String>,
    pub redirecionamentos: Vec<Redirecionamento>,
}

/// Representa uma pipeline de comandos conectados por pipes
#[derive(Debug)]
pub struct Pipeline {
    pub comandos: Vec<Comando>,
}

/// Analisa uma linha de entrada e retorna a pipeline de comandos
pub fn analisar_linha(linha: &str) -> Result<Pipeline, String> {
    let linha = linha.trim();
    if linha.is_empty() {
        return Err("Linha vazia".to_string());
    }

    let partes_pipe: Vec<&str> = dividir_por_pipe(linha);
    let mut comandos = Vec::new();

    for parte in partes_pipe {
        let comando = analisar_comando(parte.trim())?;
        comandos.push(comando);
    }

    Ok(Pipeline { comandos })
}

/// Divide a linha pelos pipes, respeitando aspas
fn dividir_por_pipe(linha: &str) -> Vec<&str> {
    let mut partes = Vec::new();
    let mut inicio = 0;
    let mut dentro_aspas = false;
    let bytes = linha.as_bytes();

    for i in 0..bytes.len() {
        match bytes[i] {
            b'"' | b'\'' => dentro_aspas = !dentro_aspas,
            b'|' if !dentro_aspas => {
                partes.push(&linha[inicio..i]);
                inicio = i + 1;
            }
            _ => {}
        }
    }
    partes.push(&linha[inicio..]);
    partes
}

/// Analisa um único segmento de comando
fn analisar_comando(texto: &str) -> Result<Comando, String> {
    let tokens = tokenizar(texto);
    if tokens.is_empty() {
        return Err("Comando vazio".to_string());
    }

    let mut programa = String::new();
    let mut argumentos = Vec::new();
    let mut redirecionamentos = Vec::new();
    let mut i = 0;

    while i < tokens.len() {
        match tokens[i].as_str() {
            ">" => {
                i += 1;
                let arquivo = tokens.get(i)
                    .ok_or("Esperado nome de arquivo após '>'")?;
                redirecionamentos.push(
                    Redirecionamento::SaidaParaArquivo(arquivo.clone())
                );
            }
            ">>" => {
                i += 1;
                let arquivo = tokens.get(i)
                    .ok_or("Esperado nome de arquivo após '>>'")?;
                redirecionamentos.push(
                    Redirecionamento::SaidaAppend(arquivo.clone())
                );
            }
            "<" => {
                i += 1;
                let arquivo = tokens.get(i)
                    .ok_or("Esperado nome de arquivo após '<'")?;
                redirecionamentos.push(
                    Redirecionamento::EntradaDeArquivo(arquivo.clone())
                );
            }
            token => {
                if programa.is_empty() {
                    programa = token.to_string();
                } else {
                    argumentos.push(token.to_string());
                }
            }
        }
        i += 1;
    }

    if programa.is_empty() {
        return Err("Comando vazio após parsing".to_string());
    }

    Ok(Comando {
        programa,
        argumentos,
        redirecionamentos,
    })
}

/// Divide o texto em tokens respeitando aspas
fn tokenizar(texto: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut atual = String::new();
    let mut dentro_aspas = false;
    let mut caractere_aspas = ' ';

    for c in texto.chars() {
        if dentro_aspas {
            if c == caractere_aspas {
                dentro_aspas = false;
            } else {
                atual.push(c);
            }
        } else {
            match c {
                '"' | '\'' => {
                    dentro_aspas = true;
                    caractere_aspas = c;
                }
                ' ' | '\t' => {
                    if !atual.is_empty() {
                        tokens.push(atual.clone());
                        atual.clear();
                    }
                }
                '>' => {
                    if !atual.is_empty() {
                        tokens.push(atual.clone());
                        atual.clear();
                    }
                    // Verifica se é >> (append)
                    if tokens.last().map_or(false, |t| t == ">") {
                        tokens.pop();
                        tokens.push(">>".to_string());
                    } else {
                        tokens.push(">".to_string());
                    }
                }
                '<' => {
                    if !atual.is_empty() {
                        tokens.push(atual.clone());
                        atual.clear();
                    }
                    tokens.push("<".to_string());
                }
                _ => atual.push(c),
            }
        }
    }

    if !atual.is_empty() {
        tokens.push(atual);
    }

    tokens
}

#[cfg(test)]
mod testes {
    use super::*;

    #[test]
    fn testar_comando_simples() {
        let pipeline = analisar_linha("ls -la /tmp").unwrap();
        assert_eq!(pipeline.comandos.len(), 1);
        assert_eq!(pipeline.comandos[0].programa, "ls");
        assert_eq!(pipeline.comandos[0].argumentos, vec!["-la", "/tmp"]);
    }

    #[test]
    fn testar_pipe() {
        let pipeline = analisar_linha("ls | grep txt").unwrap();
        assert_eq!(pipeline.comandos.len(), 2);
        assert_eq!(pipeline.comandos[0].programa, "ls");
        assert_eq!(pipeline.comandos[1].programa, "grep");
    }
}

Passo 2: Comandos Embutidos (Builtins)

Alguns comandos precisam ser executados pelo próprio shell, como cd (que precisa mudar o diretório do processo do shell) e exit.

Crie o arquivo src/builtins.rs:

use std::collections::VecDeque;
use std::env;
use std::path::Path;

/// Histórico de comandos
pub struct Historico {
    entradas: VecDeque<String>,
    capacidade: usize,
}

impl Historico {
    pub fn new(capacidade: usize) -> Self {
        Self {
            entradas: VecDeque::with_capacity(capacidade),
            capacidade,
        }
    }

    pub fn adicionar(&mut self, comando: String) {
        if self.entradas.len() >= self.capacidade {
            self.entradas.pop_front();
        }
        self.entradas.push_back(comando);
    }

    pub fn listar(&self) {
        for (i, entrada) in self.entradas.iter().enumerate() {
            println!("  {} {}", i + 1, entrada);
        }
    }
}

/// Resultado da execução de um builtin
pub enum ResultadoBuiltin {
    /// O comando foi executado com sucesso
    Executado,
    /// O comando pede para sair do shell
    Sair,
    /// O comando não é um builtin
    NaoEhBuiltin,
}

/// Tenta executar um comando embutido
pub fn executar_builtin(
    programa: &str,
    argumentos: &[String],
    historico: &Historico,
) -> ResultadoBuiltin {
    match programa {
        "exit" | "sair" => ResultadoBuiltin::Sair,

        "cd" => {
            let destino = if argumentos.is_empty() {
                // Sem argumento, vai para o diretório home
                env::var("HOME").unwrap_or_else(|_| "/".to_string())
            } else {
                argumentos[0].clone()
            };

            let caminho = Path::new(&destino);
            if let Err(e) = env::set_current_dir(caminho) {
                eprintln!("cd: {}: {}", destino, e);
            }
            ResultadoBuiltin::Executado
        }

        "pwd" => {
            match env::current_dir() {
                Ok(dir) => println!("{}", dir.display()),
                Err(e) => eprintln!("pwd: {}", e),
            }
            ResultadoBuiltin::Executado
        }

        "echo" => {
            let saida: Vec<&str> = argumentos.iter().map(|s| s.as_str()).collect();
            println!("{}", saida.join(" "));
            ResultadoBuiltin::Executado
        }

        "history" | "historico" => {
            historico.listar();
            ResultadoBuiltin::Executado
        }

        "help" | "ajuda" => {
            println!("Shell Básico em Rust - Comandos embutidos:");
            println!("  cd [dir]     - Mudar diretório (sem argumento = home)");
            println!("  pwd          - Mostrar diretório atual");
            println!("  echo [args]  - Imprimir argumentos");
            println!("  history      - Mostrar histórico de comandos");
            println!("  help         - Mostrar esta ajuda");
            println!("  exit         - Sair do shell");
            println!();
            println!("Operadores:");
            println!("  cmd1 | cmd2  - Pipe: saída de cmd1 como entrada de cmd2");
            println!("  cmd > arq    - Redirecionar saída para arquivo");
            println!("  cmd >> arq   - Anexar saída ao arquivo");
            println!("  cmd < arq    - Redirecionar entrada de arquivo");
            ResultadoBuiltin::Executado
        }

        _ => ResultadoBuiltin::NaoEhBuiltin,
    }
}

Passo 3: O Executor de Comandos

O executor cria processos, configura pipes entre eles e aplica redirecionamentos de I/O.

Crie o arquivo src/executor.rs:

use crate::parser::{Comando, Pipeline, Redirecionamento};
use std::fs::{File, OpenOptions};
use std::io;
use std::process::{Command, Stdio};

/// Executa uma pipeline de comandos
pub fn executar_pipeline(pipeline: &Pipeline) -> io::Result<()> {
    let total = pipeline.comandos.len();

    if total == 1 {
        // Comando simples, sem pipe
        return executar_comando_simples(&pipeline.comandos[0]);
    }

    // Pipeline com múltiplos comandos
    let mut entrada_anterior: Option<std::process::ChildStdout> = None;
    let mut processos = Vec::new();

    for (i, comando) in pipeline.comandos.iter().enumerate() {
        let eh_primeiro = i == 0;
        let eh_ultimo = i == total - 1;

        let mut cmd = Command::new(&comando.programa);
        cmd.args(&comando.argumentos);

        // Configura stdin
        if eh_primeiro {
            aplicar_redirecionamento_entrada(&mut cmd, comando)?;
        } else if let Some(stdout_anterior) = entrada_anterior.take() {
            cmd.stdin(Stdio::from(stdout_anterior));
        }

        // Configura stdout
        if eh_ultimo {
            aplicar_redirecionamento_saida(&mut cmd, comando)?;
        } else {
            cmd.stdout(Stdio::piped());
        }

        let mut processo = cmd.spawn().map_err(|e| {
            io::Error::new(
                e.kind(),
                format!("{}: {}", comando.programa, e),
            )
        })?;

        // Captura o stdout para o próximo comando
        if !eh_ultimo {
            entrada_anterior = processo.stdout.take();
        }

        processos.push(processo);
    }

    // Espera todos os processos terminarem
    for mut processo in processos {
        processo.wait()?;
    }

    Ok(())
}

/// Executa um único comando (sem pipe)
fn executar_comando_simples(comando: &Comando) -> io::Result<()> {
    let mut cmd = Command::new(&comando.programa);
    cmd.args(&comando.argumentos);

    aplicar_redirecionamento_entrada(&mut cmd, comando)?;
    aplicar_redirecionamento_saida(&mut cmd, comando)?;

    let mut processo = cmd.spawn().map_err(|e| {
        io::Error::new(
            e.kind(),
            format!("{}: {}", comando.programa, e),
        )
    })?;

    processo.wait()?;
    Ok(())
}

/// Aplica redirecionamento de entrada (<)
fn aplicar_redirecionamento_entrada(
    cmd: &mut Command,
    comando: &Comando,
) -> io::Result<()> {
    for redir in &comando.redirecionamentos {
        if let Redirecionamento::EntradaDeArquivo(caminho) = redir {
            let arquivo = File::open(caminho).map_err(|e| {
                io::Error::new(e.kind(), format!("{}: {}", caminho, e))
            })?;
            cmd.stdin(Stdio::from(arquivo));
        }
    }
    Ok(())
}

/// Aplica redirecionamento de saída (> ou >>)
fn aplicar_redirecionamento_saida(
    cmd: &mut Command,
    comando: &Comando,
) -> io::Result<()> {
    for redir in &comando.redirecionamentos {
        match redir {
            Redirecionamento::SaidaParaArquivo(caminho) => {
                let arquivo = File::create(caminho).map_err(|e| {
                    io::Error::new(e.kind(), format!("{}: {}", caminho, e))
                })?;
                cmd.stdout(Stdio::from(arquivo));
            }
            Redirecionamento::SaidaAppend(caminho) => {
                let arquivo = OpenOptions::new()
                    .create(true)
                    .append(true)
                    .open(caminho)
                    .map_err(|e| {
                        io::Error::new(e.kind(), format!("{}: {}", caminho, e))
                    })?;
                cmd.stdout(Stdio::from(arquivo));
            }
            _ => {}
        }
    }
    Ok(())
}

Passo 4: O Loop Principal (main.rs)

Agora vamos juntar tudo no loop principal do shell.

mod builtins;
mod executor;
mod parser;

use std::env;
use std::io::{self, BufRead, Write};

fn obter_prompt() -> String {
    let dir = env::current_dir()
        .map(|p| {
            // Encurta o caminho substituindo o home por ~
            let caminho = p.display().to_string();
            if let Ok(home) = env::var("HOME") {
                if let Some(resto) = caminho.strip_prefix(&home) {
                    return format!("~{}", resto);
                }
            }
            caminho
        })
        .unwrap_or_else(|_| "?".to_string());

    format!("rsh:{}$ ", dir)
}

fn main() {
    println!("Shell Básico em Rust (rsh) v0.1.0");
    println!("Digite 'help' para ver os comandos disponíveis.\n");

    let stdin = io::stdin();
    let mut historico = builtins::Historico::new(100);

    loop {
        // Exibe o prompt
        print!("{}", obter_prompt());
        io::stdout().flush().unwrap();

        // Lê a linha de entrada
        let mut linha = String::new();
        if stdin.lock().read_line(&mut linha).unwrap() == 0 {
            println!();
            break;
        }
        let entrada = linha.trim().to_string();

        if entrada.is_empty() {
            continue;
        }

        // Adiciona ao histórico
        historico.adicionar(entrada.clone());

        // Analisa a linha de comando
        let pipeline = match parser::analisar_linha(&entrada) {
            Ok(p) => p,
            Err(e) => {
                eprintln!("rsh: erro de parsing: {}", e);
                continue;
            }
        };

        // Verifica se o primeiro comando é um builtin
        // (builtins só funcionam como comando único, sem pipe)
        if pipeline.comandos.len() == 1 {
            let cmd = &pipeline.comandos[0];
            match builtins::executar_builtin(
                &cmd.programa,
                &cmd.argumentos,
                &historico,
            ) {
                builtins::ResultadoBuiltin::Executado => continue,
                builtins::ResultadoBuiltin::Sair => {
                    println!("Até logo!");
                    break;
                }
                builtins::ResultadoBuiltin::NaoEhBuiltin => {}
            }
        }

        // Executa a pipeline
        if let Err(e) = executor::executar_pipeline(&pipeline) {
            eprintln!("rsh: {}", e);
        }
    }
}

Como Executar

# Compilar e executar
cargo run

# Sessão de exemplo:
rsh:~/projetos/shell-basico$ echo Ola mundo
Ola mundo

rsh:~/projetos/shell-basico$ ls -la | grep Cargo
-rw-r--r-- 1 user user   82 fev 24 10:00 Cargo.toml

rsh:~/projetos/shell-basico$ ls src > arquivos.txt

rsh:~/projetos/shell-basico$ cat < arquivos.txt
builtins.rs
executor.rs
main.rs
parser.rs

rsh:~/projetos/shell-basico$ cd /tmp
rsh:/tmp$ pwd
/tmp

rsh:/tmp$ cd
rsh:~$ history
  1 echo Ola mundo
  2 ls -la | grep Cargo
  3 ls src > arquivos.txt
  4 cat < arquivos.txt
  5 cd /tmp
  6 pwd
  7 cd
  8 history

rsh:~$ exit
Até logo!

# Executar os testes
cargo test

Desafios para Expandir

  1. Expansão de variáveis de ambiente: Implemente a substituição $HOME, $PATH etc. nos argumentos dos comandos, além de export para definir variáveis.
  2. Execução em segundo plano: Adicione suporte ao operador & para executar processos em background, com um comando jobs para listá-los.
  3. Autocompletar com Tab: Implemente autocompletar para nomes de arquivos e comandos usando a crate rustyline para uma experiência REPL completa.
  4. Scripts de shell: Permita executar arquivos de script (./script.rsh) lendo comandos linha por linha, com suporte a comentários (#).
  5. Operadores lógicos: Adicione suporte a && (executa o próximo se o anterior teve sucesso) e || (executa se o anterior falhou).

Veja Também