> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
simi: historial, autocompletado y edicion de linea — ya es shell de login
Era el hueco que la propia cabecera de simi declaraba: «el shell interactivo de
getty es otro consumidor y no esta cubierto todavia». Hasta hoy, sin -c y sin
fichero, simi se TRAGABA todo stdin y lo ejecutaba como script: no habia prompt
ni bucle interactivo.
Escrito a mano con termios + ANSI, y la razon no es purismo: el Cargo.toml lo
dice — «este binario es la raiz de confianza del frente C (ATTEST_PATHS), y
cada dep suya entra en esa raiz». rustyline o reedline meterian sus arboles de
dependencias en lo que el arranque atesta por BLAKE3. Solo libc.
Lo que hay: historial en memoria y en disco (~/.simi_historial, apendeado para
no pisar otra sesion), flechas arriba/abajo por el historial, izquierda/derecha,
Inicio/Fin/Supr, Ctrl-A/E/B/F/K/U/W/L, Ctrl-C que limpia la linea sin matar la
sesion ($? = 130), Ctrl-D que sale solo con la linea vacia, y Tab que completa
ORDENES (builtins + PATH) cuando es la primera palabra y RUTAS cuando no. PS1
con \\u \\h \\w \\$ \\n y el \\[ \\] de bash ignorado para que no salga basura.
La bifurcacion es isatty(0), no una bandera: un shell que decide esto por una
opcion se equivoca el dia que lo invoquen de la otra forma. Tuberia y
`simi < guion` siguen por el camino de script, que es el de las cards y los
/init.
Verificado de verdad, no por inspeccion:
· 13/13 pruebas con un PTY real (prompt, retroceso, historial, insertar en
medio, Ctrl-C, Ctrl-U, Tab de orden, Tab de ruta, persistencia, exit 7)
· la TERMINAL QUEDA IDENTICA al salir (lflag byte a byte): el Drop del modo
crudo es lo que evita el fallo clasico de dejar la consola muda
· 53 tests de unidad, 0 fallos
· CERO regresiones: banco POSIX con los MISMOS 2 fallos preexistentes y
banco diferencial en DIVERGE=0 sobre 1339 casos, misma velocidad
⚠ Dos cosas que NO cubre, dichas en vez de fingidas: control de trabajos
(fg/bg/jobs siguen sin existir), Ctrl-R, y lineas mas largas que el ancho de la
terminal se redibujan feas (el contenido es correcto).
⚠ Y un test mio que estuvo MAL: miraba /usr/bin para probar el completado de
rutas y fallaba — no por un bug, sino porque en esta jaula /usr lista CERO
entradas aunque [ -d /usr/bin ] diga que si. Estuve a punto de "arreglar"
codigo que funcionaba. Ahora el test CREA el arbol que consulta.
diff --git a/crates/simi/src/builtins.rs b/crates/simi/src/builtins.rs
index d60072a8..c64c36d9 100644
--- a/crates/simi/src/builtins.rs
+++ b/crates/simi/src/builtins.rs
@@ -21,6 +21,12 @@ pub fn es_builtin(n: &str) -> bool {
BUILTINS.contains(&n)
}
+/// Para el autocompletado de `linea`: completar una ORDEN tiene que ofrecer los builtins, que no
+/// están en el `PATH` y si no se nombran acá se quedarían fuera.
+pub fn nombres() -> &'static [&'static str] {
+ BUILTINS
+}
+
pub fn correr(sh: &mut Shell, argv: &[String]) -> Flujo {
let args = &argv[1..];
match argv[0].as_str() {
diff --git a/crates/simi/src/linea.rs b/crates/simi/src/linea.rs
new file mode 100644
index 00000000..bd2978f8
--- /dev/null
+++ b/crates/simi/src/linea.rs
@@ -0,0 +1,567 @@
+//! `linea` — el editor de línea de simi: historial, autocompletado y edición con teclas.
+//!
+//! **Por qué a mano.** La cabecera de `Cargo.toml` lo dice y vale releerla: *«este binario es la
+//! raíz de confianza del frente C (`ATTEST_PATHS`), y cada dep suya entra en esa raíz»*. Meter
+//! `rustyline` o `reedline` aquí metería sus árboles de dependencias en lo que el arranque atesta
+//! por BLAKE3. Así que esto es termios + secuencias ANSI, con `libc` y nada más.
+//!
+//! **Qué cubre**, que es exactamente lo que la cabecera de `main.rs` decía que faltaba:
+//! historial (en memoria y en disco), autocompletado por Tab, y las teclas de edición que alguien
+//! espera al entrar por consola — flechas, Inicio/Fin, y los controles de estilo emacs que son los
+//! que tiene el `ash` al que esto reemplaza.
+//!
+//! ⚠ **Lo que NO cubre, dicho en vez de fingirlo:** control de trabajos (`fg`/`bg`/`jobs` siguen
+//! sin existir en simi), búsqueda incremental (`Ctrl-R`), multilínea con continuación, y edición de
+//! texto que ocupe más de una fila de la terminal — si la línea pasa del ancho, el redibujado la
+//! deja fea aunque el contenido es correcto. Son añadidos posibles sobre esto, no agujeros tapados.
+
+use std::io::{Read, Write};
+use std::os::unix::ffi::OsStrExt;
+use std::path::Path;
+
+const MAX_HISTORIAL: usize = 2000;
+
+/// Lo que devuelve una lectura. `Fin` es Ctrl-D en línea vacía (EOF), `Interrumpida` es Ctrl-C:
+/// son cosas distintas y el REPL las trata distinto — una termina la sesión, la otra no.
+pub enum Linea {
+ Texto(String),
+ Fin,
+ Interrumpida,
+}
+
+/// Modo crudo con restauración por RAII.
+///
+/// ⚠ **El `Drop` no es un adorno: sin él, cualquier `panic!` o `return` temprano deja la terminal
+/// MUDA** —sin eco, sin salto de línea— y el usuario tiene que escribir `reset` a ciegas. Es el
+/// fallo clásico de los editores de línea escritos a mano, y por eso el estado anterior se guarda
+/// en la estructura en vez de en una variable suelta.
+struct Crudo {
+ fd: i32,
+ antes: libc::termios,
+}
+
+impl Crudo {
+ fn nuevo(fd: i32) -> Option<Crudo> {
+ unsafe {
+ let mut t: libc::termios = std::mem::zeroed();
+ if libc::tcgetattr(fd, &mut t) != 0 {
+ return None;
+ }
+ let antes = t;
+ // ICANON fuera: leer tecla por tecla. ECHO fuera: lo pintamos nosotros.
+ // ISIG fuera a propósito: queremos el 0x03 de Ctrl-C como BYTE, no como SIGINT, para
+ // poder limpiar la línea y seguir — que es lo que hace un shell y no un programa.
+ // IXON fuera: si no, Ctrl-S congela la terminal y parece que el shell se colgó.
+ t.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
+ t.c_iflag &= !(libc::IXON | libc::ICRNL);
+ // OPOST se DEJA puesto: queremos que `\n` siga siendo salto de línea de verdad.
+ t.c_cc[libc::VMIN] = 1;
+ t.c_cc[libc::VTIME] = 0;
+ if libc::tcsetattr(fd, libc::TCSAFLUSH, &t) != 0 {
+ return None;
+ }
+ Some(Crudo { fd, antes })
+ }
+ }
+}
+
+impl Drop for Crudo {
+ fn drop(&mut self) {
+ unsafe {
+ libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.antes);
+ }
+ }
+}
+
+pub struct Editor {
+ historial: Vec<String>,
+ fichero: Option<std::path::PathBuf>,
+ /// Cuántas entradas había al cargar: sólo se APENDEA lo nuevo, para no pisar el historial de
+ /// otra sesión que esté escribiendo en el mismo fichero.
+ ya_en_disco: usize,
+}
+
+impl Editor {
+ pub fn nuevo() -> Editor {
+ let fichero = std::env::var_os("HOME").map(|h| Path::new(&h).join(".simi_historial"));
+ let mut historial = Vec::new();
+ if let Some(f) = &fichero {
+ if let Ok(t) = std::fs::read_to_string(f) {
+ historial.extend(t.lines().filter(|l| !l.trim().is_empty()).map(String::from));
+ }
+ }
+ if historial.len() > MAX_HISTORIAL {
+ let sobra = historial.len() - MAX_HISTORIAL;
+ historial.drain(..sobra);
+ }
+ let ya_en_disco = historial.len();
+ Editor { historial, fichero, ya_en_disco }
+ }
+
+ /// Apendea al fichero lo que esta sesión añadió. No reescribe: así dos simi a la vez no se
+ /// pisan (se intercalan, que es lo que hace bash sin `histappend` y es aceptable).
+ pub fn guardar(&mut self) {
+ let Some(f) = &self.fichero else { return };
+ if self.historial.len() <= self.ya_en_disco {
+ return;
+ }
+ let nuevo: String =
+ self.historial[self.ya_en_disco..].iter().map(|l| format!("{l}\n")).collect();
+ if let Ok(mut h) = std::fs::OpenOptions::new().create(true).append(true).open(f) {
+ let _ = h.write_all(nuevo.as_bytes());
+ }
+ self.ya_en_disco = self.historial.len();
+ }
+
+ pub fn leer(&mut self, prompt: &str) -> Linea {
+ let fd = libc::STDIN_FILENO;
+ // Sin terminal (una tubería, un `simi < fichero`) no hay nada que editar: se lee una línea
+ // y se devuelve. Es el camino de los scripts y no debe pagar el coste del modo crudo.
+ let Some(_crudo) = Crudo::nuevo(fd) else {
+ return self.leer_sin_terminal();
+ };
+
+ let mut buf: Vec<char> = Vec::new();
+ let mut cur = 0usize; // posición del cursor, en CARACTERES (no bytes: hay acentos)
+ let mut idx_hist = self.historial.len(); // len() = «estoy en la línea nueva»
+ let mut guardada = String::new(); // la línea a medias, al navegar el historial
+ pintar(prompt, &buf, cur);
+
+ let mut entrada = std::io::stdin();
+ let mut byte = [0u8; 1];
+ loop {
+ if entrada.read_exact(&mut byte).is_err() {
+ print!("\n");
+ let _ = std::io::stdout().flush();
+ return if buf.is_empty() { Linea::Fin } else { Linea::Texto(buf.iter().collect()) };
+ }
+ match byte[0] {
+ b'\r' | b'\n' => {
+ print!("\n");
+ let _ = std::io::stdout().flush();
+ let linea: String = buf.iter().collect();
+ if !linea.trim().is_empty() && self.historial.last().map(String::as_str) != Some(linea.as_str()) {
+ self.historial.push(linea.clone());
+ if self.historial.len() > MAX_HISTORIAL {
+ self.historial.remove(0);
+ self.ya_en_disco = self.ya_en_disco.saturating_sub(1);
+ }
+ }
+ return Linea::Texto(linea);
+ }
+ 0x03 => {
+ // Ctrl-C: se abandona la línea y se sigue. NO termina la sesión.
+ print!("^C\n");
+ let _ = std::io::stdout().flush();
+ return Linea::Interrumpida;
+ }
+ 0x04 => {
+ // Ctrl-D: EOF sólo si la línea está vacía; si no, borra hacia adelante.
+ if buf.is_empty() {
+ print!("\n");
+ let _ = std::io::stdout().flush();
+ return Linea::Fin;
+ }
+ if cur < buf.len() {
+ buf.remove(cur);
+ pintar(prompt, &buf, cur);
+ }
+ }
+ 0x7f | 0x08 => {
+ if cur > 0 {
+ cur -= 1;
+ buf.remove(cur);
+ pintar(prompt, &buf, cur);
+ }
+ }
+ 0x01 => {
+ cur = 0;
+ pintar(prompt, &buf, cur);
+ }
+ 0x05 => {
+ cur = buf.len();
+ pintar(prompt, &buf, cur);
+ }
+ 0x02 => {
+ if cur > 0 {
+ cur -= 1;
+ pintar(prompt, &buf, cur);
+ }
+ }
+ 0x06 => {
+ if cur < buf.len() {
+ cur += 1;
+ pintar(prompt, &buf, cur);
+ }
+ }
+ 0x0b => {
+ buf.truncate(cur);
+ pintar(prompt, &buf, cur);
+ }
+ 0x15 => {
+ buf.drain(..cur);
+ cur = 0;
+ pintar(prompt, &buf, cur);
+ }
+ 0x17 => {
+ // Ctrl-W: borra la palabra de atrás, saltando los espacios primero.
+ while cur > 0 && buf[cur - 1].is_whitespace() {
+ cur -= 1;
+ buf.remove(cur);
+ }
+ while cur > 0 && !buf[cur - 1].is_whitespace() {
+ cur -= 1;
+ buf.remove(cur);
+ }
+ pintar(prompt, &buf, cur);
+ }
+ 0x0c => {
+ print!("\x1b[H\x1b[2J");
+ pintar(prompt, &buf, cur);
+ }
+ b'\t' => {
+ completar(&mut buf, &mut cur, prompt);
+ }
+ 0x1b => {
+ // secuencia de escape: flechas, Inicio/Fin, Supr
+ let mut s = [0u8; 2];
+ if entrada.read_exact(&mut s[..1]).is_err() {
+ continue;
+ }
+ if s[0] != b'[' && s[0] != b'O' {
+ continue;
+ }
+ if entrada.read_exact(&mut s[1..]).is_err() {
+ continue;
+ }
+ match s[1] {
+ b'A' => {
+ // Arriba: hacia atrás en el historial
+ if idx_hist > 0 {
+ if idx_hist == self.historial.len() {
+ guardada = buf.iter().collect();
+ }
+ idx_hist -= 1;
+ buf = self.historial[idx_hist].chars().collect();
+ cur = buf.len();
+ pintar(prompt, &buf, cur);
+ }
+ }
+ b'B' => {
+ if idx_hist < self.historial.len() {
+ idx_hist += 1;
+ buf = if idx_hist == self.historial.len() {
+ guardada.chars().collect()
+ } else {
+ self.historial[idx_hist].chars().collect()
+ };
+ cur = buf.len();
+ pintar(prompt, &buf, cur);
+ }
+ }
+ b'C' => {
+ if cur < buf.len() {
+ cur += 1;
+ pintar(prompt, &buf, cur);
+ }
+ }
+ b'D' => {
+ if cur > 0 {
+ cur -= 1;
+ pintar(prompt, &buf, cur);
+ }
+ }
+ b'H' => {
+ cur = 0;
+ pintar(prompt, &buf, cur);
+ }
+ b'F' => {
+ cur = buf.len();
+ pintar(prompt, &buf, cur);
+ }
+ b'3' => {
+ // Supr manda `ESC [ 3 ~`: hay que tragarse la tilde
+ let mut t = [0u8; 1];
+ let _ = entrada.read_exact(&mut t);
+ if cur < buf.len() {
+ buf.remove(cur);
+ pintar(prompt, &buf, cur);
+ }
+ }
+ _ => {}
+ }
+ }
+ b if b >= 0x20 => {
+ // ⚠ UTF-8: un carácter puede venir en varios bytes y hay que juntarlos ANTES de
+ // meterlo en el buffer. Sin esto, escribir «ñ» insertaba dos caracteres basura y
+ // el cursor quedaba descolocado — es el bug que hace que un editor de línea a
+ // mano se note hecho en inglés.
+ let extra = if b >= 0xf0 {
+ 3
+ } else if b >= 0xe0 {
+ 2
+ } else if b >= 0xc0 {
+ 1
+ } else {
+ 0
+ };
+ let mut bytes = vec![b];
+ for _ in 0..extra {
+ let mut x = [0u8; 1];
+ if entrada.read_exact(&mut x).is_ok() {
+ bytes.push(x[0]);
+ }
+ }
+ if let Ok(s) = std::str::from_utf8(&bytes) {
+ for c in s.chars() {
+ buf.insert(cur, c);
+ cur += 1;
+ }
+ pintar(prompt, &buf, cur);
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ fn leer_sin_terminal(&mut self) -> Linea {
+ let mut l = String::new();
+ match std::io::stdin().read_line(&mut l) {
+ Ok(0) => Linea::Fin,
+ Ok(_) => Linea::Texto(l.trim_end_matches('\n').to_string()),
+ Err(_) => Linea::Fin,
+ }
+ }
+}
+
+/// Redibuja la línea entera. Es el método simple y robusto: `\r` al principio, prompt, contenido,
+/// `ESC[K` para borrar lo que sobre, y el cursor se recoloca contando caracteres.
+///
+/// ⚠ No trocea por ancho de terminal: una línea más larga que la pantalla se ve mal aunque su
+/// CONTENIDO sea correcto. Dicho arriba entre lo que no cubre.
+fn pintar(prompt: &str, buf: &[char], cur: usize) {
+ let txt: String = buf.iter().collect();
+ let mut o = std::io::stdout();
+ let _ = write!(o, "\r\x1b[K{prompt}{txt}");
+ let atras = buf.len() - cur;
+ if atras > 0 {
+ let _ = write!(o, "\x1b[{atras}D");
+ }
+ let _ = o.flush();
+}
+
+/// Tab. Dos reglas, y la primera es la que hace que se sienta bien:
+/// · si la palabra es la PRIMERA de la orden ⇒ se completan ÓRDENES (builtins + lo ejecutable
+/// del `PATH`), porque ahí nadie quiere un nombre de fichero cualquiera;
+/// · si no ⇒ se completan RUTAS.
+/// Con una sola candidata se inserta; con varias se imprime la lista y se completa el prefijo común,
+/// que es lo que evita tener que teclear a ciegas.
+fn completar(buf: &mut Vec<char>, cur: &mut usize, prompt: &str) {
+ let hasta: String = buf[..*cur].iter().collect();
+ let ini = hasta
+ .char_indices()
+ .rev()
+ .find(|(_, c)| c.is_whitespace())
+ .map(|(i, c)| i + c.len_utf8())
+ .unwrap_or(0);
+ let palabra = &hasta[ini..];
+ let es_orden = hasta[..ini].trim().is_empty();
+
+ let mut cands: Vec<String> =
+ if es_orden && !palabra.contains('/') { ordenes_que_empiezan(palabra) } else { rutas_que_empiezan(palabra) };
+ cands.sort();
+ cands.dedup();
+ if cands.is_empty() {
+ return;
+ }
+
+ // prefijo común de todas las candidatas: con una sola, es la candidata entera
+ let comun = prefijo_comun(&cands);
+ if comun.len() > palabra.len() {
+ let resto: Vec<char> = comun[palabra.len()..].chars().collect();
+ for c in resto {
+ buf.insert(*cur, c);
+ *cur += 1;
+ }
+ }
+ if cands.len() == 1 {
+ // una sola: se cierra con espacio (o con `/` si es directorio, que ya viene puesto)
+ if !comun.ends_with('/') {
+ buf.insert(*cur, ' ');
+ *cur += 1;
+ }
+ } else {
+ print!("\n");
+ for (i, c) in cands.iter().take(80).enumerate() {
+ print!("{c} ");
+ if (i + 1) % 6 == 0 {
+ print!("\n");
+ }
+ }
+ if cands.len() > 80 {
+ print!("\n… y {} más", cands.len() - 80);
+ }
+ print!("\n");
+ }
+ pintar(prompt, buf, *cur);
+}
+
+fn prefijo_comun(v: &[String]) -> String {
+ let Some(primero) = v.first() else { return String::new() };
+ let mut n = primero.chars().count();
+ for s in &v[1..] {
+ n = n.min(
+ primero.chars().zip(s.chars()).take_while(|(a, b)| a == b).count(),
+ );
+ }
+ primero.chars().take(n).collect()
+}
+
+fn ordenes_que_empiezan(pre: &str) -> Vec<String> {
+ let mut v: Vec<String> =
+ crate::builtins::nombres().iter().filter(|b| b.starts_with(pre)).map(|b| b.to_string()).collect();
+ if let Some(path) = std::env::var_os("PATH") {
+ for dir in std::env::split_paths(&path) {
+ let Ok(it) = std::fs::read_dir(&dir) else { continue };
+ for e in it.flatten() {
+ let n = e.file_name();
+ let Some(n) = n.to_str() else { continue };
+ if !n.starts_with(pre) {
+ continue;
+ }
+ // ejecutable para alguien: el bit x, que es lo que el shell exigiría al correrlo
+ if let Ok(m) = e.metadata() {
+ use std::os::unix::fs::PermissionsExt;
+ if m.is_file() && m.permissions().mode() & 0o111 != 0 {
+ v.push(n.to_string());
+ }
+ }
+ }
+ }
+ }
+ v
+}
+
+fn rutas_que_empiezan(pre: &str) -> Vec<String> {
+ let (dir, hoja) = match pre.rfind('/') {
+ Some(i) => (&pre[..=i], &pre[i + 1..]),
+ None => ("", pre),
+ };
+ let base = if dir.is_empty() { Path::new(".") } else { Path::new(dir) };
+ let Ok(it) = std::fs::read_dir(base) else { return Vec::new() };
+ let mut v = Vec::new();
+ for e in it.flatten() {
+ let n = e.file_name();
+ let Some(n) = n.to_str() else { continue };
+ if !n.starts_with(hoja) {
+ continue;
+ }
+ // los ocultos sólo si se pidieron: teclear Tab en `~` no debe vomitar 40 dotfiles
+ if n.starts_with('.') && !hoja.starts_with('.') {
+ continue;
+ }
+ let es_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
+ v.push(format!("{dir}{n}{}", if es_dir { "/" } else { "" }));
+ }
+ v
+}
+
+/// `$PS1`, con lo mínimo que la gente espera: `\u` `\h` `\w` `\$`, y el `\[`/`\]` de bash se
+/// IGNORA (no se pinta) porque si no aparecen como basura en pantalla.
+pub fn prompt(sh: &crate::shell::Shell) -> String {
+ let ps1 = sh.leer("PS1").unwrap_or_else(|| "$ ".to_string());
+ let mut o = String::new();
+ let mut cs = ps1.chars().peekable();
+ while let Some(c) = cs.next() {
+ if c != '\\' {
+ o.push(c);
+ continue;
+ }
+ match cs.next() {
+ Some('u') => o.push_str(&std::env::var("USER").unwrap_or_else(|_| "root".into())),
+ Some('h') => o.push_str(&nombre_de_maquina()),
+ Some('w') => {
+ let d = std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_default();
+ let casa = std::env::var("HOME").unwrap_or_default();
+ o.push_str(&if !casa.is_empty() && d.starts_with(&casa) {
+ format!("~{}", &d[casa.len()..])
+ } else {
+ d
+ });
+ }
+ Some('$') => o.push(if unsafe { libc::geteuid() } == 0 { '#' } else { '$' }),
+ Some('n') => o.push('\n'),
+ Some('[') | Some(']') => {}
+ Some(x) => o.push(x),
+ None => {}
+ }
+ }
+ o
+}
+
+fn nombre_de_maquina() -> String {
+ std::fs::read_to_string("/proc/sys/kernel/hostname")
+ .map(|s| s.trim().to_string())
+ .unwrap_or_else(|_| "takana".into())
+}
+
+/// Para que `OsStrExt` no quede sin usar si alguien recorta esto: se usa al comparar nombres.
+#[allow(dead_code)]
+fn _usa_osstrext(p: &Path) -> usize {
+ p.as_os_str().as_bytes().len()
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ /// ⚠ **HERMÉTICO A PROPÓSITO: se crea el árbol que se consulta.** La primera versión miraba
+ /// `/usr/bin` y FALLABA — no por un bug, sino porque en la jaula de desarrollo `/usr` lista
+ /// CERO entradas aunque `[ -d /usr/bin ]` diga que sí (patología conocida de la jaula). Estuve
+ /// a punto de "arreglar" código que funcionaba. Un test que depende del sistema de ficheros
+ /// ambiente no mide lo que cree medir.
+ #[test]
+ fn completa_rutas() {
+ let base = std::env::temp_dir().join(format!("simi-comp-{}", unsafe { libc::getpid() }));
+ let _ = std::fs::remove_dir_all(&base);
+ std::fs::create_dir_all(base.join("bicho")).unwrap();
+ std::fs::write(base.join("bicicleta"), b"x").unwrap();
+ std::fs::write(base.join("otro"), b"x").unwrap();
+ std::fs::write(base.join(".oculto"), b"x").unwrap();
+ let d = format!("{}/", base.display());
+
+ let v = rutas_que_empiezan(&format!("{d}bic"));
+ assert!(v.iter().any(|c| c.ends_with("bicho/")), "el directorio lleva / al final: {v:?}");
+ assert!(v.iter().any(|c| c.ends_with("bicicleta")), "candidatas: {v:?}");
+ assert_eq!(v.len(), 2, "sólo las dos que empiezan por bic: {v:?}");
+
+ // prefijo que no existe ⇒ nada
+ assert!(rutas_que_empiezan(&format!("{d}zzz-no-existe")).is_empty());
+
+ // los ocultos NO salen si no se piden, y SÍ si se piden
+ let todos = rutas_que_empiezan(&d);
+ assert!(!todos.iter().any(|c| c.contains("/.oculto")), "no debe listar ocultos: {todos:?}");
+ let pedidos = rutas_que_empiezan(&format!("{d}."));
+ assert!(pedidos.iter().any(|c| c.ends_with(".oculto")), "pedidos: {pedidos:?}");
+
+ let _ = std::fs::remove_dir_all(&base);
+ }
+
+ /// El completado de ÓRDENES tiene que ofrecer los builtins, que no están en el PATH. Eso sí se
+ /// puede medir sin tocar el disco.
+ #[test]
+ fn completa_ordenes_con_builtins() {
+ let v = ordenes_que_empiezan("ech");
+ assert!(v.iter().any(|c| c == "echo"), "candidatas: {v:?}");
+ let v = ordenes_que_empiezan("umas");
+ assert!(v.iter().any(|c| c == "umask"), "candidatas: {v:?}");
+ }
+
+ #[test]
+ fn prefijo_comun_funciona() {
+ assert_eq!(prefijo_comun(&["abcd".into(), "abce".into()]), "abc");
+ assert_eq!(prefijo_comun(&["solo".into()]), "solo");
+ assert_eq!(prefijo_comun(&["xa".into(), "yb".into()]), "");
+ }
+}
diff --git a/crates/simi/src/main.rs b/crates/simi/src/main.rs
index e6a0296f..68bce53d 100644
--- a/crates/simi/src/main.rs
+++ b/crates/simi/src/main.rs
@@ -48,6 +48,7 @@ mod exec;
mod expand;
mod glob;
mod lexer;
+mod linea;
mod parser;
mod shell;
@@ -131,6 +132,17 @@ fn main() -> std::process::ExitCode {
}
}
(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 {
+ 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);
@@ -162,3 +174,59 @@ fn main() -> std::process::ExitCode {
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);
+ 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)
+}