diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..97ef60d57 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -511,6 +511,15 @@ fn args_signature_xlookup(arg_count: usize) -> Vec { result } +fn args_signature_xmatch(arg_count: usize) -> Vec { + if !(2..=4).contains(&arg_count) { + return vec![Signature::Error; arg_count]; + } + let mut result = vec![Signature::Scalar; arg_count]; + result[1] = Signature::Vector; // lookup_array should be Vector + result +} + fn args_signature_textafter(arg_count: usize) -> Vec { if !(2..=6).contains(&arg_count) { vec![Signature::Scalar; arg_count] @@ -657,6 +666,7 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_one_vector(arg_count), Function::Vlookup => args_signature_hlookup(arg_count), Function::Xlookup => args_signature_xlookup(arg_count), + Function::Xmatch => args_signature_xmatch(arg_count), Function::Concat => vec![Signature::Vector; arg_count], Function::Concatenate => vec![Signature::Scalar; arg_count], Function::Exact => args_signature_scalars(arg_count, 2, 0), @@ -862,6 +872,7 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Rows => not_implemented(args), Function::Vlookup => not_implemented(args), Function::Xlookup => not_implemented(args), + Function::Xmatch => not_implemented(args), Function::Concat => not_implemented(args), Function::Concatenate => not_implemented(args), Function::Exact => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..b1ba4cc4d 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -107,6 +107,7 @@ pub enum Function { Rows, Vlookup, Xlookup, + Xmatch, // Text Concat, @@ -253,7 +254,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -311,6 +312,7 @@ impl Function { Function::Rows, Function::Vlookup, Function::Xlookup, + Function::Xmatch, Function::Concatenate, Function::Exact, Function::Value, @@ -469,6 +471,7 @@ impl Function { Function::Minifs => "_xlfn.MINIFS".to_string(), Function::Switch => "_xlfn.SWITCH".to_string(), Function::Xlookup => "_xlfn.XLOOKUP".to_string(), + Function::Xmatch => "_xlfn.XMATCH".to_string(), Function::Xor => "_xlfn.XOR".to_string(), Function::Textbefore => "_xlfn.TEXTBEFORE".to_string(), Function::Textafter => "_xlfn.TEXTAFTER".to_string(), @@ -570,6 +573,7 @@ impl Function { "ROWS" => Some(Function::Rows), "VLOOKUP" => Some(Function::Vlookup), "XLOOKUP" | "_XLFN.XLOOKUP" => Some(Function::Xlookup), + "XMATCH" | "_XLFN.XMATCH" => Some(Function::Xmatch), "CONCATENATE" => Some(Function::Concatenate), "EXACT" => Some(Function::Exact), @@ -789,6 +793,7 @@ impl fmt::Display for Function { Function::Rows => write!(f, "ROWS"), Function::Vlookup => write!(f, "VLOOKUP"), Function::Xlookup => write!(f, "XLOOKUP"), + Function::Xmatch => write!(f, "XMATCH"), Function::Concatenate => write!(f, "CONCATENATE"), Function::Exact => write!(f, "EXACT"), Function::Value => write!(f, "VALUE"), @@ -1027,6 +1032,7 @@ impl Model { Function::Rows => self.fn_rows(args, cell), Function::Vlookup => self.fn_vlookup(args, cell), Function::Xlookup => self.fn_xlookup(args, cell), + Function::Xmatch => self.fn_xmatch(args, cell), // Text Function::Concatenate => self.fn_concatenate(args, cell), Function::Exact => self.fn_exact(args, cell), diff --git a/base/src/functions/xlookup.rs b/base/src/functions/xlookup.rs index ed90ae3fc..6661a502f 100644 --- a/base/src/functions/xlookup.rs +++ b/base/src/functions/xlookup.rs @@ -4,6 +4,9 @@ use crate::{ calc_result::CalcResult, expressions::parser::Node, expressions::token::Error, model::Model, }; +#[cfg(feature = "use_regex_lite")] +use regex_lite as regex; + use super::{ binary_search::{ binary_search_descending_or_greater, binary_search_descending_or_smaller, @@ -26,6 +29,7 @@ enum MatchMode { ExactMatch = 0, ExactMatchLarger = 1, WildcardMatch = 2, + RegexMatch = 3, } // lookup_value in array, match_mode search_mode @@ -114,6 +118,29 @@ fn linear_search( } } } + MatchMode::RegexMatch => { + let result_matches: Box bool> = + if let CalcResult::String(s) = &lookup_value { + if let Ok(reg) = regex::Regex::new(&s.to_lowercase()) { + Box::new(move |x| result_matches_regex(x, ®)) + } else { + Box::new(move |_| false) + } + } else { + Box::new(move |x| compare_values(x, lookup_value) == 0) + }; + for l in 0..length { + let index = if search_mode == SearchMode::StartAtFirstItem { + l + } else { + length - l - 1 + }; + let value = &array[index]; + if result_matches(value) { + return Some(index); + } + } + } } None } @@ -387,4 +414,180 @@ impl Model { }, } } + + /// XMATCH(lookup_value, lookup_array, [match_mode], [search_mode]) + /// Returns the relative position of an item in an array or range + pub(crate) fn fn_xmatch(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() < 2 || args.len() > 4 { + return CalcResult::new_args_number_error(cell); + } + let lookup_value = self.evaluate_node_in_context(&args[0], cell); + if lookup_value.is_error() { + return lookup_value; + } + let match_mode = if args.len() >= 3 { + match self.get_number(&args[2], cell) { + Ok(c) => match c.floor() as i32 { + -1 => MatchMode::ExactMatchSmaller, + 0 => MatchMode::ExactMatch, + 1 => MatchMode::ExactMatchLarger, + 2 => MatchMode::WildcardMatch, + 3 => MatchMode::RegexMatch, + _ => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + MatchMode::ExactMatch + }; + let search_mode = if args.len() == 4 { + match self.get_number(&args[3], cell) { + Ok(c) => match c.floor() as i32 { + 1 => SearchMode::StartAtFirstItem, + -1 => SearchMode::StartAtLastItem, + -2 => SearchMode::BinarySearchDescending, + 2 => SearchMode::BinarySearchAscending, + _ => { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Unexpected number".to_string(), + }; + } + }, + Err(s) => return s, + } + } else { + SearchMode::StartAtFirstItem + }; + match self.evaluate_node_in_context(&args[1], cell) { + CalcResult::Range { left, right } => { + let is_row_vector; + if left.row == right.row { + is_row_vector = false; + } else if left.column == right.column { + is_row_vector = true; + } else { + return CalcResult::Error { + error: Error::ERROR, + origin: cell, + message: "Second argument must be a vector".to_string(), + }; + } + let mut row2 = right.row; + let row1 = left.row; + let mut column2 = right.column; + let column1 = left.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), + ); + } + }; + } + let left = CellReferenceIndex { + sheet: left.sheet, + column: column1, + row: row1, + }; + let right = CellReferenceIndex { + sheet: left.sheet, + column: column2, + row: row2, + }; + match search_mode { + SearchMode::StartAtFirstItem | SearchMode::StartAtLastItem => { + let array = self.prepare_array(&left, &right, is_row_vector); + match linear_search(&lookup_value, &array, search_mode, match_mode) { + Some(index) => CalcResult::Number(index as f64 + 1.0), + None => CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }, + } + } + SearchMode::BinarySearchAscending | SearchMode::BinarySearchDescending => { + let array = self.prepare_array(&left, &right, is_row_vector); + let index = match match_mode { + MatchMode::ExactMatchLarger => { + if search_mode == SearchMode::BinarySearchAscending { + binary_search_or_greater(&lookup_value, &array) + } else { + binary_search_descending_or_greater(&lookup_value, &array) + } + } + MatchMode::ExactMatchSmaller | MatchMode::ExactMatch => { + if search_mode == SearchMode::BinarySearchAscending { + binary_search_or_smaller(&lookup_value, &array) + } else { + binary_search_descending_or_smaller(&lookup_value, &array) + } + } + MatchMode::WildcardMatch | MatchMode::RegexMatch => { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Cannot use wildcard in binary search".to_string(), + }; + } + }; + match index { + None => CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }, + Some(l) => { + if match_mode == MatchMode::ExactMatch { + let value = self.evaluate_cell(CellReferenceIndex { + sheet: left.sheet, + row: left.row + if is_row_vector { l } else { 0 }, + column: left.column + if is_row_vector { 0 } else { l }, + }); + if compare_values(&value, &lookup_value) != 0 { + return CalcResult::Error { + error: Error::NA, + origin: cell, + message: "Not found".to_string(), + }; + } + } + CalcResult::Number(l as f64 + 1.0) + } + } + } + } + } + error @ CalcResult::Error { .. } => error, + _ => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Range expected".to_string(), + }, + } + } } diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..964bb17a1 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -28,6 +28,7 @@ mod test_fn_sumifs; mod test_fn_textbefore; mod test_fn_textjoin; mod test_fn_unicode; +mod test_fn_xmatch; mod test_frozen_rows_columns; mod test_general; mod test_math; diff --git a/base/src/test/test_fn_xmatch.rs b/base/src/test/test_fn_xmatch.rs new file mode 100644 index 000000000..2f654e3d5 --- /dev/null +++ b/base/src/test/test_fn_xmatch.rs @@ -0,0 +1,154 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn test_fn_xmatch_basic_exact_match() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("A3", "30"); + model._set("B1", "=XMATCH(20, A1:A3)"); // Default mode 0 + model._set("B2", "=XMATCH(25, A1:A3)"); // Not found + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); + assert_eq!(model._get_text("B2"), *"#N/A"); +} + +#[test] +fn test_fn_xmatch_match_modes() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("A3", "30"); + model._set("B1", "=XMATCH(25, A1:A3, -1)"); // Exact or next smaller + model._set("B2", "=XMATCH(15, A1:A3, 1)"); // Exact or next larger + model._set("C1", "apple"); + model._set("C2", "banana"); + model._set("C3", "apricot"); + model._set("B3", "=XMATCH(\"ap*\", C1:C3, 2)"); // Wildcard + model._set("B4", "=XMATCH(\"^[a-c]\", C1:C3, 3)"); // Regex + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); // Found 20 (next smaller than 25) + assert_eq!(model._get_text("B2"), *"2"); // Found 20 (next larger than 15) + assert_eq!(model._get_text("B3"), *"1"); // Found "apple" + assert_eq!(model._get_text("B4"), *"1"); // Found "apple" +} + +#[test] +fn test_fn_xmatch_search_modes() { + let mut model = new_empty_model(); + model._set("A1", "a"); + model._set("A2", "b"); + model._set("A3", "a"); // Duplicate + model._set("B1", "=XMATCH(\"a\", A1:A3, 0, 1)"); // Search from first + model._set("B2", "=XMATCH(\"a\", A1:A3, 0, -1)"); // Search from last + // Binary search tests + model._set("C1", "10"); + model._set("C2", "20"); + model._set("C3", "30"); + model._set("B3", "=XMATCH(20, C1:C3, 0, 2)"); // Binary ascending + model._set("D1", "30"); + model._set("D2", "20"); + model._set("D3", "10"); + model._set("B4", "=XMATCH(20, D1:D3, 0, -2)"); // Binary descending + model.evaluate(); + assert_eq!(model._get_text("B1"), *"1"); // First occurrence + assert_eq!(model._get_text("B2"), *"3"); // Last occurrence + assert_eq!(model._get_text("B3"), *"2"); // Binary search ascending + assert_eq!(model._get_text("B4"), *"2"); // Binary search descending +} + +#[test] +fn test_fn_xmatch_data_types_and_vectors() { + let mut model = new_empty_model(); + // Different data types + model._set("A1", "1.5"); + model._set("A2", "hello"); + model._set("A3", "TRUE"); + model._set("B1", "=XMATCH(\"hello\", A1:A3)"); + model._set("B2", "=XMATCH(TRUE, A1:A3)"); + // Column vector + model._set("C1", "apple"); + model._set("D1", "banana"); + model._set("E1", "cherry"); + model._set("B3", "=XMATCH(\"banana\", C1:E1)"); + // Empty cells + model._set("F1", ""); + model._set("F2", "test"); + model._set("B4", "=XMATCH(\"\", F1:F2)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"2"); + assert_eq!(model._get_text("B2"), *"3"); + assert_eq!(model._get_text("B3"), *"2"); // Column vector + assert_eq!(model._get_text("B4"), *"1"); // Empty cell +} + +#[test] +fn test_fn_xmatch_error_conditions() { + let mut model = new_empty_model(); + model._set("A1", "test"); + // Wrong number of arguments + model._set("B1", "=XMATCH(\"test\")"); + model._set("B2", "=XMATCH(\"test\", A1:A1, 0, 1, 1)"); + // Invalid modes + model._set("B3", "=XMATCH(\"test\", A1:A1, 5)"); // Invalid match mode + model._set("B4", "=XMATCH(\"test\", A1:A1, 0, 5)"); // Invalid search mode + // Non-vector range + model._set("A2", "test2"); + model._set("C1", "test3"); + model._set("C2", "test4"); + model._set("B5", "=XMATCH(\"test\", A1:C2)"); // 2x2 range + // Binary search with wildcard (should error) + model._set("B6", "=XMATCH(\"ap*\", A1:A1, 2, 2)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); + assert_eq!(model._get_text("B3"), *"#VALUE!"); + assert_eq!(model._get_text("B4"), *"#ERROR!"); + assert_eq!(model._get_text("B5"), *"#ERROR!"); + assert_eq!(model._get_text("B6"), *"#VALUE!"); +} + +#[test] +fn test_fn_xmatch_edge_cases() { + let mut model = new_empty_model(); + // Case sensitivity (case-insensitive comparison) + model._set("A1", "Test"); + model._set("A2", "TEST"); + model._set("A3", "test"); + model._set("B1", "=XMATCH(\"test\", A1:A3)"); + // No smaller/larger values available + model._set("C1", "20"); + model._set("C2", "30"); + model._set("C3", "40"); + model._set("B2", "=XMATCH(10, C1:C3, -1)"); // No smaller + model._set("B3", "=XMATCH(50, C1:C3, 1)"); // No larger + // Invalid regex pattern + model._set("B4", "=XMATCH(\"[\", A1:A1, 3)"); + model.evaluate(); + assert_eq!(model._get_text("B1"), *"1"); // Case-insensitive + assert_eq!(model._get_text("B2"), *"#N/A"); // No smaller value + assert_eq!(model._get_text("B3"), *"#N/A"); // No larger value + assert_eq!(model._get_text("B4"), *"#N/A"); // Invalid regex +} + +#[test] +fn test_fn_xmatch_range_as_lookup_value() { + let mut model = new_empty_model(); + model._set("A1", "10"); + model._set("A2", "20"); + model._set("B1", "10"); + model._set("B2", "20"); + model._set("B3", "30"); + + // Test passing a range as lookup_value (should error since implicit intersection not supported) + model._set("C1", "=XMATCH(A1:A2, B1:B3)"); // Range as first argument should error + model._set("C2", "=XMATCH(A1:A1, B1:B3)"); // Single-cell range as first argument should also error + + model.evaluate(); + + // Since implicit intersection isn't supported, these return #N/A + assert_eq!(model._get_text("C1"), *"#N/A"); + assert_eq!(model._get_text("C2"), *"#N/A"); +} diff --git a/docs/src/functions/lookup-and-reference.md b/docs/src/functions/lookup-and-reference.md index 250474c77..bf886cbb8 100644 --- a/docs/src/functions/lookup-and-reference.md +++ b/docs/src/functions/lookup-and-reference.md @@ -47,4 +47,4 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | WRAPCOLS | | – | | WRAPROWS | | – | | XLOOKUP | | – | -| XMATCH | | – | +| XMATCH | | – | diff --git a/docs/src/functions/lookup_and_reference/xmatch.md b/docs/src/functions/lookup_and_reference/xmatch.md index 104bf351d..530b8c67e 100644 --- a/docs/src/functions/lookup_and_reference/xmatch.md +++ b/docs/src/functions/lookup_and_reference/xmatch.md @@ -7,6 +7,5 @@ lang: en-US # XMATCH ::: warning -🚧 This function is not yet available in IronCalc. -[Follow development here](https://github.com/ironcalc/IronCalc/labels/Functions) +🚧 This function is implemented but currently lacks detailed documentation. For guidance, you may refer to the equivalent functionality in [Microsoft Excel documentation](https://support.microsoft.com/en-us/office/excel-functions-by-category-5f91f4e9-7b42-46d2-9bd1-63f26a86c0eb). ::: \ No newline at end of file