feat(charka): charka-runtime — soporte de ejecución (campos Num y Text)

El soporte que los programas COBOL transpilados enlazan. charka-codegen
emitirá Rust que llama a esta biblioteca, no Rust autónomo.

- Num: campo numérico (PIC 9(5)V99) — un Decimal conformado a su
  Picture. store trunca a la escala declarada, store_rounded redondea;
  al desbordar la parte entera conserva los dígitos de bajo orden (el
  ON SIZE ERROR de COBOL sin cláusula). display da los dígitos con
  relleno de ceros y signo.
- Text: campo alfanumérico (PIC X(n)) de longitud fija — store
  justifica a la izquierda y rellena/trunca; fill mueve figurativas.
- cobol_text_cmp: comparación alfanumérica con relleno de espacios.
- Reexporta Decimal/Picture/Rounding de charka-bcd.

Construido antes que charka-codegen (la nota de orden del plan los
listaba al revés): el codegen emite contra esta API. 17 tests; fmt +
clippy limpios.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 20:27:28 +00:00
parent 71a4068d12
commit 85156c1509
8 changed files with 414 additions and 6 deletions
@@ -0,0 +1,72 @@
//! `charka-runtime` — el soporte de ejecución de los programas COBOL
//! transpilados.
//!
//! Lo que `charka-codegen` emite no es Rust autónomo: es Rust que
//! enlaza contra esta biblioteca. Aquí viven los tipos que dan a un
//! programa transpilado la semántica de COBOL en tiempo de ejecución:
//!
//! - [`Num`] — un campo numérico (`PIC 9(5)V99`): un [`Decimal`] de
//! punto fijo conformado a su [`Picture`]. Toda asignación trunca a
//! la escala y al tamaño declarados, como el `MOVE` de COBOL.
//! - [`Text`] — un campo alfanumérico (`PIC X(20)`) de longitud fija:
//! toda asignación justifica a la izquierda y rellena o trunca.
//!
//! La aritmética decimal exacta la aporta `charka-bcd`, cuyos tipos
//! ([`Decimal`], [`Picture`], [`Rounding`]) se reexportan para que el
//! código generado sólo necesite `use charka_runtime::*;`.
#![forbid(unsafe_code)]
mod num;
mod text;
pub use charka_bcd::{Decimal, Picture, Rounding};
pub use num::Num;
pub use text::Text;
use std::cmp::Ordering;
/// Compara dos campos alfanuméricos con la semántica de COBOL: el más
/// corto se considera rellenado con espacios a la derecha, de modo que
/// `"AB"` y `"AB "` son iguales.
pub fn cobol_text_cmp(a: &str, b: &str) -> Ordering {
let n = a.chars().count().max(b.chars().count());
let padded = |s: &str| -> Vec<char> {
let mut v: Vec<char> = s.chars().collect();
while v.len() < n {
v.push(' ');
}
v
};
padded(a).cmp(&padded(b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_cmp_orders_lexically() {
assert_eq!(cobol_text_cmp("ABC", "ABD"), Ordering::Less);
assert_eq!(cobol_text_cmp("ABD", "ABC"), Ordering::Greater);
assert_eq!(cobol_text_cmp("ABC", "ABC"), Ordering::Equal);
}
#[test]
fn text_cmp_pads_the_shorter_with_spaces() {
assert_eq!(cobol_text_cmp("AB", "AB "), Ordering::Equal);
assert_eq!(cobol_text_cmp("AB", "ABC"), Ordering::Less);
}
#[test]
fn fields_compose_for_generated_code() {
// Un mini-programa transpilado a mano: WS-CT crece, WS-MSG fijo.
let mut ws_ct = Num::with_value(Picture::new(3, 0, false), "0");
ws_ct.store(ws_ct.value().add(&Decimal::from_integer(5)));
assert_eq!(ws_ct.display(), "005");
let mut ws_msg = Text::new(10);
ws_msg.store("LISTO");
assert_eq!(ws_msg.as_str(), "LISTO ");
}
}
@@ -0,0 +1,158 @@
//! `Num` — un campo numérico COBOL en tiempo de ejecución.
use charka_bcd::{Decimal, Picture, Rounding};
/// Un campo numérico: un valor [`Decimal`] más la [`Picture`] que lo
/// conforma. Toda asignación pasa por la PICTURE — ese es el `MOVE` de
/// COBOL: el valor se ajusta a la escala y al tamaño declarados.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Num {
value: Decimal,
pic: Picture,
}
impl Num {
/// Campo nuevo en cero, con la PICTURE dada.
pub fn new(pic: Picture) -> Self {
Self {
value: Decimal::zero(),
pic,
}
}
/// Campo con un `VALUE` inicial (el texto del literal). Un literal
/// inválido deja el campo en cero.
pub fn with_value(pic: Picture, literal: &str) -> Self {
let mut n = Self::new(pic);
if let Ok(d) = Decimal::parse(literal) {
n.store(d);
}
n
}
/// El valor decimal actual.
pub fn value(&self) -> Decimal {
self.value
}
/// La PICTURE del campo.
pub fn picture(&self) -> Picture {
self.pic
}
/// Asigna un valor conformándolo a la PICTURE: ajusta la escala
/// truncando los dígitos fraccionarios sobrantes.
pub fn store(&mut self, v: Decimal) {
self.value = fit(v, &self.pic, Rounding::Truncate);
}
/// Como [`store`](Self::store) pero redondeando — el `ROUNDED`.
pub fn store_rounded(&mut self, v: Decimal) {
self.value = fit(v, &self.pic, Rounding::HalfUp);
}
/// Representación para `DISPLAY`: los dígitos del campo, rellenados
/// con ceros a la izquierda hasta el total de la PICTURE; con un
/// `-` adelante si el campo lleva signo y el valor es negativo.
pub fn display(&self) -> String {
let total = self.pic.total_digits() as usize;
let abs = self.value.mantissa().unsigned_abs().to_string();
let digits = if abs.len() >= total {
abs[abs.len() - total..].to_string()
} else {
format!("{}{}", "0".repeat(total - abs.len()), abs)
};
if self.pic.signed && self.value.is_negative() {
format!("-{digits}")
} else {
digits
}
}
}
/// Conforma un valor a una PICTURE. Si la parte entera no cabe (el
/// `ON SIZE ERROR` de COBOL) y ninguna cláusula lo captura, COBOL deja
/// los dígitos de bajo orden: reescalamos y enmascaramos.
fn fit(v: Decimal, pic: &Picture, rounding: Rounding) -> Decimal {
if let Ok(d) = v.coerce(pic, rounding) {
return d;
}
let r = v.rescale(pic.fraction_digits, rounding);
let modulus = 10i128.pow(pic.total_digits() as u32);
let mut m = r.mantissa() % modulus;
if !pic.signed && m < 0 {
m = -m;
}
Decimal::new(m, pic.fraction_digits)
}
#[cfg(test)]
mod tests {
use super::*;
fn pic(s: &str) -> Picture {
Picture::parse(s).expect("PICTURE válida")
}
fn dec(s: &str) -> Decimal {
Decimal::parse(s).expect("decimal válido")
}
#[test]
fn new_field_is_zero() {
let n = Num::new(pic("9(5)"));
assert!(n.value().is_zero());
assert_eq!(n.display(), "00000");
}
#[test]
fn with_value_initializes() {
let n = Num::with_value(pic("9(3)"), "42");
assert_eq!(n.display(), "042");
}
#[test]
fn store_truncates_fraction() {
let mut n = Num::new(pic("9(3)V99"));
n.store(dec("12.3456"));
assert_eq!(n.value(), dec("12.34"));
}
#[test]
fn store_rounded_rounds_fraction() {
let mut n = Num::new(pic("9(3)V99"));
n.store_rounded(dec("12.3456"));
assert_eq!(n.value(), dec("12.35"));
}
#[test]
fn store_overflow_keeps_low_order_digits() {
// 1234 no cabe en 9(3): COBOL conserva los 3 dígitos bajos.
let mut n = Num::new(pic("9(3)"));
n.store(dec("1234"));
assert_eq!(n.display(), "234");
}
#[test]
fn unsigned_field_stores_magnitude() {
let mut n = Num::new(pic("9(3)"));
n.store(dec("-7"));
assert_eq!(n.display(), "007");
assert!(!n.value().is_negative());
}
#[test]
fn signed_field_keeps_sign_in_display() {
let mut n = Num::new(pic("S9(3)"));
n.store(dec("-7"));
assert_eq!(n.display(), "-007");
}
#[test]
fn display_includes_implied_fraction_digits() {
// PIC 9(2)V99, valor 7.5 → dígitos 0750.
let mut n = Num::new(pic("9(2)V99"));
n.store(dec("7.5"));
assert_eq!(n.display(), "0750");
}
}
@@ -0,0 +1,110 @@
//! `Text` — un campo alfanumérico COBOL en tiempo de ejecución.
/// Un campo alfanumérico de longitud fija (`PIC X(n)`). El contenido
/// se mantiene siempre con exactamente `len` caracteres — toda
/// asignación justifica a la izquierda y rellena o trunca.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Text {
buf: String,
len: usize,
}
impl Text {
/// Campo nuevo de `len` caracteres, lleno de espacios.
pub fn new(len: usize) -> Self {
Self {
buf: " ".repeat(len),
len,
}
}
/// Campo con un `VALUE` inicial.
pub fn with_value(len: usize, literal: &str) -> Self {
let mut t = Self::new(len);
t.store(literal);
t
}
/// Asigna un texto: lo justifica a la izquierda, y rellena con
/// espacios o trunca hasta `len` — el `MOVE` alfanumérico de COBOL.
pub fn store(&mut self, s: &str) {
let mut chars: Vec<char> = s.chars().take(self.len).collect();
while chars.len() < self.len {
chars.push(' ');
}
self.buf = chars.into_iter().collect();
}
/// Llena el campo entero con un carácter — para mover las
/// constantes figurativas (`SPACES`, `ZEROS`...).
pub fn fill(&mut self, ch: char) {
self.buf = (0..self.len).map(|_| ch).collect();
}
/// El contenido actual (siempre exactamente `len` caracteres).
pub fn as_str(&self) -> &str {
&self.buf
}
/// La longitud declarada del campo.
pub fn len(&self) -> usize {
self.len
}
/// ¿El campo se declaró con longitud cero?
pub fn is_empty(&self) -> bool {
self.len == 0
}
/// Representación para `DISPLAY` — el contenido tal cual, con sus
/// espacios de relleno incluidos.
pub fn display(&self) -> String {
self.buf.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_field_is_all_spaces() {
let t = Text::new(5);
assert_eq!(t.as_str(), " ");
assert_eq!(t.len(), 5);
}
#[test]
fn with_value_left_justifies_and_pads() {
let t = Text::with_value(5, "AB");
assert_eq!(t.as_str(), "AB ");
}
#[test]
fn store_truncates_when_too_long() {
let mut t = Text::new(3);
t.store("HELLO");
assert_eq!(t.as_str(), "HEL");
}
#[test]
fn store_pads_when_too_short() {
let mut t = Text::new(6);
t.store("HI");
assert_eq!(t.as_str(), "HI ");
}
#[test]
fn fill_sets_every_position() {
let mut t = Text::new(4);
t.fill('0');
assert_eq!(t.as_str(), "0000");
}
#[test]
fn zero_length_field_is_empty() {
let t = Text::new(0);
assert!(t.is_empty());
assert_eq!(t.as_str(), "");
}
}