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>
This commit is contained in:
sergio
2026-05-21 23:00:15 +00:00
parent b3278bdb0c
commit 634a43006a
15 changed files with 264 additions and 23 deletions
+1 -3
View File
@@ -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);
+5 -5
View File
@@ -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
@@ -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("");
@@ -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)));
@@ -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);
+30 -12
View File
@@ -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
+26 -1
View File
@@ -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);
@@ -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"), " ");
}
}
@@ -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;
@@ -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;
};
@@ -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() {
@@ -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.
@@ -0,0 +1,5 @@
[ 1,234.50]
[ 7.00]
[ 0.00]
[234,567]
[ 89]
+1
View File
@@ -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
+23
View File
@@ -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