runmat_runtime/builtins/strings/transform/
strcat.rs

1//! MATLAB-compatible `strcat` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{CellArray, CharArray, StringArray, 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::strings::common::{char_row_to_string_slice, is_missing_string};
12#[cfg(feature = "doc_export")]
13use crate::register_builtin_doc_text;
14use crate::{
15    gather_if_needed, make_cell_with_shape, register_builtin_fusion_spec, register_builtin_gpu_spec,
16};
17
18#[cfg(feature = "doc_export")]
19pub const DOC_MD: &str = r#"---
20title: "strcat"
21category: "strings/transform"
22keywords: ["strcat", "string concatenation", "character arrays", "cell arrays", "trailing spaces"]
23summary: "Concatenate text inputs element-wise with MATLAB-compatible trimming and implicit expansion."
24references:
25  - https://www.mathworks.com/help/matlab/ref/strcat.html
26gpu_support:
27  elementwise: false
28  reduction: false
29  precisions: []
30  broadcasting: "matlab"
31  notes: "Executes on the CPU; GPU-resident inputs are gathered before concatenation so trimming semantics match MATLAB."
32fusion:
33  elementwise: false
34  reduction: false
35  max_inputs: 8
36  constants: "inline"
37requires_feature: null
38tested:
39  unit: "builtins::strings::transform::strcat::tests"
40  integration: "builtins::strings::transform::strcat::tests::strcat_cell_array_trims_trailing_spaces"
41---
42
43# What does the `strcat` function do in MATLAB / RunMat?
44`strcat` horizontally concatenates text inputs element-wise. It accepts string arrays, character arrays,
45character vectors, and cell arrays of character vectors, applying MATLAB's implicit expansion rules to
46match array sizes.
47
48## How does the `strcat` function behave in MATLAB / RunMat?
49- Inputs are concatenated element-wise. Scalars expand across arrays of matching dimensions using MATLAB's
50  implicit expansion rules.
51- When at least one input is a string array (or string scalar), the result is a string array. `<missing>`
52  values propagate, so any missing operand yields a missing result for that element.
53- When no string arrays are present but any input is a cell array of character vectors, the result is a cell
54  array whose elements are character vectors.
55- Otherwise, the result is a character array. For character inputs, `strcat` removes trailing space characters
56  from each operand **before** concatenating.
57- Cell array elements must be character vectors (or string scalars). Mixing cell arrays with unsupported
58  content raises a MATLAB-compatible error.
59- Empty inputs broadcast naturally: an operand with a zero-length dimension yields an empty output after
60  broadcasting.
61
62## `strcat` Function GPU Execution Behaviour
63RunMat currently performs text concatenation on the CPU. When any operand resides on the GPU, the runtime
64gathers it to host memory before applying MATLAB-compatible trimming and concatenation rules. Providers do
65not need to implement device kernels for this builtin today.
66
67## GPU residency in RunMat (Do I need `gpuArray`?)
68No. String manipulation runs on the CPU. If intermediate values are on the GPU, RunMat gathers them
69automatically so you can call `strcat` without extra residency management.
70
71## Examples of using the `strcat` function in MATLAB / RunMat
72
73### Concatenate string scalars element-wise
74```matlab
75greeting = strcat("Run", "Mat");
76```
77Expected output:
78```matlab
79greeting = "RunMat"
80```
81
82### Concatenate a string scalar with a string array
83```matlab
84names = ["Ignition", "Turbine", "Accelerate"];
85tagged = strcat("runmat-", names);
86```
87Expected output:
88```matlab
89tagged = 1×3 string
90    "runmat-Ignition"    "runmat-Turbine"    "runmat-Accelerate"
91```
92
93### Concatenate character arrays while trimming trailing spaces
94```matlab
95A = char("GPU ", "Planner");
96B = char("Accel", " Stage ");
97result = strcat(A, B);
98```
99Expected output:
100```matlab
101result =
102
103  2×11 char array
104
105    'GPUAccel'
106    'PlannerStage'
107```
108
109### Concatenate cell arrays of character vectors
110```matlab
111C = {'Run ', 'Plan '; 'Fuse ', 'Cache '};
112suffix = {'Mat', 'Ops'; 'Kernels', 'Stats'};
113combined = strcat(C, suffix);
114```
115Expected output:
116```matlab
117combined = 2×2 cell
118    {'RunMat'}    {'PlanOps'}
119    {'FuseKernels'}    {'CacheStats'}
120```
121
122### Propagate missing strings during concatenation
123```matlab
124values = [string(missing) "ready"];
125out = strcat("job-", values);
126```
127Expected output:
128```matlab
129out = 1×2 string
130    <missing>    "job-ready"
131```
132
133### Broadcast a scalar character vector across a character array
134```matlab
135labels = char("core", "runtime", "planner");
136prefixed = strcat("runmat-", labels);
137```
138Expected output:
139```matlab
140prefixed =
141
142  3×11 char array
143
144    'runmat-core'
145    'runmat-runtime'
146    'runmat-planner'
147```
148
149## FAQ
150
151### Does `strcat` remove spaces between words?
152No. `strcat` only strips trailing **space characters** from character inputs before concatenating. Spaces in
153the middle of a string remain untouched. To insert separators explicitly, concatenate the desired delimiter
154or use `join`.
155
156### How are missing strings handled?
157Missing string scalars (`string(missing)`) propagate. If any operand is missing for a specific element, the
158resulting element is `<missing>`.
159
160### What happens when I mix strings and character arrays?
161The output is a string array. Character inputs are converted to strings (after trimming trailing spaces) and
162combined element-wise with the string operands.
163
164### Can I concatenate cell arrays with string arrays?
165Yes. Inputs are implicitly converted to strings when any operand is a string array, so the result is a string
166array. Cell array elements must still contain character vectors (or scalar strings).
167
168### What if I pass numeric or logical inputs?
169`strcat` only accepts strings, character arrays, character vectors, or cell arrays of character vectors.
170Passing unsupported types raises a MATLAB-compatible error.
171
172### How are empty inputs treated?
173Dimensions with length zero propagate through implicit expansion. For example, concatenating with an empty
174string array returns an empty array with the broadcasted shape.
175
176## See Also
177[string](../core/string), [plus](../../core/plus) (string concatenation with operator overloading),
178[join](./join), [cellstr](../../../cells/core/cellstr)
179
180## Source & Feedback
181- Implementation: [`crates/runmat-runtime/src/builtins/strings/transform/strcat.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/transform/strcat.rs)
182- Found an issue? Please [open a GitHub issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
183"#;
184
185pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
186    name: "strcat",
187    op_kind: GpuOpKind::Custom("string-transform"),
188    supported_precisions: &[],
189    broadcast: BroadcastSemantics::Matlab,
190    provider_hooks: &[],
191    constant_strategy: ConstantStrategy::InlineLiteral,
192    residency: ResidencyPolicy::GatherImmediately,
193    nan_mode: ReductionNaN::Include,
194    two_pass_threshold: None,
195    workgroup_size: None,
196    accepts_nan_mode: false,
197    notes: "Executes on the CPU with trailing-space trimming; GPU inputs are gathered before concatenation.",
198};
199
200register_builtin_gpu_spec!(GPU_SPEC);
201
202pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
203    name: "strcat",
204    shape: ShapeRequirements::BroadcastCompatible,
205    constant_strategy: ConstantStrategy::InlineLiteral,
206    elementwise: None,
207    reduction: None,
208    emits_nan: false,
209    notes: "String concatenation runs on the host and is not eligible for fusion.",
210};
211
212register_builtin_fusion_spec!(FUSION_SPEC);
213
214#[cfg(feature = "doc_export")]
215register_builtin_doc_text!("strcat", DOC_MD);
216
217const ERROR_NOT_ENOUGH_INPUTS: &str = "strcat: not enough input arguments";
218const ERROR_INVALID_INPUT: &str =
219    "strcat: inputs must be strings, character arrays, or cell arrays of character vectors";
220const ERROR_INVALID_CELL_ELEMENT: &str =
221    "strcat: cell array elements must be character vectors or string scalars";
222
223#[derive(Clone, Copy, PartialEq, Eq)]
224enum OperandKind {
225    String,
226    Cell,
227    Char,
228}
229
230#[derive(Clone)]
231struct TextElement {
232    text: String,
233    missing: bool,
234}
235
236#[derive(Clone)]
237struct TextOperand {
238    data: Vec<TextElement>,
239    shape: Vec<usize>,
240    strides: Vec<usize>,
241    kind: OperandKind,
242}
243
244impl TextOperand {
245    fn from_value(value: Value) -> Result<Self, String> {
246        match value {
247            Value::String(s) => Ok(Self::from_string_scalar(s)),
248            Value::StringArray(sa) => Ok(Self::from_string_array(sa)),
249            Value::CharArray(ca) => Self::from_char_array(&ca),
250            Value::Cell(ca) => Self::from_cell_array(&ca),
251            _ => Err(ERROR_INVALID_INPUT.to_string()),
252        }
253    }
254
255    fn from_string_scalar(text: String) -> Self {
256        let missing = is_missing_string(&text);
257        Self {
258            data: vec![TextElement { text, missing }],
259            shape: vec![1, 1],
260            strides: vec![1, 1],
261            kind: OperandKind::String,
262        }
263    }
264
265    fn from_string_array(array: StringArray) -> Self {
266        let missing_flags: Vec<bool> = array.data.iter().map(|s| is_missing_string(s)).collect();
267        let data = array
268            .data
269            .into_iter()
270            .zip(missing_flags)
271            .map(|(text, missing)| TextElement { text, missing })
272            .collect();
273        let shape = array.shape.clone();
274        let strides = compute_strides(&shape);
275        Self {
276            data,
277            shape,
278            strides,
279            kind: OperandKind::String,
280        }
281    }
282
283    fn from_char_array(array: &CharArray) -> Result<Self, String> {
284        let rows = array.rows;
285        let cols = array.cols;
286        let mut elements = Vec::with_capacity(rows);
287        for row in 0..rows {
288            let text = char_row_to_string_slice(&array.data, cols, row);
289            let trimmed = trim_trailing_spaces(&text);
290            elements.push(TextElement {
291                text: trimmed,
292                missing: false,
293            });
294        }
295        let shape = vec![rows, 1];
296        let strides = compute_row_major_strides(&shape);
297        Ok(Self {
298            data: elements,
299            shape,
300            strides,
301            kind: OperandKind::Char,
302        })
303    }
304
305    fn from_cell_array(array: &CellArray) -> Result<Self, String> {
306        let total = array.data.len();
307        let mut elements = Vec::with_capacity(total);
308        for handle in &array.data {
309            let text_element = cell_element_to_text(handle)?;
310            elements.push(text_element);
311        }
312        let shape = array.shape.clone();
313        let strides = compute_row_major_strides(&shape);
314        Ok(Self {
315            data: elements,
316            shape,
317            strides,
318            kind: OperandKind::Cell,
319        })
320    }
321}
322
323#[derive(Clone, Copy, PartialEq, Eq)]
324enum OutputKind {
325    Char,
326    Cell,
327    String,
328}
329
330impl OutputKind {
331    fn update(self, operand_kind: OperandKind) -> Self {
332        match (self, operand_kind) {
333            (_, OperandKind::String) => OutputKind::String,
334            (OutputKind::String, _) => OutputKind::String,
335            (OutputKind::Cell, _) => OutputKind::Cell,
336            (_, OperandKind::Cell) => OutputKind::Cell,
337            _ => self,
338        }
339    }
340}
341
342fn trim_trailing_spaces(text: &str) -> String {
343    text.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
344        .to_string()
345}
346
347fn compute_row_major_strides(shape: &[usize]) -> Vec<usize> {
348    if shape.is_empty() {
349        return Vec::new();
350    }
351    let mut strides = vec![0usize; shape.len()];
352    let mut stride = 1usize;
353    for dim in (0..shape.len()).rev() {
354        strides[dim] = stride;
355        let extent = shape[dim].max(1);
356        stride = stride.saturating_mul(extent);
357    }
358    strides
359}
360
361fn column_major_coords(mut index: usize, shape: &[usize]) -> Vec<usize> {
362    if shape.is_empty() {
363        return Vec::new();
364    }
365    let mut coords = Vec::with_capacity(shape.len());
366    for &extent in shape {
367        if extent == 0 {
368            coords.push(0);
369        } else {
370            coords.push(index % extent);
371            index /= extent;
372        }
373    }
374    coords
375}
376
377fn row_major_index(coords: &[usize], shape: &[usize]) -> usize {
378    if coords.is_empty() {
379        return 0;
380    }
381    let mut index = 0usize;
382    let mut stride = 1usize;
383    for dim in (0..coords.len()).rev() {
384        let extent = shape[dim].max(1);
385        index += coords[dim] * stride;
386        stride = stride.saturating_mul(extent);
387    }
388    index
389}
390
391fn cell_element_to_text(value: &Value) -> Result<TextElement, String> {
392    match value {
393        Value::String(s) => Ok(TextElement {
394            text: s.clone(),
395            missing: is_missing_string(s),
396        }),
397        Value::StringArray(sa) if sa.data.len() == 1 => {
398            let text = sa.data[0].clone();
399            Ok(TextElement {
400                missing: is_missing_string(&text),
401                text,
402            })
403        }
404        Value::CharArray(ca) if ca.rows <= 1 => {
405            let text = if ca.rows == 0 {
406                String::new()
407            } else {
408                char_row_to_string_slice(&ca.data, ca.cols, 0)
409            };
410            Ok(TextElement {
411                text: trim_trailing_spaces(&text),
412                missing: false,
413            })
414        }
415        Value::CharArray(_) => Err(ERROR_INVALID_CELL_ELEMENT.to_string()),
416        _ => Err(ERROR_INVALID_CELL_ELEMENT.to_string()),
417    }
418}
419
420#[runtime_builtin(
421    name = "strcat",
422    category = "strings/transform",
423    summary = "Concatenate strings, character arrays, or cell arrays of character vectors element-wise.",
424    keywords = "strcat,string concatenation,character arrays,cell arrays",
425    accel = "sink"
426)]
427fn strcat_builtin(rest: Vec<Value>) -> Result<Value, String> {
428    if rest.is_empty() {
429        return Err(ERROR_NOT_ENOUGH_INPUTS.to_string());
430    }
431
432    let mut operands = Vec::with_capacity(rest.len());
433    let mut output_kind = OutputKind::Char;
434
435    for value in rest {
436        let gathered = gather_if_needed(&value).map_err(|e| format!("strcat: {e}"))?;
437        let operand = TextOperand::from_value(gathered)?;
438        output_kind = output_kind.update(operand.kind);
439        operands.push(operand);
440    }
441
442    let mut output_shape = operands
443        .first()
444        .map(|op| op.shape.clone())
445        .unwrap_or_else(|| vec![1, 1]);
446    for operand in operands.iter().skip(1) {
447        output_shape = broadcast_shapes("strcat", &output_shape, &operand.shape)?;
448    }
449
450    let total_len: usize = output_shape.iter().product();
451    let mut concatenated = Vec::with_capacity(total_len);
452
453    for linear in 0..total_len {
454        let mut buffer = String::new();
455        let mut any_missing = false;
456        for operand in &operands {
457            let idx = broadcast_index(linear, &output_shape, &operand.shape, &operand.strides);
458            let element = &operand.data[idx];
459            if output_kind == OutputKind::String && element.missing {
460                any_missing = true;
461                continue;
462            }
463            buffer.push_str(&element.text);
464        }
465        match output_kind {
466            OutputKind::String if any_missing => concatenated.push(String::from("<missing>")),
467            _ => concatenated.push(buffer),
468        }
469    }
470
471    match output_kind {
472        OutputKind::String => build_string_output(concatenated, &output_shape),
473        OutputKind::Cell => build_cell_output(concatenated, &output_shape),
474        OutputKind::Char => build_char_output(concatenated),
475    }
476}
477
478fn build_string_output(data: Vec<String>, shape: &[usize]) -> Result<Value, String> {
479    if data.is_empty() {
480        let array = StringArray::new(data, shape.to_vec()).map_err(|e| format!("strcat: {e}"))?;
481        return Ok(Value::StringArray(array));
482    }
483
484    let is_scalar = shape.is_empty() || shape.iter().all(|&dim| dim == 1);
485    if is_scalar {
486        return Ok(Value::String(data[0].clone()));
487    }
488
489    let array = StringArray::new(data, shape.to_vec()).map_err(|e| format!("strcat: {e}"))?;
490    Ok(Value::StringArray(array))
491}
492
493fn build_cell_output(mut data: Vec<String>, shape: &[usize]) -> Result<Value, String> {
494    if data.is_empty() {
495        return make_cell_with_shape(Vec::new(), shape.to_vec())
496            .map_err(|e| format!("strcat: {e}"));
497    }
498    if shape.len() > 1 {
499        let mut reordered = vec![String::new(); data.len()];
500        for (cm_index, text) in data.into_iter().enumerate() {
501            let coords = column_major_coords(cm_index, shape);
502            let rm_index = row_major_index(&coords, shape);
503            reordered[rm_index] = text;
504        }
505        data = reordered;
506    }
507    let mut values = Vec::with_capacity(data.len());
508    for text in data {
509        let char_array = CharArray::new_row(&text);
510        values.push(Value::CharArray(char_array));
511    }
512    make_cell_with_shape(values, shape.to_vec()).map_err(|e| format!("strcat: {e}"))
513}
514
515fn build_char_output(data: Vec<String>) -> Result<Value, String> {
516    let rows = data.len();
517    if rows == 0 {
518        let array = CharArray::new(Vec::new(), 0, 0).map_err(|e| format!("strcat: {e}"))?;
519        return Ok(Value::CharArray(array));
520    }
521
522    let max_cols = data.iter().map(|s| s.chars().count()).max().unwrap_or(0);
523    let mut chars = Vec::with_capacity(rows * max_cols);
524    for text in data {
525        let mut row_chars: Vec<char> = text.chars().collect();
526        if row_chars.len() < max_cols {
527            row_chars.resize(max_cols, ' ');
528        }
529        chars.extend(row_chars.into_iter());
530    }
531    let array = CharArray::new(chars, rows, max_cols).map_err(|e| format!("strcat: {e}"))?;
532    Ok(Value::CharArray(array))
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    #[cfg(feature = "wgpu")]
539    use runmat_builtins::Tensor;
540    use runmat_builtins::{CellArray, CharArray, IntValue, StringArray};
541
542    #[cfg(any(feature = "doc_export", feature = "wgpu"))]
543    use crate::builtins::common::test_support;
544
545    #[test]
546    fn strcat_string_scalar_concatenation() {
547        let result = strcat_builtin(vec![
548            Value::String("Run".into()),
549            Value::String("Mat".into()),
550        ])
551        .expect("strcat");
552        assert_eq!(result, Value::String("RunMat".into()));
553    }
554
555    #[test]
556    fn strcat_string_array_broadcasts_scalar() {
557        let array = StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).unwrap();
558        let result = strcat_builtin(vec![
559            Value::String("runmat-".into()),
560            Value::StringArray(array),
561        ])
562        .expect("strcat");
563        match result {
564            Value::StringArray(sa) => {
565                assert_eq!(sa.shape, vec![1, 2]);
566                assert_eq!(
567                    sa.data,
568                    vec![String::from("runmat-core"), String::from("runmat-runtime")]
569                );
570            }
571            other => panic!("expected string array, got {other:?}"),
572        }
573    }
574
575    #[test]
576    fn strcat_char_array_multiple_rows_concatenates_per_row() {
577        let first = CharArray::new(vec!['A', ' ', 'B', 'C'], 2, 2).expect("char");
578        let second = CharArray::new(vec!['X', 'Y', 'Z', ' '], 2, 2).expect("char");
579        let result = strcat_builtin(vec![Value::CharArray(first), Value::CharArray(second)])
580            .expect("strcat");
581        match result {
582            Value::CharArray(ca) => {
583                assert_eq!(ca.rows, 2);
584                assert_eq!(ca.cols, 3);
585                let expected: Vec<char> = vec!['A', 'X', 'Y', 'B', 'C', 'Z'];
586                assert_eq!(ca.data, expected);
587            }
588            other => panic!("expected char array, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn strcat_char_array_trims_trailing_spaces() {
594        let first = CharArray::new_row("GPU ");
595        let second = CharArray::new_row(" Accel  ");
596        let result = strcat_builtin(vec![Value::CharArray(first), Value::CharArray(second)])
597            .expect("strcat");
598        match result {
599            Value::CharArray(ca) => {
600                assert_eq!(ca.rows, 1);
601                assert_eq!(ca.cols, 9);
602                let expected: Vec<char> = "GPU Accel".chars().collect();
603                assert_eq!(ca.data, expected);
604            }
605            other => panic!("expected char array, got {other:?}"),
606        }
607    }
608
609    #[test]
610    fn strcat_mixed_char_and_string_returns_string_array() {
611        let prefixes = CharArray::new(vec!['A', ' ', 'B', ' '], 2, 2).expect("char");
612        let suffixes =
613            StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).expect("strings");
614        let result = strcat_builtin(vec![
615            Value::CharArray(prefixes),
616            Value::StringArray(suffixes),
617        ])
618        .expect("strcat");
619        match result {
620            Value::StringArray(sa) => {
621                assert_eq!(sa.shape, vec![2, 2]);
622                assert_eq!(
623                    sa.data,
624                    vec![
625                        "Acore".to_string(),
626                        "Bcore".to_string(),
627                        "Aruntime".to_string(),
628                        "Bruntime".to_string()
629                    ]
630                );
631            }
632            other => panic!("expected string array, got {other:?}"),
633        }
634    }
635
636    #[test]
637    fn strcat_cell_array_trims_trailing_spaces() {
638        let cell = make_cell_with_shape(
639            vec![
640                Value::CharArray(CharArray::new_row("Run ")),
641                Value::CharArray(CharArray::new_row("Mat ")),
642            ],
643            vec![1, 2],
644        )
645        .expect("cell");
646        let suffix = Value::CharArray(CharArray::new_row("Core "));
647        let result = strcat_builtin(vec![cell, suffix]).expect("strcat");
648        match result {
649            Value::Cell(ca) => {
650                assert_eq!(ca.shape, vec![1, 2]);
651                let first: &Value = &ca.data[0];
652                let second: &Value = &ca.data[1];
653                match (first, second) {
654                    (Value::CharArray(a), Value::CharArray(b)) => {
655                        assert_eq!(a.data, "RunCore".chars().collect::<Vec<char>>());
656                        assert_eq!(b.data, "MatCore".chars().collect::<Vec<char>>());
657                    }
658                    other => panic!("unexpected cell contents {other:?}"),
659                }
660            }
661            other => panic!("expected cell array, got {other:?}"),
662        }
663    }
664
665    #[test]
666    fn strcat_cell_array_two_by_two_preserves_row_major_order() {
667        let cell = make_cell_with_shape(
668            vec![
669                Value::CharArray(CharArray::new_row("Top ")),
670                Value::CharArray(CharArray::new_row("Right ")),
671                Value::CharArray(CharArray::new_row("Bottom ")),
672                Value::CharArray(CharArray::new_row("Last ")),
673            ],
674            vec![2, 2],
675        )
676        .expect("cell");
677        let suffix = Value::CharArray(CharArray::new_row("X"));
678        let result = strcat_builtin(vec![cell, suffix]).expect("strcat");
679        match result {
680            Value::Cell(ca) => {
681                assert_eq!(ca.shape, vec![2, 2]);
682                let v00 = ca.get(0, 0).expect("cell (0,0)");
683                let v01 = ca.get(0, 1).expect("cell (0,1)");
684                let v10 = ca.get(1, 0).expect("cell (1,0)");
685                let v11 = ca.get(1, 1).expect("cell (1,1)");
686                match (v00, v01, v10, v11) {
687                    (
688                        Value::CharArray(a),
689                        Value::CharArray(b),
690                        Value::CharArray(c),
691                        Value::CharArray(d),
692                    ) => {
693                        assert_eq!(a.data, "TopX".chars().collect::<Vec<char>>());
694                        assert_eq!(b.data, "RightX".chars().collect::<Vec<char>>());
695                        assert_eq!(c.data, "BottomX".chars().collect::<Vec<char>>());
696                        assert_eq!(d.data, "LastX".chars().collect::<Vec<char>>());
697                    }
698                    other => panic!("unexpected cell contents {other:?}"),
699                }
700            }
701            other => panic!("expected cell array, got {other:?}"),
702        }
703    }
704
705    #[test]
706    fn strcat_missing_strings_propagate() {
707        let array = StringArray::new(
708            vec![String::from("<missing>"), String::from("ready")],
709            vec![1, 2],
710        )
711        .unwrap();
712        let result = strcat_builtin(vec![
713            Value::String("job-".into()),
714            Value::StringArray(array),
715        ])
716        .expect("strcat");
717        match result {
718            Value::StringArray(sa) => {
719                assert_eq!(sa.data[0], "<missing>");
720                assert_eq!(sa.data[1], "job-ready");
721            }
722            other => panic!("expected string array, got {other:?}"),
723        }
724    }
725
726    #[test]
727    fn strcat_empty_dimension_returns_empty_array() {
728        let empty = StringArray::new(Vec::<String>::new(), vec![0, 2]).expect("string array");
729        let result = strcat_builtin(vec![
730            Value::StringArray(empty),
731            Value::String("prefix".into()),
732        ])
733        .expect("strcat");
734        match result {
735            Value::StringArray(sa) => {
736                assert_eq!(sa.shape, vec![0, 2]);
737                assert!(sa.data.is_empty());
738            }
739            other => panic!("expected empty string array, got {other:?}"),
740        }
741    }
742
743    #[test]
744    fn strcat_errors_on_invalid_input_type() {
745        let err = strcat_builtin(vec![Value::Int(IntValue::I32(4))]).expect_err("expected error");
746        assert!(err.contains("inputs must be strings"));
747    }
748
749    #[test]
750    fn strcat_errors_on_mismatched_sizes() {
751        let left = CharArray::new(vec!['A', 'B'], 2, 1).expect("char");
752        let right = CharArray::new(vec!['C', 'D', 'E'], 3, 1).expect("char");
753        let err = strcat_builtin(vec![Value::CharArray(left), Value::CharArray(right)])
754            .expect_err("expected broadcast error");
755        assert!(
756            err.contains("size mismatch"),
757            "unexpected error text: {err}"
758        );
759    }
760
761    #[test]
762    fn strcat_errors_on_invalid_cell_element() {
763        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
764        let err = strcat_builtin(vec![Value::Cell(cell)]).expect_err("expected error");
765        assert!(err.contains("cell array elements must be character vectors"));
766    }
767
768    #[test]
769    fn strcat_errors_on_empty_argument_list() {
770        let err = strcat_builtin(Vec::new()).expect_err("expected error");
771        assert_eq!(err, ERROR_NOT_ENOUGH_INPUTS);
772    }
773
774    #[cfg(feature = "wgpu")]
775    #[test]
776    fn strcat_gpu_operand_still_errors_on_type() {
777        test_support::with_test_provider(|provider| {
778            let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor");
779            let view = runmat_accelerate_api::HostTensorView {
780                data: &tensor.data,
781                shape: &tensor.shape,
782            };
783            let handle = provider.upload(&view).expect("upload");
784            let err = strcat_builtin(vec![Value::GpuTensor(handle)]).expect_err("expected error");
785            assert!(err.contains("inputs must be strings"));
786        });
787    }
788
789    #[cfg(feature = "doc_export")]
790    #[test]
791    fn doc_examples_compile() {
792        let blocks = test_support::doc_examples(DOC_MD);
793        assert!(!blocks.is_empty());
794    }
795}