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…
feat(simi): el prompt hacía cinco escapes y ninguna sustitución — ningún prompt real funcionaba
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}