gioser

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 1471Shell 173Rust 105Markdown 943545 Sutichaykuna

⎇ main crates/simi/src/lexer.rs Rust · 891 siq'ikuna · 31.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! Tokenizador. Primero se tokeniza TODO, después se parsea: así los cuerpos de los aquí-documentos
2//! —que viven en las líneas siguientes al operador— ya están resueltos cuando el parser los necesita.
3//!
4//! El sitio delicado de todo el shell está acá: **la barra invertida dentro de `` ` ` ``**
5//! (POSIX.1-2024 §2.6.3). Es lo que rompió a brush (reubeno/brush#1394) y se lleva 25 líneas del
6//! `libtool` que genera cualquier `configure`. La regla, que NO es la de `$( )`:
7//!
8//! > dentro de `` ` ` `` la barra invertida conserva su significado literal, **salvo** cuando
9//! > precede a `$`, `` ` `` o `\`, donde se ELIMINA antes de parsear el texto de la orden.
10//!
11//! Ver `pruebas::comillas_invertidas_posix_2_6_3`, que es el caso de brush calcado.
12
13use crate::ast::{Palabra, ParamOp, Seg};
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum Tok {
17    Pal(Palabra),
18    Op(String),
19    IoNum(i32),
20    /// Referencia al cuerpo del aquí-documento, en la tabla que devuelve `tokenizar`.
21    AquiDoc(usize),
22    Nueva,
23}
24
25pub struct Lexer {
26    src: Vec<char>,
27    i: usize,
28    toks: Vec<Tok>,
29    /// `(cuerpo, expande)`
30    cuerpos: Vec<(String, bool)>,
31    /// aquí-documentos anunciados en esta línea y pendientes de leer su cuerpo
32    pendientes: Vec<(usize, String, bool, bool)>, // (id, delim, expande, quitar_tabs)
33}
34
35/// `(tokens, cuerpos de aquí-documento)`
36pub fn tokenizar(entrada: &str) -> Result<(Vec<Tok>, Vec<(String, bool)>), String> {
37    let mut lx = Lexer {
38        src: entrada.chars().collect(),
39        i: 0,
40        toks: Vec::new(),
41        cuerpos: Vec::new(),
42        pendientes: Vec::new(),
43    };
44    lx.correr()?;
45    Ok((lx.toks, lx.cuerpos))
46}
47
48const OPERADORES: &[&str] = &[
49    "<<-", ";;", "&&", "||", ">>", "<<", ">&", "<&", "<>", ";", "&", "|", "(", ")", "<", ">",
50];
51
52fn es_meta(c: char) -> bool {
53    matches!(c, ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>')
54}
55
56impl Lexer {
57    fn ver(&self, n: usize) -> Option<char> {
58        self.src.get(self.i + n).copied()
59    }
60
61    fn correr(&mut self) -> Result<(), String> {
62        loop {
63            // espacios (sin newline) y continuación de línea
64            while let Some(c) = self.ver(0) {
65                if c == ' ' || c == '\t' {
66                    self.i += 1;
67                } else if c == '\\' && self.ver(1) == Some('\n') {
68                    self.i += 2;
69                } else if c == '#' && self.al_principio_de_palabra() {
70                    while self.ver(0).is_some() && self.ver(0) != Some('\n') {
71                        self.i += 1;
72                    }
73                } else {
74                    break;
75                }
76            }
77            let Some(c) = self.ver(0) else { break };
78
79            if c == '\n' {
80                self.i += 1;
81                self.leer_cuerpos_pendientes()?;
82                self.toks.push(Tok::Nueva);
83                continue;
84            }
85            if let Some(op) = self.operador_en_curso() {
86                self.i += op.chars().count();
87                if op == "<<" || op == "<<-" {
88                    self.anunciar_aqui_doc(&op)?;
89                } else {
90                    self.toks.push(Tok::Op(op));
91                }
92                continue;
93            }
94            let pal = self.leer_palabra()?;
95            // `2>` y `2<`: un número pegado a una redirección es el descriptor, no una palabra.
96            if let Some(n) = numero_de_fd(&pal) {
97                if matches!(self.ver(0), Some('>') | Some('<')) {
98                    self.toks.push(Tok::IoNum(n));
99                    continue;
100                }
101            }
102            self.toks.push(Tok::Pal(pal));
103        }
104        self.leer_cuerpos_pendientes()?;
105        Ok(())
106    }
107
108    fn al_principio_de_palabra(&self) -> bool {
109        // El carácter INMEDIATAMENTE anterior, sin saltarse los blancos: `a#b` no es comentario
110        // (el `#` va pegado a `a`) y `a #b` sí. Saltarse los blancos invertía las dos.
111        match self.i.checked_sub(1).map(|n| self.src[n]) {
112            None => true,
113            Some(c) => es_meta(c),
114        }
115    }
116
117    fn operador_en_curso(&self) -> Option<String> {
118        let resto: String = self.src[self.i..].iter().take(3).collect();
119        OPERADORES.iter().find(|op| resto.starts_with(**op)).map(|op| op.to_string())
120    }
121
122    fn anunciar_aqui_doc(&mut self, op: &str) -> Result<(), String> {
123        while matches!(self.ver(0), Some(' ') | Some('\t')) {
124            self.i += 1;
125        }
126        // El delimitador: si trae comillas, el cuerpo NO se expande.
127        let mut delim = String::new();
128        let mut comillado = false;
129        while let Some(c) = self.ver(0) {
130            if es_meta(c) {
131                break;
132            }
133            match c {
134                '\'' | '"' => {
135                    comillado = true;
136                    let cierre = c;
137                    self.i += 1;
138                    while let Some(d) = self.ver(0) {
139                        if d == cierre {
140                            break;
141                        }
142                        delim.push(d);
143                        self.i += 1;
144                    }
145                    self.i += 1;
146                }
147                '\\' => {
148                    comillado = true;
149                    self.i += 1;
150                    if let Some(d) = self.ver(0) {
151                        delim.push(d);
152                        self.i += 1;
153                    }
154                }
155                _ => {
156                    delim.push(c);
157                    self.i += 1;
158                }
159            }
160        }
161        if delim.is_empty() {
162            return Err("aquí-documento sin delimitador".into());
163        }
164        let id = self.cuerpos.len();
165        self.cuerpos.push((String::new(), !comillado));
166        self.pendientes.push((id, delim, !comillado, op == "<<-"));
167        self.toks.push(Tok::Op("<<".into()));
168        self.toks.push(Tok::AquiDoc(id));
169        Ok(())
170    }
171
172    fn leer_cuerpos_pendientes(&mut self) -> Result<(), String> {
173        let pendientes: Vec<_> = self.pendientes.drain(..).collect();
174        for (id, delim, _expande, quitar_tabs) in pendientes {
175            let mut cuerpo = String::new();
176            loop {
177                if self.i >= self.src.len() {
178                    break; // EOF sin delimitador: POSIX avisa; acá se acepta lo leído
179                }
180                let mut linea = String::new();
181                while let Some(c) = self.ver(0) {
182                    self.i += 1;
183                    if c == '\n' {
184                        break;
185                    }
186                    linea.push(c);
187                }
188                let comparar = if quitar_tabs { linea.trim_start_matches('\t') } else { &linea[..] };
189                if comparar == delim {
190                    break;
191                }
192                cuerpo.push_str(comparar);
193                cuerpo.push('\n');
194            }
195            self.cuerpos[id].0 = cuerpo;
196        }
197        Ok(())
198    }
199
200    // ───────────────────────────────────────────── palabras
201
202    fn leer_palabra(&mut self) -> Result<Palabra, String> {
203        let mut segs: Vec<Seg> = Vec::new();
204        // El literal en curso y SU estado de comillado. Cuando el estado cambia hay que cerrar el
205        // seg: si no, `lt_y=\"hola\"` queda como UN seg entrecomillado y el parser ya no ve la
206        // asignación —el prefijo antes del `=` tiene que ser desnudo— así que la ejecuta como una
207        // orden. Es lo que rompía el bucle de `config.status` de libtool, medido con el banco.
208        let mut lit = String::new();
209        let mut lit_q: Option<bool> = None;
210        let mut vacio_comillado = false;
211
212        macro_rules! volcar {
213            () => {
214                if !lit.is_empty() {
215                    segs.push(Seg::Lit {
216                        texto: std::mem::take(&mut lit),
217                        comillado: lit_q.unwrap_or(false),
218                    });
219                }
220                lit_q = None;
221            };
222        }
223        macro_rules! empujar {
224            ($c:expr, $q:expr) => {{
225                let q: bool = $q;
226                if lit_q.is_some() && lit_q != Some(q) {
227                    volcar!();
228                }
229                lit_q = Some(q);
230                lit.push($c);
231            }};
232        }
233
234        if self.ver(0) == Some('~') {
235            segs.push(Seg::Tilde);
236            self.i += 1;
237        }
238
239        while let Some(c) = self.ver(0) {
240            if es_meta(c) {
241                break;
242            }
243            match c {
244                '\\' => {
245                    self.i += 1;
246                    match self.ver(0) {
247                        Some('\n') => self.i += 1, // continuación de línea: desaparece
248                        Some(d) => {
249                            empujar!(d, true);
250                            self.i += 1;
251                        }
252                        None => empujar!('\\', true),
253                    }
254                }
255                '\'' => {
256                    self.i += 1;
257                    let mut vacia = true;
258                    let mut cerrada = false;
259                    while let Some(d) = self.ver(0) {
260                        self.i += 1;
261                        if d == '\'' {
262                            cerrada = true;
263                            break;
264                        }
265                        empujar!(d, true);
266                        vacia = false;
267                    }
268                    if !cerrada {
269                        return Err("comilla simple sin cerrar".into());
270                    }
271                    if vacia && lit.is_empty() && segs.is_empty() {
272                        vacio_comillado = true;
273                    }
274                }
275                '"' => {
276                    self.i += 1;
277                    let mut vacia = true;
278                    let mut cerrada = false;
279                    loop {
280                        let Some(d) = self.ver(0) else { break };
281                        if d == '"' {
282                            self.i += 1;
283                            cerrada = true;
284                            break;
285                        }
286                        vacia = false;
287                        match d {
288                            '\\' => {
289                                self.i += 1;
290                                match self.ver(0) {
291                                    // Dentro de comillas dobles la barra sólo escapa a estos cuatro.
292                                    Some(e @ ('$' | '`' | '"' | '\\')) => {
293                                        empujar!(e, true);
294                                        self.i += 1;
295                                    }
296                                    Some('\n') => self.i += 1,
297                                    Some(e) => {
298                                        empujar!('\\', true);
299                                        empujar!(e, true);
300                                        self.i += 1;
301                                    }
302                                    None => empujar!('\\', true),
303                                }
304                            }
305                            '$' => {
306                                volcar!();
307                                let s = self.leer_dolar(true)?;
308                                segs.push(s);
309                            }
310                            '`' => {
311                                volcar!();
312                                let cuerpo = self.leer_backtick(true)?;
313                                segs.push(Seg::CmdSub { cuerpo, comillado: true });
314                            }
315                            _ => {
316                                empujar!(d, true);
317                                self.i += 1;
318                            }
319                        }
320                    }
321                    if !cerrada {
322                        return Err("comilla doble sin cerrar".into());
323                    }
324                    if vacia && lit.is_empty() && segs.is_empty() {
325                        vacio_comillado = true;
326                    }
327                }
328                '$' => {
329                    volcar!();
330                    let s = self.leer_dolar(false)?;
331                    segs.push(s);
332                }
333                '`' => {
334                    volcar!();
335                    let cuerpo = self.leer_backtick(false)?;
336                    segs.push(Seg::CmdSub { cuerpo, comillado: false });
337                }
338                _ => {
339                    empujar!(c, false);
340                    self.i += 1;
341                }
342            }
343        }
344        volcar!();
345        if segs.is_empty() && vacio_comillado {
346            segs.push(Seg::Lit { texto: String::new(), comillado: true });
347        }
348        if segs.is_empty() {
349            return Err("palabra vacía".into());
350        }
351        Ok(segs)
352    }
353
354    /// `$x`, `${…}`, `$(( … ))`, `$( … )`, `$?`, `$1`, `$@`…
355    fn leer_dolar(&mut self, comillado: bool) -> Result<Seg, String> {
356        self.i += 1; // $
357        match self.ver(0) {
358            Some('(') if self.ver(1) == Some('(') => {
359                self.i += 2;
360                let expr = self.leer_hasta_parentesis_doble()?;
361                Ok(Seg::Arith { expr, comillado })
362            }
363            Some('(') => {
364                self.i += 1;
365                let cuerpo = self.leer_hasta_parentesis()?;
366                Ok(Seg::CmdSub { cuerpo, comillado })
367            }
368            Some('{') => {
369                self.i += 1;
370                self.leer_llaves(comillado)
371            }
372            Some(c) if c.is_ascii_digit() => {
373                self.i += 1;
374                Ok(Seg::Param {
375                    nombre: c.to_string(),
376                    op: ParamOp::Valor,
377                    arg: vec![],
378                    comillado,
379                })
380            }
381            Some(c @ ('@' | '*' | '#' | '?' | '$' | '!' | '-' | '0')) => {
382                self.i += 1;
383                Ok(Seg::Param { nombre: c.to_string(), op: ParamOp::Valor, arg: vec![], comillado })
384            }
385            Some(c) if c == '_' || c.is_ascii_alphabetic() => {
386                let mut n = String::new();
387                while let Some(d) = self.ver(0) {
388                    if d == '_' || d.is_ascii_alphanumeric() {
389                        n.push(d);
390                        self.i += 1;
391                    } else {
392                        break;
393                    }
394                }
395                Ok(Seg::Param { nombre: n, op: ParamOp::Valor, arg: vec![], comillado })
396            }
397            // `$` solo: literal
398            _ => Ok(Seg::Lit { texto: "$".into(), comillado }),
399        }
400    }
401
402    fn leer_llaves(&mut self, comillado: bool) -> Result<Seg, String> {
403        let largo = if self.ver(0) == Some('#') {
404            // `${#x}`; ojo con `${#}` que es el número de posicionales
405            if matches!(self.ver(1), Some('}')) {
406                self.i += 2;
407                return Ok(Seg::Param {
408                    nombre: "#".into(),
409                    op: ParamOp::Valor,
410                    arg: vec![],
411                    comillado,
412                });
413            }
414            self.i += 1;
415            true
416        } else {
417            false
418        };
419        let mut nombre = String::new();
420        if let Some(c @ ('@' | '*' | '#' | '?' | '$' | '!' | '-')) = self.ver(0) {
421            nombre.push(c);
422            self.i += 1;
423        } else {
424            while let Some(d) = self.ver(0) {
425                if d == '_' || d.is_ascii_alphanumeric() {
426                    nombre.push(d);
427                    self.i += 1;
428                } else {
429                    break;
430                }
431            }
432        }
433        if nombre.is_empty() {
434            return Err("${} sin nombre".into());
435        }
436        if largo {
437            if self.ver(0) != Some('}') {
438                return Err("${#x} mal cerrado".into());
439            }
440            self.i += 1;
441            return Ok(Seg::Param { nombre, op: ParamOp::Largo, arg: vec![], comillado });
442        }
443        let op = match self.ver(0) {
444            Some('}') => {
445                self.i += 1;
446                return Ok(Seg::Param { nombre, op: ParamOp::Valor, arg: vec![], comillado });
447            }
448            Some(':') => {
449                self.i += 1;
450                match self.ver(0) {
451                    Some('-') => ParamOp::Defecto { dos_puntos: true },
452                    Some('=') => ParamOp::Asigna { dos_puntos: true },
453                    Some('?') => ParamOp::Error { dos_puntos: true },
454                    Some('+') => ParamOp::Alterno { dos_puntos: true },
455                    // ⚠ Todo lo demás tras `:` es SUBCADENA, no un error. POSIX define sólo
456                    // `-=?+` ahí; `${x:1:2}` es extensión, y no reconocerla era rechazar sintaxis
457                    // que POSIX deja libre. `desde` y `largo` son expresiones aritméticas: se
458                    // parten por el PRIMER `:` a profundidad 0 de paréntesis/corchetes, porque el
459                    // ternario de `$(( ))` también lleva `:` y partir por el primero a secas
460                    // rompería `${x:$((a?b:c))}`.
461                    _ => {
462                        let txt = self.leer_hasta_llave()?;
463                        let (d, l) = partir_subcadena(&txt);
464                        return Ok(Seg::Param {
465                            nombre,
466                            op: ParamOp::Subcadena { desde: d, largo: l },
467                            arg: vec![],
468                            comillado,
469                        });
470                    }
471                }
472            }
473            Some('-') => ParamOp::Defecto { dos_puntos: false },
474            Some('=') => ParamOp::Asigna { dos_puntos: false },
475            Some('?') => ParamOp::Error { dos_puntos: false },
476            Some('+') => ParamOp::Alterno { dos_puntos: false },
477            Some('#') => {
478                if self.ver(1) == Some('#') {
479                    self.i += 1;
480                    ParamOp::QuitaPrefijo { largo: true }
481                } else {
482                    ParamOp::QuitaPrefijo { largo: false }
483                }
484            }
485            Some('%') => {
486                if self.ver(1) == Some('%') {
487                    self.i += 1;
488                    ParamOp::QuitaSufijo { largo: true }
489                } else {
490                    ParamOp::QuitaSufijo { largo: false }
491                }
492            }
493            // `${x/pat/rep}` y `${x//pat/rep}` — sustituir. Extensión; ver la nota en ast.rs.
494            Some('/') => {
495                self.i += 1;
496                let todas = if self.ver(0) == Some('/') {
497                    self.i += 1;
498                    true
499                } else {
500                    false
501                };
502                let txt = self.leer_hasta_llave()?;
503                let (pat_txt, rep_txt) = partir_sustitucion(&txt);
504                // el anclaje se separa ACÁ: ver la nota en ast.rs
505                let (ancla, pat_txt) = match pat_txt.chars().next() {
506                    Some(c @ ('#' | '%')) => (Some(c), pat_txt[c.len_utf8()..].to_string()),
507                    _ => (None, pat_txt),
508                };
509                let pat = palabra_de(&pat_txt)?;
510                let rep = palabra_de(&rep_txt)?;
511                return Ok(Seg::Param {
512                    nombre,
513                    op: ParamOp::Sustituye { todas, ancla, rep },
514                    arg: pat,
515                    comillado,
516                });
517            }
518            // `${x^}` `${x^^}` `${x,}` `${x,,}` — caja. Sin patrón: bash admite uno y es raro.
519            Some(c @ ('^' | ',')) => {
520                self.i += 1;
521                let todas = if self.ver(0) == Some(c) {
522                    self.i += 1;
523                    true
524                } else {
525                    false
526                };
527                if self.ver(0) != Some('}') {
528                    return Err(format!("${{x{c}}} no admite patrón en simi"));
529                }
530                self.i += 1;
531                return Ok(Seg::Param {
532                    nombre,
533                    op: ParamOp::Caso { mayus: c == '^', todas },
534                    arg: vec![],
535                    comillado,
536                });
537            }
538            _ => return Err("operador ${…} no soportado".into()),
539        };
540        self.i += 1; // el carácter del operador
541        // el argumento, hasta la `}` que cierra (contando anidamiento y comillas)
542        let arg_txt = self.leer_hasta_llave()?;
543        let arg = if arg_txt.is_empty() {
544            vec![]
545        } else {
546            let (ts, _) = tokenizar(&arg_txt)?;
547            match ts.into_iter().find_map(|t| match t {
548                Tok::Pal(p) => Some(p),
549                _ => None,
550            }) {
551                Some(p) => p,
552                None => vec![Seg::Lit { texto: String::new(), comillado: true }],
553            }
554        };
555        Ok(Seg::Param { nombre, op, arg, comillado })
556    }
557
558    fn leer_hasta_llave(&mut self) -> Result<String, String> {
559        let mut prof = 1;
560        let mut out = String::new();
561        while let Some(c) = self.ver(0) {
562            if c == '\\' {
563                // barra invertida: se copia con lo que escapa, sin interpretarlo
564                out.push(c);
565                self.i += 1;
566                if let Some(d) = self.ver(0) {
567                    out.push(d);
568                    self.i += 1;
569                }
570                continue;
571            }
572            match c {
573                '{' => prof += 1,
574                '}' => {
575                    prof -= 1;
576                    if prof == 0 {
577                        self.i += 1;
578                        return Ok(out);
579                    }
580                }
581                '\'' | '"' => {
582                    let cierre = c;
583                    out.push(c);
584                    self.i += 1;
585                    while let Some(d) = self.ver(0) {
586                        out.push(d);
587                        self.i += 1;
588                        if d == cierre {
589                            break;
590                        }
591                        if d == '\\' {
592                            if let Some(e) = self.ver(0) {
593                                out.push(e);
594                                self.i += 1;
595                            }
596                        }
597                    }
598                    continue;
599                }
600                _ => {}
601            }
602            out.push(c);
603            self.i += 1;
604        }
605        Err("${…} sin cerrar".into())
606    }
607
608    fn leer_hasta_parentesis(&mut self) -> Result<String, String> {
609        let mut prof = 1;
610        let mut out = String::new();
611        while let Some(c) = self.ver(0) {
612            if c == '\\' {
613                // barra invertida: se copia con lo que escapa, sin interpretarlo
614                out.push(c);
615                self.i += 1;
616                if let Some(d) = self.ver(0) {
617                    out.push(d);
618                    self.i += 1;
619                }
620                continue;
621            }
622            match c {
623                '(' => prof += 1,
624                ')' => {
625                    prof -= 1;
626                    if prof == 0 {
627                        self.i += 1;
628                        return Ok(out);
629                    }
630                }
631                '\'' | '"' => {
632                    let cierre = c;
633                    out.push(c);
634                    self.i += 1;
635                    while let Some(d) = self.ver(0) {
636                        out.push(d);
637                        self.i += 1;
638                        if d == cierre {
639                            break;
640                        }
641                        if d == '\\' && cierre == '"' {
642                            if let Some(e) = self.ver(0) {
643                                out.push(e);
644                                self.i += 1;
645                            }
646                        }
647                    }
648                    continue;
649                }
650                _ => {}
651            }
652            out.push(c);
653            self.i += 1;
654        }
655        Err("$( sin cerrar".into())
656    }
657
658    fn leer_hasta_parentesis_doble(&mut self) -> Result<String, String> {
659        let mut prof = 0;
660        let mut out = String::new();
661        while let Some(c) = self.ver(0) {
662            if c == '\\' {
663                out.push(c);
664                self.i += 1;
665                if let Some(d) = self.ver(0) {
666                    out.push(d);
667                    self.i += 1;
668                }
669                continue;
670            }
671            if c == '(' {
672                prof += 1;
673            } else if c == ')' {
674                if prof == 0 {
675                    if self.ver(1) == Some(')') {
676                        self.i += 2;
677                        return Ok(out);
678                    }
679                    return Err("$(( sin cerrar".into());
680                }
681                prof -= 1;
682            }
683            out.push(c);
684            self.i += 1;
685        }
686        Err("$(( sin cerrar".into())
687    }
688
689    /// El cuerpo de `` ` … ` ``, aplicando POSIX §2.6.3 a la barra invertida.
690    ///
691    /// Dentro de las comillas invertidas la barra es LITERAL salvo ante `$`, `` ` `` y `\`, donde
692    /// se elimina. brush aplicaba acá las reglas de `$( )` y por eso el `config.status` de libtool
693    /// generaba 25 líneas con un nivel de escapado de menos.
694    fn leer_backtick(&mut self, en_comillas_dobles: bool) -> Result<String, String> {
695        self.i += 1; // `
696        let mut out = String::new();
697        while let Some(c) = self.ver(0) {
698            match c {
699                '`' => {
700                    self.i += 1;
701                    return Ok(out);
702                }
703                '\\' => {
704                    // Fuera de comillas la barra se elimina ante `$`, `` ` `` y `\`. DENTRO de
705                    // comillas dobles el cuerpo del backtick sigue estando entre comillas, así que
706                    // el `"` se suma al conjunto: `"`echo \"`"` deja el cuerpo `echo "`, que es un
707                    // error de sintaxis — y es lo que hace el ash. Medido con el banco.
708                    let quitar = matches!(
709                        self.ver(1),
710                        Some('$') | Some('`') | Some('\\')
711                    ) || (en_comillas_dobles && self.ver(1) == Some('"'));
712                    if quitar {
713                        out.push(self.ver(1).unwrap());
714                        self.i += 2;
715                    } else {
716                        out.push('\\');
717                        self.i += 1;
718                    }
719                }
720                _ => {
721                    out.push(c);
722                    self.i += 1;
723                }
724            }
725        }
726        Err("` sin cerrar".into())
727    }
728}
729
730fn numero_de_fd(p: &Palabra) -> Option<i32> {
731    if p.len() != 1 {
732        return None;
733    }
734    match &p[0] {
735        Seg::Lit { texto, comillado: false } if !texto.is_empty() => {
736            if texto.chars().all(|c| c.is_ascii_digit()) {
737                texto.parse().ok()
738            } else {
739                None
740            }
741        }
742        _ => None,
743    }
744}
745
746/// Si la palabra es exactamente un literal sin comillas, devuelve su texto. Lo usa el parser para
747/// reconocer palabras reservadas: `"if"` entrecomillado NO es la reservada `if`.
748pub fn literal_desnudo(p: &Palabra) -> Option<&str> {
749    match p.as_slice() {
750        [Seg::Lit { texto, comillado: false }] => Some(texto),
751        _ => None,
752    }
753}
754
755#[cfg(test)]
756mod pruebas {
757    use super::*;
758
759    fn cmdsub(entrada: &str) -> String {
760        let (ts, _) = tokenizar(entrada).unwrap();
761        for t in ts {
762            if let Tok::Pal(p) = t {
763                for s in p {
764                    if let Seg::CmdSub { cuerpo, .. } = s {
765                        return cuerpo;
766                    }
767                }
768            }
769        }
770        panic!("no había sustitución en {entrada:?}");
771    }
772
773    /// POSIX.1-2024 §2.6.3 — el bug de brush (reubeno/brush#1394), calcado del reproductor.
774    #[test]
775    fn comillas_invertidas_posix_2_6_3() {
776        // \$  ⇒ se elimina la barra: el cuerpo pide `echo $V`… NO: la barra ante `$` se ELIMINA,
777        // así que el cuerpo es `echo $V` y la expansión la hace la orden de adentro.
778        assert_eq!(cmdsub(r#"echo "a: [`echo \$V`]""#), "echo $V");
779        // \\  ⇒ una sola barra
780        assert_eq!(cmdsub(r#"echo "b: [`echo \\`]""#), r"echo \");
781        // \x  ⇒ la barra NO se toca (x no es $ ` ni \)
782        assert_eq!(cmdsub(r#"echo "[`echo \x`]""#), r"echo \x");
783        // en $( ) no hay regla especial: el cuerpo va tal cual
784        let (ts, _) = tokenizar(r#"echo "[$(echo \$V)]""#).unwrap();
785        let cuerpo = ts
786            .into_iter()
787            .find_map(|t| match t {
788                Tok::Pal(p) => p.into_iter().find_map(|s| match s {
789                    Seg::CmdSub { cuerpo, .. } => Some(cuerpo),
790                    _ => None,
791                }),
792                _ => None,
793            })
794            .unwrap();
795        assert_eq!(cuerpo, r"echo \$V");
796    }
797
798    #[test]
799    fn aqui_doc_con_y_sin_comillas() {
800        let (_, cuerpos) = tokenizar("cat <<EOF\nuno $V\nEOF\ncat <<'FIN'\ndos $V\nFIN\n").unwrap();
801        assert_eq!(cuerpos.len(), 2);
802        assert_eq!(cuerpos[0], ("uno $V\n".to_string(), true));
803        assert_eq!(cuerpos[1], ("dos $V\n".to_string(), false));
804    }
805
806    #[test]
807    fn aqui_doc_guion_come_tabulaciones() {
808        let (_, cuerpos) = tokenizar("cat <<-EOF\n\t\tsin-tab\n\tEOF\n").unwrap();
809        assert_eq!(cuerpos[0].0, "sin-tab\n");
810    }
811
812    #[test]
813    fn descriptor_pegado_a_redireccion() {
814        let (ts, _) = tokenizar("cmd 2>/dev/null").unwrap();
815        assert!(ts.contains(&Tok::IoNum(2)));
816        assert!(ts.contains(&Tok::Op(">".into())));
817    }
818
819    #[test]
820    fn comentario_solo_al_principio_de_palabra() {
821        let (ts, _) = tokenizar("echo a#b # comentario").unwrap();
822        let pals: Vec<_> = ts
823            .iter()
824            .filter_map(|t| match t {
825                Tok::Pal(p) => literal_desnudo(p),
826                _ => None,
827            })
828            .collect();
829        assert_eq!(pals, vec!["echo", "a#b"]);
830    }
831
832    #[test]
833    fn comillas_vacias_son_un_campo() {
834        let (ts, _) = tokenizar("echo ''").unwrap();
835        match &ts[1] {
836            Tok::Pal(p) => assert_eq!(p, &vec![Seg::Lit { texto: String::new(), comillado: true }]),
837            t => panic!("{t:?}"),
838        }
839    }
840}
841
842/// Parte el texto de `${x:A:B}` en `(A, Some(B))` o `(A, None)`, por el PRIMER `:` a profundidad 0.
843/// La profundidad cuenta `(` `[` porque el ternario de `$(( a?b:c ))` lleva `:` adentro.
844fn partir_subcadena(txt: &str) -> (String, Option<String>) {
845    let mut prof = 0i32;
846    for (i, c) in txt.char_indices() {
847        match c {
848            '(' | '[' => prof += 1,
849            ')' | ']' => prof -= 1,
850            ':' if prof == 0 => return (txt[..i].to_string(), Some(txt[i + 1..].to_string())),
851            _ => {}
852        }
853    }
854    (txt.to_string(), None)
855}
856
857/// Parte el texto de `${x/pat/rep}` en `(pat, rep)` por el primer `/` NO escapado a profundidad 0.
858/// Sin `/` ⇒ el reemplazo es vacío, que es lo que hace bash con `${x/pat}`.
859fn partir_sustitucion(txt: &str) -> (String, String) {
860    let mut prof = 0i32;
861    let cs: Vec<char> = txt.chars().collect();
862    let mut i = 0;
863    while i < cs.len() {
864        match cs[i] {
865            '\\' => i += 1, // se salta lo escapado
866            '(' | '[' | '{' => prof += 1,
867            ')' | ']' | '}' => prof -= 1,
868            '/' if prof == 0 => {
869                return (cs[..i].iter().collect(), cs[i + 1..].iter().collect());
870            }
871            _ => {}
872        }
873        i += 1;
874    }
875    (txt.to_string(), String::new())
876}
877
878/// Tokeniza un trozo suelto a `Palabra`, como hace el camino normal del argumento de `${…}`.
879fn palabra_de(txt: &str) -> Result<Palabra, String> {
880    if txt.is_empty() {
881        return Ok(vec![]);
882    }
883    let (ts, _) = tokenizar(txt)?;
884    Ok(ts
885        .into_iter()
886        .find_map(|t| match t {
887            Tok::Pal(p) => Some(p),
888            _ => None,
889        })
890        .unwrap_or_else(|| vec![Seg::Lit { texto: String::new(), comillado: true }]))
891}