runmat_runtime/builtins/strings/core/
strcmp.rs

1//! MATLAB-compatible `strcmp` builtin for RunMat.
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: "strcmp"
20category: "strings/core"
21keywords: ["strcmp", "string compare", "text equality", "cell array", "character vector"]
22summary: "Compare text inputs for exact equality with MATLAB-compatible implicit expansion across text types."
23references:
24  - https://www.mathworks.com/help/matlab/ref/strcmp.html
25gpu_support:
26  elementwise: false
27  reduction: false
28  precisions: []
29  broadcasting: "matlab"
30  notes: "Executes on the CPU; GPU-resident inputs are gathered automatically so results match MATLAB behaviour exactly."
31fusion:
32  elementwise: false
33  reduction: false
34  max_inputs: 2
35  constants: "inline"
36requires_feature: null
37tested:
38  unit: "builtins::strings::core::strcmp::tests"
39  integration: "builtins::strings::core::strcmp::tests::strcmp_cell_array_scalar"
40---
41
42# What does the `strcmp` function do in MATLAB / RunMat?
43`strcmp(a, b)` returns logical `true` when two pieces of text match exactly and `false` otherwise.
44It accepts string arrays, character vectors/arrays, and cell arrays of character vectors, mirroring MATLAB semantics.
45
46## How does the `strcmp` function behave in MATLAB / RunMat?
47- **Accepted text types**: Works with string scalars/arrays, character vectors or matrices created with `char`, and cell arrays of character vectors. Mixed combinations are converted automatically, matching MATLAB.
48- **Implicit expansion**: Scalar inputs expand to the shape of the other operand, producing element-wise comparisons for higher-dimensional arrays.
49- **Character arrays**: Rows are compared individually. The result is a column vector whose length equals the number of rows in the character array.
50- **Cell arrays**: Each cell is treated as a text scalar. Scalar cell arrays expand across the other operand before comparison.
51- **Missing strings**: String elements equal to `missing` compare unequal to everything, including other `missing` values.
52- **Result form**: A single logical scalar is returned for scalar comparisons; otherwise you receive a logical array using column-major MATLAB layout.
53- **Case sensitivity**: Matching is case-sensitive. Use `strcmpi` for case-insensitive comparisons.
54
55## `strcmp` Function GPU Execution Behaviour
56`strcmp` is registered as an acceleration sink. When either input resides on the GPU, RunMat gathers both
57operands back to host memory before comparing them so the results match MATLAB exactly. Providers do not
58need to implement custom kernels, and the logical result is always returned on the CPU.
59
60## Examples of using the `strcmp` function in MATLAB / RunMat
61
62### Compare Two Equal Strings
63```matlab
64tf = strcmp("RunMat", "RunMat");
65```
66Expected output:
67```matlab
68tf = logical
69   1
70```
71
72### Compare String Array With Scalar Text
73```matlab
74names = ["red" "green" "blue"];
75tf = strcmp(names, "green");
76```
77Expected output:
78```matlab
79tf = 1×3 logical array
80   0   1   0
81```
82
83### Compare Character Array Rows
84```matlab
85labels = char("cat", "dog", "cat");
86tf = strcmp(labels, "cat");
87```
88Expected output:
89```matlab
90tf = 3×1 logical array
91   1
92   0
93   1
94```
95
96### Compare Two Cell Arrays Of Character Vectors
97```matlab
98C1 = {'apple', 'pear', 'grape'};
99C2 = {'apple', 'peach', 'grape'};
100tf = strcmp(C1, C2);
101```
102Expected output:
103```matlab
104tf = 1×3 logical array
105   1   0   1
106```
107
108### Handle Missing Strings
109```matlab
110vals = ["alpha" missing];
111tf = strcmp(vals, "alpha");
112```
113Expected output:
114```matlab
115tf = 1×2 logical array
116   1   0
117```
118
119### Implicit Expansion With Column Vector Text
120```matlab
121patterns = char("north", "south");
122tf = strcmp(patterns, ["north" "east"]);
123```
124Expected output:
125```matlab
126tf = 2×2 logical array
127   1   0
128   0   0
129```
130
131## GPU residency in RunMat (Do I need `gpuArray`?)
132You normally do **not** need to call `gpuArray`. If you do, RunMat gathers the operands before computing `strcmp`
133so the output matches MATLAB. The result always lives on the host because this builtin inspects text data.
134
135## FAQ
136
137### What types can I pass to `strcmp`?
138Use string arrays, character vectors/arrays, or cell arrays of character vectors. Mixed combinations are accepted and follow MATLAB's implicit expansion rules.
139
140### Does `strcmp` ignore letter case?
141No. `strcmp` is case-sensitive. Use `strcmpi` for case-insensitive comparisons.
142
143### What happens when the inputs contain missing strings?
144Missing string scalars compare unequal to every value (including other missing strings), so the result is `false`.
145
146### Can `strcmp` compare matrices of characters?
147Yes. Character arrays compare row-by-row, returning a column vector whose entries tell you whether each row matches.
148
149### Does `strcmp` return numeric or logical results?
150It returns logical results. Scalars become logical scalars (`Value::Bool`), while arrays are returned as logical arrays.
151
152## See Also
153[string](./string), [char](./char), [contains](../../search/contains), [startswith](../../search/startswith), [strlength](./strlength)
154
155## Source & Feedback
156- Implementation: [`crates/runmat-runtime/src/builtins/strings/core/strcmp.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/core/strcmp.rs)
157- Found a bug? Please [open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
158"#;
159
160pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
161    name: "strcmp",
162    op_kind: GpuOpKind::Custom("string-compare"),
163    supported_precisions: &[],
164    broadcast: BroadcastSemantics::Matlab,
165    provider_hooks: &[],
166    constant_strategy: ConstantStrategy::InlineLiteral,
167    residency: ResidencyPolicy::GatherImmediately,
168    nan_mode: ReductionNaN::Include,
169    two_pass_threshold: None,
170    workgroup_size: None,
171    accepts_nan_mode: false,
172    notes: "Performs host-side text comparisons; GPU operands are gathered automatically before evaluation.",
173};
174
175register_builtin_gpu_spec!(GPU_SPEC);
176
177pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
178    name: "strcmp",
179    shape: ShapeRequirements::Any,
180    constant_strategy: ConstantStrategy::InlineLiteral,
181    elementwise: None,
182    reduction: None,
183    emits_nan: false,
184    notes: "Produces logical results on the host; not eligible for GPU fusion.",
185};
186
187register_builtin_fusion_spec!(FUSION_SPEC);
188
189#[cfg(feature = "doc_export")]
190register_builtin_doc_text!("strcmp", DOC_MD);
191
192#[runtime_builtin(
193    name = "strcmp",
194    category = "strings/core",
195    summary = "Compare text inputs for exact matches (case-sensitive).",
196    keywords = "strcmp,string compare,text equality",
197    accel = "sink"
198)]
199fn strcmp_builtin(a: Value, b: Value) -> Result<Value, String> {
200    let a = gather_if_needed(&a).map_err(|e| format!("strcmp: {e}"))?;
201    let b = gather_if_needed(&b).map_err(|e| format!("strcmp: {e}"))?;
202    let left = TextCollection::from_argument("strcmp", a, "first argument")?;
203    let right = TextCollection::from_argument("strcmp", b, "second argument")?;
204    evaluate_strcmp(&left, &right)
205}
206
207fn evaluate_strcmp(left: &TextCollection, right: &TextCollection) -> Result<Value, String> {
208    let shape = broadcast_shapes("strcmp", &left.shape, &right.shape)?;
209    let total = tensor::element_count(&shape);
210    if total == 0 {
211        return logical_result("strcmp", Vec::new(), shape);
212    }
213    let left_strides = compute_strides(&left.shape);
214    let right_strides = compute_strides(&right.shape);
215    let mut data = Vec::with_capacity(total);
216    for linear in 0..total {
217        let li = broadcast_index(linear, &shape, &left.shape, &left_strides);
218        let ri = broadcast_index(linear, &shape, &right.shape, &right_strides);
219        let equal = match (&left.elements[li], &right.elements[ri]) {
220            (TextElement::Missing, _) => false,
221            (_, TextElement::Missing) => false,
222            (TextElement::Text(lhs), TextElement::Text(rhs)) => lhs == rhs,
223        };
224        data.push(if equal { 1 } else { 0 });
225    }
226    logical_result("strcmp", data, shape)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    #[cfg(feature = "doc_export")]
233    use crate::builtins::common::test_support;
234    use runmat_builtins::{CellArray, CharArray, LogicalArray, StringArray};
235
236    #[test]
237    fn strcmp_string_scalar_true() {
238        let result = strcmp_builtin(
239            Value::String("RunMat".into()),
240            Value::String("RunMat".into()),
241        )
242        .expect("strcmp");
243        assert_eq!(result, Value::Bool(true));
244    }
245
246    #[test]
247    fn strcmp_string_scalar_false() {
248        let result = strcmp_builtin(
249            Value::String("RunMat".into()),
250            Value::String("runmat".into()),
251        )
252        .expect("strcmp");
253        assert_eq!(result, Value::Bool(false));
254    }
255
256    #[test]
257    fn strcmp_string_array_broadcast_scalar() {
258        let array = StringArray::new(
259            vec!["red".into(), "green".into(), "blue".into()],
260            vec![1, 3],
261        )
262        .unwrap();
263        let result =
264            strcmp_builtin(Value::StringArray(array), Value::String("green".into())).expect("cmp");
265        let expected = LogicalArray::new(vec![0, 1, 0], vec![1, 3]).unwrap();
266        assert_eq!(result, Value::LogicalArray(expected));
267    }
268
269    #[test]
270    fn strcmp_char_array_row_compare() {
271        let chars = CharArray::new(vec!['c', 'a', 't', 'd', 'o', 'g'], 2, 3).unwrap();
272        let result =
273            strcmp_builtin(Value::CharArray(chars), Value::String("cat".into())).expect("cmp");
274        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
275        assert_eq!(result, Value::LogicalArray(expected));
276    }
277
278    #[test]
279    fn strcmp_char_array_to_char_array() {
280        let left = CharArray::new(vec!['a', 'b', 'c', 'd'], 2, 2).unwrap();
281        let right = CharArray::new(vec!['a', 'b', 'x', 'y'], 2, 2).unwrap();
282        let result =
283            strcmp_builtin(Value::CharArray(left), Value::CharArray(right)).expect("strcmp");
284        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
285        assert_eq!(result, Value::LogicalArray(expected));
286    }
287
288    #[test]
289    fn strcmp_cell_array_scalar() {
290        let cell = CellArray::new(
291            vec![
292                Value::from("apple"),
293                Value::from("pear"),
294                Value::from("grape"),
295            ],
296            1,
297            3,
298        )
299        .unwrap();
300        let result =
301            strcmp_builtin(Value::Cell(cell), Value::String("grape".into())).expect("strcmp");
302        let expected = LogicalArray::new(vec![0, 0, 1], vec![1, 3]).unwrap();
303        assert_eq!(result, Value::LogicalArray(expected));
304    }
305
306    #[test]
307    fn strcmp_cell_array_to_cell_array_broadcasts() {
308        let left = CellArray::new(vec![Value::from("red"), Value::from("blue")], 2, 1).unwrap();
309        let right = CellArray::new(vec![Value::from("red")], 1, 1).unwrap();
310        let result = strcmp_builtin(Value::Cell(left), Value::Cell(right)).expect("strcmp");
311        let expected = LogicalArray::new(vec![1, 0], vec![2, 1]).unwrap();
312        assert_eq!(result, Value::LogicalArray(expected));
313    }
314
315    #[test]
316    fn strcmp_string_array_multi_dimensional_broadcast() {
317        let left = StringArray::new(vec!["north".into(), "south".into()], vec![2, 1]).unwrap();
318        let right = StringArray::new(
319            vec!["north".into(), "east".into(), "south".into()],
320            vec![1, 3],
321        )
322        .unwrap();
323        let result =
324            strcmp_builtin(Value::StringArray(left), Value::StringArray(right)).expect("strcmp");
325        let expected = LogicalArray::new(vec![1, 0, 0, 0, 0, 1], vec![2, 3]).unwrap();
326        assert_eq!(result, Value::LogicalArray(expected));
327    }
328
329    #[test]
330    fn strcmp_char_array_trailing_space_is_not_equal() {
331        let chars = CharArray::new(vec!['c', 'a', 't', ' '], 1, 4).unwrap();
332        let result =
333            strcmp_builtin(Value::CharArray(chars), Value::String("cat".into())).expect("strcmp");
334        assert_eq!(result, Value::Bool(false));
335    }
336
337    #[test]
338    fn strcmp_char_array_empty_rows_returns_empty() {
339        let chars = CharArray::new(Vec::new(), 0, 0).unwrap();
340        let result = strcmp_builtin(Value::CharArray(chars), Value::String("anything".into()))
341            .expect("strcmp");
342        match result {
343            Value::LogicalArray(array) => {
344                assert_eq!(array.shape, vec![0, 1]);
345                assert!(array.data.is_empty());
346            }
347            other => panic!("expected empty logical array, got {other:?}"),
348        }
349    }
350
351    #[test]
352    fn strcmp_missing_strings_compare_false() {
353        let strings = StringArray::new(vec!["<missing>".into()], vec![1, 1]).unwrap();
354        let result = strcmp_builtin(
355            Value::StringArray(strings.clone()),
356            Value::StringArray(strings),
357        )
358        .expect("strcmp");
359        assert_eq!(result, Value::Bool(false));
360    }
361
362    #[test]
363    fn strcmp_missing_string_false() {
364        let array = StringArray::new(vec!["alpha".into(), "<missing>".into()], vec![1, 2]).unwrap();
365        let result =
366            strcmp_builtin(Value::StringArray(array), Value::String("alpha".into())).expect("cmp");
367        let expected = LogicalArray::new(vec![1, 0], vec![1, 2]).unwrap();
368        assert_eq!(result, Value::LogicalArray(expected));
369    }
370
371    #[test]
372    fn strcmp_size_mismatch_error() {
373        let left = StringArray::new(vec!["a".into(), "b".into()], vec![2, 1]).unwrap();
374        let right = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![3, 1]).unwrap();
375        let err = strcmp_builtin(Value::StringArray(left), Value::StringArray(right))
376            .expect_err("size mismatch");
377        assert!(err.contains("size mismatch"));
378    }
379
380    #[test]
381    fn strcmp_invalid_argument_type() {
382        let err =
383            strcmp_builtin(Value::Num(1.0), Value::String("a".into())).expect_err("invalid type");
384        assert!(err.contains("first argument must be text"));
385    }
386
387    #[test]
388    #[cfg(feature = "doc_export")]
389    fn doc_examples_present() {
390        let blocks = test_support::doc_examples(DOC_MD);
391        assert!(!blocks.is_empty());
392    }
393}