//! Expansiones, **en el orden que manda POSIX §2.6** — y el orden es el contrato, no un detalle: //! //! 1. tilde · 2. parámetro, orden y aritmética (de izquierda a derecha) · 3. división en campos //! por `IFS` · 4. expansión de rutas · 5. quita de comillas. //! //! Un shell que divide antes de expandir, o que globea lo que vino entrecomillado, pasa igual //! cualquier banco de casos sencillos y rompe el primer `for f in $LISTA` de verdad. use crate::ast::{Palabra, ParamOp, Seg}; use crate::glob; use crate::shell::Shell; /// Un trozo ya expandido. `comillado` decide si se divide en campos y si se globea; /// `corte` fuerza un campo nuevo (lo necesita `"$@"`). struct Trozo { texto: String, comillado: bool, corte: bool, } /// Campos finales de una palabra: expansión + división + rutas + quita de comillas. pub fn campos(sh: &mut Shell, p: &Palabra) -> Result, String> { let trozos = expandir(sh, p)?; let crudos = armar(trozos, &sh.ifs()); let mut out = Vec::new(); for (campo, globeable) in crudos { let texto: String = campo.iter().map(|(c, _)| *c).collect(); if !sh.opts.noglob && globeable && hay_meta_sin_proteger(&campo) { let hits = glob::expandir(&como_patron(&campo)); if hits.is_empty() { out.push(texto); } else { out.extend(hits); } } else { out.push(texto); } } Ok(out) } /// Una palabra donde la división en campos NO aplica: asignaciones, destinos de redirección, /// cuerpos de aquí-documento, sujeto de `case`. pub fn una(sh: &mut Shell, p: &Palabra) -> Result { let trozos = expandir(sh, p)?; Ok(trozos.into_iter().map(|t| t.texto).collect()) } /// Como `una`, pero devolviendo un PATRÓN: lo que vino entrecomillado queda escapado, así /// `case "$x" in \*)` compara un asterisco literal. Lo usan `case` y `${x#pat}`. pub fn patron(sh: &mut Shell, p: &Palabra) -> Result { let trozos = expandir(sh, p)?; let mut campo: Vec<(char, bool)> = Vec::new(); for t in trozos { for c in t.texto.chars() { campo.push((c, t.comillado)); } } Ok(como_patron(&campo)) } /// ¿Hay un metacarácter de ruta SIN proteger? Los protegidos vinieron de comillas y son literales. fn hay_meta_sin_proteger(campo: &[(char, bool)]) -> bool { campo.iter().any(|(c, prot)| !prot && matches!(c, '*' | '?' | '[')) } /// Texto de patrón para `glob`: los caracteres protegidos se escapan con `\`, y una barra /// invertida NO protegida —la que salió de una expansión— se escapa para que siga siendo una /// barra literal. /// /// ⚠ Acá estaba el bug que el banco diferencial destapó: la primera versión usaba `\` como marca /// interna y después la quitaba con un `quitar_escapes` final, así que `$(echo \\$V)` perdía la /// barra que la sustitución había producido (`ash` daba `\hola`, simi daba `hola`). **La quita de /// comillas es sobre las comillas de la PALABRA, nunca sobre lo que produjo una expansión** — y eso /// no se puede representar con un escape dentro del texto: hace falta la máscara. fn como_patron(campo: &[(char, bool)]) -> String { let mut out = String::new(); for (c, prot) in campo { if *prot { if matches!(c, '*' | '?' | '[' | ']' | '\\') { out.push('\\'); } } else if *c == '\\' { out.push('\\'); } out.push(*c); } out } fn expandir(sh: &mut Shell, p: &Palabra) -> Result, String> { let mut out: Vec = Vec::new(); for seg in p { match seg { Seg::Lit { texto, comillado } => { out.push(Trozo { texto: texto.clone(), comillado: *comillado, corte: false }) } Seg::Tilde => { let casa = sh.leer("HOME").unwrap_or_default(); out.push(Trozo { texto: casa, comillado: true, corte: false }); } Seg::Arith { expr, comillado } => { let texto = crate::exec::evaluar_aritmetica(sh, expr)?; out.push(Trozo { texto, comillado: *comillado, corte: false }); } Seg::CmdSub { cuerpo, comillado } => { let texto = crate::exec::sustituir_orden(sh, cuerpo)?; out.push(Trozo { texto, comillado: *comillado, corte: false }); } Seg::Param { nombre, op, arg, comillado } => { expandir_param(sh, nombre, op, arg, *comillado, &mut out)?; } } } Ok(out) } fn expandir_param( sh: &mut Shell, nombre: &str, op: &ParamOp, arg: &Palabra, comillado: bool, out: &mut Vec, ) -> Result<(), String> { // `$@` y `$*`: los únicos que producen varios campos. if nombre == "@" || nombre == "*" { let params = sh.params.clone(); if *op == ParamOp::Largo { out.push(Trozo { texto: params.len().to_string(), comillado, corte: false }); return Ok(()); } if nombre == "*" && comillado { let sep = sh.ifs().chars().next().map(|c| c.to_string()).unwrap_or_default(); out.push(Trozo { texto: params.join(&sep), comillado: true, corte: false }); return Ok(()); } for (n, v) in params.iter().enumerate() { out.push(Trozo { texto: v.clone(), comillado, corte: n > 0 }); } return Ok(()); } let valor = sh.leer(nombre); let definida_no_vacia = valor.as_deref().is_some_and(|v| !v.is_empty()); let definida = valor.is_some(); let texto = match op { ParamOp::Valor => { if valor.is_none() && sh.opts.nounset { return Err(format!("{nombre}: variable sin definir")); } valor.unwrap_or_default() } ParamOp::Largo => valor.unwrap_or_default().chars().count().to_string(), ParamOp::Defecto { dos_puntos } => { let usar = if *dos_puntos { definida_no_vacia } else { definida }; if usar { valor.unwrap_or_default() } else { una(sh, arg)? } } ParamOp::Asigna { dos_puntos } => { let usar = if *dos_puntos { definida_no_vacia } else { definida }; if usar { valor.unwrap_or_default() } else { let v = una(sh, arg)?; sh.asignar(nombre, &v); v } } ParamOp::Error { dos_puntos } => { let usar = if *dos_puntos { definida_no_vacia } else { definida }; if usar { valor.unwrap_or_default() } else { let m = una(sh, arg)?; let m = if m.is_empty() { "parámetro sin definir".to_string() } else { m }; return Err(format!("{nombre}: {m}")); } } ParamOp::Alterno { dos_puntos } => { let usar = if *dos_puntos { definida_no_vacia } else { definida }; if usar { una(sh, arg)? } else { String::new() } } ParamOp::QuitaPrefijo { largo } => { let v = valor.unwrap_or_default(); let pat = patron(sh, arg)?; quitar(&v, &pat, true, *largo) } ParamOp::QuitaSufijo { largo } => { let v = valor.unwrap_or_default(); let pat = patron(sh, arg)?; 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 = 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::() .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::() .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 }); Ok(()) } /// `${x#pat}` / `${x%pat}`. `prefijo` elige el lado; `largo` elige la coincidencia más larga. fn quitar(valor: &str, pat: &str, prefijo: bool, largo: bool) -> String { let cs: Vec = valor.chars().collect(); let mut mejor: Option = None; let rango: Vec = (0..=cs.len()).collect(); for n in rango { let trozo: String = if prefijo { cs[..n].iter().collect() } else { cs[cs.len() - n..].iter().collect() }; if glob::encaja(pat, &trozo) { match mejor { None => mejor = Some(n), Some(m) => { if (largo && n > m) || (!largo && n < m) { mejor = Some(n); } } } if !largo { break; // la más corta: la primera que encaja } } } match mejor { None => valor.to_string(), Some(n) => { if prefijo { cs[n..].iter().collect() } else { cs[..cs.len() - n].iter().collect() } } } } /// División en campos por `IFS`. Devuelve `(campo con máscara, se_puede_globear)`. fn armar(trozos: Vec, ifs: &str) -> Vec<(Vec<(char, bool)>, bool)> { let mut campos: Vec<(Vec<(char, bool)>, bool)> = Vec::new(); let mut actual: Vec<(char, bool)> = Vec::new(); let mut globeable = false; let mut hay_algo = false; for t in trozos { if t.corte { campos.push((std::mem::take(&mut actual), globeable)); globeable = false; hay_algo = true; } if t.comillado { actual.extend(t.texto.chars().map(|c| (c, true))); hay_algo = true; continue; } globeable = true; hay_algo = true; let mut primero = true; for parte in dividir(&t.texto, ifs) { if !primero { campos.push((std::mem::take(&mut actual), globeable)); globeable = true; } actual.extend(parte.chars().map(|c| (c, false))); primero = false; } if !t.texto.is_empty() && t.texto.chars().next_back().is_some_and(|c| ifs.contains(c)) { campos.push((std::mem::take(&mut actual), globeable)); globeable = true; } } if hay_algo { campos.push((actual, globeable)); } // POSIX: los campos vacíos que salen de la división se descartan; los que vienen de comillas // se quedan. Un campo vacío no-globeable venía de comillas. campos.retain(|(t, g)| !t.is_empty() || !*g); campos } /// Divide por `IFS`: los blancos de `IFS` colapsan, los no-blancos delimitan de a uno. fn dividir(s: &str, ifs: &str) -> Vec { if ifs.is_empty() { return vec![s.to_string()]; } let blancos: Vec = ifs.chars().filter(|c| matches!(c, ' ' | '\t' | '\n')).collect(); let otros: Vec = ifs.chars().filter(|c| !matches!(c, ' ' | '\t' | '\n')).collect(); let mut out: Vec = Vec::new(); let mut act = String::new(); let mut i = 0; let cs: Vec = s.chars().collect(); while i < cs.len() { let c = cs[i]; if blancos.contains(&c) { out.push(std::mem::take(&mut act)); while i < cs.len() && blancos.contains(&cs[i]) { i += 1; } continue; } if otros.contains(&c) { out.push(std::mem::take(&mut act)); i += 1; continue; } act.push(c); i += 1; } out.push(act); // los blancos del principio y del final no crean campos vacíos while out.first().is_some_and(|f| f.is_empty()) && out.len() > 1 { out.remove(0); } out } #[cfg(test)] mod pruebas { use super::*; use crate::parser::parsear; fn campos_de(sh: &mut Shell, entrada: &str) -> Vec { let l = parsear(entrada).unwrap(); let orden = &l[0].0.primera.ordenes[0]; let crate::ast::Orden::Simple { palabras, .. } = orden else { panic!() }; let mut out = Vec::new(); for p in &palabras[1..] { out.extend(campos(sh, p).unwrap()); } out } #[test] fn comillas_no_dividen_y_desnudo_si() { let mut sh = Shell::nuevo(); sh.asignar("x", "a b"); assert_eq!(campos_de(&mut sh, "cmd \"$x\""), vec!["a b"]); assert_eq!(campos_de(&mut sh, "cmd $x"), vec!["a", "b"]); } #[test] fn expansiones_de_parametro() { let mut sh = Shell::nuevo(); sh.asignar("v", "arch.tar.gz"); assert_eq!(campos_de(&mut sh, "cmd ${v%%.*}"), vec!["arch"]); assert_eq!(campos_de(&mut sh, "cmd ${v##*.}"), vec!["gz"]); assert_eq!(campos_de(&mut sh, "cmd ${v%.gz}"), vec!["arch.tar"]); assert_eq!(campos_de(&mut sh, "cmd ${v#arch.}"), vec!["tar.gz"]); assert_eq!(campos_de(&mut sh, "cmd ${#v}"), vec!["11"]); assert_eq!(campos_de(&mut sh, "cmd ${nada:-def}"), vec!["def"]); 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::>()); // 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(); sh.params = vec!["uno".into(), "dos tres".into()]; assert_eq!(campos_de(&mut sh, "cmd \"$@\""), vec!["uno", "dos tres"]); assert_eq!(campos_de(&mut sh, "cmd $@"), vec!["uno", "dos", "tres"]); assert_eq!(campos_de(&mut sh, "cmd \"$*\""), vec!["uno dos tres"]); assert_eq!(campos_de(&mut sh, "cmd $#"), vec!["2"]); } #[test] fn ifs_con_separador_no_blanco() { let mut sh = Shell::nuevo(); sh.asignar("IFS", ":"); sh.asignar("x", "a:b:c"); assert_eq!(campos_de(&mut sh, "cmd $x"), vec!["a", "b", "c"]); } #[test] fn lo_entrecomillado_no_se_globea() { let mut sh = Shell::nuevo(); // `"/dev/nul*"` es literal; `/dev/nul*` expande assert_eq!(campos_de(&mut sh, "cmd \"/dev/nul*\""), vec!["/dev/nul*"]); assert_eq!(campos_de(&mut sh, "cmd /dev/nul*"), vec!["/dev/null"]); } #[test] fn glob_sin_coincidencia_queda_tal_cual() { let mut sh = Shell::nuevo(); assert_eq!(campos_de(&mut sh, "cmd /no-existe-jamas*"), vec!["/no-existe-jamas*"]); } #[test] fn campo_vacio_entrecomillado_sobrevive() { let mut sh = Shell::nuevo(); sh.asignar("vacia", ""); assert_eq!(campos_de(&mut sh, "cmd \"$vacia\""), vec![""]); 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) -> 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 = 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::()); } 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 = 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::()); } ini += 1; } if crate::glob::encaja(p, "") { return format!("{valor}{rep}"); } return valor.to_string(); } let cs: Vec = valor.chars().collect(); let mut out = String::new(); let mut i = 0; let mut hecho = false; while i < cs.len() { let mut encontrado: Option = 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 }