Skip to main content

runmat_runtime/builtins/strings/transform/
strcat.rs

1//! MATLAB-compatible `strcat` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CellArray, CharArray, StringArray, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::broadcast::{broadcast_index, broadcast_shapes, compute_strides};
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
17use crate::builtins::strings::type_resolvers::text_concat_type;
18use crate::{
19    build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult, RuntimeError,
20};
21
22#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24    name: "strcat",
25    op_kind: GpuOpKind::Custom("string-transform"),
26    supported_precisions: &[],
27    broadcast: BroadcastSemantics::Matlab,
28    provider_hooks: &[],
29    constant_strategy: ConstantStrategy::InlineLiteral,
30    residency: ResidencyPolicy::GatherImmediately,
31    nan_mode: ReductionNaN::Include,
32    two_pass_threshold: None,
33    workgroup_size: None,
34    accepts_nan_mode: false,
35    notes: "Executes on the CPU with trailing-space trimming; GPU inputs are gathered before concatenation.",
36};
37
38#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
39pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
40    name: "strcat",
41    shape: ShapeRequirements::BroadcastCompatible,
42    constant_strategy: ConstantStrategy::InlineLiteral,
43    elementwise: None,
44    reduction: None,
45    emits_nan: false,
46    notes: "String concatenation runs on the host and is not eligible for fusion.",
47};
48
49const BUILTIN_NAME: &str = "strcat";
50
51const STRCAT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
52    name: "out",
53    ty: BuiltinParamType::Any,
54    arity: BuiltinParamArity::Required,
55    default: None,
56    description: "Concatenated text preserving strcat output container semantics.",
57}];
58
59const STRCAT_INPUTS: [BuiltinParamDescriptor; 2] = [
60    BuiltinParamDescriptor {
61        name: "str1",
62        ty: BuiltinParamType::Any,
63        arity: BuiltinParamArity::Required,
64        default: None,
65        description: "First text input (string/char/cell).",
66    },
67    BuiltinParamDescriptor {
68        name: "str2",
69        ty: BuiltinParamType::Any,
70        arity: BuiltinParamArity::Variadic,
71        default: None,
72        description: "Additional text inputs to concatenate element-wise.",
73    },
74];
75
76const STRCAT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
77    label: "out = strcat(str1, str2, ...)",
78    inputs: &STRCAT_INPUTS,
79    outputs: &STRCAT_OUTPUT,
80}];
81
82const STRCAT_ERROR_NOT_ENOUGH_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
83    code: "RM.STRCAT.NOT_ENOUGH_INPUTS",
84    identifier: Some("RunMat:strcat:NotEnoughInputs"),
85    when: "No arguments are supplied.",
86    message: "strcat: not enough input arguments",
87};
88
89const STRCAT_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
90    code: "RM.STRCAT.INVALID_INPUT",
91    identifier: Some("RunMat:strcat:InvalidInput"),
92    when: "An input is not a string, character array, or cell array of text scalars.",
93    message:
94        "strcat: inputs must be strings, character arrays, or cell arrays of character vectors",
95};
96
97const STRCAT_ERROR_INVALID_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
98    code: "RM.STRCAT.CELL_ELEMENT",
99    identifier: Some("RunMat:strcat:CellElement"),
100    when: "A cell array contains a non-text element or non-row char array element.",
101    message: "strcat: cell array elements must be character vectors or string scalars",
102};
103
104const STRCAT_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
105    code: "RM.STRCAT.SIZE_MISMATCH",
106    identifier: Some("RunMat:strcat:SizeMismatch"),
107    when: "Input shapes are not broadcast-compatible.",
108    message: "strcat: array sizes are not compatible for broadcasting",
109};
110
111const STRCAT_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
112    code: "RM.STRCAT.INTERNAL",
113    identifier: Some("RunMat:strcat:InternalError"),
114    when: "Internal output container construction failed.",
115    message: "strcat: internal error",
116};
117
118const STRCAT_ERRORS: [BuiltinErrorDescriptor; 5] = [
119    STRCAT_ERROR_NOT_ENOUGH_INPUTS,
120    STRCAT_ERROR_INVALID_INPUT,
121    STRCAT_ERROR_INVALID_CELL_ELEMENT,
122    STRCAT_ERROR_SIZE_MISMATCH,
123    STRCAT_ERROR_INTERNAL,
124];
125
126pub const STRCAT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
127    signatures: &STRCAT_SIGNATURES,
128    output_mode: BuiltinOutputMode::Fixed,
129    completion_policy: BuiltinCompletionPolicy::Public,
130    errors: &STRCAT_ERRORS,
131};
132
133fn map_flow(err: RuntimeError) -> RuntimeError {
134    map_control_flow_with_builtin(err, BUILTIN_NAME)
135}
136
137fn strcat_error_with_message(
138    message: impl Into<String>,
139    error: &'static BuiltinErrorDescriptor,
140) -> RuntimeError {
141    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
142    if let Some(identifier) = error.identifier {
143        builder = builder.with_identifier(identifier);
144    }
145    builder.build()
146}
147
148fn strcat_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
149    strcat_error_with_message(error.message, error)
150}
151
152#[derive(Clone, Copy, PartialEq, Eq)]
153enum OperandKind {
154    String,
155    Cell,
156    Char,
157}
158
159#[derive(Clone)]
160struct TextElement {
161    text: String,
162    missing: bool,
163}
164
165#[derive(Clone)]
166struct TextOperand {
167    data: Vec<TextElement>,
168    shape: Vec<usize>,
169    strides: Vec<usize>,
170    kind: OperandKind,
171}
172
173impl TextOperand {
174    fn from_value(value: Value) -> BuiltinResult<Self> {
175        match value {
176            Value::String(s) => Ok(Self::from_string_scalar(s)),
177            Value::StringArray(sa) => Ok(Self::from_string_array(sa)),
178            Value::CharArray(ca) => Self::from_char_array(&ca),
179            Value::Cell(ca) => Self::from_cell_array(&ca),
180            _ => Err(strcat_error(&STRCAT_ERROR_INVALID_INPUT)),
181        }
182    }
183
184    fn from_string_scalar(text: String) -> Self {
185        let missing = is_missing_string(&text);
186        Self {
187            data: vec![TextElement { text, missing }],
188            shape: vec![1, 1],
189            strides: vec![1, 1],
190            kind: OperandKind::String,
191        }
192    }
193
194    fn from_string_array(array: StringArray) -> Self {
195        let missing_flags: Vec<bool> = array.data.iter().map(|s| is_missing_string(s)).collect();
196        let data = array
197            .data
198            .into_iter()
199            .zip(missing_flags)
200            .map(|(text, missing)| TextElement { text, missing })
201            .collect();
202        let shape = array.shape.clone();
203        let strides = compute_strides(&shape);
204        Self {
205            data,
206            shape,
207            strides,
208            kind: OperandKind::String,
209        }
210    }
211
212    fn from_char_array(array: &CharArray) -> BuiltinResult<Self> {
213        let rows = array.rows;
214        let cols = array.cols;
215        let mut elements = Vec::with_capacity(rows);
216        for row in 0..rows {
217            let text = char_row_to_string_slice(&array.data, cols, row);
218            let trimmed = trim_trailing_spaces(&text);
219            elements.push(TextElement {
220                text: trimmed,
221                missing: false,
222            });
223        }
224        let shape = vec![rows, 1];
225        let strides = compute_row_major_strides(&shape);
226        Ok(Self {
227            data: elements,
228            shape,
229            strides,
230            kind: OperandKind::Char,
231        })
232    }
233
234    fn from_cell_array(array: &CellArray) -> BuiltinResult<Self> {
235        let total = array.data.len();
236        let mut elements = Vec::with_capacity(total);
237        for handle in &array.data {
238            let text_element = cell_element_to_text(handle)?;
239            elements.push(text_element);
240        }
241        let shape = array.shape.clone();
242        let strides = compute_row_major_strides(&shape);
243        Ok(Self {
244            data: elements,
245            shape,
246            strides,
247            kind: OperandKind::Cell,
248        })
249    }
250}
251
252#[derive(Clone, Copy, PartialEq, Eq)]
253enum OutputKind {
254    Char,
255    Cell,
256    String,
257}
258
259impl OutputKind {
260    fn update(self, operand_kind: OperandKind) -> Self {
261        match (self, operand_kind) {
262            (_, OperandKind::String) => OutputKind::String,
263            (OutputKind::String, _) => OutputKind::String,
264            (OutputKind::Cell, _) => OutputKind::Cell,
265            (_, OperandKind::Cell) => OutputKind::Cell,
266            _ => self,
267        }
268    }
269}
270
271fn trim_trailing_spaces(text: &str) -> String {
272    text.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
273        .to_string()
274}
275
276fn compute_row_major_strides(shape: &[usize]) -> Vec<usize> {
277    if shape.is_empty() {
278        return Vec::new();
279    }
280    let mut strides = vec![0usize; shape.len()];
281    let mut stride = 1usize;
282    for dim in (0..shape.len()).rev() {
283        strides[dim] = stride;
284        let extent = shape[dim].max(1);
285        stride = stride.saturating_mul(extent);
286    }
287    strides
288}
289
290fn column_major_coords(mut index: usize, shape: &[usize]) -> Vec<usize> {
291    if shape.is_empty() {
292        return Vec::new();
293    }
294    let mut coords = Vec::with_capacity(shape.len());
295    for &extent in shape {
296        if extent == 0 {
297            coords.push(0);
298        } else {
299            coords.push(index % extent);
300            index /= extent;
301        }
302    }
303    coords
304}
305
306fn row_major_index(coords: &[usize], shape: &[usize]) -> usize {
307    if coords.is_empty() {
308        return 0;
309    }
310    let mut index = 0usize;
311    let mut stride = 1usize;
312    for dim in (0..coords.len()).rev() {
313        let extent = shape[dim].max(1);
314        index += coords[dim] * stride;
315        stride = stride.saturating_mul(extent);
316    }
317    index
318}
319
320fn cell_element_to_text(value: &Value) -> BuiltinResult<TextElement> {
321    match value {
322        Value::String(s) => Ok(TextElement {
323            text: s.clone(),
324            missing: is_missing_string(s),
325        }),
326        Value::StringArray(sa) if sa.data.len() == 1 => {
327            let text = sa.data[0].clone();
328            Ok(TextElement {
329                missing: is_missing_string(&text),
330                text,
331            })
332        }
333        Value::CharArray(ca) if ca.rows <= 1 => {
334            let text = if ca.rows == 0 {
335                String::new()
336            } else {
337                char_row_to_string_slice(&ca.data, ca.cols, 0)
338            };
339            Ok(TextElement {
340                text: trim_trailing_spaces(&text),
341                missing: false,
342            })
343        }
344        Value::CharArray(_) => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
345        _ => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
346    }
347}
348
349#[runtime_builtin(
350    name = "strcat",
351    category = "strings/transform",
352    summary = "Concatenate text inputs element-wise across compatible array sizes.",
353    keywords = "strcat,string concatenation,character arrays,cell arrays",
354    accel = "sink",
355    type_resolver(text_concat_type),
356    descriptor(crate::builtins::strings::transform::strcat::STRCAT_DESCRIPTOR),
357    builtin_path = "crate::builtins::strings::transform::strcat"
358)]
359async fn strcat_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
360    if rest.is_empty() {
361        return Err(strcat_error(&STRCAT_ERROR_NOT_ENOUGH_INPUTS));
362    }
363
364    let mut operands = Vec::with_capacity(rest.len());
365    let mut output_kind = OutputKind::Char;
366
367    for value in rest {
368        let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
369        let operand = TextOperand::from_value(gathered)?;
370        output_kind = output_kind.update(operand.kind);
371        operands.push(operand);
372    }
373
374    let mut output_shape = operands
375        .first()
376        .map(|op| op.shape.clone())
377        .unwrap_or_else(|| vec![1, 1]);
378    for operand in operands.iter().skip(1) {
379        output_shape =
380            broadcast_shapes(BUILTIN_NAME, &output_shape, &operand.shape).map_err(|e| {
381                strcat_error_with_message(
382                    format!("{}: {e}", STRCAT_ERROR_SIZE_MISMATCH.message),
383                    &STRCAT_ERROR_SIZE_MISMATCH,
384                )
385            })?;
386    }
387
388    let total_len: usize = output_shape.iter().product();
389    let mut concatenated = Vec::with_capacity(total_len);
390
391    for linear in 0..total_len {
392        let mut buffer = String::new();
393        let mut any_missing = false;
394        for operand in &operands {
395            let idx = broadcast_index(linear, &output_shape, &operand.shape, &operand.strides);
396            let element = &operand.data[idx];
397            if output_kind == OutputKind::String && element.missing {
398                any_missing = true;
399                continue;
400            }
401            buffer.push_str(&element.text);
402        }
403        if matches!(output_kind, OutputKind::String) && any_missing {
404            concatenated.push(String::from("<missing>"));
405        } else {
406            concatenated.push(buffer);
407        }
408    }
409
410    match output_kind {
411        OutputKind::String => build_string_output(concatenated, &output_shape),
412        OutputKind::Cell => build_cell_output(concatenated, &output_shape),
413        OutputKind::Char => build_char_output(concatenated),
414    }
415}
416
417fn build_string_output(data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
418    if data.is_empty() {
419        let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
420            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
421        })?;
422        return Ok(Value::StringArray(array));
423    }
424
425    let is_scalar = shape.is_empty() || shape.iter().all(|&dim| dim == 1);
426    if is_scalar {
427        return Ok(Value::String(data[0].clone()));
428    }
429
430    let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
431        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
432    })?;
433    Ok(Value::StringArray(array))
434}
435
436fn build_cell_output(mut data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
437    if data.is_empty() {
438        return make_cell_with_shape(Vec::new(), shape.to_vec()).map_err(|e| {
439            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
440        });
441    }
442    if shape.len() > 1 {
443        let mut reordered = vec![String::new(); data.len()];
444        for (cm_index, text) in data.into_iter().enumerate() {
445            let coords = column_major_coords(cm_index, shape);
446            let rm_index = row_major_index(&coords, shape);
447            reordered[rm_index] = text;
448        }
449        data = reordered;
450    }
451    let mut values = Vec::with_capacity(data.len());
452    for text in data {
453        let char_array = CharArray::new_row(&text);
454        values.push(Value::CharArray(char_array));
455    }
456    make_cell_with_shape(values, shape.to_vec()).map_err(|e| {
457        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
458    })
459}
460
461fn build_char_output(data: Vec<String>) -> BuiltinResult<Value> {
462    let rows = data.len();
463    if rows == 0 {
464        let array = CharArray::new(Vec::new(), 0, 0).map_err(|e| {
465            strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
466        })?;
467        return Ok(Value::CharArray(array));
468    }
469
470    let max_cols = data.iter().map(|s| s.chars().count()).max().unwrap_or(0);
471    let mut chars = Vec::with_capacity(rows * max_cols);
472    for text in data {
473        let mut row_chars: Vec<char> = text.chars().collect();
474        if row_chars.len() < max_cols {
475            row_chars.resize(max_cols, ' ');
476        }
477        chars.extend(row_chars.into_iter());
478    }
479    let array = CharArray::new(chars, rows, max_cols).map_err(|e| {
480        strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
481    })?;
482    Ok(Value::CharArray(array))
483}
484
485#[cfg(test)]
486pub(crate) mod tests {
487    use super::*;
488    #[cfg(feature = "wgpu")]
489    use crate::builtins::common::test_support;
490    #[cfg(feature = "wgpu")]
491    use runmat_builtins::Tensor;
492    use runmat_builtins::{CellArray, CharArray, IntValue, ResolveContext, StringArray, Type};
493
494    fn run_strcat(rest: Vec<Value>) -> BuiltinResult<Value> {
495        futures::executor::block_on(strcat_builtin(rest))
496    }
497
498    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
499    #[test]
500    fn strcat_string_scalar_concatenation() {
501        let result = run_strcat(vec![
502            Value::String("Run".into()),
503            Value::String("Mat".into()),
504        ])
505        .expect("strcat");
506        assert_eq!(result, Value::String("RunMat".into()));
507    }
508
509    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
510    #[test]
511    fn strcat_string_array_broadcasts_scalar() {
512        let array = StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).unwrap();
513        let result = run_strcat(vec![
514            Value::String("runmat-".into()),
515            Value::StringArray(array),
516        ])
517        .expect("strcat");
518        match result {
519            Value::StringArray(sa) => {
520                assert_eq!(sa.shape, vec![1, 2]);
521                assert_eq!(
522                    sa.data,
523                    vec![String::from("runmat-core"), String::from("runmat-runtime")]
524                );
525            }
526            other => panic!("expected string array, got {other:?}"),
527        }
528    }
529
530    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
531    #[test]
532    fn strcat_char_array_multiple_rows_concatenates_per_row() {
533        let first = CharArray::new(vec!['A', ' ', 'B', 'C'], 2, 2).expect("char");
534        let second = CharArray::new(vec!['X', 'Y', 'Z', ' '], 2, 2).expect("char");
535        let result =
536            run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
537        match result {
538            Value::CharArray(ca) => {
539                assert_eq!(ca.rows, 2);
540                assert_eq!(ca.cols, 3);
541                let expected: Vec<char> = vec!['A', 'X', 'Y', 'B', 'C', 'Z'];
542                assert_eq!(ca.data, expected);
543            }
544            other => panic!("expected char array, got {other:?}"),
545        }
546    }
547
548    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
549    #[test]
550    fn strcat_char_array_trims_trailing_spaces() {
551        let first = CharArray::new_row("GPU ");
552        let second = CharArray::new_row(" Accel  ");
553        let result =
554            run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
555        match result {
556            Value::CharArray(ca) => {
557                assert_eq!(ca.rows, 1);
558                assert_eq!(ca.cols, 9);
559                let expected: Vec<char> = "GPU Accel".chars().collect();
560                assert_eq!(ca.data, expected);
561            }
562            other => panic!("expected char array, got {other:?}"),
563        }
564    }
565
566    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
567    #[test]
568    fn strcat_mixed_char_and_string_returns_string_array() {
569        let prefixes = CharArray::new(vec!['A', ' ', 'B', ' '], 2, 2).expect("char");
570        let suffixes =
571            StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).expect("strings");
572        let result = run_strcat(vec![
573            Value::CharArray(prefixes),
574            Value::StringArray(suffixes),
575        ])
576        .expect("strcat");
577        match result {
578            Value::StringArray(sa) => {
579                assert_eq!(sa.shape, vec![2, 2]);
580                assert_eq!(
581                    sa.data,
582                    vec![
583                        "Acore".to_string(),
584                        "Bcore".to_string(),
585                        "Aruntime".to_string(),
586                        "Bruntime".to_string()
587                    ]
588                );
589            }
590            other => panic!("expected string array, got {other:?}"),
591        }
592    }
593
594    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
595    #[test]
596    fn strcat_cell_array_trims_trailing_spaces() {
597        let cell = make_cell_with_shape(
598            vec![
599                Value::CharArray(CharArray::new_row("Run ")),
600                Value::CharArray(CharArray::new_row("Mat ")),
601            ],
602            vec![1, 2],
603        )
604        .expect("cell");
605        let suffix = Value::CharArray(CharArray::new_row("Core "));
606        let result = run_strcat(vec![cell, suffix]).expect("strcat");
607        match result {
608            Value::Cell(ca) => {
609                assert_eq!(ca.shape, vec![1, 2]);
610                let first: &Value = &ca.data[0];
611                let second: &Value = &ca.data[1];
612                match (first, second) {
613                    (Value::CharArray(a), Value::CharArray(b)) => {
614                        assert_eq!(a.data, "RunCore".chars().collect::<Vec<char>>());
615                        assert_eq!(b.data, "MatCore".chars().collect::<Vec<char>>());
616                    }
617                    other => panic!("unexpected cell contents {other:?}"),
618                }
619            }
620            other => panic!("expected cell array, got {other:?}"),
621        }
622    }
623
624    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
625    #[test]
626    fn strcat_cell_array_two_by_two_preserves_row_major_order() {
627        let cell = make_cell_with_shape(
628            vec![
629                Value::CharArray(CharArray::new_row("Top ")),
630                Value::CharArray(CharArray::new_row("Right ")),
631                Value::CharArray(CharArray::new_row("Bottom ")),
632                Value::CharArray(CharArray::new_row("Last ")),
633            ],
634            vec![2, 2],
635        )
636        .expect("cell");
637        let suffix = Value::CharArray(CharArray::new_row("X"));
638        let result = run_strcat(vec![cell, suffix]).expect("strcat");
639        match result {
640            Value::Cell(ca) => {
641                assert_eq!(ca.shape, vec![2, 2]);
642                let v00 = ca.get(0, 0).expect("cell (0,0)");
643                let v01 = ca.get(0, 1).expect("cell (0,1)");
644                let v10 = ca.get(1, 0).expect("cell (1,0)");
645                let v11 = ca.get(1, 1).expect("cell (1,1)");
646                match (v00, v01, v10, v11) {
647                    (
648                        Value::CharArray(a),
649                        Value::CharArray(b),
650                        Value::CharArray(c),
651                        Value::CharArray(d),
652                    ) => {
653                        assert_eq!(a.data, "TopX".chars().collect::<Vec<char>>());
654                        assert_eq!(b.data, "RightX".chars().collect::<Vec<char>>());
655                        assert_eq!(c.data, "BottomX".chars().collect::<Vec<char>>());
656                        assert_eq!(d.data, "LastX".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    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
666    #[test]
667    fn strcat_missing_strings_propagate() {
668        let array = StringArray::new(
669            vec![String::from("<missing>"), String::from("ready")],
670            vec![1, 2],
671        )
672        .unwrap();
673        let result = run_strcat(vec![
674            Value::String("job-".into()),
675            Value::StringArray(array),
676        ])
677        .expect("strcat");
678        match result {
679            Value::StringArray(sa) => {
680                assert_eq!(sa.data[0], "<missing>");
681                assert_eq!(sa.data[1], "job-ready");
682            }
683            other => panic!("expected string array, got {other:?}"),
684        }
685    }
686
687    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
688    #[test]
689    fn strcat_empty_dimension_returns_empty_array() {
690        let empty = StringArray::new(Vec::<String>::new(), vec![0, 2]).expect("string array");
691        let result = run_strcat(vec![
692            Value::StringArray(empty),
693            Value::String("prefix".into()),
694        ])
695        .expect("strcat");
696        match result {
697            Value::StringArray(sa) => {
698                assert_eq!(sa.shape, vec![0, 2]);
699                assert!(sa.data.is_empty());
700            }
701            other => panic!("expected empty string array, got {other:?}"),
702        }
703    }
704
705    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
706    #[test]
707    fn strcat_errors_on_invalid_input_type() {
708        let err = run_strcat(vec![Value::Int(IntValue::I32(4))]).expect_err("expected error");
709        assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
710        assert_eq!(
711            err.identifier.as_deref(),
712            STRCAT_ERROR_INVALID_INPUT.identifier
713        );
714    }
715
716    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
717    #[test]
718    fn strcat_errors_on_mismatched_sizes() {
719        let left = CharArray::new(vec!['A', 'B'], 2, 1).expect("char");
720        let right = CharArray::new(vec!['C', 'D', 'E'], 3, 1).expect("char");
721        let err = run_strcat(vec![Value::CharArray(left), Value::CharArray(right)])
722            .expect_err("expected broadcast error");
723        assert!(err
724            .to_string()
725            .starts_with(STRCAT_ERROR_SIZE_MISMATCH.message));
726        assert_eq!(
727            err.identifier.as_deref(),
728            STRCAT_ERROR_SIZE_MISMATCH.identifier
729        );
730    }
731
732    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
733    #[test]
734    fn strcat_errors_on_invalid_cell_element() {
735        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
736        let err = run_strcat(vec![Value::Cell(cell)]).expect_err("expected error");
737        assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_CELL_ELEMENT.message);
738        assert_eq!(
739            err.identifier.as_deref(),
740            STRCAT_ERROR_INVALID_CELL_ELEMENT.identifier
741        );
742    }
743
744    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
745    #[test]
746    fn strcat_errors_on_empty_argument_list() {
747        let err = run_strcat(Vec::new()).expect_err("expected error");
748        assert_eq!(err.to_string(), STRCAT_ERROR_NOT_ENOUGH_INPUTS.message);
749        assert_eq!(
750            err.identifier.as_deref(),
751            STRCAT_ERROR_NOT_ENOUGH_INPUTS.identifier
752        );
753    }
754
755    #[cfg(feature = "wgpu")]
756    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
757    #[test]
758    fn strcat_gpu_operand_still_errors_on_type() {
759        test_support::with_test_provider(|provider| {
760            let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor");
761            let view = runmat_accelerate_api::HostTensorView {
762                data: &tensor.data,
763                shape: &tensor.shape,
764            };
765            let handle = provider.upload(&view).expect("upload");
766            let err = run_strcat(vec![Value::GpuTensor(handle)]).expect_err("expected error");
767            assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
768        });
769    }
770
771    #[test]
772    fn strcat_type_concatenates_text() {
773        assert_eq!(
774            text_concat_type(&[Type::String], &ResolveContext::new(Vec::new())),
775            Type::String
776        );
777    }
778}