feat(charka): charka-ir — representación intermedia con statements tipados
Tercera etapa del transpilador: Program -> Ir. El PROCEDURE division
pasa de sentencias con tokens crudos a un árbol de instrucciones
tipadas.
- lower(&Program) -> Ir: total y tolerante, nunca falla. La DATA
division pasa tal cual y sirve de tabla de símbolos.
- Stmt cubre MOVE, DISPLAY, ACCEPT, COMPUTE, ADD, SUBTRACT, MULTIPLY,
DIVIDE, IF/ELSE/END-IF, PERFORM (fuera de línea, en línea, TIMES,
UNTIL), GO TO, STOP RUN, GOBACK, EXIT, CONTINUE.
- Expresiones de COMPUTE con precedencia y paréntesis (Pratt).
Condiciones con comparadores símbolo/palabra, AND/OR/NOT y nombres
de condición (nivel 88).
- Delimita statements por palabras frontera (COBOL no los separa con
un símbolo). Verbo no soportado -> Stmt::Unknown con tokens crudos.
- Módulos: ast / kw / cursor / expr / stmt. 17 tests; fmt + clippy
limpios.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
71a4068d12e81d0be7b08d0717114dd5b10868fe · por sergio · 2026-05-21T20:23:19Z (hace 5 meses) · padre b95383b01 · ver el árbol en este commit
Delta semántico · 93
crates/modules/charka/charka-ir/src/ast.rs
crates/modules/charka/charka-ir/src/cursor.rs
crates/modules/charka/charka-ir/src/expr.rs
crates/modules/charka/charka-ir/src/kw.rs
crates/modules/charka/charka-ir/src/lib.rs
crates/modules/charka/charka-ir/src/stmt.rs
diff --git a/Cargo.lock b/Cargo.lock
index 7a9fdaa..543eb95 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2315,6 +2315,14 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "charka-ir"
+version = "0.1.0"
+dependencies = [
+ "charka-lexer",
+ "charka-parser",
+]
+
[[package]]
name = "charka-lexer"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 9006621..4756036 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -159,6 +159,7 @@ members = [
"crates/modules/charka/charka-bcd",
"crates/modules/charka/charka-lexer",
"crates/modules/charka/charka-parser",
+ "crates/modules/charka/charka-ir",
# ============================================================
# modules/mirada/ — Compositor Wayland
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
index 722d541..8e8c52c 100644
--- a/crates/modules/charka/SDD.md
+++ b/crates/modules/charka/SDD.md
@@ -13,6 +13,7 @@ embebido, dialectos IBM Enterprise) es un esfuerzo multi-mes.
| `charka-bcd` | lib | Aritmética decimal de punto fijo con semántica COBOL: `Picture`, `Decimal`, redondeo, `ON SIZE ERROR` |
| `charka-lexer` | lib | Tokenizador COBOL: formato fijo (tarjeta de 80 columnas) y libre |
| `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-bcd
@@ -64,15 +65,38 @@ Segunda etapa: tokens → AST. Alcance v1 — el **esqueleto del programa**.
- Limitación v1: no parsea statements, ni la ENVIRONMENT division, ni
CICS / SQL embebido / dialectos IBM.
+## charka-ir
+
+Tercera etapa: `Program` → `Ir`. Aquí se parsea cada `Sentence` cruda
+(tokens) a statements tipados — el PROCEDURE division pasa de tokens a
+árbol de instrucciones.
+
+- `lower(&Program) -> Ir` — **total y tolerante**, nunca falla.
+- `Ir { program_id, data: Vec<DataItem>, procedures: Vec<Procedure> }`.
+ El modelo de datos pasa tal cual (sirve de tabla de símbolos).
+- `Procedure { name, body: Vec<Stmt> }`. `Stmt` cubre `Move`,
+ `Display`, `Accept`, `Compute`, `Add`/`Subtract`/`Multiply`/`Divide`,
+ `If`, `Perform`, `GoTo`, `StopRun`, `Goback`, `Exit`, `Continue`.
+- `Expr` — expresiones aritméticas con precedencia y paréntesis (Pratt:
+ `+ -` < `* /` < `**` der.). `Cond` — comparaciones (símbolo o forma
+ palabra) unidas por `AND`/`OR`/`NOT`, más nombres de condición (88).
+- Un verbo no soportado se conserva como `Stmt::Unknown { verb,
+ tokens }` — el lowering jamás aborta.
+- COBOL no separa statements con un símbolo: cada uno corta donde
+ empieza el verbo del siguiente. El parser usa palabras "frontera"
+ (verbos + terminadores `END-*`/`ELSE` + conectores `TO`/`GIVING`...)
+ para delimitar listas de operandos.
+- Fuera de alcance v1: `EVALUATE`, `STRING`/`UNSTRING`, E/S de
+ ficheros, `PERFORM VARYING`, CICS, SQL embebido.
+
## Estado
-`charka-bcd` (22 tests), `charka-lexer` (17 tests) y `charka-parser`
-(15 tests) implementados y verdes. **Pendiente** — el resto del
-transpilador (Fase D del plan macro):
+`charka-bcd` (22 tests), `charka-lexer` (17 tests), `charka-parser`
+(15 tests) y `charka-ir` (17 tests) implementados y verdes.
+**Pendiente** — el resto del transpilador (Fase D del plan macro):
| crate pendiente | rol |
| ----------------- | ---------------------------------------------------- |
-| `charka-ir` | representación intermedia (parseo de statements) |
| `charka-codegen` | emisión de Rust |
| `charka-shadow` | validador en sombra (original vs transpilado) |
| `charka-runtime` | runtime determinista (sobre `charka-bcd`) |
diff --git a/crates/modules/charka/charka-ir/Cargo.toml b/crates/modules/charka/charka-ir/Cargo.toml
new file mode 100644
index 0000000..fabdade
--- /dev/null
+++ b/crates/modules/charka/charka-ir/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "charka-ir"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "charka-ir — representación intermedia: el AST de charka-parser con los statements del PROCEDURE ya parseados a instrucciones tipadas (MOVE, IF, PERFORM, COMPUTE...)."
+
+[dependencies]
+charka-parser = { path = "../charka-parser" }
+
+[dev-dependencies]
+charka-lexer = { path = "../charka-lexer" }
diff --git a/crates/modules/charka/charka-ir/src/ast.rs b/crates/modules/charka/charka-ir/src/ast.rs
new file mode 100644
index 0000000..5ea48b0
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/ast.rs
@@ -0,0 +1,200 @@
+//! Los tipos del IR: el programa COBOL con su PROCEDURE division ya
+//! parseada a instrucciones tipadas.
+
+pub use charka_parser::{DataItem, Token};
+
+/// Un programa COBOL en representación intermedia.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Ir {
+ /// El `PROGRAM-ID` ("" si el programa no lo declara).
+ pub program_id: String,
+ /// El modelo de datos — el árbol de [`DataItem`] tal cual lo
+ /// produjo `charka-parser`. Sirve de tabla de símbolos.
+ pub data: Vec<DataItem>,
+ /// Los párrafos del PROCEDURE, con sus statements ya tipados.
+ pub procedures: Vec<Procedure>,
+}
+
+/// Un párrafo del PROCEDURE: un nombre y un cuerpo de statements.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Procedure {
+ /// Nombre del párrafo en mayúsculas; "" para el párrafo implícito.
+ pub name: String,
+ /// Los statements del párrafo, en orden.
+ pub body: Vec<Stmt>,
+}
+
+/// Un operando: lo que puede ir donde se espera un valor.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Operand {
+ /// Referencia a un dato, por nombre (en mayúsculas).
+ Data(String),
+ /// Literal numérico (texto, posiblemente con signo).
+ Num(String),
+ /// Literal de texto.
+ Str(String),
+ /// Constante figurativa (`ZERO`, `SPACE`...).
+ Figurative(Figurative),
+}
+
+/// Las constantes figurativas de COBOL.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Figurative {
+ Zero,
+ Space,
+ HighValue,
+ LowValue,
+ Quote,
+ Null,
+}
+
+/// Una expresión aritmética (la parte derecha de un `COMPUTE`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum Expr {
+ /// Un operando hoja.
+ Operand(Operand),
+ /// Negación unaria.
+ Neg(Box<Expr>),
+ /// Operación binaria.
+ Binary {
+ op: BinOp,
+ lhs: Box<Expr>,
+ rhs: Box<Expr>,
+ },
+}
+
+/// Operador aritmético binario.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BinOp {
+ Add,
+ Sub,
+ Mul,
+ Div,
+ /// Exponenciación (`**`).
+ Pow,
+}
+
+/// Una condición (la guarda de un `IF` o de un `PERFORM UNTIL`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum Cond {
+ /// Comparación relacional `lhs op rhs`.
+ Compare {
+ lhs: Operand,
+ op: CmpOp,
+ rhs: Operand,
+ },
+ /// Un nombre de condición (un dato de nivel 88) usado solo.
+ Named(String),
+ /// Negación lógica.
+ Not(Box<Cond>),
+ /// Conjunción lógica.
+ And(Box<Cond>, Box<Cond>),
+ /// Disyunción lógica.
+ Or(Box<Cond>, Box<Cond>),
+}
+
+/// Operador relacional.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CmpOp {
+ Eq,
+ Ne,
+ Lt,
+ Gt,
+ Le,
+ Ge,
+}
+
+/// Un statement del PROCEDURE division.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Stmt {
+ /// `MOVE from TO to...`
+ Move { from: Operand, to: Vec<String> },
+ /// `DISPLAY items...`
+ Display { items: Vec<Operand> },
+ /// `ACCEPT into`
+ Accept { into: String },
+ /// `COMPUTE targets... [ROUNDED] = expr`
+ Compute {
+ targets: Vec<String>,
+ rounded: bool,
+ expr: Expr,
+ },
+ /// `ADD addends... TO to... [GIVING giving...]`
+ Add {
+ addends: Vec<Operand>,
+ to: Vec<String>,
+ giving: Vec<String>,
+ rounded: bool,
+ },
+ /// `SUBTRACT amounts... FROM from... [GIVING giving...]`
+ Subtract {
+ amounts: Vec<Operand>,
+ from: Vec<String>,
+ giving: Vec<String>,
+ rounded: bool,
+ },
+ /// `MULTIPLY left BY by [GIVING giving...]`
+ Multiply {
+ left: Operand,
+ by: Operand,
+ giving: Vec<String>,
+ rounded: bool,
+ },
+ /// `DIVIDE left {BY|INTO} right [GIVING giving...]`. `by_form` es
+ /// true para `BY` (`left/right`), false para `INTO` (`right/left`).
+ Divide {
+ left: Operand,
+ right: Operand,
+ by_form: bool,
+ giving: Vec<String>,
+ rounded: bool,
+ },
+ /// `IF cond [THEN] then_branch [ELSE else_branch] [END-IF]`
+ If {
+ cond: Cond,
+ then_branch: Vec<Stmt>,
+ else_branch: Vec<Stmt>,
+ },
+ /// `PERFORM ...` — ver [`Perform`].
+ Perform(Perform),
+ /// `GO TO target`
+ GoTo { target: String },
+ /// `STOP RUN`
+ StopRun,
+ /// `GOBACK`
+ Goback,
+ /// `EXIT` (y sus variantes `EXIT PROGRAM`/`PARAGRAPH`...).
+ Exit,
+ /// `CONTINUE` — el no-op de COBOL.
+ Continue,
+ /// Un verbo que la v1 no parsea: se conserva crudo para que las
+ /// etapas siguientes (o un humano) lo revisen.
+ Unknown { verb: String, tokens: Vec<Token> },
+}
+
+/// Un statement `PERFORM`: a quién ejecuta y cuántas veces.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Perform {
+ pub target: PerformTarget,
+ pub control: PerformControl,
+}
+
+/// El cuerpo que un `PERFORM` ejecuta.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PerformTarget {
+ /// `PERFORM PARA [THRU PARA2]` — ejecuta uno o un rango de párrafos.
+ Paragraph { name: String, thru: Option<String> },
+ /// `PERFORM ... statements ... END-PERFORM` — cuerpo en línea.
+ Inline(Vec<Stmt>),
+}
+
+/// Cuántas veces se ejecuta el cuerpo de un `PERFORM`.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PerformControl {
+ /// Una sola vez.
+ Once,
+ /// `n TIMES`.
+ Times(Operand),
+ /// `UNTIL cond`.
+ Until(Cond),
+}
diff --git a/crates/modules/charka/charka-ir/src/cursor.rs b/crates/modules/charka/charka-ir/src/cursor.rs
new file mode 100644
index 0000000..51ac5a2
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/cursor.rs
@@ -0,0 +1,139 @@
+//! `Cursor` — un cursor de avance sobre la lista de tokens de una
+//! sentencia, más las primitivas para leer un operando.
+
+use charka_parser::{Token, TokenKind};
+
+use crate::ast::{Figurative, Operand};
+
+/// Cursor sobre los tokens de una sentencia. `pos` es público dentro
+/// del crate para que el parser de condiciones pueda rebobinar.
+pub(crate) struct Cursor<'a> {
+ pub(crate) toks: &'a [Token],
+ pub(crate) pos: usize,
+}
+
+impl<'a> Cursor<'a> {
+ pub(crate) fn new(toks: &'a [Token]) -> Self {
+ Self { toks, pos: 0 }
+ }
+
+ /// ¿Se agotaron los tokens?
+ pub(crate) fn done(&self) -> bool {
+ self.pos >= self.toks.len()
+ }
+
+ /// El token actual, sin consumirlo.
+ pub(crate) fn peek(&self) -> Option<&Token> {
+ self.toks.get(self.pos)
+ }
+
+ /// El token `n` posiciones adelante, sin consumirlo.
+ pub(crate) fn peek_at(&self, n: usize) -> Option<&Token> {
+ self.toks.get(self.pos + n)
+ }
+
+ /// Consume y devuelve el token actual.
+ pub(crate) fn bump(&mut self) -> Option<Token> {
+ let t = self.toks.get(self.pos).cloned();
+ if t.is_some() {
+ self.pos += 1;
+ }
+ t
+ }
+
+ /// El token actual, si es una palabra, en mayúsculas.
+ pub(crate) fn peek_word(&self) -> Option<String> {
+ word_of(self.peek())
+ }
+
+ /// La palabra `n` posiciones adelante, en mayúsculas.
+ pub(crate) fn word_at(&self, n: usize) -> Option<String> {
+ word_of(self.peek_at(n))
+ }
+
+ /// ¿El token actual es la palabra `kw`?
+ pub(crate) fn at_word(&self, kw: &str) -> bool {
+ self.peek_word().as_deref() == Some(kw)
+ }
+
+ /// Consume el token actual si es la palabra `kw`.
+ pub(crate) fn eat_word(&mut self, kw: &str) -> bool {
+ if self.at_word(kw) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+
+ /// ¿El token actual es el símbolo `s`?
+ pub(crate) fn at_sym(&self, s: &str) -> bool {
+ matches!(self.peek(), Some(t) if t.kind == TokenKind::Symbol && t.text == s)
+ }
+
+ /// Consume el token actual si es el símbolo `s`.
+ pub(crate) fn eat_sym(&mut self, s: &str) -> bool {
+ if self.at_sym(s) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+}
+
+/// Si el token es una palabra, su texto en mayúsculas.
+fn word_of(t: Option<&Token>) -> Option<String> {
+ match t {
+ Some(t) if t.kind == TokenKind::Word => Some(t.text.to_uppercase()),
+ _ => None,
+ }
+}
+
+/// Lee un operando: un literal con signo opcional, o un token suelto.
+pub(crate) fn parse_operand(c: &mut Cursor) -> Operand {
+ // Signo delante de un literal numérico (`-5`, `+3`).
+ if (c.at_sym("-") || c.at_sym("+")) && c.peek_at(1).map(|t| t.kind) == Some(TokenKind::Number) {
+ let neg = c.at_sym("-");
+ c.bump();
+ let num = c.bump().expect("número tras el signo");
+ return Operand::Num(if neg {
+ format!("-{}", num.text)
+ } else {
+ num.text
+ });
+ }
+ match c.bump() {
+ Some(t) => token_to_operand(&t),
+ None => Operand::Num("0".into()),
+ }
+}
+
+/// Clasifica un token suelto como operando.
+pub(crate) fn token_to_operand(t: &Token) -> Operand {
+ match t.kind {
+ TokenKind::Number => Operand::Num(t.text.clone()),
+ TokenKind::String => Operand::Str(t.text.clone()),
+ TokenKind::Word => {
+ let u = t.text.to_uppercase();
+ match figurative(&u) {
+ Some(f) => Operand::Figurative(f),
+ None => Operand::Data(u),
+ }
+ }
+ TokenKind::Period | TokenKind::Symbol => Operand::Data(t.text.clone()),
+ }
+}
+
+/// Reconoce una constante figurativa por su nombre en mayúsculas.
+fn figurative(w: &str) -> Option<Figurative> {
+ Some(match w {
+ "ZERO" | "ZEROS" | "ZEROES" => Figurative::Zero,
+ "SPACE" | "SPACES" => Figurative::Space,
+ "HIGH-VALUE" | "HIGH-VALUES" => Figurative::HighValue,
+ "LOW-VALUE" | "LOW-VALUES" => Figurative::LowValue,
+ "QUOTE" | "QUOTES" => Figurative::Quote,
+ "NULL" | "NULLS" => Figurative::Null,
+ _ => return None,
+ })
+}
diff --git a/crates/modules/charka/charka-ir/src/expr.rs b/crates/modules/charka/charka-ir/src/expr.rs
new file mode 100644
index 0000000..477290a
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/expr.rs
@@ -0,0 +1,208 @@
+//! Parseo de expresiones aritméticas (`COMPUTE`) y de condiciones
+//! (`IF`, `PERFORM UNTIL`).
+
+use charka_parser::TokenKind;
+
+use crate::ast::{BinOp, CmpOp, Cond, Expr, Operand};
+use crate::cursor::{parse_operand, Cursor};
+use crate::kw::is_boundary;
+
+// ── Expresiones ───────────────────────────────────────────────────
+
+/// Parsea una expresión aritmética con precedencia y paréntesis.
+pub(crate) fn parse_expr(c: &mut Cursor) -> Expr {
+ parse_bin(c, 0)
+}
+
+/// Trepa por precedencia: `min_prec` es la mínima precedencia que este
+/// nivel acepta seguir consumiendo.
+fn parse_bin(c: &mut Cursor, min_prec: u8) -> Expr {
+ let mut lhs = parse_unary(c);
+ while let Some((op, prec, right_assoc)) = peek_binop(c) {
+ if prec < min_prec {
+ break;
+ }
+ c.bump();
+ let next_min = if right_assoc { prec } else { prec + 1 };
+ let rhs = parse_bin(c, next_min);
+ lhs = Expr::Binary {
+ op,
+ lhs: Box::new(lhs),
+ rhs: Box::new(rhs),
+ };
+ }
+ lhs
+}
+
+/// Negación o signo unario delante de un primario.
+fn parse_unary(c: &mut Cursor) -> Expr {
+ if c.eat_sym("-") {
+ return Expr::Neg(Box::new(parse_unary(c)));
+ }
+ if c.eat_sym("+") {
+ return parse_unary(c);
+ }
+ parse_primary(c)
+}
+
+/// Un primario: un paréntesis o un operando hoja.
+fn parse_primary(c: &mut Cursor) -> Expr {
+ if c.eat_sym("(") {
+ let e = parse_bin(c, 0);
+ c.eat_sym(")");
+ return e;
+ }
+ // No consumir un verbo o conector: la expresión terminó.
+ if c.done() || c.peek_word().map(|w| is_boundary(&w)).unwrap_or(false) {
+ return Expr::Operand(Operand::Num("0".into()));
+ }
+ Expr::Operand(parse_operand(c))
+}
+
+/// El operador binario en el token actual, con su precedencia y si es
+/// asociativo a derecha. `**` es la única potencia, asociativa a der.
+fn peek_binop(c: &Cursor) -> Option<(BinOp, u8, bool)> {
+ let t = c.peek()?;
+ if t.kind != TokenKind::Symbol {
+ return None;
+ }
+ match t.text.as_str() {
+ "+" => Some((BinOp::Add, 1, false)),
+ "-" => Some((BinOp::Sub, 1, false)),
+ "*" => Some((BinOp::Mul, 2, false)),
+ "/" => Some((BinOp::Div, 2, false)),
+ "**" => Some((BinOp::Pow, 3, true)),
+ _ => None,
+ }
+}
+
+// ── Condiciones ───────────────────────────────────────────────────
+
+/// Parsea una condición: comparaciones unidas por `AND`/`OR`/`NOT`.
+pub(crate) fn parse_cond(c: &mut Cursor) -> Cond {
+ parse_or(c)
+}
+
+fn parse_or(c: &mut Cursor) -> Cond {
+ let mut lhs = parse_and(c);
+ while c.eat_word("OR") {
+ let rhs = parse_and(c);
+ lhs = Cond::Or(Box::new(lhs), Box::new(rhs));
+ }
+ lhs
+}
+
+fn parse_and(c: &mut Cursor) -> Cond {
+ let mut lhs = parse_not(c);
+ while c.eat_word("AND") {
+ let rhs = parse_not(c);
+ lhs = Cond::And(Box::new(lhs), Box::new(rhs));
+ }
+ lhs
+}
+
+fn parse_not(c: &mut Cursor) -> Cond {
+ if c.eat_word("NOT") {
+ return Cond::Not(Box::new(parse_not(c)));
+ }
+ parse_cond_primary(c)
+}
+
+/// Un primario de condición: un paréntesis, una comparación, o un dato
+/// suelto (un nombre de condición de nivel 88).
+fn parse_cond_primary(c: &mut Cursor) -> Cond {
+ if c.eat_sym("(") {
+ let inner = parse_or(c);
+ c.eat_sym(")");
+ return inner;
+ }
+ if c.done() || c.peek_word().map(|w| is_boundary(&w)).unwrap_or(false) {
+ return Cond::Named(String::new());
+ }
+ let lhs = parse_operand(c);
+ match parse_cmp_op(c) {
+ Some(op) => {
+ let rhs = parse_operand(c);
+ Cond::Compare { lhs, op, rhs }
+ }
+ None => match lhs {
+ Operand::Data(n) => Cond::Named(n),
+ // Un literal solo como condición es raro; se degrada a "≠ 0".
+ other => Cond::Compare {
+ lhs: other,
+ op: CmpOp::Ne,
+ rhs: Operand::Num("0".into()),
+ },
+ },
+ }
+}
+
+/// Lee un operador relacional (forma símbolo o forma palabra). Si no
+/// hay ninguno, rebobina el cursor y devuelve `None`.
+fn parse_cmp_op(c: &mut Cursor) -> Option<CmpOp> {
+ let save = c.pos;
+ c.eat_word("IS");
+ let negated = c.eat_word("NOT");
+ if let Some(op) = cmp_core(c) {
+ return Some(if negated { negate(op) } else { op });
+ }
+ c.pos = save;
+ None
+}
+
+/// El núcleo del comparador, sin el `IS`/`NOT` opcionales.
+fn cmp_core(c: &mut Cursor) -> Option<CmpOp> {
+ if c.eat_sym("<>") {
+ return Some(CmpOp::Ne);
+ }
+ if c.eat_sym("<=") {
+ return Some(CmpOp::Le);
+ }
+ if c.eat_sym(">=") {
+ return Some(CmpOp::Ge);
+ }
+ if c.eat_sym("=") {
+ return Some(CmpOp::Eq);
+ }
+ if c.eat_sym("<") {
+ return Some(CmpOp::Lt);
+ }
+ if c.eat_sym(">") {
+ return Some(CmpOp::Gt);
+ }
+ if c.eat_word("EQUAL") || c.eat_word("EQUALS") {
+ c.eat_word("TO");
+ return Some(CmpOp::Eq);
+ }
+ if c.eat_word("GREATER") {
+ c.eat_word("THAN");
+ if c.eat_word("OR") {
+ c.eat_word("EQUAL");
+ c.eat_word("TO");
+ return Some(CmpOp::Ge);
+ }
+ return Some(CmpOp::Gt);
+ }
+ if c.eat_word("LESS") {
+ c.eat_word("THAN");
+ if c.eat_word("OR") {
+ c.eat_word("EQUAL");
+ c.eat_word("TO");
+ return Some(CmpOp::Le);
+ }
+ return Some(CmpOp::Lt);
+ }
+ None
+}
+
+/// El comparador opuesto — para resolver `NOT` delante de un relacional.
+fn negate(op: CmpOp) -> CmpOp {
+ match op {
+ CmpOp::Eq => CmpOp::Ne,
+ CmpOp::Ne => CmpOp::Eq,
+ CmpOp::Lt => CmpOp::Ge,
+ CmpOp::Ge => CmpOp::Lt,
+ CmpOp::Gt => CmpOp::Le,
+ CmpOp::Le => CmpOp::Gt,
+ }
+}
diff --git a/crates/modules/charka/charka-ir/src/kw.rs b/crates/modules/charka/charka-ir/src/kw.rs
new file mode 100644
index 0000000..b0542ca
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/kw.rs
@@ -0,0 +1,101 @@
+//! Predicados sobre las palabras clave de COBOL. COBOL no termina los
+//! statements con un símbolo: una sentencia es una secuencia de
+//! statements que se delimitan por la aparición del siguiente verbo.
+//! Estos predicados le dicen al parser dónde corta un statement.
+
+/// ¿Es `w` un verbo que inicia un statement? (En mayúsculas.)
+pub(crate) fn is_verb(w: &str) -> bool {
+ matches!(
+ w,
+ "ACCEPT"
+ | "ADD"
+ | "ALTER"
+ | "CALL"
+ | "CANCEL"
+ | "CLOSE"
+ | "COMPUTE"
+ | "CONTINUE"
+ | "DELETE"
+ | "DISPLAY"
+ | "DIVIDE"
+ | "EVALUATE"
+ | "EXIT"
+ | "GO"
+ | "GOBACK"
+ | "IF"
+ | "INITIALIZE"
+ | "INSPECT"
+ | "MERGE"
+ | "MOVE"
+ | "MULTIPLY"
+ | "OPEN"
+ | "PERFORM"
+ | "READ"
+ | "RELEASE"
+ | "RETURN"
+ | "REWRITE"
+ | "SEARCH"
+ | "SET"
+ | "SORT"
+ | "START"
+ | "STOP"
+ | "STRING"
+ | "SUBTRACT"
+ | "UNSTRING"
+ | "WRITE"
+ )
+}
+
+/// ¿Es `w` un terminador de ámbito (`END-IF`, `ELSE`, `WHEN`...)?
+pub(crate) fn is_terminator(w: &str) -> bool {
+ matches!(
+ w,
+ "ELSE"
+ | "WHEN"
+ | "END-IF"
+ | "END-PERFORM"
+ | "END-EVALUATE"
+ | "END-ADD"
+ | "END-SUBTRACT"
+ | "END-MULTIPLY"
+ | "END-DIVIDE"
+ | "END-COMPUTE"
+ | "END-READ"
+ | "END-WRITE"
+ | "END-CALL"
+ | "END-STRING"
+ | "END-UNSTRING"
+ | "END-SEARCH"
+ | "END-START"
+ | "END-DELETE"
+ | "END-REWRITE"
+ | "END-RETURN"
+ )
+}
+
+/// ¿Es `w` una palabra de conexión de una cláusula (`TO`, `GIVING`...)?
+fn is_connector(w: &str) -> bool {
+ matches!(
+ w,
+ "TO" | "FROM"
+ | "GIVING"
+ | "BY"
+ | "INTO"
+ | "THRU"
+ | "THROUGH"
+ | "UNTIL"
+ | "TIMES"
+ | "ROUNDED"
+ | "THEN"
+ | "WITH"
+ | "UPON"
+ | "REMAINDER"
+ | "VARYING"
+ )
+}
+
+/// ¿Marca `w` el final de una lista de operandos o de nombres? Es así
+/// para todo verbo, terminador o conector — ninguno puede ser un dato.
+pub(crate) fn is_boundary(w: &str) -> bool {
+ is_verb(w) || is_terminator(w) || is_connector(w)
+}
diff --git a/crates/modules/charka/charka-ir/src/lib.rs b/crates/modules/charka/charka-ir/src/lib.rs
new file mode 100644
index 0000000..9bccad4
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/lib.rs
@@ -0,0 +1,329 @@
+//! `charka-ir` — la representación intermedia del transpilador.
+//!
+//! Tercera etapa del pipeline COBOL→Rust: toma el [`Program`] de
+//! `charka-parser` (cuyo PROCEDURE division es una lista de sentencias
+//! con tokens crudos) y produce un [`Ir`] donde cada sentencia ya es un
+//! árbol de [`Stmt`] tipados: `MOVE`, `IF`, `PERFORM`, `COMPUTE`, los
+//! verbos aritméticos, etc.
+//!
+//! El modelo de datos (`DATA division`) pasa tal cual — el árbol de
+//! [`DataItem`] que ya armó el parser sirve de tabla de símbolos.
+//!
+//! El lowering es **tolerante y total**: nunca falla. Un verbo que la
+//! v1 no parsea se conserva como [`Stmt::Unknown`] con sus tokens
+//! crudos, listo para que el codegen (o un humano) lo revise.
+//!
+//! Alcance v1 — los verbos parseados a fondo: `MOVE`, `DISPLAY`,
+//! `ACCEPT`, `COMPUTE` (con expresiones con precedencia), `ADD`,
+//! `SUBTRACT`, `MULTIPLY`, `DIVIDE`, `IF`/`ELSE`/`END-IF` (con
+//! condiciones `AND`/`OR`/`NOT`), `PERFORM` (fuera de línea, en línea,
+//! `TIMES`, `UNTIL`), `GO TO`, `STOP RUN`, `GOBACK`, `EXIT`,
+//! `CONTINUE`. Fuera de alcance: `EVALUATE`, `STRING`/`UNSTRING`, E/S
+//! de ficheros, `PERFORM VARYING`, CICS y SQL embebido.
+
+#![forbid(unsafe_code)]
+
+mod ast;
+mod cursor;
+mod expr;
+mod kw;
+mod stmt;
+
+pub use ast::*;
+pub use charka_parser::Program;
+
+use cursor::Cursor;
+
+/// Baja un [`Program`] parseado a la representación intermedia.
+pub fn lower(program: &Program) -> Ir {
+ let procedures = program
+ .paragraphs
+ .iter()
+ .map(|p| {
+ let mut body = Vec::new();
+ for sentence in &p.sentences {
+ let mut cur = Cursor::new(&sentence.tokens);
+ body.extend(stmt::parse_statements(&mut cur, &[]));
+ }
+ Procedure {
+ name: p.name.clone(),
+ body,
+ }
+ })
+ .collect();
+
+ Ir {
+ program_id: program.program_id.clone().unwrap_or_default(),
+ data: program.data.clone(),
+ procedures,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use charka_lexer::{lex, SourceFormat};
+
+ /// Helper: lexa, parsea y baja a IR un fuente en formato libre.
+ fn ir(src: &str) -> Ir {
+ let toks = lex(src, SourceFormat::Free).expect("lex OK");
+ let program = charka_parser::parse(&toks).expect("parse OK");
+ lower(&program)
+ }
+
+ /// Helper: el cuerpo del primer (y normalmente único) párrafo.
+ fn body(src: &str) -> Vec<Stmt> {
+ let prog = format!("PROCEDURE DIVISION.\nMAIN.\n{src}\n");
+ ir(&prog).procedures.into_iter().next().unwrap().body
+ }
+
+ #[test]
+ fn empty_program_lowers_to_default() {
+ let got = lower(&charka_parser::parse(&[]).unwrap());
+ assert_eq!(got, Ir::default());
+ }
+
+ #[test]
+ fn move_simple() {
+ let b = body("MOVE 5 TO WS-X.");
+ assert_eq!(
+ b,
+ vec![Stmt::Move {
+ from: Operand::Num("5".into()),
+ to: vec!["WS-X".into()],
+ }]
+ );
+ }
+
+ #[test]
+ fn move_to_several_targets() {
+ let b = body("MOVE WS-A TO WS-B WS-C.");
+ assert_eq!(
+ b,
+ vec![Stmt::Move {
+ from: Operand::Data("WS-A".into()),
+ to: vec!["WS-B".into(), "WS-C".into()],
+ }]
+ );
+ }
+
+ #[test]
+ fn display_items_and_figurative() {
+ let b = body("DISPLAY 'TOTAL: ' WS-TOTAL SPACES.");
+ assert_eq!(
+ b,
+ vec![Stmt::Display {
+ items: vec![
+ Operand::Str("TOTAL: ".into()),
+ Operand::Data("WS-TOTAL".into()),
+ Operand::Figurative(Figurative::Space),
+ ],
+ }]
+ );
+ }
+
+ #[test]
+ fn compute_respects_precedence() {
+ let b = body("COMPUTE WS-T = WS-A + WS-B * 2.");
+ let expr = match &b[0] {
+ Stmt::Compute { targets, expr, .. } => {
+ assert_eq!(targets, &vec!["WS-T".to_string()]);
+ expr.clone()
+ }
+ other => panic!("se esperaba COMPUTE, vino {other:?}"),
+ };
+ // WS-A + (WS-B * 2)
+ assert_eq!(
+ expr,
+ Expr::Binary {
+ op: BinOp::Add,
+ lhs: Box::new(Expr::Operand(Operand::Data("WS-A".into()))),
+ rhs: Box::new(Expr::Binary {
+ op: BinOp::Mul,
+ lhs: Box::new(Expr::Operand(Operand::Data("WS-B".into()))),
+ rhs: Box::new(Expr::Operand(Operand::Num("2".into()))),
+ }),
+ }
+ );
+ }
+
+ #[test]
+ fn compute_rounded_flag() {
+ let b = body("COMPUTE WS-T ROUNDED = WS-A / 3.");
+ assert!(matches!(&b[0], Stmt::Compute { rounded: true, .. }));
+ }
+
+ #[test]
+ fn add_in_place_and_giving() {
+ assert_eq!(
+ body("ADD 1 TO WS-CT."),
+ vec![Stmt::Add {
+ addends: vec![Operand::Num("1".into())],
+ to: vec!["WS-CT".into()],
+ giving: vec![],
+ rounded: false,
+ }]
+ );
+ assert_eq!(
+ body("ADD WS-A WS-B GIVING WS-C."),
+ vec![Stmt::Add {
+ addends: vec![Operand::Data("WS-A".into()), Operand::Data("WS-B".into()),],
+ to: vec![],
+ giving: vec!["WS-C".into()],
+ rounded: false,
+ }]
+ );
+ }
+
+ #[test]
+ fn subtract_from_giving() {
+ assert_eq!(
+ body("SUBTRACT WS-TAX FROM WS-GROSS GIVING WS-NET."),
+ vec![Stmt::Subtract {
+ amounts: vec![Operand::Data("WS-TAX".into())],
+ from: vec!["WS-GROSS".into()],
+ giving: vec!["WS-NET".into()],
+ rounded: false,
+ }]
+ );
+ }
+
+ #[test]
+ fn divide_by_and_into() {
+ assert!(matches!(
+ &body("DIVIDE WS-A BY WS-B GIVING WS-C.")[0],
+ Stmt::Divide { by_form: true, .. }
+ ));
+ assert!(matches!(
+ &body("DIVIDE WS-A INTO WS-B.")[0],
+ Stmt::Divide { by_form: false, .. }
+ ));
+ }
+
+ #[test]
+ fn if_else_end_if() {
+ let b = body("IF WS-X > 0 DISPLAY 'POS' ELSE DISPLAY 'NEG' END-IF.");
+ match &b[0] {
+ Stmt::If {
+ cond,
+ then_branch,
+ else_branch,
+ } => {
+ assert_eq!(
+ cond,
+ &Cond::Compare {
+ lhs: Operand::Data("WS-X".into()),
+ op: CmpOp::Gt,
+ rhs: Operand::Num("0".into()),
+ }
+ );
+ assert_eq!(then_branch.len(), 1);
+ assert_eq!(else_branch.len(), 1);
+ }
+ other => panic!("se esperaba IF, vino {other:?}"),
+ }
+ }
+
+ #[test]
+ fn if_condition_with_and() {
+ let b = body("IF A = 1 AND B = 2 CONTINUE END-IF.");
+ match &b[0] {
+ Stmt::If { cond, .. } => {
+ assert!(matches!(cond, Cond::And(_, _)));
+ }
+ other => panic!("se esperaba IF, vino {other:?}"),
+ }
+ }
+
+ #[test]
+ fn if_named_condition() {
+ // Un dato suelto en la condición es un nombre de condición (88).
+ let b = body("IF FLAG-IS-OK MOVE 1 TO X END-IF.");
+ match &b[0] {
+ Stmt::If { cond, .. } => {
+ assert_eq!(cond, &Cond::Named("FLAG-IS-OK".into()));
+ }
+ other => panic!("se esperaba IF, vino {other:?}"),
+ }
+ }
+
+ #[test]
+ fn perform_paragraph_and_times() {
+ assert_eq!(
+ body("PERFORM SUB-PARA."),
+ vec![Stmt::Perform(Perform {
+ target: PerformTarget::Paragraph {
+ name: "SUB-PARA".into(),
+ thru: None,
+ },
+ control: PerformControl::Once,
+ })]
+ );
+ assert_eq!(
+ body("PERFORM SUB-PARA 3 TIMES."),
+ vec![Stmt::Perform(Perform {
+ target: PerformTarget::Paragraph {
+ name: "SUB-PARA".into(),
+ thru: None,
+ },
+ control: PerformControl::Times(Operand::Num("3".into())),
+ })]
+ );
+ }
+
+ #[test]
+ fn perform_inline_until() {
+ let b = body("PERFORM UNTIL WS-DONE = 1 ADD 1 TO WS-CT END-PERFORM.");
+ match &b[0] {
+ Stmt::Perform(p) => {
+ assert!(matches!(p.control, PerformControl::Until(_)));
+ match &p.target {
+ PerformTarget::Inline(body) => assert_eq!(body.len(), 1),
+ other => panic!("se esperaba cuerpo en línea, vino {other:?}"),
+ }
+ }
+ other => panic!("se esperaba PERFORM, vino {other:?}"),
+ }
+ }
+
+ #[test]
+ fn several_statements_in_one_sentence() {
+ let b = body("MOVE 1 TO X DISPLAY X STOP RUN.");
+ assert_eq!(b.len(), 3);
+ assert!(matches!(b[0], Stmt::Move { .. }));
+ assert!(matches!(b[1], Stmt::Display { .. }));
+ assert_eq!(b[2], Stmt::StopRun);
+ }
+
+ #[test]
+ fn unrecognized_verb_becomes_unknown() {
+ let b = body("INSPECT WS-X TALLYING WS-N FOR ALL ' '.");
+ match &b[0] {
+ Stmt::Unknown { verb, tokens } => {
+ assert_eq!(verb, "INSPECT");
+ assert!(!tokens.is_empty());
+ }
+ other => panic!("se esperaba Unknown, vino {other:?}"),
+ }
+ }
+
+ #[test]
+ fn full_program_lowers() {
+ let program = ir("IDENTIFICATION DIVISION.\n\
+ PROGRAM-ID. ADDER.\n\
+ DATA DIVISION.\n\
+ WORKING-STORAGE SECTION.\n\
+ 01 WS-A PIC 9(3) VALUE 10.\n\
+ 01 WS-T PIC 9(4).\n\
+ PROCEDURE DIVISION.\n\
+ MAIN-PARA.\n\
+ COMPUTE WS-T = WS-A + 5.\n\
+ DISPLAY WS-T.\n\
+ STOP RUN.\n");
+ assert_eq!(program.program_id, "ADDER");
+ assert_eq!(program.data.len(), 2);
+ assert_eq!(program.procedures.len(), 1);
+ assert_eq!(program.procedures[0].name, "MAIN-PARA");
+ assert_eq!(program.procedures[0].body.len(), 3);
+ }
+}
diff --git a/crates/modules/charka/charka-ir/src/stmt.rs b/crates/modules/charka/charka-ir/src/stmt.rs
new file mode 100644
index 0000000..4dff640
--- /dev/null
+++ b/crates/modules/charka/charka-ir/src/stmt.rs
@@ -0,0 +1,402 @@
+//! Parseo de los statements del PROCEDURE division. COBOL no separa
+//! statements con un símbolo: cada uno termina donde empieza el verbo
+//! del siguiente, por eso las listas de operandos se cortan al ver una
+//! palabra "frontera" (ver [`crate::kw`]).
+
+use charka_parser::TokenKind;
+
+use crate::ast::{Operand, Perform, PerformControl, PerformTarget, Stmt};
+use crate::cursor::{parse_operand, Cursor};
+use crate::expr::{parse_cond, parse_expr};
+use crate::kw::{is_boundary, is_terminator, is_verb};
+
+/// Parsea statements hasta agotar los tokens o toparse con una palabra
+/// de `stops` (los terminadores del bloque que llama).
+pub(crate) fn parse_statements(c: &mut Cursor, stops: &[&str]) -> Vec<Stmt> {
+ let mut out = Vec::new();
+ while !c.done() {
+ if let Some(w) = c.peek_word() {
+ if stops.contains(&w.as_str()) {
+ break;
+ }
+ }
+ out.push(parse_one_stmt(c, stops));
+ }
+ out
+}
+
+/// Parsea un statement: despacha por el verbo. Todo parser consume al
+/// menos un token, así que el bucle de [`parse_statements`] progresa.
+fn parse_one_stmt(c: &mut Cursor, stops: &[&str]) -> Stmt {
+ match c.peek_word().unwrap_or_default().as_str() {
+ "MOVE" => parse_move(c),
+ "DISPLAY" => parse_display(c),
+ "ACCEPT" => parse_accept(c),
+ "COMPUTE" => parse_compute(c),
+ "ADD" => parse_add(c),
+ "SUBTRACT" => parse_subtract(c),
+ "MULTIPLY" => parse_multiply(c),
+ "DIVIDE" => parse_divide(c),
+ "IF" => parse_if(c),
+ "PERFORM" => parse_perform(c),
+ "GO" => parse_goto(c),
+ "STOP" => parse_stop(c),
+ "GOBACK" => {
+ c.bump();
+ Stmt::Goback
+ }
+ "EXIT" => parse_exit(c),
+ "CONTINUE" => {
+ c.bump();
+ Stmt::Continue
+ }
+ _ => parse_unknown(c, stops),
+ }
+}
+
+// ── Listas ────────────────────────────────────────────────────────
+
+/// Lee una lista de nombres de dato (separados por comas opcionales),
+/// hasta una palabra frontera. Consume las apariciones de `ROUNDED`.
+fn parse_name_list(c: &mut Cursor, rounded: &mut bool) -> Vec<String> {
+ let mut names = Vec::new();
+ loop {
+ c.eat_sym(",");
+ if c.eat_word("ROUNDED") {
+ *rounded = true;
+ continue;
+ }
+ match c.peek_word() {
+ Some(w) if !is_boundary(&w) => {
+ c.bump();
+ names.push(w);
+ }
+ _ => break,
+ }
+ }
+ names
+}
+
+/// Lee una lista de operandos hasta una palabra frontera.
+fn parse_operand_list(c: &mut Cursor) -> Vec<Operand> {
+ let mut ops = Vec::new();
+ loop {
+ c.eat_sym(",");
+ if c.done() {
+ break;
+ }
+ if let Some(w) = c.peek_word() {
+ if is_boundary(&w) {
+ break;
+ }
+ }
+ let is_start = matches!(
+ c.peek().map(|t| t.kind),
+ Some(TokenKind::Number | TokenKind::String | TokenKind::Word)
+ ) || c.at_sym("-")
+ || c.at_sym("+");
+ if !is_start {
+ break;
+ }
+ ops.push(parse_operand(c));
+ }
+ ops
+}
+
+/// Salta los tokens de una cláusula que la v1 no modela, hasta el
+/// siguiente verbo o terminador de ámbito.
+fn skip_to_stmt_boundary(c: &mut Cursor) {
+ while !c.done() {
+ if let Some(w) = c.peek_word() {
+ if is_verb(&w) || is_terminator(&w) {
+ break;
+ }
+ }
+ c.bump();
+ }
+}
+
+/// Lee un único nombre de dato, si lo hay y no es una palabra frontera.
+fn parse_one_name(c: &mut Cursor) -> Option<String> {
+ match c.peek_word() {
+ Some(w) if !is_boundary(&w) => {
+ c.bump();
+ Some(w)
+ }
+ _ => None,
+ }
+}
+
+// ── Statements ────────────────────────────────────────────────────
+
+fn parse_move(c: &mut Cursor) -> Stmt {
+ c.bump(); // MOVE
+ c.eat_word("CORRESPONDING");
+ c.eat_word("CORR");
+ let from = parse_operand(c);
+ c.eat_word("TO");
+ let mut rounded = false;
+ let to = parse_name_list(c, &mut rounded);
+ Stmt::Move { from, to }
+}
+
+fn parse_display(c: &mut Cursor) -> Stmt {
+ c.bump(); // DISPLAY
+ let items = parse_operand_list(c);
+ skip_to_stmt_boundary(c); // p. ej. `WITH NO ADVANCING`, `UPON ...`
+ Stmt::Display { items }
+}
+
+fn parse_accept(c: &mut Cursor) -> Stmt {
+ c.bump(); // ACCEPT
+ let into = parse_one_name(c).unwrap_or_default();
+ skip_to_stmt_boundary(c); // p. ej. `FROM DATE`
+ Stmt::Accept { into }
+}
+
+fn parse_compute(c: &mut Cursor) -> Stmt {
+ c.bump(); // COMPUTE
+ let mut rounded = false;
+ let targets = parse_name_list(c, &mut rounded);
+ if !c.eat_sym("=") {
+ c.eat_word("EQUAL");
+ }
+ let expr = parse_expr(c);
+ c.eat_word("END-COMPUTE");
+ Stmt::Compute {
+ targets,
+ rounded,
+ expr,
+ }
+}
+
+fn parse_add(c: &mut Cursor) -> Stmt {
+ c.bump(); // ADD
+ c.eat_word("CORRESPONDING");
+ c.eat_word("CORR");
+ let addends = parse_operand_list(c);
+ let mut rounded = false;
+ let mut to = Vec::new();
+ let mut giving = Vec::new();
+ if c.eat_word("TO") {
+ to = parse_name_list(c, &mut rounded);
+ }
+ if c.eat_word("GIVING") {
+ giving = parse_name_list(c, &mut rounded);
+ }
+ c.eat_word("END-ADD");
+ Stmt::Add {
+ addends,
+ to,
+ giving,
+ rounded,
+ }
+}
+
+fn parse_subtract(c: &mut Cursor) -> Stmt {
+ c.bump(); // SUBTRACT
+ c.eat_word("CORRESPONDING");
+ c.eat_word("CORR");
+ let amounts = parse_operand_list(c);
+ let mut rounded = false;
+ let mut from = Vec::new();
+ let mut giving = Vec::new();
+ if c.eat_word("FROM") {
+ from = parse_name_list(c, &mut rounded);
+ }
+ if c.eat_word("GIVING") {
+ giving = parse_name_list(c, &mut rounded);
+ }
+ c.eat_word("END-SUBTRACT");
+ Stmt::Subtract {
+ amounts,
+ from,
+ giving,
+ rounded,
+ }
+}
+
+fn parse_multiply(c: &mut Cursor) -> Stmt {
+ c.bump(); // MULTIPLY
+ let left = parse_operand(c);
+ c.eat_word("BY");
+ let by = parse_operand(c);
+ let mut rounded = false;
+ let mut giving = Vec::new();
+ if c.eat_word("GIVING") {
+ giving = parse_name_list(c, &mut rounded);
+ } else if c.eat_word("ROUNDED") {
+ rounded = true;
+ }
+ c.eat_word("END-MULTIPLY");
+ Stmt::Multiply {
+ left,
+ by,
+ giving,
+ rounded,
+ }
+}
+
+fn parse_divide(c: &mut Cursor) -> Stmt {
+ c.bump(); // DIVIDE
+ let left = parse_operand(c);
+ let by_form = if c.eat_word("BY") {
+ true
+ } else {
+ c.eat_word("INTO");
+ false
+ };
+ let right = parse_operand(c);
+ let mut rounded = false;
+ let mut giving = Vec::new();
+ if c.eat_word("GIVING") {
+ giving = parse_name_list(c, &mut rounded);
+ } else if c.eat_word("ROUNDED") {
+ rounded = true;
+ }
+ if c.eat_word("REMAINDER") {
+ let _ = parse_name_list(c, &mut rounded);
+ }
+ c.eat_word("END-DIVIDE");
+ Stmt::Divide {
+ left,
+ right,
+ by_form,
+ giving,
+ rounded,
+ }
+}
+
+fn parse_if(c: &mut Cursor) -> Stmt {
+ c.bump(); // IF
+ let cond = parse_cond(c);
+ c.eat_word("THEN");
+ let then_branch = parse_statements(c, &["ELSE", "END-IF"]);
+ let else_branch = if c.eat_word("ELSE") {
+ parse_statements(c, &["END-IF"])
+ } else {
+ Vec::new()
+ };
+ c.eat_word("END-IF");
+ Stmt::If {
+ cond,
+ then_branch,
+ else_branch,
+ }
+}
+
+fn parse_perform(c: &mut Cursor) -> Stmt {
+ c.bump(); // PERFORM
+
+ // `PERFORM UNTIL cond ... END-PERFORM` — cuerpo en línea.
+ if c.eat_word("UNTIL") {
+ let cond = parse_cond(c);
+ let body = parse_statements(c, &["END-PERFORM"]);
+ c.eat_word("END-PERFORM");
+ return inline_perform(body, PerformControl::Until(cond));
+ }
+
+ // `PERFORM n TIMES ... END-PERFORM` — cuerpo en línea.
+ if matches!(c.peek().map(|t| t.kind), Some(TokenKind::Number)) {
+ let n = parse_operand(c);
+ c.eat_word("TIMES");
+ let body = parse_statements(c, &["END-PERFORM"]);
+ c.eat_word("END-PERFORM");
+ return inline_perform(body, PerformControl::Times(n));
+ }
+
+ // `PERFORM ... END-PERFORM` — cuerpo en línea, una vez.
+ if c.peek_word().map(|w| is_verb(&w)).unwrap_or(false) {
+ let body = parse_statements(c, &["END-PERFORM"]);
+ c.eat_word("END-PERFORM");
+ return inline_perform(body, PerformControl::Once);
+ }
+
+ // `PERFORM PARA [THRU PARA2] [n TIMES | UNTIL cond]` — fuera de línea.
+ let Some(name) = parse_one_name(c) else {
+ // Forma no soportada (p. ej. `PERFORM VARYING`): perform vacío.
+ return inline_perform(Vec::new(), PerformControl::Once);
+ };
+ let thru = if c.eat_word("THRU") || c.eat_word("THROUGH") {
+ parse_one_name(c)
+ } else {
+ None
+ };
+ let control = if c.eat_word("UNTIL") {
+ PerformControl::Until(parse_cond(c))
+ } else if at_count(c) {
+ let n = parse_operand(c);
+ c.eat_word("TIMES");
+ PerformControl::Times(n)
+ } else {
+ PerformControl::Once
+ };
+ Stmt::Perform(Perform {
+ target: PerformTarget::Paragraph { name, thru },
+ control,
+ })
+}
+
+/// Arma un `PERFORM` con cuerpo en línea.
+fn inline_perform(body: Vec<Stmt>, control: PerformControl) -> Stmt {
+ Stmt::Perform(Perform {
+ target: PerformTarget::Inline(body),
+ control,
+ })
+}
+
+/// ¿El cursor está sobre `<operando> TIMES`?
+fn at_count(c: &Cursor) -> bool {
+ match c.peek().map(|t| t.kind) {
+ Some(TokenKind::Number) => true,
+ Some(TokenKind::Word) => {
+ let w = c.peek_word().unwrap_or_default();
+ !is_boundary(&w) && c.word_at(1).as_deref() == Some("TIMES")
+ }
+ _ => false,
+ }
+}
+
+fn parse_goto(c: &mut Cursor) -> Stmt {
+ c.bump(); // GO
+ c.eat_word("TO");
+ Stmt::GoTo {
+ target: parse_one_name(c).unwrap_or_default(),
+ }
+}
+
+fn parse_stop(c: &mut Cursor) -> Stmt {
+ c.bump(); // STOP
+ c.eat_word("RUN");
+ Stmt::StopRun
+}
+
+fn parse_exit(c: &mut Cursor) -> Stmt {
+ c.bump(); // EXIT
+ c.eat_word("PROGRAM");
+ c.eat_word("PARAGRAPH");
+ c.eat_word("PERFORM");
+ c.eat_word("SECTION");
+ Stmt::Exit
+}
+
+/// Verbo no soportado: conserva el verbo y sus tokens hasta el próximo
+/// statement (otro verbo), terminador de ámbito o tope del bloque.
+fn parse_unknown(c: &mut Cursor, stops: &[&str]) -> Stmt {
+ let verb = c.peek_word().unwrap_or_default();
+ let mut tokens = Vec::new();
+ if let Some(t) = c.bump() {
+ tokens.push(t);
+ }
+ while !c.done() {
+ if let Some(w) = c.peek_word() {
+ if stops.contains(&w.as_str()) || is_verb(&w) || is_terminator(&w) {
+ break;
+ }
+ }
+ if let Some(t) = c.bump() {
+ tokens.push(t);
+ }
+ }
+ Stmt::Unknown { verb, tokens }
+}
diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md
index 7de7539..4c6d898 100644
--- a/docs/changelog/charka.md
+++ b/docs/changelog/charka.md
@@ -3,6 +3,37 @@
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-ir): representación intermedia — statements tipados
+
+Crate nuevo `crates/modules/charka/charka-ir` — la tercera etapa del
+pipeline: `Program` → `Ir`. El PROCEDURE division pasa de sentencias
+con tokens crudos a un árbol de instrucciones tipadas.
+
+- `lower(&Program) -> Ir` — total y tolerante: nunca falla.
+- `Ir { program_id, data, procedures }`. El modelo de datos (la DATA
+ division) pasa tal cual — sirve de tabla de símbolos.
+- `Stmt` cubre `MOVE`, `DISPLAY`, `ACCEPT`, `COMPUTE`, `ADD`,
+ `SUBTRACT`, `MULTIPLY`, `DIVIDE`, `IF`/`ELSE`/`END-IF`, `PERFORM`
+ (fuera de línea, en línea, `TIMES`, `UNTIL`), `GO TO`, `STOP RUN`,
+ `GOBACK`, `EXIT`, `CONTINUE`.
+- Expresiones de `COMPUTE` con precedencia y paréntesis (`+ -` <
+ `* /` < `**` asociativo a derecha). Condiciones de `IF`/`UNTIL` con
+ comparadores en forma símbolo (`= < > <= >= <>`) o palabra
+ (`EQUAL TO`, `GREATER THAN`...), `AND`/`OR`/`NOT` y nombres de
+ condición (datos de nivel 88).
+- COBOL no termina los statements con un símbolo: el parser delimita
+ las listas de operandos con palabras "frontera" (verbos,
+ terminadores `END-*`/`ELSE`, conectores `TO`/`GIVING`/`BY`...).
+- Un verbo no soportado se conserva como `Stmt::Unknown { verb,
+ tokens }` — el lowering nunca aborta.
+- Fuera de alcance v1: `EVALUATE`, `STRING`/`UNSTRING`, E/S de
+ ficheros, `PERFORM VARYING`, CICS, SQL embebido.
+- 17 tests: MOVE simple y multi-destino, DISPLAY con figurativas,
+ precedencia de COMPUTE, flag ROUNDED, ADD in-place vs GIVING,
+ SUBTRACT, DIVIDE BY/INTO, IF/ELSE, condiciones con AND, nombre de
+ condición, PERFORM párrafo/TIMES/UNTIL en línea, varios statements
+ en una sentencia, verbo desconocido, programa completo.
+
### feat(charka-parser): parser COBOL'85 → AST
Crate nuevo `crates/modules/charka/charka-parser` — la segunda etapa del