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…
simi: las cuatro expansiones de bash que no rompen POSIX
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}