feat(charka): charka-bcd — aritmética decimal con semántica COBOL

Cimiento numérico del transpilador. Picture parsea la cláusula
PICTURE (9, V, S, 9(n)); Decimal es punto fijo exacto (mantissa i128
+ scale) con suma/resta/producto exactos, división con escala de
resultado fija, redondeo Truncate/HalfUp y coerce a un Picture con
detección de desbordamiento (ON SIZE ERROR).

22 tests. Determinista, sin deps de plataforma — base de Fase D.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 17:22:40 +00:00
parent 9e7fa17411
commit 737ae5a696
8 changed files with 653 additions and 0 deletions
@@ -0,0 +1,149 @@
//! La cláusula `PICTURE` — la forma declarada de un campo numérico COBOL.
//!
//! Sólo el subconjunto numérico: `9` (dígito), `V` (punto decimal
//! implícito), `S` (signo), y la repetición `9(n)`. Lo de edición
//! (`Z`, `*`, `,`, `.`, `$`, `B`…) es presentación y se trata aparte.
use serde::{Deserialize, Serialize};
use crate::BcdError;
/// La forma de un campo numérico: cuántos dígitos enteros, cuántos
/// fraccionarios y si admite signo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Picture {
pub integer_digits: u8,
pub fraction_digits: u8,
pub signed: bool,
}
impl Picture {
/// Construye una `Picture` directa.
pub fn new(integer_digits: u8, fraction_digits: u8, signed: bool) -> Self {
Self { integer_digits, fraction_digits, signed }
}
/// Dígitos totales del campo (enteros + fraccionarios).
pub fn total_digits(&self) -> u8 {
self.integer_digits + self.fraction_digits
}
/// Parsea una cláusula PICTURE — `"9(5)V99"`, `"S9(3)"`, `"9999"`.
/// Acepta el prefijo `PIC ` / `PICTURE ` opcional.
pub fn parse(src: &str) -> Result<Picture, BcdError> {
let up = src.trim().to_ascii_uppercase();
let body = up
.strip_prefix("PICTURE ")
.or_else(|| up.strip_prefix("PIC "))
.unwrap_or(&up)
.trim();
let chars: Vec<char> = body.chars().collect();
let mut i = 0;
let mut signed = false;
let mut integer_digits: u32 = 0;
let mut fraction_digits: u32 = 0;
let mut seen_v = false;
// El signo, si lo hay, va primero.
if chars.first() == Some(&'S') {
signed = true;
i = 1;
}
let bad = || BcdError::BadPicture(src.to_string());
while i < chars.len() {
match chars[i] {
'V' => {
if seen_v {
return Err(bad()); // dos puntos decimales
}
seen_v = true;
i += 1;
}
'9' => {
// Cuenta este 9 y un posible '(n)' que lo siga.
let mut count: u32 = 1;
i += 1;
if chars.get(i) == Some(&'(') {
i += 1;
let start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
if start == i || chars.get(i) != Some(&')') {
return Err(bad());
}
let n: u32 = chars[start..i]
.iter()
.collect::<String>()
.parse()
.map_err(|_| bad())?;
count = n;
i += 1; // consume ')'
}
if seen_v {
fraction_digits += count;
} else {
integer_digits += count;
}
}
_ => return Err(bad()),
}
}
let total = integer_digits + fraction_digits;
if total == 0 || total > 38 {
// i128 soporta 38 dígitos decimales.
return Err(bad());
}
Ok(Picture {
integer_digits: integer_digits as u8,
fraction_digits: fraction_digits as u8,
signed,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_integer_and_fraction() {
let p = Picture::parse("9(5)V99").unwrap();
assert_eq!(p, Picture::new(5, 2, false));
}
#[test]
fn parses_signed() {
let p = Picture::parse("S9(3)").unwrap();
assert_eq!(p, Picture::new(3, 0, true));
assert!(p.signed);
}
#[test]
fn parses_repeated_nines() {
assert_eq!(Picture::parse("9999V9").unwrap(), Picture::new(4, 1, false));
}
#[test]
fn accepts_pic_prefix() {
assert_eq!(Picture::parse("PIC 9(2)").unwrap(), Picture::new(2, 0, false));
assert_eq!(Picture::parse("PICTURE S9V9").unwrap(), Picture::new(1, 1, true));
}
#[test]
fn rejects_garbage_and_double_v() {
assert!(Picture::parse("X(3)").is_err());
assert!(Picture::parse("9V9V9").is_err());
assert!(Picture::parse("").is_err());
assert!(Picture::parse("9(").is_err());
}
#[test]
fn total_digits_sums_both_parts() {
assert_eq!(Picture::parse("9(7)V999").unwrap().total_digits(), 10);
}
}