Crear un shell personalizado desde cero es una excelente manera de mejorar tu comprensión sobre programación de sistemas, I/O asincrónico y análisis de comandos.
En este artículo, te guiaremos a través de la construcción de un shell simple en Rust, implementando comandos básicos como echo, ls, cd, y más.
Este shell operará bajo el formato REPL (Read-Eval-Print Loop), donde:
- Lee la entrada del usuario,
- Evalúa la entrada y ejecuta el comando correspondiente,
- Imprime el resultado para el usuario, y
- Vuelve a esperar el siguiente comando.
Cubrirás cómo configurar tu proyecto, manejar la entrada del usuario de manera asincrónica, analizar comandos y ejecutar operaciones del sistema como la manipulación de archivos. ¡Al final tendrás un shell básico y el conocimiento para expandirlo aún más!
Configuración del Proyecto ️
Para comenzar, configuraremos un nuevo proyecto en Rust y agregaremos las dependencias necesarias.
Primero, usa cargo para crear un nuevo proyecto:
cargo new shell
Este comando crea un directorio llamado shell con los archivos Cargo.toml y src/main.rs por defecto.
A continuación, agrega las dependencias en el archivo Cargo.toml:
[dependencies]
anyhow = "1.0.95"
tokio = { version = "1.43.0", features = ["full"] }
Tokio: Un runtime asincrónico para manejar la entrada del usuario y las operaciones I/O.Anyhow: Una librería sencilla para manejo de errores que proporciona una mejor gestión de errores que el enfoque estándar de Rust.
Manejo de Errores con anyhow ⚠️
Una parte importante de construir un shell es manejar correctamente los errores.
El manejo de errores estándar de Rust requiere un poco de trabajo adicional, pero la librería anyhow lo simplifica utilizando un tipo de error genérico, anyhow::Error.
En src/errors.rs, define un tipo de resultado reutilizable
pub type CrateResult<T> = anyhow::Result<T>;
Importa este tipo en main.rs
mod errors;
Esto configura una manera limpia y sencilla de manejar errores a lo largo del shell.
Operaciones de Entrada/Salida (I/O)
Nuestro shell será asincrónico, por lo que necesitamos manejar las operaciones I/O de manera eficiente.
La librería tokio proporciona herramientas para operaciones I/O asincrónicas, como leer la entrada del usuario y mostrar la salida.
- Usaremos
tokio::spawnpara crear una tarea que maneje la entrada del usuario. Enmain.rs:
use tokio::{io::{AsyncBufReadExt, AsyncWriteExt}, task::JoinHandle};
fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
tokio::spawn(async {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let mut reader = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::BufWriter::new(stdout);
stdout.write(b"¡Bienvenido al shell!n").await?;
while let Ok(Some(line)) = reader.next_line().await {
println!("El usuario escribió: {}", line);
}
Ok(())
})
}
Esto crea un bucle asincrónico que lee la entrada línea por línea.
Manejo de Comandos
Para procesar comandos como echo, ls, pwd, cd y otros, definiremos un enum que represente diferentes comandos del shell. Cada comando será una variante del enum.
En src/command.rs, define el enum Command:
#[derive(Clone, Debug)]
pub enum Command {
Exit,
Echo(String),
Ls,
Pwd,
Cd(String),
Touch(String),
Rm(String),
Cat(String),
}
Implementa el análisis de comandos convirtiendo la entrada del usuario en un enum Command:
impl TryFrom<&str> for Command {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let split_value: Vec<&str> = value.split_whitespace().collect();
match split_value[0] {
"exit" => Ok(Command::Exit),
"ls" => Ok(Command::Ls),
"echo" => Ok(Command::Echo(split_value[1..].join(" "))),
"pwd" => Ok(Command::Pwd),
"cd" => Ok(Command::Cd(split_value[1..].join(" "))),
"touch" => Ok(Command::Touch(split_value[1..].join(" "))),
"rm" => Ok(Command::Rm(split_value[1..].join(" "))),
"cat" => Ok(Command::Cat(split_value[1..].join(" "))),
_ => Err(anyhow!("Comando desconocido")),
}
}
}
Este código usa TryFrom<&str> para convertir una cadena en una variante del enum Command, manejando los argumentos cuando sea necesario.
Integración de I/O y Ejecución de Comandos
Ahora necesitamos procesar la entrada del usuario, ejecutar los comandos analizados y mostrar los resultados.
Actualiza el bucle REPL para procesar los comandos después de analizarlos:
async fn handle_new_line(line: &str) -> CrateResult<Command> {
let command: Command = line.try_into()?;
match &command {
Command::Exit => {
println!("Saliendo...");
return Ok(command);
}
Command::Echo(s) => {
println!("{}", s);
}
Command::Ls => {
helpers::ls()?;
}
// Agregar manejo de otros comandos (pwd, cd, touch, rm, cat)
_ => {}
}
Ok(command)
}
Integra handle_new_line en el bucle REPL principal:
while let Ok(Some(line)) = reader.next_line().await {
let command = handle_new_line(&line).await;
if let Ok(command) = &command {
match command {
Command::Exit => break,
_ => {}
}
} else {
eprintln!("Error al analizar el comando: {}", command.err().unwrap());
}
}
Implementación de los Comandos del Shell
Ahora implementemos la funcionalidad de cada comando del shell.
- Exit: Salir del bucle cuando se recibe el comando
Exit. - Echo: Simplemente imprimir el texto pasado como argumento.
- Ls: Listar los archivos en el directorio actual usando
fs::read_dir. - Pwd: Mostrar la ruta del directorio actual utilizando
std::env::current_dir. - Cd: Cambiar el directorio actual utilizando
std::env::set_current_dir. - Touch: Crear un nuevo archivo vacío utilizando
fs::File::create. - Rm: Eliminar un archivo utilizando
fs::remove_file. - Cat: Leer y mostrar el contenido de un archivo utilizando
fs::read_to_string.
Cada comando puede ser agregado al archivo helpers.rs para una mejor modularidad.
Conclusión
En esta guía, hemos creado un shell simple pero funcional en Rust. Hemos configurado la arquitectura básica, integrado el manejo de entrada asincrónica y implementado comandos clave del shell.
Este proyecto sirve como una base para el desarrollo posterior, como agregar historial de comandos, tuberías, ejecución en segundo plano, entre otros.
Al construir este shell, has adquirido un conocimiento profundo sobre cómo manejar operaciones de I/O, analizar comandos y manejar errores en Rust. ¡Feliz programación!
Artículos Relacionados
Descubre más desde CIBERED
Suscríbete y recibe las últimas entradas en tu correo electrónico.
