runmat_runtime/builtins/strings/core/
strcmpi.rs

1//! MATLAB-compatible `strcmpi` builtin for RunMat (case-insensitive string comparison).
2
3use runmat_builtins::Value;
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::broadcast::{broadcast_index, broadcast_shapes, compute_strides};
7use crate::builtins::common::spec::{
8    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
9    ReductionNaN, ResidencyPolicy, ShapeRequirements,
10};
11use crate::builtins::common::tensor;
12use crate::builtins::strings::search::text_utils::{logical_result, TextCollection, TextElement};
13#[cfg(feature = "doc_export")]
14use crate::register_builtin_doc_text;
15use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
16
17#[cfg(feature = "doc_export")]
18pub const DOC_MD: &str = r#"---
19title: "strcmpi"
20category: "strings/core"
21keywords: ["strcmpi", "case insensitive compare", "string compare", "text equality", "cell array"]
22summary: "Compare text inputs for equality without considering letter case, matching MATLAB's `strcmpi` semantics."
23references:
24  - https://www.mathworks.com/help/matlab/ref/strcmpi.html
25gpu_support:
26  elementwise: false
27  reduction: false
28  precisions: []
29  broadcasting: "matlab"
30  notes: "Runs on the host; GPU inputs are gathered automatically before comparison so results match MATLAB exactly."
31fusion:
32  elementwise: false
33  reduction: false
34  max_inputs: 2
35  constants: "inline"
36requires_feature: null
37tested:
38  unit: "builtins::strings::core::strcmpi::tests"
39  integration: "builtins::strings::core::strcmpi::tests::strcmpi_cell_array_scalar_casefold"
40---
41
42# What does the `strcmpi` function do in MATLAB / RunMat?
43`strcmpi(a, b)` compares text values without considering letter case. It returns logical `true` when inputs match case-insensitively and `false` otherwise. Supported text types mirror MATLAB: string scalars/arrays, character vectors and arrays, and cell arrays filled with character vectors.
44
45## How does the `strcmpi` function behave in MATLAB / RunMat?
46- **Case-insensitive**: Letter case is ignored. `"RunMat"`, `"runmat"`, and `"RUNMAT"` all compare equal.
47- **Accepted text types**: Works with string arrays, character vectors or matrices, and cell arrays of character vectors. Mixed combinations are normalised automatically.
48- **Implicit expansion**: Scalar operands expand against array operands so element-wise comparisons follow MATLAB broadcasting rules.
49- **Character arrays**: Rows are compared individually. Results are column vectors whose length equals the number of rows in the character array.
50- **Cell arrays**: Each cell is treated as a text scalar. Scalar cells expand across the other operand before comparison.
51- **Missing strings**: Elements whose value is `missing` (`"<missing>"`) always compare unequal, even to other missing values, matching MATLAB.
52- **Result form**: Scalar comparisons return logical scalars. Otherwise the result is a logical array that matches the broadcast shape.
53
54## `strcmpi` Function GPU Execution Behaviour
55`strcmpi` is registered as an acceleration sink. When either operand resides on the GPU, RunMat gathers both to host memory before comparing them. This keeps behaviour identical to MATLAB and avoids requiring backend-specific kernels. The logical result is produced on the CPU and never remains GPU-resident.
56
57## Examples of using the `strcmpi` function in MATLAB / RunMat
58
59### Compare Two Strings Ignoring Case
60```matlab
61tf = strcmpi("RunMat", "runmat");
62```
63Expected output:
64```matlab
65tf = logical
66   1
67```
68
69### Find Case-Insensitive Matches Inside a String Array
70```matlab
71colors = ["Red" "GREEN" "blue"];
72mask = strcmpi(colors, "green");
73```
74Expected output:
75```matlab
76mask = 1×3 logical array
77   0   1   0
78```
79
80### Compare Character Array Rows Without Case Sensitivity
81```matlab
82animals = char("Cat", "DOG", "cat");
83tf = strcmpi(animals, "cAt");
84```
85Expected output:
86```matlab
87tf = 3×1 logical array
88   1
89   0
90   1
91```
92
93### Compare Cell Arrays Of Character Vectors Case-Insensitively
94```matlab
95C1 = {'north', 'East', 'SOUTH'};
96C2 = {'NORTH', 'east', 'west'};
97tf = strcmpi(C1, C2);
98```
99Expected output:
100```matlab
101tf = 1×3 logical array
102   1   1   0
103```
104
105### Broadcast A String Scalar Against A Character Matrix
106```matlab
107patterns = char("alpha", "BETA");
108tf = strcmpi(patterns, ["ALPHA" "beta"]);
109```
110Expected output:
111```matlab
112tf = 2×2 logical array
113   1   0
114   0   1
115```
116
117### Handle Missing Strings In Case-Insensitive Comparisons
118```matlab
119values = ["Active" missing];
120mask = strcmpi(values, "active");
121```
122Expected output:
123```matlab
124mask = 1×2 logical array
125   1   0
126```
127
128## GPU residency in RunMat (Do I need `gpuArray`?)
129You rarely need to call `gpuArray` manually. When inputs already live on the GPU, RunMat gathers them before calling `strcmpi`, then returns a logical result on the host. This matches MATLAB’s behaviour while keeping the runtime simple. Subsequent GPU-aware code can explicitly transfer results back if needed.
130
131## FAQ
132
133### Does `strcmpi` support string arrays, character arrays, and cell arrays?
134Yes. All MATLAB-supported text containers are accepted, and mixed combinations are handled automatically with implicit expansion.
135
136### Is whitespace significant when comparing character arrays?
137Yes. Trailing spaces or different lengths make rows unequal, just like `strcmp`. Use `strtrim` or `strip` if you need to ignore whitespace.
138
139### Do missing strings compare equal?
140No. The `missing` sentinel compares unequal to all values, including another missing string scalar.
141
142### Can I compare complex numbers or numeric arrays with `strcmpi`?
143No. Both arguments must contain text. Numeric inputs produce a descriptive MATLAB-style error.
144
145### How are GPU inputs handled?
146They are gathered to the CPU automatically before comparison. Providers do not need to implement additional kernels for `strcmpi`.
147
148### What is returned when both inputs are scalars?
149A logical scalar is returned (`true` or `false`). For non-scalar shapes, a logical array that mirrors the broadcast dimensions is produced.
150
151## See Also
152[strcmp](./strcmp), [contains](../../search/contains), [startswith](../../search/startswith), [endswith](../../search/endswith), [strip](../../transform/strip)
153
154## Source & Feedback
155- Implementation: [`crates/runmat-runtime/src/builtins/strings/core/strcmpi.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/core/strcmpi.rs)
156- Found a bug? Please [open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
157"#;
158
159pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
160    name: "strcmpi",
161    op_kind: GpuOpKind::Custom("string-compare"),
162    supported_precisions: &[],
163    broadcast: BroadcastSemantics::Matlab,
164    provider_hooks: &[],
165    constant_strategy: ConstantStrategy::InlineLiteral,
166    residency: ResidencyPolicy::GatherImmediately,
167    nan_mode: ReductionNaN::Include,
168    two_pass_threshold: None,
169    workgroup_size: None,
170    accepts_nan_mode: false,
171    notes: "Runs entirely on the CPU; GPU operands are gathered before comparison.",
172};
173
174register_builtin_gpu_spec!(GPU_SPEC);
175
176pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
177    name: "strcmpi",
178    shape: ShapeRequirements::Any,
179    constant_strategy: ConstantStrategy::InlineLiteral,
180    elementwise: None,
181    reduction: None,
182    emits_nan: false,
183    notes: "Produces logical host results; not eligible for GPU fusion.",
184};
185
186register_builtin_fusion_spec!(FUSION_SPEC);
187
188#[cfg(feature = "doc_export")]
189register_builtin_doc_text!("strcmpi", DOC_MD);
190
191#[runtime_builtin(
192    name = "strcmpi",
193    category = "strings/core",
194    summary = "Compare text inputs for equality without considering case.",
195    keywords = "strcmpi,string compare,text equality",
196    accel = "sink"
197)]
198fn strcmpi_builtin(a: Value, b: Value) -> Result<Value, String> {
199    let a = gather_if_needed(&a).map_err(|e| format!("strcmpi: {e}"))?;
200    let b = gather_if_needed(&b).map_err(|e| format!("strcmpi: {e}"))?;
201    let left = TextCollection::from_argument("strcmpi", a, "first argument")?;
202    let right = TextCollection::from_argument("strcmpi", b, "second argument")?;
203    evaluate_strcmpi(&left, &right)
204}
205
206fn evaluate_strcmpi(left: &TextCollection, right: &TextCollection) -> Result<Value, String> {
207    let shape = broadcast_shapes("strcmpi", &left.shape, &right.shape)?;
208    let total = tensor::element_count(&shape);
209    if total == 0 {
210        return logical_result("strcmpi", Vec::new(), shape);
211    }
212    let left_strides = compute_strides(&left.shape);
213    let right_strides = compute_strides(&right.shape);
214    let left_lower = left.lowercased();
215    let right_lower = right.lowercased();
216    let mut data = Vec::with_capacity(total);
217    for linear in 0..total {
218        let li = broadcast_index(linear, &shape, &left.shape, &left_strides);
219        let ri = broadcast_index(linear, &shape, &right.shape, &right_strides);
220        let equal = match (&left.elements[li], &right.elements[ri]) {
221            (TextElement::Missing, _) => false,
222            (_, TextElement::Missing) => false,
223            (TextElement::Text(_), TextElement::Text(_)) => {
224                match (&left_lower[li], &right_lower[ri]) {
225                    (Some(lhs), Some(rhs)) => lhs == rhs,
226                    _ => false,
227                }
228            }
229        };
230        data.push(if equal { 1 } else { 0 });
231    }
232    logical_result("strcmpi", data, shape)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    #[cfg(feature = "doc_export")]
239    use crate::builtins::common::test_support;
240    use runmat_builtins::{CellArray, CharArray, LogicalArray, StringArray};
241
242    #[test]
243    fn strcmpi_string_scalar_true_ignores_case() {
244        let result = strcmpi_builtin(
245            Value::String("RunMat".into()),
246            Value::String("runmat".into()),
247        )
248        .expect("strcmpi");
249        assert_eq!(result, Value::Bool(true));
250    }
251
252    #[test]
253    fn strcmpi_string_scalar_false_when_text_differs() {
254        let result = strcmpi_builtin(
255            Value::String("RunMat".into()),
256            Value::String("runtime".into()),
257        )
258        .expect("strcmpi");
259        assert_eq!(result, Value::Bool(false));
260    }
261
262    #[test]
263    fn strcmpi_string_array_broadcast_scalar_case_insensitive() {
264        let array = StringArray::new(
265            vec!["red".into(), "green".into(), "blue".into()],
266            vec![1, 3],
267        )
268        .unwrap();
269        let result = strcmpi_builtin(Value::StringArray(array), Value::String("GREEN".into()))
270            .expect("strcmpi");
271        let expected = LogicalArray::new(vec![0, 1, 0], vec![1, 3]).unwrap();
272        assert_eq!(result, Value::LogicalArray(expected));
273    }
274
275    #[test]
276    fn strcmpi_char_array_row_compare_casefold() {
277        let chars = CharArray::new(vec!['c', 'a', 't', 'D', 'O', 'G'], 2, 3).unwrap();
278        let result =
279            strcmpi_builtin(Value::CharArray(chars), Value::String("CaT".into())).expect("cmp");
280        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
281        assert_eq!(result, Value::LogicalArray(expected));
282    }
283
284    #[test]
285    fn strcmpi_char_array_to_char_array_casefold() {
286        let left = CharArray::new(vec!['A', 'b', 'C', 'd'], 2, 2).unwrap();
287        let right = CharArray::new(vec!['a', 'B', 'x', 'Y'], 2, 2).unwrap();
288        let result =
289            strcmpi_builtin(Value::CharArray(left), Value::CharArray(right)).expect("strcmpi");
290        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
291        assert_eq!(result, Value::LogicalArray(expected));
292    }
293
294    #[test]
295    fn strcmpi_cell_array_scalar_casefold() {
296        let cell = CellArray::new(
297            vec![
298                Value::from("North"),
299                Value::from("east"),
300                Value::from("South"),
301            ],
302            1,
303            3,
304        )
305        .unwrap();
306        let result =
307            strcmpi_builtin(Value::Cell(cell), Value::String("EAST".into())).expect("strcmpi");
308        let expected = LogicalArray::new(vec![0, 1, 0], vec![1, 3]).unwrap();
309        assert_eq!(result, Value::LogicalArray(expected));
310    }
311
312    #[test]
313    fn strcmpi_cell_array_vs_cell_array_broadcast() {
314        let left = CellArray::new(vec![Value::from("North"), Value::from("East")], 1, 2).unwrap();
315        let right = CellArray::new(vec![Value::from("north")], 1, 1).unwrap();
316        let result = strcmpi_builtin(Value::Cell(left), Value::Cell(right)).expect("strcmpi");
317        let expected = LogicalArray::new(vec![1, 0], vec![1, 2]).unwrap();
318        assert_eq!(result, Value::LogicalArray(expected));
319    }
320
321    #[test]
322    fn strcmpi_string_array_multi_dimensional_broadcast() {
323        let left = StringArray::new(vec!["north".into(), "south".into()], vec![2, 1]).unwrap();
324        let right = StringArray::new(
325            vec!["NORTH".into(), "EAST".into(), "SOUTH".into()],
326            vec![1, 3],
327        )
328        .unwrap();
329        let result =
330            strcmpi_builtin(Value::StringArray(left), Value::StringArray(right)).expect("strcmpi");
331        let expected = LogicalArray::new(vec![1, 0, 0, 0, 0, 1], vec![2, 3]).unwrap();
332        assert_eq!(result, Value::LogicalArray(expected));
333    }
334
335    #[test]
336    fn strcmpi_missing_strings_compare_false() {
337        let strings = StringArray::new(vec!["<missing>".into()], vec![1, 1]).unwrap();
338        let result = strcmpi_builtin(
339            Value::StringArray(strings.clone()),
340            Value::StringArray(strings),
341        )
342        .expect("strcmpi");
343        assert_eq!(result, Value::Bool(false));
344    }
345
346    #[test]
347    fn strcmpi_char_array_trailing_space_not_equal() {
348        let chars = CharArray::new(vec!['c', 'a', 't', ' '], 1, 4).unwrap();
349        let result =
350            strcmpi_builtin(Value::CharArray(chars), Value::String("cat".into())).expect("strcmpi");
351        assert_eq!(result, Value::Bool(false));
352    }
353
354    #[test]
355    fn strcmpi_size_mismatch_error() {
356        let left = StringArray::new(vec!["a".into(), "b".into()], vec![2, 1]).unwrap();
357        let right = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![3, 1]).unwrap();
358        let err = strcmpi_builtin(Value::StringArray(left), Value::StringArray(right))
359            .expect_err("size mismatch");
360        assert!(err.contains("size mismatch"));
361    }
362
363    #[test]
364    fn strcmpi_invalid_argument_type() {
365        let err =
366            strcmpi_builtin(Value::Num(1.0), Value::String("a".into())).expect_err("invalid type");
367        assert!(err.contains("first argument must be text"));
368    }
369
370    #[test]
371    fn strcmpi_cell_array_invalid_element_errors() {
372        let cell = CellArray::new(vec![Value::Num(42.0)], 1, 1).unwrap();
373        let err = strcmpi_builtin(Value::Cell(cell), Value::String("test".into()))
374            .expect_err("cell element type");
375        assert!(err.contains("cell array elements must be character vectors or string scalars"));
376    }
377
378    #[test]
379    fn strcmpi_empty_char_array_returns_empty() {
380        let chars = CharArray::new(Vec::<char>::new(), 0, 3).unwrap();
381        let result = strcmpi_builtin(Value::CharArray(chars), Value::String("anything".into()))
382            .expect("cmp");
383        let expected = LogicalArray::new(Vec::<u8>::new(), vec![0, 1]).unwrap();
384        assert_eq!(result, Value::LogicalArray(expected));
385    }
386
387    #[test]
388    #[cfg(feature = "wgpu")]
389    fn strcmpi_with_wgpu_provider_matches_expected() {
390        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
391            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
392        );
393        let names = StringArray::new(vec!["North".into(), "south".into()], vec![2, 1]).unwrap();
394        let comparison = StringArray::new(vec!["north".into()], vec![1, 1]).unwrap();
395        let result = strcmpi_builtin(Value::StringArray(names), Value::StringArray(comparison))
396            .expect("strcmpi");
397        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
398        assert_eq!(result, Value::LogicalArray(expected));
399    }
400
401    #[test]
402    #[cfg(feature = "doc_export")]
403    fn doc_examples_present() {
404        let blocks = test_support::doc_examples(DOC_MD);
405        assert!(!blocks.is_empty());
406    }
407}