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
- Expansão de variáveis de ambiente: Implemente a substituição
$HOME,$PATHetc. nos argumentos dos comandos, além deexportpara definir variáveis. - Execução em segundo plano: Adicione suporte ao operador
¶ executar processos em background, com um comandojobspara listá-los. - Autocompletar com Tab: Implemente autocompletar para nomes de arquivos e comandos usando a crate
rustylinepara uma experiência REPL completa. - Scripts de shell: Permita executar arquivos de script (
./script.rsh) lendo comandos linha por linha, com suporte a comentários (#). - Operadores lógicos: Adicione suporte a
&&(executa o próximo se o anterior teve sucesso) e||(executa se o anterior falhou).
Veja Também
- Módulo Process — criação e gerenciamento de processos
- Entrada e Saída Padrão — stdin, stdout e stderr
- Variáveis de Ambiente — acesso a variáveis do sistema
- Trabalhando com Strings — parsing de comandos
- Como Ler Input do Usuário — leitura de entrada interativa