From 92ecd7d6bf1939666d8cb49249f5e81f2f1dd8e1 Mon Sep 17 00:00:00 2001 From: Brian Hung Date: Mon, 14 Jul 2025 01:31:58 -0700 Subject: [PATCH 1/2] Add PROPER and REPLACE text functions --- .../src/expressions/parser/static_analysis.rs | 4 + base/src/functions/mod.rs | 12 +- base/src/functions/text.rs | 143 ++++++++++++++++++ docs/src/functions/text.md | 4 +- docs/src/functions/text/proper.md | 3 +- docs/src/functions/text/replace.md | 3 +- 6 files changed, 162 insertions(+), 7 deletions(-) diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 80f194360..206ff0a91 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -665,6 +665,8 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 1, 0), Function::Lower => args_signature_scalars(arg_count, 1, 0), Function::Mid => args_signature_scalars(arg_count, 3, 0), + Function::Proper => args_signature_scalars(arg_count, 1, 0), + Function::Replace => args_signature_scalars(arg_count, 4, 0), Function::Rept => args_signature_scalars(arg_count, 2, 0), Function::Right => args_signature_scalars(arg_count, 2, 1), Function::Search => args_signature_scalars(arg_count, 2, 1), @@ -870,6 +872,8 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Len => not_implemented(args), Function::Lower => not_implemented(args), Function::Mid => not_implemented(args), + Function::Proper => not_implemented(args), + Function::Replace => not_implemented(args), Function::Rept => not_implemented(args), Function::Right => not_implemented(args), Function::Search => not_implemented(args), diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index 21c8f72da..ce517539f 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -117,6 +117,8 @@ pub enum Function { Len, Lower, Mid, + Proper, + Replace, Rept, Right, Search, @@ -253,7 +255,7 @@ pub enum Function { } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -322,6 +324,8 @@ impl Function { Function::Len, Function::Lower, Function::Mid, + Function::Proper, + Function::Replace, Function::Right, Function::Search, Function::Text, @@ -582,6 +586,8 @@ impl Function { "LEN" => Some(Function::Len), "LOWER" => Some(Function::Lower), "MID" => Some(Function::Mid), + "PROPER" => Some(Function::Proper), + "REPLACE" => Some(Function::Replace), "RIGHT" => Some(Function::Right), "SEARCH" => Some(Function::Search), "TEXT" => Some(Function::Text), @@ -800,6 +806,8 @@ impl fmt::Display for Function { Function::Len => write!(f, "LEN"), Function::Lower => write!(f, "LOWER"), Function::Mid => write!(f, "MID"), + Function::Proper => write!(f, "PROPER"), + Function::Replace => write!(f, "REPLACE"), Function::Right => write!(f, "RIGHT"), Function::Search => write!(f, "SEARCH"), Function::Text => write!(f, "TEXT"), @@ -1039,6 +1047,8 @@ impl Model { Function::Len => self.fn_len(args, cell), Function::Lower => self.fn_lower(args, cell), Function::Mid => self.fn_mid(args, cell), + Function::Proper => self.fn_proper(args, cell), + Function::Replace => self.fn_replace(args, cell), Function::Right => self.fn_right(args, cell), Function::Search => self.fn_search(args, cell), Function::Text => self.fn_text(args, cell), diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index d2f41a791..bc895c9c6 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -382,6 +382,59 @@ impl Model { CalcResult::new_args_number_error(cell) } + pub(crate) fn fn_proper(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() == 1 { + let text = match self.evaluate_node_in_context(&args[0], cell) { + CalcResult::Number(v) => format!("{v}"), + CalcResult::String(v) => v, + CalcResult::Boolean(b) => { + if b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } + error @ CalcResult::Error { .. } => return error, + CalcResult::Range { .. } => { + return CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Implicit Intersection not implemented".to_string(), + }; + } + CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(), + CalcResult::Array(_) => { + return CalcResult::Error { + error: Error::NIMPL, + origin: cell, + message: "Arrays not supported yet".to_string(), + } + } + }; + let mut result = String::new(); + let mut start_word = true; + for ch in text.chars() { + if ch.is_alphabetic() { + if start_word { + for c in ch.to_uppercase() { + result.push(c); + } + } else { + for c in ch.to_lowercase() { + result.push(c); + } + } + start_word = false; + } else { + result.push(ch); + start_word = true; + } + } + return CalcResult::String(result); + } + CalcResult::new_args_number_error(cell) + } + pub(crate) fn fn_unicode(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() == 1 { let s = match self.evaluate_node_in_context(&args[0], cell) { @@ -752,6 +805,56 @@ impl Model { CalcResult::String(result) } + pub(crate) fn fn_replace(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 4 { + return CalcResult::new_args_number_error(cell); + } + let old_text = match self.get_string(&args[0], cell) { + Ok(s) => s, + Err(e) => return e, + }; + let start_num = match self.get_number(&args[1], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let num_chars = match self.get_number(&args[2], cell) { + Ok(f) => f, + Err(e) => return e, + }; + let new_text = match self.get_string(&args[3], cell) { + Ok(s) => s, + Err(e) => return e, + }; + if start_num < 1.0 || num_chars < 0.0 { + return CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Invalid value".to_string(), + }; + } + let start_index = start_num.floor() as usize - 1; + let num = num_chars.floor() as usize; + fn byte_index_from_char(s: &str, idx: usize) -> usize { + let mut count = 0usize; + for (b, _) in s.char_indices() { + if count == idx { + return b; + } + count += 1; + } + s.len() + } + let start_byte = byte_index_from_char(&old_text, start_index); + let end_byte = byte_index_from_char(&old_text, start_index.saturating_add(num)); + let result = format!( + "{}{}{}", + &old_text[..start_byte], + new_text, + &old_text[end_byte..] + ); + CalcResult::String(result) + } + // REPT(text, number_times) pub(crate) fn fn_rept(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { if args.len() != 2 { @@ -1271,3 +1374,43 @@ impl Model { CalcResult::String(text) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{expressions::parser::Node, Model}; + + fn default_cell() -> CellReferenceIndex { + CellReferenceIndex { + sheet: 0, + row: 1, + column: 1, + } + } + + #[test] + fn test_proper_basic() { + let mut model = Model::new_empty("test", "en", "UTC").unwrap(); + let result = model.fn_proper(&[Node::StringKind("one TWO".to_string())], default_cell()); + match result { + CalcResult::String(s) => assert_eq!(s, "One Two"), + _ => panic!("unexpected result"), + } + } + + #[test] + fn test_replace_basic() { + let mut model = Model::new_empty("test", "en", "UTC").unwrap(); + let args = [ + Node::StringKind("abcdef".to_string()), + Node::NumberKind(2.0), + Node::NumberKind(3.0), + Node::StringKind("XYZ".to_string()), + ]; + let result = model.fn_replace(&args, default_cell()); + match result { + CalcResult::String(s) => assert_eq!(s, "aXYZef"), + _ => panic!("unexpected result"), + } + } +} diff --git a/docs/src/functions/text.md b/docs/src/functions/text.md index 4217d90d6..918512118 100644 --- a/docs/src/functions/text.md +++ b/docs/src/functions/text.md @@ -34,8 +34,8 @@ You can track the progress in this [GitHub issue](https://github.com/ironcalc/Ir | MIDB | | – | | NUMBERVALUE | | – | | PHONETIC | | – | -| PROPER | | – | -| REPLACE | | – | +| PROPER | | – | +| REPLACE | | – | | REPLACEB | | – | | REPT | | – | | RIGHT | | – | diff --git a/docs/src/functions/text/proper.md b/docs/src/functions/text/proper.md index fce3b9a26..455305881 100644 --- a/docs/src/functions/text/proper.md +++ b/docs/src/functions/text/proper.md @@ -7,6 +7,5 @@ lang: en-US # PROPER ::: 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 diff --git a/docs/src/functions/text/replace.md b/docs/src/functions/text/replace.md index eccdaa854..368a2d927 100644 --- a/docs/src/functions/text/replace.md +++ b/docs/src/functions/text/replace.md @@ -7,6 +7,5 @@ lang: en-US # REPLACE ::: 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 From 4c5154ec7d08d61e66706502587e03ef21a64fb1 Mon Sep 17 00:00:00 2001 From: BrianHung Date: Mon, 14 Jul 2025 12:02:34 -0700 Subject: [PATCH 2/2] refactor test cases out --- base/src/functions/text.rs | 44 +---------------------- base/src/test/mod.rs | 2 ++ base/src/test/test_fn_proper.rs | 45 +++++++++++++++++++++++ base/src/test/test_fn_replace.rs | 61 ++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 43 deletions(-) create mode 100644 base/src/test/test_fn_proper.rs create mode 100644 base/src/test/test_fn_replace.rs diff --git a/base/src/functions/text.rs b/base/src/functions/text.rs index bc895c9c6..a7828ef70 100644 --- a/base/src/functions/text.rs +++ b/base/src/functions/text.rs @@ -835,12 +835,10 @@ impl Model { let start_index = start_num.floor() as usize - 1; let num = num_chars.floor() as usize; fn byte_index_from_char(s: &str, idx: usize) -> usize { - let mut count = 0usize; - for (b, _) in s.char_indices() { + for (count, (b, _)) in s.char_indices().enumerate() { if count == idx { return b; } - count += 1; } s.len() } @@ -1374,43 +1372,3 @@ impl Model { CalcResult::String(text) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{expressions::parser::Node, Model}; - - fn default_cell() -> CellReferenceIndex { - CellReferenceIndex { - sheet: 0, - row: 1, - column: 1, - } - } - - #[test] - fn test_proper_basic() { - let mut model = Model::new_empty("test", "en", "UTC").unwrap(); - let result = model.fn_proper(&[Node::StringKind("one TWO".to_string())], default_cell()); - match result { - CalcResult::String(s) => assert_eq!(s, "One Two"), - _ => panic!("unexpected result"), - } - } - - #[test] - fn test_replace_basic() { - let mut model = Model::new_empty("test", "en", "UTC").unwrap(); - let args = [ - Node::StringKind("abcdef".to_string()), - Node::NumberKind(2.0), - Node::NumberKind(3.0), - Node::StringKind("XYZ".to_string()), - ]; - let result = model.fn_replace(&args, default_cell()); - match result { - CalcResult::String(s) => assert_eq!(s, "aXYZef"), - _ => panic!("unexpected result"), - } - } -} diff --git a/base/src/test/mod.rs b/base/src/test/mod.rs index a0a0d69d6..067d6b0f2 100644 --- a/base/src/test/mod.rs +++ b/base/src/test/mod.rs @@ -22,6 +22,8 @@ mod test_fn_maxifs; mod test_fn_minifs; mod test_fn_or_xor; mod test_fn_product; +mod test_fn_proper; +mod test_fn_replace; mod test_fn_rept; mod test_fn_sum; mod test_fn_sumifs; diff --git a/base/src/test/test_fn_proper.rs b/base/src/test/test_fn_proper.rs new file mode 100644 index 000000000..f25e8b2c2 --- /dev/null +++ b/base/src/test/test_fn_proper.rs @@ -0,0 +1,45 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_proper_args_number() { + let mut model = new_empty_model(); + + // No arguments + model._set("A1", "=PROPER()"); + // Too many arguments + model._set("A2", "=PROPER(\"text\", \"extra\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"#ERROR!"); + assert_eq!(model._get_text("A2"), *"#ERROR!"); +} + +#[test] +fn fn_proper_basic() { + let mut model = new_empty_model(); + model._set("A1", "one TWO"); + + model._set("B1", "=PROPER(A1)"); + model._set("B2", "=PROPER(\"mcdonald\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"One Two"); + assert_eq!(model._get_text("B2"), *"Mcdonald"); +} + +#[test] +fn fn_proper_punctuation() { + let mut model = new_empty_model(); + + model._set("A1", "=PROPER(\"o'reilly\")"); + model._set("A2", "=PROPER(\"smith-jones\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"O'Reilly"); + assert_eq!(model._get_text("A2"), *"Smith-Jones"); +} diff --git a/base/src/test/test_fn_replace.rs b/base/src/test/test_fn_replace.rs new file mode 100644 index 000000000..02b0a6673 --- /dev/null +++ b/base/src/test/test_fn_replace.rs @@ -0,0 +1,61 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; + +#[test] +fn fn_replace_args_number() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + // Too few arguments + model._set("B1", "=REPLACE(A1, 2, 3)"); + // Too many arguments + model._set("B2", "=REPLACE(A1, 2, 3, \"X\", \"Y\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"#ERROR!"); + assert_eq!(model._get_text("B2"), *"#ERROR!"); +} + +#[test] +fn fn_replace_basic() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + model._set("B1", "=REPLACE(A1, 2, 3, \"XYZ\")"); + model._set("B2", "=REPLACE(\"12345\", 1, 1, \"abc\")"); + + model.evaluate(); + + assert_eq!(model._get_text("B1"), *"aXYZef"); + assert_eq!(model._get_text("B2"), *"abc2345"); +} + +#[test] +fn fn_replace_invalid_values() { + let mut model = new_empty_model(); + model._set("A1", "abcdef"); + + // start_num less than 1 + model._set("C1", "=REPLACE(A1, 0, 2, \"x\")"); + // num_chars negative + model._set("C2", "=REPLACE(A1, 2, -1, \"x\")"); + + model.evaluate(); + + assert_eq!(model._get_text("C1"), *"#VALUE!"); + assert_eq!(model._get_text("C2"), *"#VALUE!"); +} + +#[test] +fn fn_replace_start_beyond_length() { + let mut model = new_empty_model(); + + // start_num is greater than the length of old_text → new_text should be appended + model._set("A1", "=REPLACE(\"abc\", 5, 0, \"X\")"); + + model.evaluate(); + + assert_eq!(model._get_text("A1"), *"abcX"); +}