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