Files
gridcraft/crates/core/src/value.rs
T
3b65b9af58 Sonar maintainability: explicit imports, method paths, smaller functions (#2412)
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix imports

* Fix serde imports

* Fix engine imports

* Fix clippy gate and a type error from the explicit-import change

Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Apply rustfmt to the explicit-import cleanup so the fmt gate passes

* Share the per-directory font path expansion in theme.rs

The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Read the Windows fonts folder in one place in theme.rs

Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Build the light and dark UI palettes from one table in theme.rs

Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share the Noto CJK font list between the CJK script lists in theme.rs

The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Share Linux Noto directory list across Thai and Hebrew font lookups

Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.

* Reuse in_dirs for Thai and Hebrew Noto font candidates

Removes the remaining duplicated per-directory loops flagged by SonarCloud.

* Share one font-list builder for the Thai and Hebrew faces

The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 12:32:02 -04:00

465 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cell values and Excel's coercion and comparison rules.
use std::cmp::Ordering;
use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
/// Excel's error values.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CellError {
Null,
Div0,
Value,
Ref,
Name,
Num,
NA,
GettingData,
Spill,
Calc,
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
Circ,
}
impl CellError {
pub const ALL: [CellError; 10] = [
CellError::Null,
CellError::Div0,
CellError::Value,
CellError::Ref,
CellError::Name,
CellError::Num,
CellError::NA,
CellError::GettingData,
CellError::Spill,
CellError::Calc,
];
pub fn as_str(&self) -> &'static str {
match self {
CellError::Null => "#NULL!",
CellError::Div0 => "#DIV/0!",
CellError::Value => "#VALUE!",
CellError::Ref => "#REF!",
CellError::Name => "#NAME?",
CellError::Num => "#NUM!",
CellError::NA => "#N/A",
CellError::GettingData => "#GETTING_DATA",
CellError::Spill => "#SPILL!",
CellError::Calc => "#CALC!",
CellError::Circ => "#CIRC!",
}
}
/// `ERROR.TYPE` number.
pub fn code(&self) -> u32 {
match self {
CellError::Null => 1,
CellError::Div0 => 2,
CellError::Value => 3,
CellError::Ref => 4,
CellError::Name => 5,
CellError::Num => 6,
CellError::NA => 7,
CellError::GettingData => 8,
CellError::Spill => 9,
CellError::Calc => 14,
CellError::Circ => 15,
}
}
pub fn parse(s: &str) -> Option<CellError> {
let u = s.to_ascii_uppercase();
CellError::ALL.iter().chain([CellError::Circ].iter()).find(|e| e.as_str() == u).copied()
}
}
impl fmt::Display for CellError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// A 2-D array of values (row-major). Always at least 1×1.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Array {
pub rows: usize,
pub cols: usize,
pub data: Vec<Value>,
}
impl Array {
pub fn new(rows: usize, cols: usize, data: Vec<Value>) -> Option<Array> {
if rows == 0 || cols == 0 || rows.checked_mul(cols)? != data.len() {
return None;
}
Some(Array { rows, cols, data })
}
pub fn filled(rows: usize, cols: usize, v: Value) -> Array {
let (rows, cols) = (rows.max(1), cols.max(1));
Array { rows, cols, data: vec![v; rows.saturating_mul(cols)] }
}
pub fn scalar(v: Value) -> Array {
Array { rows: 1, cols: 1, data: vec![v] }
}
pub fn column(v: Vec<Value>) -> Array {
if v.is_empty() {
return Array::scalar(Value::Error(CellError::Calc));
}
Array { rows: v.len(), cols: 1, data: v }
}
pub fn row(v: Vec<Value>) -> Array {
if v.is_empty() {
return Array::scalar(Value::Error(CellError::Calc));
}
Array { rows: 1, cols: v.len(), data: v }
}
pub fn get(&self, r: usize, c: usize) -> Option<&Value> {
if r >= self.rows || c >= self.cols {
return None;
}
self.data.get(r * self.cols + c)
}
/// Excel's broadcasting read: a 1-row or 1-column array repeats along that axis; outside the
/// array is `#N/A`.
pub fn get_broadcast(&self, r: usize, c: usize) -> Value {
let rr = if self.rows == 1 { 0 } else { r };
let cc = if self.cols == 1 { 0 } else { c };
self.get(rr, cc).cloned().unwrap_or(Value::Error(CellError::NA))
}
pub fn transpose(&self) -> Array {
let mut data = Vec::with_capacity(self.data.len());
for c in 0..self.cols {
for r in 0..self.rows {
data.push(self.get(r, c).cloned().unwrap_or(Value::Empty));
}
}
Array { rows: self.cols, cols: self.rows, data }
}
pub fn iter(&self) -> impl Iterator<Item = &Value> {
self.data.iter()
}
}
/// A cell value or an intermediate formula result.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "t", content = "v")]
pub enum Value {
#[default]
Empty,
Number(f64),
Text(Arc<str>),
Bool(bool),
Error(CellError),
Array(Arc<Array>),
}
impl From<f64> for Value {
fn from(v: f64) -> Self {
Value::number(v)
}
}
impl From<bool> for Value {
fn from(v: bool) -> Self {
Value::Bool(v)
}
}
impl From<&str> for Value {
fn from(v: &str) -> Self {
Value::Text(Arc::from(v))
}
}
impl From<String> for Value {
fn from(v: String) -> Self {
Value::Text(Arc::from(v))
}
}
impl From<CellError> for Value {
fn from(v: CellError) -> Self {
Value::Error(v)
}
}
impl From<Array> for Value {
fn from(a: Array) -> Self {
Value::Array(Arc::new(a))
}
}
impl Value {
/// A number, or `#NUM!` when it is NaN or infinite (Excel never stores those).
pub fn number(v: f64) -> Value {
if v.is_finite() { Value::Number(if v == 0.0 { 0.0 } else { v }) } else { Value::Error(CellError::Num) }
}
pub fn text(s: impl Into<Arc<str>>) -> Value {
Value::Text(s.into())
}
pub fn is_empty(&self) -> bool {
matches!(self, Value::Empty)
}
pub fn is_error(&self) -> bool {
matches!(self, Value::Error(_))
}
pub fn is_number(&self) -> bool {
matches!(self, Value::Number(_))
}
pub fn is_text(&self) -> bool {
matches!(self, Value::Text(_))
}
pub fn as_error(&self) -> Option<CellError> {
if let Value::Error(e) = self { Some(*e) } else { None }
}
pub fn as_f64(&self) -> Option<f64> {
if let Value::Number(n) = self { Some(*n) } else { None }
}
pub fn as_text(&self) -> Option<&str> {
if let Value::Text(t) = self { Some(t) } else { None }
}
/// Top-left element of an array, the value itself otherwise.
pub fn scalar(&self) -> Value {
match self {
Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty),
v => v.clone(),
}
}
/// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses
/// (including dates, times, percentages and currency), other text = `#VALUE!`.
pub fn to_number(&self) -> Result<f64, CellError> {
self.to_number_in(crate::DateSystem::D1900)
}
/// [`Value::to_number`] with date text read in the given workbook date system.
pub fn to_number_in(&self, sys: crate::DateSystem) -> Result<f64, CellError> {
match self {
Value::Empty => Ok(0.0),
Value::Number(n) => Ok(*n),
Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
Value::Text(t) => crate::parse::parse_number_text_in(t, sys).ok_or(CellError::Value),
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(|v| v.to_number_in(sys)).unwrap_or(Ok(0.0)),
}
}
/// Coercion to text as `&` does it.
pub fn to_text(&self) -> Result<String, CellError> {
match self {
Value::Empty => Ok(String::new()),
Value::Number(n) => Ok(number_to_text(*n)),
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
Value::Text(t) => Ok(t.to_string()),
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
}
}
/// Coercion to a boolean as `IF` does it.
pub fn to_bool(&self) -> Result<bool, CellError> {
match self {
Value::Empty => Ok(false),
Value::Number(n) => Ok(*n != 0.0),
Value::Bool(b) => Ok(*b),
Value::Text(t) => {
if t.eq_ignore_ascii_case("TRUE") {
Ok(true)
} else if t.eq_ignore_ascii_case("FALSE") {
Ok(false)
} else {
Err(CellError::Value)
}
}
Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
}
}
/// Display text with General formatting (for logs, CSV and tests).
pub fn display(&self) -> String {
match self {
Value::Error(e) => e.as_str().to_string(),
v => v.to_text().unwrap_or_default(),
}
}
/// Type rank for comparison and sorting: numbers < text < booleans < errors; empty sorts last.
fn rank(&self) -> u8 {
match self {
Value::Number(_) => 0,
Value::Text(_) => 1,
Value::Bool(_) => 2,
Value::Error(_) => 3,
Value::Empty => 4,
Value::Array(_) => 5,
}
}
}
/// Excel's comparison for `=`, `<`… operators. Text compares case-insensitively; empty acts as
/// 0, "" or FALSE depending on the other side. Errors are handled by the caller.
pub fn compare(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Empty, Value::Empty) => Ordering::Equal,
(Value::Empty, Value::Number(n)) => compare_numbers(0.0, *n),
(Value::Number(n), Value::Empty) => compare_numbers(*n, 0.0),
(Value::Empty, Value::Text(t)) => "".cmp(&t.to_lowercase() as &str),
(Value::Text(t), Value::Empty) => (t.to_lowercase() as String).as_str().cmp(""),
(Value::Empty, Value::Bool(b)) => false.cmp(b),
(Value::Bool(b), Value::Empty) => b.cmp(&false),
(Value::Number(x), Value::Number(y)) => compare_numbers(*x, *y),
(Value::Text(x), Value::Text(y)) => compare_text(x, y),
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
(Value::Error(x), Value::Error(y)) => x.code().cmp(&y.code()),
_ => a.rank().cmp(&b.rank()),
}
}
/// Compares numbers the way Excel does: both are rounded to 15 significant digits first, so binary
/// noise such as `0.1+0.2` versus `0.3` counts as equal.
pub fn compare_numbers(a: f64, b: f64) -> Ordering {
fn round15(x: f64) -> f64 {
if !x.is_finite() || x == 0.0 { x } else { format!("{x:.14e}").parse().unwrap_or(x) }
}
if a == b {
return Ordering::Equal;
}
// Numbers further apart than the 15th digit can't round together, and rounding keeps their
// order: skip the text round trip (this is on the sort and lookup hot path).
if (a - b).abs() > a.abs().max(b.abs()) * 1e-13 {
return a.partial_cmp(&b).unwrap_or(Ordering::Equal);
}
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
}
/// Case-insensitive text order (Unicode lowercase).
pub fn compare_text(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().flat_map(char::to_lowercase);
let mut bi = b.chars().flat_map(char::to_lowercase);
loop {
match (ai.next(), bi.next()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(x), Some(y)) if x != y => return x.cmp(&y),
_ => {}
}
}
}
/// Sorting order used by Sort (ascending): numbers, text, booleans, errors, then blanks last.
pub fn sort_compare(a: &Value, b: &Value) -> Ordering {
match (a, b) {
(Value::Empty, Value::Empty) => Ordering::Equal,
(Value::Empty, _) => Ordering::Greater,
(_, Value::Empty) => Ordering::Less,
(Value::Error(_), Value::Error(_)) => Ordering::Equal,
_ => compare(a, b),
}
}
/// Number → text the way Excel converts in `&` and `TEXT(x,"General")`-free contexts: up to 15
/// significant digits, scientific for very large or very small magnitudes.
pub fn number_to_text(n: f64) -> String {
if n == 0.0 || !n.is_finite() {
return "0".into();
}
let a = n.abs();
if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {
return sci_text(n, 15);
}
// A value just below 1e15 can round up to 1e15 at 15 significant digits.
if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {
return sci_text(n, 15);
}
// 15 significant digits, trailing zeros trimmed.
let mag = a.log10().floor() as i32;
let decimals = (14 - mag).clamp(0, 30) as usize;
let s = format!("{:.*}", decimals, n);
trim_decimal(&s)
}
/// Removes trailing zeros (and a trailing point) from a decimal string.
pub fn trim_decimal(s: &str) -> String {
if s.contains('.') {
let t = s.trim_end_matches('0').trim_end_matches('.');
if t == "-0" { "0".into() } else { t.to_string() }
} else {
s.to_string()
}
}
/// Scientific text like `1.23456789012346E+15` with up to `sig` significant digits.
pub fn sci_text(n: f64, sig: usize) -> String {
let s = format!("{:.*e}", sig.saturating_sub(1), n);
let Some((mant, exp)) = s.split_once('e') else { return s };
let mant = trim_decimal(mant);
let e: i32 = exp.parse().unwrap_or(0);
format!("{}E{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_text() {
assert_eq!(number_to_text(1.0), "1");
assert_eq!(number_to_text(-2.5), "-2.5");
assert_eq!(number_to_text(0.1 + 0.2), "0.3");
assert_eq!(number_to_text(123456789012345.0), "123456789012345");
assert_eq!(number_to_text(1e15), "1E+15");
assert_eq!(number_to_text(1234567890123456.0), "1.23456789012346E+15");
assert_eq!(number_to_text(0.0001), "0.0001");
assert_eq!(number_to_text(1e-10), "1E-10");
assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333");
}
#[test]
fn number_text_rounds_up_to_scientific_boundary() {
assert_eq!(number_to_text(999999999999999.9), "1E+15");
assert_eq!(number_to_text(-999999999999999.9), "-1E+15");
assert_eq!(number_to_text(999999999999999.0), "999999999999999");
}
#[test]
fn coercion() {
assert_eq!(Value::from("12").to_number(), Ok(12.0));
assert_eq!(Value::from(" 1,234.5 ").to_number(), Ok(1234.5));
assert_eq!(Value::from("50%").to_number(), Ok(0.5));
assert_eq!(Value::from("abc").to_number(), Err(CellError::Value));
assert_eq!(Value::Bool(true).to_number(), Ok(1.0));
assert_eq!(Value::Empty.to_text(), Ok(String::new()));
assert_eq!(Value::from("true").to_bool(), Ok(true));
assert!(Value::number(f64::NAN).is_error());
}
#[test]
fn comparisons() {
assert_eq!(compare(&Value::from("abc"), &Value::from("ABC")), Ordering::Equal);
assert_eq!(compare(&Value::Number(5.0), &Value::from("1")), Ordering::Less);
assert_eq!(compare(&Value::from("z"), &Value::Bool(false)), Ordering::Less);
assert_eq!(compare(&Value::Empty, &Value::Number(0.0)), Ordering::Equal);
assert_eq!(sort_compare(&Value::Empty, &Value::Number(1.0)), Ordering::Greater);
}
#[test]
fn errors() {
for e in CellError::ALL {
assert_eq!(CellError::parse(e.as_str()), Some(e));
}
assert_eq!(CellError::parse("#div/0!"), Some(CellError::Div0));
}
#[test]
fn numbers_compare_at_15_significant_digits() {
let num = Value::Number;
assert_eq!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Equal);
assert_eq!(compare(&num(1.1 * 3.0), &num(3.3)), Ordering::Equal);
assert_eq!(compare(&num(1.0), &num(1.0 + 1e-16)), Ordering::Equal);
assert_ne!(compare(&num(0.1 + 0.2), &num(0.3)), Ordering::Greater);
assert_eq!(compare(&num(1.0), &num(1.00000000000001)), Ordering::Less);
assert_eq!(compare(&num(1.0), &num(2.0)), Ordering::Less);
}
#[test]
fn arrays() {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0)));
assert!(Array::new(2, 2, vec![]).is_none());
let row = Array::row(vec![1.0.into(), 2.0.into()]);
assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0));
}
}