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
@@ -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;