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