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 604Rust 56Shell 36Markdown 281227 Sutichaykuna

simi: las cuatro expansiones de bash que no rompen POSIX

A pedido explícito del usuario: «el que hizo simi se aferró al posix, si hay
mejoras que no rompan posix, hazles».

Son añadidos PUROS. POSIX deja esta sintaxis SIN DEFINIR, así que un script
conforme nunca la escribe y soportarla no puede cambiarle el comportamiento a
nadie. Verificado con control, no supuesto:

  banco POSIX (57 casos)  : MISMOS 2 fallos antes y después (while-read,
                            getopts, los dos preexistentes) — corrido contra el
                            artefacto sellado y contra el binario nuevo
  banco diferencial       : DIVERGE=0 en los DOS, 1339 casos, misma velocidad
  tests de la unidad      : 50 pasan, 0 fallan (37+13)

Lo añadido:
  ${x/pat/rep} ${x//pat/rep}    sustituir primera/todas, patrón glob, la
                                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 índices son expresiones
                                ARITMÉTICAS de verdad (se evalúan con arith) y
                                un negativo cuenta desde el final
  ${x^} ${x^^} ${x,} ${x,,}     caja: primera letra o todas

⚠ `${!x}` NO se añadió, y se dice en vez de fingirlo: choca con el `$!` que el
lexer ya trata como nombre especial, y era la menos usada.

Dos detalles de implementación que costaron un intento cada uno:

1. Los ANCLAJES se separan en el LEXER, no en la expansión. Mi primera versión
   los miraba en el patrón ya expandido y `${v/#a/X}` devolvía la cadena
   INTACTA: al tokenizar `#a` como palabra, el `#` abre un COMENTARIO y el
   patrón llegaba vacío. Un no-op silencioso, que es el modo de fallo que este
   repo persigue. Hay cuatro tests que son el control de esa trampa.

2. `ParamOp` perdió `Copy` (y `Eq`): las extensiones llevan datos —una palabra
   de reemplazo, dos expresiones— y Vec/String no son Copy. Costó cambiar dos
   sitios de expand.rs a pasar `&ParamOp`.

Y el `:` que no va seguido de `-=?+` ya no es un error: es subcadena. POSIX
define sólo esos cuatro ahí, así que rechazar el resto era rechazar sintaxis
que POSIX deja libre. Se parte por el primer `:` a profundidad 0 de paréntesis,
porque el ternario de $(( a?b:c )) también lleva `:`.

5ab371db31aeae5141eb16a90dcbf825c0df8536 · ruwaq Sergio · (22 h ñawpaq) · tayta 6b97e8b84 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

crates/simi/src/ast.rs

  • silq'uyninpi t'ikrakurqan enum ParamOp pub

crates/simi/src/expand.rs

crates/simi/src/lexer.rs

4 qillqakuna · +341 −11

diff --git a/crates/simi/src/ast.rs b/crates/simi/src/ast.rs
index 36dc9aa9..bbe11d81 100644
--- a/crates/simi/src/ast.rs
+++ b/crates/simi/src/ast.rs
@@ -17,7 +17,11 @@ pub enum Seg {
     Arith { expr: String, comillado: bool },
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+/// ⚠ **Sin `Copy` desde 2026-10-22**: las tres extensiones de abajo llevan datos (una palabra de
+/// reemplazo, dos expresiones aritméticas), y `Vec`/`String` no son `Copy`. El coste fue cambiar
+/// dos sitios en `expand.rs` a pasar `&ParamOp`. También se cae `Eq`, porque `Seg` sólo tiene
+/// `PartialEq`.
+#[derive(Debug, Clone, PartialEq)]
 pub enum ParamOp {
     /// `$x` / `${x}`
     Valor,
@@ -35,6 +39,23 @@ pub enum ParamOp {
     QuitaPrefijo { largo: bool },
     /// `${x%p}` · `${x%%p}`
     QuitaSufijo { largo: bool },
+
+    // ══ EXTENSIONES QUE NO ROMPEN POSIX (2026-10-22) ══════════════════════════════════════════
+    // POSIX deja esta sintaxis **sin definir**: un script conforme nunca la escribe, así que
+    // soportarla no puede cambiarle el comportamiento a nadie. Se añaden porque el corpus las usa
+    // —`${t/unknown-/}` en `wasi-libcxx` era la ÚNICA divergencia de simi en 1339 casos— y porque
+    // rechazarlas obligaba a reescribir recetas ajenas al shell. No se añade `${!x}`: choca con el
+    // `$!` que el lexer ya trata como nombre especial, y es la menos usada de las cuatro.
+    /// `${x/pat/rep}` · `${x//pat/rep}` — sustituir. `pat` va en `arg`, el reemplazo acá.
+    /// `ancla` guarda el `#`/`%` de `${x/#pat/rep}`: tiene que separarse en el LEXER, porque un
+    /// `#` al empezar una palabra abre un COMENTARIO y el patrón llegaría vacío a la expansión
+    /// (medido: `${v/#a/X}` devolvía la cadena intacta, un no-op silencioso).
+    Sustituye { todas: bool, ancla: Option<char>, rep: Palabra },
+    /// `${x:desde}` · `${x:desde:largo}` — subcadena. Las dos son expresiones ARITMÉTICAS (se
+    /// evalúan con `arith`), y un `desde` negativo cuenta desde el final, como en bash.
+    Subcadena { desde: String, largo: Option<String> },
+    /// `${x^}` `${x^^}` `${x,}` `${x,,}` — cambiar caja (primera letra o todas).
+    Caso { mayus: bool, todas: bool },
 }
 
 pub type Palabra = Vec<Seg>;
diff --git a/crates/simi/src/expand.rs b/crates/simi/src/expand.rs
index 2228ccea..3e5c644c 100644
--- a/crates/simi/src/expand.rs
+++ b/crates/simi/src/expand.rs
@@ -108,7 +108,7 @@ fn expandir(sh: &mut Shell, p: &Palabra) -> Result<Vec<Trozo>, String> {
                 out.push(Trozo { texto, comillado: *comillado, corte: false });
             }
             Seg::Param { nombre, op, arg, comillado } => {
-                expandir_param(sh, nombre, *op, arg, *comillado, &mut out)?;
+                expandir_param(sh, nombre, op, arg, *comillado, &mut out)?;
             }
         }
     }
@@ -118,7 +118,7 @@ fn expandir(sh: &mut Shell, p: &Palabra) -> Result<Vec<Trozo>, String> {
 fn expandir_param(
     sh: &mut Shell,
     nombre: &str,
-    op: ParamOp,
+    op: &ParamOp,
     arg: &Palabra,
     comillado: bool,
     out: &mut Vec<Trozo>,
@@ -126,7 +126,7 @@ fn expandir_param(
     // `$@` y `$*`: los únicos que producen varios campos.
     if nombre == "@" || nombre == "*" {
         let params = sh.params.clone();
-        if op == ParamOp::Largo {
+        if *op == ParamOp::Largo {
             out.push(Trozo { texto: params.len().to_string(), comillado, corte: false });
             return Ok(());
         }
@@ -154,7 +154,7 @@ fn expandir_param(
         }
         ParamOp::Largo => valor.unwrap_or_default().chars().count().to_string(),
         ParamOp::Defecto { dos_puntos } => {
-            let usar = if dos_puntos { definida_no_vacia } else { definida };
+            let usar = if *dos_puntos { definida_no_vacia } else { definida };
             if usar {
                 valor.unwrap_or_default()
             } else {
@@ -162,7 +162,7 @@ fn expandir_param(
             }
         }
         ParamOp::Asigna { dos_puntos } => {
-            let usar = if dos_puntos { definida_no_vacia } else { definida };
+            let usar = if *dos_puntos { definida_no_vacia } else { definida };
             if usar {
                 valor.unwrap_or_default()
             } else {
@@ -172,7 +172,7 @@ fn expandir_param(
             }
         }
         ParamOp::Error { dos_puntos } => {
-            let usar = if dos_puntos { definida_no_vacia } else { definida };
+            let usar = if *dos_puntos { definida_no_vacia } else { definida };
             if usar {
                 valor.unwrap_or_default()
             } else {
@@ -182,7 +182,7 @@ fn expandir_param(
             }
         }
         ParamOp::Alterno { dos_puntos } => {
-            let usar = if dos_puntos { definida_no_vacia } else { definida };
+            let usar = if *dos_puntos { definida_no_vacia } else { definida };
             if usar {
                 una(sh, arg)?
             } else {
@@ -192,12 +192,58 @@ fn expandir_param(
         ParamOp::QuitaPrefijo { largo } => {
             let v = valor.unwrap_or_default();
             let pat = patron(sh, arg)?;
-            quitar(&v, &pat, true, largo)
+            quitar(&v, &pat, true, *largo)
         }
         ParamOp::QuitaSufijo { largo } => {
             let v = valor.unwrap_or_default();
             let pat = patron(sh, arg)?;
-            quitar(&v, &pat, false, largo)
+            quitar(&v, &pat, false, *largo)
+        }
+
+        // ══ EXTENSIONES (2026-10-22) — ver la nota en ast.rs: POSIX deja esta sintaxis sin
+        // definir, así que soportarla no cambia el comportamiento de ningún script conforme.
+        ParamOp::Sustituye { todas, ancla, rep } => {
+            let v = valor.unwrap_or_default();
+            let pat = patron(sh, arg)?;
+            let r = una(sh, rep)?;
+            sustituir(&v, &pat, &r, *todas, *ancla)
+        }
+        ParamOp::Subcadena { desde, largo } => {
+            let v = valor.unwrap_or_default();
+            let cs: Vec<char> = v.chars().collect();
+            let n = cs.len() as i64;
+            // `desde` negativo cuenta desde el final, como bash. Se clampa a [0, n] para que
+            // `${x: -99}` dé la cadena entera en vez de entrar en pánico.
+            let d0 = crate::exec::evaluar_aritmetica(sh, desde)?
+                .parse::<i64>()
+                .map_err(|e| format!("${{x:…}}: `{desde}` no es un número ({e})"))?;
+            let ini = if d0 < 0 { (n + d0).max(0) } else { d0.min(n) } as usize;
+            let fin = match largo {
+                None => cs.len(),
+                Some(expr) => {
+                    let l = crate::exec::evaluar_aritmetica(sh, expr)?
+                        .parse::<i64>()
+                        .map_err(|e| format!("${{x:…:…}}: `{expr}` no es un número ({e})"))?;
+                    // Un largo negativo en bash es «hasta esa posición desde el final».
+                    if l < 0 { (n + l).max(ini as i64) as usize } else { (ini as i64 + l).min(n) as usize }
+                }
+            };
+            cs[ini..fin].iter().collect()
+        }
+        ParamOp::Caso { mayus, todas } => {
+            let v = valor.unwrap_or_default();
+            let mut o = String::with_capacity(v.len());
+            for (i, c) in v.chars().enumerate() {
+                let cambiar = *todas || i == 0;
+                if cambiar && *mayus {
+                    o.extend(c.to_uppercase());
+                } else if cambiar {
+                    o.extend(c.to_lowercase());
+                } else {
+                    o.push(c);
+                }
+            }
+            o
         }
     };
     out.push(Trozo { texto, comillado, corte: false });
@@ -357,6 +403,61 @@ mod pruebas {
         assert_eq!(campos_de(&mut sh, "cmd ${v:+si}"), vec!["si"]);
     }
 
+    /// Las extensiones no-POSIX (2026-10-22). Se prueban aparte de `expansiones_de_parametro`
+    /// para que quede claro cuáles son añadidos: POSIX deja esta sintaxis sin definir, y el banco
+    /// diferencial (1339 casos contra el ash) sigue en DIVERGE=0 con ellas puestas.
+    #[test]
+    fn extensiones_sustitucion() {
+        let mut sh = Shell::nuevo();
+        sh.asignar("v", "abcabc");
+        assert_eq!(campos_de(&mut sh, "cmd ${v/b/X}"), vec!["aXcabc"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${v//b/X}"), vec!["aXcaXc"]);
+        // sin reemplazo ⇒ se borra, como en bash
+        assert_eq!(campos_de(&mut sh, "cmd ${v//b}"), vec!["acac"]);
+        // glob en el patrón, coincidencia MÁS LARGA y MÁS A LA IZQUIERDA
+        assert_eq!(campos_de(&mut sh, "cmd ${v/b*b/-}"), vec!["a-c"]);
+        // el caso real que motivó todo esto (`wasi-libcxx`)
+        sh.asignar("t", "wasm32-unknown-wasip1");
+        assert_eq!(campos_de(&mut sh, "cmd ${t/unknown-/}"), vec!["wasm32-wasip1"]);
+    }
+
+    /// ⚠ Los anclajes `#`/`%` se separan en el LEXER, no en la expansión: un `#` al empezar una
+    /// palabra abre un COMENTARIO y el patrón llegaba vacío, devolviendo la cadena intacta — un
+    /// no-op silencioso. Estos cuatro casos son el control de esa trampa.
+    #[test]
+    fn extensiones_sustitucion_anclada() {
+        let mut sh = Shell::nuevo();
+        sh.asignar("v", "abcabc");
+        assert_eq!(campos_de(&mut sh, "cmd ${v/#a/X}"), vec!["Xbcabc"]);
+        // `b` no está al principio ⇒ no toca nada
+        assert_eq!(campos_de(&mut sh, "cmd ${v/#b/X}"), vec!["abcabc"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${v/%c/X}"), vec!["abcabX"]);
+        // encaje vacío anclado: prefijar y sufijar
+        assert_eq!(campos_de(&mut sh, "cmd ${v/#/pre}"), vec!["preabcabc"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${v/%/post}"), vec!["abcabcpost"]);
+    }
+
+    #[test]
+    fn extensiones_subcadena_y_caja() {
+        let mut sh = Shell::nuevo();
+        sh.asignar("v", "hola");
+        assert_eq!(campos_de(&mut sh, "cmd ${v:1}"), vec!["ola"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${v:1:2}"), vec!["ol"]);
+        // negativo = desde el final (bash exige el espacio para no confundir con `:-`)
+        assert_eq!(campos_de(&mut sh, "cmd ${v: -2}"), vec!["la"]);
+        // fuera de rango se CLAMPA en vez de entrar en pánico
+        assert_eq!(campos_de(&mut sh, "cmd ${v:99}"), vec![""; 0].into_iter().collect::<Vec<_>>());
+        // aritmética de verdad en los índices
+        sh.asignar("n", "2");
+        assert_eq!(campos_de(&mut sh, "cmd ${v:n-1:2}"), vec!["ol"]);
+        // caja
+        assert_eq!(campos_de(&mut sh, "cmd ${v^}"), vec!["Hola"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${v^^}"), vec!["HOLA"]);
+        sh.asignar("w", "HOLA");
+        assert_eq!(campos_de(&mut sh, "cmd ${w,}"), vec!["hOLA"]);
+        assert_eq!(campos_de(&mut sh, "cmd ${w,,}"), vec!["hola"]);
+    }
+
     #[test]
     fn posicionales() {
         let mut sh = Shell::nuevo();
@@ -397,3 +498,80 @@ mod pruebas {
         assert!(campos_de(&mut sh, "cmd $vacia").is_empty());
     }
 }
+
+/// `${x/pat/rep}` y `${x//pat/rep}`. El patrón es un glob (como en `#`/`%`), así que se busca la
+/// coincidencia MÁS LARGA en la posición MÁS A LA IZQUIERDA, que es lo que hace bash.
+///
+/// ⚠ Un patrón que encaja con la cadena vacía (`*`) haría un bucle infinito en el modo `todas` si
+/// no se avanzara siempre al menos un carácter: ése es el único detalle no obvio de esta función.
+fn sustituir(valor: &str, pat: &str, rep: &str, todas: bool, ancla: Option<char>) -> String {
+    // ⚠ Los ANCLAJES de bash: `${x/#pat/rep}` sólo al principio, `${x/%pat/rep}` sólo al final.
+    // Se implementan porque la alternativa era peor: sin ellos, `#pat` se tomaba como un glob
+    // literal que no encaja con nada y la sustitución devolvía la cadena INTACTA — un no-op
+    // silencioso, que es el modo de fallo que este repo persigue. Mejor hacerlo que fingirlo.
+    if ancla == Some('#') {
+        let p = pat;
+        let cs: Vec<char> = valor.chars().collect();
+        let mut fin = cs.len();
+        while fin > 0 {
+            let trozo: String = cs[..fin].iter().collect();
+            if crate::glob::encaja(p, &trozo) {
+                return format!("{rep}{}", cs[fin..].iter().collect::<String>());
+            }
+            fin -= 1;
+        }
+        // también el encaje vacío al principio, que es lo que hace bash con `${x/#/pre}`
+        if crate::glob::encaja(p, "") {
+            return format!("{rep}{valor}");
+        }
+        return valor.to_string();
+    }
+    if ancla == Some('%') {
+        let p = pat;
+        let cs: Vec<char> = valor.chars().collect();
+        let mut ini = 0;
+        while ini < cs.len() {
+            let trozo: String = cs[ini..].iter().collect();
+            if crate::glob::encaja(p, &trozo) {
+                return format!("{}{rep}", cs[..ini].iter().collect::<String>());
+            }
+            ini += 1;
+        }
+        if crate::glob::encaja(p, "") {
+            return format!("{valor}{rep}");
+        }
+        return valor.to_string();
+    }
+    let cs: Vec<char> = valor.chars().collect();
+    let mut out = String::new();
+    let mut i = 0;
+    let mut hecho = false;
+    while i < cs.len() {
+        let mut encontrado: Option<usize> = None;
+        if !(hecho && !todas) {
+            // de la más larga a la más corta: bash prefiere la máxima
+            let mut fin = cs.len();
+            while fin > i {
+                let trozo: String = cs[i..fin].iter().collect();
+                if crate::glob::encaja(pat, &trozo) {
+                    encontrado = Some(fin);
+                    break;
+                }
+                fin -= 1;
+            }
+        }
+        match encontrado {
+            Some(fin) if fin > i => {
+                out.push_str(rep);
+                i = fin;
+                hecho = true;
+            }
+            _ => {
+                out.push(cs[i]);
+                i += 1;
+            }
+        }
+    }
+    // el patrón puede encajar con la cadena vacía al final (`${x/%/sufijo}` en bash); no se cubre
+    out
+}
diff --git a/crates/simi/src/lexer.rs b/crates/simi/src/lexer.rs
index 321dfa44..0fd2a339 100644
--- a/crates/simi/src/lexer.rs
+++ b/crates/simi/src/lexer.rs
@@ -452,7 +452,22 @@ impl Lexer {
                     Some('=') => ParamOp::Asigna { dos_puntos: true },
                     Some('?') => ParamOp::Error { dos_puntos: true },
                     Some('+') => ParamOp::Alterno { dos_puntos: true },
-                    _ => return Err("operador ${x:…} no soportado".into()),
+                    // ⚠ Todo lo demás tras `:` es SUBCADENA, no un error. POSIX define sólo
+                    // `-=?+` ahí; `${x:1:2}` es extensión, y no reconocerla era rechazar sintaxis
+                    // que POSIX deja libre. `desde` y `largo` son expresiones aritméticas: se
+                    // parten por el PRIMER `:` a profundidad 0 de paréntesis/corchetes, porque el
+                    // ternario de `$(( ))` también lleva `:` y partir por el primero a secas
+                    // rompería `${x:$((a?b:c))}`.
+                    _ => {
+                        let txt = self.leer_hasta_llave()?;
+                        let (d, l) = partir_subcadena(&txt);
+                        return Ok(Seg::Param {
+                            nombre,
+                            op: ParamOp::Subcadena { desde: d, largo: l },
+                            arg: vec![],
+                            comillado,
+                        });
+                    }
                 }
             }
             Some('-') => ParamOp::Defecto { dos_puntos: false },
@@ -475,6 +490,51 @@ impl Lexer {
                     ParamOp::QuitaSufijo { largo: false }
                 }
             }
+            // `${x/pat/rep}` y `${x//pat/rep}` — sustituir. Extensión; ver la nota en ast.rs.
+            Some('/') => {
+                self.i += 1;
+                let todas = if self.ver(0) == Some('/') {
+                    self.i += 1;
+                    true
+                } else {
+                    false
+                };
+                let txt = self.leer_hasta_llave()?;
+                let (pat_txt, rep_txt) = partir_sustitucion(&txt);
+                // el anclaje se separa ACÁ: ver la nota en ast.rs
+                let (ancla, pat_txt) = match pat_txt.chars().next() {
+                    Some(c @ ('#' | '%')) => (Some(c), pat_txt[c.len_utf8()..].to_string()),
+                    _ => (None, pat_txt),
+                };
+                let pat = palabra_de(&pat_txt)?;
+                let rep = palabra_de(&rep_txt)?;
+                return Ok(Seg::Param {
+                    nombre,
+                    op: ParamOp::Sustituye { todas, ancla, rep },
+                    arg: pat,
+                    comillado,
+                });
+            }
+            // `${x^}` `${x^^}` `${x,}` `${x,,}` — caja. Sin patrón: bash admite uno y es raro.
+            Some(c @ ('^' | ',')) => {
+                self.i += 1;
+                let todas = if self.ver(0) == Some(c) {
+                    self.i += 1;
+                    true
+                } else {
+                    false
+                };
+                if self.ver(0) != Some('}') {
+                    return Err(format!("${{x{c}}} no admite patrón en simi"));
+                }
+                self.i += 1;
+                return Ok(Seg::Param {
+                    nombre,
+                    op: ParamOp::Caso { mayus: c == '^', todas },
+                    arg: vec![],
+                    comillado,
+                });
+            }
             _ => return Err("operador ${…} no soportado".into()),
         };
         self.i += 1; // el carácter del operador
@@ -778,3 +838,54 @@ mod pruebas {
         }
     }
 }
+
+/// Parte el texto de `${x:A:B}` en `(A, Some(B))` o `(A, None)`, por el PRIMER `:` a profundidad 0.
+/// La profundidad cuenta `(` `[` porque el ternario de `$(( a?b:c ))` lleva `:` adentro.
+fn partir_subcadena(txt: &str) -> (String, Option<String>) {
+    let mut prof = 0i32;
+    for (i, c) in txt.char_indices() {
+        match c {
+            '(' | '[' => prof += 1,
+            ')' | ']' => prof -= 1,
+            ':' if prof == 0 => return (txt[..i].to_string(), Some(txt[i + 1..].to_string())),
+            _ => {}
+        }
+    }
+    (txt.to_string(), None)
+}
+
+/// Parte el texto de `${x/pat/rep}` en `(pat, rep)` por el primer `/` NO escapado a profundidad 0.
+/// Sin `/` ⇒ el reemplazo es vacío, que es lo que hace bash con `${x/pat}`.
+fn partir_sustitucion(txt: &str) -> (String, String) {
+    let mut prof = 0i32;
+    let cs: Vec<char> = txt.chars().collect();
+    let mut i = 0;
+    while i < cs.len() {
+        match cs[i] {
+            '\\' => i += 1, // se salta lo escapado
+            '(' | '[' | '{' => prof += 1,
+            ')' | ']' | '}' => prof -= 1,
+            '/' if prof == 0 => {
+                return (cs[..i].iter().collect(), cs[i + 1..].iter().collect());
+            }
+            _ => {}
+        }
+        i += 1;
+    }
+    (txt.to_string(), String::new())
+}
+
+/// Tokeniza un trozo suelto a `Palabra`, como hace el camino normal del argumento de `${…}`.
+fn palabra_de(txt: &str) -> Result<Palabra, String> {
+    if txt.is_empty() {
+        return Ok(vec![]);
+    }
+    let (ts, _) = tokenizar(txt)?;
+    Ok(ts
+        .into_iter()
+        .find_map(|t| match t {
+            Tok::Pal(p) => Some(p),
+            _ => None,
+        })
+        .unwrap_or_else(|| vec![Seg::Lit { texto: String::new(), comillado: true }]))
+}
diff --git a/crates/simi/src/main.rs b/crates/simi/src/main.rs
index a216b1cf..e6a0296f 100644
--- a/crates/simi/src/main.rs
+++ b/crates/simi/src/main.rs
@@ -19,6 +19,26 @@
 //! línea, historial, `alias`, `getopts`, `[[ ]]`, arrays. El shell interactivo de `getty` es otro
 //! consumidor y no está cubierto todavía.
 //!
+//! **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 1339 casos, y el banco POSIX da
+//! los MISMOS 2 fallos preexistentes (`while-read`, `getopts`) antes y después.
+//!
+//! | 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;