//! simi — el `/bin/sh` del producto. //! //! «simi» es *boca, lengua* en quechua: es cómo se le habla a la máquina. //! //! **Por qué existe.** El frente C del ADR 0008 pone a busybox en la raíz de confianza del arranque //! (`STAGE1_COMPONENTS`, `ATTEST_PATHS`) y en el `exec` de cada card de arje. Botarlo pedía un `sh`, //! y el candidato que se midió —brush 0.4.0— pesa 6,9 MB, arranca 3,7× más lento que el ash de //! busybox y trae dos divergencias medidas contra POSIX (reubeno/brush#1394 y #1396). //! //! **Alcance, y está MEDIDO, no supuesto** (`docs/plan-botar-busybox.md`): //! //! | consumidor | qué usa | //! |---|---| //! | las 23 cards de arje con shell | 8 builtins, 13 construcciones, ninguna función, ningún `if` | //! | los 21 `/init` empotrados de las imágenes | + `if`, funciones, `case`, aquí-documentos, `${…}`, posicionales | //! | `takana-live-install.sh` (corre en la imagen viva) | POSIX entero | //! //! Lo que NO está y se dice en vez de fingirlo: control de trabajos (`jobs`/`fg`/`bg`), `times`, //! `[[ ]]` y arrays. //! //! ✅ **Lo que SÍ está y antes decía esta lista que no** (2026-10-22/23): edición de línea, //! historial, autocompletado (`linea.rs`), `alias`/`unalias`, `getopts`, los ficheros de arranque //! (`/etc/profile`, `~/.profile`, `$ENV`) y un `PS1` con el juego de escapes de bash más //! sustitución de comandos. No era una lista de deseos: el shell interactivo de `getty` ES el //! consumidor principal de simi —`/bin/sh` de takana es la shell de login de la consola— y //! quedaba sin cubrir. //! //! **Extensiones NO-POSIX que sí están** (2026-10-22, a pedido explícito del usuario: «si hay //! mejoras que no rompan posix, hazles»). POSIX deja esta sintaxis **sin definir**, así que un //! script conforme nunca la escribe y soportarla no puede cambiarle el comportamiento a nadie — //! verificado: el banco diferencial sigue en **DIVERGE=0** sobre 1344 casos, y el banco POSIX pasa //! de `OK=55 FALLO=2` a **`OK=57 FALLO=0`** — los dos que quedaban se arreglaron: `getopts` estaba //! sin implementar, y `while-read` era un fallo REAL del lexer (el patrón `|` de `${r#|}` quedaba //! vacío) que se había dado por «preexistente» sin diagnosticarlo. //! //! | forma | qué hace | //! |---|---| //! | `${x/pat/rep}` `${x//pat/rep}` | sustituir la primera / todas. Patrón glob, coincidencia más larga y más a la izquierda | //! | `${x/#pat/rep}` `${x/%pat/rep}` | anclado al principio / al final | //! | `${x:desde}` `${x:desde:largo}` | subcadena; los dos índices son expresiones ARITMÉTICAS y un negativo cuenta desde el final | //! | `${x^}` `${x^^}` `${x,}` `${x,,}` | caja: primera letra o todas | //! //! ⚠ **`${!x}` (indirección) NO se añadió**, y es deliberado: choca con el `$!` que el lexer ya //! trata como nombre especial, y era la menos usada de las cuatro. Se dice en vez de fingirlo. //! //! Por qué se añadieron: el corpus las usa. `${t/unknown-/}` en `wasi-libcxx` era la ÚNICA //! divergencia de simi en 1339 casos, y rechazarla obligaba a reescribir recetas por una razón //! ajena a lo que la receta hace. //! //! Uso: `simi [-c ORDEN | FICHERO] [-e] [-u] [-x] [-n] [ARGS…]`, y por stdin si no hay ninguno. mod arith; mod ast; mod builtins; mod exec; mod expand; mod glob; mod lexer; mod linea; mod parser; mod shell; use std::io::Read; use shell::{Flujo, Shell}; fn main() -> std::process::ExitCode { let args: Vec = std::env::args().collect(); let mut sh = Shell::nuevo(); // `SIMI_VERSION`: toda shell publica la suya —bash `BASH_VERSION`, zsh `ZSH_VERSION`, ksh // `KSH_VERSION`— y simi no publicaba NADA, así que un script compartido no tenía forma de // saber quién lo estaba leyendo. Eso importa de verdad en `/etc/profile.d`, que lo leen simi // y bash por igual: el gancho de una herramienta que sólo da init de bash (atuin, direnv) // necesita saber en cuál está. Medido antes de esto: `env -i simi -c set` definía UNA sola // variable, `IFS`. Se ASIGNA sin exportar, como las de las otras shells: es un hecho sobre el // proceso, no entorno que deban heredar los hijos. sh.asignar("SIMI_VERSION", env!("CARGO_PKG_VERSION")); // argv[0] con guión delante = shell de LOGIN. Es la convención que usa `login`/`agetty` para // avisarlo, y desde el 2026-10-23 no es sólo cosmético: decide si se leen los perfiles. let argv0 = args.first().cloned().unwrap_or_else(|| "simi".into()); sh.nombre = argv0.trim_start_matches('-').rsplit('/').next().unwrap_or("simi").to_string(); let mut es_login = argv0.starts_with('-'); let mut i = 1; let mut orden: Option = None; let mut fichero: Option = None; while i < args.len() { let a = &args[i]; match a.as_str() { "-c" => { i += 1; match args.get(i) { Some(c) => orden = Some(c.clone()), None => { eprintln!("{}: -c necesita una orden", sh.nombre); return std::process::ExitCode::from(2); } } } "-s" => {} "--" => { i += 1; break; } _ if a.starts_with('-') && a.len() > 1 && !a.starts_with("--") => { for c in a[1..].chars() { match c { 'e' => sh.opts.errexit = true, 'u' => sh.opts.nounset = true, 'x' => sh.opts.xtrace = true, 'n' => sh.opts.noexec = true, 'f' => sh.opts.noglob = true, // `-i` se acepta y se ignora: lo interactivo lo decide `isatty(0)`, no una // bandera (ver la nota de la bifurcación más abajo). 'i' => {} // ⚠ `-l` ESTABA EN ESTA MISMA RAMA, ignorado junto con `-i`, y por eso // `sh -l` no leía los perfiles: añadí un brazo `'l'` más abajo y quedó // INALCANZABLE —en Rust gana el primero que coincide—. El compilador lo // avisó con «unreachable pattern» y yo estaba filtrando sólo `^error`. 'l' => es_login = true, otro => { eprintln!("{}: -{otro}: opción no soportada", sh.nombre); return std::process::ExitCode::from(2); } } } } _ => { if orden.is_none() { fichero = Some(a.clone()); } break; } } i += 1; } // ── FICHEROS DE ARRANQUE (2026-10-23) ───────────────────────────────────────────────────── // POSIX: un shell de LOGIN lee `/etc/profile` y luego `~/.profile`; uno INTERACTIVO expande // `$ENV` y lee ese fichero. Hasta hoy simi no leía ninguno, y eso hacía que todo el entorno de // consola —`LANG`, `PS1`, alias, completado— no se aplicara al entrar por la consola, porque // el login de `agetty` es `/bin/sh` y `/bin/sh` es simi. // // ⚠ **El orden importa y el alcance más:** los perfiles se leen ANTES de `-c` y del fichero, // y SÓLO si es login. Un `sh -c` pelado —que es como arje ejecuta cada card y como corre el // driver de reconstrucción— NO los lee, igual que manda POSIX. Sourcear un perfil ahí // cambiaría el entorno de los servicios y les costaría un parseo en cada arranque. if es_login { leer_arranque(&mut sh, "/etc/profile"); if let Some(casa) = std::env::var_os("HOME") { let p = std::path::Path::new(&casa).join(".profile"); leer_arranque(&mut sh, &p.to_string_lossy()); } } // Los argumentos que quedan son los posicionales. Con `-c`, el primero es `$0`. let resto: Vec = args[i..].to_vec(); let texto = match (&orden, &fichero) { (Some(c), _) => { if !resto.is_empty() { sh.nombre = resto[0].clone(); sh.params = resto[1..].to_vec(); } c.clone() } (None, Some(f)) => { sh.nombre = f.clone(); sh.params = resto[1..].to_vec(); match std::fs::read_to_string(f) { Ok(t) => t, Err(e) => { eprintln!("{}: {}: {}", argv0, f, e); return std::process::ExitCode::from(127); } } } (None, None) => { // ⚠ **Sin fichero ni `-c` hay DOS caminos, y antes había uno.** Si la entrada es una // TERMINAL, esto es un shell interactivo y entra al REPL con editor de línea, // historial y Tab (`linea.rs`). Si no lo es —una tubería, `simi < guion`— se traga la // entrada y se ejecuta como script, que es el comportamiento de siempre y el que usan // las cards y los `/init`. // // La distinción es `isatty(0)`, no una bandera: un shell que decide esto por una // opción se equivoca el día que alguien lo invoque de la otra forma. if unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 { // `$ENV` es el rc de un sh INTERACTIVO (POSIX). Se expande antes de usarse, que es // lo que permite el idioma de siempre: `ENV=$HOME/.shrc`. if let Some(env) = sh.leer("ENV") { if !env.is_empty() { if let Ok(r) = exec::expandir_texto(&mut sh, &env) { leer_arranque(&mut sh, &r); } } } return repl(&mut sh); } let mut t = String::new(); if std::io::stdin().read_to_string(&mut t).is_err() { eprintln!("{}: no pude leer la entrada", sh.nombre); return std::process::ExitCode::from(2); } t } }; let lista = match parser::parsear(&texto) { Ok(l) => l, Err(e) => { eprintln!("{}: error de sintaxis: {}", sh.nombre, e); return std::process::ExitCode::from(2); } }; // `-n`: parsear y no ejecutar. Es lo que usa el banco diferencial, y lo que hace que un guion // roto se detecte sin correrlo. if sh.opts.noexec { return std::process::ExitCode::SUCCESS; } let flujo = exec::correr_lista(&mut sh, &lista); let estado = match flujo { Flujo::Exit(c) => c, _ => sh.estado, }; exec::correr_trap_exit(&mut sh, estado); let final_ = if sh.trap_exit_corrido { estado } else { sh.estado }; std::process::ExitCode::from((final_ & 0xff) as u8) } /// El bucle interactivo. Separado de la ruta de script a propósito: lee UNA orden, la ejecuta y /// vuelve, de modo que un error de sintaxis o un fallo de la orden no termina la sesión — que es /// justo lo contrario de lo que debe hacer un script. fn repl(sh: &mut Shell) -> std::process::ExitCode { let mut ed = linea::Editor::nuevo(); // `PS1` por defecto si nadie la puso. Se ASIGNA (no sólo se usa) para que `echo $PS1` la vea, // que es lo que alguien espera al entrar. if sh.leer("PS1").is_none() { sh.asignar("PS1", "\\u@\\h:\\w\\$ "); } let mut ultimo = 0i32; loop { let p = linea::prompt(sh, ed.largo_historial()); match ed.leer(&p) { linea::Linea::Fin => break, linea::Linea::Interrumpida => { // Ctrl-C: `$?` pasa a 130 como en bash (128 + SIGINT), y se sigue. ultimo = 130; sh.estado = 130; continue; } linea::Linea::Texto(t) => { if t.trim().is_empty() { continue; } match parser::parsear(&t) { Ok(lista) => { // `correr_lista` devuelve `Flujo`, no un código: un `exit` escrito en el // prompt tiene que TERMINAR la sesión con su código, y el resto de los // flujos (`break`/`continue` sueltos) no significan nada acá y se ignoran. match exec::correr_lista(sh, &lista) { Flujo::Exit(c) => { ultimo = c; break; } _ => ultimo = sh.estado, } } Err(e) => { // Un error de sintaxis en interactivo NO mata la sesión: se avisa y se // sigue. `2` es el código que POSIX pide para un error de sintaxis. eprintln!("{}: {e}", sh.nombre); ultimo = 2; sh.estado = 2; } } } } } // El historial se guarda ANTES del trap de salida: si el trap aborta, lo tecleado no se pierde. ed.guardar(); exec::correr_trap_exit(sh, ultimo); let final_ = if sh.trap_exit_corrido { ultimo } else { sh.estado }; std::process::ExitCode::from((final_ & 0xff) as u8) } /// Lee un fichero de arranque EN EL SHELL ACTUAL, igual que el builtin `.`. /// /// ⚠ Un fichero AUSENTE no es error, y eso es POSIX, no indulgencia: `/etc/profile` y `~/.profile` /// son opcionales, y un shell que se quejara de que faltan llenaría de ruido cada login de una /// imagen mínima. Un fichero que existe y NO PARSEA sí se denuncia — ahí hay un error de alguien. fn leer_arranque(sh: &mut Shell, ruta: &str) { let Ok(texto) = std::fs::read_to_string(ruta) else { return }; match parser::parsear(&texto) { Ok(l) => { exec::correr_lista(sh, &l); } Err(e) => eprintln!("{}: {ruta}: {e}", sh.nombre), } }