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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user