diff --git a/Cargo.lock b/Cargo.lock index 7d6017e..10c610b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2355,6 +2355,17 @@ dependencies = [ "charka-bcd", ] +[[package]] +name = "charka-shadow" +version = "0.1.0" +dependencies = [ + "charka-ir", + "charka-lexer", + "charka-parser", + "charka-runtime", + "thiserror 2.0.18", +] + [[package]] name = "chasqui-card" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8385186..859a60a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,7 @@ members = [ "crates/modules/charka/charka-ir", "crates/modules/charka/charka-runtime", "crates/modules/charka/charka-codegen", + "crates/modules/charka/charka-shadow", # ============================================================ # modules/mirada/ — Compositor Wayland diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md index 9f661fe..835672d 100644 --- a/crates/modules/charka/SDD.md +++ b/crates/modules/charka/SDD.md @@ -16,6 +16,7 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes. | `charka-ir` | lib | Representación intermedia: el AST con los statements del PROCEDURE ya tipados | | `charka-runtime` | lib | Soporte de ejecución de los programas transpilados: campos `Num` y `Text` | | `charka-codegen` | lib | Emisión de Rust: IR → fuente Rust sobre `charka-runtime` | +| `charka-shadow` | lib | Validador en sombra: intérprete del IR + corpus de prueba | ## charka-bcd @@ -136,15 +137,36 @@ del programa COBOL. - Fuera de alcance v1: grupos como campo propio, `REDEFINES`, `OCCURS`/tablas, `PERFORM ... THRU` como rango, E/S de ficheros. +## charka-shadow + +El validador: certifica que el pipeline preserva la semántica del +COBOL original. Lo hace con una **ejecución sombra** — un intérprete +que corre el `Ir` directamente sobre `charka-runtime`, sin compilar. + +- `interpret(&Ir) -> Outcome` — ejecuta el IR y captura las líneas de + `DISPLAY`. `run_source(&str)` corre el pipeline completo. +- El intérprete es una segunda ruta de ejecución, independiente del + código que emite `charka-codegen`: si la sombra y el transpilado + divergieran, eso delataría un bug. +- Tope de pasos: un bucle que no termina se corta con + `Halt::StepLimit` en vez de colgarse. +- La referencia v1 es el **corpus** (`corpus/`): 7 programas COBOL de + complejidad graduada con sus salidas esperadas verificadas a mano. + Un modo futuro, con GnuCOBOL, diferenciará contra el compilador real. + +## El corpus + +`crates/modules/charka/corpus/` — 7 programas COBOL graduados +(`01-hola` … `07-clasificar`), cada uno con su `.expected`. Ejercita +el pipeline completo de punta a punta. Ver su `README.md`. + ## Estado -`charka-bcd` (22 tests), `charka-lexer` (17 tests), `charka-parser` -(15 tests), `charka-ir` (17 tests), `charka-runtime` (17 tests) y -`charka-codegen` (14 tests) implementados y verdes. El pipeline -COBOL→Rust corre de punta a punta. **Pendiente** — el último crate: +Pipeline **completo** — `charka-bcd` (22 tests), `charka-lexer` (17), +`charka-parser` (15), `charka-ir` (17), `charka-runtime` (17), +`charka-codegen` (14) y `charka-shadow` (11) implementados y verdes. +COBOL → Rust corre de punta a punta, validado contra el corpus. -| crate pendiente | rol | -| ----------------- | ---------------------------------------------------- | -| `charka-shadow` | validador en sombra (original vs transpilado) | - -Hito intermedio sugerido: subconjunto COBOL'85 puro antes de CICS/SQL. +Próximo hito mayor: salir del subconjunto COBOL'85 puro hacia CICS, +SQL embebido y los dialectos IBM Enterprise; ampliar el codegen +(grupos, `REDEFINES`, `OCCURS`/tablas, E/S de ficheros). diff --git a/crates/modules/charka/charka-shadow/Cargo.toml b/crates/modules/charka/charka-shadow/Cargo.toml new file mode 100644 index 0000000..fc693c3 --- /dev/null +++ b/crates/modules/charka/charka-shadow/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "charka-shadow" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "charka-shadow — validador en sombra del transpilador: un intérprete del IR que ejecuta el programa COBOL y compara su salida contra una referencia." + +[dependencies] +charka-lexer = { path = "../charka-lexer" } +charka-parser = { path = "../charka-parser" } +charka-ir = { path = "../charka-ir" } +charka-runtime = { path = "../charka-runtime" } +thiserror = { workspace = true } diff --git a/crates/modules/charka/charka-shadow/src/field.rs b/crates/modules/charka/charka-shadow/src/field.rs new file mode 100644 index 0000000..f7be2c6 --- /dev/null +++ b/crates/modules/charka/charka-shadow/src/field.rs @@ -0,0 +1,128 @@ +//! El estado de los datos durante la ejecución sombra: el árbol de +//! `DataItem` del IR se aplana a un mapa de campos vivos. +//! +//! La clasificación de PICTURE refleja la de `charka-codegen` — un +//! futuro refactor la unificaría en `charka-runtime`. + +use std::collections::HashMap; + +use charka_ir::DataItem; +use charka_runtime::{Num, Picture, Text}; + +/// Un campo vivo: numérico o alfanumérico. +pub(crate) enum Cell { + Num(Num), + Text(Text), +} + +/// Aplana el árbol de datos en un mapa `nombre COBOL → campo`. +pub(crate) fn build_fields(data: &[DataItem]) -> HashMap { + let mut map = HashMap::new(); + collect(data, &mut map); + map +} + +/// Recorre el árbol: los grupos no son campos (se recurre en sus +/// hijos); se saltan los niveles 88/66 y los `FILLER`. +fn collect(items: &[DataItem], map: &mut HashMap) { + for it in items { + if it.level == 88 || it.level == 66 { + continue; + } + if !it.children.is_empty() { + collect(&it.children, map); + continue; + } + if it.name == "FILLER" { + continue; + } + if let Some(cell) = make_cell(it.picture.as_deref(), it.value.as_deref()) { + map.entry(it.name.to_uppercase()).or_insert(cell); + } + } +} + +/// Construye un campo desde su PICTURE y su cláusula `VALUE`. +fn make_cell(pic: Option<&str>, value: Option<&str>) -> Option { + let up = pic?.to_uppercase(); + if up.contains('X') || up.contains('A') { + return Some(Cell::Text(Text::with_value( + pic_width(&up).max(1), + &text_value(value), + ))); + } + if let Ok(p) = Picture::parse(&up) { + return Some(Cell::Num(Num::with_value(p, &numeric_value(value)))); + } + // PICTURE de edición → campo de texto de presentación. + Some(Cell::Text(Text::with_value( + pic_width(&up).max(1), + &text_value(value), + ))) +} + +/// Cuenta las posiciones de presentación de una PICTURE, expandiendo +/// la repetición `C(n)`. `S` y `V` no ocupan posición. +fn pic_width(up: &str) -> usize { + let chars: Vec = up.chars().collect(); + let mut i = 0; + let mut total = 0usize; + while i < chars.len() { + let c = chars[i]; + i += 1; + if c == 'S' || c == 'V' { + continue; + } + let mut count = 1usize; + if chars.get(i) == Some(&'(') { + i += 1; + let start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + if let Ok(n) = chars[start..i].iter().collect::().parse::() { + count = n; + } + if chars.get(i) == Some(&')') { + i += 1; + } + } + total += count; + } + total +} + +/// Normaliza el `VALUE` de un campo numérico a un literal parseable. +fn numeric_value(v: Option<&str>) -> String { + let Some(raw) = v else { + return "0".to_string(); + }; + if matches!(raw.to_uppercase().as_str(), "ZERO" | "ZEROS" | "ZEROES") { + return "0".to_string(); + } + if charka_runtime::Decimal::parse(raw).is_ok() { + raw.to_string() + } else { + "0".to_string() + } +} + +/// Normaliza el `VALUE` de un campo de texto. El parser envuelve los +/// literales de texto en comillas simples; aquí se desenvuelven. +fn text_value(v: Option<&str>) -> String { + let Some(raw) = v else { + return String::new(); + }; + let up = raw.to_uppercase(); + if matches!(up.as_str(), "SPACE" | "SPACES") { + return String::new(); + } + if matches!(up.as_str(), "ZERO" | "ZEROS" | "ZEROES") { + return "0".to_string(); + } + if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') { + raw[1..raw.len() - 1].to_string() + } else { + raw.to_string() + } +} diff --git a/crates/modules/charka/charka-shadow/src/interp.rs b/crates/modules/charka/charka-shadow/src/interp.rs new file mode 100644 index 0000000..71614f7 --- /dev/null +++ b/crates/modules/charka/charka-shadow/src/interp.rs @@ -0,0 +1,497 @@ +//! El intérprete del IR: la ejecución «sombra» del programa COBOL. +//! +//! Ejecuta el [`Ir`] directamente sobre los tipos de `charka-runtime`, +//! sin compilar nada. Es una segunda ruta de ejecución, independiente +//! del código que emite `charka-codegen` — eso es lo que lo hace un +//! validador: si el intérprete y el transpilado divergen, hay un bug. + +use std::collections::HashMap; + +use charka_ir::{ + BinOp, CmpOp, Cond, Expr, Figurative, Ir, Operand, Perform, PerformControl, PerformTarget, Stmt, +}; +use charka_runtime::{cobol_text_cmp, Decimal, Rounding}; + +use crate::field::{build_fields, Cell}; + +/// Tope de pasos: corta los bucles que no terminan (un `PERFORM UNTIL` +/// con una condición que nunca se cumple) en vez de colgarse. +const STEP_BUDGET: u64 = 5_000_000; + +/// Escala intermedia de la división dentro de una expresión. +const DIV_SCALE: u8 = 9; + +/// El resultado de ejecutar un statement: cómo sigue el control. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Flow { + /// Sigue con el statement siguiente. + Normal, + /// Sale del párrafo actual (`EXIT`). + Exit, + /// Termina el programa (`STOP RUN`, `GOBACK`, tope de pasos). + Stop, +} + +/// La máquina sombra: el estado y el motor de ejecución. +pub(crate) struct Machine<'a> { + ir: &'a Ir, + fields: HashMap, + para_index: HashMap, + pub output: Vec, + budget: u64, + pub step_limit_hit: bool, + pub stopped: bool, +} + +impl<'a> Machine<'a> { + /// Prepara la máquina: aplana los datos e indexa los párrafos. + pub(crate) fn new(ir: &'a Ir) -> Self { + let mut para_index = HashMap::new(); + for (i, proc) in ir.procedures.iter().enumerate() { + para_index.entry(proc.name.to_uppercase()).or_insert(i); + } + Self { + ir, + fields: build_fields(&ir.data), + para_index, + output: Vec::new(), + budget: STEP_BUDGET, + step_limit_hit: false, + stopped: false, + } + } + + /// Corre el programa: encadena los párrafos en orden (el «caer» de + /// COBOL) hasta un `STOP RUN` o el final. + pub(crate) fn run(&mut self) { + let ir = self.ir; + for i in 0..ir.procedures.len() { + if let Flow::Stop = self.exec_block(&ir.procedures[i].body) { + self.stopped = true; + break; + } + } + } + + // ── Ejecución ───────────────────────────────────────────────── + + /// Consume un paso del presupuesto. `true` si se agotó. + fn tick(&mut self) -> bool { + if self.budget == 0 { + self.step_limit_hit = true; + return true; + } + self.budget -= 1; + false + } + + fn exec_block(&mut self, stmts: &'a [Stmt]) -> Flow { + for s in stmts { + match self.exec_stmt(s) { + Flow::Normal => {} + other => return other, + } + } + Flow::Normal + } + + fn exec_stmt(&mut self, stmt: &'a Stmt) -> Flow { + if self.tick() { + return Flow::Stop; + } + match stmt { + Stmt::Move { from, to } => { + for t in to { + self.do_move(from, t); + } + Flow::Normal + } + Stmt::Display { items } => { + let line: String = items.iter().map(|o| self.eval_text(o)).collect(); + self.output.push(line); + Flow::Normal + } + Stmt::Accept { .. } => Flow::Normal, // sin entrada: deja el campo igual + Stmt::Compute { + targets, + rounded, + expr, + } => { + let value = self.eval_expr(expr); + for t in targets { + self.store(t, value, *rounded); + } + Flow::Normal + } + Stmt::Add { + addends, + to, + giving, + rounded, + } => { + let sum = self.fold_sum(addends); + if giving.is_empty() { + for t in to { + let cur = self.field_value(t); + self.store(t, cur.add(&sum), *rounded); + } + } else { + let base = match to.first() { + Some(first) => sum.add(&self.field_value(first)), + None => sum, + }; + for g in giving { + self.store(g, base, *rounded); + } + } + Flow::Normal + } + Stmt::Subtract { + amounts, + from, + giving, + rounded, + } => { + let sum = self.fold_sum(amounts); + if giving.is_empty() { + for t in from { + let cur = self.field_value(t); + self.store(t, cur.sub(&sum), *rounded); + } + } else { + let minuend = from + .first() + .map(|f| self.field_value(f)) + .unwrap_or_else(Decimal::zero); + let value = minuend.sub(&sum); + for g in giving { + self.store(g, value, *rounded); + } + } + Flow::Normal + } + Stmt::Multiply { + left, + by, + giving, + rounded, + } => { + let value = self.eval_decimal(left).mul(&self.eval_decimal(by)); + if giving.is_empty() { + if let Operand::Data(name) = by { + self.store(name, value, *rounded); + } + } else { + for g in giving { + self.store(g, value, *rounded); + } + } + Flow::Normal + } + Stmt::Divide { + left, + right, + by_form, + giving, + rounded, + } => { + let (num, den) = if *by_form { + (self.eval_decimal(left), self.eval_decimal(right)) + } else { + (self.eval_decimal(right), self.eval_decimal(left)) + }; + if giving.is_empty() { + if let Operand::Data(name) = right { + let v = divide(num, den, self.target_scale(name)); + self.store(name, v, *rounded); + } + } else { + for g in giving { + let v = divide(num, den, self.target_scale(g)); + self.store(g, v, *rounded); + } + } + Flow::Normal + } + Stmt::If { + cond, + then_branch, + else_branch, + } => { + if self.eval_cond(cond) { + self.exec_block(then_branch) + } else { + self.exec_block(else_branch) + } + } + Stmt::Perform(p) => self.exec_perform(p), + Stmt::GoTo { target } => { + // Aproximación: ejecuta el destino y sale del párrafo. + match self.run_paragraph(target) { + Flow::Stop => Flow::Stop, + _ => Flow::Exit, + } + } + Stmt::StopRun | Stmt::Goback => Flow::Stop, + Stmt::Exit => Flow::Exit, + Stmt::Continue => Flow::Normal, + Stmt::Unknown { .. } => Flow::Normal, // verbo no soportado: se omite + } + } + + fn exec_perform(&mut self, p: &'a Perform) -> Flow { + match &p.control { + PerformControl::Once => self.run_target(&p.target), + PerformControl::Times(n) => { + let count = self.count_of(n); + for _ in 0..count { + if self.tick() { + return Flow::Stop; + } + if let Flow::Stop = self.run_target(&p.target) { + return Flow::Stop; + } + } + Flow::Normal + } + PerformControl::Until(cond) => loop { + if self.tick() { + return Flow::Stop; + } + if self.eval_cond(cond) { + return Flow::Normal; + } + if let Flow::Stop = self.run_target(&p.target) { + return Flow::Stop; + } + }, + } + } + + /// Ejecuta una vez el cuerpo de un `PERFORM`. Un `EXIT` dentro de + /// él termina esa pasada, no el programa. + fn run_target(&mut self, target: &'a PerformTarget) -> Flow { + let flow = match target { + PerformTarget::Paragraph { name, .. } => self.run_paragraph(name), + PerformTarget::Inline(body) => self.exec_block(body), + }; + match flow { + Flow::Stop => Flow::Stop, + _ => Flow::Normal, + } + } + + fn run_paragraph(&mut self, name: &str) -> Flow { + let Some(&idx) = self.para_index.get(&name.to_uppercase()) else { + return Flow::Normal; + }; + let ir = self.ir; + match self.exec_block(&ir.procedures[idx].body) { + Flow::Stop => Flow::Stop, + _ => Flow::Normal, + } + } + + /// `MOVE from` a un solo campo destino. + fn do_move(&mut self, from: &Operand, target: &str) { + let key = target.to_uppercase(); + match self.fields.get(&key) { + Some(Cell::Num(_)) => { + let v = self.eval_decimal(from); + if let Some(Cell::Num(n)) = self.fields.get_mut(&key) { + n.store(v); + } + } + Some(Cell::Text(_)) => { + if let Operand::Figurative(fig) = from { + let ch = figurative_fill(*fig); + if let Some(Cell::Text(t)) = self.fields.get_mut(&key) { + t.fill(ch); + } + } else { + let s = self.eval_text(from); + if let Some(Cell::Text(t)) = self.fields.get_mut(&key) { + t.store(&s); + } + } + } + None => {} + } + } + + /// Almacena un valor en un campo, conformándolo a su tipo. + fn store(&mut self, name: &str, value: Decimal, rounded: bool) { + match self.fields.get_mut(&name.to_uppercase()) { + Some(Cell::Num(n)) => { + if rounded { + n.store_rounded(value); + } else { + n.store(value); + } + } + Some(Cell::Text(t)) => t.store(&value.to_string()), + None => {} + } + } + + // ── Evaluación ──────────────────────────────────────────────── + + fn eval_decimal(&self, op: &Operand) -> Decimal { + match op { + Operand::Num(n) => Decimal::parse(n).unwrap_or_else(|_| Decimal::zero()), + Operand::Str(s) => Decimal::parse(s).unwrap_or_else(|_| Decimal::zero()), + Operand::Figurative(_) => Decimal::zero(), + Operand::Data(name) => match self.fields.get(&name.to_uppercase()) { + Some(Cell::Num(n)) => n.value(), + Some(Cell::Text(t)) => { + Decimal::parse(t.as_str().trim()).unwrap_or_else(|_| Decimal::zero()) + } + None => Decimal::zero(), + }, + } + } + + fn eval_text(&self, op: &Operand) -> String { + match op { + Operand::Str(s) => s.clone(), + Operand::Num(n) => n.clone(), + Operand::Figurative(f) => figurative_text(*f).to_string(), + Operand::Data(name) => match self.fields.get(&name.to_uppercase()) { + Some(Cell::Num(n)) => n.display(), + Some(Cell::Text(t)) => t.display(), + None => String::new(), + }, + } + } + + fn eval_expr(&self, e: &Expr) -> Decimal { + match e { + Expr::Operand(op) => self.eval_decimal(op), + Expr::Neg(inner) => Decimal::zero().sub(&self.eval_expr(inner)), + Expr::Binary { op, lhs, rhs } => { + let l = self.eval_expr(lhs); + let r = self.eval_expr(rhs); + match op { + BinOp::Add => l.add(&r), + BinOp::Sub => l.sub(&r), + BinOp::Mul => l.mul(&r), + BinOp::Div => divide(l, r, DIV_SCALE), + BinOp::Pow => pow(&l, &r), + } + } + } + } + + fn eval_cond(&self, c: &Cond) -> bool { + match c { + Cond::Compare { lhs, op, rhs } => { + let ord = if self.is_text(lhs) || self.is_text(rhs) { + cobol_text_cmp(&self.eval_text(lhs), &self.eval_text(rhs)) + } else { + self.eval_decimal(lhs).cmp(&self.eval_decimal(rhs)) + }; + match op { + CmpOp::Eq => ord.is_eq(), + CmpOp::Ne => ord.is_ne(), + CmpOp::Lt => ord.is_lt(), + CmpOp::Gt => ord.is_gt(), + CmpOp::Le => ord.is_le(), + CmpOp::Ge => ord.is_ge(), + } + } + Cond::Named(_) => false, // nombres de condición (88): no soportado + Cond::Not(inner) => !self.eval_cond(inner), + Cond::And(a, b) => self.eval_cond(a) && self.eval_cond(b), + Cond::Or(a, b) => self.eval_cond(a) || self.eval_cond(b), + } + } + + fn is_text(&self, op: &Operand) -> bool { + match op { + Operand::Str(_) => true, + Operand::Data(name) => { + matches!(self.fields.get(&name.to_uppercase()), Some(Cell::Text(_))) + } + _ => false, + } + } + + /// La suma de una lista de operandos. + fn fold_sum(&self, ops: &[Operand]) -> Decimal { + let mut acc = Decimal::zero(); + for o in ops { + acc = acc.add(&self.eval_decimal(o)); + } + acc + } + + /// El valor actual de un campo por nombre. + fn field_value(&self, name: &str) -> Decimal { + match self.fields.get(&name.to_uppercase()) { + Some(Cell::Num(n)) => n.value(), + Some(Cell::Text(t)) => { + Decimal::parse(t.as_str().trim()).unwrap_or_else(|_| Decimal::zero()) + } + None => Decimal::zero(), + } + } + + /// Los dígitos fraccionarios de un campo numérico destino. + fn target_scale(&self, name: &str) -> u8 { + match self.fields.get(&name.to_uppercase()) { + Some(Cell::Num(n)) => n.picture().fraction_digits, + _ => 4, + } + } + + /// El número de repeticiones de un `PERFORM ... TIMES`. + fn count_of(&self, op: &Operand) -> usize { + let m = self + .eval_decimal(op) + .rescale(0, Rounding::Truncate) + .mantissa(); + if m < 0 { + 0 + } else { + m as usize + } + } +} + +/// División con escala fija; una división por cero da cero. +fn divide(num: Decimal, den: Decimal, scale: u8) -> Decimal { + num.div(&den, scale, Rounding::Truncate) + .unwrap_or_else(|_| Decimal::zero()) +} + +/// Potencia con exponente entero no negativo; en otro caso da 1. +fn pow(base: &Decimal, exp: &Decimal) -> Decimal { + let e = exp.rescale(0, Rounding::Truncate).mantissa(); + if !(0..=256).contains(&e) { + return Decimal::from_integer(1); + } + let mut acc = Decimal::from_integer(1); + for _ in 0..e { + acc = acc.mul(base); + } + acc +} + +/// El texto que representa una constante figurativa. +fn figurative_text(f: Figurative) -> &'static str { + match f { + Figurative::Zero => "0", + Figurative::Space => " ", + Figurative::Quote => "\"", + Figurative::HighValue | Figurative::LowValue | Figurative::Null => "", + } +} + +/// El carácter de relleno de una figurativa, para `Text::fill`. +fn figurative_fill(f: Figurative) -> char { + match f { + Figurative::Zero => '0', + Figurative::Quote => '"', + _ => ' ', + } +} diff --git a/crates/modules/charka/charka-shadow/src/lib.rs b/crates/modules/charka/charka-shadow/src/lib.rs new file mode 100644 index 0000000..325963d --- /dev/null +++ b/crates/modules/charka/charka-shadow/src/lib.rs @@ -0,0 +1,154 @@ +//! `charka-shadow` — el validador en sombra del transpilador. +//! +//! Certifica que el pipeline de charka (lexer → parser → IR → codegen) +//! preserva la semántica del programa COBOL original. Lo hace con una +//! **ejecución sombra**: un intérprete que corre el [`Ir`] directamente +//! sobre los tipos de `charka-runtime`, sin compilar nada. +//! +//! El intérprete es una segunda ruta de ejecución, independiente del +//! código que emite `charka-codegen`. Si la sombra y el transpilado +//! produjeran salidas distintas, eso delataría un bug del codegen. +//! +//! - [`interpret`] — ejecuta un `Ir` y devuelve su salida. +//! - [`run_source`] — el pipeline completo, de fuente COBOL a salida. +//! +//! La referencia contra la que se comparan los resultados es, en la +//! v1, un conjunto de salidas esperadas verificadas a mano (el corpus +//! en `crates/modules/charka/corpus/`). Cuando haya un GnuCOBOL +//! disponible, un modo futuro podrá diferenciar contra el compilador +//! de COBOL real — la validación «original vs transpilado» plena. +//! +//! El intérprete tiene un tope de pasos: un bucle que no termina se +//! corta con [`Halt::StepLimit`] en vez de colgarse. + +#![forbid(unsafe_code)] + +mod field; +mod interp; + +use charka_ir::Ir; +use interp::Machine; + +/// Cómo terminó una ejecución sombra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Halt { + /// Cayó por el final del PROCEDURE division. + Normal, + /// Un `STOP RUN` o `GOBACK`. + StopRun, + /// Se agotó el tope de pasos (un bucle que no termina). + StepLimit, +} + +/// El resultado de una ejecución sombra. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// Las líneas que el programa emitió por `DISPLAY`. + pub lines: Vec, + /// Cómo terminó. + pub halt: Halt, +} + +/// Falla del pipeline previo al intérprete. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ShadowError { + #[error("error de léxico: {0}")] + Lex(#[from] charka_lexer::LexError), + #[error("error de parseo: {0}")] + Parse(#[from] charka_parser::ParseError), +} + +/// Ejecuta un [`Ir`] en sombra y captura su salida. +pub fn interpret(ir: &Ir) -> Outcome { + let mut machine = Machine::new(ir); + machine.run(); + let halt = if machine.step_limit_hit { + Halt::StepLimit + } else if machine.stopped { + Halt::StopRun + } else { + Halt::Normal + }; + Outcome { + lines: machine.output, + halt, + } +} + +/// Corre el pipeline completo: fuente COBOL (formato libre) → salida. +pub fn run_source(cobol: &str) -> Result { + let tokens = charka_lexer::lex(cobol, charka_lexer::SourceFormat::Free)?; + let program = charka_parser::parse(&tokens)?; + let ir = charka_ir::lower(&program); + Ok(interpret(&ir)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifica un programa del corpus contra su salida esperada. La + /// comparación ignora los espacios finales de cada línea. + fn check(cobol: &str, expected: &str) { + let outcome = run_source(cobol).expect("el pipeline no debe fallar"); + let got: Vec<&str> = outcome.lines.iter().map(|l| l.trim_end()).collect(); + let want: Vec<&str> = expected.lines().map(|l| l.trim_end()).collect(); + assert_eq!(got, want, "salida sombra distinta de la esperada"); + } + + /// Declara un test que corre un programa del corpus. + macro_rules! corpus_test { + ($name:ident, $file:literal) => { + #[test] + fn $name() { + check( + include_str!(concat!("../../corpus/", $file, ".cob")), + include_str!(concat!("../../corpus/", $file, ".expected")), + ); + } + }; + } + + corpus_test!(corpus_01_hola, "01-hola"); + corpus_test!(corpus_02_aritmetica, "02-aritmetica"); + corpus_test!(corpus_03_condicional, "03-condicional"); + corpus_test!(corpus_04_bucle, "04-bucle"); + corpus_test!(corpus_05_factorial, "05-factorial"); + corpus_test!(corpus_06_nomina, "06-nomina"); + corpus_test!(corpus_07_clasificar, "07-clasificar"); + + #[test] + fn empty_source_runs_clean() { + let outcome = run_source("").expect("pipeline OK"); + assert!(outcome.lines.is_empty()); + assert_eq!(outcome.halt, Halt::Normal); + } + + #[test] + fn stop_run_is_reported() { + let outcome = run_source("PROCEDURE DIVISION.\nMAIN.\n DISPLAY 'X'.\n STOP RUN.\n") + .expect("pipeline OK"); + assert_eq!(outcome.lines, vec!["X".to_string()]); + assert_eq!(outcome.halt, Halt::StopRun); + } + + #[test] + fn endless_loop_is_cut_by_the_step_limit() { + // `PERFORM UNTIL 1 = 0` nunca se cumple — el tope lo corta. + let outcome = run_source( + "PROCEDURE DIVISION.\n\ + MAIN.\n\ + PERFORM UNTIL 1 = 0\n\ + CONTINUE\n\ + END-PERFORM.\n", + ) + .expect("pipeline OK"); + assert_eq!(outcome.halt, Halt::StepLimit); + } + + #[test] + fn lex_error_surfaces() { + let err = run_source("PROCEDURE DIVISION.\nMAIN.\n DISPLAY 'sin cerrar.\n").unwrap_err(); + assert!(matches!(err, ShadowError::Lex(_))); + } +} diff --git a/crates/modules/charka/corpus/01-hola.cob b/crates/modules/charka/corpus/01-hola.cob new file mode 100644 index 0000000..81e5fd6 --- /dev/null +++ b/crates/modules/charka/corpus/01-hola.cob @@ -0,0 +1,7 @@ +* corpus charka — nivel 1: el programa mínimo +IDENTIFICATION DIVISION. +PROGRAM-ID. HOLA. +PROCEDURE DIVISION. +MAIN. + DISPLAY 'HOLA, MUNDO'. + STOP RUN. diff --git a/crates/modules/charka/corpus/01-hola.expected b/crates/modules/charka/corpus/01-hola.expected new file mode 100644 index 0000000..6740652 --- /dev/null +++ b/crates/modules/charka/corpus/01-hola.expected @@ -0,0 +1 @@ +HOLA, MUNDO diff --git a/crates/modules/charka/corpus/02-aritmetica.cob b/crates/modules/charka/corpus/02-aritmetica.cob new file mode 100644 index 0000000..310e2aa --- /dev/null +++ b/crates/modules/charka/corpus/02-aritmetica.cob @@ -0,0 +1,19 @@ +* corpus charka — nivel 2: datos y aritmética con GIVING +IDENTIFICATION DIVISION. +PROGRAM-ID. ARITMETICA. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-A PIC 9(4) VALUE 120. +01 WS-B PIC 9(4) VALUE 35. +01 WS-SUMA PIC 9(5). +01 WS-RESTA PIC 9(5). +01 WS-PROD PIC 9(8). +PROCEDURE DIVISION. +MAIN. + ADD WS-A WS-B GIVING WS-SUMA. + SUBTRACT WS-B FROM WS-A GIVING WS-RESTA. + MULTIPLY WS-A BY WS-B GIVING WS-PROD. + DISPLAY 'SUMA=' WS-SUMA. + DISPLAY 'RESTA=' WS-RESTA. + DISPLAY 'PRODUCTO=' WS-PROD. + STOP RUN. diff --git a/crates/modules/charka/corpus/02-aritmetica.expected b/crates/modules/charka/corpus/02-aritmetica.expected new file mode 100644 index 0000000..cf68b64 --- /dev/null +++ b/crates/modules/charka/corpus/02-aritmetica.expected @@ -0,0 +1,3 @@ +SUMA=00155 +RESTA=00085 +PRODUCTO=00004200 diff --git a/crates/modules/charka/corpus/03-condicional.cob b/crates/modules/charka/corpus/03-condicional.cob new file mode 100644 index 0000000..6b7922a --- /dev/null +++ b/crates/modules/charka/corpus/03-condicional.cob @@ -0,0 +1,17 @@ +* corpus charka — nivel 3: condicionales IF / ELSE +IDENTIFICATION DIVISION. +PROGRAM-ID. CONDICIONAL. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-NOTA PIC 9(3) VALUE 72. +PROCEDURE DIVISION. +MAIN. + IF WS-NOTA >= 60 + DISPLAY 'APROBADO' + ELSE + DISPLAY 'REPROBADO' + END-IF. + IF WS-NOTA > 90 + DISPLAY 'CON HONORES' + END-IF. + STOP RUN. diff --git a/crates/modules/charka/corpus/03-condicional.expected b/crates/modules/charka/corpus/03-condicional.expected new file mode 100644 index 0000000..9e2d08e --- /dev/null +++ b/crates/modules/charka/corpus/03-condicional.expected @@ -0,0 +1 @@ +APROBADO diff --git a/crates/modules/charka/corpus/04-bucle.cob b/crates/modules/charka/corpus/04-bucle.cob new file mode 100644 index 0000000..af90c02 --- /dev/null +++ b/crates/modules/charka/corpus/04-bucle.cob @@ -0,0 +1,15 @@ +* corpus charka — nivel 4: PERFORM n TIMES y un párrafo aparte +IDENTIFICATION DIVISION. +PROGRAM-ID. BUCLE. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-CONT PIC 9(3) VALUE 0. +01 WS-TOTAL PIC 9(5) VALUE 0. +PROCEDURE DIVISION. +MAIN. + PERFORM SUMAR 5 TIMES. + DISPLAY 'TOTAL=' WS-TOTAL. + STOP RUN. +SUMAR. + ADD 1 TO WS-CONT. + ADD WS-CONT TO WS-TOTAL. diff --git a/crates/modules/charka/corpus/04-bucle.expected b/crates/modules/charka/corpus/04-bucle.expected new file mode 100644 index 0000000..9047151 --- /dev/null +++ b/crates/modules/charka/corpus/04-bucle.expected @@ -0,0 +1 @@ +TOTAL=00015 diff --git a/crates/modules/charka/corpus/05-factorial.cob b/crates/modules/charka/corpus/05-factorial.cob new file mode 100644 index 0000000..ba42dbc --- /dev/null +++ b/crates/modules/charka/corpus/05-factorial.cob @@ -0,0 +1,16 @@ +* corpus charka — nivel 4: PERFORM UNTIL en línea (factorial) +IDENTIFICATION DIVISION. +PROGRAM-ID. FACTORIAL. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-N PIC 9(2) VALUE 6. +01 WS-I PIC 9(2) VALUE 1. +01 WS-FACT PIC 9(8) VALUE 1. +PROCEDURE DIVISION. +MAIN. + PERFORM UNTIL WS-I > WS-N + MULTIPLY WS-I BY WS-FACT + ADD 1 TO WS-I + END-PERFORM. + DISPLAY 'FACTORIAL DE 6 = ' WS-FACT. + STOP RUN. diff --git a/crates/modules/charka/corpus/05-factorial.expected b/crates/modules/charka/corpus/05-factorial.expected new file mode 100644 index 0000000..140ff08 --- /dev/null +++ b/crates/modules/charka/corpus/05-factorial.expected @@ -0,0 +1 @@ +FACTORIAL DE 6 = 00000720 diff --git a/crates/modules/charka/corpus/06-nomina.cob b/crates/modules/charka/corpus/06-nomina.cob new file mode 100644 index 0000000..6c0b3a0 --- /dev/null +++ b/crates/modules/charka/corpus/06-nomina.cob @@ -0,0 +1,32 @@ +* corpus charka — nivel 5: grupos, COMPUTE con paréntesis, ROUNDED +IDENTIFICATION DIVISION. +PROGRAM-ID. NOMINA. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-EMPLEADO. + 05 WS-NOMBRE PIC X(10) VALUE 'ANA'. + 05 WS-HORAS PIC 9(3) VALUE 45. + 05 WS-TARIFA PIC 9(3)V99 VALUE 8.50. +01 WS-BRUTO PIC 9(6)V99 VALUE 0. +01 WS-EXTRA PIC 9(6)V99 VALUE 0. +01 WS-IMPUESTO PIC 9(6)V99 VALUE 0. +01 WS-NETO PIC 9(6)V99 VALUE 0. +PROCEDURE DIVISION. +MAIN-PARA. + PERFORM CALCULAR-BRUTO. + PERFORM CALCULAR-IMPUESTO. + COMPUTE WS-NETO = WS-BRUTO - WS-IMPUESTO. + DISPLAY 'EMPLEADO: ' WS-NOMBRE. + DISPLAY 'BRUTO: ' WS-BRUTO. + DISPLAY 'IMPUESTO: ' WS-IMPUESTO. + DISPLAY 'NETO: ' WS-NETO. + STOP RUN. +CALCULAR-BRUTO. + IF WS-HORAS > 40 + COMPUTE WS-EXTRA = (WS-HORAS - 40) * WS-TARIFA + COMPUTE WS-BRUTO = 40 * WS-TARIFA + WS-EXTRA + ELSE + COMPUTE WS-BRUTO = WS-HORAS * WS-TARIFA + END-IF. +CALCULAR-IMPUESTO. + COMPUTE WS-IMPUESTO ROUNDED = WS-BRUTO * 0.15. diff --git a/crates/modules/charka/corpus/06-nomina.expected b/crates/modules/charka/corpus/06-nomina.expected new file mode 100644 index 0000000..cb84fda --- /dev/null +++ b/crates/modules/charka/corpus/06-nomina.expected @@ -0,0 +1,4 @@ +EMPLEADO: ANA +BRUTO: 00038250 +IMPUESTO: 00005738 +NETO: 00032512 diff --git a/crates/modules/charka/corpus/07-clasificar.cob b/crates/modules/charka/corpus/07-clasificar.cob new file mode 100644 index 0000000..4100512 --- /dev/null +++ b/crates/modules/charka/corpus/07-clasificar.cob @@ -0,0 +1,23 @@ +* corpus charka — nivel 5: IF anidado y condiciones con AND +IDENTIFICATION DIVISION. +PROGRAM-ID. CLASIFICAR. +DATA DIVISION. +WORKING-STORAGE SECTION. +01 WS-EDAD PIC 9(3) VALUE 0. +01 WS-INDICE PIC 9(2) VALUE 1. +PROCEDURE DIVISION. +MAIN-PARA. + PERFORM CLASIFICAR-PARA 4 TIMES. + STOP RUN. +CLASIFICAR-PARA. + COMPUTE WS-EDAD = WS-INDICE * 25. + IF WS-EDAD < 18 + DISPLAY 'MENOR DE EDAD' + ELSE + IF WS-EDAD >= 18 AND WS-EDAD < 65 + DISPLAY 'ADULTO' + ELSE + DISPLAY 'ADULTO MAYOR' + END-IF + END-IF. + ADD 1 TO WS-INDICE. diff --git a/crates/modules/charka/corpus/07-clasificar.expected b/crates/modules/charka/corpus/07-clasificar.expected new file mode 100644 index 0000000..dd66d92 --- /dev/null +++ b/crates/modules/charka/corpus/07-clasificar.expected @@ -0,0 +1,4 @@ +ADULTO +ADULTO +ADULTO MAYOR +ADULTO MAYOR diff --git a/crates/modules/charka/corpus/README.md b/crates/modules/charka/corpus/README.md new file mode 100644 index 0000000..27911b0 --- /dev/null +++ b/crates/modules/charka/corpus/README.md @@ -0,0 +1,32 @@ +# Corpus COBOL de charka + +Programas COBOL de prueba, de complejidad **graduada**, para ejercitar +el pipeline completo del transpilador (lexer → parser → IR → codegen) y +el validador en sombra `charka-shadow`. + +Cada programa `NN-nombre.cob` viene con su `NN-nombre.expected`: la +salida correcta, una línea por `DISPLAY`. + +| programa | nivel | qué ejercita | +| ------------------- | ----- | -------------------------------------------------- | +| `01-hola` | 1 | el programa mínimo — un `DISPLAY` de literal | +| `02-aritmetica` | 2 | datos, `ADD`/`SUBTRACT`/`MULTIPLY` con `GIVING` | +| `03-condicional` | 3 | `IF` / `ELSE` / `END-IF` | +| `04-bucle` | 4 | `PERFORM n TIMES`, un párrafo aparte, `ADD` in situ| +| `05-factorial` | 4 | `PERFORM UNTIL` en línea, `MULTIPLY` in situ | +| `06-nomina` | 5 | grupos, `COMPUTE` con paréntesis, `ROUNDED`, V99 | +| `07-clasificar` | 5 | `IF` anidado, condiciones con `AND` | + +## Formato + +Los fuentes están en **formato libre** de COBOL (la línea entera es +código; `*` al inicio es comentario). El comparador del validador +**ignora los espacios finales** de cada línea — un campo `PIC X(n)` se +muestra con su relleno de espacios, que aquí se omite por legibilidad. + +## La salida esperada + +Las `.expected` se derivaron a mano de la semántica de COBOL'85. Cuando +GnuCOBOL esté disponible, `charka-shadow` podrá regenerarlas desde el +compilador de referencia y diferenciar contra él — el modo «sombra» +pleno (original vs transpilado). diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md index 457aacb..a640410 100644 --- a/docs/changelog/charka.md +++ b/docs/changelog/charka.md @@ -3,6 +3,31 @@ Transpilador COBOL → Rust. El módulo más grande del ecosistema (Fase D del plan macro) — el parser COBOL completo es un esfuerzo multi-mes. +### feat(charka-shadow): validador en sombra + corpus COBOL + +Crate nuevo `crates/modules/charka/charka-shadow` y un corpus de prueba +`crates/modules/charka/corpus/` — el pipeline COBOL→Rust queda +completo y validado de punta a punta. + +- `charka-shadow` certifica que el transpilador preserva la semántica + del COBOL original con una **ejecución sombra**: un intérprete que + corre el `Ir` directamente sobre `charka-runtime`, sin compilar nada. + Es una segunda ruta de ejecución, independiente del código que emite + `charka-codegen`. +- `interpret(&Ir) -> Outcome` ejecuta el IR y captura las líneas de + `DISPLAY`; `run_source(&str)` corre el pipeline completo (lexer → + parser → IR → intérprete). +- Tope de pasos (`Halt::StepLimit`): un bucle que no termina se corta + en vez de colgar la ejecución. +- **Corpus**: 7 programas COBOL de complejidad graduada — `01-hola` + (un `DISPLAY`), `02-aritmetica` (`ADD`/`SUBTRACT`/`MULTIPLY`), + `03-condicional` (`IF`/`ELSE`), `04-bucle` (`PERFORM TIMES`), + `05-factorial` (`PERFORM UNTIL`), `06-nomina` (grupos, `COMPUTE` con + paréntesis, `ROUNDED`, `V99`), `07-clasificar` (`IF` anidado, `AND`). + Cada uno con su `.expected` verificada a mano. +- 11 tests: los 7 programas del corpus + fuente vacío, `STOP RUN`, + corte por tope de pasos y propagación de error de léxico. + ### feat(charka-codegen): emisión de Rust desde el IR Crate nuevo `crates/modules/charka/charka-codegen` — la etapa final del