sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
simi: las cuatro expansiones de bash que no rompen POSIX
1//! 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}