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 1569Shell 203Rust 109Markdown 993728 Definiciones

⎇ main crates/simi/src/expand.rs Rust · 577 líneas · 22.0 KB Historia Quién tocó qué crudo

simi: las cuatro expansiones de bash que no rompen POSIX 5ab371db3 · Sergio ·

1//! Expansiones, **en el orden que manda POSIX §2.6** — y el orden es el contrato, no un detalle:
2//!
3//! 1. tilde · 2. parámetro, orden y aritmética (de izquierda a derecha) · 3. división en campos
4//! por `IFS` · 4. expansión de rutas · 5. quita de comillas.
5//!
6//! Un shell que divide antes de expandir, o que globea lo que vino entrecomillado, pasa igual
7//! cualquier banco de casos sencillos y rompe el primer `for f in $LISTA` de verdad.
8
9use crate::ast::{Palabra, ParamOp, Seg};
10use crate::glob;
11use crate::shell::Shell;
12
13/// Un trozo ya expandido. `comillado` decide si se divide en campos y si se globea;
14/// `corte` fuerza un campo nuevo (lo necesita `"$@"`).
15struct Trozo {
16    texto: String,
17    comillado: bool,
18    corte: bool,
19}
20
21/// Campos finales de una palabra: expansión + división + rutas + quita de comillas.
22pub fn campos(sh: &mut Shell, p: &Palabra) -> Result<Vec<String>, String> {
23    let trozos = expandir(sh, p)?;
24    let crudos = armar(trozos, &sh.ifs());
25    let mut out = Vec::new();
26    for (campo, globeable) in crudos {
27        let texto: String = campo.iter().map(|(c, _)| *c).collect();
28        if !sh.opts.noglob && globeable && hay_meta_sin_proteger(&campo) {
29            let hits = glob::expandir(&como_patron(&campo));
30            if hits.is_empty() {
31                out.push(texto);
32            } else {
33                out.extend(hits);
34            }
35        } else {
36            out.push(texto);
37        }
38    }
39    Ok(out)
40}
41
42/// Una palabra donde la división en campos NO aplica: asignaciones, destinos de redirección,
43/// cuerpos de aquí-documento, sujeto de `case`.
44pub fn una(sh: &mut Shell, p: &Palabra) -> Result<String, String> {
45    let trozos = expandir(sh, p)?;
46    Ok(trozos.into_iter().map(|t| t.texto).collect())
47}
48
49/// Como `una`, pero devolviendo un PATRÓN: lo que vino entrecomillado queda escapado, así
50/// `case "$x" in \*)` compara un asterisco literal. Lo usan `case` y `${x#pat}`.
51pub fn patron(sh: &mut Shell, p: &Palabra) -> Result<String, String> {
52    let trozos = expandir(sh, p)?;
53    let mut campo: Vec<(char, bool)> = Vec::new();
54    for t in trozos {
55        for c in t.texto.chars() {
56            campo.push((c, t.comillado));
57        }
58    }
59    Ok(como_patron(&campo))
60}
61
62/// ¿Hay un metacarácter de ruta SIN proteger? Los protegidos vinieron de comillas y son literales.
63fn hay_meta_sin_proteger(campo: &[(char, bool)]) -> bool {
64    campo.iter().any(|(c, prot)| !prot && matches!(c, '*' | '?' | '['))
65}
66
67/// Texto de patrón para `glob`: los caracteres protegidos se escapan con `\`, y una barra
68/// invertida NO protegida —la que salió de una expansión— se escapa para que siga siendo una
69/// barra literal.
70///
71/// ⚠ Acá estaba el bug que el banco diferencial destapó: la primera versión usaba `\` como marca
72/// interna y después la quitaba con un `quitar_escapes` final, así que `$(echo \\$V)` perdía la
73/// barra que la sustitución había producido (`ash` daba `\hola`, simi daba `hola`). **La quita de
74/// comillas es sobre las comillas de la PALABRA, nunca sobre lo que produjo una expansión** — y eso
75/// no se puede representar con un escape dentro del texto: hace falta la máscara.
76fn como_patron(campo: &[(char, bool)]) -> String {
77    let mut out = String::new();
78    for (c, prot) in campo {
79        if *prot {
80            if matches!(c, '*' | '?' | '[' | ']' | '\\') {
81                out.push('\\');
82            }
83        } else if *c == '\\' {
84            out.push('\\');
85        }
86        out.push(*c);
87    }
88    out
89}
90
91fn expandir(sh: &mut Shell, p: &Palabra) -> Result<Vec<Trozo>, String> {
92    let mut out: Vec<Trozo> = Vec::new();
93    for seg in p {
94        match seg {
95            Seg::Lit { texto, comillado } => {
96                out.push(Trozo { texto: texto.clone(), comillado: *comillado, corte: false })
97            }
98            Seg::Tilde => {
99                let casa = sh.leer("HOME").unwrap_or_default();
100                out.push(Trozo { texto: casa, comillado: true, corte: false });
101            }
102            Seg::Arith { expr, comillado } => {
103                let texto = crate::exec::evaluar_aritmetica(sh, expr)?;
104                out.push(Trozo { texto, comillado: *comillado, corte: false });
105            }
106            Seg::CmdSub { cuerpo, comillado } => {
107                let texto = crate::exec::sustituir_orden(sh, cuerpo)?;
108                out.push(Trozo { texto, comillado: *comillado, corte: false });
109            }
110            Seg::Param { nombre, op, arg, comillado } => {
111                expandir_param(sh, nombre, op, arg, *comillado, &mut out)?;
112            }
113        }
114    }
115    Ok(out)
116}
117
118fn expandir_param(
119    sh: &mut Shell,
120    nombre: &str,
121    op: &ParamOp,
122    arg: &Palabra,
123    comillado: bool,
124    out: &mut Vec<Trozo>,
125) -> Result<(), String> {
126    // `$@` y `$*`: los únicos que producen varios campos.
127    if nombre == "@" || nombre == "*" {
128        let params = sh.params.clone();
129        if *op == ParamOp::Largo {
130            out.push(Trozo { texto: params.len().to_string(), comillado, corte: false });
131            return Ok(());
132        }
133        if nombre == "*" && comillado {
134            let sep = sh.ifs().chars().next().map(|c| c.to_string()).unwrap_or_default();
135            out.push(Trozo { texto: params.join(&sep), comillado: true, corte: false });
136            return Ok(());
137        }
138        for (n, v) in params.iter().enumerate() {
139            out.push(Trozo { texto: v.clone(), comillado, corte: n > 0 });
140        }
141        return Ok(());
142    }
143
144    let valor = sh.leer(nombre);
145    let definida_no_vacia = valor.as_deref().is_some_and(|v| !v.is_empty());
146    let definida = valor.is_some();
147
148    let texto = match op {
149        ParamOp::Valor => {
150            if valor.is_none() && sh.opts.nounset {
151                return Err(format!("{nombre}: variable sin definir"));
152            }
153            valor.unwrap_or_default()
154        }
155        ParamOp::Largo => valor.unwrap_or_default().chars().count().to_string(),
156        ParamOp::Defecto { dos_puntos } => {
157            let usar = if *dos_puntos { definida_no_vacia } else { definida };
158            if usar {
159                valor.unwrap_or_default()
160            } else {
161                una(sh, arg)?
162            }
163        }
164        ParamOp::Asigna { dos_puntos } => {
165            let usar = if *dos_puntos { definida_no_vacia } else { definida };
166            if usar {
167                valor.unwrap_or_default()
168            } else {
169                let v = una(sh, arg)?;
170                sh.asignar(nombre, &v);
171                v
172            }
173        }
174        ParamOp::Error { dos_puntos } => {
175            let usar = if *dos_puntos { definida_no_vacia } else { definida };
176            if usar {
177                valor.unwrap_or_default()
178            } else {
179                let m = una(sh, arg)?;
180                let m = if m.is_empty() { "parámetro sin definir".to_string() } else { m };
181                return Err(format!("{nombre}: {m}"));
182            }
183        }
184        ParamOp::Alterno { dos_puntos } => {
185            let usar = if *dos_puntos { definida_no_vacia } else { definida };
186            if usar {
187                una(sh, arg)?
188            } else {
189                String::new()
190            }
191        }
192        ParamOp::QuitaPrefijo { largo } => {
193            let v = valor.unwrap_or_default();
194            let pat = patron(sh, arg)?;
195            quitar(&v, &pat, true, *largo)
196        }
197        ParamOp::QuitaSufijo { largo } => {
198            let v = valor.unwrap_or_default();
199            let pat = patron(sh, arg)?;
200            quitar(&v, &pat, false, *largo)
201        }
202
203        // ══ EXTENSIONES (2026-10-22) — ver la nota en ast.rs: POSIX deja esta sintaxis sin
204        // definir, así que soportarla no cambia el comportamiento de ningún script conforme.
205        ParamOp::Sustituye { todas, ancla, rep } => {
206            let v = valor.unwrap_or_default();
207            let pat = patron(sh, arg)?;
208            let r = una(sh, rep)?;
209            sustituir(&v, &pat, &r, *todas, *ancla)
210        }
211        ParamOp::Subcadena { desde, largo } => {
212            let v = valor.unwrap_or_default();
213            let cs: Vec<char> = v.chars().collect();
214            let n = cs.len() as i64;
215            // `desde` negativo cuenta desde el final, como bash. Se clampa a [0, n] para que
216            // `${x: -99}` dé la cadena entera en vez de entrar en pánico.
217            let d0 = crate::exec::evaluar_aritmetica(sh, desde)?
218                .parse::<i64>()
219                .map_err(|e| format!("${{x:…}}: `{desde}` no es un número ({e})"))?;
220            let ini = if d0 < 0 { (n + d0).max(0) } else { d0.min(n) } as usize;
221            let fin = match largo {
222                None => cs.len(),
223                Some(expr) => {
224                    let l = crate::exec::evaluar_aritmetica(sh, expr)?
225                        .parse::<i64>()
226                        .map_err(|e| format!("${{x:…:…}}: `{expr}` no es un número ({e})"))?;
227                    // Un largo negativo en bash es «hasta esa posición desde el final».
228                    if l < 0 { (n + l).max(ini as i64) as usize } else { (ini as i64 + l).min(n) as usize }
229                }
230            };
231            cs[ini..fin].iter().collect()
232        }
233        ParamOp::Caso { mayus, todas } => {
234            let v = valor.unwrap_or_default();
235            let mut o = String::with_capacity(v.len());
236            for (i, c) in v.chars().enumerate() {
237                let cambiar = *todas || i == 0;
238                if cambiar && *mayus {
239                    o.extend(c.to_uppercase());
240                } else if cambiar {
241                    o.extend(c.to_lowercase());
242                } else {
243                    o.push(c);
244                }
245            }
246            o
247        }
248    };
249    out.push(Trozo { texto, comillado, corte: false });
250    Ok(())
251}
252
253/// `${x#pat}` / `${x%pat}`. `prefijo` elige el lado; `largo` elige la coincidencia más larga.
254fn quitar(valor: &str, pat: &str, prefijo: bool, largo: bool) -> String {
255    let cs: Vec<char> = valor.chars().collect();
256    let mut mejor: Option<usize> = None;
257    let rango: Vec<usize> = (0..=cs.len()).collect();
258    for n in rango {
259        let trozo: String = if prefijo {
260            cs[..n].iter().collect()
261        } else {
262            cs[cs.len() - n..].iter().collect()
263        };
264        if glob::encaja(pat, &trozo) {
265            match mejor {
266                None => mejor = Some(n),
267                Some(m) => {
268                    if (largo && n > m) || (!largo && n < m) {
269                        mejor = Some(n);
270                    }
271                }
272            }
273            if !largo {
274                break; // la más corta: la primera que encaja
275            }
276        }
277    }
278    match mejor {
279        None => valor.to_string(),
280        Some(n) => {
281            if prefijo {
282                cs[n..].iter().collect()
283            } else {
284                cs[..cs.len() - n].iter().collect()
285            }
286        }
287    }
288}
289
290/// División en campos por `IFS`. Devuelve `(campo con máscara, se_puede_globear)`.
291fn armar(trozos: Vec<Trozo>, ifs: &str) -> Vec<(Vec<(char, bool)>, bool)> {
292    let mut campos: Vec<(Vec<(char, bool)>, bool)> = Vec::new();
293    let mut actual: Vec<(char, bool)> = Vec::new();
294    let mut globeable = false;
295    let mut hay_algo = false;
296
297    for t in trozos {
298        if t.corte {
299            campos.push((std::mem::take(&mut actual), globeable));
300            globeable = false;
301            hay_algo = true;
302        }
303        if t.comillado {
304            actual.extend(t.texto.chars().map(|c| (c, true)));
305            hay_algo = true;
306            continue;
307        }
308        globeable = true;
309        hay_algo = true;
310        let mut primero = true;
311        for parte in dividir(&t.texto, ifs) {
312            if !primero {
313                campos.push((std::mem::take(&mut actual), globeable));
314                globeable = true;
315            }
316            actual.extend(parte.chars().map(|c| (c, false)));
317            primero = false;
318        }
319        if !t.texto.is_empty() && t.texto.chars().next_back().is_some_and(|c| ifs.contains(c)) {
320            campos.push((std::mem::take(&mut actual), globeable));
321            globeable = true;
322        }
323    }
324    if hay_algo {
325        campos.push((actual, globeable));
326    }
327    // POSIX: los campos vacíos que salen de la división se descartan; los que vienen de comillas
328    // se quedan. Un campo vacío no-globeable venía de comillas.
329    campos.retain(|(t, g)| !t.is_empty() || !*g);
330    campos
331}
332
333/// Divide por `IFS`: los blancos de `IFS` colapsan, los no-blancos delimitan de a uno.
334fn dividir(s: &str, ifs: &str) -> Vec<String> {
335    if ifs.is_empty() {
336        return vec![s.to_string()];
337    }
338    let blancos: Vec<char> = ifs.chars().filter(|c| matches!(c, ' ' | '\t' | '\n')).collect();
339    let otros: Vec<char> = ifs.chars().filter(|c| !matches!(c, ' ' | '\t' | '\n')).collect();
340    let mut out: Vec<String> = Vec::new();
341    let mut act = String::new();
342    let mut i = 0;
343    let cs: Vec<char> = s.chars().collect();
344    while i < cs.len() {
345        let c = cs[i];
346        if blancos.contains(&c) {
347            out.push(std::mem::take(&mut act));
348            while i < cs.len() && blancos.contains(&cs[i]) {
349                i += 1;
350            }
351            continue;
352        }
353        if otros.contains(&c) {
354            out.push(std::mem::take(&mut act));
355            i += 1;
356            continue;
357        }
358        act.push(c);
359        i += 1;
360    }
361    out.push(act);
362    // los blancos del principio y del final no crean campos vacíos
363    while out.first().is_some_and(|f| f.is_empty()) && out.len() > 1 {
364        out.remove(0);
365    }
366    out
367}
368
369#[cfg(test)]
370mod pruebas {
371    use super::*;
372    use crate::parser::parsear;
373
374    fn campos_de(sh: &mut Shell, entrada: &str) -> Vec<String> {
375        let l = parsear(entrada).unwrap();
376        let orden = &l[0].0.primera.ordenes[0];
377        let crate::ast::Orden::Simple { palabras, .. } = orden else { panic!() };
378        let mut out = Vec::new();
379        for p in &palabras[1..] {
380            out.extend(campos(sh, p).unwrap());
381        }
382        out
383    }
384
385    #[test]
386    fn comillas_no_dividen_y_desnudo_si() {
387        let mut sh = Shell::nuevo();
388        sh.asignar("x", "a  b");
389        assert_eq!(campos_de(&mut sh, "cmd \"$x\""), vec!["a  b"]);
390        assert_eq!(campos_de(&mut sh, "cmd $x"), vec!["a", "b"]);
391    }
392
393    #[test]
394    fn expansiones_de_parametro() {
395        let mut sh = Shell::nuevo();
396        sh.asignar("v", "arch.tar.gz");
397        assert_eq!(campos_de(&mut sh, "cmd ${v%%.*}"), vec!["arch"]);
398        assert_eq!(campos_de(&mut sh, "cmd ${v##*.}"), vec!["gz"]);
399        assert_eq!(campos_de(&mut sh, "cmd ${v%.gz}"), vec!["arch.tar"]);
400        assert_eq!(campos_de(&mut sh, "cmd ${v#arch.}"), vec!["tar.gz"]);
401        assert_eq!(campos_de(&mut sh, "cmd ${#v}"), vec!["11"]);
402        assert_eq!(campos_de(&mut sh, "cmd ${nada:-def}"), vec!["def"]);
403        assert_eq!(campos_de(&mut sh, "cmd ${v:+si}"), vec!["si"]);
404    }
405
406    /// Las extensiones no-POSIX (2026-10-22). Se prueban aparte de `expansiones_de_parametro`
407    /// para que quede claro cuáles son añadidos: POSIX deja esta sintaxis sin definir, y el banco
408    /// diferencial (1339 casos contra el ash) sigue en DIVERGE=0 con ellas puestas.
409    #[test]
410    fn extensiones_sustitucion() {
411        let mut sh = Shell::nuevo();
412        sh.asignar("v", "abcabc");
413        assert_eq!(campos_de(&mut sh, "cmd ${v/b/X}"), vec!["aXcabc"]);
414        assert_eq!(campos_de(&mut sh, "cmd ${v//b/X}"), vec!["aXcaXc"]);
415        // sin reemplazo ⇒ se borra, como en bash
416        assert_eq!(campos_de(&mut sh, "cmd ${v//b}"), vec!["acac"]);
417        // glob en el patrón, coincidencia MÁS LARGA y MÁS A LA IZQUIERDA
418        assert_eq!(campos_de(&mut sh, "cmd ${v/b*b/-}"), vec!["a-c"]);
419        // el caso real que motivó todo esto (`wasi-libcxx`)
420        sh.asignar("t", "wasm32-unknown-wasip1");
421        assert_eq!(campos_de(&mut sh, "cmd ${t/unknown-/}"), vec!["wasm32-wasip1"]);
422    }
423
424    /// ⚠ Los anclajes `#`/`%` se separan en el LEXER, no en la expansión: un `#` al empezar una
425    /// palabra abre un COMENTARIO y el patrón llegaba vacío, devolviendo la cadena intacta — un
426    /// no-op silencioso. Estos cuatro casos son el control de esa trampa.
427    #[test]
428    fn extensiones_sustitucion_anclada() {
429        let mut sh = Shell::nuevo();
430        sh.asignar("v", "abcabc");
431        assert_eq!(campos_de(&mut sh, "cmd ${v/#a/X}"), vec!["Xbcabc"]);
432        // `b` no está al principio ⇒ no toca nada
433        assert_eq!(campos_de(&mut sh, "cmd ${v/#b/X}"), vec!["abcabc"]);
434        assert_eq!(campos_de(&mut sh, "cmd ${v/%c/X}"), vec!["abcabX"]);
435        // encaje vacío anclado: prefijar y sufijar
436        assert_eq!(campos_de(&mut sh, "cmd ${v/#/pre}"), vec!["preabcabc"]);
437        assert_eq!(campos_de(&mut sh, "cmd ${v/%/post}"), vec!["abcabcpost"]);
438    }
439
440    #[test]
441    fn extensiones_subcadena_y_caja() {
442        let mut sh = Shell::nuevo();
443        sh.asignar("v", "hola");
444        assert_eq!(campos_de(&mut sh, "cmd ${v:1}"), vec!["ola"]);
445        assert_eq!(campos_de(&mut sh, "cmd ${v:1:2}"), vec!["ol"]);
446        // negativo = desde el final (bash exige el espacio para no confundir con `:-`)
447        assert_eq!(campos_de(&mut sh, "cmd ${v: -2}"), vec!["la"]);
448        // fuera de rango se CLAMPA en vez de entrar en pánico
449        assert_eq!(campos_de(&mut sh, "cmd ${v:99}"), vec![""; 0].into_iter().collect::<Vec<_>>());
450        // aritmética de verdad en los índices
451        sh.asignar("n", "2");
452        assert_eq!(campos_de(&mut sh, "cmd ${v:n-1:2}"), vec!["ol"]);
453        // caja
454        assert_eq!(campos_de(&mut sh, "cmd ${v^}"), vec!["Hola"]);
455        assert_eq!(campos_de(&mut sh, "cmd ${v^^}"), vec!["HOLA"]);
456        sh.asignar("w", "HOLA");
457        assert_eq!(campos_de(&mut sh, "cmd ${w,}"), vec!["hOLA"]);
458        assert_eq!(campos_de(&mut sh, "cmd ${w,,}"), vec!["hola"]);
459    }
460
461    #[test]
462    fn posicionales() {
463        let mut sh = Shell::nuevo();
464        sh.params = vec!["uno".into(), "dos tres".into()];
465        assert_eq!(campos_de(&mut sh, "cmd \"$@\""), vec!["uno", "dos tres"]);
466        assert_eq!(campos_de(&mut sh, "cmd $@"), vec!["uno", "dos", "tres"]);
467        assert_eq!(campos_de(&mut sh, "cmd \"$*\""), vec!["uno dos tres"]);
468        assert_eq!(campos_de(&mut sh, "cmd $#"), vec!["2"]);
469    }
470
471    #[test]
472    fn ifs_con_separador_no_blanco() {
473        let mut sh = Shell::nuevo();
474        sh.asignar("IFS", ":");
475        sh.asignar("x", "a:b:c");
476        assert_eq!(campos_de(&mut sh, "cmd $x"), vec!["a", "b", "c"]);
477    }
478
479    #[test]
480    fn lo_entrecomillado_no_se_globea() {
481        let mut sh = Shell::nuevo();
482        // `"/dev/nul*"` es literal; `/dev/nul*` expande
483        assert_eq!(campos_de(&mut sh, "cmd \"/dev/nul*\""), vec!["/dev/nul*"]);
484        assert_eq!(campos_de(&mut sh, "cmd /dev/nul*"), vec!["/dev/null"]);
485    }
486
487    #[test]
488    fn glob_sin_coincidencia_queda_tal_cual() {
489        let mut sh = Shell::nuevo();
490        assert_eq!(campos_de(&mut sh, "cmd /no-existe-jamas*"), vec!["/no-existe-jamas*"]);
491    }
492
493    #[test]
494    fn campo_vacio_entrecomillado_sobrevive() {
495        let mut sh = Shell::nuevo();
496        sh.asignar("vacia", "");
497        assert_eq!(campos_de(&mut sh, "cmd \"$vacia\""), vec![""]);
498        assert!(campos_de(&mut sh, "cmd $vacia").is_empty());
499    }
500}
501
502/// `${x/pat/rep}` y `${x//pat/rep}`. El patrón es un glob (como en `#`/`%`), así que se busca la
503/// coincidencia MÁS LARGA en la posición MÁS A LA IZQUIERDA, que es lo que hace bash.
504///
505/// ⚠ Un patrón que encaja con la cadena vacía (`*`) haría un bucle infinito en el modo `todas` si
506/// no se avanzara siempre al menos un carácter: ése es el único detalle no obvio de esta función.
507fn sustituir(valor: &str, pat: &str, rep: &str, todas: bool, ancla: Option<char>) -> String {
508    // ⚠ Los ANCLAJES de bash: `${x/#pat/rep}` sólo al principio, `${x/%pat/rep}` sólo al final.
509    // Se implementan porque la alternativa era peor: sin ellos, `#pat` se tomaba como un glob
510    // literal que no encaja con nada y la sustitución devolvía la cadena INTACTA — un no-op
511    // silencioso, que es el modo de fallo que este repo persigue. Mejor hacerlo que fingirlo.
512    if ancla == Some('#') {
513        let p = pat;
514        let cs: Vec<char> = valor.chars().collect();
515        let mut fin = cs.len();
516        while fin > 0 {
517            let trozo: String = cs[..fin].iter().collect();
518            if crate::glob::encaja(p, &trozo) {
519                return format!("{rep}{}", cs[fin..].iter().collect::<String>());
520            }
521            fin -= 1;
522        }
523        // también el encaje vacío al principio, que es lo que hace bash con `${x/#/pre}`
524        if crate::glob::encaja(p, "") {
525            return format!("{rep}{valor}");
526        }
527        return valor.to_string();
528    }
529    if ancla == Some('%') {
530        let p = pat;
531        let cs: Vec<char> = valor.chars().collect();
532        let mut ini = 0;
533        while ini < cs.len() {
534            let trozo: String = cs[ini..].iter().collect();
535            if crate::glob::encaja(p, &trozo) {
536                return format!("{}{rep}", cs[..ini].iter().collect::<String>());
537            }
538            ini += 1;
539        }
540        if crate::glob::encaja(p, "") {
541            return format!("{valor}{rep}");
542        }
543        return valor.to_string();
544    }
545    let cs: Vec<char> = valor.chars().collect();
546    let mut out = String::new();
547    let mut i = 0;
548    let mut hecho = false;
549    while i < cs.len() {
550        let mut encontrado: Option<usize> = None;
551        if !(hecho && !todas) {
552            // de la más larga a la más corta: bash prefiere la máxima
553            let mut fin = cs.len();
554            while fin > i {
555                let trozo: String = cs[i..fin].iter().collect();
556                if crate::glob::encaja(pat, &trozo) {
557                    encontrado = Some(fin);
558                    break;
559                }
560                fin -= 1;
561            }
562        }
563        match encontrado {
564            Some(fin) if fin > i => {
565                out.push_str(rep);
566                i = fin;
567                hecho = true;
568            }
569            _ => {
570                out.push(cs[i]);
571                i += 1;
572            }
573        }
574    }
575    // el patrón puede encajar con la cadena vacía al final (`${x/%/sufijo}` en bash); no se cubre
576    out
577}