qillqa: el awk propio — 3531 líneas, sólo libc, y CERO divergencias contra dos oráculos
«awk soberano» (usuario, 2026-09-22), que era la tercera opción del ADR pendiente
y la única que no adoptaba la semántica de otro: gawk es C con extensiones GNU,
goawk es Go y rompe la premisa de cadena de la Etapa C, frawk es otro dialecto.
Banco diferencial contra DOS oráculos a la vez —el awk de busybox 1.36.1 (el
applet que hoy viaja) y gawk 5.3.2 --posix— con 109 casos: campos y separadores,
el modelo de valores, aritmética, expresiones regulares, las funciones de cadena,
printf, control de flujo, arrays, funciones de usuario, E/S y los rincones del
parser. Resultado: 104 iguales a los dos, 0 divergencias, y 5 casos donde los
ORÁCULOS discrepan entre sí — que es justo para lo que sirve tener dos.
De esos 5, cuatro los tenemos del lado correcto por POSIX: `2^3^2` es 512 (la
potencia asocia a la derecha; busybox da 64), `index(s,"")` es 1, el `%*d` de
printf existe, y `length(array)` funciona. El quinto era nuestro y se arregló:
una coincidencia VACÍA pegada al final de la anterior no sustituye, así que
`gsub(/a*/,"-")` sobre "xax" da 3 y `-x-x-`, no 4 y `-x--x-`.
El motor de expresiones regulares es propio y esa es la razón de fondo para no
adoptar un crate: POSIX es leftmost-LONGEST y la familia Perl —el crate `regex`
en su modo por defecto— es leftmost-first. Para /a|ab/ sobre "ab", POSIX devuelve
`ab` y Perl devuelve `a`. En un awk eso decide qué borra un sub(), dónde parte un
split() y cuánto valen RSTART y RLENGTH. Construcción de Thompson con conjunto de
hilos: sin retroceso, así que `(a*)*b` contra sesenta `a` no explota.
Las cinco cosas que el banco destapó, todas dando resultados plausibles y ninguna
reventando —quedan como pruebas de regresión en tests/divergencias_medidas.rs:
· leer NF no forzaba el partido en campos ⇒ `{print NF}` daba 0 hasta que
alguien tocara un campo. El partido es perezoso a propósito (es lo que hace
barato asignar a $0) y el precio es acordarse de forzarlo.
· `length` sin paréntesis se leía como una VARIABLE llamada length, que vale
vacío. Es el único builtin al que POSIX permite omitirlos.
· los arrays se pasan por referencia, y eso se decide mirando si la función usa
el parámetro como array — `a["k"]=1` tiene el nombre en el LUGAR de la
asignación, y sólo se miraba el valor.
· substr con inicio ≤ 0 recorta a 1 SIN ajustar el largo, y los no enteros se
truncan. La lectura estricta de POSIX daría otra cosa; ni busybox ni gawk la
hacen, y lo que hay que ejecutar son los guiones escritos contra ellos.
· la regla del vacío en gsub, arriba.
Verificado además contra este repo: de los 61 programas awk únicos que usan sus
guiones y recetas, qillqa acepta los 59 reales (los 2 restantes son f-strings de
Python que mi extractor tomó por awk). 29 pruebas propias en verde.
El nombre es quechua («escritura») y sigue el precedente de simi: lo registra el
comentario de cabecera y lo DECIDE un ADR. El comando instalado será `awk` — eso
es contrato y lo llaman los configure de medio corpus.
⚠ Lo medido es el build NATIVO del hub. Falta la receta, el estático musl con zig
cc, y la prueba que de verdad decide: un `configure` de autotools con qillqa como
/usr/bin/awk. Es la lección de brush — pasó 56/57 casos POSIX y no construía una
sola receta.
Va también recipes/ncurses-tools.toml (clear, reset, tput, tset, infocmp, tic),
declarada en `base`: la canónica instala sólo lib y cabeceras, así que clear y
reset en una imagen eran el applet de busybox. Instala los binarios y NO la base
terminfo: resuelven con los --with-fallbacks compilados en la librería, que
cubren la consola del metal, QEMU y cualquier ssh moderno.
⚠ Y ncurses-tools selló DINÁMICO dos veces antes de salir bien: ncurses usa
`-static`/`-dynamic` como MARCADORES de tramo alrededor de las librerías —su
forma portable de -Wl,-Bstatic … -Wl,-Bdynamic—, no como decisión global. El
`-dynamic` llega al final de la línea de enlace y gana por orden. Se quita
sobreescribiendo LIBS_TIC y LIBS_TINFO en la línea de make. Tercer sabor del
agujero que documenta scripts/static-audit.sh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ae6f0b45d94eb275e2464d898b35ee9a64595f43 · by Sergio · 2026-09-22T03:48:25Z (1 month ago) · parent 87d34b95f · browse the tree at this commit
Semantic delta · 222
crates/qillqa/src/formato.rs
crates/qillqa/src/interp.rs
crates/qillqa/src/lexer.rs
crates/qillqa/src/main.rs
crates/qillqa/src/parser.rs
crates/qillqa/src/regex.rs
crates/qillqa/src/valor.rs
crates/qillqa/tests/divergencias_medidas.rs
scripts/awk-banco-diferencial.py
diff --git a/Cargo.lock b/Cargo.lock
index c7f876c6..4f3670a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -881,6 +881,13 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "qillqa"
+version = "0.0.1"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
diff --git a/Cargo.toml b/Cargo.toml
index be5d6b00..4e4e8366 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ members = [
"crates/hammerd",
"crates/netup",
"crates/simi",
+ "crates/qillqa",
]
[workspace.package]
diff --git a/crates/qillqa/Cargo.toml b/crates/qillqa/Cargo.toml
new file mode 100644
index 00000000..05224202
--- /dev/null
+++ b/crates/qillqa/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "qillqa"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+description = "qillqa — el awk del producto: POSIX, sin deps, con su propio motor de expresiones regulares."
+
+[[bin]]
+name = "qillqa"
+path = "src/main.rs"
+
+# Cero crates de terceros, igual que simi. Un awk necesita leer ficheros, escribir a tuberías y
+# lanzar procesos (`system`, `print | "cmd"`, `getline < f`), y eso es libc. Lo demás —el motor ERE,
+# la conversión número/cadena, el printf— es propio: son justamente las piezas donde un awk se
+# diferencia de otro, y adoptarlas de un crate sería adoptar su semántica en vez de la de POSIX.
+[dependencies]
+libc = "0.2"
diff --git a/crates/qillqa/src/ast.rs b/crates/qillqa/src/ast.rs
new file mode 100644
index 00000000..98c5ec8a
--- /dev/null
+++ b/crates/qillqa/src/ast.rs
@@ -0,0 +1,133 @@
+//! El árbol del programa.
+
+use std::rc::Rc;
+use crate::regex::Regex;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum BinOp { Suma, Resta, Mult, Div, Modulo, Potencia }
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum CmpOp { Lt, Le, Gt, Ge, Eq, Ne }
+
+#[derive(Clone)]
+pub enum Expr {
+ Num(f64),
+ Cad(String),
+ /// Una ERE suelta: como expresión vale `$0 ~ /re/`.
+ Ere(Rc<Regex>),
+ Var(String),
+ Campo(Box<Expr>),
+ /// `a[i]`, `a[i,j]` (los índices se unen con SUBSEP).
+ Indice(String, Vec<Expr>),
+ Asigna(Box<Lugar>, Box<Expr>),
+ /// `+=`, `-=`, … con su operador.
+ AsignaOp(Box<Lugar>, BinOp, Box<Expr>),
+ Bin(BinOp, Box<Expr>, Box<Expr>),
+ Cmp(CmpOp, Box<Expr>, Box<Expr>),
+ /// `~` y `!~`. El segundo `bool` dice si está negado.
+ Coincide(Box<Expr>, Box<Expr>, bool),
+ Y(Box<Expr>, Box<Expr>),
+ O(Box<Expr>, Box<Expr>),
+ No(Box<Expr>),
+ Neg(Box<Expr>),
+ Pos(Box<Expr>),
+ Ternario(Box<Expr>, Box<Expr>, Box<Expr>),
+ /// Concatenación por yuxtaposición: `a b`.
+ Concat(Box<Expr>, Box<Expr>),
+ /// `++x` / `x++` (y `--`). `previo` = es prefijo.
+ Incr(Box<Lugar>, bool, f64),
+ /// `(i, j) in a` y `i in a`.
+ En(Vec<Expr>, String),
+ Llamada(String, Vec<Expr>),
+ Builtin(String, Vec<Expr>),
+ /// `getline` en sus cuatro formas.
+ Getline(Getline),
+ /// `(expr)` agrupada: hay que recordarlo para distinguir `(a,b) in arr` de una lista.
+ Grupo(Box<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Getline {
+ /// `getline [lugar]` — de la entrada normal.
+ Simple(Option<Box<Lugar>>),
+ /// `getline [lugar] < "fichero"`.
+ DeFichero(Option<Box<Lugar>>, Box<Expr>),
+ /// `"cmd" | getline [lugar]`.
+ DeTuberia(Box<Expr>, Option<Box<Lugar>>),
+}
+
+/// Algo a lo que se puede asignar.
+#[derive(Clone)]
+pub enum Lugar {
+ Var(String),
+ Campo(Box<Expr>),
+ Indice(String, Vec<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Redir {
+ /// `> f`
+ Fichero(Box<Expr>),
+ /// `>> f`
+ Anexa(Box<Expr>),
+ /// `| "cmd"`
+ Tuberia(Box<Expr>),
+}
+
+#[derive(Clone)]
+pub enum Sent {
+ Expr(Expr),
+ Print(Vec<Expr>, Option<Redir>),
+ Printf(Vec<Expr>, Option<Redir>),
+ Bloque(Vec<Sent>),
+ Si(Expr, Box<Sent>, Option<Box<Sent>>),
+ Mientras(Expr, Box<Sent>),
+ HazMientras(Box<Sent>, Expr),
+ /// `for (init; cond; paso) cuerpo`
+ Para(Option<Box<Sent>>, Option<Expr>, Option<Box<Sent>>, Box<Sent>),
+ /// `for (k in a) cuerpo`
+ ParaEn(String, String, Box<Sent>),
+ Corta,
+ Sigue,
+ Siguiente,
+ SiguienteFichero,
+ Sale(Option<Expr>),
+ Retorna(Option<Expr>),
+ Borra(String, Vec<Expr>),
+ /// `delete a` entero.
+ BorraTodo(String),
+ Nada,
+}
+
+#[derive(Clone)]
+pub enum Patron {
+ Begin,
+ End,
+ /// Sin patrón: la acción corre para cada registro.
+ Siempre,
+ Expr(Expr),
+ /// `expr1, expr2` — rango. El `bool` es el estado (dentro/fuera), que vive en el intérprete.
+ Rango(Expr, Expr),
+}
+
+#[derive(Clone)]
+pub struct Regla {
+ pub patron: Patron,
+ pub accion: Option<Vec<Sent>>,
+ /// Índice del estado de rango, para las reglas `expr1, expr2`.
+ pub rango_id: usize,
+}
+
+#[derive(Clone)]
+pub struct Funcion {
+ pub nombre: String,
+ pub params: Vec<String>,
+ pub cuerpo: Vec<Sent>,
+}
+
+#[derive(Clone, Default)]
+pub struct Programa {
+ pub reglas: Vec<Regla>,
+ pub funciones: Vec<Funcion>,
+ pub rangos: usize,
+}
diff --git a/crates/qillqa/src/formato.rs b/crates/qillqa/src/formato.rs
new file mode 100644
index 00000000..5b03176a
--- /dev/null
+++ b/crates/qillqa/src/formato.rs
@@ -0,0 +1,349 @@
+//! `printf`/`sprintf` de awk, y la conversión número→cadena que usan CONVFMT y OFMT.
+//!
+//! Se escribe a mano porque el `format!` de Rust no habla el mismo idioma: no tiene `%c`, no tiene
+//! el `*` que toma la anchura de un argumento, y su `{:e}` no produce lo que produce `%e` de C
+//! (`1e2` contra `1.000000e+02`). Y porque awk manda cosas que C no: `%c` con un número es el
+//! carácter de ese código, pero `%c` con una cadena es su PRIMER carácter.
+//!
+//! Los formatos admitidos son los de POSIX: `%d %i %o %u %x %X %c %s %e %E %f %F %g %G %a %A %%`,
+//! con banderas `-+ #0`, anchura, precisión y `*` en las dos.
+
+pub struct Campo {
+ pub izquierda: bool,
+ pub signo: bool,
+ pub espacio: bool,
+ pub alterno: bool,
+ pub cero: bool,
+ pub ancho: Option<usize>,
+ pub precision: Option<usize>,
+ pub conv: char,
+}
+
+/// Aplica un formato de UNA conversión a un número. Lo usa `numero_a_cadena` para CONVFMT/OFMT,
+/// donde el formato viene de una variable del programa y puede ser cualquier cosa.
+pub fn una_conversion(fmt: &str, n: f64) -> String {
+ let mut salida = String::new();
+ let cs: Vec<char> = fmt.chars().collect();
+ let mut i = 0;
+ while i < cs.len() {
+ if cs[i] != '%' { salida.push(cs[i]); i += 1; continue; }
+ if i + 1 < cs.len() && cs[i + 1] == '%' { salida.push('%'); i += 2; continue; }
+ let (campo, siguiente) = match analizar(&cs, i + 1, &mut Vec::new(), &mut 0) {
+ Some(x) => x,
+ None => { salida.push('%'); i += 1; continue; }
+ };
+ salida.push_str(&aplicar_numero(&campo, n));
+ i = siguiente;
+ // Sólo la PRIMERA conversión consume el número; el resto del formato se copia tal cual.
+ while i < cs.len() { salida.push(cs[i]); i += 1; }
+ }
+ salida
+}
+
+/// Lee un especificador tras el `%`. `args` y `pos` sirven para los `*`, que consumen argumentos.
+fn analizar(cs: &[char], mut i: usize, anchos: &mut Vec<i64>, _pos: &mut usize) -> Option<(Campo, usize)> {
+ let mut c = Campo {
+ izquierda: false, signo: false, espacio: false, alterno: false, cero: false,
+ ancho: None, precision: None, conv: 's',
+ };
+ loop {
+ match cs.get(i) {
+ Some('-') => c.izquierda = true,
+ Some('+') => c.signo = true,
+ Some(' ') => c.espacio = true,
+ Some('#') => c.alterno = true,
+ Some('0') => c.cero = true,
+ _ => break,
+ }
+ i += 1;
+ }
+ if cs.get(i) == Some(&'*') {
+ i += 1;
+ let v = if anchos.is_empty() { 0 } else { anchos.remove(0) };
+ if v < 0 { c.izquierda = true; c.ancho = Some((-v) as usize); } else { c.ancho = Some(v as usize); }
+ } else {
+ let mut n = String::new();
+ while matches!(cs.get(i), Some(d) if d.is_ascii_digit()) { n.push(cs[i]); i += 1; }
+ if !n.is_empty() { c.ancho = n.parse().ok(); }
+ }
+ if cs.get(i) == Some(&'.') {
+ i += 1;
+ if cs.get(i) == Some(&'*') {
+ i += 1;
+ let v = if anchos.is_empty() { 0 } else { anchos.remove(0) };
+ c.precision = Some(v.max(0) as usize);
+ } else {
+ let mut n = String::new();
+ while matches!(cs.get(i), Some(d) if d.is_ascii_digit()) { n.push(cs[i]); i += 1; }
+ c.precision = Some(n.parse().unwrap_or(0));
+ }
+ }
+ // Modificadores de longitud de C: se aceptan y se ignoran, que es lo que hacen los awk.
+ while matches!(cs.get(i), Some('h') | Some('l') | Some('L') | Some('q') | Some('j') | Some('z') | Some('t')) { i += 1; }
+ let conv = *cs.get(i)?;
+ c.conv = conv;
+ Some((c, i + 1))
+}
+
+fn rellenar(s: String, c: &Campo, numerico: bool) -> String {
+ let ancho = match c.ancho { None => return s, Some(a) => a };
+ let largo = s.chars().count();
+ if largo >= ancho { return s; }
+ let faltan = ancho - largo;
+ if c.izquierda { return s + &" ".repeat(faltan); }
+ if c.cero && numerico && c.precision.is_none() {
+ // El cero va DESPUÉS del signo: `%05d` de -1 es `-0001`, no `000-1`.
+ let (signo, resto) = match s.chars().next() {
+ Some(x) if x == '-' || x == '+' || x == ' ' => (s[..1].to_string(), s[1..].to_string()),
+ _ => (String::new(), s),
+ };
+ return signo + &"0".repeat(faltan) + &resto;
+ }
+ " ".repeat(faltan) + &s
+}
+
+fn con_signo(s: String, negativo: bool, c: &Campo) -> String {
+ if negativo { return s; }
+ if c.signo { return format!("+{s}"); }
+ if c.espacio { return format!(" {s}"); }
+ s
+}
+
+fn aplicar_numero(c: &Campo, n: f64) -> String {
+ let cuerpo = match c.conv {
+ 'd' | 'i' => {
+ // awk trunca hacia cero, como C.
+ let v = if n.is_nan() { 0 } else { n.trunc() as i64 };
+ let mut s = v.abs().to_string();
+ if let Some(p) = c.precision { if s.len() < p { s = "0".repeat(p - s.len()) + &s; } }
+ let s = if v < 0 { format!("-{s}") } else { s };
+ con_signo(s, v < 0, c)
+ }
+ 'o' | 'x' | 'X' | 'u' => {
+ // Los negativos se ven como unsigned de 64 bits, igual que C.
+ let v = if n.is_nan() { 0i64 } else { n.trunc() as i64 };
+ let u = v as u64;
+ let mut s = match c.conv {
+ 'o' => format!("{u:o}"),
+ 'x' => format!("{u:x}"),
+ 'X' => format!("{u:X}"),
+ _ => format!("{u}"),
+ };
+ if let Some(p) = c.precision { if s.len() < p { s = "0".repeat(p - s.len()) + &s; } }
+ if c.alterno {
+ match c.conv {
+ 'o' if !s.starts_with('0') => s = format!("0{s}"),
+ 'x' if u != 0 => s = format!("0x{s}"),
+ 'X' if u != 0 => s = format!("0X{s}"),
+ _ => {}
+ }
+ }
+ s
+ }
+ 'c' => {
+ let cod = n.trunc() as u32;
+ char::from_u32(cod).map(|x| x.to_string()).unwrap_or_default()
+ }
+ 'e' | 'E' | 'f' | 'F' | 'g' | 'G' | 'a' | 'A' => {
+ let p = c.precision.unwrap_or(6);
+ let neg = n.is_sign_negative() && n != 0.0;
+ let a = n.abs();
+ let s = match c.conv {
+ 'f' | 'F' => format!("{a:.p$}", p = p),
+ 'e' | 'E' => exponencial(a, p, c.conv == 'E'),
+ 'a' | 'A' => format!("{a}"), // hexadecimal de coma flotante: rarísimo en awk
+ _ => general(a, p, c.conv == 'G', c.alterno),
+ };
+ let s = if neg { format!("-{s}") } else { s };
+ con_signo(s, neg, c)
+ }
+ 's' => String::new(),
+ otro => format!("%{otro}"),
+ };
+ rellenar(cuerpo, c, c.conv != 's' && c.conv != 'c')
+}
+
+fn exponencial(a: f64, p: usize, mayus: bool) -> String {
+ let s = format!("{:.*e}", p, a);
+ // Rust escribe `1.5e2`; C quiere `1.5e+02`, con al menos dos dígitos y signo siempre.
+ let (mant, exp) = match s.split_once('e') { Some(x) => x, None => return s };
+ let n: i32 = exp.parse().unwrap_or(0);
+ let e = if mayus { 'E' } else { 'e' };
+ format!("{mant}{e}{}{:02}", if n < 0 { '-' } else { '+' }, n.abs())
+}
+
+/// `%g`: el más corto entre `%e` y `%f`, y sin ceros a la derecha salvo con `#`.
+fn general(a: f64, p: usize, mayus: bool, alterno: bool) -> String {
+ let p = if p == 0 { 1 } else { p };
+ let exp = if a == 0.0 { 0 } else { a.abs().log10().floor() as i32 };
+ let usa_e = exp < -4 || exp >= p as i32;
+ let mut s = if usa_e {
+ exponencial(a, p - 1, mayus)
+ } else {
+ let dec = (p as i32 - 1 - exp).max(0) as usize;
+ format!("{a:.dec$}")
+ };
+ if !alterno && s.contains('.') {
+ // Quitar ceros de cola, y el punto si queda solo. Con exponente hay que respetar la parte
+ // de después de la `e`.
+ if let Some(pos_e) = s.find(|c| c == 'e' || c == 'E') {
+ let (m, e) = s.split_at(pos_e);
+ let m = m.trim_end_matches('0').trim_end_matches('.');
+ s = format!("{m}{e}");
+ } else {
+ s = s.trim_end_matches('0').trim_end_matches('.').to_string();
+ }
+ }
+ s
+}
+
+/// El `printf` completo: recorre el formato consumiendo argumentos.
+pub fn printf(fmt: &str, args: &[Arg], convfmt: &str) -> String {
+ let cs: Vec<char> = fmt.chars().collect();
+ let mut salida = String::new();
+ let mut i = 0;
+ let mut n_arg = 0;
+ // Los `*` consumen argumentos por delante; se resuelven al vuelo.
+ while i < cs.len() {
+ if cs[i] != '%' { salida.push(cs[i]); i += 1; continue; }
+ if cs.get(i + 1) == Some(&'%') { salida.push('%'); i += 2; continue; }
+ // Contar cuántos `*` trae este especificador para sacarlos de los argumentos.
+ let mut anchos: Vec<i64> = Vec::new();
+ {
+ let mut j = i + 1;
+ while matches!(cs.get(j), Some('-') | Some('+') | Some(' ') | Some('#') | Some('0')) { j += 1; }
+ if cs.get(j) == Some(&'*') {
+ anchos.push(args.get(n_arg).map(|a| a.numero() as i64).unwrap_or(0));
+ n_arg += 1;
+ j += 1;
+ } else { while matches!(cs.get(j), Some(d) if d.is_ascii_digit()) { j += 1; } }
+ if cs.get(j) == Some(&'.') {
+ j += 1;
+ if cs.get(j) == Some(&'*') {
+ anchos.push(args.get(n_arg).map(|a| a.numero() as i64).unwrap_or(0));
+ n_arg += 1;
+ }
+ }
+ }
+ let (campo, sig) = match analizar(&cs, i + 1, &mut anchos, &mut n_arg) {
+ Some(x) => x,
+ None => { salida.push('%'); i += 1; continue; }
+ };
+ i = sig;
+ let arg = args.get(n_arg);
+ n_arg += 1;
+ match campo.conv {
+ 's' => {
+ let mut s = arg.map(|a| a.cadena(convfmt)).unwrap_or_default();
+ if let Some(p) = campo.precision { s = s.chars().take(p).collect(); }
+ salida.push_str(&rellenar(s, &campo, false));
+ }
+ 'c' => {
+ // ⚠ `%c` mira el TIPO: con un número es el carácter de ese código; con una cadena,
+ // su primer carácter. Un `printf "%c", 65` da `A`, y `printf "%c", "65"` da `6`.
+ let s = match arg {
+ None => String::new(),
+ Some(a) => match a.caracter(convfmt) { Some(c) => c.to_string(), None => String::new() },
+ };
+ salida.push_str(&rellenar(s, &campo, false));
+ }
+ _ => {
+ let n = arg.map(|a| a.numero()).unwrap_or(0.0);
+ salida.push_str(&aplicar_numero(&campo, n));
+ }
+ }
+ }
+ salida
+}
+
+/// Lo que `printf` necesita saber de un argumento. Se define acá para que `formato` no dependa del
+/// intérprete entero.
+pub enum Arg {
+ Numero(f64),
+ Texto(String),
+ /// De la entrada: `%c` lo trata como cadena, `%d` como número.
+ Entrada(String, Option<f64>),
+}
+
+impl Arg {
+ pub fn numero(&self) -> f64 {
+ match self {
+ Arg::Numero(n) => *n,
+ Arg::Texto(s) => crate::valor::prefijo_numerico(s),
+ Arg::Entrada(s, n) => n.unwrap_or_else(|| crate::valor::prefijo_numerico(s)),
+ }
+ }
+ pub fn cadena(&self, convfmt: &str) -> String {
+ match self {
+ Arg::Numero(n) => crate::valor::numero_a_cadena(*n, convfmt),
+ Arg::Texto(s) => s.clone(),
+ Arg::Entrada(s, _) => s.clone(),
+ }
+ }
+ fn caracter(&self, convfmt: &str) -> Option<char> {
+ match self {
+ Arg::Numero(n) => char::from_u32(n.trunc() as u32),
+ Arg::Texto(s) => s.chars().next(),
+ // Un strnum con pinta de número se comporta como número.
+ Arg::Entrada(s, Some(n)) => { let _ = convfmt; char::from_u32(n.trunc() as u32).or_else(|| s.chars().next()) }
+ Arg::Entrada(s, None) => s.chars().next(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ fn p(fmt: &str, args: Vec<Arg>) -> String { printf(fmt, &args, "%.6g") }
+
+ #[test]
+ fn enteros_con_ancho_y_signo() {
+ assert_eq!(p("%d", vec![Arg::Numero(42.0)]), "42");
+ assert_eq!(p("%5d|", vec![Arg::Numero(42.0)]), " 42|");
+ assert_eq!(p("%-5d|", vec![Arg::Numero(42.0)]), "42 |");
+ assert_eq!(p("%05d", vec![Arg::Numero(-1.0)]), "-0001"); // el cero va tras el signo
+ assert_eq!(p("%+d", vec![Arg::Numero(7.0)]), "+7");
+ assert_eq!(p("%d", vec![Arg::Numero(-3.9)]), "-3"); // trunca hacia cero
+ }
+
+ #[test]
+ fn coma_flotante() {
+ assert_eq!(p("%.2f", vec![Arg::Numero(3.14159)]), "3.14");
+ assert_eq!(p("%e", vec![Arg::Numero(150.0)]), "1.500000e+02");
+ assert_eq!(p("%g", vec![Arg::Numero(150.0)]), "150");
+ assert_eq!(p("%g", vec![Arg::Numero(0.0000123)]), "1.23e-05");
+ assert_eq!(p("%.3g", vec![Arg::Numero(3.14159)]), "3.14");
+ }
+
+ #[test]
+ fn porciento_c_mira_el_tipo() {
+ assert_eq!(p("%c", vec![Arg::Numero(65.0)]), "A");
+ assert_eq!(p("%c", vec![Arg::Texto("65".into())]), "6");
+ assert_eq!(p("%c", vec![Arg::Texto("héroe".into())]), "h");
+ }
+
+ #[test]
+ fn el_asterisco_toma_la_anchura_del_argumento() {
+ assert_eq!(p("%*d|", vec![Arg::Numero(5.0), Arg::Numero(42.0)]), " 42|");
+ assert_eq!(p("%.*f", vec![Arg::Numero(2.0), Arg::Numero(3.14159)]), "3.14");
+ }
+
+ #[test]
+ fn cadenas_con_precision() {
+ assert_eq!(p("%.3s", vec![Arg::Texto("abcdef".into())]), "abc");
+ assert_eq!(p("%10.3s|", vec![Arg::Texto("abcdef".into())]), " abc|");
+ }
+
+ #[test]
+ fn faltan_argumentos_no_es_error() {
+ assert_eq!(p("%s-%d", vec![]), "-0");
+ }
+
+ #[test]
+ fn hexadecimal_y_octal() {
+ assert_eq!(p("%x", vec![Arg::Numero(255.0)]), "ff");
+ assert_eq!(p("%#X", vec![Arg::Numero(255.0)]), "0XFF");
+ assert_eq!(p("%o", vec![Arg::Numero(8.0)]), "10");
+ }
+}
diff --git a/crates/qillqa/src/interp.rs b/crates/qillqa/src/interp.rs
new file mode 100644
index 00000000..fd8060b6
--- /dev/null
+++ b/crates/qillqa/src/interp.rs
@@ -0,0 +1,1306 @@
+//! El intérprete: recorre el árbol y ejecuta.
+//!
+//! Tres piezas merecen leerse antes de tocar nada:
+//!
+//! * **Los campos y `$0` son un mismo dato con dos caras.** Escribir `$2` reconstruye `$0` uniendo
+//! con OFS; escribir `$0` vuelve a partir en campos con FS; y asignar a `$5` cuando sólo hay tres
+//! campos CREA los que faltan vacíos y sube NF. Todo eso vive en `partir` / `rehacer_registro`.
+//! * **El separador FS tiene cuatro comportamientos**, no uno: `" "` (el de por defecto) parte por
+//! rachas de espacios/tabuladores/saltos y descarta los de los extremos; un carácter suelto parte
+//! por ese carácter exacto; `"\t"` es ese caso; y cualquier cosa de más de un carácter es una ERE.
+//! * **Los arrays se pasan POR REFERENCIA a las funciones y los escalares por valor**, y eso no se
+//! declara: se descubre al usar el parámetro. Por eso un parámetro puede empezar indefinido y
+//! convertirse en array dentro de la llamada.
+
+use crate::ast::*;
+use crate::formato::{self, Arg};
+use crate::regex::Regex;
+use crate::valor::{self, Valor};
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::rc::Rc;
+
+/// Lo que puede haber en una variable: un escalar o un array. No hay un tercer caso, y confundirlos
+/// es un error en awk (`x[1]=1; x=2` es ilegal), pero no lo comprobamos: los awk reales tampoco lo
+/// hacen en todos los caminos y romper eso sólo rompe guiones que ya funcionaban.
+#[derive(Clone)]
+pub enum Celda {
+ Escalar(Valor),
+ Array(Rc<RefCell<HashMap<String, Valor>>>),
+}
+
+/// Por qué se interrumpió la ejecución de un bloque.
+pub enum Flujo {
+ Normal,
+ Corta,
+ Sigue,
+ Siguiente,
+ SiguienteFichero,
+ Retorna(Valor),
+ Sale(i32),
+}
+
+macro_rules! propaga {
+ ($e:expr) => {
+ match $e { Flujo::Normal => {}, otro => return Ok(otro) }
+ };
+}
+
+pub struct Salida {
+ escritor: Box<dyn Write>,
+ /// Si es una tubería, hay un hijo al que esperar en `close`.
+ hijo: Option<std::process::Child>,
+}
+
+pub struct Entrada {
+ lector: Box<dyn BufRead>,
+ hijo: Option<std::process::Child>,
+}
+
+pub struct Interprete {
+ pub prog: Programa,
+ globales: HashMap<String, Celda>,
+ /// Marcos de las funciones. El último es el actual.
+ marcos: Vec<HashMap<String, Celda>>,
+ campos: Vec<String>,
+ registro: String,
+ /// `$0` ya no refleja los campos (alguien escribió `$2`) y hay que rehacerlo antes de leerlo.
+ sucio_registro: bool,
+ /// Los campos ya no reflejan `$0`.
+ sucio_campos: bool,
+ rangos_activos: Vec<bool>,
+ salidas: HashMap<String, Salida>,
+ entradas: HashMap<String, Entrada>,
+ ere_cache: HashMap<String, Rc<Regex>>,
+ pub codigo_salida: i32,
+ semilla: u64,
+ semilla_previa: f64,
+ /// Ficheros de la línea de órdenes pendientes, y el que se está leyendo.
+ lector_actual: Option<Box<dyn BufRead>>,
+ indice_argv: usize,
+ pub stdout: Box<dyn Write>,
+}
+
+fn ahora_ms() -> u64 {
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
+}
+
+impl Interprete {
+ pub fn nuevo(prog: Programa) -> Interprete {
+ let rangos = prog.rangos;
+ let mut it = Interprete {
+ prog,
+ globales: HashMap::new(),
+ marcos: Vec::new(),
+ campos: Vec::new(),
+ registro: String::new(),
+ sucio_registro: false,
+ sucio_campos: false,
+ rangos_activos: vec![false; rangos],
+ salidas: HashMap::new(),
+ entradas: HashMap::new(),
+ ere_cache: HashMap::new(),
+ codigo_salida: 0,
+ semilla: 0,
+ semilla_previa: 0.0,
+ lector_actual: None,
+ indice_argv: 1,
+ stdout: Box::new(std::io::BufWriter::new(std::io::stdout())),
+ };
+ for (k, v) in [("FS", " "), ("OFS", " "), ("ORS", "\n"), ("RS", "\n"),
+ ("SUBSEP", "\u{1c}"), ("CONVFMT", "%.6g"), ("OFMT", "%.6g"), ("FILENAME", "")] {
+ it.globales.insert(k.into(), Celda::Escalar(Valor::Cad(v.into())));
+ }
+ for k in ["NR", "NF", "FNR", "RSTART", "RLENGTH"] {
+ let v = if k == "RLENGTH" { -1.0 } else { 0.0 };
+ it.globales.insert(k.into(), Celda::Escalar(Valor::Num(v)));
+ }
+ it.semilla = 0;
+ it
+ }
+
+ // ── Variables ────────────────────────────────────────────────────────────────────────────────
+
+ fn celda(&self, n: &str) -> Option<&Celda> {
+ if let Some(m) = self.marcos.last() {
+ if let Some(c) = m.get(n) { return Some(c); }
+ }
+ self.globales.get(n)
+ }
+
+ fn en_marco(&self, n: &str) -> bool {
+ self.marcos.last().map_or(false, |m| m.contains_key(n))
+ }
+
+ pub fn leer_var(&self, n: &str) -> Valor {
+ match self.celda(n) {
+ Some(Celda::Escalar(v)) => v.clone(),
+ Some(Celda::Array(_)) => Valor::Vacio,
+ None => Valor::Vacio,
+ }
+ }
+
+ pub fn poner_var(&mut self, n: &str, v: Valor) {
+ if self.en_marco(n) {
+ self.marcos.last_mut().unwrap().insert(n.into(), Celda::Escalar(v));
+ return;
+ }
+ // Escribir NF recorta o extiende el registro; escribir los demás no tiene efecto colateral.
+ if n == "NF" {
+ let nuevo = v.numero().max(0.0) as usize;
+ self.asegurar_campos();
+ self.campos.resize(nuevo, String::new());
+ self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(nuevo as f64)));
+ self.sucio_registro = true;
+ return;
+ }
+ self.globales.insert(n.into(), Celda::Escalar(v));
+ }
+
+ fn cadena_de(&self, v: &Valor) -> String { v.cadena(&self.convfmt()) }
+ fn convfmt(&self) -> String { self.leer_var("CONVFMT").cadena("%.6g") }
+ fn ofmt(&self) -> String { self.leer_var("OFMT").cadena("%.6g") }
+
+ fn array(&mut self, n: &str) -> Rc<RefCell<HashMap<String, Valor>>> {
+ if let Some(Celda::Array(a)) = self.celda(n) { return a.clone(); }
+ let a = Rc::new(RefCell::new(HashMap::new()));
+ if self.en_marco(n) {
+ self.marcos.last_mut().unwrap().insert(n.into(), Celda::Array(a.clone()));
+ } else {
+ self.globales.insert(n.into(), Celda::Array(a.clone()));
+ }
+ a
+ }
+
+ // ── Campos ───────────────────────────────────────────────────────────────────────────────────
+
+ /// ⚠ Los cuatro comportamientos de FS.
+ fn partir(&mut self, linea: &str) -> Vec<String> {
+ let fs = self.leer_var("FS").cadena("%.6g");
+ Self::partir_con(linea, &fs, |p| self.ere(p))
+ }
+
+ fn partir_con<F: FnMut(&str) -> Rc<Regex>>(linea: &str, fs: &str, mut ere: F) -> Vec<String> {
+ if fs == " " {
+ return linea.split(|c: char| c == ' ' || c == '\t' || c == '\n')
+ .filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
+ }
+ if linea.is_empty() { return Vec::new(); }
+ if fs.chars().count() == 1 && fs != "\\" {
+ let c = fs.chars().next().unwrap();
+ // Un único carácter es literal, salvo que sea el espacio (ya tratado). En POSIX un
+ // metacarácter suelto —`.` o `|`— también es literal acá, y ése es el punto.
+ return linea.split(c).map(|s| s.to_string()).collect();
+ }
+ let re = ere(fs);
+ let cs: Vec<char> = linea.chars().collect();
+ let mut trozos = Vec::new();
+ let mut ini = 0;
+ let mut i = 0;
+ while i <= cs.len() {
+ match re.buscar(&cs, i) {
+ Some((a, b)) if b > a => {
+ trozos.push(cs[ini..a].iter().collect::<String>());
+ ini = b;
+ i = b;
+ }
+ // Una coincidencia VACÍA no parte: si no, `FS="x*"` haría un trozo por carácter y
+ // se comería la cadena entera.
+ Some((a, _)) => { i = a + 1; }
+ None => break,
+ }
+ }
+ trozos.push(cs[ini.min(cs.len())..].iter().collect::<String>());
+ trozos
+ }
+
+ fn asegurar_campos(&mut self) {
+ if !self.sucio_campos { return; }
+ self.sucio_campos = false;
+ let reg = self.registro.clone();
+ self.campos = self.partir(®);
+ let nf = self.campos.len() as f64;
+ self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(nf)));
+ }
+
+ fn asegurar_registro(&mut self) {
+ if !self.sucio_registro { return; }
+ self.sucio_registro = false;
+ let ofs = self.leer_var("OFS").cadena("%.6g");
+ self.registro = self.campos.join(&ofs);
+ }
+
+ pub fn poner_registro(&mut self, s: String) {
+ self.registro = s;
+ self.sucio_campos = true;
+ self.sucio_registro = false;
+ }
+
+ fn leer_campo(&mut self, i: usize) -> Valor {
+ if i == 0 {
+ self.asegurar_registro();
+ return Valor::de_entrada(&self.registro.clone());
+ }
+ self.asegurar_campos();
+ match self.campos.get(i - 1) {
+ Some(s) => Valor::de_entrada(s),
+ None => Valor::Vacio,
+ }
+ }
+
+ fn poner_campo(&mut self, i: usize, v: String) {
+ if i == 0 {
+ self.poner_registro(v);
+ self.asegurar_campos();
+ return;
+ }
+ self.asegurar_campos();
+ if i > self.campos.len() {
+ self.campos.resize(i, String::new());
+ self.globales.insert("NF".into(), Celda::Escalar(Valor::Num(self.campos.len() as f64)));
+ }
+ self.campos[i - 1] = v;
+ self.sucio_registro = true;
+ }
+
+ // ── Expresiones regulares ────────────────────────────────────────────────────────────────────
+
+ /// Compila y CACHEA. Sin la caché, un `gsub(/x/,"")` dentro de un bucle recompilaría el patrón
+ /// en cada vuelta; con ella se compila una vez por texto distinto.
+ fn ere(&self, patron: &str) -> Rc<Regex> {
+ // La caché es interior-mutable de hecho: se usa desde `&self` para poder llamarla desde
+ // `partir`, que ya tiene prestado el intérprete. Se resuelve compilando sin cachear cuando
+ // no se puede tomar prestado — es raro y el coste es de una compilación.
+ if let Some(r) = self.ere_cache.get(patron) { return r.clone(); }
+ Rc::new(Regex::nueva(patron).unwrap_or_else(|e| {
+ eprintln!("qillqa: expresión regular inválida /{patron}/: {e}");
+ std::process::exit(2);
+ }))
+ }
+
+ fn ere_mut(&mut self, patron: &str) -> Rc<Regex> {
+ if let Some(r) = self.ere_cache.get(patron) { return r.clone(); }
+ let r = self.ere(patron);
+ self.ere_cache.insert(patron.to_string(), r.clone());
+ r
+ }
+
+ /// La ERE de una expresión: si es un literal `/re/`, la suya; si no, se compila su valor.
+ fn ere_de(&mut self, e: &Expr) -> Result<Rc<Regex>, String> {
+ if let Expr::Ere(r) = e { return Ok(r.clone()); }
+ let v = self.eval(e)?;
+ let s = self.cadena_de(&v);
+ Ok(self.ere_mut(&s))
+ }
+
+ // ── Ejecución ────────────────────────────────────────────────────────────────────────────────
+
+ pub fn ejecutar_begin(&mut self) -> Result<Flujo, String> {
+ let reglas = self.prog.reglas.clone();
+ for r in reglas.iter().filter(|r| matches!(r.patron, Patron::Begin)) {
+ if let Some(a) = &r.accion {
+ propaga!(self.bloque(a)?);
+ }
+ }
+ Ok(Flujo::Normal)
+ }
+
+ pub fn ejecutar_end(&mut self) -> Result<Flujo, String> {
+ let reglas = self.prog.reglas.clone();
+ for r in reglas.iter().filter(|r| matches!(r.patron, Patron::End)) {
+ if let Some(a) = &r.accion {
+ propaga!(self.bloque(a)?);
+ }
+ }
+ Ok(Flujo::Normal)
+ }
+
+ pub fn hay_main(&self) -> bool {
+ self.prog.reglas.iter().any(|r| !matches!(r.patron, Patron::Begin))
+ }
+ pub fn hay_end(&self) -> bool {
+ self.prog.reglas.iter().any(|r| matches!(r.patron, Patron::End))
+ }
+
+ /// Corre las reglas normales para el registro actual.
+ pub fn ejecutar_reglas(&mut self) -> Result<Flujo, String> {
+ let reglas = self.prog.reglas.clone();
+ for r in ®las {
+ let corre = match &r.patron {
+ Patron::Begin | Patron::End => continue,
+ Patron::Siempre => true,
+ Patron::Expr(e) => self.eval(e)?.verdad(),
+ Patron::Rango(a, b) => {
+ let id = r.rango_id;
+ if !self.rangos_activos[id] {
+ if self.eval(a)?.verdad() {
+ // Un rango cuyo final casa en la MISMA línea empieza y acaba ahí.
+ self.rangos_activos[id] = !self.eval(b)?.verdad();
+ true
+ } else { false }
+ } else {
+ if self.eval(b)?.verdad() { self.rangos_activos[id] = false; }
+ true
+ }
+ }
+ };
+ if !corre { continue; }
+ match &r.accion {
+ None => { let s = self.leer_campo(0); let t = self.cadena_de(&s); self.escribir_linea(&t, &None)?; }
+ Some(a) => propaga!(self.bloque(a)?),
+ }
+ }
+ Ok(Flujo::Normal)
+ }
+
+ fn bloque(&mut self, ss: &[Sent]) -> Result<Flujo, String> {
+ for s in ss { propaga!(self.sentencia(s)?); }
+ Ok(Flujo::Normal)
+ }
+
+ fn sentencia(&mut self, s: &Sent) -> Result<Flujo, String> {
+ match s {
+ Sent::Nada => {}
+ Sent::Expr(e) => { self.eval(e)?; }
+ Sent::Bloque(b) => propaga!(self.bloque(b)?),
+ Sent::Print(args, redir) => {
+ let ofs = self.leer_var("OFS").cadena("%.6g");
+ let ofmt = self.ofmt();
+ let mut partes = Vec::new();
+ if args.is_empty() {
+ let v = self.leer_campo(0);
+ partes.push(self.cadena_de(&v));
+ } else {
+ let planos = Self::aplanar(args);
+ for a in &planos {
+ let v = self.eval(a)?;
+ // `print` usa OFMT para los no-enteros, no CONVFMT. Es la única diferencia
+ // entre las dos variables y se nota en cuanto alguien toca OFMT.
+ partes.push(match &v {
+ Valor::Num(n) => valor::numero_a_cadena(*n, &ofmt),
+ otro => self.cadena_de(otro),
+ });
+ }
+ }
+ let linea = partes.join(&ofs);
+ self.escribir_linea(&linea, redir)?;
+ }
+ Sent::Printf(args, redir) => {
+ let planos = Self::aplanar(args);
+ if planos.is_empty() { return Err("printf sin formato".into()); }
+ let fv = self.eval(&planos[0])?;
+ let fmt = self.cadena_de(&fv);
+ let mut vals = Vec::new();
+ for a in &planos[1..] {
+ let v = self.eval(a)?;
+ vals.push(match v {
+ Valor::Num(n) => Arg::Numero(n),
+ Valor::Cad(s) => Arg::Texto(s),
+ Valor::StrNum(s, n) => Arg::Entrada(s, n),
+ Valor::Vacio => Arg::Texto(String::new()),
+ });
+ }
+ let texto = formato::printf(&fmt, &vals, &self.convfmt());
+ self.escribir_crudo(&texto, redir)?;
+ }
+ Sent::Si(c, a, b) => {
+ if self.eval(c)?.verdad() { propaga!(self.sentencia(a)?); }
+ else if let Some(b) = b { propaga!(self.sentencia(b)?); }
+ }
+ Sent::Mientras(c, cuerpo) => {
+ while self.eval(c)?.verdad() {
+ match self.sentencia(cuerpo)? {
+ Flujo::Corta => break,
+ Flujo::Sigue | Flujo::Normal => {}
+ otro => return Ok(otro),
+ }
+ }
+ }
+ Sent::HazMientras(cuerpo, c) => {
+ loop {
+ match self.sentencia(cuerpo)? {
+ Flujo::Corta => break,
+ Flujo::Sigue | Flujo::Normal => {}
+ otro => return Ok(otro),
+ }
+ if !self.eval(c)?.verdad() { break; }
+ }
+ }
+ Sent::Para(init, cond, paso, cuerpo) => {
+ if let Some(i) = init { propaga!(self.sentencia(i)?); }
+ loop {
+ if let Some(c) = cond { if !self.eval(c)?.verdad() { break; } }
+ match self.sentencia(cuerpo)? {
+ Flujo::Corta => break,
+ Flujo::Sigue | Flujo::Normal => {}
+ otro => return Ok(otro),
+ }
+ if let Some(p) = paso { propaga!(self.sentencia(p)?); }
+ }
+ }
+ Sent::ParaEn(k, arr, cuerpo) => {
+ let a = self.array(arr);
+ // Se copian las claves: el cuerpo puede borrar elementos, y recorrer el mapa
+ // mientras se modifica es justo lo que rompe en los awk que no lo hacen.
+ let claves: Vec<String> = a.borrow().keys().cloned().collect();
+ for c in claves {
+ if !a.borrow().contains_key(&c) { continue; }
+ self.poner_var(k, Valor::de_entrada(&c));
+ match self.sentencia(cuerpo)? {
+ Flujo::Corta => break,
+ Flujo::Sigue | Flujo::Normal => {}
+ otro => return Ok(otro),
+ }
+ }
+ }
+ Sent::Corta => return Ok(Flujo::Corta),
+ Sent::Sigue => return Ok(Flujo::Sigue),
+ Sent::Siguiente => return Ok(Flujo::Siguiente),
+ Sent::SiguienteFichero => return Ok(Flujo::SiguienteFichero),
+ Sent::Sale(e) => {
+ let c = match e { Some(x) => self.eval(x)?.numero() as i32, None => self.codigo_salida };
+ return Ok(Flujo::Sale(c));
+ }
+ Sent::Retorna(e) => {
+ let v = match e { Some(x) => self.eval(x)?, None => Valor::Vacio };
+ return Ok(Flujo::Retorna(v));
+ }
+ Sent::Borra(n, idx) => {
+ let clave = self.clave(idx)?;
+ let a = self.array(n);
+ a.borrow_mut().remove(&clave);
+ }
+ Sent::BorraTodo(n) => { let a = self.array(n); a.borrow_mut().clear(); }
+ }
+ Ok(Flujo::Normal)
+ }
+
+ /// `print (a, b)` llega como una «llamada» sintética `@lista`: acá se desarma.
+ fn aplanar(args: &[Expr]) -> Vec<Expr> {
+ if args.len() == 1 {
+ if let Expr::Llamada(n, lista) = &args[0] {
+ if n == "@lista" { return lista.clone(); }
+ }
+ }
+ args.to_vec()
+ }
+
+ fn clave(&mut self, idx: &[Expr]) -> Result<String, String> {
+ let subsep = self.leer_var("SUBSEP").cadena("%.6g");
+ let mut partes = Vec::new();
+ for e in idx {
+ let v = self.eval(e)?;
+ partes.push(self.cadena_de(&v));
+ }
+ Ok(partes.join(&subsep))
+ }
+
+ // ── Salidas y entradas ───────────────────────────────────────────────────────────────────────
+
+ fn escribir_linea(&mut self, s: &str, r: &Option<Redir>) -> Result<(), String> {
+ let ors = self.leer_var("ORS").cadena("%.6g");
+ self.escribir_crudo(&format!("{s}{ors}"), r)
+ }
+
+ fn escribir_crudo(&mut self, s: &str, r: &Option<Redir>) -> Result<(), String> {
+ match r {
+ None => { let _ = self.stdout.write_all(s.as_bytes()); }
+ Some(red) => {
+ let (destino, modo) = match red {
+ Redir::Fichero(e) => (self.eval(e)?, 'w'),
+ Redir::Anexa(e) => (self.eval(e)?, 'a'),
+ Redir::Tuberia(e) => (self.eval(e)?, 'p'),
+ };
+ let nombre = self.cadena_de(&destino);
+ self.abrir_salida(&nombre, modo)?;
+ if let Some(sal) = self.salidas.get_mut(&nombre) {
+ let _ = sal.escritor.write_all(s.as_bytes());
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn abrir_salida(&mut self, nombre: &str, modo: char) -> Result<(), String> {
+ if self.salidas.contains_key(nombre) { return Ok(()); }
+ let sal = match modo {
+ 'p' => {
+ // La tubería la abre un `sh -c`, como manda POSIX: el destino es una LÍNEA DE
+ // ÓRDENES, no un programa. Por eso `print | "sort -k2 | head"` funciona.
+ let mut hijo = std::process::Command::new("/bin/sh")
+ .arg("-c").arg(nombre)
+ .stdin(std::process::Stdio::piped())
+ .spawn().map_err(|e| format!("no pude lanzar `{nombre}`: {e}"))?;
+ let entrada = hijo.stdin.take().unwrap();
+ Salida { escritor: Box::new(entrada), hijo: Some(hijo) }
+ }
+ _ => {
+ if nombre == "/dev/stdout" || nombre == "-" {
+ Salida { escritor: Box::new(std::io::stdout()), hijo: None }
+ } else if nombre == "/dev/stderr" {
+ Salida { escritor: Box::new(std::io::stderr()), hijo: None }
+ } else {
+ let f = std::fs::OpenOptions::new()
+ .write(true).create(true)
+ .append(modo == 'a').truncate(modo == 'w')
+ .open(nombre).map_err(|e| format!("no pude abrir `{nombre}`: {e}"))?;
+ Salida { escritor: Box::new(std::io::BufWriter::new(f)), hijo: None }
+ }
+ }
+ };
+ self.salidas.insert(nombre.to_string(), sal);
+ Ok(())
+ }
+
+ pub fn cerrar_todo(&mut self) {
+ let _ = self.stdout.flush();
+ let nombres: Vec<String> = self.salidas.keys().cloned().collect();
+ for n in nombres { self.cerrar(&n); }
+ let nombres: Vec<String> = self.entradas.keys().cloned().collect();
+ for n in nombres { self.cerrar(&n); }
+ }
+
+ fn cerrar(&mut self, nombre: &str) -> i32 {
+ let mut r = -1;
+ if let Some(mut s) = self.salidas.remove(nombre) {
+ let _ = s.escritor.flush();
+ drop(s.escritor);
+ if let Some(mut h) = s.hijo.take() {
+ r = h.wait().ok().and_then(|e| e.code()).unwrap_or(0);
+ } else { r = 0; }
+ }
+ if let Some(mut e) = self.entradas.remove(nombre) {
+ drop(std::mem::replace(&mut e.lector, Box::new(std::io::empty())));
+ if let Some(mut h) = e.hijo.take() {
+ r = h.wait().ok().and_then(|x| x.code()).unwrap_or(0);
+ } else { r = 0; }
+ }
+ r
+ }
+
+ /// Lee un registro del lector dado, respetando RS. Devuelve `None` en fin de fichero.
+ fn leer_registro(lector: &mut dyn BufRead, rs: &str) -> Option<String> {
+ if rs == "\n" {
+ let mut buf = Vec::new();
+ match lector.read_until(b'\n', &mut buf) {
+ Ok(0) | Err(_) => return None,
+ Ok(_) => {}
+ }
+ if buf.last() == Some(&b'\n') { buf.pop(); }
+ if buf.last() == Some(&b'\r') { buf.pop(); }
+ return Some(String::from_utf8_lossy(&buf).into_owned());
+ }
+ if rs.is_empty() {
+ // RS="" es el MODO PÁRRAFO: los registros se separan por líneas en blanco, las de
+ // delante se descartan, y `\n` pasa a ser separador de campos además de FS.
+ let mut lineas: Vec<String> = Vec::new();
+ loop {
+ let mut buf = Vec::new();
+ match lector.read_until(b'\n', &mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(_) => {}
+ }
+ if buf.last() == Some(&b'\n') { buf.pop(); }
+ let l = String::from_utf8_lossy(&buf).into_owned();
+ if l.is_empty() {
+ if lineas.is_empty() { continue; } // blancos de delante
+ break;
+ }
+ lineas.push(l);
+ }
+ if lineas.is_empty() { return None; }
+ return Some(lineas.join("\n"));
+ }
+ // RS de un carácter.
+ let sep = rs.as_bytes()[0];
+ let mut buf = Vec::new();
+ match lector.read_until(sep, &mut buf) {
+ Ok(0) | Err(_) => return None,
+ Ok(_) => {}
+ }
+ if buf.last() == Some(&sep) { buf.pop(); }
+ Some(String::from_utf8_lossy(&buf).into_owned())
+ }
+
+ /// Siguiente registro de la entrada principal (los ficheros de ARGV, o la entrada estándar).
+ pub fn siguiente_registro(&mut self) -> Option<String> {
+ let rs = self.leer_var("RS").cadena("%.6g");
+ loop {
+ if self.lector_actual.is_none() && !self.abrir_siguiente_fuente() { return None; }
+ let lector = self.lector_actual.as_mut().unwrap();
+ match Self::leer_registro(lector.as_mut(), &rs) {
+ Some(r) => {
+ let nr = self.leer_var("NR").numero() + 1.0;
+ let fnr = self.leer_var("FNR").numero() + 1.0;
+ self.poner_var("NR", Valor::Num(nr));
+ self.poner_var("FNR", Valor::Num(fnr));
+ return Some(r);
+ }
+ None => { self.lector_actual = None; }
+ }
+ }
+ }
+
+ pub fn cerrar_fuente_actual(&mut self) { self.lector_actual = None; }
+
+ /// Abre el siguiente operando de ARGV que no sea una asignación. Devuelve false si no queda.
+ fn abrir_siguiente_fuente(&mut self) -> bool {
+ let argc = self.leer_var("ARGC").numero() as usize;
+ while self.indice_argv < argc {
+ let i = self.indice_argv;
+ self.indice_argv += 1;
+ let arr = self.array("ARGV");
+ let v = arr.borrow().get(&i.to_string()).cloned().unwrap_or(Valor::Vacio);
+ let s = self.cadena_de(&v);
+ if s.is_empty() { continue; }
+ // `var=valor` entre ficheros: asignación diferida, no un fichero.
+ if let Some(p) = posicion_asignacion(&s) {
+ let (n, v) = (s[..p].to_string(), desescapar(&s[p + 1..]));
+ self.poner_var(&n, Valor::de_entrada(&v));
+ continue;
+ }
+ self.poner_var("FILENAME", Valor::Cad(s.clone()));
+ self.poner_var("FNR", Valor::Num(0.0));
+ if s == "-" || s == "/dev/stdin" {
+ self.lector_actual = Some(Box::new(BufReader::new(std::io::stdin())));
+ return true;
+ }
+ match std::fs::File::open(&s) {
+ Ok(f) => { self.lector_actual = Some(Box::new(BufReader::new(f))); return true; }
+ Err(e) => {
+ eprintln!("qillqa: no puedo leer `{s}`: {e}");
+ self.codigo_salida = 2;
+ continue;
+ }
+ }
+ }
+ // Ningún operando de fichero: se lee la entrada estándar, una sola vez.
+ if !self.uso_stdin() {
+ self.marcar_stdin();
+ self.lector_actual = Some(Box::new(BufReader::new(std::io::stdin())));
+ return true;
+ }
+ false
+ }
+
+ fn uso_stdin(&self) -> bool { self.globales.contains_key("@stdin-usado") }
+ fn marcar_stdin(&mut self) { self.globales.insert("@stdin-usado".into(), Celda::Escalar(Valor::Num(1.0))); }
+
+ // ── Evaluación de expresiones ────────────────────────────────────────────────────────────────
+
+ pub fn eval(&mut self, e: &Expr) -> Result<Valor, String> {
+ Ok(match e {
+ Expr::Num(n) => Valor::Num(*n),
+ Expr::Cad(s) => Valor::Cad(s.clone()),
+ Expr::Grupo(inner) => self.eval(inner)?,
+ Expr::Ere(r) => {
+ // Una ERE suelta vale `$0 ~ /re/`.
+ let reg = self.leer_campo(0);
+ let s = self.cadena_de(®);
+ Valor::Num(if r.casa(&s) { 1.0 } else { 0.0 })
+ }
+ // ⚠ Leer NF obliga a PARTIR el registro: si no, `{print NF}` da 0 hasta que alguien
+ // toque un campo. El estado perezoso (`sucio_campos`) es lo que hace barato asignar a
+ // `$0`, y el precio es acordarse de forzarlo en cada lectura que dependa de los campos.
+ Expr::Var(n) if n == "NF" => { self.asegurar_campos(); self.leer_var(n) }
+ Expr::Var(n) => self.leer_var(n),
+ Expr::Campo(i) => {
+ let idx = self.eval(i)?.numero();
+ if idx < 0.0 { return Err(format!("intento de acceder al campo {idx}")); }
+ self.leer_campo(idx as usize)
+ }
+ Expr::Indice(n, idx) => {
+ let clave = self.clave(idx)?;
+ let a = self.array(n);
+ // Leer un elemento que no existe lo CREA vacío. Es observable con `in`, y es lo que
+ // manda POSIX: `if (a["x"] == "") ...` deja `"x"` dentro del array.
+ let mut m = a.borrow_mut();
+ m.entry(clave).or_insert(Valor::Vacio).clone()
+ }
+ Expr::Asigna(l, v) => { let val = self.eval(v)?; self.asignar(l, val.clone())?; val }
+ Expr::AsignaOp(l, op, v) => {
+ let actual = self.leer_lugar(l)?;
+ let d = self.eval(v)?;
+ let r = Valor::Num(aritmetica(*op, actual.numero(), d.numero()));
+ self.asignar(l, r.clone())?;
+ r
+ }
+ Expr::Bin(op, a, b) => {
+ let x = self.eval(a)?.numero();
+ let y = self.eval(b)?.numero();
+ Valor::Num(aritmetica(*op, x, y))
+ }
+ Expr::Cmp(op, a, b) => {
+ let x = self.eval(a)?;
+ let y = self.eval(b)?;
+ let o = x.comparar(&y, &self.convfmt());
+ let r = match op {
+ CmpOp::Lt => o.is_lt(), CmpOp::Le => o.is_le(), CmpOp::Gt => o.is_gt(),
+ CmpOp::Ge => o.is_ge(), CmpOp::Eq => o.is_eq(), CmpOp::Ne => o.is_ne(),
+ };
+ Valor::Num(if r { 1.0 } else { 0.0 })
+ }
+ Expr::Coincide(a, b, negado) => {
+ let v = self.eval(a)?;
+ let s = self.cadena_de(&v);
+ let re = self.ere_de(b)?;
+ let casa = re.casa(&s);
+ Valor::Num(if casa != *negado { 1.0 } else { 0.0 })
+ }
+ Expr::Y(a, b) => {
+ if !self.eval(a)?.verdad() { Valor::Num(0.0) }
+ else { Valor::Num(if self.eval(b)?.verdad() { 1.0 } else { 0.0 }) }
+ }
+ Expr::O(a, b) => {
+ if self.eval(a)?.verdad() { Valor::Num(1.0) }
+ else { Valor::Num(if self.eval(b)?.verdad() { 1.0 } else { 0.0 }) }
+ }
+ Expr::No(a) => Valor::Num(if self.eval(a)?.verdad() { 0.0 } else { 1.0 }),
+ Expr::Neg(a) => Valor::Num(-self.eval(a)?.numero()),
+ Expr::Pos(a) => Valor::Num(self.eval(a)?.numero()),
+ Expr::Ternario(c, a, b) => if self.eval(c)?.verdad() { self.eval(a)? } else { self.eval(b)? },
+ Expr::Concat(a, b) => {
+ let x = self.eval(a)?;
+ let y = self.eval(b)?;
+ Valor::Cad(format!("{}{}", self.cadena_de(&x), self.cadena_de(&y)))
+ }
+ Expr::Incr(l, previo, delta) => {
+ let antes = self.leer_lugar(l)?.numero();
+ let despues = antes + delta;
+ self.asignar(l, Valor::Num(despues))?;
+ Valor::Num(if *previo { despues } else { antes })
+ }
+ Expr::En(idx, arr) => {
+ let clave = self.clave(idx)?;
+ let a = self.array(arr);
+ let hay = a.borrow().contains_key(&clave);
+ Valor::Num(if hay { 1.0 } else { 0.0 })
+ }
+ Expr::Builtin(n, args) => self.builtin(n, args)?,
+ Expr::Llamada(n, args) => self.llamar(n, args)?,
+ Expr::Getline(g) => self.getline(g)?,
+ })
+ }
+
+ fn leer_lugar(&mut self, l: &Lugar) -> Result<Valor, String> {
+ Ok(match l {
+ Lugar::Var(n) => self.leer_var(n),
+ Lugar::Campo(i) => { let idx = self.eval(i)?.numero() as usize; self.leer_campo(idx) }
+ Lugar::Indice(n, idx) => {
+ let clave = self.clave(idx)?;
+ let a = self.array(n);
+ let v = a.borrow().get(&clave).cloned();
+ v.unwrap_or(Valor::Vacio)
+ }
+ })
+ }
+
+ fn asignar(&mut self, l: &Lugar, v: Valor) -> Result<(), String> {
+ match l {
+ Lugar::Var(n) => self.poner_var(n, v),
+ Lugar::Campo(i) => {
+ let idx = self.eval(i)?.numero() as usize;
+ let s = self.cadena_de(&v);
+ self.poner_campo(idx, s);
+ }
+ Lugar::Indice(n, idx) => {
+ let clave = self.clave(idx)?;
+ let a = self.array(n);
+ a.borrow_mut().insert(clave, v);
+ }
+ }
+ Ok(())
+ }
+
+ // ── Funciones del programa ───────────────────────────────────────────────────────────────────
+
+ fn llamar(&mut self, nombre: &str, args: &[Expr]) -> Result<Valor, String> {
+ let f = self.prog.funciones.iter().find(|f| f.nombre == nombre).cloned()
+ .ok_or_else(|| format!("función no definida: `{nombre}`"))?;
+ let mut marco: HashMap<String, Celda> = HashMap::new();
+ for (i, p) in f.params.iter().enumerate() {
+ match args.get(i) {
+ None => { marco.insert(p.clone(), Celda::Escalar(Valor::Vacio)); }
+ Some(a) => {
+ // ⚠ Un array se pasa POR REFERENCIA y un escalar por valor. Cuál es cada uno se
+ // decide mirando qué hay en la variable: si ya es un array, se comparte el Rc.
+ if let Expr::Var(vn) = a {
+ let es_array = matches!(self.celda(vn), Some(Celda::Array(_)));
+ if es_array {
+ let arr = self.array(vn);
+ marco.insert(p.clone(), Celda::Array(arr));
+ continue;
+ }
+ // Una variable nunca usada que se pasa a un parámetro que la función usa
+ // como array tiene que compartir el array. Se resuelve creándolo ya.
+ if self.celda(vn).is_none() && self.usa_como_array(&f, p) {
+ let arr = self.array(vn);
+ marco.insert(p.clone(), Celda::Array(arr));
+ continue;
+ }
+ }
+ let v = self.eval(a)?;
+ marco.insert(p.clone(), Celda::Escalar(v));
+ }
+ }
+ }
+ self.marcos.push(marco);
+ let r = self.bloque(&f.cuerpo);
+ self.marcos.pop();
+ match r? {
+ Flujo::Retorna(v) => Ok(v),
+ Flujo::Sale(c) => Err(format!("@salir:{c}")),
+ _ => Ok(Valor::Vacio),
+ }
+ }
+
+ /// ¿La función usa este parámetro como array? Se mira el cuerpo: `p[...]`, `in p`, `delete p`.
+ fn usa_como_array(&self, f: &Funcion, p: &str) -> bool {
+ fn en_sent(s: &Sent, p: &str) -> bool {
+ match s {
+ Sent::Expr(e) => en_expr(e, p),
+ Sent::Print(a, _) | Sent::Printf(a, _) => a.iter().any(|e| en_expr(e, p)),
+ Sent::Bloque(b) => b.iter().any(|s| en_sent(s, p)),
+ Sent::Si(c, a, b) => en_expr(c, p) || en_sent(a, p) || b.as_ref().map_or(false, |b| en_sent(b, p)),
+ Sent::Mientras(c, b) => en_expr(c, p) || en_sent(b, p),
+ Sent::HazMientras(b, c) => en_expr(c, p) || en_sent(b, p),
+ Sent::Para(i, c, s2, b) => i.as_ref().map_or(false, |x| en_sent(x, p))
+ || c.as_ref().map_or(false, |x| en_expr(x, p))
+ || s2.as_ref().map_or(false, |x| en_sent(x, p)) || en_sent(b, p),
+ Sent::ParaEn(_, a, b) => a == p || en_sent(b, p),
+ Sent::Borra(n, _) | Sent::BorraTodo(n) => n == p,
+ Sent::Sale(e) | Sent::Retorna(e) => e.as_ref().map_or(false, |x| en_expr(x, p)),
+ _ => false,
+ }
+ }
+ fn en_lugar(l: &Lugar, p: &str) -> bool {
+ match l { Lugar::Indice(n, _) => n == p, Lugar::Campo(e) => en_expr(e, p), _ => false }
+ }
+ fn en_expr(e: &Expr, p: &str) -> bool {
+ match e {
+ Expr::Indice(n, _) => n == p,
+ Expr::En(_, n) => n == p,
+ Expr::Llamada(_, args) | Expr::Builtin(_, args) => args.iter().any(|a| en_expr(a, p))
+ || args.iter().any(|a| matches!(a, Expr::Var(v) if v == p)),
+ Expr::Bin(_, a, b) | Expr::Cmp(_, a, b) | Expr::Y(a, b) | Expr::O(a, b)
+ | Expr::Concat(a, b) => en_expr(a, p) || en_expr(b, p),
+ Expr::Coincide(a, b, _) => en_expr(a, p) || en_expr(b, p),
+ Expr::No(a) | Expr::Neg(a) | Expr::Pos(a) | Expr::Grupo(a) | Expr::Campo(a) => en_expr(a, p),
+ Expr::Ternario(a, b, c) => en_expr(a, p) || en_expr(b, p) || en_expr(c, p),
+ // ⚠ `a["k"] = 1` marca a `a` como array, y el nombre está en el LUGAR, no en el
+ // valor. Mirar sólo el valor dejaba `funcion-array-ref` sin compartir el array.
+ Expr::Asigna(l, v) => en_lugar(l, p) || en_expr(v, p),
+ Expr::AsignaOp(l, _, v) => en_lugar(l, p) || en_expr(v, p),
+ Expr::Incr(l, _, _) => en_lugar(l, p),
+ _ => false,
+ }
+ }
+ f.cuerpo.iter().any(|s| en_sent(s, p))
+ }
+
+ // ── getline ──────────────────────────────────────────────────────────────────────────────────
+
+ fn getline(&mut self, g: &Getline) -> Result<Valor, String> {
+ let rs = self.leer_var("RS").cadena("%.6g");
+ match g {
+ Getline::Simple(l) => {
+ match self.siguiente_registro() {
+ None => Ok(Valor::Num(0.0)),
+ Some(r) => {
+ match l {
+ None => { self.poner_registro(r); }
+ Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+ }
+ Ok(Valor::Num(1.0))
+ }
+ }
+ }
+ Getline::DeFichero(l, f) => {
+ let fv = self.eval(f)?;
+ let nombre = self.cadena_de(&fv);
+ if !self.entradas.contains_key(&nombre) {
+ let lector: Box<dyn BufRead> = if nombre == "-" || nombre == "/dev/stdin" {
+ Box::new(BufReader::new(std::io::stdin()))
+ } else {
+ match std::fs::File::open(&nombre) {
+ Ok(f) => Box::new(BufReader::new(f)),
+ // getline devuelve -1 cuando no puede abrir: es un valor, no un error.
+ Err(_) => return Ok(Valor::Num(-1.0)),
+ }
+ };
+ self.entradas.insert(nombre.clone(), Entrada { lector, hijo: None });
+ }
+ let ent = self.entradas.get_mut(&nombre).unwrap();
+ match Self::leer_registro(ent.lector.as_mut(), &rs) {
+ None => Ok(Valor::Num(0.0)),
+ Some(r) => {
+ match l {
+ // `getline < f` sin variable pone $0 y NF, pero NO toca NR.
+ None => { self.poner_registro(r); }
+ Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+ }
+ Ok(Valor::Num(1.0))
+ }
+ }
+ }
+ Getline::DeTuberia(cmd, l) => {
+ let cv = self.eval(cmd)?;
+ let nombre = self.cadena_de(&cv);
+ if !self.entradas.contains_key(&nombre) {
+ let _ = self.stdout.flush(); // que lo ya impreso salga antes que lo del hijo
+ let mut hijo = std::process::Command::new("/bin/sh")
+ .arg("-c").arg(&nombre)
+ .stdout(std::process::Stdio::piped())
+ .spawn().map_err(|e| format!("no pude lanzar `{nombre}`: {e}"))?;
+ let salida = hijo.stdout.take().unwrap();
+ self.entradas.insert(nombre.clone(), Entrada {
+ lector: Box::new(BufReader::new(salida)), hijo: Some(hijo),
+ });
+ }
+ let ent = self.entradas.get_mut(&nombre).unwrap();
+ match Self::leer_registro(ent.lector.as_mut(), &rs) {
+ None => Ok(Valor::Num(0.0)),
+ Some(r) => {
+ // `"cmd" | getline` SÍ incrementa NR (POSIX lo distingue de `getline < f`).
+ let nr = self.leer_var("NR").numero() + 1.0;
+ self.poner_var("NR", Valor::Num(nr));
+ match l {
+ None => { self.poner_registro(r); }
+ Some(lug) => { let lug = (**lug).clone(); self.asignar(&lug, Valor::de_entrada(&r))?; }
+ }
+ Ok(Valor::Num(1.0))
+ }
+ }
+ }
+ }
+ }
+
+ // ── Funciones incorporadas ───────────────────────────────────────────────────────────────────
+
+ fn builtin(&mut self, n: &str, args: &[Expr]) -> Result<Valor, String> {
+ match n {
+ "length" => {
+ if args.is_empty() {
+ let v = self.leer_campo(0);
+ return Ok(Valor::Num(self.cadena_de(&v).chars().count() as f64));
+ }
+ // `length(a)` con un array da su número de elementos.
+ if let Expr::Var(vn) = &args[0] {
+ if let Some(Celda::Array(a)) = self.celda(vn) {
+ return Ok(Valor::Num(a.borrow().len() as f64));
+ }
+ }
+ let v = self.eval(&args[0])?;
+ Ok(Valor::Num(self.cadena_de(&v).chars().count() as f64))
+ }
+ "substr" => {
+ let v = self.eval(&args[0])?;
+ let s: Vec<char> = self.cadena_de(&v).chars().collect();
+ let m = self.eval(&args[1])?.numero();
+ // ⚠ Los índices empiezan en 1, y los dos rincones de esta función se resolvieron
+ // MIDIENDO los dos oráculos, no leyendo el estándar — que acá es ambiguo:
+ // · un inicio ≤ 0 se recorta a 1 y **el largo NO se ajusta**:
+ // `substr("abcdef", -1, 3)` es `abc` en busybox y en gawk --posix, no `a`.
+ // La lectura estricta de POSIX daría `a` (las posiciones -1,0,1); ningún awk
+ // real hace eso, y un guión escrito contra ellos es lo que hay que ejecutar.
+ // · los no enteros se TRUNCAN hacia cero: `substr("abcdef", 1.5, 2.5)` es `ab`.
+ let inicio = m.trunc() as i64;
+ let (desde, largo) = if args.len() > 2 {
+ let l = self.eval(&args[2])?.numero().trunc() as i64;
+ (inicio.max(1), l)
+ } else {
+ let d = inicio.max(1);
+ (d, s.len() as i64 - d + 1)
+ };
+ if largo <= 0 || desde > s.len() as i64 { return Ok(Valor::Cad(String::new())); }
+ let a = (desde - 1) as usize;
+ let b = ((desde - 1 + largo) as usize).min(s.len());
+ Ok(Valor::Cad(s[a..b].iter().collect()))
+ }
+ "index" => {
+ let a = self.eval(&args[0])?; let sa = self.cadena_de(&a);
+ let b = self.eval(&args[1])?; let sb = self.cadena_de(&b);
+ let ha: Vec<char> = sa.chars().collect();
+ let ag: Vec<char> = sb.chars().collect();
+ if ag.is_empty() { return Ok(Valor::Num(if ha.is_empty() { 0.0 } else { 1.0 })); }
+ for i in 0..ha.len().saturating_sub(ag.len() - 1) {
+ if ha[i..i + ag.len()] == ag[..] { return Ok(Valor::Num((i + 1) as f64)); }
+ }
+ Ok(Valor::Num(0.0))
+ }
+ "split" => {
+ let v = self.eval(&args[0])?;
+ let s = self.cadena_de(&v);
+ let nombre = match &args[1] {
+ Expr::Var(n) => n.clone(),
+ Expr::Indice(n, _) => n.clone(),
+ _ => return Err("split necesita un array como segundo argumento".into()),
+ };
+ let fs = if args.len() > 2 {
+ match &args[2] {
+ Expr::Ere(r) => r.fuente().to_string(),
+ e => { let v = self.eval(e)?; self.cadena_de(&v) }
+ }
+ } else { self.leer_var("FS").cadena("%.6g") };
+ let trozos = if s.is_empty() { Vec::new() } else {
+ let mut cache: Vec<(String, Rc<Regex>)> = Vec::new();
+ let t = Self::partir_con(&s, &fs, |p| {
+ if let Some((_, r)) = cache.iter().find(|(k, _)| k == p) { return r.clone(); }
+ let r = Rc::new(Regex::nueva(p).unwrap_or_else(|e| {
+ eprintln!("qillqa: expresión regular inválida /{p}/: {e}");
+ std::process::exit(2);
+ }));
+ cache.push((p.to_string(), r.clone()));
+ r
+ });
+ t
+ };
+ let a = self.array(&nombre);
+ a.borrow_mut().clear();
+ for (i, t) in trozos.iter().enumerate() {
+ a.borrow_mut().insert((i + 1).to_string(), Valor::de_entrada(t));
+ }
+ Ok(Valor::Num(trozos.len() as f64))
+ }
+ "sub" | "gsub" => {
+ let global = n == "gsub";
+ let re = self.ere_de(&args[0])?;
+ let rv = self.eval(&args[1])?;
+ let reemplazo = self.cadena_de(&rv);
+ let destino = if args.len() > 2 {
+ match &args[2] {
+ Expr::Var(v) => Lugar::Var(v.clone()),
+ Expr::Campo(i) => Lugar::Campo(i.clone()),
+ Expr::Indice(v, i) => Lugar::Indice(v.clone(), i.clone()),
+ Expr::Grupo(g) => match &**g {
+ Expr::Var(v) => Lugar::Var(v.clone()),
+ Expr::Campo(i) => Lugar::Campo(i.clone()),
+ Expr::Indice(v, i) => Lugar::Indice(v.clone(), i.clone()),
+ _ => return Err("sub/gsub necesita algo asignable".into()),
+ },
+ _ => return Err("sub/gsub necesita algo asignable".into()),
+ }
+ } else { Lugar::Campo(Box::new(Expr::Num(0.0))) };
+ let actual = self.leer_lugar(&destino)?;
+ let texto = self.cadena_de(&actual);
+ let (nuevo, cuantas) = sustituir(&re, &texto, &reemplazo, global);
+ if cuantas > 0 { self.asignar(&destino, Valor::Cad(nuevo))?; }
+ Ok(Valor::Num(cuantas as f64))
+ }
+ "match" => {
+ let v = self.eval(&args[0])?;
+ let s = self.cadena_de(&v);
+ let re = self.ere_de(&args[1])?;
+ let cs: Vec<char> = s.chars().collect();
+ match re.buscar(&cs, 0) {
+ Some((a, b)) => {
+ self.poner_var("RSTART", Valor::Num((a + 1) as f64));
+ self.poner_var("RLENGTH", Valor::Num((b - a) as f64));
+ Ok(Valor::Num((a + 1) as f64))
+ }
+ None => {
+ self.poner_var("RSTART", Valor::Num(0.0));
+ self.poner_var("RLENGTH", Valor::Num(-1.0));
+ Ok(Valor::Num(0.0))
+ }
+ }
+ }
+ "sprintf" => {
+ if args.is_empty() { return Ok(Valor::Cad(String::new())); }
+ let fv = self.eval(&args[0])?;
+ let fmt = self.cadena_de(&fv);
+ let mut vals = Vec::new();
+ for a in &args[1..] {
+ let v = self.eval(a)?;
+ vals.push(match v {
+ Valor::Num(x) => Arg::Numero(x),
+ Valor::Cad(s) => Arg::Texto(s),
+ Valor::StrNum(s, x) => Arg::Entrada(s, x),
+ Valor::Vacio => Arg::Texto(String::new()),
+ });
+ }
+ Ok(Valor::Cad(formato::printf(&fmt, &vals, &self.convfmt())))
+ }
+ "sin" => Ok(Valor::Num(self.arg_num(args, 0)?.sin())),
+ "cos" => Ok(Valor::Num(self.arg_num(args, 0)?.cos())),
+ "atan2" => { let a = self.arg_num(args, 0)?; let b = self.arg_num(args, 1)?; Ok(Valor::Num(a.atan2(b))) }
+ "exp" => Ok(Valor::Num(self.arg_num(args, 0)?.exp())),
+ "log" => Ok(Valor::Num(self.arg_num(args, 0)?.ln())),
+ "sqrt" => Ok(Valor::Num(self.arg_num(args, 0)?.sqrt())),
+ "int" => Ok(Valor::Num(self.arg_num(args, 0)?.trunc())),
+ "rand" => {
+ // xorshift64*: determinista y suficiente. `rand` de awk NO promete calidad
+ // criptográfica; lo que sí promete es repetirse con la misma semilla.
+ self.semilla ^= self.semilla << 13;
+ self.semilla ^= self.semilla >> 7;
+ self.semilla ^= self.semilla << 17;
+ let x = self.semilla.wrapping_mul(0x2545F4914F6CDD1D);
+ Ok(Valor::Num((x >> 11) as f64 / (1u64 << 53) as f64))
+ }
+ "srand" => {
+ let previa = self.semilla_previa;
+ let nueva = if args.is_empty() { ahora_ms() as f64 } else { self.arg_num(args, 0)? };
+ self.semilla_previa = nueva;
+ self.semilla = (nueva as i64 as u64) | 1; // el 0 congelaría el xorshift
+ Ok(Valor::Num(previa))
+ }
+ "tolower" | "toupper" => {
+ let v = self.eval(&args[0])?;
+ let s = self.cadena_de(&v);
+ Ok(Valor::Cad(if n == "tolower" { s.to_lowercase() } else { s.to_uppercase() }))
+ }
+ "system" => {
+ let v = self.eval(&args[0])?;
+ let cmd = self.cadena_de(&v);
+ let _ = self.stdout.flush(); // POSIX: se vacía la salida ANTES de lanzar
+ let r = std::process::Command::new("/bin/sh").arg("-c").arg(&cmd).status();
+ Ok(Valor::Num(r.ok().and_then(|s| s.code()).unwrap_or(-1) as f64))
+ }
+ "close" => {
+ let v = self.eval(&args[0])?;
+ let nombre = self.cadena_de(&v);
+ Ok(Valor::Num(self.cerrar(&nombre) as f64))
+ }
+ "fflush" => {
+ if args.is_empty() { let _ = self.stdout.flush(); return Ok(Valor::Num(0.0)); }
+ let v = self.eval(&args[0])?;
+ let nombre = self.cadena_de(&v);
+ if let Some(s) = self.salidas.get_mut(&nombre) { let _ = s.escritor.flush(); }
+ else { let _ = self.stdout.flush(); }
+ Ok(Valor::Num(0.0))
+ }
+ otro => Err(format!("función incorporada desconocida: `{otro}`")),
+ }
+ }
+
+ fn arg_num(&mut self, args: &[Expr], i: usize) -> Result<f64, String> {
+ match args.get(i) { Some(e) => Ok(self.eval(e)?.numero()), None => Ok(0.0) }
+ }
+}
+
+fn redondear(x: f64) -> i64 {
+ // POSIX dice «truncado al entero más cercano», que es redondeo a la mitad hacia arriba.
+ (x + 0.5).floor() as i64
+}
+
+fn aritmetica(op: BinOp, a: f64, b: f64) -> f64 {
+ match op {
+ BinOp::Suma => a + b,
+ BinOp::Resta => a - b,
+ BinOp::Mult => a * b,
+ BinOp::Div => a / b,
+ // `%` de awk es `fmod`, no el resto entero: `5.5 % 2` es 1.5.
+ BinOp::Modulo => a % b,
+ BinOp::Potencia => a.powf(b),
+ }
+}
+
+/// `sub`/`gsub`. En el reemplazo, `&` es el texto que casó y `\&` es un `&` literal.
+///
+/// ⚠ La regla que decide el caso raro: **una coincidencia VACÍA pegada al final de la anterior no
+/// sustituye**. Sin ella, `gsub(/a*/,"-")` sobre `"xax"` da `-x--x-` con 4 sustituciones —que es lo
+/// que hace el awk de busybox— en vez de `-x-x-` con 3, que es lo que hacen gawk y el awk original
+/// y lo que se deduce de POSIX. El banco diferencial lo destapó como una discrepancia ENTRE los
+/// oráculos, que es justo para lo que sirve tener dos.
+fn sustituir(re: &Regex, texto: &str, reemplazo: &str, global: bool) -> (String, usize) {
+ let cs: Vec<char> = texto.chars().collect();
+ let mut salida = String::new();
+ let mut i = 0;
+ let mut cuantas = 0;
+ let mut ultimo_vacio_en: Option<usize> = None;
+ while i <= cs.len() {
+ match re.buscar(&cs, i) {
+ None => break,
+ Some((a, b)) => {
+ // Una coincidencia VACÍA justo donde terminó la anterior no cuenta: si no,
+ // `gsub(/x*/,"-")` sobre "xax" produciría guiones de más.
+ if a == b && ultimo_vacio_en == Some(a) {
+ if a >= cs.len() { break; }
+ salida.push(cs[a]);
+ i = a + 1;
+ continue;
+ }
+ salida.extend(&cs[i..a]);
+ let casado: String = cs[a..b].iter().collect();
+ let mut rc = reemplazo.chars().peekable();
+ while let Some(c) = rc.next() {
+ match c {
+ '&' => salida.push_str(&casado),
+ '\\' => match rc.peek() {
+ Some('&') => { salida.push('&'); rc.next(); }
+ Some('\\') => { salida.push('\\'); rc.next(); }
+ _ => salida.push('\\'),
+ },
+ otro => salida.push(otro),
+ }
+ }
+ cuantas += 1;
+ if !global {
+ salida.extend(&cs[b..]);
+ return (salida, cuantas);
+ }
+ if a == b {
+ ultimo_vacio_en = Some(a);
+ if a >= cs.len() { i = a; break; }
+ salida.push(cs[a]);
+ i = a + 1;
+ } else {
+ // Tras una coincidencia real, la posición donde termina queda VEDADA para una
+ // vacía: es la regla de arriba, y es la única diferencia con busybox.
+ ultimo_vacio_en = Some(b);
+ i = b;
+ }
+ }
+ }
+ }
+ if i <= cs.len() { salida.extend(&cs[i.min(cs.len())..]); }
+ (salida, cuantas)
+}
+
+/// ¿`s` tiene forma `nombre=valor`? Devuelve la posición del `=`.
+pub fn posicion_asignacion(s: &str) -> Option<usize> {
+ let cs: Vec<char> = s.chars().collect();
+ if cs.is_empty() { return None; }
+ if !(cs[0].is_alphabetic() || cs[0] == '_') { return None; }
+ let mut i = 1;
+ while i < cs.len() && (cs[i].is_alphanumeric() || cs[i] == '_') { i += 1; }
+ if i < cs.len() && cs[i] == '=' {
+ return Some(s.char_indices().nth(i).map(|(p, _)| p).unwrap_or(i));
+ }
+ None
+}
+
+/// Los escapes de una asignación de la línea de órdenes se procesan como en una cadena.
+pub fn desescapar(s: &str) -> String {
+ let mut out = String::new();
+ let mut it = s.chars().peekable();
+ while let Some(c) = it.next() {
+ if c != '\\' { out.push(c); continue; }
+ match it.next() {
+ None => out.push('\\'),
+ Some('n') => out.push('\n'), Some('t') => out.push('\t'), Some('r') => out.push('\r'),
+ Some('\\') => out.push('\\'), Some('"') => out.push('"'), Some('/') => out.push('/'),
+ Some('a') => out.push('\x07'), Some('b') => out.push('\x08'),
+ Some('f') => out.push('\x0c'), Some('v') => out.push('\x0b'),
+ Some(d @ '0'..='7') => {
+ let mut v = d.to_digit(8).unwrap();
+ let mut n = 1;
+ while n < 3 {
+ match it.peek() {
+ Some(x) if x.is_digit(8) => { v = v * 8 + x.to_digit(8).unwrap(); it.next(); n += 1; }
+ _ => break,
+ }
+ }
+ out.push(char::from_u32(v).unwrap_or('\0'));
+ }
+ Some(o) => { out.push('\\'); out.push(o); }
+ }
+ }
+ out
+}
+
+impl Interprete {
+ /// Acceso al array desde `main` (ENVIRON, ARGV), que es el único sitio de fuera que los toca.
+ pub fn array_publico(&mut self, n: &str) -> Rc<RefCell<HashMap<String, Valor>>> { self.array(n) }
+}
diff --git a/crates/qillqa/src/lexer.rs b/crates/qillqa/src/lexer.rs
new file mode 100644
index 00000000..399641bf
--- /dev/null
+++ b/crates/qillqa/src/lexer.rs
@@ -0,0 +1,249 @@
+//! Analizador léxico de awk.
+//!
+//! Dos cosas hacen a este lexer distinto de uno corriente, y las dos vienen de la gramática de awk:
+//!
+//! 1. **`/` es ambiguo**: puede abrir una expresión regular (`/^foo/`) o ser una división (`a / b`).
+//! No se puede decidir mirando sólo el carácter. La regla —la misma que usan awk, sed y los
+//! lenguajes con literales de regex— es MIRAR EL TOKEN ANTERIOR: tras un valor (número, cadena,
+//! identificador, `)`, `]`, `$x`, un incremento) una `/` es división; en cualquier otro sitio
+//! abre una ERE. Por eso el lexer recuerda el último token emitido.
+//!
+//! 2. **El salto de línea es SIGNIFICATIVO**: termina una sentencia. Pero no siempre — tras `{`,
+//! `&&`, `||`, `,`, `do`, `else` y un `)` de `if`/`for`/`while`, la línea continúa. El lexer
+//! emite `Nueva` y es el parser quien decide cuándo ignorarla; mezclarlo acá haría falta un
+//! lexer que entienda de sintaxis.
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Tok {
+ Num(f64),
+ Cad(String),
+ Ere(String),
+ Ident(String),
+ /// Nombre seguido de `(` SIN espacio: sólo así se llama a una función en awk.
+ Funcion(String),
+ PalabraClave(String),
+ /// Operador o signo de puntuación, tal cual.
+ Op(String),
+ Nueva,
+ Fin,
+}
+
+const CLAVES: &[&str] = &[
+ "BEGIN", "END", "function", "func", "if", "else", "while", "for", "do", "break", "continue",
+ "next", "nextfile", "exit", "return", "delete", "in", "getline", "print", "printf",
+];
+
+pub struct Lexer {
+ src: Vec<char>,
+ i: usize,
+ anterior: Option<Tok>,
+}
+
+impl Lexer {
+ pub fn nuevo(src: &str) -> Lexer {
+ Lexer { src: src.chars().collect(), i: 0, anterior: None }
+ }
+
+ fn ver(&self) -> Option<char> { self.src.get(self.i).copied() }
+ fn ver2(&self) -> Option<char> { self.src.get(self.i + 1).copied() }
+
+ /// ¿Una `/` aquí abre una ERE? Depende de lo último que se emitió.
+ fn toca_ere(&self) -> bool {
+ match &self.anterior {
+ None => true,
+ Some(Tok::Num(_)) | Some(Tok::Cad(_)) | Some(Tok::Ere(_)) | Some(Tok::Ident(_)) => false,
+ Some(Tok::Op(o)) => !matches!(o.as_str(), ")" | "]" | "++" | "--" | "$"),
+ Some(Tok::PalabraClave(k)) => !matches!(k.as_str(), "getline"),
+ _ => true,
+ }
+ }
+
+ fn espacios_y_comentarios(&mut self) {
+ loop {
+ match self.ver() {
+ Some(' ') | Some('\t') | Some('\r') => { self.i += 1; }
+ // `\` al final de línea: continuación explícita, no hay token de nueva línea.
+ Some('\\') if self.ver2() == Some('\n') => { self.i += 2; }
+ Some('\\') if self.ver2() == Some('\r') => {
+ self.i += 2;
+ if self.ver() == Some('\n') { self.i += 1; }
+ }
+ Some('#') => { while !matches!(self.ver(), None | Some('\n')) { self.i += 1; } }
+ _ => return,
+ }
+ }
+ }
+
+ pub fn siguiente(&mut self) -> Result<Tok, String> {
+ let t = self.siguiente_crudo()?;
+ self.anterior = Some(t.clone());
+ Ok(t)
+ }
+
+ fn siguiente_crudo(&mut self) -> Result<Tok, String> {
+ self.espacios_y_comentarios();
+ let c = match self.ver() { None => return Ok(Tok::Fin), Some(c) => c };
+ if c == '\n' { self.i += 1; return Ok(Tok::Nueva); }
+
+ if c.is_ascii_digit() || (c == '.' && matches!(self.ver2(), Some(d) if d.is_ascii_digit())) {
+ return self.numero();
+ }
+ if c == '"' { return self.cadena(); }
+ if c == '/' && self.toca_ere() { return self.ere(); }
+ if c.is_alphabetic() || c == '_' { return Ok(self.palabra()); }
+
+ // Operadores, los largos primero.
+ let tres = self.tramo(3);
+ if tres == "**=" { self.i += 3; return Ok(Tok::Op("^=".into())); } // `**` es el `^` histórico
+ let dos = self.tramo(2);
+ for op in ["==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=", "^=", "!~", ">>"] {
+ if dos == *op { self.i += 2; return Ok(Tok::Op(op.to_string())); }
+ }
+ if dos == "**" { self.i += 2; return Ok(Tok::Op("^".into())); }
+ self.i += 1;
+ let uno = c.to_string();
+ if "{}()[];,+-*/%^<>=!?:~$|&".contains(c) { return Ok(Tok::Op(uno)); }
+ Err(format!("carácter inesperado: `{c}`"))
+ }
+
+ fn tramo(&self, n: usize) -> String {
+ self.src[self.i..(self.i + n).min(self.src.len())].iter().collect()
+ }
+
+ fn numero(&mut self) -> Result<Tok, String> {
+ let ini = self.i;
+ while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+ if self.ver() == Some('.') {
+ self.i += 1;
+ while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+ }
+ if matches!(self.ver(), Some('e') | Some('E')) {
+ let guardado = self.i;
+ self.i += 1;
+ if matches!(self.ver(), Some('+') | Some('-')) { self.i += 1; }
+ if matches!(self.ver(), Some(c) if c.is_ascii_digit()) {
+ while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { self.i += 1; }
+ } else { self.i = guardado; }
+ }
+ let t: String = self.src[ini..self.i].iter().collect();
+ t.parse::<f64>().map(Tok::Num).map_err(|_| format!("número mal formado: `{t}`"))
+ }
+
+ fn cadena(&mut self) -> Result<Tok, String> {
+ self.i += 1; // la comilla de apertura
+ let mut s = String::new();
+ loop {
+ match self.ver() {
+ None | Some('\n') => return Err("cadena sin cerrar".into()),
+ Some('"') => { self.i += 1; return Ok(Tok::Cad(s)); }
+ Some('\\') => {
+ self.i += 1;
+ let e = self.ver().ok_or("`\\` al final de la cadena")?;
+ self.i += 1;
+ match e {
+ 'n' => s.push('\n'), 't' => s.push('\t'), 'r' => s.push('\r'),
+ '\\' => s.push('\\'), '"' => s.push('"'), '/' => s.push('/'),
+ 'a' => s.push('\x07'), 'b' => s.push('\x08'), 'f' => s.push('\x0c'),
+ 'v' => s.push('\x0b'),
+ // Octal: `\101` es `A`. Hasta tres dígitos.
+ '0'..='7' => {
+ let mut v = e.to_digit(8).unwrap();
+ let mut n = 1;
+ while n < 3 {
+ match self.ver() {
+ Some(d) if d.is_digit(8) => { v = v * 8 + d.to_digit(8).unwrap(); self.i += 1; n += 1; }
+ _ => break,
+ }
+ }
+ s.push(char::from_u32(v).unwrap_or('\0'));
+ }
+ // POSIX: una barra ante cualquier otro carácter queda INDEFINIDA. awk y gawk
+ // conservan los dos caracteres, que es lo menos sorprendente.
+ otro => { s.push('\\'); s.push(otro); }
+ }
+ }
+ Some(c) => { s.push(c); self.i += 1; }
+ }
+ }
+ }
+
+ fn ere(&mut self) -> Result<Tok, String> {
+ self.i += 1; // la `/` de apertura
+ let mut s = String::new();
+ let mut en_clase = false;
+ loop {
+ match self.ver() {
+ None | Some('\n') => return Err("expresión regular sin cerrar".into()),
+ // Dentro de `[...]` una `/` no cierra: `/[/]/` es la ERE de una barra.
+ Some('[') if !en_clase => { en_clase = true; s.push('['); self.i += 1; }
+ Some(']') if en_clase => { en_clase = false; s.push(']'); self.i += 1; }
+ Some('/') if !en_clase => { self.i += 1; return Ok(Tok::Ere(s)); }
+ Some('\\') => {
+ self.i += 1;
+ let e = self.ver().ok_or("`\\` al final de la expresión regular")?;
+ self.i += 1;
+ // `\/` es una barra literal; el resto de escapes se los queda el motor ERE.
+ if e == '/' { s.push('/'); } else { s.push('\\'); s.push(e); }
+ }
+ Some(c) => { s.push(c); self.i += 1; }
+ }
+ }
+ }
+
+ fn palabra(&mut self) -> Tok {
+ let ini = self.i;
+ while matches!(self.ver(), Some(c) if c.is_alphanumeric() || c == '_') { self.i += 1; }
+ let p: String = self.src[ini..self.i].iter().collect();
+ if CLAVES.contains(&p.as_str()) {
+ return Tok::PalabraClave(if p == "func" { "function".into() } else { p });
+ }
+ // Llamada a función: el `(` va PEGADO. `f (x)` con espacio es concatenación, no llamada —
+ // regla de POSIX que distingue a awk de casi todo lo demás.
+ if self.ver() == Some('(') { Tok::Funcion(p) } else { Tok::Ident(p) }
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ fn toks(s: &str) -> Vec<Tok> {
+ let mut l = Lexer::nuevo(s);
+ let mut v = Vec::new();
+ loop {
+ let t = l.siguiente().unwrap();
+ if t == Tok::Fin { break; }
+ v.push(t);
+ }
+ v
+ }
+
+ #[test]
+ fn la_barra_es_division_tras_un_valor_y_ere_en_otro_sitio() {
+ assert_eq!(toks("a / b"), vec![Tok::Ident("a".into()), Tok::Op("/".into()), Tok::Ident("b".into())]);
+ assert_eq!(toks("$0 ~ /x/"), vec![
+ Tok::Op("$".into()), Tok::Num(0.0), Tok::Op("~".into()), Tok::Ere("x".into())]);
+ }
+
+ #[test]
+ fn una_barra_dentro_de_una_clase_no_cierra_la_ere() {
+ assert_eq!(toks("/[/]/"), vec![Tok::Ere("[/]".into())]);
+ }
+
+ #[test]
+ fn llamada_a_funcion_solo_con_el_parentesis_pegado() {
+ assert_eq!(toks("f(1)")[0], Tok::Funcion("f".into()));
+ assert_eq!(toks("f (1)")[0], Tok::Ident("f".into()));
+ }
+
+ #[test]
+ fn la_continuacion_de_linea_no_emite_nueva_linea() {
+ assert_eq!(toks("a \\\n b"), vec![Tok::Ident("a".into()), Tok::Ident("b".into())]);
+ assert_eq!(toks("a \n b"), vec![Tok::Ident("a".into()), Tok::Nueva, Tok::Ident("b".into())]);
+ }
+
+ #[test]
+ fn escapes_de_cadena() {
+ assert_eq!(toks(r#""a\tb\101""#), vec![Tok::Cad("a\tbA".into())]);
+ }
+}
diff --git a/crates/qillqa/src/main.rs b/crates/qillqa/src/main.rs
new file mode 100644
index 00000000..f797f556
--- /dev/null
+++ b/crates/qillqa/src/main.rs
@@ -0,0 +1,164 @@
+//! qillqa — el awk del producto.
+//!
+//! *qillqa* es «escritura» en quechua, y awk es la herramienta de escribir sobre texto. El nombre
+//! sigue el criterio del proyecto (los nombres propios van en quechua) y el precedente de `simi`:
+//! **el nombre lo registra este comentario y lo DECIDE un ADR**, igual que el del shell. Lo que no
+//! está sujeto a decisión es el nombre del comando instalado: la receta enlaza `awk`, porque eso es
+//! contrato y lo llaman los `configure` de medio corpus.
+//!
+//! Uso: qillqa [-F sepc] [-v var=valor]… 'programa' [fichero | var=valor]…
+//! qillqa [-F sepc] [-v var=valor]… -f fuente.awk [-f más.awk]… [fichero | var=valor]…
+
+mod ast;
+mod formato;
+mod interp;
+mod lexer;
+mod parser;
+mod regex;
+mod valor;
+
+use interp::{Flujo, Interprete};
+use std::io::Write;
+use valor::Valor;
+
+fn uso() -> ! {
+ eprintln!("uso: qillqa [-F sepc] [-v var=valor]... 'programa' [fichero...]");
+ eprintln!(" qillqa [-F sepc] [-v var=valor]... -f fuente.awk [fichero...]");
+ std::process::exit(2);
+}
+
+fn main() {
+ let argv: Vec<String> = std::env::args().collect();
+ let mut fuentes: Vec<String> = Vec::new();
+ let mut asignaciones: Vec<String> = Vec::new();
+ let mut fs: Option<String> = None;
+ let mut texto_programa: Option<String> = None;
+ let mut i = 1;
+
+ while i < argv.len() {
+ let a = &argv[i];
+ if a == "--" { i += 1; break; }
+ if a == "-" || !a.starts_with('-') { break; }
+ // Las opciones se pueden pegar al valor (`-F:`) o separar (`-F :`), como en todo POSIX.
+ let (op, pegado) = {
+ let cs: Vec<char> = a.chars().collect();
+ (cs[1], cs[2..].iter().collect::<String>())
+ };
+ let mut toma = |pegado: &str, i: &mut usize| -> String {
+ if !pegado.is_empty() { return pegado.to_string(); }
+ *i += 1;
+ argv.get(*i).cloned().unwrap_or_else(|| uso())
+ };
+ match op {
+ 'F' => {
+ let v = toma(&pegado, &mut i);
+ // `-F '\t'` llega con la barra literal: hay que desescaparlo como una cadena.
+ // Y `-F t` es el tabulador por compatibilidad histórica con el awk original.
+ fs = Some(if v == "t" { "\t".to_string() } else { interp::desescapar(&v) });
+ }
+ 'v' => asignaciones.push(toma(&pegado, &mut i)),
+ 'f' => {
+ let v = toma(&pegado, &mut i);
+ match std::fs::read_to_string(&v) {
+ Ok(s) => fuentes.push(s),
+ Err(e) => { eprintln!("qillqa: no puedo leer `{v}`: {e}"); std::process::exit(2); }
+ }
+ }
+ _ => uso(),
+ }
+ i += 1;
+ }
+
+ if fuentes.is_empty() {
+ match argv.get(i) {
+ None => uso(),
+ Some(p) => { texto_programa = Some(p.clone()); i += 1; }
+ }
+ }
+ let fuente = match texto_programa { Some(t) => t, None => fuentes.join("\n") };
+
+ let prog = match parser::Parser::nuevo(&fuente).and_then(|mut p| p.programa()) {
+ Ok(p) => p,
+ Err(e) => { eprintln!("qillqa: {e}"); std::process::exit(2); }
+ };
+
+ let mut it = Interprete::nuevo(prog);
+
+ // ENVIRON, antes que nada: un BEGIN puede leerlo.
+ {
+ let env = it.array_publico("ENVIRON");
+ for (k, v) in std::env::vars() {
+ env.borrow_mut().insert(k, Valor::de_entrada(&v));
+ }
+ }
+ if let Some(f) = fs { it.poner_var("FS", Valor::Cad(f)); }
+ for a in &asignaciones {
+ match interp::posicion_asignacion(a) {
+ Some(p) => it.poner_var(&a[..p], Valor::de_entrada(&interp::desescapar(&a[p + 1..]))),
+ None => { eprintln!("qillqa: `-v {a}` no tiene forma var=valor"); std::process::exit(2); }
+ }
+ }
+
+ // ARGV/ARGC: ARGV[0] es el nombre del programa y los operandos van detrás.
+ {
+ let operandos: Vec<String> = argv[i..].to_vec();
+ let arr = it.array_publico("ARGV");
+ arr.borrow_mut().insert("0".into(), Valor::Cad("qillqa".into()));
+ for (n, o) in operandos.iter().enumerate() {
+ arr.borrow_mut().insert((n + 1).to_string(), Valor::de_entrada(o));
+ }
+ it.poner_var("ARGC", Valor::Num((operandos.len() + 1) as f64));
+ }
+
+ let mut codigo = 0;
+ let mut salir_ya = false;
+
+ match it.ejecutar_begin() {
+ Err(e) => { fin_con_error(&mut it, &e); }
+ Ok(Flujo::Sale(c)) => { codigo = c; salir_ya = true; }
+ Ok(_) => {}
+ }
+
+ // El bucle principal sólo corre si hay reglas que no sean BEGIN. Si el programa es *sólo* un
+ // BEGIN, awk NO lee la entrada — y eso importa: `awk 'BEGIN{print 1}'` no se cuelga esperando.
+ if !salir_ya && (it.hay_main() || it.hay_end()) {
+ 'registros: loop {
+ let r = match it.siguiente_registro() { None => break, Some(r) => r };
+ it.poner_registro(r);
+ match it.ejecutar_reglas() {
+ Err(e) => { fin_con_error(&mut it, &e); }
+ Ok(Flujo::Sale(c)) => { codigo = c; salir_ya = true; break 'registros; }
+ Ok(Flujo::SiguienteFichero) => { it.cerrar_fuente_actual(); }
+ Ok(_) => {}
+ }
+ }
+ }
+
+ if !salir_ya || it.hay_end() {
+ // POSIX: un `exit` en BEGIN o en el cuerpo SÍ ejecuta los END, salvo que el propio END
+ // llame a `exit`. Por eso se entra acá incluso con `salir_ya`.
+ match it.ejecutar_end() {
+ Err(e) => { fin_con_error(&mut it, &e); }
+ Ok(Flujo::Sale(c)) => { codigo = c; }
+ Ok(_) => {}
+ }
+ }
+
+ it.cerrar_todo();
+ if it.codigo_salida != 0 && codigo == 0 { codigo = it.codigo_salida; }
+ std::process::exit(codigo);
+}
+
+fn fin_con_error(it: &mut Interprete, e: &str) -> ! {
+ // `@salir:N` es cómo viaja un `exit` desde dentro de una función: no es un error de verdad.
+ if let Some(n) = e.strip_prefix("@salir:") {
+ let c = n.parse().unwrap_or(0);
+ let _ = it.ejecutar_end();
+ it.cerrar_todo();
+ std::process::exit(c);
+ }
+ it.cerrar_todo();
+ let _ = std::io::stderr().flush();
+ eprintln!("qillqa: {e}");
+ std::process::exit(2);
+}
diff --git a/crates/qillqa/src/parser.rs b/crates/qillqa/src/parser.rs
new file mode 100644
index 00000000..3295e52d
--- /dev/null
+++ b/crates/qillqa/src/parser.rs
@@ -0,0 +1,589 @@
+//! Analizador sintáctico de awk: de tokens a `Programa`.
+//!
+//! La gramática de awk tiene tres rincones que obligan a escribir el parser de cierta forma, y los
+//! tres están marcados abajo donde aparecen:
+//!
+//! 1. **La concatenación no tiene operador.** `a b` es concatenar. Eso choca con el unario `-`:
+//! en `a - b` hay una resta, no la concatenación de `a` con `-b`. Se resuelve dando a la
+//! concatenación una precedencia propia, entre la comparación y la suma, y no dejándola empezar
+//! por un token que pueda continuar la expresión de la izquierda.
+//! 2. **`>` es ambiguo dentro de `print`.** `print a > "f"` REDIRIGE; no compara. Por eso el parser
+//! lleva una bandera `sin_mayor` mientras lee la lista de un `print`.
+//! 3. **`in` y `|` cambian de significado según el contexto**: `(i,j) in a`, `"cmd" | getline`.
+
+use crate::ast::*;
+use crate::lexer::{Lexer, Tok};
+use crate::regex::Regex;
+use std::rc::Rc;
+
+const BUILTINS: &[&str] = &[
+ "length", "substr", "index", "split", "sub", "gsub", "match", "sprintf", "sin", "cos", "atan2",
+ "exp", "log", "sqrt", "int", "rand", "srand", "tolower", "toupper", "system", "close", "fflush",
+];
+
+pub struct Parser {
+ toks: Vec<Tok>,
+ i: usize,
+ sin_mayor: bool,
+ rangos: usize,
+ funciones_vistas: Vec<String>,
+}
+
+impl Parser {
+ pub fn nuevo(src: &str) -> Result<Parser, String> {
+ let mut lx = Lexer::nuevo(src);
+ let mut toks = Vec::new();
+ loop {
+ let t = lx.siguiente()?;
+ let fin = t == Tok::Fin;
+ toks.push(t);
+ if fin { break; }
+ }
+ Ok(Parser { toks, i: 0, sin_mayor: false, rangos: 0, funciones_vistas: Vec::new() })
+ }
+
+ fn ver(&self) -> &Tok { &self.toks[self.i.min(self.toks.len() - 1)] }
+ fn avanza(&mut self) -> Tok { let t = self.ver().clone(); self.i += 1; t }
+ fn es_op(&self, o: &str) -> bool { matches!(self.ver(), Tok::Op(x) if x == o) }
+ fn es_clave(&self, k: &str) -> bool { matches!(self.ver(), Tok::PalabraClave(x) if x == k) }
+
+ fn come_op(&mut self, o: &str) -> bool { if self.es_op(o) { self.i += 1; true } else { false } }
+ fn come_clave(&mut self, k: &str) -> bool { if self.es_clave(k) { self.i += 1; true } else { false } }
+
+ fn exige_op(&mut self, o: &str) -> Result<(), String> {
+ if self.come_op(o) { Ok(()) } else { Err(format!("esperaba `{o}` y encontré {:?}", self.ver())) }
+ }
+
+ /// Se come los saltos de línea y los `;` que separan sentencias.
+ fn saltos(&mut self) {
+ while matches!(self.ver(), Tok::Nueva) || self.es_op(";") { self.i += 1; }
+ }
+ fn solo_saltos(&mut self) {
+ while matches!(self.ver(), Tok::Nueva) { self.i += 1; }
+ }
+
+ pub fn programa(&mut self) -> Result<Programa, String> {
+ let mut p = Programa::default();
+ self.saltos();
+ while !matches!(self.ver(), Tok::Fin) {
+ if self.es_clave("function") {
+ self.i += 1;
+ let nombre = match self.avanza() {
+ Tok::Ident(n) | Tok::Funcion(n) => n,
+ t => return Err(format!("nombre de función esperado, encontré {t:?}")),
+ };
+ self.exige_op("(")?;
+ let mut params = Vec::new();
+ if !self.es_op(")") {
+ loop {
+ match self.avanza() {
+ Tok::Ident(n) => params.push(n),
+ t => return Err(format!("parámetro esperado, encontré {t:?}")),
+ }
+ self.solo_saltos();
+ if !self.come_op(",") { break; }
+ self.solo_saltos();
+ }
+ }
+ self.exige_op(")")?;
+ self.solo_saltos();
+ self.funciones_vistas.push(nombre.clone());
+ let cuerpo = self.bloque()?;
+ p.funciones.push(Funcion { nombre, params, cuerpo });
+ } else {
+ let regla = self.regla()?;
+ p.reglas.push(regla);
+ }
+ self.saltos();
+ }
+ p.rangos = self.rangos;
+ Ok(p)
+ }
+
+ fn regla(&mut self) -> Result<Regla, String> {
+ let mut rango_id = usize::MAX;
+ let patron = if self.come_clave("BEGIN") { Patron::Begin }
+ else if self.come_clave("END") { Patron::End }
+ else if self.es_op("{") { Patron::Siempre }
+ else {
+ let e1 = self.expr()?;
+ if self.come_op(",") {
+ self.solo_saltos();
+ let e2 = self.expr()?;
+ rango_id = self.rangos;
+ self.rangos += 1;
+ Patron::Rango(e1, e2)
+ } else { Patron::Expr(e1) }
+ };
+ let accion = if self.es_op("{") { Some(self.bloque()?) } else { None };
+ Ok(Regla { patron, accion, rango_id })
+ }
+
+ fn bloque(&mut self) -> Result<Vec<Sent>, String> {
+ self.exige_op("{")?;
+ let mut v = Vec::new();
+ self.saltos();
+ while !self.es_op("}") {
+ if matches!(self.ver(), Tok::Fin) { return Err("falta `}`".into()); }
+ v.push(self.sentencia()?);
+ self.saltos();
+ }
+ self.exige_op("}")?;
+ Ok(v)
+ }
+
+ /// Una sentencia, que puede ser un bloque o una simple.
+ fn sentencia(&mut self) -> Result<Sent, String> {
+ if self.es_op("{") { return Ok(Sent::Bloque(self.bloque()?)); }
+ if self.come_op(";") { return Ok(Sent::Nada); }
+
+ if self.come_clave("if") {
+ self.exige_op("(")?;
+ let cond = self.expr()?;
+ self.exige_op(")")?;
+ self.solo_saltos();
+ let entonces = Box::new(self.sentencia()?);
+ // El `else` puede venir tras saltos de línea y tras `;`.
+ let guardado = self.i;
+ self.saltos();
+ let si_no = if self.come_clave("else") {
+ self.solo_saltos();
+ Some(Box::new(self.sentencia()?))
+ } else { self.i = guardado; None };
+ return Ok(Sent::Si(cond, entonces, si_no));
+ }
+ if self.come_clave("while") {
+ self.exige_op("(")?;
+ let cond = self.expr()?;
+ self.exige_op(")")?;
+ self.solo_saltos();
+ // `while (c) ;` es un bucle con cuerpo vacío.
+ if self.come_op(";") { return Ok(Sent::Mientras(cond, Box::new(Sent::Nada))); }
+ return Ok(Sent::Mientras(cond, Box::new(self.sentencia()?)));
+ }
+ if self.come_clave("do") {
+ self.solo_saltos();
+ let cuerpo = Box::new(self.sentencia()?);
+ self.saltos();
+ if !self.come_clave("while") { return Err("`do` sin `while`".into()); }
+ self.exige_op("(")?;
+ let cond = self.expr()?;
+ self.exige_op(")")?;
+ return Ok(Sent::HazMientras(cuerpo, cond));
+ }
+ if self.come_clave("for") { return self.para(); }
+ if self.come_clave("break") { return Ok(Sent::Corta); }
+ if self.come_clave("continue") { return Ok(Sent::Sigue); }
+ if self.come_clave("next") { return Ok(Sent::Siguiente); }
+ if self.come_clave("nextfile") { return Ok(Sent::SiguienteFichero); }
+ if self.come_clave("exit") {
+ let e = if self.fin_de_sentencia() { None } else { Some(self.expr()?) };
+ return Ok(Sent::Sale(e));
+ }
+ if self.come_clave("return") {
+ let e = if self.fin_de_sentencia() { None } else { Some(self.expr()?) };
+ return Ok(Sent::Retorna(e));
+ }
+ if self.come_clave("delete") {
+ let nombre = match self.avanza() {
+ Tok::Ident(n) | Tok::Funcion(n) => n,
+ t => return Err(format!("`delete` necesita un array, encontré {t:?}")),
+ };
+ if self.come_op("[") {
+ let mut idx = vec![self.expr()?];
+ while self.come_op(",") { idx.push(self.expr()?); }
+ self.exige_op("]")?;
+ return Ok(Sent::Borra(nombre, idx));
+ }
+ // `delete a` y `delete a()` (la forma con paréntesis que acepta gawk)
+ if self.come_op("(") { self.exige_op(")")?; }
+ return Ok(Sent::BorraTodo(nombre));
+ }
+ if self.es_clave("print") || self.es_clave("printf") {
+ let es_printf = self.es_clave("printf");
+ self.i += 1;
+ return self.print(es_printf);
+ }
+ Ok(Sent::Expr(self.expr()?))
+ }
+
+ fn fin_de_sentencia(&self) -> bool {
+ matches!(self.ver(), Tok::Nueva | Tok::Fin) || self.es_op(";") || self.es_op("}")
+ }
+
+ fn para(&mut self) -> Result<Sent, String> {
+ self.exige_op("(")?;
+ // `for (k in a)` — se distingue de `for (expr; …)` mirando dos tokens adelante.
+ if let Tok::Ident(k) = self.ver().clone() {
+ if matches!(&self.toks[self.i + 1], Tok::PalabraClave(p) if p == "in") {
+ self.i += 2;
+ let arr = match self.avanza() {
+ Tok::Ident(a) | Tok::Funcion(a) => a,
+ t => return Err(format!("`for (k in a)` necesita un array, encontré {t:?}")),
+ };
+ self.exige_op(")")?;
+ self.solo_saltos();
+ return Ok(Sent::ParaEn(k, arr, Box::new(self.sentencia()?)));
+ }
+ }
+ let init = if self.es_op(";") { None } else { Some(Box::new(self.sentencia()?)) };
+ self.exige_op(";")?;
+ self.solo_saltos();
+ let cond = if self.es_op(";") { None } else { Some(self.expr()?) };
+ self.exige_op(";")?;
+ self.solo_saltos();
+ let paso = if self.es_op(")") { None } else { Some(Box::new(self.sentencia()?)) };
+ self.exige_op(")")?;
+ self.solo_saltos();
+ if self.come_op(";") { return Ok(Sent::Para(init, cond, paso, Box::new(Sent::Nada))); }
+ Ok(Sent::Para(init, cond, paso, Box::new(self.sentencia()?)))
+ }
+
+ /// ⚠ RINCÓN 2: dentro de la lista de un `print`, un `>` REDIRIGE, no compara. `sin_mayor` lo
+ /// dice, y se apaga en cuanto entramos en un paréntesis —`print (a > b)` sí compara.
+ fn print(&mut self, es_printf: bool) -> Result<Sent, String> {
+ let mut args = Vec::new();
+ if !self.fin_de_sentencia() && !self.es_op(">") && !self.es_op(">>") && !self.es_op("|") {
+ let previo = self.sin_mayor;
+ self.sin_mayor = true;
+ args.push(self.expr()?);
+ while self.come_op(",") {
+ self.solo_saltos();
+ args.push(self.expr()?);
+ }
+ self.sin_mayor = previo;
+ }
+ // `print (a, b) > "f"`: una lista entre paréntesis es la lista de argumentos, no una
+ // expresión agrupada. Se detecta cuando el único argumento es un grupo con comas dentro —
+ // eso lo resuelve `expr_lista_agrupada` al construirlo.
+ let redir = if self.come_op(">") { Some(Redir::Fichero(Box::new(self.expr()?))) }
+ else if self.come_op(">>") { Some(Redir::Anexa(Box::new(self.expr()?))) }
+ else if self.come_op("|") { Some(Redir::Tuberia(Box::new(self.expr()?))) }
+ else { None };
+ Ok(if es_printf { Sent::Printf(args, redir) } else { Sent::Print(args, redir) })
+ }
+
+ // ── Expresiones, por precedencia de menor a mayor ────────────────────────────────────────────
+
+ pub fn expr(&mut self) -> Result<Expr, String> { self.ternario() }
+
+ fn ternario(&mut self) -> Result<Expr, String> {
+ let cond = self.o_logico()?;
+ if self.come_op("?") {
+ self.solo_saltos();
+ let a = self.ternario()?;
+ self.solo_saltos();
+ self.exige_op(":")?;
+ self.solo_saltos();
+ let b = self.ternario()?;
+ return Ok(Expr::Ternario(Box::new(cond), Box::new(a), Box::new(b)));
+ }
+ // La asignación se reconoce DESPUÉS: en awk `a = b ? c : d` asigna el ternario entero, y
+ // el lado izquierdo de una asignación siempre es un lugar simple.
+ if let Some(op) = self.op_asignacion() {
+ if let Some(lugar) = self.a_lugar(&cond) {
+ self.i += 1;
+ self.solo_saltos();
+ let val = self.ternario()?;
+ return Ok(match op.as_str() {
+ "=" => Expr::Asigna(Box::new(lugar), Box::new(val)),
+ "+=" => Expr::AsignaOp(Box::new(lugar), BinOp::Suma, Box::new(val)),
+ "-=" => Expr::AsignaOp(Box::new(lugar), BinOp::Resta, Box::new(val)),
+ "*=" => Expr::AsignaOp(Box::new(lugar), BinOp::Mult, Box::new(val)),
+ "/=" => Expr::AsignaOp(Box::new(lugar), BinOp::Div, Box::new(val)),
+ "%=" => Expr::AsignaOp(Box::new(lugar), BinOp::Modulo, Box::new(val)),
+ "^=" => Expr::AsignaOp(Box::new(lugar), BinOp::Potencia, Box::new(val)),
+ _ => unreachable!(),
+ });
+ }
+ }
+ Ok(cond)
+ }
+
+ fn op_asignacion(&self) -> Option<String> {
+ match self.ver() {
+ Tok::Op(o) if matches!(o.as_str(), "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "^=") => Some(o.clone()),
+ _ => None,
+ }
+ }
+
+ fn a_lugar(&self, e: &Expr) -> Option<Lugar> {
+ match e {
+ Expr::Var(n) => Some(Lugar::Var(n.clone())),
+ Expr::Campo(i) => Some(Lugar::Campo(i.clone())),
+ Expr::Indice(n, idx) => Some(Lugar::Indice(n.clone(), idx.clone())),
+ _ => None,
+ }
+ }
+
+ fn o_logico(&mut self) -> Result<Expr, String> {
+ let mut izq = self.y_logico()?;
+ while self.come_op("||") {
+ self.solo_saltos();
+ let der = self.y_logico()?;
+ izq = Expr::O(Box::new(izq), Box::new(der));
+ }
+ Ok(izq)
+ }
+
+ fn y_logico(&mut self) -> Result<Expr, String> {
+ let mut izq = self.en_array()?;
+ while self.come_op("&&") {
+ self.solo_saltos();
+ let der = self.en_array()?;
+ izq = Expr::Y(Box::new(izq), Box::new(der));
+ }
+ Ok(izq)
+ }
+
+ fn en_array(&mut self) -> Result<Expr, String> {
+ let mut izq = self.coincidencia()?;
+ while self.es_clave("in") {
+ self.i += 1;
+ let arr = match self.avanza() {
+ Tok::Ident(a) | Tok::Funcion(a) => a,
+ t => return Err(format!("`in` necesita un array, encontré {t:?}")),
+ };
+ // `(i,j) in a` llega como un grupo con lista; `i in a`, como una expresión suelta.
+ izq = match izq {
+ Expr::Grupo(inner) => Expr::En(vec![*inner], arr),
+ otro => Expr::En(vec![otro], arr),
+ };
+ }
+ Ok(izq)
+ }
+
+ fn coincidencia(&mut self) -> Result<Expr, String> {
+ let mut izq = self.comparacion()?;
+ loop {
+ if self.come_op("~") {
+ let der = self.comparacion()?;
+ izq = Expr::Coincide(Box::new(izq), Box::new(der), false);
+ } else if self.come_op("!~") {
+ let der = self.comparacion()?;
+ izq = Expr::Coincide(Box::new(izq), Box::new(der), true);
+ } else { break; }
+ }
+ Ok(izq)
+ }
+
+ /// POSIX: los relacionales NO son asociativos (`a < b < c` es un error en la gramática), pero
+ /// todos los awk reales lo aceptan como `(a<b)<c`. Se acepta igual, que romper eso sólo rompe
+ /// guiones que ya funcionaban en otro lado.
+ fn comparacion(&mut self) -> Result<Expr, String> {
+ let izq = self.concatenacion()?;
+ let op = match self.ver() {
+ Tok::Op(o) => match o.as_str() {
+ "<" => CmpOp::Lt, "<=" => CmpOp::Le, "==" => CmpOp::Eq, "!=" => CmpOp::Ne,
+ ">=" if !self.sin_mayor => CmpOp::Ge,
+ ">" if !self.sin_mayor => CmpOp::Gt,
+ _ => return Ok(izq),
+ },
+ _ => return Ok(izq),
+ };
+ self.i += 1;
+ let der = self.concatenacion()?;
+ Ok(Expr::Cmp(op, Box::new(izq), Box::new(der)))
+ }
+
+ /// ⚠ RINCÓN 1: la concatenación no tiene operador. Continúa mientras lo que viene PUEDE empezar
+ /// una expresión unaria… salvo `+` y `-`, que se leen como binarios (`a - b` es una resta).
+ fn concatenacion(&mut self) -> Result<Expr, String> {
+ let mut izq = self.aditiva()?;
+ while self.empieza_valor() {
+ let der = self.aditiva()?;
+ izq = Expr::Concat(Box::new(izq), Box::new(der));
+ }
+ Ok(izq)
+ }
+
+ fn empieza_valor(&self) -> bool {
+ match self.ver() {
+ Tok::Num(_) | Tok::Cad(_) | Tok::Ere(_) | Tok::Ident(_) | Tok::Funcion(_) => true,
+ Tok::Op(o) => matches!(o.as_str(), "$" | "(" | "!" | "++" | "--"),
+ Tok::PalabraClave(k) => k == "getline",
+ _ => false,
+ }
+ }
+
+ fn aditiva(&mut self) -> Result<Expr, String> {
+ let mut izq = self.multiplicativa()?;
+ loop {
+ if self.come_op("+") { let d = self.multiplicativa()?; izq = Expr::Bin(BinOp::Suma, Box::new(izq), Box::new(d)); }
+ else if self.come_op("-") { let d = self.multiplicativa()?; izq = Expr::Bin(BinOp::Resta, Box::new(izq), Box::new(d)); }
+ else { break; }
+ }
+ Ok(izq)
+ }
+
+ fn multiplicativa(&mut self) -> Result<Expr, String> {
+ let mut izq = self.unaria()?;
+ loop {
+ if self.come_op("*") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Mult, Box::new(izq), Box::new(d)); }
+ else if self.come_op("/") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Div, Box::new(izq), Box::new(d)); }
+ else if self.come_op("%") { let d = self.unaria()?; izq = Expr::Bin(BinOp::Modulo, Box::new(izq), Box::new(d)); }
+ else { break; }
+ }
+ Ok(izq)
+ }
+
+ fn unaria(&mut self) -> Result<Expr, String> {
+ if self.come_op("!") { return Ok(Expr::No(Box::new(self.unaria()?))); }
+ if self.come_op("-") { return Ok(Expr::Neg(Box::new(self.unaria()?))); }
+ if self.come_op("+") { return Ok(Expr::Pos(Box::new(self.unaria()?))); }
+ self.potencia()
+ }
+
+ /// `^` asocia a la DERECHA (`2^3^2` es 512), y su operando derecho admite unario:
+ /// `2^-1` es válido.
+ fn potencia(&mut self) -> Result<Expr, String> {
+ let base = self.postfija()?;
+ if self.come_op("^") {
+ let exp = self.unaria()?;
+ return Ok(Expr::Bin(BinOp::Potencia, Box::new(base), Box::new(exp)));
+ }
+ Ok(base)
+ }
+
+ fn postfija(&mut self) -> Result<Expr, String> {
+ let e = self.primaria()?;
+ if self.es_op("++") || self.es_op("--") {
+ if let Some(l) = self.a_lugar(&e) {
+ let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+ self.i += 1;
+ return Ok(Expr::Incr(Box::new(l), false, delta));
+ }
+ }
+ Ok(e)
+ }
+
+ fn primaria(&mut self) -> Result<Expr, String> {
+ // Pre-incremento.
+ if self.es_op("++") || self.es_op("--") {
+ let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+ self.i += 1;
+ let obj = self.primaria()?;
+ let l = self.a_lugar(&obj).ok_or("`++` necesita una variable, un campo o un elemento")?;
+ return Ok(Expr::Incr(Box::new(l), true, delta));
+ }
+ if self.come_op("$") {
+ let idx = self.primaria()?;
+ let campo = Expr::Campo(Box::new(idx));
+ // `$1++` incrementa el campo.
+ if self.es_op("++") || self.es_op("--") {
+ let delta = if self.es_op("++") { 1.0 } else { -1.0 };
+ self.i += 1;
+ if let Some(l) = self.a_lugar(&campo) {
+ return Ok(Expr::Incr(Box::new(l), false, delta));
+ }
+ }
+ return Ok(campo);
+ }
+ if self.come_op("(") {
+ let previo = self.sin_mayor;
+ self.sin_mayor = false; // dentro de paréntesis, `>` vuelve a comparar
+ self.solo_saltos();
+ let e = self.expr()?;
+ // `(a, b) in arr` y `print (a, b)`: una lista.
+ if self.es_op(",") {
+ let mut lista = vec![e];
+ while self.come_op(",") { self.solo_saltos(); lista.push(self.expr()?); }
+ self.exige_op(")")?;
+ self.sin_mayor = previo;
+ if self.es_clave("in") {
+ self.i += 1;
+ let arr = match self.avanza() {
+ Tok::Ident(a) | Tok::Funcion(a) => a,
+ t => return Err(format!("`in` necesita un array, encontré {t:?}")),
+ };
+ return Ok(Expr::En(lista, arr));
+ }
+ // Lista suelta: sólo tiene sentido como argumentos de print, y allí se desarma.
+ return Ok(Expr::Llamada("@lista".into(), lista));
+ }
+ self.exige_op(")")?;
+ self.sin_mayor = previo;
+ let g = Expr::Grupo(Box::new(e));
+ // `("cmd" | getline x)`
+ return self.quizas_tuberia(g);
+ }
+ match self.avanza() {
+ Tok::Num(n) => Ok(Expr::Num(n)),
+ Tok::Cad(s) => self.quizas_tuberia(Expr::Cad(s)),
+ Tok::Ere(r) => Ok(Expr::Ere(Rc::new(Regex::nueva(&r)?))),
+ // ⚠ `length` sin paréntesis es el ÚNICO builtin al que POSIX se lo permite, y tiene
+ // que ir ANTES del brazo general de identificador — si no, se lee como una variable
+ // llamada `length`, que siempre vale vacío. El banco lo cazó con `{print length}`.
+ Tok::Ident(n) if n == "length" && !self.es_op("[") => Ok(Expr::Builtin("length".into(), vec![])),
+ Tok::Ident(n) => {
+ if self.come_op("[") {
+ let mut idx = vec![self.expr()?];
+ while self.come_op(",") { idx.push(self.expr()?); }
+ self.exige_op("]")?;
+ return Ok(Expr::Indice(n, idx));
+ }
+ Ok(Expr::Var(n))
+ }
+ Tok::Funcion(n) => {
+ self.exige_op("(")?;
+ let previo = self.sin_mayor;
+ self.sin_mayor = false;
+ let mut args = Vec::new();
+ self.solo_saltos();
+ if !self.es_op(")") {
+ loop {
+ args.push(self.expr()?);
+ self.solo_saltos();
+ if !self.come_op(",") { break; }
+ self.solo_saltos();
+ }
+ }
+ self.exige_op(")")?;
+ self.sin_mayor = previo;
+ if BUILTINS.contains(&n.as_str()) { Ok(Expr::Builtin(n, args)) }
+ else { Ok(Expr::Llamada(n, args)) }
+ }
+ Tok::PalabraClave(k) if k == "getline" => {
+ // `getline`, `getline var`, `getline < f`, `getline var < f`
+ let lugar = self.lugar_opcional()?;
+ if self.come_op("<") {
+ let f = self.concatenacion()?;
+ return Ok(Expr::Getline(Getline::DeFichero(lugar, Box::new(f))));
+ }
+ Ok(Expr::Getline(Getline::Simple(lugar)))
+ }
+ t => Err(format!("expresión esperada, encontré {t:?}")),
+ }
+ }
+
+ /// `expr | getline [lugar]`
+ fn quizas_tuberia(&mut self, izq: Expr) -> Result<Expr, String> {
+ if self.es_op("|") && matches!(&self.toks[self.i + 1], Tok::PalabraClave(k) if k == "getline") {
+ self.i += 2;
+ let lugar = self.lugar_opcional()?;
+ return Ok(Expr::Getline(Getline::DeTuberia(Box::new(izq), lugar)));
+ }
+ Ok(izq)
+ }
+
+ fn lugar_opcional(&mut self) -> Result<Option<Box<Lugar>>, String> {
+ match self.ver().clone() {
+ Tok::Ident(n) => {
+ self.i += 1;
+ if self.come_op("[") {
+ let mut idx = vec![self.expr()?];
+ while self.come_op(",") { idx.push(self.expr()?); }
+ self.exige_op("]")?;
+ return Ok(Some(Box::new(Lugar::Indice(n, idx))));
+ }
+ Ok(Some(Box::new(Lugar::Var(n))))
+ }
+ Tok::Op(o) if o == "$" => {
+ self.i += 1;
+ let idx = self.primaria()?;
+ Ok(Some(Box::new(Lugar::Campo(Box::new(idx)))))
+ }
+ _ => Ok(None),
+ }
+ }
+}
diff --git a/crates/qillqa/src/regex.rs b/crates/qillqa/src/regex.rs
new file mode 100644
index 00000000..107a043e
--- /dev/null
+++ b/crates/qillqa/src/regex.rs
@@ -0,0 +1,427 @@
+//! Motor de expresiones regulares ERE (POSIX.1-2024 §9.4), propio.
+//!
+//! # Por qué propio y no un crate
+//!
+//! La semántica de POSIX es **leftmost-longest**: entre todas las coincidencias que empiezan en la
+//! posición más a la izquierda, gana la MÁS LARGA. La familia Perl —y con ella el crate `regex` en
+//! su modo por defecto— usa leftmost-first: para `a|ab` sobre `"ab"`, POSIX devuelve `ab` y Perl
+//! devuelve `a`. En un awk eso no es un detalle académico: decide qué borra un `sub()`, dónde parte
+//! un `split()` y qué valen `RSTART`/`RLENGTH`. Adoptar un motor ajeno sería adoptar su semántica.
+//!
+//! # Cómo funciona
+//!
+//! Construcción de Thompson: el patrón se compila a un programa de instrucciones y se simula con un
+//! CONJUNTO de hilos que avanzan a la vez, un carácter por paso. No hay retroceso, así que no hay
+//! explosión exponencial —`(a*)*b` contra sesenta `a` termina igual de rápido que cualquier otra
+//! cosa— y quedarse con el final más lejano alcanzado da el «más largo» de POSIX sin esfuerzo extra.
+//!
+//! Trabaja sobre `char`, no sobre bytes: en awk los índices de `substr`, `index`, `match` y
+//! `RSTART` se cuentan en caracteres, y con UTF-8 en el fichero de entrada contar bytes daría
+//! posiciones que no se pueden usar para cortar.
+
+/// Una instrucción del programa compilado.
+#[derive(Debug, Clone)]
+enum Inst {
+ /// Consume exactamente este carácter.
+ Char(char),
+ /// Consume cualquier carácter (`.`). En awk `.` SÍ casa con `\n` (no hay modo multilínea).
+ Any,
+ /// Consume un carácter si pertenece (o no, si `negada`) al conjunto.
+ Clase { partes: Vec<Parte>, negada: bool },
+ /// Bifurca: sigue por las dos ramas. El orden no importa porque no hay preferencia (longest).
+ Split(usize, usize),
+ /// Salta.
+ Jmp(usize),
+ /// Ancla de principio de cadena (`^`).
+ Bol,
+ /// Ancla de final de cadena (`$`).
+ Eol,
+ /// Aceptación.
+ Match,
+}
+
+/// Un trozo de una clase `[...]`.
+#[derive(Debug, Clone)]
+enum Parte {
+ Uno(char),
+ Rango(char, char),
+ /// `[:alpha:]` y compañía, resueltas por función para no materializar Unicode entero.
+ Nombrada(Nombrada),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum Nombrada {
+ Alpha, Digit, Alnum, Upper, Lower, Space, Blank, Punct, Print, Graph, Cntrl, Xdigit,
+}
+
+impl Nombrada {
+ fn de(nombre: &str) -> Option<Nombrada> {
+ Some(match nombre {
+ "alpha" => Nombrada::Alpha, "digit" => Nombrada::Digit, "alnum" => Nombrada::Alnum,
+ "upper" => Nombrada::Upper, "lower" => Nombrada::Lower, "space" => Nombrada::Space,
+ "blank" => Nombrada::Blank, "punct" => Nombrada::Punct, "print" => Nombrada::Print,
+ "graph" => Nombrada::Graph, "cntrl" => Nombrada::Cntrl, "xdigit" => Nombrada::Xdigit,
+ _ => return None,
+ })
+ }
+ fn casa(self, c: char) -> bool {
+ match self {
+ Nombrada::Alpha => c.is_alphabetic(),
+ Nombrada::Digit => c.is_ascii_digit(),
+ Nombrada::Alnum => c.is_alphanumeric(),
+ Nombrada::Upper => c.is_uppercase(),
+ Nombrada::Lower => c.is_lowercase(),
+ Nombrada::Space => c.is_whitespace(),
+ Nombrada::Blank => c == ' ' || c == '\t',
+ Nombrada::Punct => c.is_ascii_punctuation(),
+ Nombrada::Print => !c.is_control(),
+ Nombrada::Graph => !c.is_control() && !c.is_whitespace(),
+ Nombrada::Cntrl => c.is_control(),
+ Nombrada::Xdigit => c.is_ascii_hexdigit(),
+ }
+ }
+}
+
+/// Árbol del patrón, paso intermedio entre el texto y el programa.
+#[derive(Debug, Clone)]
+enum Nodo {
+ Vacio,
+ Char(char),
+ Any,
+ Clase { partes: Vec<Parte>, negada: bool },
+ Bol,
+ Eol,
+ Cat(Box<Nodo>, Box<Nodo>),
+ Alt(Box<Nodo>, Box<Nodo>),
+ Estrella(Box<Nodo>),
+ Mas(Box<Nodo>),
+ Opcional(Box<Nodo>),
+ /// `{n,m}`: se expande al compilar. `hasta = None` es `{n,}`.
+ Repite(Box<Nodo>, u32, Option<u32>),
+}
+
+pub struct Regex {
+ prog: Vec<Inst>,
+ /// El patrón original, para mensajes de error y para `Regex::fuente`.
+ fuente: String,
+}
+
+struct Analizador<'a> {
+ p: &'a [char],
+ i: usize,
+}
+
+impl<'a> Analizador<'a> {
+ fn fin(&self) -> bool { self.i >= self.p.len() }
+ fn ver(&self) -> Option<char> { self.p.get(self.i).copied() }
+ fn tomar(&mut self) -> Option<char> { let c = self.ver(); if c.is_some() { self.i += 1; } c }
+
+ /// alternancia := concatenación ('|' concatenación)*
+ fn alternancia(&mut self) -> Result<Nodo, String> {
+ let mut izq = self.concatenacion()?;
+ while self.ver() == Some('|') {
+ self.i += 1;
+ let der = self.concatenacion()?;
+ izq = Nodo::Alt(Box::new(izq), Box::new(der));
+ }
+ Ok(izq)
+ }
+
+ /// concatenación := repetición*
+ fn concatenacion(&mut self) -> Result<Nodo, String> {
+ let mut acc: Option<Nodo> = None;
+ while !self.fin() && self.ver() != Some('|') && self.ver() != Some(')') {
+ let n = self.repeticion()?;
+ acc = Some(match acc {
+ None => n,
+ Some(a) => Nodo::Cat(Box::new(a), Box::new(n)),
+ });
+ }
+ Ok(acc.unwrap_or(Nodo::Vacio))
+ }
+
+ /// repetición := átomo ('*' | '+' | '?' | '{n,m}')*
+ fn repeticion(&mut self) -> Result<Nodo, String> {
+ let mut n = self.atomo()?;
+ loop {
+ match self.ver() {
+ Some('*') => { self.i += 1; n = Nodo::Estrella(Box::new(n)); }
+ Some('+') => { self.i += 1; n = Nodo::Mas(Box::new(n)); }
+ Some('?') => { self.i += 1; n = Nodo::Opcional(Box::new(n)); }
+ Some('{') => {
+ // Sólo es intervalo si lo que sigue es un dígito: en ERE un `{` suelto es
+ // literal, y hay guiones que escriben `/x{/` sin escapar.
+ if !matches!(self.p.get(self.i + 1), Some(c) if c.is_ascii_digit()) { break; }
+ let guardado = self.i;
+ self.i += 1;
+ match self.intervalo() {
+ Ok((desde, hasta)) => n = Nodo::Repite(Box::new(n), desde, hasta),
+ Err(_) => { self.i = guardado; break; }
+ }
+ }
+ _ => break,
+ }
+ }
+ Ok(n)
+ }
+
+ fn intervalo(&mut self) -> Result<(u32, Option<u32>), String> {
+ let mut desde = String::new();
+ while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { desde.push(self.tomar().unwrap()); }
+ if desde.is_empty() { return Err("intervalo sin mínimo".into()); }
+ let d: u32 = desde.parse().map_err(|_| "intervalo demasiado grande".to_string())?;
+ match self.ver() {
+ Some('}') => { self.i += 1; Ok((d, Some(d))) }
+ Some(',') => {
+ self.i += 1;
+ let mut hasta = String::new();
+ while matches!(self.ver(), Some(c) if c.is_ascii_digit()) { hasta.push(self.tomar().unwrap()); }
+ if self.ver() != Some('}') { return Err("intervalo sin cerrar".into()); }
+ self.i += 1;
+ if hasta.is_empty() { Ok((d, None)) }
+ else {
+ let h: u32 = hasta.parse().map_err(|_| "intervalo demasiado grande".to_string())?;
+ if h < d { return Err("intervalo con máximo menor que el mínimo".into()); }
+ Ok((d, Some(h)))
+ }
+ }
+ _ => Err("intervalo mal formado".into()),
+ }
+ }
+
+ fn atomo(&mut self) -> Result<Nodo, String> {
+ match self.tomar() {
+ None => Ok(Nodo::Vacio),
+ Some('(') => {
+ let dentro = self.alternancia()?;
+ if self.tomar() != Some(')') { return Err("falta `)`".into()); }
+ Ok(dentro)
+ }
+ Some('[') => self.clase(),
+ Some('.') => Ok(Nodo::Any),
+ Some('^') => Ok(Nodo::Bol),
+ Some('$') => Ok(Nodo::Eol),
+ Some('\\') => match self.tomar() {
+ None => Err("`\\` al final del patrón".into()),
+ // Los escapes de C que awk reconoce dentro de una ERE.
+ Some('n') => Ok(Nodo::Char('\n')),
+ Some('t') => Ok(Nodo::Char('\t')),
+ Some('r') => Ok(Nodo::Char('\r')),
+ Some('f') => Ok(Nodo::Char('\x0c')),
+ Some('b') => Ok(Nodo::Char('\x08')),
+ Some('v') => Ok(Nodo::Char('\x0b')),
+ Some('a') => Ok(Nodo::Char('\x07')),
+ Some('/') => Ok(Nodo::Char('/')),
+ Some(c) => Ok(Nodo::Char(c)),
+ },
+ Some(c) => Ok(Nodo::Char(c)),
+ }
+ }
+
+ /// `[abc]`, `[^a-z]`, `[[:digit:]]`. Un `]` justo tras el `[` (o tras `[^`) es LITERAL.
+ fn clase(&mut self) -> Result<Nodo, String> {
+ let mut negada = false;
+ if self.ver() == Some('^') { negada = true; self.i += 1; }
+ let mut partes: Vec<Parte> = Vec::new();
+ let mut primero = true;
+ loop {
+ let c = match self.tomar() {
+ None => return Err("clase `[` sin cerrar".into()),
+ Some(']') if !primero => break,
+ Some(c) => c,
+ };
+ primero = false;
+ // `[:alpha:]` dentro de la clase.
+ if c == '[' && self.ver() == Some(':') {
+ let guardado = self.i;
+ self.i += 1;
+ let mut nombre = String::new();
+ while matches!(self.ver(), Some(c) if c != ':' && c != ']') { nombre.push(self.tomar().unwrap()); }
+ if self.ver() == Some(':') && self.p.get(self.i + 1) == Some(&']') {
+ self.i += 2;
+ match Nombrada::de(&nombre) {
+ Some(n) => { partes.push(Parte::Nombrada(n)); continue; }
+ None => return Err(format!("clase de caracteres desconocida: [:{nombre}:]")),
+ }
+ }
+ self.i = guardado; // no era una clase nombrada: el `[` es literal
+ }
+ let c = if c == '\\' {
+ match self.tomar() {
+ None => return Err("`\\` al final de una clase".into()),
+ Some('n') => '\n', Some('t') => '\t', Some('r') => '\r',
+ Some('f') => '\x0c', Some('b') => '\x08', Some('v') => '\x0b', Some('a') => '\x07',
+ Some(o) => o,
+ }
+ } else { c };
+ // Rango `a-z`, salvo que el `-` sea el último carácter de la clase.
+ if self.ver() == Some('-') && self.p.get(self.i + 1).map_or(false, |&d| d != ']') {
+ self.i += 1;
+ let hasta = match self.tomar() {
+ None => return Err("rango sin final".into()),
+ Some('\\') => self.tomar().ok_or("`\\` al final de un rango")?,
+ Some(d) => d,
+ };
+ partes.push(Parte::Rango(c, hasta));
+ } else {
+ partes.push(Parte::Uno(c));
+ }
+ }
+ Ok(Nodo::Clase { partes, negada })
+ }
+}
+
+/// Emite el programa. Devuelve el índice de la primera instrucción del fragmento.
+fn emitir(prog: &mut Vec<Inst>, n: &Nodo) {
+ match n {
+ Nodo::Vacio => {}
+ Nodo::Char(c) => prog.push(Inst::Char(*c)),
+ Nodo::Any => prog.push(Inst::Any),
+ Nodo::Bol => prog.push(Inst::Bol),
+ Nodo::Eol => prog.push(Inst::Eol),
+ Nodo::Clase { partes, negada } => prog.push(Inst::Clase { partes: partes.clone(), negada: *negada }),
+ Nodo::Cat(a, b) => { emitir(prog, a); emitir(prog, b); }
+ Nodo::Alt(a, b) => {
+ let split = prog.len();
+ prog.push(Inst::Jmp(0)); // reservado, se reescribe como Split
+ emitir(prog, a);
+ let jmp = prog.len();
+ prog.push(Inst::Jmp(0));
+ let inicio_b = prog.len();
+ emitir(prog, b);
+ let fin = prog.len();
+ prog[split] = Inst::Split(split + 1, inicio_b);
+ prog[jmp] = Inst::Jmp(fin);
+ }
+ Nodo::Estrella(a) => {
+ let split = prog.len();
+ prog.push(Inst::Jmp(0));
+ emitir(prog, a);
+ prog.push(Inst::Jmp(split));
+ let fin = prog.len();
+ prog[split] = Inst::Split(split + 1, fin);
+ }
+ Nodo::Mas(a) => {
+ let inicio = prog.len();
+ emitir(prog, a);
+ let split = prog.len();
+ prog.push(Inst::Jmp(0));
+ let fin = prog.len();
+ prog[split] = Inst::Split(inicio, fin);
+ }
+ Nodo::Opcional(a) => {
+ let split = prog.len();
+ prog.push(Inst::Jmp(0));
+ emitir(prog, a);
+ let fin = prog.len();
+ prog[split] = Inst::Split(split + 1, fin);
+ }
+ Nodo::Repite(a, desde, hasta) => {
+ // Se expande: `x{2,4}` es `xx(x(x)?)?`. Es lo que hace cualquier motor sin contadores, y
+ // el coste es lineal en `hasta`, que en la práctica es un número pequeño.
+ for _ in 0..*desde { emitir(prog, a); }
+ match hasta {
+ None => emitir(prog, &Nodo::Estrella(a.clone())),
+ Some(h) => {
+ let opcionales = h.saturating_sub(*desde);
+ let mut splits = Vec::new();
+ for _ in 0..opcionales {
+ let s = prog.len();
+ prog.push(Inst::Jmp(0));
+ splits.push(s);
+ emitir(prog, a);
+ }
+ let fin = prog.len();
+ for s in splits { prog[s] = Inst::Split(s + 1, fin); }
+ }
+ }
+ }
+ }
+}
+
+impl Regex {
+ pub fn nueva(patron: &str) -> Result<Regex, String> {
+ let chars: Vec<char> = patron.chars().collect();
+ let mut an = Analizador { p: &chars, i: 0 };
+ let nodo = an.alternancia()?;
+ if !an.fin() {
+ return Err(format!("basura al final del patrón: `{}`", chars[an.i..].iter().collect::<String>()));
+ }
+ let mut prog = Vec::new();
+ emitir(&mut prog, &nodo);
+ prog.push(Inst::Match);
+ Ok(Regex { prog, fuente: patron.to_string() })
+ }
+
+ pub fn fuente(&self) -> &str { &self.fuente }
+
+ /// Añade un hilo y todos los alcanzables sin consumir (cierre-epsilon).
+ fn agregar(&self, lista: &mut Vec<usize>, visto: &mut [bool], pc: usize, pos: usize, largo: usize) {
+ if visto[pc] { return; }
+ visto[pc] = true;
+ match self.prog[pc] {
+ Inst::Jmp(d) => self.agregar(lista, visto, d, pos, largo),
+ Inst::Split(a, b) => {
+ self.agregar(lista, visto, a, pos, largo);
+ self.agregar(lista, visto, b, pos, largo);
+ }
+ Inst::Bol => { if pos == 0 { self.agregar(lista, visto, pc + 1, pos, largo); } }
+ Inst::Eol => { if pos == largo { self.agregar(lista, visto, pc + 1, pos, largo); } }
+ _ => lista.push(pc),
+ }
+ }
+
+ /// El final del match MÁS LARGO que empieza exactamente en `desde`, si lo hay.
+ pub fn casa_en(&self, s: &[char], desde: usize) -> Option<usize> {
+ let largo = s.len();
+ let mut visto = vec![false; self.prog.len()];
+ let mut actual: Vec<usize> = Vec::new();
+ self.agregar(&mut actual, &mut visto, 0, desde, largo);
+ let mut mejor: Option<usize> = None;
+ let mut pos = desde;
+ loop {
+ // ¿Alguno acepta aquí? Nos quedamos con el más lejano: eso es «longest».
+ if actual.iter().any(|&pc| matches!(self.prog[pc], Inst::Match)) {
+ mejor = Some(pos);
+ }
+ if pos >= largo || actual.is_empty() { break; }
+ let c = s[pos];
+ let mut siguiente: Vec<usize> = Vec::new();
+ let mut visto2 = vec![false; self.prog.len()];
+ for &pc in &actual {
+ let avanza = match &self.prog[pc] {
+ Inst::Char(x) => *x == c,
+ Inst::Any => true,
+ Inst::Clase { partes, negada } => {
+ let dentro = partes.iter().any(|p| match p {
+ Parte::Uno(u) => *u == c,
+ Parte::Rango(a, b) => *a <= c && c <= *b,
+ Parte::Nombrada(n) => n.casa(c),
+ });
+ dentro != *negada
+ }
+ _ => false,
+ };
+ if avanza { self.agregar(&mut siguiente, &mut visto2, pc + 1, pos + 1, largo); }
+ }
+ actual = siguiente;
+ pos += 1;
+ }
+ mejor
+ }
+
+ /// Primera coincidencia a partir de `desde`: la más a la izquierda y, entre ésas, la más larga.
+ /// Devuelve `(inicio, fin)` en índices de CARÁCTER, con `fin` exclusivo.
+ pub fn buscar(&self, s: &[char], desde: usize) -> Option<(usize, usize)> {
+ for i in desde..=s.len() {
+ if let Some(fin) = self.casa_en(s, i) { return Some((i, fin)); }
+ }
+ None
+ }
+
+ /// ¿Casa en algún sitio? Es lo que piden `~`, los patrones de regla y `split`.
+ pub fn casa(&self, s: &str) -> bool {
+ let v: Vec<char> = s.chars().collect();
+ self.buscar(&v, 0).is_some()
+ }
+}
diff --git a/crates/qillqa/src/valor.rs b/crates/qillqa/src/valor.rs
new file mode 100644
index 00000000..5a86867c
--- /dev/null
+++ b/crates/qillqa/src/valor.rs
@@ -0,0 +1,208 @@
+//! El modelo de valores de awk. Es la parte que más silenciosamente se equivoca.
+//!
+//! awk tiene TRES clases de valor, no dos (POSIX.1-2024 §2.6, «Expressions in awk»):
+//!
+//! · **número** — `1`, `x+0`, el resultado de cualquier aritmética.
+//! · **cadena** — un literal entre comillas, o el resultado de `substr`, `sprintf`…
+//! · **strnum** — lo que VIENE DE FUERA y *parece* un número: campos (`$1`), `getline`, los
+//! trozos de `split`, `ARGV`, `ENVIRON`, `FILENAME`. Se compara como número.
+//!
+//! La tercera es la que decide, y la diferencia es observable:
+//!
+//! ```text
+//! echo '10 9' | awk '{ print ($1 > $2) }' ⇒ 1 (strnum: comparación NUMÉRICA)
+//! awk 'BEGIN { print ("10" > "9") }' ⇒ 0 (cadenas: "1" < "9" alfabéticamente)
+//! ```
+//!
+//! Un awk que trate los campos como cadenas ordena mal cualquier tabla de números; uno que los trate
+//! siempre como números rompe `$1 == "0012"`. Por eso `Valor` lleva la procedencia y no sólo el dato.
+
+use std::fmt::Write as _;
+
+#[derive(Debug, Clone)]
+pub enum Valor {
+ Num(f64),
+ Cad(String),
+ /// Vino de la entrada. Guarda el texto TAL CUAL —`print $1` tiene que imprimir `0012`, no `12`—
+ /// y, si parece un número, también su valor.
+ StrNum(String, Option<f64>),
+ /// Variable nunca asignada: vale `""` y `0` a la vez, y compara igual con los dos.
+ Vacio,
+}
+
+/// ¿Este texto es un número en el sentido de awk? Admite espacios alrededor, signo, punto decimal y
+/// exponente; NO admite basura detrás (`"12x"` es cadena) ni hexadecimal (POSIX lo deja fuera, y
+/// gawk sólo lo acepta en modo no-POSIX).
+pub fn como_numero(s: &str) -> Option<f64> {
+ let t = s.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\n');
+ if t.is_empty() { return None; }
+ let b: Vec<char> = t.chars().collect();
+ let mut i = 0;
+ if b[i] == '+' || b[i] == '-' { i += 1; }
+ let mut digitos = false;
+ while i < b.len() && b[i].is_ascii_digit() { i += 1; digitos = true; }
+ if i < b.len() && b[i] == '.' {
+ i += 1;
+ while i < b.len() && b[i].is_ascii_digit() { i += 1; digitos = true; }
+ }
+ if !digitos { return None; }
+ if i < b.len() && (b[i] == 'e' || b[i] == 'E') {
+ let guardado = i;
+ i += 1;
+ if i < b.len() && (b[i] == '+' || b[i] == '-') { i += 1; }
+ if i < b.len() && b[i].is_ascii_digit() {
+ while i < b.len() && b[i].is_ascii_digit() { i += 1; }
+ } else { i = guardado; }
+ }
+ if i != b.len() { return None; }
+ t.parse::<f64>().ok()
+}
+
+/// El prefijo numérico del texto, que es lo que usa la CONVERSIÓN explícita (`"12x"+0` es 12).
+/// Distinto de `como_numero`, que exige que no sobre nada: ahí `"12x"` no es un número.
+pub fn prefijo_numerico(s: &str) -> f64 {
+ let t = s.trim_start_matches(|c: char| c == ' ' || c == '\t' || c == '\n');
+ let b: Vec<char> = t.chars().collect();
+ let mut i = 0;
+ if i < b.len() && (b[i] == '+' || b[i] == '-') { i += 1; }
+ let mut fin = i;
+ while fin < b.len() && b[fin].is_ascii_digit() { fin += 1; }
+ if fin < b.len() && b[fin] == '.' {
+ fin += 1;
+ while fin < b.len() && b[fin].is_ascii_digit() { fin += 1; }
+ }
+ if fin < b.len() && (b[fin] == 'e' || b[fin] == 'E') {
+ let guardado = fin;
+ let mut j = fin + 1;
+ if j < b.len() && (b[j] == '+' || b[j] == '-') { j += 1; }
+ if j < b.len() && b[j].is_ascii_digit() {
+ while j < b.len() && b[j].is_ascii_digit() { j += 1; }
+ fin = j;
+ } else { fin = guardado; }
+ }
+ let t2: String = b[..fin].iter().collect();
+ t2.parse::<f64>().unwrap_or(0.0)
+}
+
+/// Formatea un número como lo hace awk al convertirlo a cadena.
+///
+/// La regla de POSIX: si el valor es **entero exacto**, se escribe como entero —`print 1/1` da `1`,
+/// no `1.000000`—; si no, con `CONVFMT` (por defecto `%.6g`) o con `OFMT` cuando lo imprime `print`.
+/// Ese «entero exacto» es por VALOR, no por tipo: `2.0` es entero y `1e17` también.
+pub fn numero_a_cadena(n: f64, fmt: &str) -> String {
+ if n.is_nan() { return "nan".into(); }
+ if n.is_infinite() { return if n > 0.0 { "inf".into() } else { "-inf".into() }; }
+ if n == n.trunc() && n.abs() < 1e17 {
+ let mut s = String::new();
+ let _ = write!(s, "{}", n as i64);
+ return s;
+ }
+ crate::formato::una_conversion(fmt, n)
+}
+
+impl Valor {
+ pub fn de_entrada(s: &str) -> Valor {
+ let n = como_numero(s);
+ Valor::StrNum(s.to_string(), n)
+ }
+
+ pub fn cadena(&self, convfmt: &str) -> String {
+ match self {
+ Valor::Num(n) => numero_a_cadena(*n, convfmt),
+ Valor::Cad(s) => s.clone(),
+ Valor::StrNum(s, _) => s.clone(),
+ Valor::Vacio => String::new(),
+ }
+ }
+
+ pub fn numero(&self) -> f64 {
+ match self {
+ Valor::Num(n) => *n,
+ Valor::Cad(s) => prefijo_numerico(s),
+ Valor::StrNum(_, Some(n)) => *n,
+ Valor::StrNum(s, None) => prefijo_numerico(s),
+ Valor::Vacio => 0.0,
+ }
+ }
+
+ /// Verdad de awk: un número es verdadero si no es 0; una CADENA, si no está vacía. Y un strnum
+ /// se juzga como número — `$1` valiendo `"0"` es FALSO, aunque la cadena no esté vacía.
+ pub fn verdad(&self) -> bool {
+ match self {
+ Valor::Num(n) => *n != 0.0,
+ Valor::Cad(s) => !s.is_empty(),
+ Valor::StrNum(s, n) => match n { Some(v) => *v != 0.0, None => !s.is_empty() },
+ Valor::Vacio => false,
+ }
+ }
+
+ /// ¿Se compara como número frente a otro? Sí si es número, si es un strnum que parece número, o
+ /// si nunca se asignó.
+ fn numerico(&self) -> bool {
+ matches!(self, Valor::Num(_) | Valor::Vacio | Valor::StrNum(_, Some(_)))
+ }
+
+ /// Comparación de awk: numérica si AMBOS lados lo admiten, de cadena en cuanto uno sea cadena.
+ pub fn comparar(&self, otro: &Valor, convfmt: &str) -> std::cmp::Ordering {
+ if self.numerico() && otro.numerico() {
+ let (a, b) = (self.numero(), otro.numero());
+ return a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal);
+ }
+ self.cadena(convfmt).cmp(&otro.cadena(convfmt))
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ #[test]
+ fn strnum_compara_como_numero_y_la_cadena_no() {
+ let campo10 = Valor::de_entrada("10");
+ let campo9 = Valor::de_entrada("9");
+ assert_eq!(campo10.comparar(&campo9, "%.6g"), std::cmp::Ordering::Greater);
+ let cad10 = Valor::Cad("10".into());
+ let cad9 = Valor::Cad("9".into());
+ assert_eq!(cad10.comparar(&cad9, "%.6g"), std::cmp::Ordering::Less);
+ }
+
+ #[test]
+ fn un_campo_que_no_parece_numero_se_compara_como_cadena() {
+ let a = Valor::de_entrada("12x");
+ let b = Valor::de_entrada("9");
+ assert_eq!(a.comparar(&b, "%.6g"), std::cmp::Ordering::Less); // "12x" < "9"
+ assert_eq!(a.numero(), 12.0); // pero convertido a número es su prefijo
+ }
+
+ #[test]
+ fn el_texto_original_se_conserva() {
+ let v = Valor::de_entrada("0012");
+ assert_eq!(v.cadena("%.6g"), "0012");
+ assert_eq!(v.numero(), 12.0);
+ }
+
+ #[test]
+ fn enteros_exactos_no_llevan_decimales() {
+ assert_eq!(numero_a_cadena(1.0, "%.6g"), "1");
+ assert_eq!(numero_a_cadena(-0.0, "%.6g"), "0");
+ assert_eq!(numero_a_cadena(0.5, "%.6g"), "0.5");
+ assert_eq!(numero_a_cadena(1.0 / 3.0, "%.6g"), "0.333333");
+ }
+
+ #[test]
+ fn cero_en_un_campo_es_falso_aunque_la_cadena_no_este_vacia() {
+ assert!(!Valor::de_entrada("0").verdad());
+ assert!(Valor::Cad("0".into()).verdad());
+ assert!(!Valor::de_entrada("").verdad());
+ }
+
+ #[test]
+ fn que_es_numero_y_que_no() {
+ assert_eq!(como_numero(" 3.5e2 "), Some(350.0));
+ assert_eq!(como_numero("+.5"), Some(0.5));
+ assert_eq!(como_numero("12x"), None);
+ assert_eq!(como_numero("0x10"), None); // POSIX no admite hexadecimal
+ assert_eq!(como_numero(""), None);
+ assert_eq!(como_numero("."), None);
+ }
+}
diff --git a/crates/qillqa/tests/divergencias_medidas.rs b/crates/qillqa/tests/divergencias_medidas.rs
new file mode 100644
index 00000000..f2de4c13
--- /dev/null
+++ b/crates/qillqa/tests/divergencias_medidas.rs
@@ -0,0 +1,106 @@
+//! Las divergencias que el banco diferencial destapó contra `busybox awk` y `gawk --posix`.
+//!
+//! Cada una estuvo ROTA y daba un resultado plausible: ninguna reventaba, ninguna imprimía un
+//! error. Por eso están acá y no sólo en el banco — el banco compara contra oráculos que hay que
+//! tener en el store, y esto corre con `cargo test` en cualquier sitio.
+
+use std::process::{Command, Stdio};
+use std::io::Write;
+
+fn awk(prog: &str, entrada: &str) -> String {
+ let exe = env!("CARGO_BIN_EXE_qillqa");
+ let mut h = Command::new(exe).arg(prog)
+ .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped())
+ .spawn().expect("no pude lanzar qillqa");
+ h.stdin.as_mut().unwrap().write_all(entrada.as_bytes()).unwrap();
+ let s = h.wait_with_output().unwrap();
+ String::from_utf8_lossy(&s.stdout).trim_end_matches('\n').to_string()
+}
+
+/// Leer NF tiene que forzar el partido en campos. Daba 0 hasta que alguien tocara un campo, porque
+/// el partido es perezoso — y eso es lo que hace barato asignar a `$0`.
+#[test]
+fn nf_se_lee_sin_tocar_un_campo() {
+ assert_eq!(awk("{print NF}", " a b \n"), "2");
+ assert_eq!(awk("{print NF, $NF}", "a b c\n"), "3 c");
+}
+
+/// `length` sin paréntesis es el único builtin al que POSIX se lo permite, y se leía como una
+/// variable llamada `length`, que siempre vale vacío.
+#[test]
+fn length_sin_parentesis() {
+ assert_eq!(awk("{print length}", "abcd\n"), "4");
+ assert_eq!(awk("BEGIN{print length(\"hola\")}", ""), "4");
+}
+
+/// Un array se pasa POR REFERENCIA. Se decide mirando si la función usa el parámetro como array, y
+/// `a["k"] = 1` tiene el nombre en el LUGAR de la asignación, no en el valor.
+#[test]
+fn los_arrays_se_pasan_por_referencia() {
+ assert_eq!(awk(r#"function g(a){a["k"]="puesto"} BEGIN{g(A); print A["k"]}"#, ""), "puesto");
+ // Y los escalares siguen yendo por valor.
+ assert_eq!(awk("function f(x){x=99} BEGIN{y=1; f(y); print y}", ""), "1");
+}
+
+/// `substr` con inicio ≤ 0: se recorta a 1 y **el largo no se ajusta**. La lectura estricta de
+/// POSIX daría `a`; ni busybox ni gawk hacen eso, y lo que hay que ejecutar son los guiones
+/// escritos contra ellos.
+#[test]
+fn substr_con_indices_fuera_de_rango() {
+ assert_eq!(awk(r#"BEGIN{print substr("abcdef", 0, 3)}"#, ""), "abc");
+ assert_eq!(awk(r#"BEGIN{print substr("abcdef", -1, 3)}"#, ""), "abc");
+ assert_eq!(awk(r#"BEGIN{print "["substr("abc", 10)"]"}"#, ""), "[]");
+ // Los no enteros se truncan hacia cero, no se redondean.
+ assert_eq!(awk(r#"BEGIN{print substr("abcdef", 1.5, 2.5)}"#, ""), "ab");
+}
+
+/// Una coincidencia VACÍA pegada al final de la anterior no sustituye. Es la única diferencia
+/// medida entre gawk y el awk de busybox en todo el banco, y gawk es quien sigue a POSIX.
+#[test]
+fn gsub_no_sustituye_el_vacio_pegado_a_la_coincidencia_anterior() {
+ assert_eq!(awk(r#"BEGIN{s="xax"; n=gsub(/a*/,"-",s); print n, s}"#, ""), "3 -x-x-");
+ assert_eq!(awk(r#"BEGIN{s="abc"; n=gsub(/x*/,"-",s); print n, s}"#, ""), "4 -a-b-c-");
+}
+
+/// El campo que viene de la entrada se compara como número si lo parece, y como cadena si no.
+#[test]
+fn strnum() {
+ assert_eq!(awk("{print ($1 > $2)}", "10 9\n"), "1");
+ assert_eq!(awk(r#"BEGIN{print ("10" > "9")}"#, ""), "0");
+ assert_eq!(awk("{print $1}", "0012\n"), "0012");
+ assert_eq!(awk("{print $1+0}", "0012\n"), "12");
+}
+
+/// `^` asocia a la DERECHA. El awk de busybox lo hace al revés y da 64.
+#[test]
+fn la_potencia_asocia_a_la_derecha() {
+ assert_eq!(awk("BEGIN{print 2^3^2}", ""), "512");
+}
+
+/// La ERE es leftmost-LONGEST, no leftmost-first: para `a|ab` sobre `"ab"` gana `ab`.
+#[test]
+fn la_regex_es_leftmost_longest() {
+ assert_eq!(awk(r#"BEGIN{print match("ab", /a|ab/), RLENGTH}"#, ""), "1 2");
+ assert_eq!(awk(r#"BEGIN{s="ab"; sub(/a|ab/, "X", s); print s}"#, ""), "X");
+}
+
+/// Un `exit` en el cuerpo ejecuta los END; uno dentro de END, no.
+#[test]
+fn exit_ejecuta_los_end() {
+ assert_eq!(awk(r#"BEGIN{exit 0} END{print "end corre"}"#, ""), "end corre");
+}
+
+/// El `%c` de printf mira el TIPO del argumento.
+#[test]
+fn printf_c_mira_el_tipo() {
+ assert_eq!(awk(r#"BEGIN{printf "%c|%c\n", 65, "65"}"#, ""), "A|6");
+}
+
+/// Los campos se rehacen con OFS al tocar cualquiera, y `$0` se reparte al asignarlo.
+#[test]
+fn campos_y_registro_son_dos_caras_del_mismo_dato() {
+ assert_eq!(awk(r#"BEGIN{OFS="-"} {$1=$1; print}"#, "a b c\n"), "a-b-c");
+ assert_eq!(awk(r#"{$5="X"; print; print NF}"#, "a b\n"), "a b X\n5");
+ assert_eq!(awk(r#"{$0="p q r"; print $2, NF}"#, "a b\n"), "q 3");
+ assert_eq!(awk(r#"{NF=2; print}"#, "a b c d\n"), "a b");
+}
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 4cb5e37a..a9a26fdf 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -141,6 +141,16 @@ paquetes = [
# `bc` es gavinhoward/bc: `bc` Y `dc` del mismo árbol, C99 sin dependencias, el que usan Alpine y
# FreeBSD. La alternativa de GNU arrastra `ed` y `flex`, y su `dc` va en otro paquete.
"psmisc", "bc",
+ # `ncurses-tools` (2026-09-22): `clear`, `reset`, `tput`, `tset`, `infocmp`, `tic`. La canónica
+ # `ncurses` instala `install.libs install.includes` y nada más ⇒ `clear` y `reset` en una imagen
+ # de takana eran el applet de busybox. Es el mismo caso que `xz-tools` y `zstd-cli`: variante con
+ # las herramientas, porque ampliar la canónica re-hashearía a sus 15 consumidores.
+ # ⚠ Instala los binarios y NO la base terminfo (~4000 entradas): `clear` y `tput` resuelven con
+ # los `--with-fallbacks` compilados dentro de la librería —linux, vt100, vt220, xterm,
+ # xterm-256color, screen, tmux, tmux-256color, ansi—, que cubren la consola del metal, QEMU y
+ # cualquier ssh moderno. Con un TERM exótico contestan «unknown terminal type» en vez de pintar
+ # basura. Corolario: `tic` e `infocmp` viajan pero no tienen base que consultar.
+ "ncurses-tools",
]
[perfil.cli]
diff --git a/recipes/ncurses-tools.toml b/recipes/ncurses-tools.toml
new file mode 100644
index 00000000..f707c4f7
--- /dev/null
+++ b/recipes/ncurses-tools.toml
@@ -0,0 +1,67 @@
+# ncurses 6.5 — VARIANTE con los PROGRAMAS: `clear`, `reset`, `tput`, `tset`, `infocmp`, `tic`.
+# NO sustituye a `recipes/ncurses.toml`.
+#
+# ── POR QUÉ EXISTE ──────────────────────────────────────────────────────────────────────────────
+# La canónica instala `make install.libs install.includes`: librería y cabeceras, nada más. Es
+# deliberado —de ella cuelgan 15 recetas— pero el efecto medido es que `clear` y `reset` en una
+# imagen de takana eran **el applet de busybox**, con `ncurses` declarado en los 7 perfiles
+# (`scripts/busybox-censo-proveedores.py`, 2026-09-21). Tocar la canónica re-hashearía a sus 15
+# consumidores; va como variante, igual que `xz-tools` sobre `xz` y `zstd-cli` sobre `zstd`.
+#
+# ── LA DECISIÓN QUE HAY DETRÁS: terminfo ────────────────────────────────────────────────────────
+# `clear` y `reset` necesitan saber qué secuencias manda cada terminal, y eso sale de la base
+# terminfo (~4000 entradas, varios MB) o de los `--with-fallbacks` COMPILADOS DENTRO de la librería.
+# La canónica ya pinea nueve: linux, vt100, vt220, xterm, xterm-256color, screen, tmux,
+# tmux-256color, ansi. Esta variante instala `install.progs` y **no** `install.data`: los binarios
+# viajan, la base de datos no. Con eso, `clear` funciona para los nueve TERM que cubren la consola
+# del metal, QEMU y cualquier ssh desde un emulador moderno, y para un TERM exótico contesta
+# «unknown terminal type» en vez de pintar basura.
+#
+# El corolario, dicho para que nadie lo descubra a la mala: **`tic` e `infocmp` viajan pero no
+# tienen base que leer**. Sirven para compilar una entrada propia, no para consultar el sistema.
+
+name = "ncurses-tools"
+version = "6.5"
+license = "MIT"
+
+[source]
+tarball = "https://ftp.gnu.org/gnu/ncurses/ncurses-6.5.tar.gz"
+sha256 = "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6"
+
+[build]
+compiler = "zig-cc"
+target = "x86_64-linux-musl"
+link = "static"
+
+# Mismo `strip_debug` que la canónica: la variante no es sitio para cambiar de política.
+strip_debug = true
+
+[deps]
+build = ["binutils"]
+
+[build.phases]
+# `configure` IDÉNTICO al de la canónica —mismos fallbacks, mismo widec— porque los binarios tienen
+# que hablar la misma librería que ya viaja. Cambiar una perilla acá haría que `tput` resolviera
+# distinto que la `ncursesw` que enlazan las aplicaciones.
+configure = "./configure --prefix=/usr --without-shared --with-normal --enable-widec --without-debug --without-ada --without-tests --enable-pc-files --with-pkg-config-libdir=/usr/lib/pkgconfig --with-fallbacks=linux,vt100,vt220,xterm,xterm-256color,screen,tmux,tmux-256color,ansi"
+# ⚠ Los diez binarios salen DINÁMICOS aunque la receta diga `link = "static"` y el harness inyecte
+# `LDFLAGS=-static`, y la causa no es un flag que falte: es que ncurses usa `-static`/`-dynamic`
+# como **marcadores de tramo** alrededor de las librerías —su forma portable de
+# `-Wl,-Bstatic … -Wl,-Bdynamic`— y no como una decisión global. El Makefile generado trae:
+#
+# LIBS_TIC = -L../lib -static -lncursesw -lncursesw -dynamic
+# LIBS_TINFO = -static -lncursesw -dynamic
+#
+# o sea «ncursesw estática, lo demás dinámico», que es exactamente lo que `--with-normal` promete.
+# El `-dynamic` llega al final de la línea de enlace y gana por orden. Se quita sobreescribiendo las
+# DOS variables en la línea de `make` (ahí sí: son variables del Makefile, no valores ya sustituidos
+# por el configure — la diferencia que hizo fallar el primer intento con `psmisc`).
+#
+# Es el tercer sabor del agujero que documenta `scripts/static-audit.sh`: cada sistema de build
+# decide dónde pone el flag, y `link = "static"` es una declaración que hay que ir a COMPROBAR al
+# binario. Aquí lo comprueba `file`; en el corpus, ese guión.
+compile = 'make LIBS_TIC="-L../lib -static -lncursesw -lncursesw" LIBS_TINFO="-static -lncursesw"'
+# Sólo `install.progs`. Ni `install.libs` ni `install.includes`: los publica la canónica, y dos
+# copias de `libncursesw.a` disputándose la misma ruta hacen que quién gana dependa del orden de
+# hidratación — que es justo lo que no debe decidir nada. Mismo criterio que `xz-tools`.
+install = 'make LIBS_TIC="-L../lib -static -lncursesw -lncursesw" LIBS_TINFO="-static -lncursesw" install.progs DESTDIR=/out'
diff --git a/scripts/awk-banco-diferencial.py b/scripts/awk-banco-diferencial.py
new file mode 100755
index 00000000..51a0f449
--- /dev/null
+++ b/scripts/awk-banco-diferencial.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""Banco DIFERENCIAL de awk: corre los mismos casos en varios motores y compara.
+
+ scripts/awk-banco-diferencial.py # qillqa contra busybox awk y gawk --posix
+ scripts/awk-banco-diferencial.py --motor ruta/al/awk # añade otro candidato
+
+Por qué diferencial y no de salidas esperadas escritas a mano: un banco escrito a mano mide lo que
+al autor se le ocurrió preguntar, y ahí es donde se cuela el sesgo — es la lección del §paso 3 de
+`docs/plan-botar-busybox.md`, donde brush pasó 56/57 casos POSIX y no construía una sola receta.
+Contra un oráculo que ya está en producción, cada divergencia es un hecho, no una opinión.
+
+⚠ Los oráculos NO coinciden entre sí en todo: busybox awk y gawk difieren en `substr` con índices
+raros, en `%c` y en el formato de algunos números. Por eso el veredicto no es «igual a X» sino
+«igual a LOS DOS», y cuando los dos difieren entre ellos el caso se marca `discrepan` y no cuenta
+como fallo de nadie — se mira a mano y se decide siguiendo POSIX.
+"""
+import argparse, json, os, subprocess, sys
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def del_store(receta, rel):
+ est = json.load(open(os.path.join(RAIZ, "docs/state/build-state.json")))["nodes"]
+ if receta not in est:
+ return None
+ p = "/store/" + est[receta]["hash"].split(":")[1] + "-" + receta + "/" + rel
+ return p if os.path.exists(p) else None
+
+
+# (nombre, programa, entrada)
+CASOS = [
+ # ── campos, separadores, registros ──────────────────────────────────────────────────────────
+ ("campos-simple", '{print $2}', "a b c\nd e f\n"),
+ ("campos-nf", '{print NF, $NF}', "a b c\nx\n"),
+ ("campos-vacio", '{print "["$5"]"}', "a b\n"),
+ ("campos-cero", '{print $0}', " hola mundo \n"),
+ ("fs-espacios-rachas", '{print NF}', " a b \n"),
+ ("fs-coma", '-F, {print $2}', "a,b,c\n"),
+ ("fs-tab", '-F\t {print $2}', "a\tb\tc\n"),
+ ("fs-ere", '-F[0-9]+ {print $2}', "ab123cd\n"),
+ ("fs-punto-literal", '-F. {print NF}', "a.b.c\n"),
+ ("asignar-campo", '{$2="X"; print}', "a b c\n"),
+ ("asignar-campo-lejos", '{$5="X"; print; print NF}', "a b\n"),
+ ("asignar-cero", '{$0="p q r"; print $2, NF}', "a b\n"),
+ ("nf-recorta", '{NF=2; print; print NF}', "a b c d\n"),
+ ("ofs-al-rehacer", 'BEGIN{OFS="-"} {$1=$1; print}', "a b c\n"),
+ ("registros-nr-fnr", '{print NR, FNR}', "x\ny\n"),
+ # ── valores: la parte que más se equivoca ───────────────────────────────────────────────────
+ ("strnum-numerico", '{print ($1 > $2)}', "10 9\n"),
+ ("cadena-alfabetica", 'BEGIN{print ("10" > "9")}', ""),
+ ("strnum-cero-falso", '{if ($1) print "si"; else print "no"}', "0\n"),
+ ("cadena-cero-verdad", 'BEGIN{ if ("0") print "si"; else print "no"}', ""),
+ ("texto-se-conserva", '{print $1}', "0012\n"),
+ ("suma-con-basura", '{print $1+0}', "12x\n"),
+ ("entero-sin-decimales", 'BEGIN{print 2/1, 1/3}', ""),
+ ("convfmt", 'BEGIN{CONVFMT="%.2g"; x=1/3; print x ""}', ""),
+ ("ofmt", 'BEGIN{OFMT="%.2f"; print 1/3}', ""),
+ ("uninit-es-cero-y-vacio", 'BEGIN{print x+0, "["x"]", (x==0), (x=="")}', ""),
+ # ── aritmética y operadores ─────────────────────────────────────────────────────────────────
+ ("potencia-derecha", 'BEGIN{print 2^3^2}', ""),
+ ("modulo-real", 'BEGIN{print 5.5 % 2}', ""),
+ ("unario-vs-concat", 'BEGIN{a=1;b=2;print a-b; print a" "-b}', ""),
+ ("incremento", 'BEGIN{i=5; print i++, i, ++i, i}', ""),
+ ("asigna-op", 'BEGIN{x=10; x-=3; x*=2; print x}', ""),
+ ("ternario", 'BEGIN{print (1?"a":"b"), (0?"a":"b")}', ""),
+ ("comparacion-encadena", 'BEGIN{print (1<2), (2<=2), (3!=3)}', ""),
+ ("concatenacion", 'BEGIN{print "a" 1+1 "b"}', ""),
+ # ── expresiones regulares ───────────────────────────────────────────────────────────────────
+ ("ere-patron", '/b/{print "hay"}', "abc\nxyz\n"),
+ ("ere-tilde", '{print ($0 ~ /^a/), ($0 !~ /^a/)}', "abc\nxbc\n"),
+ ("ere-alternancia-larga", 'BEGIN{print match("ab", /a|ab/), RLENGTH}', ""),
+ ("ere-clase", 'BEGIN{print match("foo123", /[[:digit:]]+/), RSTART, RLENGTH}', ""),
+ ("ere-intervalo", 'BEGIN{print match("aaaa", /a{2,3}/), RLENGTH}', ""),
+ ("ere-anclas", 'BEGIN{print ("abc" ~ /^abc$/), ("abc" ~ /^b/)}', ""),
+ ("ere-punto", 'BEGIN{print ("a.c" ~ /a\\.c/), ("abc" ~ /a\\.c/)}', ""),
+ ("ere-estrella-vacia", 'BEGIN{print match("bbb", /a*/), RLENGTH}', ""),
+ ("ere-clase-negada", 'BEGIN{print match("abc1", /[^a-z]/), RSTART}', ""),
+ # ── funciones de cadena ─────────────────────────────────────────────────────────────────────
+ ("substr-normal", 'BEGIN{print substr("abcdef", 2, 3)}', ""),
+ ("substr-sin-largo", 'BEGIN{print substr("abcdef", 3)}', ""),
+ ("substr-inicio-cero", 'BEGIN{print "["substr("abcdef", 0, 3)"]"}', ""),
+ ("substr-negativo", 'BEGIN{print "["substr("abcdef", -1, 3)"]"}', ""),
+ ("substr-pasado", 'BEGIN{print "["substr("abc", 10)"]"}', ""),
+ ("substr-redondeo", 'BEGIN{print substr("abcdef", 1.5, 2.5)}', ""),
+ ("index", 'BEGIN{print index("abcabc","bc"), index("abc","z"), index("abc","")}', ""),
+ ("length", 'BEGIN{print length("hola"), length()}', ""),
+ ("length-campo", '{print length}', "abcd\n"),
+ ("toupper-tolower", 'BEGIN{print toupper("aBc"), tolower("AbC")}', ""),
+ ("sub-simple", 'BEGIN{s="aaa"; n=sub(/a/,"b",s); print n, s}', ""),
+ ("gsub-simple", 'BEGIN{s="aaa"; n=gsub(/a/,"b",s); print n, s}', ""),
+ ("gsub-ampersand", 'BEGIN{s="abc"; gsub(/b/,"[&]",s); print s}', ""),
+ ("gsub-ampersand-escapado", 'BEGIN{s="abc"; gsub(/b/,"[\\\\&]",s); print s}', ""),
+ ("gsub-vacio", 'BEGIN{s="xax"; n=gsub(/a*/,"-",s); print n, s}', ""),
+ ("gsub-en-campo", '{gsub(/o/,"0"); print}', "foo boo\n"),
+ ("split-fs", 'BEGIN{n=split("a:b:c",A,":"); print n, A[1], A[3]}', ""),
+ ("split-ere", 'BEGIN{n=split("a1b22c",A,/[0-9]+/); print n, A[2], A[3]}', ""),
+ ("split-vacio", 'BEGIN{n=split("",A,":"); print n}', ""),
+ ("split-default", 'BEGIN{n=split(" a b ",A); print n, A[1], A[2]}', ""),
+ ("sprintf", 'BEGIN{print sprintf("%03d-%s", 7, "x")}', ""),
+ ("match-sin-exito", 'BEGIN{print match("abc",/z/), RSTART, RLENGTH}', ""),
+ # ── printf ──────────────────────────────────────────────────────────────────────────────────
+ ("printf-enteros", 'BEGIN{printf "%d|%5d|%-5d|%05d\\n", 42, 42, 42, 42}', ""),
+ ("printf-negativo", 'BEGIN{printf "%05d|%+d|% d\\n", -1, 7, 7}', ""),
+ ("printf-flotante", 'BEGIN{printf "%.2f|%e|%g\\n", 3.14159, 150, 0.0000123}', ""),
+ ("printf-cadena", 'BEGIN{printf "%s|%10s|%-10s|%.2s|\\n", "ab","ab","ab","abcd"}', ""),
+ ("printf-caracter-num", 'BEGIN{printf "%c\\n", 65}', ""),
+ ("printf-hex", 'BEGIN{printf "%x|%X|%o\\n", 255, 255, 8}', ""),
+ ("printf-asterisco", 'BEGIN{printf "%*d|%.*f\\n", 5, 42, 2, 3.14159}', ""),
+ ("printf-porciento", 'BEGIN{printf "100%%\\n"}', ""),
+ ("printf-trunca", 'BEGIN{printf "%d\\n", 3.99}', ""),
+ # ── control de flujo ────────────────────────────────────────────────────────────────────────
+ ("if-else", 'BEGIN{ if (1) print "a"; else print "b" }', ""),
+ ("while", 'BEGIN{i=0; while(i<3){printf "%d", i; i++}; print ""}', ""),
+ ("do-while", 'BEGIN{i=0; do {printf "%d", i; i++} while(i<3); print ""}', ""),
+ ("for-clasico", 'BEGIN{for(i=0;i<3;i++) printf "%d", i; print ""}', ""),
+ ("for-in", 'BEGIN{a[1];a[2];a[3]; n=0; for(k in a) n++; print n}', ""),
+ ("break-continue", 'BEGIN{for(i=0;i<5;i++){if(i==1)continue; if(i==3)break; printf "%d",i}; print ""}', ""),
+ ("next", '/skip/{next} {print}', "uno\nskip esto\ndos\n"),
+ ("exit-con-end", 'BEGIN{exit 0} END{print "end corre"}', ""),
+ ("patron-rango", '/ini/,/fin/{print}', "a\nini\nx\nfin\nb\n"),
+ ("patron-expresion", 'NR==2{print}', "a\nb\nc\n"),
+ ("patron-sin-accion", 'NR==2', "a\nb\nc\n"),
+ # ── arrays ──────────────────────────────────────────────────────────────────────────────────
+ ("array-subsep", 'BEGIN{a[1,2]="x"; for(k in a){split(k,p,SUBSEP); print p[1],p[2]}}', ""),
+ ("array-in-crea", 'BEGIN{ if (a["k"]=="") print "vacio"; print ("k" in a) }', ""),
+ ("array-delete", 'BEGIN{a[1]=1;a[2]=2; delete a[1]; n=0; for(k in a)n++; print n}', ""),
+ ("array-delete-todo", 'BEGIN{a[1]=1;a[2]=2; delete a; n=0; for(k in a)n++; print n}', ""),
+ ("array-length", 'BEGIN{a[1];a[2]; print length(a)}', ""),
+ # ── funciones de usuario ────────────────────────────────────────────────────────────────────
+ ("funcion-simple", 'function f(x){return x*2} BEGIN{print f(21)}', ""),
+ ("funcion-recursiva", 'function f(n){return n<=1?1:n*f(n-1)} BEGIN{print f(6)}', ""),
+ ("funcion-array-ref", 'function g(a){a["k"]="puesto"} BEGIN{g(A); print A["k"]}', ""),
+ ("funcion-local", 'function f(x, t){t=x*2; return t} BEGIN{t=9; print f(2), t}', ""),
+ ("funcion-sin-return", 'function f(){} BEGIN{print "["f()"]"}', ""),
+ # ── entrada/salida ──────────────────────────────────────────────────────────────────────────
+ ("print-redirigido", 'BEGIN{print "x" > "/dev/stderr"}', ""),
+ ("print-coma-ofs", 'BEGIN{OFS=":"; print "a","b"}', ""),
+ ("print-ors", 'BEGIN{ORS="|"} {print}', "a\nb\n"),
+ ("print-parentesis", 'BEGIN{print (1), (2)}', ""),
+ ("getline-tuberia", 'BEGIN{ "echo hola" | getline x; print x }', ""),
+ ("getline-simple", 'NR==1{ getline; print "leido:" $0 }', "a\nb\nc\n"),
+ ("rs-parrafo", 'BEGIN{RS=""} {print NR": "$1"-"$2}', "a\nb\n\nc\nd\n"),
+ ("rs-caracter", 'BEGIN{RS=":"} {print NR, $0}', "a:b:c"),
+ # ── rincones del parser ─────────────────────────────────────────────────────────────────────
+ ("barra-division", 'BEGIN{a=6; b=3; print a / b}', ""),
+ ("print-mayor-redirige", 'BEGIN{print (1>2)}', ""),
+ ("comentario", 'BEGIN{ # esto es comentario\nprint "ok"}', ""),
+ ("punto-y-coma", 'BEGIN{;;print "ok";;}', ""),
+ ("continuacion-linea", 'BEGIN{print "a" \\\n "b"}', ""),
+ ("cadena-escapes", 'BEGIN{print "a\\tb\\\\c"}', ""),
+ ("expresion-regex-valor", '{print /b/}', "abc\nxyz\n"),
+]
+
+
+def corre(motor, caso, entrada):
+ """Devuelve (salida+error, código)."""
+ args = list(motor)
+ prog = caso
+ # Los casos que empiezan por `-F` traen la opción pegada al programa.
+ if prog.startswith("-F"):
+ i = prog.index(" ")
+ args += [prog[:i]]
+ prog = prog[i + 1:]
+ args.append(prog)
+ try:
+ r = subprocess.run(args, input=entrada, capture_output=True, text=True, timeout=10)
+ return (r.stdout + r.stderr).rstrip("\n"), r.returncode
+ except subprocess.TimeoutExpired:
+ return "@TIMEOUT", -1
+ except Exception as e:
+ return f"@ERROR {e}", -1
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--motor", default=os.path.join(RAIZ, "target/release/qillqa"))
+ ap.add_argument("--verbose", action="store_true")
+ a = ap.parse_args()
+
+ bb = del_store("busybox", "usr/bin/awk")
+ gawk = del_store("gawk", "usr/bin/gawk")
+ oraculos = []
+ if bb: oraculos.append(("busybox", [bb]))
+ if gawk: oraculos.append(("gawk", [gawk, "--posix"]))
+ if not oraculos:
+ print("sin oráculos en el store: no se puede comparar", file=sys.stderr)
+ return 2
+
+ print(f"motor: {a.motor}")
+ print(f"oráculos: {', '.join(n for n, _ in oraculos)}\n")
+ igual = discrepan = distinto = 0
+ fallos = []
+ for nombre, prog, entrada in CASOS:
+ mio, _ = corre([a.motor], prog, entrada)
+ refs = [(n, corre(cmd, prog, entrada)[0]) for n, cmd in oraculos]
+ valores = {r for _, r in refs}
+ if len(valores) > 1:
+ discrepan += 1
+ if a.verbose:
+ print(f" ~ {nombre}: los oráculos discrepan " +
+ " · ".join(f"{n}=[{r}]" for n, r in refs) + f" · qillqa=[{mio}]")
+ continue
+ esperado = refs[0][1]
+ if mio == esperado:
+ igual += 1
+ else:
+ distinto += 1
+ fallos.append((nombre, prog, entrada, esperado, mio))
+
+ print(f" iguales a los oráculos : {igual}")
+ print(f" DIVERGEN : {distinto}")
+ print(f" los oráculos discrepan : {discrepan} (no cuentan: se deciden por POSIX a mano)")
+ if fallos:
+ print("\n── divergencias ──")
+ for n, p, e, esp, mio in fallos:
+ print(f"\n ✗ {n}\n programa: {p}\n entrada : {e!r}\n oráculos: [{esp}]\n qillqa : [{mio}]")
+ return 1 if fallos else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())