tawasuyu

sergio/takana

> 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…

TOML 911Rust 57Shell 37Markdown 321282 Definiciones

⎇ main crates/simi/src/linea.rs Rust · 719 líneas · 29.3 KB Historia Quién tocó qué crudo

feat(simi): el prompt hacía cinco escapes y ninguna sustitución — ningún prompt real funcionaba 2b8c20005 · Sergio ·

1//! `linea` — el editor de línea de simi: historial, autocompletado y edición con teclas.
2//!
3//! **Por qué a mano.** La cabecera de `Cargo.toml` lo dice y vale releerla: *«este binario es la
4//! raíz de confianza del frente C (`ATTEST_PATHS`), y cada dep suya entra en esa raíz»*. Meter
5//! `rustyline` o `reedline` aquí metería sus árboles de dependencias en lo que el arranque atesta
6//! por BLAKE3. Así que esto es termios + secuencias ANSI, con `libc` y nada más.
7//!
8//! **Qué cubre**, que es exactamente lo que la cabecera de `main.rs` decía que faltaba:
9//! historial (en memoria y en disco), autocompletado por Tab, y las teclas de edición que alguien
10//! espera al entrar por consola — flechas, Inicio/Fin, y los controles de estilo emacs que son los
11//! que tiene el `ash` al que esto reemplaza.
12//!
13//! ⚠ **Lo que NO cubre, dicho en vez de fingirlo:** control de trabajos (`fg`/`bg`/`jobs` siguen
14//! sin existir en simi), búsqueda incremental (`Ctrl-R`), multilínea con continuación, y edición de
15//! texto que ocupe más de una fila de la terminal — si la línea pasa del ancho, el redibujado la
16//! deja fea aunque el contenido es correcto. Son añadidos posibles sobre esto, no agujeros tapados.
17
18use std::io::{Read, Write};
19use std::os::unix::ffi::OsStrExt;
20use std::path::Path;
21
22const MAX_HISTORIAL: usize = 2000;
23
24/// Lo que devuelve una lectura. `Fin` es Ctrl-D en línea vacía (EOF), `Interrumpida` es Ctrl-C:
25/// son cosas distintas y el REPL las trata distinto — una termina la sesión, la otra no.
26pub enum Linea {
27    Texto(String),
28    Fin,
29    Interrumpida,
30}
31
32/// Modo crudo con restauración por RAII.
33///
34/// ⚠ **El `Drop` no es un adorno: sin él, cualquier `panic!` o `return` temprano deja la terminal
35/// MUDA** —sin eco, sin salto de línea— y el usuario tiene que escribir `reset` a ciegas. Es el
36/// fallo clásico de los editores de línea escritos a mano, y por eso el estado anterior se guarda
37/// en la estructura en vez de en una variable suelta.
38struct Crudo {
39    fd: i32,
40    antes: libc::termios,
41}
42
43impl Crudo {
44    fn nuevo(fd: i32) -> Option<Crudo> {
45        unsafe {
46            let mut t: libc::termios = std::mem::zeroed();
47            if libc::tcgetattr(fd, &mut t) != 0 {
48                return None;
49            }
50            let antes = t;
51            // ICANON fuera: leer tecla por tecla. ECHO fuera: lo pintamos nosotros.
52            // ISIG fuera a propósito: queremos el 0x03 de Ctrl-C como BYTE, no como SIGINT, para
53            // poder limpiar la línea y seguir — que es lo que hace un shell y no un programa.
54            // IXON fuera: si no, Ctrl-S congela la terminal y parece que el shell se colgó.
55            t.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
56            t.c_iflag &= !(libc::IXON | libc::ICRNL);
57            // OPOST se DEJA puesto: queremos que `\n` siga siendo salto de línea de verdad.
58            t.c_cc[libc::VMIN] = 1;
59            t.c_cc[libc::VTIME] = 0;
60            if libc::tcsetattr(fd, libc::TCSAFLUSH, &t) != 0 {
61                return None;
62            }
63            Some(Crudo { fd, antes })
64        }
65    }
66}
67
68impl Drop for Crudo {
69    fn drop(&mut self) {
70        unsafe {
71            libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.antes);
72        }
73    }
74}
75
76pub struct Editor {
77    historial: Vec<String>,
78    fichero: Option<std::path::PathBuf>,
79    /// Cuántas entradas había al cargar: sólo se APENDEA lo nuevo, para no pisar el historial de
80    /// otra sesión que esté escribiendo en el mismo fichero.
81    ya_en_disco: usize,
82}
83
84impl Editor {
85    pub fn nuevo() -> Editor {
86        let fichero = std::env::var_os("HOME").map(|h| Path::new(&h).join(".simi_historial"));
87        let mut historial = Vec::new();
88        if let Some(f) = &fichero {
89            if let Ok(t) = std::fs::read_to_string(f) {
90                historial.extend(t.lines().filter(|l| !l.trim().is_empty()).map(String::from));
91            }
92        }
93        if historial.len() > MAX_HISTORIAL {
94            let sobra = historial.len() - MAX_HISTORIAL;
95            historial.drain(..sobra);
96        }
97        let ya_en_disco = historial.len();
98        Editor { historial, fichero, ya_en_disco }
99    }
100
101    /// Apendea al fichero lo que esta sesión añadió. No reescribe: así dos simi a la vez no se
102    /// pisan (se intercalan, que es lo que hace bash sin `histappend` y es aceptable).
103    pub fn guardar(&mut self) {
104        let Some(f) = &self.fichero else { return };
105        if self.historial.len() <= self.ya_en_disco {
106            return;
107        }
108        let nuevo: String =
109            self.historial[self.ya_en_disco..].iter().map(|l| format!("{l}\n")).collect();
110        if let Ok(mut h) = std::fs::OpenOptions::new().create(true).append(true).open(f) {
111            let _ = h.write_all(nuevo.as_bytes());
112        }
113        self.ya_en_disco = self.historial.len();
114    }
115
116    pub fn leer(&mut self, prompt: &str) -> Linea {
117        let fd = libc::STDIN_FILENO;
118        // Sin terminal (una tubería, un `simi < fichero`) no hay nada que editar: se lee una línea
119        // y se devuelve. Es el camino de los scripts y no debe pagar el coste del modo crudo.
120        let Some(_crudo) = Crudo::nuevo(fd) else {
121            return self.leer_sin_terminal();
122        };
123
124        let mut buf: Vec<char> = Vec::new();
125        let mut cur = 0usize; // posición del cursor, en CARACTERES (no bytes: hay acentos)
126        let mut idx_hist = self.historial.len(); // len() = «estoy en la línea nueva»
127        let mut guardada = String::new(); // la línea a medias, al navegar el historial
128        pintar(prompt, &buf, cur);
129
130        let mut entrada = std::io::stdin();
131        let mut byte = [0u8; 1];
132        loop {
133            if entrada.read_exact(&mut byte).is_err() {
134                print!("\n");
135                let _ = std::io::stdout().flush();
136                return if buf.is_empty() { Linea::Fin } else { Linea::Texto(buf.iter().collect()) };
137            }
138            match byte[0] {
139                b'\r' | b'\n' => {
140                    print!("\n");
141                    let _ = std::io::stdout().flush();
142                    let linea: String = buf.iter().collect();
143                    if !linea.trim().is_empty() && self.historial.last().map(String::as_str) != Some(linea.as_str()) {
144                        self.historial.push(linea.clone());
145                        if self.historial.len() > MAX_HISTORIAL {
146                            self.historial.remove(0);
147                            self.ya_en_disco = self.ya_en_disco.saturating_sub(1);
148                        }
149                    }
150                    return Linea::Texto(linea);
151                }
152                0x03 => {
153                    // Ctrl-C: se abandona la línea y se sigue. NO termina la sesión.
154                    print!("^C\n");
155                    let _ = std::io::stdout().flush();
156                    return Linea::Interrumpida;
157                }
158                0x04 => {
159                    // Ctrl-D: EOF sólo si la línea está vacía; si no, borra hacia adelante.
160                    if buf.is_empty() {
161                        print!("\n");
162                        let _ = std::io::stdout().flush();
163                        return Linea::Fin;
164                    }
165                    if cur < buf.len() {
166                        buf.remove(cur);
167                        pintar(prompt, &buf, cur);
168                    }
169                }
170                0x7f | 0x08 => {
171                    if cur > 0 {
172                        cur -= 1;
173                        buf.remove(cur);
174                        pintar(prompt, &buf, cur);
175                    }
176                }
177                0x01 => {
178                    cur = 0;
179                    pintar(prompt, &buf, cur);
180                }
181                0x05 => {
182                    cur = buf.len();
183                    pintar(prompt, &buf, cur);
184                }
185                0x02 => {
186                    if cur > 0 {
187                        cur -= 1;
188                        pintar(prompt, &buf, cur);
189                    }
190                }
191                0x06 => {
192                    if cur < buf.len() {
193                        cur += 1;
194                        pintar(prompt, &buf, cur);
195                    }
196                }
197                0x0b => {
198                    buf.truncate(cur);
199                    pintar(prompt, &buf, cur);
200                }
201                0x15 => {
202                    buf.drain(..cur);
203                    cur = 0;
204                    pintar(prompt, &buf, cur);
205                }
206                0x17 => {
207                    // Ctrl-W: borra la palabra de atrás, saltando los espacios primero.
208                    while cur > 0 && buf[cur - 1].is_whitespace() {
209                        cur -= 1;
210                        buf.remove(cur);
211                    }
212                    while cur > 0 && !buf[cur - 1].is_whitespace() {
213                        cur -= 1;
214                        buf.remove(cur);
215                    }
216                    pintar(prompt, &buf, cur);
217                }
218                0x0c => {
219                    print!("\x1b[H\x1b[2J");
220                    pintar(prompt, &buf, cur);
221                }
222                b'\t' => {
223                    completar(&mut buf, &mut cur, prompt);
224                }
225                0x1b => {
226                    // secuencia de escape: flechas, Inicio/Fin, Supr
227                    let mut s = [0u8; 2];
228                    if entrada.read_exact(&mut s[..1]).is_err() {
229                        continue;
230                    }
231                    if s[0] != b'[' && s[0] != b'O' {
232                        continue;
233                    }
234                    if entrada.read_exact(&mut s[1..]).is_err() {
235                        continue;
236                    }
237                    match s[1] {
238                        b'A' => {
239                            // Arriba: hacia atrás en el historial
240                            if idx_hist > 0 {
241                                if idx_hist == self.historial.len() {
242                                    guardada = buf.iter().collect();
243                                }
244                                idx_hist -= 1;
245                                buf = self.historial[idx_hist].chars().collect();
246                                cur = buf.len();
247                                pintar(prompt, &buf, cur);
248                            }
249                        }
250                        b'B' => {
251                            if idx_hist < self.historial.len() {
252                                idx_hist += 1;
253                                buf = if idx_hist == self.historial.len() {
254                                    guardada.chars().collect()
255                                } else {
256                                    self.historial[idx_hist].chars().collect()
257                                };
258                                cur = buf.len();
259                                pintar(prompt, &buf, cur);
260                            }
261                        }
262                        b'C' => {
263                            if cur < buf.len() {
264                                cur += 1;
265                                pintar(prompt, &buf, cur);
266                            }
267                        }
268                        b'D' => {
269                            if cur > 0 {
270                                cur -= 1;
271                                pintar(prompt, &buf, cur);
272                            }
273                        }
274                        b'H' => {
275                            cur = 0;
276                            pintar(prompt, &buf, cur);
277                        }
278                        b'F' => {
279                            cur = buf.len();
280                            pintar(prompt, &buf, cur);
281                        }
282                        b'3' => {
283                            // Supr manda `ESC [ 3 ~`: hay que tragarse la tilde
284                            let mut t = [0u8; 1];
285                            let _ = entrada.read_exact(&mut t);
286                            if cur < buf.len() {
287                                buf.remove(cur);
288                                pintar(prompt, &buf, cur);
289                            }
290                        }
291                        _ => {}
292                    }
293                }
294                b if b >= 0x20 => {
295                    // ⚠ UTF-8: un carácter puede venir en varios bytes y hay que juntarlos ANTES de
296                    // meterlo en el buffer. Sin esto, escribir «ñ» insertaba dos caracteres basura y
297                    // el cursor quedaba descolocado — es el bug que hace que un editor de línea a
298                    // mano se note hecho en inglés.
299                    let extra = if b >= 0xf0 {
300                        3
301                    } else if b >= 0xe0 {
302                        2
303                    } else if b >= 0xc0 {
304                        1
305                    } else {
306                        0
307                    };
308                    let mut bytes = vec![b];
309                    for _ in 0..extra {
310                        let mut x = [0u8; 1];
311                        if entrada.read_exact(&mut x).is_ok() {
312                            bytes.push(x[0]);
313                        }
314                    }
315                    if let Ok(s) = std::str::from_utf8(&bytes) {
316                        for c in s.chars() {
317                            buf.insert(cur, c);
318                            cur += 1;
319                        }
320                        pintar(prompt, &buf, cur);
321                    }
322                }
323                _ => {}
324            }
325        }
326    }
327
328    fn leer_sin_terminal(&mut self) -> Linea {
329        let mut l = String::new();
330        match std::io::stdin().read_line(&mut l) {
331            Ok(0) => Linea::Fin,
332            Ok(_) => Linea::Texto(l.trim_end_matches('\n').to_string()),
333            Err(_) => Linea::Fin,
334        }
335    }
336}
337
338/// Redibuja la línea entera. Es el método simple y robusto: `\r` al principio, prompt, contenido,
339/// `ESC[K` para borrar lo que sobre, y el cursor se recoloca contando caracteres.
340///
341/// ⚠ No trocea por ancho de terminal: una línea más larga que la pantalla se ve mal aunque su
342/// CONTENIDO sea correcto. Dicho arriba entre lo que no cubre.
343fn pintar(prompt: &str, buf: &[char], cur: usize) {
344    let txt: String = buf.iter().collect();
345    let mut o = std::io::stdout();
346    let _ = write!(o, "\r\x1b[K{prompt}{txt}");
347    let atras = buf.len() - cur;
348    if atras > 0 {
349        let _ = write!(o, "\x1b[{atras}D");
350    }
351    let _ = o.flush();
352}
353
354/// Tab. Dos reglas, y la primera es la que hace que se sienta bien:
355///   · si la palabra es la PRIMERA de la orden ⇒ se completan ÓRDENES (builtins + lo ejecutable
356///     del `PATH`), porque ahí nadie quiere un nombre de fichero cualquiera;
357///   · si no ⇒ se completan RUTAS.
358/// Con una sola candidata se inserta; con varias se imprime la lista y se completa el prefijo común,
359/// que es lo que evita tener que teclear a ciegas.
360fn completar(buf: &mut Vec<char>, cur: &mut usize, prompt: &str) {
361    let hasta: String = buf[..*cur].iter().collect();
362    let ini = hasta
363        .char_indices()
364        .rev()
365        .find(|(_, c)| c.is_whitespace())
366        .map(|(i, c)| i + c.len_utf8())
367        .unwrap_or(0);
368    let palabra = &hasta[ini..];
369    let es_orden = hasta[..ini].trim().is_empty();
370
371    let mut cands: Vec<String> =
372        if es_orden && !palabra.contains('/') { ordenes_que_empiezan(palabra) } else { rutas_que_empiezan(palabra) };
373    cands.sort();
374    cands.dedup();
375    if cands.is_empty() {
376        return;
377    }
378
379    // prefijo común de todas las candidatas: con una sola, es la candidata entera
380    let comun = prefijo_comun(&cands);
381    if comun.len() > palabra.len() {
382        let resto: Vec<char> = comun[palabra.len()..].chars().collect();
383        for c in resto {
384            buf.insert(*cur, c);
385            *cur += 1;
386        }
387    }
388    if cands.len() == 1 {
389        // una sola: se cierra con espacio (o con `/` si es directorio, que ya viene puesto)
390        if !comun.ends_with('/') {
391            buf.insert(*cur, ' ');
392            *cur += 1;
393        }
394    } else {
395        print!("\n");
396        for (i, c) in cands.iter().take(80).enumerate() {
397            print!("{c}  ");
398            if (i + 1) % 6 == 0 {
399                print!("\n");
400            }
401        }
402        if cands.len() > 80 {
403            print!("\n… y {} más", cands.len() - 80);
404        }
405        print!("\n");
406    }
407    pintar(prompt, buf, *cur);
408}
409
410fn prefijo_comun(v: &[String]) -> String {
411    let Some(primero) = v.first() else { return String::new() };
412    let mut n = primero.chars().count();
413    for s in &v[1..] {
414        n = n.min(
415            primero.chars().zip(s.chars()).take_while(|(a, b)| a == b).count(),
416        );
417    }
418    primero.chars().take(n).collect()
419}
420
421fn ordenes_que_empiezan(pre: &str) -> Vec<String> {
422    let mut v: Vec<String> =
423        crate::builtins::nombres().iter().filter(|b| b.starts_with(pre)).map(|b| b.to_string()).collect();
424    if let Some(path) = std::env::var_os("PATH") {
425        for dir in std::env::split_paths(&path) {
426            let Ok(it) = std::fs::read_dir(&dir) else { continue };
427            for e in it.flatten() {
428                let n = e.file_name();
429                let Some(n) = n.to_str() else { continue };
430                if !n.starts_with(pre) {
431                    continue;
432                }
433                // ejecutable para alguien: el bit x, que es lo que el shell exigiría al correrlo
434                if let Ok(m) = e.metadata() {
435                    use std::os::unix::fs::PermissionsExt;
436                    if m.is_file() && m.permissions().mode() & 0o111 != 0 {
437                        v.push(n.to_string());
438                    }
439                }
440            }
441        }
442    }
443    v
444}
445
446fn rutas_que_empiezan(pre: &str) -> Vec<String> {
447    let (dir, hoja) = match pre.rfind('/') {
448        Some(i) => (&pre[..=i], &pre[i + 1..]),
449        None => ("", pre),
450    };
451    let base = if dir.is_empty() { Path::new(".") } else { Path::new(dir) };
452    let Ok(it) = std::fs::read_dir(base) else { return Vec::new() };
453    let mut v = Vec::new();
454    for e in it.flatten() {
455        let n = e.file_name();
456        let Some(n) = n.to_str() else { continue };
457        if !n.starts_with(hoja) {
458            continue;
459        }
460        // los ocultos sólo si se pidieron: teclear Tab en `~` no debe vomitar 40 dotfiles
461        if n.starts_with('.') && !hoja.starts_with('.') {
462            continue;
463        }
464        let es_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
465        v.push(format!("{dir}{n}{}", if es_dir { "/" } else { "" }));
466    }
467    v
468}
469
470/// `$PS1`, con lo mínimo que la gente espera: `\u` `\h` `\w` `\$`, y el `\[`/`\]` de bash se
471/// IGNORA (no se pinta) porque si no aparecen como basura en pantalla.
472/// Expande `PS1`. Dos pasadas, en el orden de bash: primero los escapes de barra invertida, y
473/// sobre el resultado la expansión de `$VAR`, `$(…)` y backticks.
474///
475/// ⚠ Hasta el 2026-10-23 esto hacía SÓLO cinco escapes (`\u \h \w \$ \n`) y nada de expansión, y
476/// lo que no reconocía lo escupía SIN la barra — o sea que un prompt normal salía como basura
477/// legible pero mal, no como un error. Medido, con el PS1 de cada caso:
478///
479///     \033[1;32m\u…   ⇒  `033[1;32msergio@qorpa033[0m:…`   ← el color, literal
480///     \e[1;32m\u…     ⇒  `e[1;32msergioe[0m:…`             ← ídem
481///     [$(echo HOLA)]  ⇒  `[$(echo HOLA)]`                  ← sin ejecutar
482///     \t \A \s \!     ⇒  `t A s !`
483///
484/// Lo tercero es lo que más dolía: TODO prompt moderno es `$(…)` —starship en shells POSIX se
485/// documenta como `PS1='$(starship prompt)'`— y el hook de zoxide en modo `posix` se engancha
486/// añadiendo `$(__zoxide_hook)` a PS1. Sin sustitución de comandos ni starship pinta ni zoxide
487/// aprende directorios, y el prompt además muestra el `$(…)` crudo.
488///
489/// El `\$` y el `\\` se emiten como un centinela (`\u{1}`, `\u{2}`) y se restituyen DESPUÉS de la
490/// expansión: si no, un `\$` para usuario normal deja un `$` suelto que la segunda pasada
491/// intentaría expandir.
492impl Editor {
493    /// Cuántas órdenes lleva el historial. La usa `\!` del prompt.
494    pub fn largo_historial(&self) -> usize {
495        self.historial.len()
496    }
497}
498
499pub fn prompt(sh: &mut crate::shell::Shell, num_hist: usize) -> String {
500    let ps1 = sh.leer("PS1").unwrap_or_else(|| "$ ".to_string());
501    let crudo = escapes_ps1(&ps1, sh, num_hist);
502    // Un prompt que no parsea NO debe tumbar la sesión: se muestra sin expandir y se sigue.
503    let expandido = crate::exec::expandir_texto(sh, &crudo).unwrap_or_else(|_| crudo.clone());
504    expandido.replace('\u{1}', "$").replace('\u{2}', "\\")
505}
506
507/// `strftime` por libc. simi sólo depende de `libc`, así que no hay `chrono` ni `time`.
508fn ahora(fmt: &str) -> String {
509    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
510    let t = unsafe { libc::time(std::ptr::null_mut()) };
511    unsafe { libc::localtime_r(&t, &mut tm) };
512    let cf = match std::ffi::CString::new(fmt) {
513        Ok(c) => c,
514        Err(_) => return String::new(),
515    };
516    let mut buf = [0u8; 128];
517    let n = unsafe {
518        libc::strftime(buf.as_mut_ptr() as *mut libc::c_char, buf.len(), cf.as_ptr(), &tm)
519    };
520    String::from_utf8_lossy(&buf[..n]).into_owned()
521}
522
523/// El nombre del usuario. `$USER` NO sirve sola: antes se usaba con `unwrap_or("root")`, o sea que
524/// una sesión sin `USER` en el entorno mostraba `root` — justo la mentira más peligrosa que puede
525/// decir un prompt. Se cae a `$LOGNAME` y después a `/etc/passwd` por euid, que es lo que hace
526/// `getpwuid` pero sin pasar por NSS (simi es un binario musl estático).
527fn nombre_de_usuario() -> String {
528    for v in ["USER", "LOGNAME"] {
529        if let Ok(u) = std::env::var(v) {
530            if !u.is_empty() {
531                return u;
532            }
533        }
534    }
535    let euid = unsafe { libc::geteuid() };
536    if let Ok(t) = std::fs::read_to_string("/etc/passwd") {
537        for l in t.lines() {
538            let mut c = l.split(':');
539            let (Some(nom), Some(_), Some(uid)) = (c.next(), c.next(), c.next()) else { continue };
540            if uid.parse::<u32>() == Ok(euid) {
541                return nom.to_string();
542            }
543        }
544    }
545    format!("uid{euid}")
546}
547
548/// Primera pasada: los escapes de barra invertida de `PS1`, con el juego de bash.
549fn escapes_ps1(ps1: &str, sh: &crate::shell::Shell, num_hist: usize) -> String {
550    let mut o = String::new();
551    let mut cs = ps1.chars().peekable();
552    while let Some(c) = cs.next() {
553        if c != '\\' {
554            o.push(c);
555            continue;
556        }
557        match cs.next() {
558            Some('a') => o.push('\u{7}'),  // campana
559            Some('e') => o.push('\u{1b}'), // ESC — sin esto NINGÚN color funciona
560            Some('r') => o.push('\r'),
561            Some('n') => o.push('\n'),
562            Some('d') => o.push_str(&ahora("%a %b %e")),
563            Some('t') => o.push_str(&ahora("%H:%M:%S")),
564            Some('T') => o.push_str(&ahora("%I:%M:%S")),
565            Some('@') => o.push_str(&ahora("%I:%M %p")),
566            Some('A') => o.push_str(&ahora("%H:%M")),
567            Some('D') => {
568                // `\D{fmt}`; `\D{}` = el formato por defecto de la locale.
569                if cs.peek() == Some(&'{') {
570                    cs.next();
571                    let mut f = String::new();
572                    for ch in cs.by_ref() {
573                        if ch == '}' {
574                            break;
575                        }
576                        f.push(ch);
577                    }
578                    o.push_str(&ahora(if f.is_empty() { "%X" } else { &f }));
579                } else {
580                    o.push('D');
581                }
582            }
583            Some('u') => o.push_str(&nombre_de_usuario()),
584            Some('h') => {
585                let m = nombre_de_maquina();
586                o.push_str(m.split('.').next().unwrap_or(&m));
587            }
588            Some('H') => o.push_str(&nombre_de_maquina()),
589            Some('s') => o.push_str(&sh.nombre),
590            Some('v') | Some('V') => o.push_str(env!("CARGO_PKG_VERSION")),
591            Some('l') => {
592                // El basename del terminal, como `\l` de bash.
593                let t = unsafe { libc::ttyname(0) };
594                if t.is_null() {
595                    o.push_str("?");
596                } else {
597                    let n = unsafe { std::ffi::CStr::from_ptr(t) }.to_string_lossy().into_owned();
598                    o.push_str(n.rsplit('/').next().unwrap_or(&n));
599                }
600            }
601            Some('j') => o.push('0'), // simi no tiene control de trabajos; decir 0 y no mentir más
602            Some('!') | Some('#') => o.push_str(&(num_hist + 1).to_string()),
603            Some('w') => o.push_str(&ruta_con_tilde(false)),
604            Some('W') => o.push_str(&ruta_con_tilde(true)),
605            // Centinelas: se restituyen tras la expansión (ver `prompt`).
606            Some('$') => o.push(if unsafe { libc::geteuid() } == 0 { '#' } else { '\u{1}' }),
607            Some('\\') => o.push('\u{2}'),
608            Some('[') | Some(']') => {}
609            // Octal `\nnn`, que es como se escribe ESC en un fichero sh: `\033[1;32m`.
610            Some(d) if d.is_digit(8) => {
611                let mut n = d.to_digit(8).unwrap();
612                for _ in 0..2 {
613                    match cs.peek() {
614                        Some(x) if x.is_digit(8) => {
615                            n = n * 8 + cs.next().unwrap().to_digit(8).unwrap();
616                        }
617                        _ => break,
618                    }
619                }
620                if let Some(ch) = char::from_u32(n) {
621                    o.push(ch);
622                }
623            }
624            Some(x) => {
625                // Un escape que no conocemos se deja TAL CUAL, con su barra. Antes se emitía sin
626                // ella, que es cómo `\e[1;32m` se convertía en `e[1;32m` y parecía un prompt mal
627                // escrito en vez de un escape no implementado.
628                o.push('\\');
629                o.push(x);
630            }
631            None => o.push('\\'),
632        }
633    }
634    o
635}
636
637/// El cwd con `~` por `$HOME`. `solo_hoja` = el `\W` de bash (sólo el último componente).
638fn ruta_con_tilde(solo_hoja: bool) -> String {
639    let d = std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_default();
640    if solo_hoja {
641        if d == "/" {
642            return "/".into();
643        }
644        return d.rsplit('/').next().unwrap_or(&d).to_string();
645    }
646    let casa = std::env::var("HOME").unwrap_or_default();
647    // Prefijo por COMPONENTE: con HOME=/home/ana, el directorio /home/anabel no es «~bel».
648    if !casa.is_empty() && (d == casa || d.starts_with(&format!("{casa}/"))) {
649        format!("~{}", &d[casa.len()..])
650    } else {
651        d
652    }
653}
654
655fn nombre_de_maquina() -> String {
656    std::fs::read_to_string("/proc/sys/kernel/hostname")
657        .map(|s| s.trim().to_string())
658        .unwrap_or_else(|_| "takana".into())
659}
660
661/// Para que `OsStrExt` no quede sin usar si alguien recorta esto: se usa al comparar nombres.
662#[allow(dead_code)]
663fn _usa_osstrext(p: &Path) -> usize {
664    p.as_os_str().as_bytes().len()
665}
666
667#[cfg(test)]
668mod pruebas {
669    use super::*;
670
671    /// ⚠ **HERMÉTICO A PROPÓSITO: se crea el árbol que se consulta.** La primera versión miraba
672    /// `/usr/bin` y FALLABA — no por un bug, sino porque en la jaula de desarrollo `/usr` lista
673    /// CERO entradas aunque `[ -d /usr/bin ]` diga que sí (patología conocida de la jaula). Estuve
674    /// a punto de "arreglar" código que funcionaba. Un test que depende del sistema de ficheros
675    /// ambiente no mide lo que cree medir.
676    #[test]
677    fn completa_rutas() {
678        let base = std::env::temp_dir().join(format!("simi-comp-{}", unsafe { libc::getpid() }));
679        let _ = std::fs::remove_dir_all(&base);
680        std::fs::create_dir_all(base.join("bicho")).unwrap();
681        std::fs::write(base.join("bicicleta"), b"x").unwrap();
682        std::fs::write(base.join("otro"), b"x").unwrap();
683        std::fs::write(base.join(".oculto"), b"x").unwrap();
684        let d = format!("{}/", base.display());
685
686        let v = rutas_que_empiezan(&format!("{d}bic"));
687        assert!(v.iter().any(|c| c.ends_with("bicho/")), "el directorio lleva / al final: {v:?}");
688        assert!(v.iter().any(|c| c.ends_with("bicicleta")), "candidatas: {v:?}");
689        assert_eq!(v.len(), 2, "sólo las dos que empiezan por bic: {v:?}");
690
691        // prefijo que no existe ⇒ nada
692        assert!(rutas_que_empiezan(&format!("{d}zzz-no-existe")).is_empty());
693
694        // los ocultos NO salen si no se piden, y SÍ si se piden
695        let todos = rutas_que_empiezan(&d);
696        assert!(!todos.iter().any(|c| c.contains("/.oculto")), "no debe listar ocultos: {todos:?}");
697        let pedidos = rutas_que_empiezan(&format!("{d}."));
698        assert!(pedidos.iter().any(|c| c.ends_with(".oculto")), "pedidos: {pedidos:?}");
699
700        let _ = std::fs::remove_dir_all(&base);
701    }
702
703    /// El completado de ÓRDENES tiene que ofrecer los builtins, que no están en el PATH. Eso sí se
704    /// puede medir sin tocar el disco.
705    #[test]
706    fn completa_ordenes_con_builtins() {
707        let v = ordenes_que_empiezan("ech");
708        assert!(v.iter().any(|c| c == "echo"), "candidatas: {v:?}");
709        let v = ordenes_que_empiezan("umas");
710        assert!(v.iter().any(|c| c == "umask"), "candidatas: {v:?}");
711    }
712
713    #[test]
714    fn prefijo_comun_funciona() {
715        assert_eq!(prefijo_comun(&["abcd".into(), "abce".into()]), "abc");
716        assert_eq!(prefijo_comun(&["solo".into()]), "solo");
717        assert_eq!(prefijo_comun(&["xa".into(), "yb".into()]), "");
718    }
719}