diff --git a/Cargo.lock b/Cargo.lock
index b7b8723..7d6017e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2315,6 +2315,16 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "charka-codegen"
+version = "0.1.0"
+dependencies = [
+ "charka-bcd",
+ "charka-ir",
+ "charka-lexer",
+ "charka-parser",
+]
+
[[package]]
name = "charka-ir"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 5c76c2f..8385186 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -161,6 +161,7 @@ members = [
"crates/modules/charka/charka-parser",
"crates/modules/charka/charka-ir",
"crates/modules/charka/charka-runtime",
+ "crates/modules/charka/charka-codegen",
# ============================================================
# modules/mirada/ — Compositor Wayland
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
index f83fa41..9f661fe 100644
--- a/crates/modules/charka/SDD.md
+++ b/crates/modules/charka/SDD.md
@@ -15,6 +15,7 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes.
| `charka-parser` | lib | Parser COBOL'85 (subconjunto): tokens → AST (`Program`) |
| `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-bcd
@@ -113,16 +114,37 @@ los listaba al revés): el codegen emite llamadas contra esta API, así
que el runtime debe existir primero — y es un crate autocontenido,
verificable sin depender del código emitido.
+## charka-codegen
+
+La etapa final: `generate(&Ir) -> String` produce un fuente Rust (un
+`main.rs`) que, compilado contra `charka-runtime`, ejecuta la lógica
+del programa COBOL.
+
+- Un `struct Program` con un campo por cada dato elemental (`Num` /
+ `Text`); `Program::new()` lo inicializa desde las cláusulas `VALUE`.
+- Un método `p_
(&mut self)` por párrafo; `run()` los encadena
+ en orden (el «caer» de COBOL); `main()` construye y corre.
+- Cada `Stmt` → líneas Rust: `MOVE`→`.store`, `DISPLAY`→`println!`,
+ `COMPUTE`/aritmética → expresiones `Decimal`, `IF`→`if`, `PERFORM`→
+ llamada / `for` / `while`, `STOP RUN`→`process::exit`.
+- **Tolerante**: lo no transpilable (`Stmt::Unknown`, dato sin
+ resolver, `**`) se emite como comentario `// charka:` — el código
+ generado siempre compila.
+- Verificado de punta a punta: un programa COBOL de demostración
+ transpila a Rust que compila contra `charka-runtime` y produce la
+ salida correcta.
+- Fuera de alcance v1: grupos como campo propio, `REDEFINES`,
+ `OCCURS`/tablas, `PERFORM ... THRU` como rango, E/S de ficheros.
+
## Estado
`charka-bcd` (22 tests), `charka-lexer` (17 tests), `charka-parser`
-(15 tests), `charka-ir` (17 tests) y `charka-runtime` (17 tests)
-implementados y verdes. **Pendiente** — el resto del transpilador
-(Fase D del plan macro):
+(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:
| crate pendiente | rol |
| ----------------- | ---------------------------------------------------- |
-| `charka-codegen` | emisión de Rust (IR → fuente Rust sobre el runtime) |
| `charka-shadow` | validador en sombra (original vs transpilado) |
Hito intermedio sugerido: subconjunto COBOL'85 puro antes de CICS/SQL.
diff --git a/crates/modules/charka/charka-codegen/Cargo.toml b/crates/modules/charka/charka-codegen/Cargo.toml
new file mode 100644
index 0000000..53c919b
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "charka-codegen"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "charka-codegen — emisión de Rust desde el IR de charka: el modelo de datos COBOL y los statements del PROCEDURE a código que enlaza con charka-runtime."
+
+[dependencies]
+charka-ir = { path = "../charka-ir" }
+charka-bcd = { path = "../charka-bcd" }
+
+[dev-dependencies]
+charka-parser = { path = "../charka-parser" }
+charka-lexer = { path = "../charka-lexer" }
diff --git a/crates/modules/charka/charka-codegen/src/emit.rs b/crates/modules/charka/charka-codegen/src/emit.rs
new file mode 100644
index 0000000..2e9032a
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/emit.rs
@@ -0,0 +1,45 @@
+//! `Emitter` — un acumulador de código Rust con control de sangría.
+
+/// Acumula líneas de código Rust generado, llevando la sangría actual.
+pub(crate) struct Emitter {
+ out: String,
+ depth: usize,
+}
+
+impl Emitter {
+ pub(crate) fn new() -> Self {
+ Self {
+ out: String::new(),
+ depth: 0,
+ }
+ }
+
+ /// Escribe una línea con la sangría actual.
+ pub(crate) fn line(&mut self, s: &str) {
+ for _ in 0..self.depth {
+ self.out.push_str(" ");
+ }
+ self.out.push_str(s);
+ self.out.push('\n');
+ }
+
+ /// Una línea en blanco.
+ pub(crate) fn blank(&mut self) {
+ self.out.push('\n');
+ }
+
+ /// Aumenta un nivel de sangría.
+ pub(crate) fn indent(&mut self) {
+ self.depth += 1;
+ }
+
+ /// Reduce un nivel de sangría.
+ pub(crate) fn dedent(&mut self) {
+ self.depth = self.depth.saturating_sub(1);
+ }
+
+ /// Entrega el código acumulado.
+ pub(crate) fn finish(self) -> String {
+ self.out
+ }
+}
diff --git a/crates/modules/charka/charka-codegen/src/expr.rs b/crates/modules/charka/charka-codegen/src/expr.rs
new file mode 100644
index 0000000..4d99c83
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/expr.rs
@@ -0,0 +1,170 @@
+//! Emisión de expresiones y condiciones: cada nodo del IR se convierte
+//! en un fragmento de código Rust (un `String`).
+
+use charka_ir::{BinOp, CmpOp, Cond, Expr, Figurative, Operand};
+
+use crate::sym::{FieldKind, Symbols};
+
+/// Un literal de texto Rust con las comillas y los escapes adecuados.
+pub(crate) fn rust_str(s: &str) -> String {
+ let mut out = String::from("\"");
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ _ => out.push(c),
+ }
+ }
+ out.push('"');
+ out
+}
+
+/// El texto que representa una constante figurativa.
+pub(crate) 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`.
+pub(crate) fn figurative_fill(f: Figurative) -> char {
+ match f {
+ Figurative::Zero => '0',
+ Figurative::Quote => '"',
+ _ => ' ',
+ }
+}
+
+/// Un operando como expresión de tipo `Decimal`.
+pub(crate) fn operand_decimal(sym: &Symbols, op: &Operand) -> String {
+ match op {
+ Operand::Num(n) => format!("dec({})", rust_str(n)),
+ Operand::Str(s) => format!(
+ "Decimal::parse({}).unwrap_or_else(|_| Decimal::zero())",
+ rust_str(s)
+ ),
+ Operand::Figurative(_) => "Decimal::zero()".to_string(),
+ Operand::Data(name) => match sym.lookup(name) {
+ Some(f) => match f.kind {
+ FieldKind::Num { .. } => format!("self.{}.value()", f.ident),
+ FieldKind::Text { .. } => format!(
+ "Decimal::parse(self.{}.display().trim()).unwrap_or_else(|_| Decimal::zero())",
+ f.ident
+ ),
+ },
+ None => format!("Decimal::zero() /* charka: dato no resuelto {name} */"),
+ },
+ }
+}
+
+/// Un operando como expresión de tipo `&str` (para texto).
+pub(crate) fn operand_str(sym: &Symbols, op: &Operand) -> String {
+ match op {
+ Operand::Str(s) => rust_str(s),
+ Operand::Num(n) => rust_str(n),
+ Operand::Figurative(f) => rust_str(figurative_text(*f)),
+ Operand::Data(name) => match sym.lookup(name) {
+ Some(f) => format!("self.{}.display().as_str()", f.ident),
+ None => format!("\"\" /* charka: dato no resuelto {name} */"),
+ },
+ }
+}
+
+/// Un operando como expresión que implementa `Display` (para `DISPLAY`).
+pub(crate) fn operand_display(sym: &Symbols, op: &Operand) -> String {
+ match op {
+ Operand::Str(s) => rust_str(s),
+ Operand::Num(n) => rust_str(n),
+ Operand::Figurative(f) => rust_str(figurative_text(*f)),
+ Operand::Data(name) => match sym.lookup(name) {
+ Some(f) => format!("self.{}.display()", f.ident),
+ None => format!("\"\" /* charka: dato no resuelto {name} */"),
+ },
+ }
+}
+
+/// Emite una expresión aritmética como código Rust de tipo `Decimal`.
+pub(crate) fn emit_expr(sym: &Symbols, e: &Expr) -> String {
+ match e {
+ Expr::Operand(op) => operand_decimal(sym, op),
+ Expr::Neg(inner) => format!("Decimal::zero().sub(&({}))", emit_expr(sym, inner)),
+ Expr::Binary { op, lhs, rhs } => {
+ let l = emit_expr(sym, lhs);
+ let r = emit_expr(sym, rhs);
+ match op {
+ BinOp::Add => format!("({l}).add(&({r}))"),
+ BinOp::Sub => format!("({l}).sub(&({r}))"),
+ BinOp::Mul => format!("({l}).mul(&({r}))"),
+ BinOp::Div => format!(
+ "({l}).div(&({r}), 9, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())"
+ ),
+ BinOp::Pow => "Decimal::zero() /* charka: ** no soportado */".to_string(),
+ }
+ }
+ }
+}
+
+/// Emite una condición como código Rust de tipo `bool`.
+pub(crate) fn emit_cond(sym: &Symbols, c: &Cond) -> String {
+ match c {
+ Cond::Compare { lhs, op, rhs } => emit_compare(sym, lhs, *op, rhs),
+ Cond::Named(name) => {
+ format!("false /* charka: condición 88 no soportada: {name} */")
+ }
+ Cond::Not(inner) => format!("!({})", emit_cond(sym, inner)),
+ Cond::And(a, b) => format!("({}) && ({})", emit_cond(sym, a), emit_cond(sym, b)),
+ Cond::Or(a, b) => format!("({}) || ({})", emit_cond(sym, a), emit_cond(sym, b)),
+ }
+}
+
+/// Emite una comparación: numérica si ambos lados lo son, alfanumérica
+/// si alguno es texto.
+fn emit_compare(sym: &Symbols, lhs: &Operand, op: CmpOp, rhs: &Operand) -> String {
+ if is_text_operand(sym, lhs) || is_text_operand(sym, rhs) {
+ let method = match op {
+ CmpOp::Eq => "is_eq",
+ CmpOp::Ne => "is_ne",
+ CmpOp::Lt => "is_lt",
+ CmpOp::Gt => "is_gt",
+ CmpOp::Le => "is_le",
+ CmpOp::Ge => "is_ge",
+ };
+ format!(
+ "cobol_text_cmp({}, {}).{method}()",
+ operand_str(sym, lhs),
+ operand_str(sym, rhs)
+ )
+ } else {
+ let rust_op = match op {
+ CmpOp::Eq => "==",
+ CmpOp::Ne => "!=",
+ CmpOp::Lt => "<",
+ CmpOp::Gt => ">",
+ CmpOp::Le => "<=",
+ CmpOp::Ge => ">=",
+ };
+ format!(
+ "({}) {rust_op} ({})",
+ operand_decimal(sym, lhs),
+ operand_decimal(sym, rhs)
+ )
+ }
+}
+
+/// ¿El operando es alfanumérico?
+fn is_text_operand(sym: &Symbols, op: &Operand) -> bool {
+ match op {
+ Operand::Str(_) => true,
+ Operand::Data(name) => matches!(
+ sym.lookup(name).map(|f| &f.kind),
+ Some(FieldKind::Text { .. })
+ ),
+ _ => false,
+ }
+}
diff --git a/crates/modules/charka/charka-codegen/src/lib.rs b/crates/modules/charka/charka-codegen/src/lib.rs
new file mode 100644
index 0000000..e7a151c
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/lib.rs
@@ -0,0 +1,344 @@
+//! `charka-codegen` — emisión de Rust desde el IR del transpilador.
+//!
+//! Etapa final del pipeline COBOL→Rust: toma el [`Ir`] de `charka-ir`
+//! y produce un fuente Rust (un `String`) que, compilado contra
+//! `charka-runtime`, ejecuta la lógica del programa COBOL original.
+//!
+//! La forma del código emitido:
+//!
+//! - Un `struct Program` con un campo por cada dato elemental — `Num`
+//! para los numéricos, `Text` para los alfanuméricos.
+//! - `Program::new()` inicializa los campos desde sus cláusulas `VALUE`.
+//! - Un método `p_
(&mut self)` por cada párrafo del PROCEDURE.
+//! - `run()` los encadena en orden (el «caer» de COBOL); `main()`
+//! construye el `Program` y lo corre.
+//!
+//! Es **tolerante**: lo que no sabe transpilar (un `Stmt::Unknown`, un
+//! dato sin resolver, `**`) se emite como un comentario `// charka:` —
+//! el código generado siempre compila.
+//!
+//! Alcance v1 — fuera: grupos como campo propio, `REDEFINES`,
+//! `OCCURS`/tablas, `PERFORM ... THRU` como rango, E/S de ficheros,
+//! `EVALUATE`, CICS y SQL embebido.
+
+#![forbid(unsafe_code)]
+
+mod emit;
+mod expr;
+mod stmt;
+mod sym;
+
+use std::collections::HashMap;
+
+use charka_ir::{Ir, Procedure};
+
+use emit::Emitter;
+use expr::rust_str;
+use stmt::emit_stmt;
+use sym::{paragraph_method, Field, FieldKind, Symbols};
+
+/// Transpila un [`Ir`] a un fuente Rust completo (un `main.rs`).
+pub fn generate(ir: &Ir) -> String {
+ let sym = Symbols::build(&ir.data);
+ let mut em = Emitter::new();
+ emit_header(&mut em);
+ emit_struct(&mut em, &sym);
+ emit_impl(&mut em, &sym, ir);
+ emit_main(&mut em);
+ em.finish()
+}
+
+/// El preámbulo: doc, `allow`s, el `use` del runtime y el helper `dec`.
+fn emit_header(em: &mut Emitter) {
+ em.line("//! Generado por charka — transpilador COBOL → Rust.");
+ em.line("//! No editar a mano: regenerar desde el fuente COBOL.");
+ em.blank();
+ em.line(
+ "#![allow(dead_code, unused_mut, unused_variables, unused_parens, \
+unreachable_code, clippy::all)]",
+ );
+ em.blank();
+ em.line("use charka_runtime::*;");
+ em.blank();
+ em.line("/// Construye un `Decimal` desde un literal numérico COBOL.");
+ em.line("fn dec(s: &str) -> Decimal {");
+ em.line(" Decimal::parse(s).expect(\"charka: literal numérico inválido\")");
+ em.line("}");
+ em.blank();
+}
+
+/// El `struct Program` con un campo por dato elemental.
+fn emit_struct(em: &mut Emitter, sym: &Symbols) {
+ em.line("/// El estado del programa: un campo por cada dato elemental.");
+ em.line("struct Program {");
+ em.indent();
+ for f in &sym.fields {
+ let ty = match f.kind {
+ FieldKind::Num { .. } => "Num",
+ FieldKind::Text { .. } => "Text",
+ };
+ em.line(&format!("{}: {ty},", f.ident));
+ }
+ em.dedent();
+ em.line("}");
+ em.blank();
+}
+
+/// El bloque `impl Program`: `new`, los párrafos y `run`.
+fn emit_impl(em: &mut Emitter, sym: &Symbols, ir: &Ir) {
+ em.line("impl Program {");
+ em.indent();
+
+ // new()
+ em.line("fn new() -> Self {");
+ em.indent();
+ em.line("Self {");
+ em.indent();
+ for f in &sym.fields {
+ em.line(&format!("{}: {},", f.ident, field_init(f)));
+ }
+ em.dedent();
+ em.line("}");
+ em.dedent();
+ em.line("}");
+ em.blank();
+
+ // Un método por párrafo.
+ let methods = paragraph_methods(ir);
+ for (name, proc) in &methods {
+ em.line(&format!("fn {name}(&mut self) {{"));
+ em.indent();
+ for s in &proc.body {
+ emit_stmt(em, sym, s);
+ }
+ em.dedent();
+ em.line("}");
+ em.blank();
+ }
+
+ // run() — encadena los párrafos en orden.
+ em.line("fn run(&mut self) {");
+ em.indent();
+ if methods.is_empty() {
+ em.line("// programa sin PROCEDURE division");
+ }
+ for (name, _) in &methods {
+ em.line(&format!("self.{name}();"));
+ }
+ em.dedent();
+ em.line("}");
+
+ em.dedent();
+ em.line("}");
+ em.blank();
+}
+
+/// El `fn main`.
+fn emit_main(em: &mut Emitter) {
+ em.line("fn main() {");
+ em.indent();
+ em.line("Program::new().run();");
+ em.dedent();
+ em.line("}");
+}
+
+/// El inicializador de un campo, a partir de su cláusula `VALUE`.
+fn field_init(f: &Field) -> String {
+ match &f.kind {
+ FieldKind::Num { int, frac, signed } => format!(
+ "Num::with_value(Picture::new({int}, {frac}, {signed}), {})",
+ rust_str(&numeric_value(f.value.as_deref()))
+ ),
+ FieldKind::Text { len } => format!(
+ "Text::with_value({len}, {})",
+ rust_str(&text_value(f.value.as_deref()))
+ ),
+ }
+}
+
+/// 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();
+ };
+ let up = raw.to_uppercase();
+ if matches!(up.as_str(), "ZERO" | "ZEROS" | "ZEROES") {
+ return "0".to_string();
+ }
+ if charka_bcd::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()
+ }
+}
+
+/// Asigna a cada párrafo un nombre de método único.
+fn paragraph_methods(ir: &Ir) -> Vec<(String, &Procedure)> {
+ let mut seen: HashMap = HashMap::new();
+ let mut out = Vec::new();
+ for proc in &ir.procedures {
+ let base = paragraph_method(&proc.name);
+ let n = seen.entry(base.clone()).or_insert(0);
+ let name = if *n > 0 { format!("{base}_{n}") } else { base };
+ *n += 1;
+ out.push((name, proc));
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Helper: lexa, parsea, baja a IR y transpila un fuente COBOL.
+ fn gen(src: &str) -> String {
+ let toks = charka_lexer::lex(src, charka_lexer::SourceFormat::Free).expect("lex");
+ let prog = charka_parser::parse(&toks).expect("parse");
+ let ir = charka_ir::lower(&prog);
+ generate(&ir)
+ }
+
+ /// Un programa COBOL de demostración, razonablemente completo.
+ const DEMO: &str = "IDENTIFICATION DIVISION.\n\
+ PROGRAM-ID. DEMO.\n\
+ DATA DIVISION.\n\
+ WORKING-STORAGE SECTION.\n\
+ 01 WS-A PIC 9(3) VALUE 10.\n\
+ 01 WS-B PIC 9(3).\n\
+ 01 WS-NAME PIC X(8) VALUE 'BOB'.\n\
+ PROCEDURE DIVISION.\n\
+ MAIN-PARA.\n\
+ MOVE 5 TO WS-B.\n\
+ COMPUTE WS-B = WS-A + WS-B.\n\
+ DISPLAY 'B=' WS-B.\n\
+ IF WS-B > 0 DISPLAY 'POS' END-IF.\n\
+ PERFORM SUB-PARA.\n\
+ STOP RUN.\n\
+ SUB-PARA.\n\
+ DISPLAY WS-NAME.\n";
+
+ #[test]
+ fn header_and_main_are_emitted() {
+ let out = gen(DEMO);
+ assert!(out.contains("use charka_runtime::*;"));
+ assert!(out.contains("fn dec(s: &str) -> Decimal {"));
+ assert!(out.contains("fn main() {"));
+ assert!(out.contains("Program::new().run();"));
+ }
+
+ #[test]
+ fn numeric_field_becomes_num() {
+ let out = gen(DEMO);
+ assert!(out.contains("ws_a: Num,"));
+ assert!(out.contains("Num::with_value(Picture::new(3, 0, false), \"10\")"));
+ }
+
+ #[test]
+ fn alphanumeric_field_becomes_text() {
+ let out = gen(DEMO);
+ assert!(out.contains("ws_name: Text,"));
+ assert!(out.contains("Text::with_value(8, \"BOB\")"));
+ }
+
+ #[test]
+ fn move_emits_a_store() {
+ assert!(gen(DEMO).contains("self.ws_b.store(dec(\"5\"));"));
+ }
+
+ #[test]
+ fn compute_emits_the_expression() {
+ let out = gen(DEMO);
+ assert!(out.contains("self.ws_b.store((self.ws_a.value()).add(&(self.ws_b.value())));"));
+ }
+
+ #[test]
+ fn display_emits_a_println() {
+ let out = gen(DEMO);
+ assert!(out.contains("println!(\"{}{}\", \"B=\", self.ws_b.display());"));
+ }
+
+ #[test]
+ fn if_emits_a_rust_if() {
+ let out = gen(DEMO);
+ assert!(out.contains("if (self.ws_b.value()) > (dec(\"0\")) {"));
+ }
+
+ #[test]
+ fn paragraphs_become_methods_and_run_chains_them() {
+ let out = gen(DEMO);
+ assert!(out.contains("fn p_main_para(&mut self) {"));
+ assert!(out.contains("fn p_sub_para(&mut self) {"));
+ assert!(out.contains("fn run(&mut self) {"));
+ assert!(out.contains("self.p_main_para();"));
+ assert!(out.contains("self.p_sub_para();"));
+ }
+
+ #[test]
+ fn perform_calls_the_paragraph_method() {
+ assert!(gen(DEMO).contains("self.p_sub_para();"));
+ }
+
+ #[test]
+ fn stop_run_exits() {
+ assert!(gen(DEMO).contains("std::process::exit(0);"));
+ }
+
+ #[test]
+ fn unknown_verb_becomes_a_comment() {
+ let out = gen("PROCEDURE DIVISION.\n\
+ MAIN.\n\
+ INSPECT WS-X TALLYING WS-N FOR ALL ' '.\n");
+ assert!(out.contains("// charka: verbo no transpilado — INSPECT"));
+ }
+
+ #[test]
+ fn add_giving_emits_a_sum() {
+ let out = gen("DATA DIVISION.\n\
+ WORKING-STORAGE SECTION.\n\
+ 01 A PIC 9(3).\n\
+ 01 B PIC 9(3).\n\
+ 01 C PIC 9(3).\n\
+ PROCEDURE DIVISION.\n\
+ MAIN.\n\
+ ADD A B GIVING C.\n");
+ assert!(out.contains("self.c.store((self.a.value()).add(&(self.b.value())));"));
+ }
+
+ #[test]
+ fn perform_times_emits_a_loop() {
+ let out = gen("PROCEDURE DIVISION.\n\
+ MAIN.\n\
+ PERFORM SUB 3 TIMES.\n\
+ SUB.\n\
+ CONTINUE.\n");
+ assert!(out.contains("for _ in 0..3usize {"));
+ }
+
+ #[test]
+ fn empty_program_still_compiles_shape() {
+ let out = gen("");
+ assert!(out.contains("struct Program {"));
+ assert!(out.contains("fn main() {"));
+ assert!(out.contains("fn run(&mut self) {"));
+ }
+}
diff --git a/crates/modules/charka/charka-codegen/src/stmt.rs b/crates/modules/charka/charka-codegen/src/stmt.rs
new file mode 100644
index 0000000..13d6803
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/stmt.rs
@@ -0,0 +1,364 @@
+//! Emisión de los statements del PROCEDURE: cada [`Stmt`] se traduce a
+//! una o varias líneas de código Rust sobre `charka-runtime`.
+
+use charka_ir::{Operand, Perform, PerformControl, PerformTarget, Stmt};
+
+use crate::emit::Emitter;
+use crate::expr::{
+ emit_cond, emit_expr, figurative_fill, operand_decimal, operand_display, operand_str,
+};
+use crate::sym::{paragraph_method, FieldKind, Symbols};
+
+/// Emite un statement.
+pub(crate) fn emit_stmt(em: &mut Emitter, sym: &Symbols, stmt: &Stmt) {
+ match stmt {
+ Stmt::Move { from, to } => emit_move(em, sym, from, to),
+ Stmt::Display { items } => emit_display(em, sym, items),
+ Stmt::Accept { into } => {
+ em.line(&format!(
+ "// charka: ACCEPT {into} — entrada interactiva no soportada en v1"
+ ));
+ }
+ Stmt::Compute {
+ targets,
+ rounded,
+ expr,
+ } => {
+ let value = emit_expr(sym, expr);
+ for t in targets {
+ emit_store(em, sym, t, &value, *rounded);
+ }
+ }
+ Stmt::Add {
+ addends,
+ to,
+ giving,
+ rounded,
+ } => emit_add(em, sym, addends, to, giving, *rounded),
+ Stmt::Subtract {
+ amounts,
+ from,
+ giving,
+ rounded,
+ } => emit_subtract(em, sym, amounts, from, giving, *rounded),
+ Stmt::Multiply {
+ left,
+ by,
+ giving,
+ rounded,
+ } => emit_multiply(em, sym, left, by, giving, *rounded),
+ Stmt::Divide {
+ left,
+ right,
+ by_form,
+ giving,
+ rounded,
+ } => emit_divide(em, sym, left, right, *by_form, giving, *rounded),
+ Stmt::If {
+ cond,
+ then_branch,
+ else_branch,
+ } => {
+ em.line(&format!("if {} {{", emit_cond(sym, cond)));
+ em.indent();
+ emit_block(em, sym, then_branch);
+ em.dedent();
+ if else_branch.is_empty() {
+ em.line("}");
+ } else {
+ em.line("} else {");
+ em.indent();
+ emit_block(em, sym, else_branch);
+ em.dedent();
+ em.line("}");
+ }
+ }
+ Stmt::Perform(p) => emit_perform(em, sym, p),
+ Stmt::GoTo { target } => {
+ em.line(&format!(
+ "self.{}(); return; // charka: GO TO (aproximado)",
+ paragraph_method(target)
+ ));
+ }
+ Stmt::StopRun | Stmt::Goback => em.line("std::process::exit(0);"),
+ Stmt::Exit => em.line("return;"),
+ Stmt::Continue => em.line("// CONTINUE"),
+ Stmt::Unknown { verb, .. } => {
+ em.line(&format!("// charka: verbo no transpilado — {verb}"));
+ }
+ }
+}
+
+/// Emite una secuencia de statements (un cuerpo de bloque).
+fn emit_block(em: &mut Emitter, sym: &Symbols, stmts: &[Stmt]) {
+ for s in stmts {
+ emit_stmt(em, sym, s);
+ }
+}
+
+/// Almacena un valor `Decimal` (texto de expresión) en un campo.
+fn emit_store(em: &mut Emitter, sym: &Symbols, name: &str, value: &str, rounded: bool) {
+ match sym.lookup(name) {
+ Some(f) => match f.kind {
+ FieldKind::Num { .. } => {
+ let method = if rounded { "store_rounded" } else { "store" };
+ em.line(&format!("self.{}.{method}({value});", f.ident));
+ }
+ FieldKind::Text { .. } => {
+ em.line(&format!(
+ "self.{}.store(({value}).to_string().as_str());",
+ f.ident
+ ));
+ }
+ },
+ None => em.line(&format!("// charka: destino no resuelto — {name}")),
+ }
+}
+
+fn emit_move(em: &mut Emitter, sym: &Symbols, from: &Operand, to: &[String]) {
+ for t in to {
+ match sym.lookup(t) {
+ Some(f) => match f.kind {
+ FieldKind::Num { .. } => {
+ em.line(&format!(
+ "self.{}.store({});",
+ f.ident,
+ operand_decimal(sym, from)
+ ));
+ }
+ FieldKind::Text { .. } => {
+ if let Operand::Figurative(fig) = from {
+ em.line(&format!(
+ "self.{}.fill('{}');",
+ f.ident,
+ figurative_fill(*fig)
+ ));
+ } else {
+ em.line(&format!(
+ "self.{}.store({});",
+ f.ident,
+ operand_str(sym, from)
+ ));
+ }
+ }
+ },
+ None => em.line(&format!("// charka: destino MOVE no resuelto — {t}")),
+ }
+ }
+}
+
+fn emit_display(em: &mut Emitter, sym: &Symbols, items: &[Operand]) {
+ if items.is_empty() {
+ em.line("println!();");
+ return;
+ }
+ let placeholders = "{}".repeat(items.len());
+ let args: Vec = items.iter().map(|o| operand_display(sym, o)).collect();
+ em.line(&format!(
+ "println!(\"{placeholders}\", {});",
+ args.join(", ")
+ ));
+}
+
+/// La suma de una lista de operandos, encadenando `.add`.
+fn fold_sum(sym: &Symbols, ops: &[Operand]) -> String {
+ let mut it = ops.iter();
+ let Some(first) = it.next() else {
+ return "Decimal::zero()".to_string();
+ };
+ let mut acc = operand_decimal(sym, first);
+ for o in it {
+ acc = format!("({acc}).add(&({}))", operand_decimal(sym, o));
+ }
+ acc
+}
+
+fn emit_add(
+ em: &mut Emitter,
+ sym: &Symbols,
+ addends: &[Operand],
+ to: &[String],
+ giving: &[String],
+ rounded: bool,
+) {
+ let sum = fold_sum(sym, addends);
+ if !giving.is_empty() {
+ let base = match to.first() {
+ Some(first) => format!(
+ "({sum}).add(&({}))",
+ operand_decimal(sym, &Operand::Data(first.clone()))
+ ),
+ None => sum,
+ };
+ for g in giving {
+ emit_store(em, sym, g, &base, rounded);
+ }
+ } else {
+ for t in to {
+ emit_inplace(em, sym, t, "add", &sum, rounded);
+ }
+ }
+}
+
+fn emit_subtract(
+ em: &mut Emitter,
+ sym: &Symbols,
+ amounts: &[Operand],
+ from: &[String],
+ giving: &[String],
+ rounded: bool,
+) {
+ let sum = fold_sum(sym, amounts);
+ if !giving.is_empty() {
+ let minuend = from
+ .first()
+ .map(|f| operand_decimal(sym, &Operand::Data(f.clone())))
+ .unwrap_or_else(|| "Decimal::zero()".to_string());
+ let value = format!("({minuend}).sub(&({sum}))");
+ for g in giving {
+ emit_store(em, sym, g, &value, rounded);
+ }
+ } else {
+ for t in from {
+ emit_inplace(em, sym, t, "sub", &sum, rounded);
+ }
+ }
+}
+
+fn emit_multiply(
+ em: &mut Emitter,
+ sym: &Symbols,
+ left: &Operand,
+ by: &Operand,
+ giving: &[String],
+ rounded: bool,
+) {
+ let l = operand_decimal(sym, left);
+ if !giving.is_empty() {
+ let value = format!("({l}).mul(&({}))", operand_decimal(sym, by));
+ for g in giving {
+ emit_store(em, sym, g, &value, rounded);
+ }
+ } else if let Operand::Data(name) = by {
+ // `MULTIPLY a BY b` sin GIVING: b queda con a*b.
+ emit_inplace(em, sym, name, "mul", &l, rounded);
+ } else {
+ em.line("// charka: MULTIPLY sin destino claro");
+ }
+}
+
+fn emit_divide(
+ em: &mut Emitter,
+ sym: &Symbols,
+ left: &Operand,
+ right: &Operand,
+ by_form: bool,
+ giving: &[String],
+ rounded: bool,
+) {
+ // `a BY b` → a/b; `a INTO b` → b/a.
+ let (num, den) = if by_form {
+ (operand_decimal(sym, left), operand_decimal(sym, right))
+ } else {
+ (operand_decimal(sym, right), operand_decimal(sym, left))
+ };
+ if !giving.is_empty() {
+ for g in giving {
+ let value = format!(
+ "({num}).div(&({den}), {}, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())",
+ target_scale(sym, g)
+ );
+ emit_store(em, sym, g, &value, rounded);
+ }
+ } else if let Operand::Data(name) = right {
+ // `DIVIDE a INTO b` sin GIVING: b queda con b/a.
+ let value = format!(
+ "({num}).div(&({den}), {}, Rounding::Truncate).unwrap_or_else(|_| Decimal::zero())",
+ target_scale(sym, name)
+ );
+ emit_store(em, sym, name, &value, rounded);
+ } else {
+ em.line("// charka: DIVIDE sin destino claro");
+ }
+}
+
+/// Emite una operación aritmética en el lugar: `t = t rhs`.
+fn emit_inplace(em: &mut Emitter, sym: &Symbols, name: &str, op: &str, rhs: &str, rounded: bool) {
+ match sym.lookup(name) {
+ Some(f) if matches!(f.kind, FieldKind::Num { .. }) => {
+ let method = if rounded { "store_rounded" } else { "store" };
+ em.line(&format!(
+ "self.{0}.{method}(self.{0}.value().{op}(&({rhs})));",
+ f.ident
+ ));
+ }
+ _ => em.line(&format!(
+ "// charka: destino aritmético no resuelto — {name}"
+ )),
+ }
+}
+
+/// La escala de redondeo de un destino numérico (sus dígitos
+/// fraccionarios), o 4 por defecto.
+fn target_scale(sym: &Symbols, name: &str) -> u8 {
+ match sym.lookup(name).map(|f| &f.kind) {
+ Some(FieldKind::Num { frac, .. }) => *frac,
+ _ => 4,
+ }
+}
+
+/// Una expresión `usize` para el número de repeticiones de un `PERFORM`.
+fn count_expr(sym: &Symbols, op: &Operand) -> String {
+ match op {
+ Operand::Num(n) => match n.trim_start_matches('+').parse::() {
+ Ok(v) if v >= 0 => format!("{v}usize"),
+ _ => "0usize".to_string(),
+ },
+ _ => format!(
+ "(({}).rescale(0, Rounding::Truncate).mantissa().max(0) as usize)",
+ operand_decimal(sym, op)
+ ),
+ }
+}
+
+fn emit_perform(em: &mut Emitter, sym: &Symbols, p: &Perform) {
+ // Emite el "cuerpo": la llamada al párrafo o el bloque en línea.
+ let emit_body = |em: &mut Emitter, sym: &Symbols| match &p.target {
+ PerformTarget::Paragraph { name, thru } => {
+ let note = thru
+ .as_ref()
+ .map(|t| format!(" // charka: THRU {t} — rango no soportado"))
+ .unwrap_or_default();
+ em.line(&format!("self.{}();{note}", paragraph_method(name)));
+ }
+ PerformTarget::Inline(body) => emit_block(em, sym, body),
+ };
+
+ match &p.control {
+ PerformControl::Once => {
+ if matches!(p.target, PerformTarget::Inline(_)) {
+ em.line("{");
+ em.indent();
+ emit_body(em, sym);
+ em.dedent();
+ em.line("}");
+ } else {
+ emit_body(em, sym);
+ }
+ }
+ PerformControl::Times(n) => {
+ em.line(&format!("for _ in 0..{} {{", count_expr(sym, n)));
+ em.indent();
+ emit_body(em, sym);
+ em.dedent();
+ em.line("}");
+ }
+ PerformControl::Until(cond) => {
+ em.line(&format!("while !({}) {{", emit_cond(sym, cond)));
+ em.indent();
+ emit_body(em, sym);
+ em.dedent();
+ em.line("}");
+ }
+ }
+}
diff --git a/crates/modules/charka/charka-codegen/src/sym.rs b/crates/modules/charka/charka-codegen/src/sym.rs
new file mode 100644
index 0000000..cf530b3
--- /dev/null
+++ b/crates/modules/charka/charka-codegen/src/sym.rs
@@ -0,0 +1,233 @@
+//! Tabla de símbolos: el modelo de datos COBOL traducido a campos Rust.
+
+use std::collections::HashMap;
+
+use charka_ir::DataItem;
+
+/// Un campo del struct `Program` generado.
+pub(crate) struct Field {
+ /// Nombre COBOL en mayúsculas.
+ pub cobol: String,
+ /// Identificador Rust saneado y único.
+ pub ident: String,
+ /// Numérico o alfanumérico.
+ pub kind: FieldKind,
+ /// La cláusula `VALUE`, si la hay.
+ pub value: Option,
+}
+
+/// El tipo de un campo elemental.
+pub(crate) enum FieldKind {
+ /// Campo numérico — se emite como `Num`.
+ Num { int: u8, frac: u8, signed: bool },
+ /// Campo alfanumérico — se emite como `Text`.
+ Text { len: usize },
+}
+
+/// El conjunto de campos del programa, indexado por nombre COBOL.
+pub(crate) struct Symbols {
+ pub fields: Vec,
+ by_name: HashMap,
+}
+
+impl Symbols {
+ /// Construye la tabla recorriendo el árbol de datos.
+ pub(crate) fn build(data: &[DataItem]) -> Self {
+ let mut fields = Vec::new();
+ collect(data, &mut fields);
+ dedup_idents(&mut fields);
+ let by_name = fields
+ .iter()
+ .enumerate()
+ .map(|(i, f)| (f.cobol.clone(), i))
+ .collect();
+ Self { fields, by_name }
+ }
+
+ /// Busca un campo por su nombre COBOL (sin distinguir mayúsculas).
+ pub(crate) fn lookup(&self, cobol: &str) -> Option<&Field> {
+ self.by_name
+ .get(&cobol.to_uppercase())
+ .map(|&i| &self.fields[i])
+ }
+}
+
+/// Recoge los datos elementales del árbol. Los grupos no son campos —
+/// se recurre en sus hijos. Se saltan niveles 88/66 y los `FILLER`.
+fn collect(items: &[DataItem], out: &mut Vec) {
+ for it in items {
+ if it.level == 88 || it.level == 66 {
+ continue;
+ }
+ if !it.children.is_empty() {
+ collect(&it.children, out);
+ continue;
+ }
+ if it.name == "FILLER" {
+ continue;
+ }
+ let Some(kind) = classify(it.picture.as_deref()) else {
+ continue;
+ };
+ out.push(Field {
+ cobol: it.name.clone(),
+ ident: sanitize_ident(&it.name),
+ kind,
+ value: it.value.clone(),
+ });
+ }
+}
+
+/// Dos datos pueden compartir nombre (COBOL los califica); aquí, como
+/// son campos de un struct, sus identificadores deben ser únicos.
+fn dedup_idents(fields: &mut [Field]) {
+ let mut seen: HashMap = HashMap::new();
+ for f in fields.iter_mut() {
+ let n = seen.entry(f.ident.clone()).or_insert(0);
+ if *n > 0 {
+ f.ident = format!("{}_{}", f.ident, n);
+ }
+ *n += 1;
+ }
+}
+
+/// Clasifica una cláusula PICTURE: alfanumérica si tiene `X`/`A`,
+/// numérica si `charka-bcd` la parsea; una PICTURE de edición se trata
+/// como texto de presentación.
+fn classify(pic: Option<&str>) -> Option {
+ let up = pic?.to_uppercase();
+ if up.contains('X') || up.contains('A') {
+ return Some(FieldKind::Text {
+ len: pic_width(&up).max(1),
+ });
+ }
+ if let Ok(p) = charka_bcd::Picture::parse(&up) {
+ return Some(FieldKind::Num {
+ int: p.integer_digits,
+ frac: p.fraction_digits,
+ signed: p.signed,
+ });
+ }
+ Some(FieldKind::Text {
+ len: pic_width(&up).max(1),
+ })
+}
+
+/// 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
+}
+
+/// Convierte un nombre COBOL en un identificador Rust válido.
+fn sanitize_ident(name: &str) -> String {
+ let mut s: String = name
+ .chars()
+ .map(|c| {
+ if c == '-' {
+ '_'
+ } else {
+ c.to_ascii_lowercase()
+ }
+ })
+ .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
+ .collect();
+ if s.is_empty() || s.starts_with(|c: char| c.is_ascii_digit()) {
+ s = format!("f_{s}");
+ }
+ if is_rust_keyword(&s) {
+ s.push('_');
+ }
+ s
+}
+
+/// El nombre del método de un párrafo. El párrafo implícito ("") es
+/// `p_start`; el resto lleva el prefijo `p_`.
+pub(crate) fn paragraph_method(name: &str) -> String {
+ if name.is_empty() {
+ return "p_start".to_string();
+ }
+ let body: String = name
+ .chars()
+ .map(|c| {
+ if c == '-' {
+ '_'
+ } else {
+ c.to_ascii_lowercase()
+ }
+ })
+ .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
+ .collect();
+ format!("p_{body}")
+}
+
+/// ¿Es `s` una palabra reservada de Rust? (Para no chocar al nombrar
+/// campos — un dato COBOL `MOVE` se volvería el keyword `move`.)
+fn is_rust_keyword(s: &str) -> bool {
+ matches!(
+ s,
+ "as" | "break"
+ | "const"
+ | "continue"
+ | "crate"
+ | "dyn"
+ | "else"
+ | "enum"
+ | "extern"
+ | "false"
+ | "fn"
+ | "for"
+ | "if"
+ | "impl"
+ | "in"
+ | "let"
+ | "loop"
+ | "match"
+ | "mod"
+ | "move"
+ | "mut"
+ | "pub"
+ | "ref"
+ | "return"
+ | "self"
+ | "static"
+ | "struct"
+ | "super"
+ | "trait"
+ | "true"
+ | "type"
+ | "unsafe"
+ | "use"
+ | "where"
+ | "while"
+ | "async"
+ | "await"
+ | "box"
+ | "yield"
+ )
+}
diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md
index 9702cf9..457aacb 100644
--- a/docs/changelog/charka.md
+++ b/docs/changelog/charka.md
@@ -3,6 +3,33 @@
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-codegen): emisión de Rust desde el IR
+
+Crate nuevo `crates/modules/charka/charka-codegen` — la etapa final del
+pipeline. `generate(&Ir) -> String` produce un fuente Rust (un
+`main.rs`) que, compilado contra `charka-runtime`, ejecuta la lógica
+del programa COBOL.
+
+- Un `struct Program` con un campo por cada dato elemental — `Num`
+ para los numéricos, `Text` para los alfanuméricos; `Program::new()`
+ los inicializa desde sus cláusulas `VALUE`.
+- Un método `p_(&mut self)` por cada párrafo del PROCEDURE;
+ `run()` los encadena en orden (el «caer» de COBOL); `main()`
+ construye el `Program` y lo corre.
+- Cada `Stmt` → código Rust: `MOVE`→`.store`/`.fill`,
+ `DISPLAY`→`println!`, `COMPUTE` y la aritmética → expresiones
+ `Decimal`, `IF`→`if`/`else`, `PERFORM`→ llamada de método / `for` /
+ `while`, `GO TO`→ llamada + `return`, `STOP RUN`→`process::exit`.
+- Tolerante: lo no transpilable (`Stmt::Unknown`, un dato sin resolver,
+ el operador `**`) se emite como comentario `// charka:`; el código
+ generado siempre compila.
+- Saneado de identificadores COBOL→Rust (incl. choques con keywords).
+- Verificado de punta a punta: un programa COBOL de demostración
+ transpila a Rust que compila contra `charka-runtime` y produce la
+ salida esperada (`COMPUTE`, `IF`, `PERFORM`, `DISPLAY`).
+- 14 tests del fuente emitido; fuera de alcance v1: grupos como campo,
+ `REDEFINES`, `OCCURS`, `PERFORM ... THRU` como rango, E/S.
+
### feat(charka-runtime): soporte de ejecución — campos Num y Text
Crate nuevo `crates/modules/charka/charka-runtime` — el soporte que los