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

⎇ 6809ef4be28f606aae856890a74667187ff75b05 crates/simi/src/lexer.rs Rust · 780 siq'ikuna · 27.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

simi: el /bin/sh propio del producto — 4448 líneas, sólo libc, y CERO divergencias contra el ash 6809ef4be · 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                    _ => return Err("operador ${x:…} no soportado".into()),
456                }
457            }
458            Some('-') => ParamOp::Defecto { dos_puntos: false },
459            Some('=') => ParamOp::Asigna { dos_puntos: false },
460            Some('?') => ParamOp::Error { dos_puntos: false },
461            Some('+') => ParamOp::Alterno { dos_puntos: false },
462            Some('#') => {
463                if self.ver(1) == Some('#') {
464                    self.i += 1;
465                    ParamOp::QuitaPrefijo { largo: true }
466                } else {
467                    ParamOp::QuitaPrefijo { largo: false }
468                }
469            }
470            Some('%') => {
471                if self.ver(1) == Some('%') {
472                    self.i += 1;
473                    ParamOp::QuitaSufijo { largo: true }
474                } else {
475                    ParamOp::QuitaSufijo { largo: false }
476                }
477            }
478            _ => return Err("operador ${…} no soportado".into()),
479        };
480        self.i += 1; // el carácter del operador
481        // el argumento, hasta la `}` que cierra (contando anidamiento y comillas)
482        let arg_txt = self.leer_hasta_llave()?;
483        let arg = if arg_txt.is_empty() {
484            vec![]
485        } else {
486            let (ts, _) = tokenizar(&arg_txt)?;
487            match ts.into_iter().find_map(|t| match t {
488                Tok::Pal(p) => Some(p),
489                _ => None,
490            }) {
491                Some(p) => p,
492                None => vec![Seg::Lit { texto: String::new(), comillado: true }],
493            }
494        };
495        Ok(Seg::Param { nombre, op, arg, comillado })
496    }
497
498    fn leer_hasta_llave(&mut self) -> Result<String, String> {
499        let mut prof = 1;
500        let mut out = String::new();
501        while let Some(c) = self.ver(0) {
502            if c == '\\' {
503                // barra invertida: se copia con lo que escapa, sin interpretarlo
504                out.push(c);
505                self.i += 1;
506                if let Some(d) = self.ver(0) {
507                    out.push(d);
508                    self.i += 1;
509                }
510                continue;
511            }
512            match c {
513                '{' => prof += 1,
514                '}' => {
515                    prof -= 1;
516                    if prof == 0 {
517                        self.i += 1;
518                        return Ok(out);
519                    }
520                }
521                '\'' | '"' => {
522                    let cierre = c;
523                    out.push(c);
524                    self.i += 1;
525                    while let Some(d) = self.ver(0) {
526                        out.push(d);
527                        self.i += 1;
528                        if d == cierre {
529                            break;
530                        }
531                        if d == '\\' {
532                            if let Some(e) = self.ver(0) {
533                                out.push(e);
534                                self.i += 1;
535                            }
536                        }
537                    }
538                    continue;
539                }
540                _ => {}
541            }
542            out.push(c);
543            self.i += 1;
544        }
545        Err("${…} sin cerrar".into())
546    }
547
548    fn leer_hasta_parentesis(&mut self) -> Result<String, String> {
549        let mut prof = 1;
550        let mut out = String::new();
551        while let Some(c) = self.ver(0) {
552            if c == '\\' {
553                // barra invertida: se copia con lo que escapa, sin interpretarlo
554                out.push(c);
555                self.i += 1;
556                if let Some(d) = self.ver(0) {
557                    out.push(d);
558                    self.i += 1;
559                }
560                continue;
561            }
562            match c {
563                '(' => prof += 1,
564                ')' => {
565                    prof -= 1;
566                    if prof == 0 {
567                        self.i += 1;
568                        return Ok(out);
569                    }
570                }
571                '\'' | '"' => {
572                    let cierre = c;
573                    out.push(c);
574                    self.i += 1;
575                    while let Some(d) = self.ver(0) {
576                        out.push(d);
577                        self.i += 1;
578                        if d == cierre {
579                            break;
580                        }
581                        if d == '\\' && cierre == '"' {
582                            if let Some(e) = self.ver(0) {
583                                out.push(e);
584                                self.i += 1;
585                            }
586                        }
587                    }
588                    continue;
589                }
590                _ => {}
591            }
592            out.push(c);
593            self.i += 1;
594        }
595        Err("$( sin cerrar".into())
596    }
597
598    fn leer_hasta_parentesis_doble(&mut self) -> Result<String, String> {
599        let mut prof = 0;
600        let mut out = String::new();
601        while let Some(c) = self.ver(0) {
602            if c == '\\' {
603                out.push(c);
604                self.i += 1;
605                if let Some(d) = self.ver(0) {
606                    out.push(d);
607                    self.i += 1;
608                }
609                continue;
610            }
611            if c == '(' {
612                prof += 1;
613            } else if c == ')' {
614                if prof == 0 {
615                    if self.ver(1) == Some(')') {
616                        self.i += 2;
617                        return Ok(out);
618                    }
619                    return Err("$(( sin cerrar".into());
620                }
621                prof -= 1;
622            }
623            out.push(c);
624            self.i += 1;
625        }
626        Err("$(( sin cerrar".into())
627    }
628
629    /// El cuerpo de `` ` … ` ``, aplicando POSIX §2.6.3 a la barra invertida.
630    ///
631    /// Dentro de las comillas invertidas la barra es LITERAL salvo ante `$`, `` ` `` y `\`, donde
632    /// se elimina. brush aplicaba acá las reglas de `$( )` y por eso el `config.status` de libtool
633    /// generaba 25 líneas con un nivel de escapado de menos.
634    fn leer_backtick(&mut self, en_comillas_dobles: bool) -> Result<String, String> {
635        self.i += 1; // `
636        let mut out = String::new();
637        while let Some(c) = self.ver(0) {
638            match c {
639                '`' => {
640                    self.i += 1;
641                    return Ok(out);
642                }
643                '\\' => {
644                    // Fuera de comillas la barra se elimina ante `$`, `` ` `` y `\`. DENTRO de
645                    // comillas dobles el cuerpo del backtick sigue estando entre comillas, así que
646                    // el `"` se suma al conjunto: `"`echo \"`"` deja el cuerpo `echo "`, que es un
647                    // error de sintaxis — y es lo que hace el ash. Medido con el banco.
648                    let quitar = matches!(
649                        self.ver(1),
650                        Some('$') | Some('`') | Some('\\')
651                    ) || (en_comillas_dobles && self.ver(1) == Some('"'));
652                    if quitar {
653                        out.push(self.ver(1).unwrap());
654                        self.i += 2;
655                    } else {
656                        out.push('\\');
657                        self.i += 1;
658                    }
659                }
660                _ => {
661                    out.push(c);
662                    self.i += 1;
663                }
664            }
665        }
666        Err("` sin cerrar".into())
667    }
668}
669
670fn numero_de_fd(p: &Palabra) -> Option<i32> {
671    if p.len() != 1 {
672        return None;
673    }
674    match &p[0] {
675        Seg::Lit { texto, comillado: false } if !texto.is_empty() => {
676            if texto.chars().all(|c| c.is_ascii_digit()) {
677                texto.parse().ok()
678            } else {
679                None
680            }
681        }
682        _ => None,
683    }
684}
685
686/// Si la palabra es exactamente un literal sin comillas, devuelve su texto. Lo usa el parser para
687/// reconocer palabras reservadas: `"if"` entrecomillado NO es la reservada `if`.
688pub fn literal_desnudo(p: &Palabra) -> Option<&str> {
689    match p.as_slice() {
690        [Seg::Lit { texto, comillado: false }] => Some(texto),
691        _ => None,
692    }
693}
694
695#[cfg(test)]
696mod pruebas {
697    use super::*;
698
699    fn cmdsub(entrada: &str) -> String {
700        let (ts, _) = tokenizar(entrada).unwrap();
701        for t in ts {
702            if let Tok::Pal(p) = t {
703                for s in p {
704                    if let Seg::CmdSub { cuerpo, .. } = s {
705                        return cuerpo;
706                    }
707                }
708            }
709        }
710        panic!("no había sustitución en {entrada:?}");
711    }
712
713    /// POSIX.1-2024 §2.6.3 — el bug de brush (reubeno/brush#1394), calcado del reproductor.
714    #[test]
715    fn comillas_invertidas_posix_2_6_3() {
716        // \$  ⇒ se elimina la barra: el cuerpo pide `echo $V`… NO: la barra ante `$` se ELIMINA,
717        // así que el cuerpo es `echo $V` y la expansión la hace la orden de adentro.
718        assert_eq!(cmdsub(r#"echo "a: [`echo \$V`]""#), "echo $V");
719        // \\  ⇒ una sola barra
720        assert_eq!(cmdsub(r#"echo "b: [`echo \\`]""#), r"echo \");
721        // \x  ⇒ la barra NO se toca (x no es $ ` ni \)
722        assert_eq!(cmdsub(r#"echo "[`echo \x`]""#), r"echo \x");
723        // en $( ) no hay regla especial: el cuerpo va tal cual
724        let (ts, _) = tokenizar(r#"echo "[$(echo \$V)]""#).unwrap();
725        let cuerpo = ts
726            .into_iter()
727            .find_map(|t| match t {
728                Tok::Pal(p) => p.into_iter().find_map(|s| match s {
729                    Seg::CmdSub { cuerpo, .. } => Some(cuerpo),
730                    _ => None,
731                }),
732                _ => None,
733            })
734            .unwrap();
735        assert_eq!(cuerpo, r"echo \$V");
736    }
737
738    #[test]
739    fn aqui_doc_con_y_sin_comillas() {
740        let (_, cuerpos) = tokenizar("cat <<EOF\nuno $V\nEOF\ncat <<'FIN'\ndos $V\nFIN\n").unwrap();
741        assert_eq!(cuerpos.len(), 2);
742        assert_eq!(cuerpos[0], ("uno $V\n".to_string(), true));
743        assert_eq!(cuerpos[1], ("dos $V\n".to_string(), false));
744    }
745
746    #[test]
747    fn aqui_doc_guion_come_tabulaciones() {
748        let (_, cuerpos) = tokenizar("cat <<-EOF\n\t\tsin-tab\n\tEOF\n").unwrap();
749        assert_eq!(cuerpos[0].0, "sin-tab\n");
750    }
751
752    #[test]
753    fn descriptor_pegado_a_redireccion() {
754        let (ts, _) = tokenizar("cmd 2>/dev/null").unwrap();
755        assert!(ts.contains(&Tok::IoNum(2)));
756        assert!(ts.contains(&Tok::Op(">".into())));
757    }
758
759    #[test]
760    fn comentario_solo_al_principio_de_palabra() {
761        let (ts, _) = tokenizar("echo a#b # comentario").unwrap();
762        let pals: Vec<_> = ts
763            .iter()
764            .filter_map(|t| match t {
765                Tok::Pal(p) => literal_desnudo(p),
766                _ => None,
767            })
768            .collect();
769        assert_eq!(pals, vec!["echo", "a#b"]);
770    }
771
772    #[test]
773    fn comillas_vacias_son_un_campo() {
774        let (ts, _) = tokenizar("echo ''").unwrap();
775        match &ts[1] {
776            Tok::Pal(p) => assert_eq!(p, &vec![Seg::Lit { texto: String::new(), comillado: true }]),
777            t => panic!("{t:?}"),
778        }
779    }
780}