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::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}