feat(charka): PICTURE de edición — Z, coma de millares y punto decimal
El formateo de informes de COBOL: supresión de ceros a la izquierda,
coma de millares e inserción del punto decimal. Rebanada vertical.
- charka-lexer: el punto separador exige un espacio detrás; un punto
pegado a un carácter (ZZ9.99) ya no es terminador, sino símbolo —
el parser lo reensambla dentro de la cláusula PICTURE.
- charka-runtime: format_edited(valor, pic) — 9, Z, coma, punto, B.
- charka-ir: Field::edit guarda la PICTURE; el campo es texto.
- charka-codegen / charka-shadow: MOVE a un campo de edición pasa por
format_edited antes de almacenar.
- Corpus: 19-reporte. Sombra y crate compilado dan la misma salida.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
diff --git a/crates/apps/charka/src/main.rs b/crates/apps/charka/src/main.rs
index aada11e..2983faf 100644
--- a/crates/apps/charka/src/main.rs
+++ b/crates/apps/charka/src/main.rs
@@ -240,9 +240,7 @@ fn collect_unknowns(stmts: &[Stmt], out: &mut Vec<String>) {
collect_unknowns(other, out);
}
Stmt::Read {
- at_end,
- not_at_end,
- ..
+ at_end, not_at_end, ..
} => {
collect_unknowns(at_end, out);
collect_unknowns(not_at_end, out);
diff --git a/crates/modules/charka/SDD.md b/crates/modules/charka/SDD.md
index 9e2e894..7e40570 100644
--- a/crates/modules/charka/SDD.md
+++ b/crates/modules/charka/SDD.md
@@ -189,8 +189,8 @@ que corre el `Ir` directamente sobre `charka-runtime`, sin compilar.
## El corpus
-`crates/modules/charka/corpus/` — 18 programas COBOL graduados
-(`01-hola` … `18-fichero`), cada uno con su `.expected`. Ejercita el
+`crates/modules/charka/corpus/` — 19 programas COBOL graduados
+(`01-hola` … `19-reporte`), cada uno con su `.expected`. Ejercita el
pipeline completo de punta a punta. Ver su `README.md`.
## La CLI
@@ -203,9 +203,9 @@ no transpilados.
## Estado
-Pipeline **completo** — `charka-bcd` (22 tests), `charka-lexer` (17),
-`charka-parser` (15), `charka-ir` (17), `charka-runtime` (17),
-`charka-codegen` (14), `charka-shadow` (11) y la CLI `charka` (4)
+Pipeline **completo** — `charka-bcd` (22 tests), `charka-lexer` (18),
+`charka-parser` (17), `charka-ir` (30), `charka-runtime` (22),
+`charka-codegen` (26), `charka-shadow` (24) y la CLI `charka` (4)
implementados y verdes. COBOL → Rust corre de punta a punta, validado
contra el corpus. El crate que genera `scaffold` compila y su salida
coincide con la del intérprete sombra — las dos rutas de ejecución
diff --git a/crates/modules/charka/charka-codegen/src/lib.rs b/crates/modules/charka/charka-codegen/src/lib.rs
index a728d42..8fb39c3 100644
--- a/crates/modules/charka/charka-codegen/src/lib.rs
+++ b/crates/modules/charka/charka-codegen/src/lib.rs
@@ -474,6 +474,21 @@ mod tests {
assert!(out.contains("self.file_arch.close();"));
}
+ #[test]
+ fn edited_field_is_text_and_move_formats_it() {
+ let out = gen("DATA DIVISION.\n\
+ WORKING-STORAGE SECTION.\n\
+ 01 WS-N PIC 9(5).\n\
+ 01 WS-E PIC Z,ZZ9.99.\n\
+ PROCEDURE DIVISION.\n\
+ MAIN.\n\
+ MOVE WS-N TO WS-E.\n");
+ // El campo de edición se materializa como texto de presentación.
+ assert!(out.contains("ws_e: Text,"));
+ // El MOVE pasa por `format_edited` con la PICTURE de edición.
+ assert!(out.contains("self.ws_e.store(&format_edited(self.ws_n.value(), \"Z,ZZ9.99\"));"));
+ }
+
#[test]
fn empty_program_still_compiles_shape() {
let out = gen("");
diff --git a/crates/modules/charka/charka-codegen/src/stmt.rs b/crates/modules/charka/charka-codegen/src/stmt.rs
index 1b1f3d7..7804cdf 100644
--- a/crates/modules/charka/charka-codegen/src/stmt.rs
+++ b/crates/modules/charka/charka-codegen/src/stmt.rs
@@ -8,7 +8,8 @@ use charka_ir::{
use crate::emit::Emitter;
use crate::expr::{
- emit_cond, emit_expr, field_ref, figurative_fill, operand_decimal, operand_display, operand_str,
+ emit_cond, emit_expr, field_ref, figurative_fill, operand_decimal, operand_display,
+ operand_str, rust_str,
};
use crate::sym::{paragraph_method, FieldKind, Symbols};
@@ -136,6 +137,21 @@ fn emit_store(em: &mut Emitter, sym: &Symbols, target: &Operand, value: &str, ro
fn emit_move(em: &mut Emitter, sym: &Symbols, from: &Operand, to: &[Operand]) {
for t in to {
+ // Un destino con PICTURE de edición formatea el valor numérico.
+ if let Operand::Data(name) = t {
+ if let Some(pic) = sym.lookup(name).and_then(|f| f.edit.clone()) {
+ let ident = sym
+ .lookup(name)
+ .map(|f| f.ident.clone())
+ .unwrap_or_default();
+ em.line(&format!(
+ "self.{ident}.store(&format_edited({}, {}));",
+ operand_decimal(sym, from),
+ rust_str(&pic)
+ ));
+ continue;
+ }
+ }
match field_ref(sym, t) {
Some((lref, FieldKind::Num { .. })) => {
em.line(&format!("{lref}.store({});", operand_decimal(sym, from)));
diff --git a/crates/modules/charka/charka-codegen/src/sym.rs b/crates/modules/charka/charka-codegen/src/sym.rs
index 1fb56fa..7b5bc69 100644
--- a/crates/modules/charka/charka-codegen/src/sym.rs
+++ b/crates/modules/charka/charka-codegen/src/sym.rs
@@ -22,6 +22,8 @@ pub(crate) struct Field {
pub init: String,
/// Si es una tabla (`OCCURS n`), su número de elementos.
pub occurs: Option<u32>,
+ /// Si es un campo de edición, su PICTURE.
+ pub edit: Option<String>,
}
/// Un fichero del programa generado.
@@ -62,6 +64,7 @@ impl Symbols {
kind: f.kind,
init: f.init.clone(),
occurs: f.occurs,
+ edit: f.edit.clone(),
})
.collect();
dedup_idents(&mut fields);
diff --git a/crates/modules/charka/charka-ir/src/model.rs b/crates/modules/charka/charka-ir/src/model.rs
index 44ec964..b9f82a2 100644
--- a/crates/modules/charka/charka-ir/src/model.rs
+++ b/crates/modules/charka/charka-ir/src/model.rs
@@ -31,6 +31,9 @@ pub struct Field {
/// Si es una tabla (`OCCURS n`), su número de elementos; `None`
/// para un dato escalar.
pub occurs: Option<u32>,
+ /// Si es un campo de edición (`ZZ,ZZ9.99`), su PICTURE — para
+ /// formatear el valor al moverlo. El campo se almacena como texto.
+ pub edit: Option<String>,
}
/// Un nombre de condición — un dato de nivel 88. `IF <name>` equivale
@@ -126,7 +129,7 @@ fn walk(items: &[DataItem], model: &mut DataModel) -> Vec<String> {
}
produced.extend(members);
} else if it.name != "FILLER" {
- if let Some(kind) = classify(it.picture.as_deref()) {
+ if let Some((kind, edit)) = classify(it.picture.as_deref()) {
let init = match kind {
FieldKind::Num { .. } => numeric_value(it.value.as_deref()),
FieldKind::Text { .. } => text_value(it.value.as_deref()),
@@ -137,6 +140,7 @@ fn walk(items: &[DataItem], model: &mut DataModel) -> Vec<String> {
kind,
init,
occurs: it.occurs,
+ edit,
});
}
}
@@ -147,23 +151,37 @@ fn walk(items: &[DataItem], model: &mut DataModel) -> Vec<String> {
/// 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<FieldKind> {
+fn classify(pic: Option<&str>) -> Option<(FieldKind, Option<String>)> {
let up = pic?.to_uppercase();
if up.contains('X') || up.contains('A') {
- return Some(FieldKind::Text {
- len: pic_width(&up).max(1),
- });
+ return Some((
+ FieldKind::Text {
+ len: pic_width(&up).max(1),
+ },
+ None,
+ ));
}
if let Ok(p) = Picture::parse(&up) {
- return Some(FieldKind::Num {
- int: p.integer_digits,
- frac: p.fraction_digits,
- signed: p.signed,
- });
+ return Some((
+ FieldKind::Num {
+ int: p.integer_digits,
+ frac: p.fraction_digits,
+ signed: p.signed,
+ },
+ None,
+ ));
}
- Some(FieldKind::Text {
+ // ¿PICTURE de edición? — tiene un símbolo de edición y un dígito.
+ let kind = FieldKind::Text {
len: pic_width(&up).max(1),
- })
+ };
+ let has_edit = up.contains(['Z', ',', '.', '*']);
+ let has_digit = up.contains(['9', 'Z']);
+ if has_edit && has_digit {
+ Some((kind, Some(up)))
+ } else {
+ Some((kind, None))
+ }
}
/// Cuenta las posiciones de presentación de una PICTURE, expandiendo
diff --git a/crates/modules/charka/charka-lexer/src/lib.rs b/crates/modules/charka/charka-lexer/src/lib.rs
index 017ee34..84d52bf 100644
--- a/crates/modules/charka/charka-lexer/src/lib.rs
+++ b/crates/modules/charka/charka-lexer/src/lib.rs
@@ -149,8 +149,20 @@ fn lex_line(content: &str, line: u32, base_col: u32, out: &mut Vec<Token>) -> Re
});
i = next;
} else if c == '.' {
+ // El separador de sentencia COBOL siempre lleva un espacio
+ // (o el fin de línea) detrás. Un punto pegado a un carácter
+ // —`ZZ9.99`— no es separador: pertenece a una PICTURE de
+ // edición y se emite como símbolo para que el parser lo
+ // reensamble dentro de la cláusula.
+ let is_separator = chars
+ .get(i + 1)
+ .map_or(true, |n| n.is_whitespace());
out.push(Token {
- kind: TokenKind::Period,
+ kind: if is_separator {
+ TokenKind::Period
+ } else {
+ TokenKind::Symbol
+ },
text: ".".into(),
line,
col,
@@ -329,6 +341,19 @@ mod tests {
);
}
+ #[test]
+ fn period_inside_an_edit_picture_is_not_a_separator() {
+ // El punto de `ZZ9.99` va pegado a un dígito: es símbolo, no
+ // terminador. El punto final, con espacio detrás, sí termina.
+ let toks = kinds("PIC Z,ZZ9.99 .", SourceFormat::Free);
+ let dots: Vec<TokenKind> = toks
+ .iter()
+ .filter(|(_, t)| t == ".")
+ .map(|(k, _)| *k)
+ .collect();
+ assert_eq!(dots, vec![TokenKind::Symbol, TokenKind::Period]);
+ }
+
#[test]
fn two_and_one_char_operators() {
let toks = kinds("A <= B >= C <> D ** E + F", SourceFormat::Free);
diff --git a/crates/modules/charka/charka-runtime/src/edited.rs b/crates/modules/charka/charka-runtime/src/edited.rs
new file mode 100644
index 0000000..6fd34df
--- /dev/null
+++ b/crates/modules/charka/charka-runtime/src/edited.rs
@@ -0,0 +1,103 @@
+//! `format_edited` — el formateo de un valor numérico según una
+//! PICTURE de edición (`ZZ,ZZ9.99`).
+
+use charka_bcd::{Decimal, Rounding};
+
+/// Formatea `value` según una PICTURE de edición. Soporta `9` (dígito),
+/// `Z` (dígito con supresión de ceros a la izquierda), `,` (coma de
+/// millares, en blanco dentro de la zona suprimida), `.` (punto
+/// decimal) y `B` (espacio). El signo se descarta.
+pub fn format_edited(value: Decimal, pic: &str) -> String {
+ let up = pic.to_uppercase();
+ let (int_pic, frac_pic) = match up.split_once('.') {
+ Some((a, b)) => (a, b),
+ None => (up.as_str(), ""),
+ };
+ let count_digits = |s: &str| s.chars().filter(|c| *c == '9' || *c == 'Z').count();
+ let int_digits = count_digits(int_pic);
+ let frac_digits = count_digits(frac_pic);
+ let total = int_digits + frac_digits;
+
+ // Los dígitos del valor, con exactamente `frac_digits` decimales.
+ let mantissa = value
+ .rescale(frac_digits as u8, Rounding::Truncate)
+ .mantissa()
+ .unsigned_abs();
+ let mut digits = mantissa.to_string();
+ if digits.len() < total {
+ digits = format!("{}{}", "0".repeat(total - digits.len()), digits);
+ } else if digits.len() > total {
+ digits = digits[digits.len() - total..].to_string();
+ }
+ let int_part = &digits.as_bytes()[..int_digits];
+ let frac_part = &digits.as_bytes()[int_digits..];
+
+ let mut out = String::new();
+ let mut di = 0;
+ let mut seen = false;
+ for ch in int_pic.chars() {
+ match ch {
+ '9' => {
+ out.push(int_part[di] as char);
+ di += 1;
+ seen = true;
+ }
+ 'Z' => {
+ let d = int_part[di];
+ di += 1;
+ if seen || d != b'0' {
+ out.push(d as char);
+ seen = true;
+ } else {
+ out.push(' ');
+ }
+ }
+ ',' => out.push(if seen { ',' } else { ' ' }),
+ 'B' => out.push(' '),
+ other => out.push(other),
+ }
+ }
+ if !frac_pic.is_empty() {
+ out.push('.');
+ let mut fi = 0;
+ for ch in frac_pic.chars() {
+ match ch {
+ '9' | 'Z' => {
+ out.push(frac_part[fi] as char);
+ fi += 1;
+ }
+ other => out.push(other),
+ }
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn dec(s: &str) -> Decimal {
+ Decimal::parse(s).unwrap()
+ }
+
+ #[test]
+ fn suppresses_leading_zeros_and_inserts_commas() {
+ assert_eq!(format_edited(dec("1234.5"), "Z,ZZ9.99"), "1,234.50");
+ assert_eq!(format_edited(dec("7"), "Z,ZZ9.99"), " 7.00");
+ assert_eq!(format_edited(dec("0"), "Z,ZZ9.99"), " 0.00");
+ }
+
+ #[test]
+ fn comma_in_suppressed_zone_is_blank() {
+ // El millar va en blanco si no hay dígito significativo antes.
+ assert_eq!(format_edited(dec("42"), "ZZ,ZZ9"), " 42");
+ assert_eq!(format_edited(dec("12345"), "ZZ,ZZ9"), "12,345");
+ }
+
+ #[test]
+ fn nine_positions_always_show() {
+ assert_eq!(format_edited(dec("0"), "999"), "000");
+ assert_eq!(format_edited(dec("0"), "ZZZ"), " ");
+ }
+}
diff --git a/crates/modules/charka/charka-runtime/src/lib.rs b/crates/modules/charka/charka-runtime/src/lib.rs
index 0d86920..965b730 100644
--- a/crates/modules/charka/charka-runtime/src/lib.rs
+++ b/crates/modules/charka/charka-runtime/src/lib.rs
@@ -17,11 +17,13 @@
#![forbid(unsafe_code)]
+mod edited;
mod file;
mod num;
mod text;
pub use charka_bcd::{Decimal, Picture, Rounding};
+pub use edited::format_edited;
pub use file::CobFile;
pub use num::Num;
pub use text::Text;
diff --git a/crates/modules/charka/charka-shadow/src/interp.rs b/crates/modules/charka/charka-shadow/src/interp.rs
index 5eb0e22..a94004a 100644
--- a/crates/modules/charka/charka-shadow/src/interp.rs
+++ b/crates/modules/charka/charka-shadow/src/interp.rs
@@ -11,7 +11,7 @@ use charka_ir::{
BinOp, CmpOp, Cond, ConditionName, Expr, Figurative, FileMode, InspectOp, Ir, Operand, Perform,
PerformControl, PerformTarget, Stmt, WhenTest,
};
-use charka_runtime::{cobol_text_cmp, CobFile, Decimal, Num, Rounding, Text};
+use charka_runtime::{cobol_text_cmp, format_edited, CobFile, Decimal, Num, Rounding, Text};
use crate::field::{build_fields, Cell};
@@ -531,6 +531,15 @@ impl<'a> Machine<'a> {
/// `MOVE from` a un solo destino (escalar o elemento de tabla).
fn do_move(&mut self, from: &Operand, target: &Operand) {
+ // Un destino con PICTURE de edición formatea el valor numérico.
+ if let Operand::Data(name) = target {
+ if let Some(pic) = self.ir.model.field(name).and_then(|f| f.edit.clone()) {
+ let value = self.eval_decimal(from);
+ let text = format_edited(value, &pic);
+ self.store_text(target, &text);
+ return;
+ }
+ }
let Some((key, idx)) = self.resolve(target) else {
return;
};
diff --git a/crates/modules/charka/charka-shadow/src/lib.rs b/crates/modules/charka/charka-shadow/src/lib.rs
index 4329f06..ea7cae6 100644
--- a/crates/modules/charka/charka-shadow/src/lib.rs
+++ b/crates/modules/charka/charka-shadow/src/lib.rs
@@ -127,6 +127,7 @@ mod tests {
corpus_test!(corpus_16_bandera, "16-bandera");
corpus_test!(corpus_17_rangopar, "17-rangopar");
corpus_test!(corpus_18_fichero, "18-fichero");
+ corpus_test!(corpus_19_reporte, "19-reporte");
#[test]
fn empty_source_runs_clean() {
diff --git a/crates/modules/charka/corpus/19-reporte.cob b/crates/modules/charka/corpus/19-reporte.cob
new file mode 100644
index 0000000..79816a7
--- /dev/null
+++ b/crates/modules/charka/corpus/19-reporte.cob
@@ -0,0 +1,22 @@
+* corpus charka — nivel 19: PICTURE de edición (Z, coma, punto)
+IDENTIFICATION DIVISION.
+PROGRAM-ID. REPORTE.
+DATA DIVISION.
+WORKING-STORAGE SECTION.
+01 WS-A PIC 9(5)V99 VALUE 1234.5.
+01 WS-B PIC 9(5)V99 VALUE 7.
+01 WS-EDIT PIC Z,ZZZ,ZZ9.99.
+01 WS-CONT PIC ZZZ,ZZ9.
+PROCEDURE DIVISION.
+MAIN.
+ MOVE WS-A TO WS-EDIT.
+ DISPLAY '[' WS-EDIT ']'.
+ MOVE WS-B TO WS-EDIT.
+ DISPLAY '[' WS-EDIT ']'.
+ MOVE 0 TO WS-EDIT.
+ DISPLAY '[' WS-EDIT ']'.
+ MOVE 234567 TO WS-CONT.
+ DISPLAY '[' WS-CONT ']'.
+ MOVE 89 TO WS-CONT.
+ DISPLAY '[' WS-CONT ']'.
+ STOP RUN.
diff --git a/crates/modules/charka/corpus/19-reporte.expected b/crates/modules/charka/corpus/19-reporte.expected
new file mode 100644
index 0000000..a0bdfc7
--- /dev/null
+++ b/crates/modules/charka/corpus/19-reporte.expected
@@ -0,0 +1,5 @@
+[ 1,234.50]
+[ 7.00]
+[ 0.00]
+[234,567]
+[ 89]
diff --git a/crates/modules/charka/corpus/README.md b/crates/modules/charka/corpus/README.md
index 639c2da..f17198b 100644
--- a/crates/modules/charka/corpus/README.md
+++ b/crates/modules/charka/corpus/README.md
@@ -27,6 +27,7 @@ salida correcta, una línea por `DISPLAY`.
| `16-bandera` | 5 | `SET` de nombres de condición (nivel 88) a `TRUE` |
| `17-rangopar` | 5 | `PERFORM ... THRU` — un rango de párrafos |
| `18-fichero` | 7 | E/S de ficheros: `SELECT`/`FD`/`OPEN`/`READ`/`WRITE`|
+| `19-reporte` | 6 | PICTURE de edición (`Z,ZZ9.99`) — formato de informe|
## Formato
diff --git a/docs/changelog/charka.md b/docs/changelog/charka.md
index 4c77013..5257a78 100644
--- a/docs/changelog/charka.md
+++ b/docs/changelog/charka.md
@@ -3,6 +3,29 @@
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): PICTURE de edición — `Z`, coma de millares y punto decimal
+
+Las PICTURE de edición (`Z,ZZ9.99`) — el formateo de informes de
+COBOL: supresión de ceros a la izquierda, coma de millares e inserción
+del punto decimal. Una rebanada vertical por el pipeline.
+
+- `charka-lexer`: el punto separador de COBOL siempre lleva un espacio
+ detrás. Un punto pegado a un carácter —`ZZ9.99`— ya no se toma como
+ terminador de sentencia: se emite como símbolo, para que el parser
+ lo reensamble dentro de la cláusula PICTURE.
+- `charka-runtime`: `format_edited(valor, pic)` — formatea un decimal
+ según una PICTURE de edición: `9` (dígito), `Z` (dígito con
+ supresión de ceros), `,` (coma, en blanco en la zona suprimida),
+ `.` (punto decimal) y `B` (espacio).
+- `charka-ir`: el modelo de datos distingue una PICTURE de edición —
+ `Field::edit` guarda la PICTURE; el campo se materializa como texto
+ de presentación.
+- `charka-codegen` y `charka-shadow`: un `MOVE` a un campo de edición
+ pasa el valor por `format_edited` antes de almacenarlo.
+- Corpus: programa nuevo `19-reporte` — formatea importes y contadores
+ con `Z,ZZZ,ZZ9.99` y `ZZZ,ZZ9`. Verificado: el intérprete sombra y
+ el crate compilado dan la misma salida.
+
### feat(charka): E/S de ficheros — SELECT / FD / OPEN / READ / WRITE / CLOSE
El gran hueco que faltaba para el COBOL real: el procesamiento de