From 737ae5a696250c7e323aafdcdbc7f3adf985e0ff Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 17:22:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(charka):=20charka-bcd=20=E2=80=94=20aritm?= =?UTF-8?q?=C3=A9tica=20decimal=20con=20sem=C3=A1ntica=20COBOL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .vamos.txt.kate-swp | Bin 4089 -> 4988 bytes Cargo.lock | 8 + Cargo.toml | 5 + crates/modules/charka/SDD.md | 43 ++ crates/modules/charka/charka-bcd/Cargo.toml | 12 + .../modules/charka/charka-bcd/src/decimal.rs | 400 ++++++++++++++++++ crates/modules/charka/charka-bcd/src/lib.rs | 36 ++ .../modules/charka/charka-bcd/src/picture.rs | 149 +++++++ 8 files changed, 653 insertions(+) create mode 100644 crates/modules/charka/SDD.md create mode 100644 crates/modules/charka/charka-bcd/Cargo.toml create mode 100644 crates/modules/charka/charka-bcd/src/decimal.rs create mode 100644 crates/modules/charka/charka-bcd/src/lib.rs create mode 100644 crates/modules/charka/charka-bcd/src/picture.rs diff --git a/.vamos.txt.kate-swp b/.vamos.txt.kate-swp index 917c06c615b4c2773adb5ba46f8eb2ee77a94d1f..65b49be13550c50a58fa8ca8251abfcfd25815c0 100644 GIT binary patch delta 704 zcmew<|3_`ZPyYJga0UkE1q=)f44w=O%nLyb1_lOx1%IF zAjTq)B9Q9EAO-^igO-AVV?jZ&f_p%yr^2I#DGGV1$*IMeCHV@4rKt)ziNy+Ksfoo3 zC5Lxef%Jp4se|+{VPKF48)2!SkepbQp07|;nx~*!ppcTEo0*qcnya8N`5v!|`Vs~P z1`CjerC`+z4B=?%(-QL%6%;0`^SKFulmvm)E`zEynq1E(U9X^^tdN?cP@IvPlcSK5 zs$i^8m7kYb3~~-gp%I9&9HaxJbp?pQz`$Unpx~04n_sNp>;v&vWg^r!pr9!%J-kw( zGG9j_IlrhVHBTWaGc^zFKah5PkU1-%=7cFoqWUi}J-;ZiptK0&Xf1vf=d#qI)RfHR zlKf(Yg8U+dg8ZDrYfCbd6G5&584?OIZ57nCU^LGsC8m@rC`?|)Z&wde4^jpWfYng- zacJsu6H794QW6y)Kv^L(uPimMB(W$nU*S>PB!!gx 38). +fn pow10(n: u32) -> i128 { + 10i128.pow(n) +} + +/// Cuántos dígitos decimales tiene `n` (`0` tiene cero dígitos). +fn digit_count(mut n: u128) -> u32 { + let mut d = 0; + while n > 0 { + n /= 10; + d += 1; + } + d +} + +/// Un decimal de punto fijo exacto: `mantissa / 10^scale`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Decimal { + mantissa: i128, + scale: u8, +} + +impl Decimal { + /// Construye desde mantisa y escala crudas. + pub fn new(mantissa: i128, scale: u8) -> Self { + Self { mantissa, scale } + } + + /// El cero (escala 0). + pub fn zero() -> Self { + Self { mantissa: 0, scale: 0 } + } + + /// Un entero como decimal de escala 0. + pub fn from_integer(value: i128) -> Self { + Self { mantissa: value, scale: 0 } + } + + /// Mantisa cruda. + pub fn mantissa(&self) -> i128 { + self.mantissa + } + + /// Escala — cantidad de dígitos fraccionarios. + pub fn scale(&self) -> u8 { + self.scale + } + + pub fn is_zero(&self) -> bool { + self.mantissa == 0 + } + + pub fn is_negative(&self) -> bool { + self.mantissa < 0 + } + + /// Parsea un literal numérico — `"123.45"`, `"-7"`, `"+0.001"`, `".5"`. + pub fn parse(src: &str) -> Result { + let t = src.trim(); + let bad = || BcdError::BadNumber(src.to_string()); + let (neg, rest) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t.strip_prefix('+').unwrap_or(t)), + }; + if !rest.bytes().any(|b| b.is_ascii_digit()) { + return Err(bad()); + } + let (int_str, frac_str) = rest.split_once('.').unwrap_or((rest, "")); + let int_str = if int_str.is_empty() { "0" } else { int_str }; + if !int_str.bytes().all(|b| b.is_ascii_digit()) + || !frac_str.bytes().all(|b| b.is_ascii_digit()) + { + return Err(bad()); + } + let mut mantissa: i128 = + format!("{int_str}{frac_str}").parse().map_err(|_| bad())?; + if neg { + mantissa = -mantissa; + } + Ok(Decimal { mantissa, scale: frac_str.len() as u8 }) + } + + /// Devuelve el mismo valor expresado en `target_scale`. Subir de + /// escala es exacto; bajar pierde dígitos según `rounding`. + pub fn rescale(&self, target_scale: u8, rounding: Rounding) -> Decimal { + match target_scale.cmp(&self.scale) { + Ordering::Equal => *self, + Ordering::Greater => { + let factor = pow10((target_scale - self.scale) as u32); + Decimal { mantissa: self.mantissa * factor, scale: target_scale } + } + Ordering::Less => { + let divisor = pow10((self.scale - target_scale) as u32); + let q = self.mantissa / divisor; + let r = self.mantissa % divisor; + let m = match rounding { + Rounding::Truncate => q, + Rounding::HalfUp => { + if r.unsigned_abs() * 2 >= divisor.unsigned_abs() { + q + self.mantissa.signum() + } else { + q + } + } + }; + Decimal { mantissa: m, scale: target_scale } + } + } + } + + /// Lleva dos decimales a una escala común (la mayor, sin pérdida). + fn aligned(&self, other: &Decimal) -> (i128, i128, u8) { + let s = self.scale.max(other.scale); + ( + self.rescale(s, Rounding::Truncate).mantissa, + other.rescale(s, Rounding::Truncate).mantissa, + s, + ) + } + + /// Suma exacta. + pub fn add(&self, other: &Decimal) -> Decimal { + let (a, b, s) = self.aligned(other); + Decimal { mantissa: a + b, scale: s } + } + + /// Resta exacta. + pub fn sub(&self, other: &Decimal) -> Decimal { + let (a, b, s) = self.aligned(other); + Decimal { mantissa: a - b, scale: s } + } + + /// Producto exacto — la escala del resultado es la suma de escalas. + pub fn mul(&self, other: &Decimal) -> Decimal { + Decimal { + mantissa: self.mantissa * other.mantissa, + scale: self.scale.saturating_add(other.scale), + } + } + + /// División con la escala del resultado fijada de antemano (como en + /// COBOL, donde el campo receptor define la precisión). Error si el + /// divisor es cero o si un producto intermedio se sale de `i128`. + pub fn div( + &self, + other: &Decimal, + result_scale: u8, + rounding: Rounding, + ) -> Result { + if other.mantissa == 0 { + return Err(BcdError::DivByZero); + } + let num_pow = other.scale as u32 + result_scale as u32; + let numerator = self + .mantissa + .checked_mul(10i128.checked_pow(num_pow).ok_or(BcdError::Overflow)?) + .ok_or(BcdError::Overflow)?; + let denominator = other + .mantissa + .checked_mul(10i128.checked_pow(self.scale as u32).ok_or(BcdError::Overflow)?) + .ok_or(BcdError::Overflow)?; + let q = numerator / denominator; + let r = numerator % denominator; + let m = match rounding { + Rounding::Truncate => q, + Rounding::HalfUp => { + if r.unsigned_abs() * 2 >= denominator.unsigned_abs() { + q + numerator.signum() * denominator.signum() + } else { + q + } + } + }; + Ok(Decimal { mantissa: m, scale: result_scale }) + } + + /// `true` si el valor entra en el campo `pic` sin perder dígitos + /// enteros (la parte fraccionaria se ajusta, no desborda). + pub fn fits(&self, pic: &Picture) -> bool { + let r = self.rescale(pic.fraction_digits, Rounding::Truncate); + let int_part = r.mantissa.unsigned_abs() / 10u128.pow(pic.fraction_digits as u32); + digit_count(int_part) <= pic.integer_digits as u32 + } + + /// Almacena el valor en un campo `pic` — la operación `MOVE` de + /// COBOL. Ajusta la escala con `rounding`; un campo sin signo guarda + /// la magnitud; si la parte entera no cabe devuelve [`BcdError::Overflow`] + /// (el `ON SIZE ERROR` de COBOL). + pub fn coerce(&self, pic: &Picture, rounding: Rounding) -> Result { + let mut r = self.rescale(pic.fraction_digits, rounding); + if !pic.signed && r.mantissa < 0 { + r.mantissa = -r.mantissa; + } + let int_part = r.mantissa.unsigned_abs() / 10u128.pow(pic.fraction_digits as u32); + if digit_count(int_part) > pic.integer_digits as u32 { + return Err(BcdError::Overflow); + } + Ok(r) + } +} + +impl PartialEq for Decimal { + /// Dos decimales son iguales si representan el mismo valor — `1.0` + /// es igual a `1.00`. + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for Decimal {} + +impl PartialOrd for Decimal { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Decimal { + fn cmp(&self, other: &Self) -> Ordering { + let (a, b, _) = self.aligned(other); + a.cmp(&b) + } +} + +impl fmt::Display for Decimal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.mantissa < 0 { + write!(f, "-")?; + } + let abs = self.mantissa.unsigned_abs(); + if self.scale == 0 { + return write!(f, "{abs}"); + } + let s = self.scale as usize; + let mut digits = abs.to_string(); + if digits.len() <= s { + // Rellena para que haya al menos un dígito entero (`0.xx`). + digits = format!("{}{}", "0".repeat(s - digits.len() + 1), digits); + } + let (int_part, frac_part) = digits.split_at(digits.len() - s); + write!(f, "{int_part}.{frac_part}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(s: &str) -> Decimal { + Decimal::parse(s).unwrap() + } + + #[test] + fn parse_and_display_roundtrip() { + assert_eq!(d("123.45").to_string(), "123.45"); + assert_eq!(d("-7").to_string(), "-7"); + assert_eq!(d("0.001").to_string(), "0.001"); + assert_eq!(d(".5").to_string(), "0.5"); + assert_eq!(d("+42").to_string(), "42"); + } + + #[test] + fn parse_rejects_garbage() { + assert!(Decimal::parse("abc").is_err()); + assert!(Decimal::parse("").is_err()); + assert!(Decimal::parse("1.2.3").is_err()); + assert!(Decimal::parse("-").is_err()); + } + + #[test] + fn addition_aligns_scales() { + // 1.5 + 2.25 = 3.75 + assert_eq!(d("1.5").add(&d("2.25")), d("3.75")); + // 100 + 0.001 = 100.001 + assert_eq!(d("100").add(&d("0.001")), d("100.001")); + } + + #[test] + fn subtraction_is_exact() { + assert_eq!(d("10.00").sub(&d("3.33")), d("6.67")); + } + + #[test] + fn multiplication_sums_scales() { + // 1.5 * 1.5 = 2.25 + let p = d("1.5").mul(&d("1.5")); + assert_eq!(p, d("2.25")); + assert_eq!(p.scale(), 2); + } + + #[test] + fn equality_ignores_trailing_zeros() { + assert_eq!(d("1.0"), d("1.00")); + assert_eq!(d("2"), d("2.000")); + } + + #[test] + fn ordering_compares_values() { + assert!(d("1.9") < d("1.91")); + assert!(d("-5") < d("0.0")); + assert!(d("100.0") > d("99.99")); + } + + #[test] + fn rescale_truncates_toward_zero() { + assert_eq!(d("1.999").rescale(2, Rounding::Truncate), d("1.99")); + assert_eq!(d("-1.999").rescale(2, Rounding::Truncate), d("-1.99")); + } + + #[test] + fn rescale_half_up_rounds_away_from_zero() { + assert_eq!(d("1.995").rescale(2, Rounding::HalfUp), d("2.00")); + assert_eq!(d("-1.995").rescale(2, Rounding::HalfUp), d("-2.00")); + assert_eq!(d("1.994").rescale(2, Rounding::HalfUp), d("1.99")); + } + + #[test] + fn division_respects_result_scale() { + // 10 / 3 a 4 decimales, truncado. + let q = d("10").div(&d("3"), 4, Rounding::Truncate).unwrap(); + assert_eq!(q, d("3.3333")); + // Redondeado. + let r = d("10").div(&d("3"), 4, Rounding::HalfUp).unwrap(); + assert_eq!(r, d("3.3333")); + // 7 / 8 = 0.875 exacto. + assert_eq!(d("7").div(&d("8"), 3, Rounding::Truncate).unwrap(), d("0.875")); + } + + #[test] + fn division_by_zero_errors() { + assert_eq!(d("1").div(&d("0"), 2, Rounding::Truncate), Err(BcdError::DivByZero)); + } + + #[test] + fn coerce_into_picture_adjusts_scale() { + let pic = Picture::parse("9(5)V99").unwrap(); + let stored = d("12.5").coerce(&pic, Rounding::Truncate).unwrap(); + assert_eq!(stored.scale(), 2); + assert_eq!(stored, d("12.50")); + } + + #[test] + fn coerce_overflow_is_a_size_error() { + let pic = Picture::parse("9(3)").unwrap(); // máx 999 + assert_eq!(d("1000").coerce(&pic, Rounding::Truncate), Err(BcdError::Overflow)); + assert!(d("999").coerce(&pic, Rounding::Truncate).is_ok()); + } + + #[test] + fn coerce_rounding_can_trigger_overflow() { + // 999.6 redondeado a entero → 1000, que ya no cabe en 9(3). + let pic = Picture::parse("9(3)").unwrap(); + assert_eq!(d("999.6").coerce(&pic, Rounding::HalfUp), Err(BcdError::Overflow)); + } + + #[test] + fn unsigned_picture_stores_magnitude() { + let pic = Picture::parse("9(4)V99").unwrap(); // sin S + let stored = d("-12.34").coerce(&pic, Rounding::Truncate).unwrap(); + assert!(!stored.is_negative()); + assert_eq!(stored, d("12.34")); + } + + #[test] + fn signed_picture_keeps_the_sign() { + let pic = Picture::parse("S9(4)V99").unwrap(); + let stored = d("-12.34").coerce(&pic, Rounding::Truncate).unwrap(); + assert!(stored.is_negative()); + } +} diff --git a/crates/modules/charka/charka-bcd/src/lib.rs b/crates/modules/charka/charka-bcd/src/lib.rs new file mode 100644 index 0000000..1d4de77 --- /dev/null +++ b/crates/modules/charka/charka-bcd/src/lib.rs @@ -0,0 +1,36 @@ +//! `charka-bcd` — aritmética decimal con semántica COBOL. +//! +//! El corazón numérico del transpilador charka. COBOL no calcula en +//! binario flotante: opera sobre campos decimales de precisión fija +//! declarados con una cláusula `PICTURE`. Reproducir un programa COBOL +//! fielmente exige reproducir esa aritmética dígito a dígito — eso es lo +//! que da este crate. +//! +//! - [`picture`] — la [`Picture`], forma declarada de un campo numérico. +//! - [`decimal`] — el [`Decimal`] de punto fijo exacto + redondeo + +//! detección de desbordamiento (`ON SIZE ERROR`). +//! +//! Determinista y sin dependencias de plataforma: mismo programa, mismos +//! dígitos, en cualquier máquina. El lexer, el parser, el IR y el codegen +//! de charka se construyen sobre este cimiento. + +#![forbid(unsafe_code)] + +pub mod decimal; +pub mod picture; + +pub use decimal::{Decimal, Rounding}; +pub use picture::Picture; + +/// Falla de una operación decimal o de una cláusula PICTURE. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BcdError { + #[error("cláusula PICTURE inválida: {0}")] + BadPicture(String), + #[error("literal numérico inválido: {0}")] + BadNumber(String), + #[error("división por cero")] + DivByZero, + #[error("desbordamiento de campo (ON SIZE ERROR)")] + Overflow, +} diff --git a/crates/modules/charka/charka-bcd/src/picture.rs b/crates/modules/charka/charka-bcd/src/picture.rs new file mode 100644 index 0000000..03e3b7c --- /dev/null +++ b/crates/modules/charka/charka-bcd/src/picture.rs @@ -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 { + 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 = 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::() + .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); + } +}