From 3e2b177ffec2fa86b268069bf2f09a23fdc6b3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher=20Andr=C3=A9s?= Date: Sun, 2 Nov 2025 19:50:58 +0100 Subject: [PATCH] UPDATE: Adds GCD and LCM functions (#502) * UPDATE: Adds GCD and LCM functions They follow SUM and accept arrays * FIX: Implement copilot suggestions --- .../src/expressions/parser/static_analysis.rs | 26 +- base/src/functions/mathematical.rs | 317 ++++++++++++++++++ base/src/functions/mod.rs | 14 +- xlsx/tests/calc_tests/GCD_LCM.xlsx | Bin 0 -> 9951 bytes 4 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 xlsx/tests/calc_tests/GCD_LCM.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index a2af299..23022a6 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -851,17 +851,19 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Even => args_signature_scalars(arg_count, 1, 0), Function::Odd => args_signature_scalars(arg_count, 1, 0), - Function::Ceiling => args_signature_scalars(arg_count, 2, 0), // (number, significance) - Function::CeilingMath => args_signature_scalars(arg_count, 1, 2), // (number, [significance], [mode]) - Function::CeilingPrecise => args_signature_scalars(arg_count, 1, 1), // (number, [significance]) - Function::Floor => args_signature_scalars(arg_count, 2, 0), // (number, significance) - Function::FloorMath => args_signature_scalars(arg_count, 1, 2), // (number, [significance], [mode]) - Function::FloorPrecise => args_signature_scalars(arg_count, 1, 1), // (number, [significance]) - Function::IsoCeiling => args_signature_scalars(arg_count, 1, 1), // (number, [significance]) - Function::Mod => args_signature_scalars(arg_count, 2, 0), // (number, divisor) - Function::Quotient => args_signature_scalars(arg_count, 2, 0), // (number, denominator) - Function::Mround => args_signature_scalars(arg_count, 2, 0), // (number, multiple) - Function::Trunc => args_signature_scalars(arg_count, 1, 1), // (num, [num_digits]) + Function::Ceiling => args_signature_scalars(arg_count, 2, 0), + Function::CeilingMath => args_signature_scalars(arg_count, 1, 2), + Function::CeilingPrecise => args_signature_scalars(arg_count, 1, 1), + Function::Floor => args_signature_scalars(arg_count, 2, 0), + Function::FloorMath => args_signature_scalars(arg_count, 1, 2), + Function::FloorPrecise => args_signature_scalars(arg_count, 1, 1), + Function::IsoCeiling => args_signature_scalars(arg_count, 1, 1), + Function::Mod => args_signature_scalars(arg_count, 2, 0), + Function::Quotient => args_signature_scalars(arg_count, 2, 0), + Function::Mround => args_signature_scalars(arg_count, 2, 0), + Function::Trunc => args_signature_scalars(arg_count, 1, 1), + Function::Gcd => vec![Signature::Vector; arg_count], + Function::Lcm => vec![Signature::Vector; arg_count], } } @@ -1112,5 +1114,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Quotient => scalar_arguments(args), Function::Mround => scalar_arguments(args), Function::Trunc => scalar_arguments(args), + Function::Gcd => not_implemented(args), + Function::Lcm => not_implemented(args), } } diff --git a/base/src/functions/mathematical.rs b/base/src/functions/mathematical.rs index af1b066..91ad94e 100644 --- a/base/src/functions/mathematical.rs +++ b/base/src/functions/mathematical.rs @@ -14,6 +14,32 @@ pub fn random() -> f64 { rand::random() } +// Euclidean gcd for i64 (non-negative inputs expected) +fn gcd_i64(mut a: i64, mut b: i64) -> i64 { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a +} + +// lcm(a, b) = a / gcd(a, b) * b +// we do it in i128 to reduce overflow risk, then back to i64/f64 +fn lcm_i64(a: i64, b: i64) -> Option { + if a == 0 || b == 0 { + return Some(0); + } + let g = gcd_i64(a, b); + let a_div_g = (a / g) as i128; + let prod = a_div_g * (b as i128); + if prod > i64::MAX as i128 { + None + } else { + Some(prod as i64) + } +} + #[cfg(target_arch = "wasm32")] pub fn random() -> f64 { use js_sys::Math; @@ -107,6 +133,297 @@ impl Model { CalcResult::Number(result) } + pub(crate) fn fn_gcd(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + let mut acc: Option = None; + let mut saw_number = false; + let mut has_range = false; + + // Returns Some(CalcResult) if an error occurred + let mut handle_number = |value: f64| -> Option { + if !value.is_finite() { + return Some(CalcResult::new_error( + Error::VALUE, + cell, + "Non-finite number in GCD".to_string(), + )); + } + let n = value.trunc() as i64; + if n < 0 { + return Some(CalcResult::new_error( + Error::NUM, + cell, + "GCD only accepts non-negative integers".to_string(), + )); + } + saw_number = true; + acc = Some(match acc { + Some(cur) => gcd_i64(cur, n), + None => n, + }); + None + }; + + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + has_range = true; + let row1 = left.row; + let mut row2 = right.row; + let column1 = left.column; + let mut column2 = right.column; + + if row1 == 1 && row2 == LAST_ROW { + row2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + + for row in row1..=row2 { + for column in column1..=column2 { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + error @ CalcResult::Error { .. } => return error, + _ => { + // ignore strings / booleans + } + } + } + } + } + CalcResult::Array(array) => { + for row in array { + for value in row { + match value { + ArrayNode::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + ArrayNode::Error(error) => { + return CalcResult::Error { + error, + origin: cell, + message: "Error in array".to_string(), + } + } + _ => { + // ignore strings / booleans + } + } + } + } + } + error @ CalcResult::Error { .. } => return error, + _ => { + // ignore strings / booleans + } + } + } + + if !saw_number && !has_range { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "No valid numbers found".to_string(), + }; + } + + CalcResult::Number(acc.unwrap_or(0) as f64) + } + + pub(crate) fn fn_lcm(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.is_empty() { + return CalcResult::new_args_number_error(cell); + } + + let mut acc: Option = None; + let mut saw_number = false; + let mut has_range = false; + + // Returns Some(CalcResult) if an error occurred + let mut handle_number = |value: f64| -> Option { + if !value.is_finite() { + return Some(CalcResult::new_error( + Error::VALUE, + cell, + "Non-finite number in LCM".to_string(), + )); + } + let n = value.trunc() as i64; + if n < 0 { + return Some(CalcResult::new_error( + Error::NUM, + cell, + "LCM only accepts non-negative integers".to_string(), + )); + } + saw_number = true; + acc = Some(match acc { + Some(cur) => match lcm_i64(cur, n) { + Some(v) => v, + None => { + return Some(CalcResult::new_error( + Error::NUM, + cell, + "LCM result too large".to_string(), + )); + } + }, + None => n, + }); + None + }; + + for arg in args { + match self.evaluate_node_in_context(arg, cell) { + CalcResult::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + CalcResult::Range { left, right } => { + if left.sheet != right.sheet { + return CalcResult::new_error( + Error::VALUE, + cell, + "Ranges are in different sheets".to_string(), + ); + } + has_range = true; + let row1 = left.row; + let mut row2 = right.row; + let column1 = left.column; + let mut column2 = right.column; + + if row1 == 1 && row2 == LAST_ROW { + row2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_row, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + if column1 == 1 && column2 == LAST_COLUMN { + column2 = match self.workbook.worksheet(left.sheet) { + Ok(s) => s.dimension().max_column, + Err(_) => { + return CalcResult::new_error( + Error::ERROR, + cell, + format!("Invalid worksheet index: '{}'", left.sheet), + ); + } + }; + } + + for row in row1..=row2 { + for column in column1..=column2 { + match self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row, + column, + }) { + CalcResult::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + error @ CalcResult::Error { .. } => return error, + _ => { + // ignore strings / booleans + } + } + } + } + } + CalcResult::Array(array) => { + for row in array { + for value in row { + match value { + ArrayNode::Number(value) => { + if let Some(res) = handle_number(value) { + return res; + } + } + ArrayNode::Error(error) => { + return CalcResult::Error { + error, + origin: cell, + message: "Error in array".to_string(), + } + } + _ => { + // ignore strings / booleans + } + } + } + } + } + error @ CalcResult::Error { .. } => return error, + _ => { + // ignore strings / booleans + } + } + } + + if !saw_number && !has_range { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "No valid numbers found".to_string(), + }; + } + + CalcResult::Number(acc.unwrap_or(0) as f64) + } + pub(crate) fn fn_sum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.is_empty() { return CalcResult::new_args_number_error(cell); diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index f65dadc..3f13716 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -108,6 +108,9 @@ pub enum Function { Mround, Trunc, + Gcd, + Lcm, + // Information ErrorType, Formulatext, @@ -301,7 +304,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -361,6 +364,8 @@ impl Function { Function::Quotient, Function::Mround, Function::Trunc, + Function::Gcd, + Function::Lcm, Function::Max, Function::Min, Function::Product, @@ -667,6 +672,9 @@ impl Function { "MROUND" => Some(Function::Mround), "TRUNC" => Some(Function::Trunc), + "GCD" => Some(Function::Gcd), + "LCM" => Some(Function::Lcm), + "PI" => Some(Function::Pi), "ABS" => Some(Function::Abs), "SQRT" => Some(Function::Sqrt), @@ -1128,6 +1136,8 @@ impl fmt::Display for Function { Function::Quotient => write!(f, "QUOTIENT"), Function::Mround => write!(f, "MROUND"), Function::Trunc => write!(f, "TRUNC"), + Function::Gcd => write!(f, "GCD"), + Function::Lcm => write!(f, "LCM"), } } } @@ -1399,6 +1409,8 @@ impl Model { Function::Quotient => self.fn_quotient(args, cell), Function::Mround => self.fn_mround(args, cell), Function::Trunc => self.fn_trunc(args, cell), + Function::Gcd => self.fn_gcd(args, cell), + Function::Lcm => self.fn_lcm(args, cell), } } } diff --git a/xlsx/tests/calc_tests/GCD_LCM.xlsx b/xlsx/tests/calc_tests/GCD_LCM.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..da0236b79b209a60790b9fc7ed5df389b5df3fe5 GIT binary patch literal 9951 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 literal 0 HcmV?d00001