Skip to main content

patch_rexx/
value.rs

1//! REXX values — everything is a string.
2//!
3//! In REXX, all values are character strings. Numbers are strings that happen
4//! to be valid numeric representations. This module implements the core value
5//! type and REXX's decimal arithmetic model (NUMERIC DIGITS / FORM / FUZZ).
6
7use bigdecimal::{BigDecimal, RoundingMode, Signed};
8use std::fmt;
9use std::num::NonZeroU64;
10use std::str::FromStr;
11
12/// Every REXX value is a string. Numeric operations interpret the string
13/// content as a number when needed, and produce string results.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct RexxValue {
16    data: String,
17}
18
19impl RexxValue {
20    pub fn new(s: impl Into<String>) -> Self {
21        Self { data: s.into() }
22    }
23
24    pub fn as_str(&self) -> &str {
25        &self.data
26    }
27
28    pub fn into_string(self) -> String {
29        self.data
30    }
31
32    pub fn len(&self) -> usize {
33        self.data.len()
34    }
35
36    pub fn is_empty(&self) -> bool {
37        self.data.is_empty()
38    }
39
40    /// Attempt to interpret this value as a REXX number.
41    /// REXX numbers can have leading/trailing blanks, optional sign,
42    /// digits with an optional decimal point, and optional exponent.
43    pub fn to_decimal(&self) -> Option<BigDecimal> {
44        let trimmed = self.data.trim();
45        if trimmed.is_empty() {
46            return None;
47        }
48        BigDecimal::from_str(trimmed).ok()
49    }
50
51    /// Check if this value is a valid REXX number.
52    pub fn is_number(&self) -> bool {
53        self.to_decimal().is_some()
54    }
55
56    /// REXX whole number check — a number with no fractional part
57    /// within current NUMERIC DIGITS precision.
58    pub fn is_whole_number(&self, digits: u32) -> bool {
59        match self.to_decimal() {
60            Some(d) => {
61                let rounded = d.round(0);
62                let diff = (&d - &rounded).abs();
63                diff < BigDecimal::from_str(&format!("1E-{digits}")).unwrap_or(BigDecimal::from(0))
64            }
65            None => false,
66        }
67    }
68
69    /// Format a `BigDecimal` according to REXX numeric formatting rules.
70    /// Respects NUMERIC DIGITS and NUMERIC FORM (SCIENTIFIC vs ENGINEERING).
71    pub fn from_decimal(d: &BigDecimal, digits: u32, form: NumericForm) -> Self {
72        let formatted = format_rexx_number(d, digits, form);
73        Self::new(formatted)
74    }
75}
76
77impl fmt::Display for RexxValue {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}", self.data)
80    }
81}
82
83impl From<&str> for RexxValue {
84    fn from(s: &str) -> Self {
85        Self::new(s)
86    }
87}
88
89impl From<String> for RexxValue {
90    fn from(s: String) -> Self {
91        Self::new(s)
92    }
93}
94
95impl From<i64> for RexxValue {
96    fn from(n: i64) -> Self {
97        Self::new(n.to_string())
98    }
99}
100
101/// NUMERIC FORM controls exponential notation style.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum NumericForm {
104    /// Exponent is a multiple of 1 (default).
105    #[default]
106    Scientific,
107    /// Exponent is a multiple of 3.
108    Engineering,
109}
110
111/// Numeric settings for the current execution context.
112#[derive(Debug, Clone)]
113pub struct NumericSettings {
114    /// Number of significant digits (default 9).
115    pub digits: u32,
116    /// Exponential notation form.
117    pub form: NumericForm,
118    /// Digits of "fuzziness" for comparisons (default 0).
119    pub fuzz: u32,
120}
121
122impl Default for NumericSettings {
123    fn default() -> Self {
124        Self {
125            digits: 9,
126            form: NumericForm::Scientific,
127            fuzz: 0,
128        }
129    }
130}
131
132/// Format a `BigDecimal` to a REXX-compliant string representation.
133/// Rounds to NUMERIC DIGITS significant digits (not decimal places),
134/// then chooses plain or exponential notation based on the adjusted exponent.
135#[allow(clippy::cast_possible_wrap)]
136fn format_rexx_number(d: &BigDecimal, digits: u32, form: NumericForm) -> String {
137    use bigdecimal::num_bigint::Sign;
138
139    if d.sign() == Sign::NoSign {
140        return "0".to_string();
141    }
142
143    let prec = NonZeroU64::new(u64::from(digits)).unwrap_or(NonZeroU64::MIN);
144    let rounded = d.with_precision_round(prec, RoundingMode::HalfUp);
145    let normed = rounded.normalized();
146    let (coeff, scale) = normed.as_bigint_and_exponent();
147    let is_negative = coeff.sign() == Sign::Minus;
148    let coeff_str = coeff.abs().to_string();
149    // Safe: coefficient length is bounded by NUMERIC DIGITS (≤ u32::MAX)
150    let n = coeff_str.len() as i64;
151
152    // Adjusted exponent: the power of 10 of the leading digit.
153    // value = coeff × 10^(−scale), in scientific form: d.ddd × 10^adj_exp
154    let adj_exp = n - 1 - scale;
155
156    // REXX uses plain notation when the adjusted exponent is in range:
157    //   non-negative and < 2×DIGITS, or negative and >= −DIGITS.
158    // Beyond that range, exponential notation is used.
159    let use_plain = if adj_exp >= 0 {
160        adj_exp < i64::from(digits) * 2
161    } else {
162        adj_exp >= -i64::from(digits)
163    };
164
165    let sign = if is_negative { "-" } else { "" };
166
167    if use_plain {
168        format!("{sign}{}", format_plain(&coeff_str, adj_exp))
169    } else {
170        format!("{sign}{}", format_exp(&coeff_str, adj_exp, form))
171    }
172}
173
174/// Format a number in plain (non-exponential) notation.
175#[allow(
176    clippy::cast_possible_wrap,
177    clippy::cast_possible_truncation,
178    clippy::cast_sign_loss
179)]
180fn format_plain(coeff: &str, adj_exp: i64) -> String {
181    let n = coeff.len() as i64;
182    if adj_exp >= n - 1 {
183        // Pure integer — append trailing zeros
184        let trailing = (adj_exp - n + 1) as usize;
185        format!("{coeff}{}", "0".repeat(trailing))
186    } else if adj_exp >= 0 {
187        // Decimal point falls within the digits
188        let split = (adj_exp + 1) as usize;
189        format!("{}.{}", &coeff[..split], &coeff[split..])
190    } else {
191        // Number < 1 — prepend leading zeros after "0."
192        let leading = (-adj_exp - 1) as usize;
193        format!("0.{}{coeff}", "0".repeat(leading))
194    }
195}
196
197/// Format a number in exponential notation (SCIENTIFIC or ENGINEERING).
198#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
199fn format_exp(coeff: &str, adj_exp: i64, form: NumericForm) -> String {
200    let (digits_before, exp) = match form {
201        NumericForm::Scientific => (1usize, adj_exp),
202        NumericForm::Engineering => {
203            let e = adj_exp - adj_exp.rem_euclid(3);
204            ((adj_exp - e + 1) as usize, e)
205        }
206    };
207
208    let n = coeff.len();
209    let mantissa = if digits_before >= n {
210        let padding = digits_before - n;
211        format!("{coeff}{}", "0".repeat(padding))
212    } else {
213        format!("{}.{}", &coeff[..digits_before], &coeff[digits_before..])
214    };
215
216    if exp >= 0 {
217        format!("{mantissa}E+{exp}")
218    } else {
219        format!("{mantissa}E{exp}")
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn string_value() {
229        let v = RexxValue::new("hello");
230        assert_eq!(v.as_str(), "hello");
231        assert!(!v.is_number());
232    }
233
234    #[test]
235    fn numeric_value() {
236        let v = RexxValue::new("42");
237        assert!(v.is_number());
238        assert_eq!(v.to_decimal().unwrap(), BigDecimal::from(42));
239    }
240
241    #[test]
242    fn numeric_with_spaces() {
243        let v = RexxValue::new("  3.14  ");
244        assert!(v.is_number());
245    }
246
247    #[test]
248    fn non_numeric() {
249        let v = RexxValue::new("hello");
250        assert!(!v.is_number());
251        assert!(v.to_decimal().is_none());
252    }
253
254    #[test]
255    fn from_integer() {
256        let v = RexxValue::from(42i64);
257        assert_eq!(v.as_str(), "42");
258    }
259
260    #[test]
261    fn default_numeric_settings() {
262        let settings = NumericSettings::default();
263        assert_eq!(settings.digits, 9);
264        assert_eq!(settings.fuzz, 0);
265        assert_eq!(settings.form, NumericForm::Scientific);
266    }
267
268    // ── format_rexx_number tests ──────────────────────────────────
269
270    #[test]
271    fn format_integer() {
272        let d = BigDecimal::from(42);
273        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "42");
274    }
275
276    #[test]
277    fn format_decimal() {
278        let d = BigDecimal::from_str("3.14").unwrap();
279        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "3.14");
280    }
281
282    #[test]
283    fn format_zero() {
284        let d = BigDecimal::from(0);
285        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "0");
286    }
287
288    #[test]
289    fn format_negative() {
290        let d = BigDecimal::from(-42);
291        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "-42");
292    }
293
294    #[test]
295    fn format_significant_digit_rounding() {
296        // 123456789.5 rounded to 9 significant digits → 123456790
297        let d = BigDecimal::from_str("123456789.5").unwrap();
298        assert_eq!(
299            format_rexx_number(&d, 9, NumericForm::Scientific),
300            "123456790"
301        );
302    }
303
304    #[test]
305    fn format_large_plain() {
306        // 10^17 has adjusted exponent 17, within 2×9=18 threshold → plain
307        let d = BigDecimal::from_str("1E17").unwrap();
308        assert_eq!(
309            format_rexx_number(&d, 9, NumericForm::Scientific),
310            "100000000000000000"
311        );
312    }
313
314    #[test]
315    fn format_large_exponential() {
316        // 10^18 has adjusted exponent 18, equals 2×9 → exponential
317        let d = BigDecimal::from_str("1E18").unwrap();
318        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "1E+18");
319    }
320
321    #[test]
322    fn format_small_plain() {
323        // 10^-9 has adjusted exponent -9, equals -DIGITS → plain
324        let d = BigDecimal::from_str("1E-9").unwrap();
325        assert_eq!(
326            format_rexx_number(&d, 9, NumericForm::Scientific),
327            "0.000000001"
328        );
329    }
330
331    #[test]
332    fn format_small_exponential() {
333        // adjusted exponent -10, below -DIGITS → exponential
334        let d = BigDecimal::from_str("1.23E-10").unwrap();
335        assert_eq!(
336            format_rexx_number(&d, 9, NumericForm::Scientific),
337            "1.23E-10"
338        );
339    }
340
341    #[test]
342    fn format_engineering_form() {
343        let d = BigDecimal::from_str("1.23E20").unwrap();
344        assert_eq!(
345            format_rexx_number(&d, 9, NumericForm::Engineering),
346            "123E+18"
347        );
348    }
349
350    #[test]
351    fn format_trailing_zeros_stripped() {
352        let d = BigDecimal::from_str("5.00").unwrap();
353        assert_eq!(format_rexx_number(&d, 9, NumericForm::Scientific), "5");
354    }
355
356    #[test]
357    fn format_negative_exponential() {
358        let d = BigDecimal::from_str("-1.5E20").unwrap();
359        assert_eq!(
360            format_rexx_number(&d, 9, NumericForm::Scientific),
361            "-1.5E+20"
362        );
363    }
364}