runmat_runtime/builtins/strings/transform/
join.rs

1//! MATLAB-compatible `join` 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::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}