1use runmat_builtins::{CellArray, CharArray, StringArray, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::spec::{
7 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
8 ReductionNaN, ResidencyPolicy, ShapeRequirements,
9};
10use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
11#[cfg(feature = "doc_export")]
12use crate::register_builtin_doc_text;
13use crate::{gather_if_needed, make_cell, register_builtin_fusion_spec, register_builtin_gpu_spec};
14
15#[cfg(feature = "doc_export")]
16pub const DOC_MD: &str = r#"---
17title: "join"
18category: "strings/transform"
19keywords: ["join", "string join", "concatenate strings", "delimiters", "cell array join", "dimension join"]
20summary: "Combine text across a specified dimension inserting delimiters between elements."
21references:
22 - https://www.mathworks.com/help/matlab/ref/join.html
23gpu_support:
24 elementwise: false
25 reduction: false
26 precisions: []
27 broadcasting: "none"
28 notes: "Executes on the CPU; GPU-resident inputs and delimiters are gathered before joining to ensure MATLAB-compatible behaviour."
29fusion:
30 elementwise: false
31 reduction: false
32 max_inputs: 2
33 constants: "inline"
34requires_feature: null
35tested:
36 unit: "builtins::strings::transform::join::tests"
37 integration: "builtins::strings::transform::join::tests::join_cell_array_of_char_vectors"
38---
39
40# What does the `join` function do in MATLAB / RunMat?
41`join` concatenates text along a chosen dimension of a string array or a cell array of character
42vectors. It inserts delimiters between neighbouring elements and mirrors MATLAB semantics for default
43dimension selection, delimiter broadcasting, and handling of missing strings.
44
45## How does the `join` function behave in MATLAB / RunMat?
46- When you omit the dimension, `join` operates along the **last dimension whose size is not 1**. If all
47 dimensions are singleton, it uses dimension 2.
48- The default delimiter is a single space character. You can pass a scalar delimiter (string or character
49 vector) or supply a string/cell array whose shape matches the input, with the join dimension reduced by
50 one, to customise delimiters for each gap.
51- Inputs may be string scalars, string arrays (including N-D), character arrays, or cell arrays of
52 character vectors. Cell inputs return cell arrays; all other inputs return string scalars or string
53 arrays.
54- If any element participating in a join is the string `<missing>`, the result for that slice is also
55 `<missing>`, matching MATLAB’s missing propagation rules.
56- Joining along a dimension greater than `ndims(str)` leaves the input unchanged.
57
58## `join` Function GPU Execution Behaviour
59`join` executes on the CPU. When text or delimiters reside on the GPU, RunMat gathers them to host
60memory before performing the concatenation, ensuring identical results to MATLAB. Providers do not need
61to implement custom kernels for this builtin today.
62
63## GPU residency in RunMat (Do I need `gpuArray`?)
64No. Text manipulation currently runs on the CPU. If your text or delimiters were produced on the GPU,
65RunMat gathers them automatically so that you can call `join` without extra steps.
66
67## Examples of using the `join` function in MATLAB / RunMat
68
69### Combine Strings In Each Row Of A Matrix
70```matlab
71names = ["Carlos" "Sada"; "Ella" "Olsen"; "Diana" "Lee"];
72fullNames = join(names);
73```
74Expected output:
75```matlab
76fullNames = 3×1 string
77 "Carlos Sada"
78 "Ella Olsen"
79 "Diana Lee"
80```
81
82### Insert A Custom Delimiter Between Elements
83```matlab
84labels = ["x" "y" "z"; "a" "b" "c"];
85joined = join(labels, "-");
86```
87Expected output:
88```matlab
89joined = 2×1 string
90 "x-y-z"
91 "a-b-c"
92```
93
94### Provide A Delimiter Array That Varies Per Row
95```matlab
96str = ["x" "y" "z"; "a" "b" "c"];
97delims = [" + " " = "; " - " " = "];
98equations = join(str, delims);
99```
100Expected output:
101```matlab
102equations = 2×1 string
103 "x + y = z"
104 "a - b = c"
105```
106
107### Join Along A Specific Dimension
108```matlab
109scores = ["Alice" "Bob"; "92" "88"; "85" "90"];
110byColumn = join(scores, 1);
111```
112Expected output:
113```matlab
114byColumn = 1×2 string
115 "Alice 92 85" "Bob 88 90"
116```
117
118### Join A Cell Array Of Character Vectors
119```matlab
120C = {'GPU', 'Accelerate'; 'Ignition', 'Interpreter'};
121result = join(C, ", ");
122```
123Expected output:
124```matlab
125result = 2×1 cell
126 {'GPU, Accelerate'}
127 {'Ignition, Interpreter'}
128```
129
130### Join Using A Dimension Argument As The Second Input
131```matlab
132words = ["RunMat"; "Accelerate"; "Planner"];
133sentence = join(words, 1);
134```
135Expected output:
136```matlab
137sentence = "RunMat Accelerate Planner"
138```
139
140### Join Rows Of An Empty String Array
141```matlab
142emptyRows = strings(2, 0);
143out = join(emptyRows);
144```
145Expected output:
146```matlab
147out = 2×1 string
148 ""
149 ""
150```
151
152## FAQ
153
154### How does `join` choose the dimension when I do not specify one?
155It looks for the last dimension whose size is not 1 and joins along that axis. If every dimension has
156size 1, it uses dimension 2.
157
158### Can I use different delimiters between separate pairs of strings?
159Yes. Supply a string array or a cell array of character vectors with the same size as `str`, except that
160the join dimension must be one element shorter. Values of size 1 in other dimensions broadcast.
161
162### What happens when `str` contains `<missing>`?
163The result for that slice becomes `<missing>`. This matches MATLAB’s behaviour and ensures missing values
164propagate.
165
166### Can I pass GPU-resident text or delimiters?
167You can; RunMat gathers them to host memory automatically before performing the join.
168
169### What if I request a dimension larger than `ndims(str)`?
170`join` returns the original text unchanged, matching MATLAB semantics.
171
172### Does `join` support numeric or logical inputs?
173No. Convert them to strings first (e.g., with `string` or `compose`), then call `join`.
174
175### How do I join every element into a single string?
176Specify the dimension explicitly. For column vectors, use `join(str, 1)`; for higher dimensional arrays,
177choose the axis that spans the elements you want to combine.
178
179### Are cell array outputs returned as strings?
180No. When the input is a cell array, the output is a cell array of character vectors, keeping parity with
181MATLAB.
182
183## See Also
184[strjoin](../../core/strjoin), [split](./split), [compose](../core/compose), [string](../core/string)
185
186## Source & Feedback
187- Implementation: [`crates/runmat-runtime/src/builtins/strings/transform/join.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/transform/join.rs)
188- Found an issue? Please [open a GitHub issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
189"#;
190
191pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
192 name: "join",
193 op_kind: GpuOpKind::Custom("string-transform"),
194 supported_precisions: &[],
195 broadcast: BroadcastSemantics::None,
196 provider_hooks: &[],
197 constant_strategy: ConstantStrategy::InlineLiteral,
198 residency: ResidencyPolicy::GatherImmediately,
199 nan_mode: ReductionNaN::Include,
200 two_pass_threshold: None,
201 workgroup_size: None,
202 accepts_nan_mode: false,
203 notes: "Executes on the host; GPU-resident inputs and delimiters are gathered before concatenation.",
204};
205
206register_builtin_gpu_spec!(GPU_SPEC);
207
208pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
209 name: "join",
210 shape: ShapeRequirements::Any,
211 constant_strategy: ConstantStrategy::InlineLiteral,
212 elementwise: None,
213 reduction: None,
214 emits_nan: false,
215 notes: "Joins operate on CPU-managed text and are ineligible for fusion.",
216};
217
218register_builtin_fusion_spec!(FUSION_SPEC);
219
220#[cfg(feature = "doc_export")]
221register_builtin_doc_text!("join", DOC_MD);
222
223const INPUT_TYPE_ERROR: &str =
224 "join: input must be a string array, string scalar, character array, or cell array of character vectors";
225const DELIMITER_TYPE_ERROR: &str =
226 "join: delimiter must be a string, character vector, string array, or cell array of character vectors";
227const DELIMITER_SIZE_ERROR: &str =
228 "join: size of delimiter array must match the size of str, with the join dimension reduced by one";
229const DIMENSION_TYPE_ERROR: &str = "join: dimension must be a positive integer scalar";
230
231#[runtime_builtin(
232 name = "join",
233 category = "strings/transform",
234 summary = "Combine text across a specified dimension inserting delimiters between elements.",
235 keywords = "join,string join,concatenate strings,delimiters,cell array join",
236 accel = "none"
237)]
238fn join_builtin(text: Value, rest: Vec<Value>) -> Result<Value, String> {
239 let text = gather_if_needed(&text).map_err(|e| format!("join: {e}"))?;
240 let mut args = Vec::with_capacity(rest.len());
241 for arg in rest {
242 args.push(gather_if_needed(&arg).map_err(|e| format!("join: {e}"))?);
243 }
244
245 let mut input = JoinInput::from_value(text)?;
246 let (delimiter_arg, dimension_arg) = parse_arguments(&args)?;
247
248 let mut shape = input.shape.clone();
249 if shape.is_empty() {
250 shape = vec![1, 1];
251 }
252
253 let default_dim = default_dimension(&shape);
254 let dimension = match dimension_arg {
255 Some(dim) => dim,
256 None => default_dim,
257 };
258
259 if dimension == 0 {
260 return Err(DIMENSION_TYPE_ERROR.to_string());
261 }
262
263 let ndims = input.ndims();
264 if dimension > ndims {
265 return input.into_value();
266 }
267
268 let axis_idx = dimension - 1;
269 input.ensure_shape_len(dimension);
270 let full_shape = input.shape.clone();
271
272 let delimiter = Delimiter::from_value(delimiter_arg, &full_shape, axis_idx)
273 .map_err(|e| format!("join: {e}"))?;
274
275 let (output_data, output_shape) = perform_join(&input.data, &full_shape, axis_idx, &delimiter);
276
277 input.build_output(output_data, output_shape)
278}
279
280fn parse_arguments(args: &[Value]) -> Result<(Option<Value>, Option<usize>), String> {
281 match args.len() {
282 0 => Ok((None, None)),
283 1 => {
284 if let Some(dim) = value_to_dimension(&args[0])? {
285 Ok((None, Some(dim)))
286 } else {
287 Ok((Some(args[0].clone()), None))
288 }
289 }
290 2 => {
291 if let Some(dim) = value_to_dimension(&args[1])? {
292 Ok((Some(args[0].clone()), Some(dim)))
293 } else if let Some(dim) = value_to_dimension(&args[0])? {
294 Ok((Some(args[1].clone()), Some(dim)))
295 } else {
296 Err(DIMENSION_TYPE_ERROR.to_string())
297 }
298 }
299 _ => Err("join: too many input arguments".to_string()),
300 }
301}
302
303fn default_dimension(shape: &[usize]) -> usize {
304 for (index, size) in shape.iter().enumerate().rev() {
305 if *size != 1 {
306 return index + 1;
307 }
308 }
309 2
310}
311
312fn value_to_dimension(value: &Value) -> Result<Option<usize>, String> {
313 match value {
314 Value::Int(i) => {
315 let v = i.to_i64();
316 if v <= 0 {
317 return Err(DIMENSION_TYPE_ERROR.to_string());
318 }
319 Ok(Some(v as usize))
320 }
321 Value::Num(n) => {
322 if !n.is_finite() || *n <= 0.0 {
323 return Err(DIMENSION_TYPE_ERROR.to_string());
324 }
325 let rounded = n.round();
326 if (rounded - n).abs() > f64::EPSILON {
327 return Err(DIMENSION_TYPE_ERROR.to_string());
328 }
329 Ok(Some(rounded as usize))
330 }
331 Value::Tensor(t) if t.data.len() == 1 => {
332 let val = t.data[0];
333 if !val.is_finite() || val <= 0.0 {
334 return Err(DIMENSION_TYPE_ERROR.to_string());
335 }
336 let rounded = val.round();
337 if (rounded - val).abs() > f64::EPSILON {
338 return Err(DIMENSION_TYPE_ERROR.to_string());
339 }
340 Ok(Some(rounded as usize))
341 }
342 _ => Ok(None),
343 }
344}
345
346struct JoinInput {
347 data: Vec<String>,
348 shape: Vec<usize>,
349 kind: OutputKind,
350}
351
352#[derive(Clone)]
353enum OutputKind {
354 StringScalar,
355 StringArray,
356 CellArray,
357}
358
359impl JoinInput {
360 fn from_value(value: Value) -> Result<Self, String> {
361 match value {
362 Value::String(text) => Ok(Self {
363 data: vec![text],
364 shape: vec![1, 1],
365 kind: OutputKind::StringScalar,
366 }),
367 Value::StringArray(array) => Ok(Self {
368 data: array.data,
369 shape: array.shape,
370 kind: OutputKind::StringArray,
371 }),
372 Value::CharArray(array) => {
373 let strings = char_array_rows_to_strings(&array);
374 Ok(Self {
375 data: strings,
376 shape: vec![array.rows, 1],
377 kind: OutputKind::StringArray,
378 })
379 }
380 Value::Cell(cell) => {
381 let (data, shape) = cell_array_to_strings(cell)?;
382 Ok(Self {
383 data,
384 shape,
385 kind: OutputKind::CellArray,
386 })
387 }
388 _ => Err(INPUT_TYPE_ERROR.to_string()),
389 }
390 }
391
392 fn ndims(&self) -> usize {
393 if self.shape.is_empty() {
394 2
395 } else {
396 self.shape.len().max(2)
397 }
398 }
399
400 fn ensure_shape_len(&mut self, dimension: usize) {
401 if self.shape.len() < dimension {
402 self.shape.resize(dimension, 1);
403 }
404 }
405
406 fn into_value(self) -> Result<Value, String> {
407 build_value(self.kind, self.data, self.shape)
408 }
409
410 fn build_output(&self, data: Vec<String>, shape: Vec<usize>) -> Result<Value, String> {
411 build_value(self.kind.clone(), data, shape)
412 }
413}
414
415fn build_value(kind: OutputKind, data: Vec<String>, shape: Vec<usize>) -> Result<Value, String> {
416 match kind {
417 OutputKind::StringScalar => Ok(Value::String(data.into_iter().next().unwrap_or_default())),
418 OutputKind::StringArray => {
419 let array = StringArray::new(data, shape).map_err(|e| format!("join: {e}"))?;
420 Ok(Value::StringArray(array))
421 }
422 OutputKind::CellArray => {
423 let rows = shape.first().copied().unwrap_or(0);
424 let cols = shape.get(1).copied().unwrap_or(1);
425 if rows == 0 || cols == 0 || data.is_empty() {
426 return make_cell(Vec::new(), rows, cols);
427 }
428 let mut values = Vec::with_capacity(rows * cols);
429 for row in 0..rows {
430 for col in 0..cols {
431 let idx = row + col * rows;
432 let text = data[idx].clone();
433 let chars: Vec<char> = text.chars().collect();
434 let cols_count = chars.len();
435 let char_array =
436 CharArray::new(chars, 1, cols_count).map_err(|e| format!("join: {e}"))?;
437 values.push(Value::CharArray(char_array));
438 }
439 }
440 make_cell(values, rows, cols).map_err(|e| format!("join: {e}"))
441 }
442 }
443}
444
445fn char_array_rows_to_strings(array: &CharArray) -> Vec<String> {
446 let mut strings = Vec::with_capacity(array.rows);
447 for row in 0..array.rows {
448 strings.push(char_row_to_string_slice(&array.data, array.cols, row));
449 }
450 strings
451}
452
453fn cell_array_to_strings(cell: CellArray) -> Result<(Vec<String>, Vec<usize>), String> {
454 let CellArray {
455 data, rows, cols, ..
456 } = cell;
457 let mut strings = Vec::with_capacity(rows * cols);
458 for col in 0..cols {
459 for row in 0..rows {
460 let idx = row * cols + col;
461 strings.push(
462 cell_element_to_string(&data[idx]).ok_or_else(|| INPUT_TYPE_ERROR.to_string())?,
463 );
464 }
465 }
466 Ok((strings, vec![rows, cols]))
467}
468
469fn cell_element_to_string(value: &Value) -> Option<String> {
470 match value {
471 Value::String(text) => Some(text.clone()),
472 Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
473 Value::CharArray(array) if array.rows <= 1 => {
474 if array.rows == 0 {
475 Some(String::new())
476 } else {
477 Some(char_row_to_string_slice(&array.data, array.cols, 0))
478 }
479 }
480 _ => None,
481 }
482}
483
484#[derive(Clone)]
485enum Delimiter {
486 Scalar(String),
487 Array(DelimiterArray),
488}
489
490#[derive(Clone)]
491struct DelimiterArray {
492 data: Vec<String>,
493 shape: Vec<usize>,
494 strides: Vec<usize>,
495}
496
497impl Delimiter {
498 fn from_value(
499 value: Option<Value>,
500 full_shape: &[usize],
501 axis_idx: usize,
502 ) -> Result<Self, String> {
503 match value {
504 None => Ok(Self::Scalar(" ".to_string())),
505 Some(v) => {
506 if let Some(text) = value_to_scalar_string(&v) {
507 return Ok(Self::Scalar(text));
508 }
509 let (data, shape) = value_to_string_array(v)?;
510 let normalized = normalize_delimiter_shape(shape, full_shape, axis_idx)?;
511 let strides = compute_strides(&normalized);
512 Ok(Self::Array(DelimiterArray {
513 data,
514 shape: normalized,
515 strides,
516 }))
517 }
518 }
519 }
520
521 fn value<'a>(&'a self, coords: &[usize], axis_idx: usize, axis_gap: usize) -> &'a str {
522 match self {
523 Delimiter::Scalar(text) => text.as_str(),
524 Delimiter::Array(array) => array.value(coords, axis_idx, axis_gap),
525 }
526 }
527}
528
529impl DelimiterArray {
530 fn value<'a>(&'a self, coords: &[usize], axis_idx: usize, axis_gap: usize) -> &'a str {
531 let mut offset = 0usize;
532 for (dim, stride) in self.strides.iter().enumerate() {
533 let size = self.shape[dim];
534 let coord = if dim == axis_idx {
535 axis_gap.min(size.saturating_sub(1))
536 } else if size == 1 {
537 0
538 } else {
539 coords[dim].min(size.saturating_sub(1))
540 };
541 offset += coord * stride;
542 }
543 &self.data[offset]
544 }
545}
546
547fn value_to_scalar_string(value: &Value) -> Option<String> {
548 match value {
549 Value::String(text) => Some(text.clone()),
550 Value::CharArray(array) if array.rows <= 1 => {
551 if array.rows == 0 {
552 Some(String::new())
553 } else {
554 Some(char_row_to_string_slice(&array.data, array.cols, 0))
555 }
556 }
557 Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
558 Value::Cell(cell) if cell.data.len() == 1 => cell_element_to_string(&cell.data[0]),
559 _ => None,
560 }
561}
562
563fn value_to_string_array(value: Value) -> Result<(Vec<String>, Vec<usize>), String> {
564 match value {
565 Value::StringArray(array) => Ok((array.data, array.shape)),
566 Value::Cell(cell) => {
567 let (data, shape) = cell_array_to_strings(cell)?;
568 Ok((data, shape))
569 }
570 Value::CharArray(array) => {
571 let rows = array.rows;
572 let strings = char_array_rows_to_strings(&array);
573 Ok((strings, vec![rows, 1]))
574 }
575 _ => Err(DELIMITER_TYPE_ERROR.to_string()),
576 }
577}
578
579fn normalize_delimiter_shape(
580 mut shape: Vec<usize>,
581 full_shape: &[usize],
582 axis_idx: usize,
583) -> Result<Vec<usize>, String> {
584 if shape.len() > full_shape.len() {
585 return Err(DELIMITER_SIZE_ERROR.to_string());
586 }
587 if shape.len() < full_shape.len() {
588 shape.resize(full_shape.len(), 1);
589 }
590
591 let axis_len = full_shape[axis_idx].saturating_sub(1);
592 if axis_len == 0 {
593 shape[axis_idx] = 1;
594 } else if shape[axis_idx] != axis_len {
595 return Err(DELIMITER_SIZE_ERROR.to_string());
596 }
597
598 for (dim, size) in shape.iter().enumerate() {
599 if dim == axis_idx {
600 continue;
601 }
602 let reference = full_shape[dim];
603 if *size != reference && *size != 1 {
604 return Err(DELIMITER_SIZE_ERROR.to_string());
605 }
606 }
607
608 Ok(shape)
609}
610
611fn perform_join(
612 data: &[String],
613 full_shape: &[usize],
614 axis_idx: usize,
615 delimiter: &Delimiter,
616) -> (Vec<String>, Vec<usize>) {
617 if full_shape.is_empty() {
618 return (vec![String::new()], vec![1, 1]);
619 }
620
621 let axis_len = full_shape[axis_idx];
622 let mut output_shape = full_shape.to_vec();
623
624 let rest_size = full_shape
625 .iter()
626 .enumerate()
627 .filter(|(idx, _)| *idx != axis_idx)
628 .fold(1usize, |acc, (_, size)| acc.saturating_mul(*size));
629
630 if rest_size == 0 {
631 output_shape[axis_idx] = 0;
632 return (Vec::new(), output_shape);
633 }
634
635 output_shape[axis_idx] = 1;
636
637 let total_output = rest_size;
638 let mut output = Vec::with_capacity(total_output);
639
640 let strides = compute_strides(full_shape);
641 let axis_stride = strides[axis_idx];
642 let dims = full_shape.len();
643 let mut coords = vec![0usize; dims];
644
645 for _ in 0..rest_size {
646 let mut base_offset = 0usize;
647 for dim in 0..dims {
648 base_offset += coords[dim] * strides[dim];
649 }
650
651 if axis_len == 0 {
652 output.push(String::new());
653 } else {
654 let mut result = String::new();
655 let mut missing = false;
656 for axis_pos in 0..axis_len {
657 let element_offset = base_offset + axis_pos * axis_stride;
658 let value = &data[element_offset];
659 if is_missing_string(value) {
660 missing = true;
661 break;
662 }
663 if axis_pos > 0 {
664 let gap = axis_pos - 1;
665 let delim = delimiter.value(&coords, axis_idx, gap);
666 result.push_str(delim);
667 }
668 result.push_str(value);
669 }
670 if missing {
671 output.push("<missing>".to_string());
672 } else {
673 output.push(result);
674 }
675 }
676
677 increment_coords(&mut coords, full_shape, axis_idx);
678 }
679
680 (output, output_shape)
681}
682
683fn compute_strides(shape: &[usize]) -> Vec<usize> {
684 let mut strides = vec![1usize; shape.len()];
685 for dim in 1..shape.len() {
686 strides[dim] = strides[dim - 1].saturating_mul(shape[dim - 1]);
687 }
688 strides
689}
690
691fn increment_coords(coords: &mut [usize], shape: &[usize], axis_idx: usize) {
692 for dim in 0..shape.len() {
693 if dim == axis_idx {
694 continue;
695 }
696 coords[dim] += 1;
697 if coords[dim] < shape[dim] {
698 break;
699 }
700 coords[dim] = 0;
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 #[cfg(feature = "doc_export")]
708 use crate::builtins::common::test_support;
709 #[cfg(feature = "wgpu")]
710 use runmat_accelerate::backend::wgpu::provider as wgpu_backend;
711 use runmat_builtins::IntValue;
712
713 #[test]
714 fn join_string_array_default_dimension() {
715 let array = StringArray::new(
716 vec![
717 "Carlos".into(),
718 "Ella".into(),
719 "Diana".into(),
720 "Sada".into(),
721 "Olsen".into(),
722 "Lee".into(),
723 ],
724 vec![3, 2],
725 )
726 .unwrap();
727 let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
728 match result {
729 Value::StringArray(sa) => {
730 assert_eq!(sa.shape, vec![3, 1]);
731 assert_eq!(
732 sa.data,
733 vec![
734 "Carlos Sada".to_string(),
735 "Ella Olsen".to_string(),
736 "Diana Lee".to_string()
737 ]
738 );
739 }
740 other => panic!("expected string array, got {other:?}"),
741 }
742 }
743
744 #[test]
745 fn join_with_custom_scalar_delimiter() {
746 let array = StringArray::new(
747 vec![
748 "x".into(),
749 "a".into(),
750 "y".into(),
751 "b".into(),
752 "z".into(),
753 "c".into(),
754 ],
755 vec![2, 3],
756 )
757 .unwrap();
758 let result =
759 join_builtin(Value::StringArray(array), vec![Value::String("-".into())]).expect("join");
760 match result {
761 Value::StringArray(sa) => {
762 assert_eq!(sa.shape, vec![2, 1]);
763 assert_eq!(sa.data, vec![String::from("x-y-z"), String::from("a-b-c")]);
764 }
765 other => panic!("expected string array, got {other:?}"),
766 }
767 }
768
769 #[test]
770 fn join_with_delimiter_array_per_row() {
771 let array = StringArray::new(
772 vec![
773 "x".into(),
774 "a".into(),
775 "y".into(),
776 "b".into(),
777 "z".into(),
778 "c".into(),
779 ],
780 vec![2, 3],
781 )
782 .unwrap();
783 let delims = StringArray::new(
784 vec![" + ".into(), " - ".into(), " = ".into(), " = ".into()],
785 vec![2, 2],
786 )
787 .unwrap();
788 let result = join_builtin(Value::StringArray(array), vec![Value::StringArray(delims)])
789 .expect("join");
790 match result {
791 Value::StringArray(sa) => {
792 assert_eq!(sa.shape, vec![2, 1]);
793 assert_eq!(
794 sa.data,
795 vec![String::from("x + y = z"), String::from("a - b = c")]
796 );
797 }
798 other => panic!("expected string array, got {other:?}"),
799 }
800 }
801
802 #[test]
803 fn join_with_dimension_argument() {
804 let array = StringArray::new(
805 vec![
806 "Carlos".into(),
807 "Ella".into(),
808 "Diana".into(),
809 "Sada".into(),
810 "Olsen".into(),
811 "Lee".into(),
812 ],
813 vec![3, 2],
814 )
815 .unwrap();
816 let result = join_builtin(
817 Value::StringArray(array),
818 vec![Value::Int(IntValue::I32(1))],
819 )
820 .expect("join");
821 match result {
822 Value::StringArray(sa) => {
823 assert_eq!(sa.shape, vec![1, 2]);
824 assert_eq!(
825 sa.data,
826 vec![
827 String::from("Carlos Ella Diana"),
828 String::from("Sada Olsen Lee"),
829 ]
830 );
831 }
832 other => panic!("expected string array, got {other:?}"),
833 }
834 }
835
836 #[test]
837 fn join_dimension_greater_than_ndims_returns_input() {
838 let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
839 let result = join_builtin(
840 Value::StringArray(array.clone()),
841 vec![Value::Int(IntValue::I32(4))],
842 )
843 .expect("join");
844 match result {
845 Value::StringArray(sa) => {
846 assert_eq!(sa.shape, array.shape);
847 assert_eq!(sa.data, array.data);
848 }
849 other => panic!("expected original array, got {other:?}"),
850 }
851 }
852
853 #[test]
854 fn join_cell_array_of_char_vectors() {
855 let gpu = CharArray::new_row("GPU");
856 let accel = CharArray::new_row("Accelerate");
857 let ignition = CharArray::new_row("Ignition");
858 let interpreter = CharArray::new_row("Interpreter");
859 let values = vec![
860 Value::CharArray(gpu),
861 Value::CharArray(accel),
862 Value::CharArray(ignition),
863 Value::CharArray(interpreter),
864 ];
865 let cell = make_cell(values, 2, 2).expect("cell");
866 let result = join_builtin(cell, vec![Value::String(", ".into())]).expect("join cell");
867 match result {
868 Value::Cell(cell_out) => {
869 assert_eq!(cell_out.rows, 2);
870 assert_eq!(cell_out.cols, 1);
871 let first = unsafe { &*cell_out.data[0].as_raw() };
872 let second = unsafe { &*cell_out.data[1].as_raw() };
873 match (first, second) {
874 (Value::CharArray(a), Value::CharArray(b)) => {
875 assert_eq!(
876 char_row_to_string_slice(&a.data, a.cols, 0),
877 "GPU, Accelerate"
878 );
879 assert_eq!(
880 char_row_to_string_slice(&b.data, b.cols, 0),
881 "Ignition, Interpreter"
882 );
883 }
884 other => panic!("expected char arrays, got {other:?}"),
885 }
886 }
887 other => panic!("expected cell array, got {other:?}"),
888 }
889 }
890
891 #[test]
892 fn join_with_numeric_second_argument_uses_default_delimiter() {
893 let array = StringArray::new(
894 vec!["RunMat".into(), "Accelerate".into(), "Planner".into()],
895 vec![3, 1],
896 )
897 .unwrap();
898 let result = join_builtin(
899 Value::StringArray(array),
900 vec![Value::Int(IntValue::I32(1))],
901 )
902 .expect("join");
903 match result {
904 Value::StringArray(sa) => {
905 assert_eq!(sa.shape, vec![1, 1]);
906 assert_eq!(sa.data, vec![String::from("RunMat Accelerate Planner")]);
907 }
908 other => panic!("expected string array, got {other:?}"),
909 }
910 }
911
912 #[test]
913 fn join_char_array_input_produces_string_array() {
914 let data: Vec<char> = "RunMatGPUDev".chars().collect();
915 let char_array = CharArray::new(data, 3, 4).unwrap();
916 let result = join_builtin(Value::CharArray(char_array), Vec::new()).expect("join");
917 match result {
918 Value::StringArray(sa) => {
919 assert_eq!(sa.shape, vec![1, 1]);
920 assert_eq!(sa.data, vec![String::from("RunM atGP UDev")]);
921 }
922 other => panic!("expected string array, got {other:?}"),
923 }
924 }
925
926 #[test]
927 fn join_with_cell_delimiter_array() {
928 let array = StringArray::new(
929 vec![
930 "g".into(),
931 "c".into(),
932 "w".into(),
933 "gpu".into(),
934 "cuda".into(),
935 "wgpu".into(),
936 ],
937 vec![3, 2],
938 )
939 .unwrap();
940 let delimiters = make_cell(
941 vec![
942 Value::String(String::from(" -> ")),
943 Value::String(String::from(" => ")),
944 Value::String(String::from(" :: ")),
945 ],
946 3,
947 1,
948 )
949 .expect("cell");
950 let result = join_builtin(
951 Value::StringArray(array),
952 vec![delimiters, Value::Int(IntValue::I32(2))],
953 )
954 .expect("join");
955 match result {
956 Value::StringArray(sa) => {
957 assert_eq!(sa.shape, vec![3, 1]);
958 assert_eq!(
959 sa.data,
960 vec![
961 String::from("g -> gpu"),
962 String::from("c => cuda"),
963 String::from("w :: wgpu")
964 ]
965 );
966 }
967 other => panic!("expected string array, got {other:?}"),
968 }
969 }
970
971 #[test]
972 fn join_3d_string_array_along_third_dimension() {
973 let mut data = Vec::new();
974 for page in 0..2 {
975 for col in 0..2 {
976 for row in 0..2 {
977 data.push(format!("r{row}c{col}p{page}"));
978 }
979 }
980 }
981 let array = StringArray::new(data, vec![2, 2, 2]).unwrap();
982 let result = join_builtin(
983 Value::StringArray(array),
984 vec![Value::String(":".into()), Value::Int(IntValue::I32(3))],
985 )
986 .expect("join");
987 match result {
988 Value::StringArray(sa) => {
989 assert_eq!(sa.shape, vec![2, 2, 1]);
990 let expected = vec![
991 String::from("r0c0p0:r0c0p1"),
992 String::from("r1c0p0:r1c0p1"),
993 String::from("r0c1p0:r0c1p1"),
994 String::from("r1c1p0:r1c1p1"),
995 ];
996 assert_eq!(sa.data, expected);
997 }
998 other => panic!("expected string array, got {other:?}"),
999 }
1000 }
1001
1002 #[test]
1003 fn join_errors_on_zero_dimension() {
1004 let array = StringArray::new(vec!["a".into()], vec![1, 1]).unwrap();
1005 let err = join_builtin(
1006 Value::StringArray(array),
1007 vec![Value::Int(IntValue::I32(0))],
1008 )
1009 .unwrap_err();
1010 assert!(
1011 err.contains("dimension"),
1012 "expected dimension error, got {err}"
1013 );
1014 }
1015
1016 #[test]
1017 fn join_errors_on_mismatched_delimiter_shape() {
1018 let array = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![1, 3]).unwrap();
1019 let delims =
1020 StringArray::new(vec!["+".into(), "-".into(), "=".into()], vec![1, 3]).unwrap();
1021 let result = join_builtin(Value::StringArray(array), vec![Value::StringArray(delims)]);
1022 assert!(result.is_err());
1023 }
1024
1025 #[test]
1026 fn join_propagates_missing_strings() {
1027 let array = StringArray::new(vec!["GPU".into(), "<missing>".into()], vec![1, 2]).unwrap();
1028 let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1029 match result {
1030 Value::StringArray(sa) => {
1031 assert_eq!(sa.data, vec![String::from("<missing>")]);
1032 }
1033 other => panic!("expected string array, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1038 fn join_accepts_char_delimiter_scalar() {
1039 let array = StringArray::new(vec!["A".into(), "B".into()], vec![1, 2]).unwrap();
1040 let delimiter_chars = CharArray::new("++".chars().collect::<Vec<char>>(), 1, 2).unwrap();
1041 let result = join_builtin(
1042 Value::StringArray(array),
1043 vec![Value::CharArray(delimiter_chars)],
1044 )
1045 .expect("join");
1046 match result {
1047 Value::StringArray(sa) => {
1048 assert_eq!(sa.data, vec![String::from("A++B")]);
1049 }
1050 other => panic!("expected string array, got {other:?}"),
1051 }
1052 }
1053
1054 #[test]
1055 fn join_handles_empty_axis() {
1056 let array = StringArray::new(Vec::new(), vec![2, 0]).unwrap();
1057 let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1058 match result {
1059 Value::StringArray(sa) => {
1060 assert_eq!(sa.shape, vec![2, 1]);
1061 assert_eq!(sa.data, vec![String::from(""), String::from("")]);
1062 }
1063 other => panic!("expected string array, got {other:?}"),
1064 }
1065 }
1066
1067 #[test]
1068 fn join_missing_dimension_broadcast_delimiters() {
1069 let array = StringArray::new(
1070 vec!["aa".into(), "cc".into(), "bb".into(), "dd".into()],
1071 vec![2, 2],
1072 )
1073 .unwrap();
1074 let delims = StringArray::new(vec!["-".into()], vec![1, 1]).unwrap();
1075 let result = join_builtin(
1076 Value::StringArray(array),
1077 vec![Value::StringArray(delims), Value::Int(IntValue::I32(2))],
1078 )
1079 .expect("join");
1080 match result {
1081 Value::StringArray(sa) => {
1082 assert_eq!(sa.shape, vec![2, 1]);
1083 assert_eq!(sa.data, vec![String::from("aa-bb"), String::from("cc-dd")]);
1084 }
1085 other => panic!("expected string array, got {other:?}"),
1086 }
1087 }
1088
1089 #[test]
1090 #[cfg(feature = "doc_export")]
1091 fn doc_examples_present() {
1092 let blocks = test_support::doc_examples(DOC_MD);
1093 assert!(!blocks.is_empty());
1094 }
1095
1096 #[test]
1097 #[cfg(feature = "wgpu")]
1098 fn join_executes_with_wgpu_provider_registered() {
1099 let _ = wgpu_backend::register_wgpu_provider(wgpu_backend::WgpuProviderOptions::default());
1100 let array = StringArray::new(vec!["GPU".into(), "Planner".into()], vec![2, 1]).unwrap();
1101 let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1102 match result {
1103 Value::StringArray(sa) => {
1104 assert_eq!(sa.data, vec![String::from("GPU Planner")]);
1105 }
1106 other => panic!("expected string array, got {other:?}"),
1107 }
1108 }
1109}