Skip to main content

visi_core/core/engine/
result_data.rs

1//! The value type a cell evaluates to.
2
3use serde::{Deserialize, Serialize};
4
5/// What a cell holds once it has been evaluated.
6///
7/// There is deliberately **no date variant**. As in Excel, a date is a plain
8/// numeric serial and the notation it was typed in lives on the cell, as
9/// `CellStyle::num_format` -- so `ISNUMBER` is true for a date, `SUM` counts
10/// it, and every numeric path works on it untouched. Only rendering consults
11/// the format, through `Sheet::get_display_string`.
12///
13/// An Excel error is a *value*, not a Rust error: `=1/0` evaluates
14/// successfully to `Error("#DIV/0!")`. See [`EngineError`] for the failures
15/// that are not values.
16///
17/// [`EngineError`]: crate::core::EngineError
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum ResultData {
20    /// A blank cell. Coerces to 0 or `""` depending on what reads it.
21    None,
22    /// `TRUE` or `FALSE`.
23    Boolean(bool),
24    /// A whole number.
25    Integer(i64),
26    /// A number that is not a whole number, or one too large for an `i64`.
27    /// A date is a `Float` holding its Excel serial.
28    Float(f64),
29    /// Text.
30    String(String),
31    /// An ordered sequence, for the engine-specific functions that return one.
32    /// Not an Excel array.
33    List(Vec<ResultData>),
34    /// Key/value pairs, for the engine-specific functions that return them.
35    Dict(Vec<(ResultData, ResultData)>),
36    /// An Excel error value, held as its code: `#DIV/0!`, `#VALUE!`, `#N/A`.
37    Error(String),
38}
39
40impl std::fmt::Display for ResultData {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            ResultData::None => write!(f, ""),
44            ResultData::Boolean(b) => write!(f, "{}", if *b { "TRUE" } else { "FALSE" }),
45            ResultData::Integer(i) => write!(f, "{}", i),
46            ResultData::Float(fl) => write!(f, "{}", format_excel_number(*fl)),
47            ResultData::String(s) => write!(f, "{}", s),
48            ResultData::List(l) => {
49                let items: Vec<String> = l.iter().map(|i| i.to_string()).collect();
50                write!(f, "[{}]", items.join(", "))
51            }
52            ResultData::Dict(d) => {
53                let items: Vec<String> = d.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
54                write!(f, "{{ {} }}", items.join(", "))
55            }
56            ResultData::Error(e) => write!(f, "Error: {}", e),
57        }
58    }
59}
60
61/// Rounds a significant-digit string to `keep` digits, half away from zero,
62/// trimming the trailing zeros Excel does not display. Returns the digits
63/// and the (possibly incremented) exponent -- rounding 999 up to 100 shifts
64/// the decimal point.
65fn round_digits_half_up(digits: &str, exp: i32, keep: usize) -> (String, i32) {
66    if digits.len() <= keep {
67        return (digits.to_string(), exp);
68    }
69    let mut kept: Vec<u8> = digits.as_bytes()[..keep].to_vec();
70    let round_up = digits.as_bytes()[keep] >= b'5';
71    let mut exp = exp;
72    if round_up {
73        let mut i = keep;
74        loop {
75            if i == 0 {
76                // Every digit carried: 999... becomes 1000..., one decimal
77                // place further left.
78                kept.insert(0, b'1');
79                kept.pop();
80                exp += 1;
81                break;
82            }
83            i -= 1;
84            if kept[i] == b'9' {
85                kept[i] = b'0';
86            } else {
87                kept[i] += 1;
88                break;
89            }
90        }
91    }
92    let mut out = String::from_utf8(kept).expect("ascii digits");
93    while out.len() > 1 && out.ends_with('0') {
94        out.pop();
95    }
96    (out, exp)
97}
98
99/// The Excel error values, spelled exactly as a cell shows them.
100///
101/// A closed set: these are the only strings a cell can hold that are an error
102/// rather than text, which is what makes recognising one on entry safe.
103pub(crate) const EXCEL_ERROR_CODES: &[&str] = &[
104    "#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A", "#CALC!", "#SPILL!",
105];
106
107/// Whether a literal cell entry is one of Excel's error values.
108///
109/// Typing `#NUM!` into Excel produces the error, not the text -- measured,
110/// along with the same thing happening when VBA assigns the string through
111/// `Range.Value`. So `Sheet::commit` recognises one, and `xlsx::text_cell_src`
112/// quotes it on import for the same reason it quotes `TRUE` and `6/22/26`:
113/// a cell Excel told us is *text* has to survive the round trip as text.
114pub(crate) fn is_excel_error_code(src: &str) -> bool {
115    EXCEL_ERROR_CODES
116        .iter()
117        .any(|e| src.eq_ignore_ascii_case(e))
118}
119
120pub(crate) fn format_excel_number(f: f64) -> String {
121    if f == 0.0 {
122        return "0".to_string();
123    }
124    if f.is_nan() || f.is_infinite() {
125        return "#NUM!".to_string();
126    }
127
128    // Excel displays 15 significant digits and no more. Everything below
129    // works from the *rounded* scientific form rather than from the f64
130    // directly, so digits past that precision are dropped instead of
131    // leaking out: (-43)^11 is 21611482313284248 in f64, but Excel writes
132    // 21611482313284200.
133    let sci = format!("{:.14e}", f);
134    let (mantissa, exp_str) = sci.split_once('e').expect("{:e} always emits an exponent");
135    let exp: i32 = exp_str.parse().expect("{:e} emits an integer exponent");
136    let sign = if f < 0.0 { "-" } else { "" };
137    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
138    let digits = digits.trim_end_matches('0');
139    let digits = if digits.is_empty() { "0" } else { digits };
140
141    // Excel keeps plain decimal notation for as long as the decimal
142    // rendering stays within 20 characters, and only then falls back to
143    // scientific. The minus sign is *not* charged against that budget --
144    // real Excel writes -2.05237592634038E-10, which is 21 characters.
145    //
146    // That is a much wider decimal range than the magnitude cutoffs this
147    // used to apply (1e-5 .. 1e11), which turned e.g. 976121418126.432 --
148    // which real Excel writes out in full -- into "9.76121418126432E+11".
149    // Verified against real Excel: 1e18 and 1e19 render in full (19 and 20
150    // characters) while 1e20 (21) goes scientific, and 0.000001207666770903
151    // renders in full (20) while 0.00000120766677090395 (22) goes
152    // scientific.
153    let decimal_len = if exp >= 0 {
154        let int_digits = (exp + 1) as usize;
155        let frac_digits = digits.len().saturating_sub(int_digits);
156        int_digits + usize::from(frac_digits > 0) + frac_digits
157    } else {
158        // "0." + leading zeros + significant digits
159        2 + (-exp - 1) as usize + digits.len()
160    };
161
162    if decimal_len <= 20 {
163        if exp >= 0 {
164            let int_digits = (exp + 1) as usize;
165            let mut out = String::from(sign);
166            if digits.len() <= int_digits {
167                out.push_str(digits);
168                out.push_str(&"0".repeat(int_digits - digits.len()));
169            } else {
170                out.push_str(&digits[..int_digits]);
171                out.push('.');
172                out.push_str(&digits[int_digits..]);
173            }
174            out
175        } else {
176            format!("{}0.{}{}", sign, "0".repeat((-exp - 1) as usize), digits)
177        }
178    } else {
179        // The 20-character budget applies to the scientific rendering too,
180        // and the exponent is charged against it: a three-digit exponent
181        // leaves one fewer mantissa digit than a two-digit one. Real Excel
182        // writes PHI(28) as "2.2775774787367E-171" (13 fractional digits)
183        // and CSCH(-23) as "-2.05237592634038E-10" (14), both 20 characters
184        // once the sign is set aside.
185        let suffix_len = format!("E{:+03}", exp).len();
186        let frac_digits = 18usize.saturating_sub(suffix_len).min(14);
187
188        // Rounded from the *15-significant-digit* value, not from the raw
189        // f64. Excel snaps a result to 15 significant digits and only then
190        // formats it, so when a three-digit exponent leaves room for just
191        // 14 the two roundings compose. 28^-92 is
192        // 7.26877317134744769...e-134: rounding that straight to 14 digits
193        // gives ...7474, but snapping to 15 first gives 7.26877317134745
194        // and then 14 gives ...7475, which is what Excel prints.
195        //
196        // Working from the digit string rather than re-rounding the f64
197        // keeps the two steps exact, and rounds half away from zero, as
198        // Excel does elsewhere (DOLLAR/FIXED/TEXT).
199        let (rounded_digits, exp) = round_digits_half_up(digits, exp, frac_digits + 1);
200        let mut mantissa = String::from(sign);
201        mantissa.push_str(&rounded_digits[..1]);
202        if rounded_digits.len() > 1 {
203            mantissa.push('.');
204            mantissa.push_str(&rounded_digits[1..]);
205        }
206        format!("{}E{:+03}", mantissa, exp)
207    }
208}