//! Tokenizador. Primero se tokeniza TODO, después se parsea: así los cuerpos de los aquí-documentos //! —que viven en las líneas siguientes al operador— ya están resueltos cuando el parser los necesita. //! //! El sitio delicado de todo el shell está acá: **la barra invertida dentro de `` ` ` ``** //! (POSIX.1-2024 §2.6.3). Es lo que rompió a brush (reubeno/brush#1394) y se lleva 25 líneas del //! `libtool` que genera cualquier `configure`. La regla, que NO es la de `$( )`: //! //! > dentro de `` ` ` `` la barra invertida conserva su significado literal, **salvo** cuando //! > precede a `$`, `` ` `` o `\`, donde se ELIMINA antes de parsear el texto de la orden. //! //! Ver `pruebas::comillas_invertidas_posix_2_6_3`, que es el caso de brush calcado. use crate::ast::{Palabra, ParamOp, Seg}; #[derive(Debug, Clone, PartialEq)] pub enum Tok { Pal(Palabra), Op(String), IoNum(i32), /// Referencia al cuerpo del aquí-documento, en la tabla que devuelve `tokenizar`. AquiDoc(usize), Nueva, } pub struct Lexer { src: Vec, i: usize, toks: Vec, /// `(cuerpo, expande)` cuerpos: Vec<(String, bool)>, /// aquí-documentos anunciados en esta línea y pendientes de leer su cuerpo pendientes: Vec<(usize, String, bool, bool)>, // (id, delim, expande, quitar_tabs) } /// `(tokens, cuerpos de aquí-documento)` pub fn tokenizar(entrada: &str) -> Result<(Vec, Vec<(String, bool)>), String> { let mut lx = Lexer { src: entrada.chars().collect(), i: 0, toks: Vec::new(), cuerpos: Vec::new(), pendientes: Vec::new(), }; lx.correr()?; Ok((lx.toks, lx.cuerpos)) } const OPERADORES: &[&str] = &[ "<<-", ";;", "&&", "||", ">>", "<<", ">&", "<&", "<>", ";", "&", "|", "(", ")", "<", ">", ]; fn es_meta(c: char) -> bool { matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>') } impl Lexer { fn ver(&self, n: usize) -> Option { self.src.get(self.i + n).copied() } fn correr(&mut self) -> Result<(), String> { loop { // espacios (sin newline) y continuación de línea while let Some(c) = self.ver(0) { if c == ' ' || c == '\t' { self.i += 1; } else if c == '\\' && self.ver(1) == Some('\n') { self.i += 2; } else if c == '#' && self.al_principio_de_palabra() { while self.ver(0).is_some() && self.ver(0) != Some('\n') { self.i += 1; } } else { break; } } let Some(c) = self.ver(0) else { break }; if c == '\n' { self.i += 1; self.leer_cuerpos_pendientes()?; self.toks.push(Tok::Nueva); continue; } if let Some(op) = self.operador_en_curso() { self.i += op.chars().count(); if op == "<<" || op == "<<-" { self.anunciar_aqui_doc(&op)?; } else { self.toks.push(Tok::Op(op)); } continue; } let pal = self.leer_palabra()?; // `2>` y `2<`: un número pegado a una redirección es el descriptor, no una palabra. if let Some(n) = numero_de_fd(&pal) { if matches!(self.ver(0), Some('>') | Some('<')) { self.toks.push(Tok::IoNum(n)); continue; } } self.toks.push(Tok::Pal(pal)); } self.leer_cuerpos_pendientes()?; Ok(()) } fn al_principio_de_palabra(&self) -> bool { // El carácter INMEDIATAMENTE anterior, sin saltarse los blancos: `a#b` no es comentario // (el `#` va pegado a `a`) y `a #b` sí. Saltarse los blancos invertía las dos. match self.i.checked_sub(1).map(|n| self.src[n]) { None => true, Some(c) => es_meta(c), } } fn operador_en_curso(&self) -> Option { let resto: String = self.src[self.i..].iter().take(3).collect(); OPERADORES.iter().find(|op| resto.starts_with(**op)).map(|op| op.to_string()) } fn anunciar_aqui_doc(&mut self, op: &str) -> Result<(), String> { while matches!(self.ver(0), Some(' ') | Some('\t')) { self.i += 1; } // El delimitador: si trae comillas, el cuerpo NO se expande. let mut delim = String::new(); let mut comillado = false; while let Some(c) = self.ver(0) { if es_meta(c) { break; } match c { '\'' | '"' => { comillado = true; let cierre = c; self.i += 1; while let Some(d) = self.ver(0) { if d == cierre { break; } delim.push(d); self.i += 1; } self.i += 1; } '\\' => { comillado = true; self.i += 1; if let Some(d) = self.ver(0) { delim.push(d); self.i += 1; } } _ => { delim.push(c); self.i += 1; } } } if delim.is_empty() { return Err("aquí-documento sin delimitador".into()); } let id = self.cuerpos.len(); self.cuerpos.push((String::new(), !comillado)); self.pendientes.push((id, delim, !comillado, op == "<<-")); self.toks.push(Tok::Op("<<".into())); self.toks.push(Tok::AquiDoc(id)); Ok(()) } fn leer_cuerpos_pendientes(&mut self) -> Result<(), String> { let pendientes: Vec<_> = self.pendientes.drain(..).collect(); for (id, delim, _expande, quitar_tabs) in pendientes { let mut cuerpo = String::new(); loop { if self.i >= self.src.len() { break; // EOF sin delimitador: POSIX avisa; acá se acepta lo leído } let mut linea = String::new(); while let Some(c) = self.ver(0) { self.i += 1; if c == '\n' { break; } linea.push(c); } let comparar = if quitar_tabs { linea.trim_start_matches('\t') } else { &linea[..] }; if comparar == delim { break; } cuerpo.push_str(comparar); cuerpo.push('\n'); } self.cuerpos[id].0 = cuerpo; } Ok(()) } // ───────────────────────────────────────────── palabras fn leer_palabra(&mut self) -> Result { let mut segs: Vec = Vec::new(); // El literal en curso y SU estado de comillado. Cuando el estado cambia hay que cerrar el // seg: si no, `lt_y=\"hola\"` queda como UN seg entrecomillado y el parser ya no ve la // asignación —el prefijo antes del `=` tiene que ser desnudo— así que la ejecuta como una // orden. Es lo que rompía el bucle de `config.status` de libtool, medido con el banco. let mut lit = String::new(); let mut lit_q: Option = None; let mut vacio_comillado = false; macro_rules! volcar { () => { if !lit.is_empty() { segs.push(Seg::Lit { texto: std::mem::take(&mut lit), comillado: lit_q.unwrap_or(false), }); } lit_q = None; }; } macro_rules! empujar { ($c:expr, $q:expr) => {{ let q: bool = $q; if lit_q.is_some() && lit_q != Some(q) { volcar!(); } lit_q = Some(q); lit.push($c); }}; } if self.ver(0) == Some('~') { segs.push(Seg::Tilde); self.i += 1; } while let Some(c) = self.ver(0) { if es_meta(c) { break; } match c { '\\' => { self.i += 1; match self.ver(0) { Some('\n') => self.i += 1, // continuación de línea: desaparece Some(d) => { empujar!(d, true); self.i += 1; } None => empujar!('\\', true), } } '\'' => { self.i += 1; let mut vacia = true; let mut cerrada = false; while let Some(d) = self.ver(0) { self.i += 1; if d == '\'' { cerrada = true; break; } empujar!(d, true); vacia = false; } if !cerrada { return Err("comilla simple sin cerrar".into()); } if vacia && lit.is_empty() && segs.is_empty() { vacio_comillado = true; } } '"' => { self.i += 1; let mut vacia = true; let mut cerrada = false; loop { let Some(d) = self.ver(0) else { break }; if d == '"' { self.i += 1; cerrada = true; break; } vacia = false; match d { '\\' => { self.i += 1; match self.ver(0) { // Dentro de comillas dobles la barra sólo escapa a estos cuatro. Some(e @ ('$' | '`' | '"' | '\\')) => { empujar!(e, true); self.i += 1; } Some('\n') => self.i += 1, Some(e) => { empujar!('\\', true); empujar!(e, true); self.i += 1; } None => empujar!('\\', true), } } '$' => { volcar!(); let s = self.leer_dolar(true)?; segs.push(s); } '`' => { volcar!(); let cuerpo = self.leer_backtick(true)?; segs.push(Seg::CmdSub { cuerpo, comillado: true }); } _ => { empujar!(d, true); self.i += 1; } } } if !cerrada { return Err("comilla doble sin cerrar".into()); } if vacia && lit.is_empty() && segs.is_empty() { vacio_comillado = true; } } '$' => { volcar!(); let s = self.leer_dolar(false)?; segs.push(s); } '`' => { volcar!(); let cuerpo = self.leer_backtick(false)?; segs.push(Seg::CmdSub { cuerpo, comillado: false }); } _ => { empujar!(c, false); self.i += 1; } } } volcar!(); if segs.is_empty() && vacio_comillado { segs.push(Seg::Lit { texto: String::new(), comillado: true }); } if segs.is_empty() { return Err("palabra vacía".into()); } Ok(segs) } /// `$x`, `${…}`, `$(( … ))`, `$( … )`, `$?`, `$1`, `$@`… fn leer_dolar(&mut self, comillado: bool) -> Result { self.i += 1; // $ match self.ver(0) { Some('(') if self.ver(1) == Some('(') => { self.i += 2; let expr = self.leer_hasta_parentesis_doble()?; Ok(Seg::Arith { expr, comillado }) } Some('(') => { self.i += 1; let cuerpo = self.leer_hasta_parentesis()?; Ok(Seg::CmdSub { cuerpo, comillado }) } Some('{') => { self.i += 1; self.leer_llaves(comillado) } Some(c) if c.is_ascii_digit() => { self.i += 1; Ok(Seg::Param { nombre: c.to_string(), op: ParamOp::Valor, arg: vec![], comillado, }) } Some(c @ ('@' | '*' | '#' | '?' | '$' | '!' | '-' | '0')) => { self.i += 1; Ok(Seg::Param { nombre: c.to_string(), op: ParamOp::Valor, arg: vec![], comillado }) } Some(c) if c == '_' || c.is_ascii_alphabetic() => { let mut n = String::new(); while let Some(d) = self.ver(0) { if d == '_' || d.is_ascii_alphanumeric() { n.push(d); self.i += 1; } else { break; } } Ok(Seg::Param { nombre: n, op: ParamOp::Valor, arg: vec![], comillado }) } // `$` solo: literal _ => Ok(Seg::Lit { texto: "$".into(), comillado }), } } fn leer_llaves(&mut self, comillado: bool) -> Result { let largo = if self.ver(0) == Some('#') { // `${#x}`; ojo con `${#}` que es el número de posicionales if matches!(self.ver(1), Some('}')) { self.i += 2; return Ok(Seg::Param { nombre: "#".into(), op: ParamOp::Valor, arg: vec![], comillado, }); } self.i += 1; true } else { false }; let mut nombre = String::new(); if let Some(c @ ('@' | '*' | '#' | '?' | '$' | '!' | '-')) = self.ver(0) { nombre.push(c); self.i += 1; } else { while let Some(d) = self.ver(0) { if d == '_' || d.is_ascii_alphanumeric() { nombre.push(d); self.i += 1; } else { break; } } } if nombre.is_empty() { return Err("${} sin nombre".into()); } if largo { if self.ver(0) != Some('}') { return Err("${#x} mal cerrado".into()); } self.i += 1; return Ok(Seg::Param { nombre, op: ParamOp::Largo, arg: vec![], comillado }); } let op = match self.ver(0) { Some('}') => { self.i += 1; return Ok(Seg::Param { nombre, op: ParamOp::Valor, arg: vec![], comillado }); } Some(':') => { self.i += 1; match self.ver(0) { Some('-') => ParamOp::Defecto { dos_puntos: true }, 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()), } } Some('-') => ParamOp::Defecto { dos_puntos: false }, Some('=') => ParamOp::Asigna { dos_puntos: false }, Some('?') => ParamOp::Error { dos_puntos: false }, Some('+') => ParamOp::Alterno { dos_puntos: false }, Some('#') => { if self.ver(1) == Some('#') { self.i += 1; ParamOp::QuitaPrefijo { largo: true } } else { ParamOp::QuitaPrefijo { largo: false } } } Some('%') => { if self.ver(1) == Some('%') { self.i += 1; ParamOp::QuitaSufijo { largo: true } } else { ParamOp::QuitaSufijo { largo: false } } } _ => return Err("operador ${…} no soportado".into()), }; self.i += 1; // el carácter del operador // el argumento, hasta la `}` que cierra (contando anidamiento y comillas) let arg_txt = self.leer_hasta_llave()?; let arg = if arg_txt.is_empty() { vec![] } else { let (ts, _) = tokenizar(&arg_txt)?; match ts.into_iter().find_map(|t| match t { Tok::Pal(p) => Some(p), _ => None, }) { Some(p) => p, None => vec![Seg::Lit { texto: String::new(), comillado: true }], } }; Ok(Seg::Param { nombre, op, arg, comillado }) } fn leer_hasta_llave(&mut self) -> Result { let mut prof = 1; let mut out = String::new(); while let Some(c) = self.ver(0) { if c == '\\' { // barra invertida: se copia con lo que escapa, sin interpretarlo out.push(c); self.i += 1; if let Some(d) = self.ver(0) { out.push(d); self.i += 1; } continue; } match c { '{' => prof += 1, '}' => { prof -= 1; if prof == 0 { self.i += 1; return Ok(out); } } '\'' | '"' => { let cierre = c; out.push(c); self.i += 1; while let Some(d) = self.ver(0) { out.push(d); self.i += 1; if d == cierre { break; } if d == '\\' { if let Some(e) = self.ver(0) { out.push(e); self.i += 1; } } } continue; } _ => {} } out.push(c); self.i += 1; } Err("${…} sin cerrar".into()) } fn leer_hasta_parentesis(&mut self) -> Result { let mut prof = 1; let mut out = String::new(); while let Some(c) = self.ver(0) { if c == '\\' { // barra invertida: se copia con lo que escapa, sin interpretarlo out.push(c); self.i += 1; if let Some(d) = self.ver(0) { out.push(d); self.i += 1; } continue; } match c { '(' => prof += 1, ')' => { prof -= 1; if prof == 0 { self.i += 1; return Ok(out); } } '\'' | '"' => { let cierre = c; out.push(c); self.i += 1; while let Some(d) = self.ver(0) { out.push(d); self.i += 1; if d == cierre { break; } if d == '\\' && cierre == '"' { if let Some(e) = self.ver(0) { out.push(e); self.i += 1; } } } continue; } _ => {} } out.push(c); self.i += 1; } Err("$( sin cerrar".into()) } fn leer_hasta_parentesis_doble(&mut self) -> Result { let mut prof = 0; let mut out = String::new(); while let Some(c) = self.ver(0) { if c == '\\' { out.push(c); self.i += 1; if let Some(d) = self.ver(0) { out.push(d); self.i += 1; } continue; } if c == '(' { prof += 1; } else if c == ')' { if prof == 0 { if self.ver(1) == Some(')') { self.i += 2; return Ok(out); } return Err("$(( sin cerrar".into()); } prof -= 1; } out.push(c); self.i += 1; } Err("$(( sin cerrar".into()) } /// El cuerpo de `` ` … ` ``, aplicando POSIX §2.6.3 a la barra invertida. /// /// Dentro de las comillas invertidas la barra es LITERAL salvo ante `$`, `` ` `` y `\`, donde /// se elimina. brush aplicaba acá las reglas de `$( )` y por eso el `config.status` de libtool /// generaba 25 líneas con un nivel de escapado de menos. fn leer_backtick(&mut self, en_comillas_dobles: bool) -> Result { self.i += 1; // ` let mut out = String::new(); while let Some(c) = self.ver(0) { match c { '`' => { self.i += 1; return Ok(out); } '\\' => { // Fuera de comillas la barra se elimina ante `$`, `` ` `` y `\`. DENTRO de // comillas dobles el cuerpo del backtick sigue estando entre comillas, así que // el `"` se suma al conjunto: `"`echo \"`"` deja el cuerpo `echo "`, que es un // error de sintaxis — y es lo que hace el ash. Medido con el banco. let quitar = matches!( self.ver(1), Some('$') | Some('`') | Some('\\') ) || (en_comillas_dobles && self.ver(1) == Some('"')); if quitar { out.push(self.ver(1).unwrap()); self.i += 2; } else { out.push('\\'); self.i += 1; } } _ => { out.push(c); self.i += 1; } } } Err("` sin cerrar".into()) } } fn numero_de_fd(p: &Palabra) -> Option { if p.len() != 1 { return None; } match &p[0] { Seg::Lit { texto, comillado: false } if !texto.is_empty() => { if texto.chars().all(|c| c.is_ascii_digit()) { texto.parse().ok() } else { None } } _ => None, } } /// Si la palabra es exactamente un literal sin comillas, devuelve su texto. Lo usa el parser para /// reconocer palabras reservadas: `"if"` entrecomillado NO es la reservada `if`. pub fn literal_desnudo(p: &Palabra) -> Option<&str> { match p.as_slice() { [Seg::Lit { texto, comillado: false }] => Some(texto), _ => None, } } #[cfg(test)] mod pruebas { use super::*; fn cmdsub(entrada: &str) -> String { let (ts, _) = tokenizar(entrada).unwrap(); for t in ts { if let Tok::Pal(p) = t { for s in p { if let Seg::CmdSub { cuerpo, .. } = s { return cuerpo; } } } } panic!("no había sustitución en {entrada:?}"); } /// POSIX.1-2024 §2.6.3 — el bug de brush (reubeno/brush#1394), calcado del reproductor. #[test] fn comillas_invertidas_posix_2_6_3() { // \$ ⇒ se elimina la barra: el cuerpo pide `echo $V`… NO: la barra ante `$` se ELIMINA, // así que el cuerpo es `echo $V` y la expansión la hace la orden de adentro. assert_eq!(cmdsub(r#"echo "a: [`echo \$V`]""#), "echo $V"); // \\ ⇒ una sola barra assert_eq!(cmdsub(r#"echo "b: [`echo \\`]""#), r"echo \"); // \x ⇒ la barra NO se toca (x no es $ ` ni \) assert_eq!(cmdsub(r#"echo "[`echo \x`]""#), r"echo \x"); // en $( ) no hay regla especial: el cuerpo va tal cual let (ts, _) = tokenizar(r#"echo "[$(echo \$V)]""#).unwrap(); let cuerpo = ts .into_iter() .find_map(|t| match t { Tok::Pal(p) => p.into_iter().find_map(|s| match s { Seg::CmdSub { cuerpo, .. } => Some(cuerpo), _ => None, }), _ => None, }) .unwrap(); assert_eq!(cuerpo, r"echo \$V"); } #[test] fn aqui_doc_con_y_sin_comillas() { let (_, cuerpos) = tokenizar("cat </dev/null").unwrap(); assert!(ts.contains(&Tok::IoNum(2))); assert!(ts.contains(&Tok::Op(">".into()))); } #[test] fn comentario_solo_al_principio_de_palabra() { let (ts, _) = tokenizar("echo a#b # comentario").unwrap(); let pals: Vec<_> = ts .iter() .filter_map(|t| match t { Tok::Pal(p) => literal_desnudo(p), _ => None, }) .collect(); assert_eq!(pals, vec!["echo", "a#b"]); } #[test] fn comillas_vacias_son_un_campo() { let (ts, _) = tokenizar("echo ''").unwrap(); match &ts[1] { Tok::Pal(p) => assert_eq!(p, &vec![Seg::Lit { texto: String::new(), comillado: true }]), t => panic!("{t:?}"), } } }