1use 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}