Skip to main content

runmat_runtime/builtins/strings/transform/
join.rs

1//! MATLAB-compatible `join` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::{CellArray, CharArray, StringArray, Value};
13
14use crate::builtins::common::map_control_flow_with_builtin;
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::common::tensor;
20use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
21use crate::builtins::strings::type_resolvers::text_concat_type;
22use crate::{build_runtime_error, gather_if_needed_async, make_cell, BuiltinResult, RuntimeError};
23
24#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::join")]
25pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
26    name: "join",
27    op_kind: GpuOpKind::Custom("string-transform"),
28    supported_precisions: &[],
29    broadcast: BroadcastSemantics::None,
30    provider_hooks: &[],
31    constant_strategy: ConstantStrategy::InlineLiteral,
32    residency: ResidencyPolicy::GatherImmediately,
33    nan_mode: ReductionNaN::Include,
34    two_pass_threshold: None,
35    workgroup_size: None,
36    accepts_nan_mode: false,
37    notes: "Executes on the host; GPU-resident inputs and delimiters are gathered before concatenation.",
38};
39
40#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::join")]
41pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
42    name: "join",
43    shape: ShapeRequirements::Any,
44    constant_strategy: ConstantStrategy::InlineLiteral,
45    elementwise: None,
46    reduction: None,
47    emits_nan: false,
48    notes: "Joins operate on CPU-managed text and are ineligible for fusion.",
49};
50
51const BUILTIN_NAME: &str = "join";
52
53const JOIN_TYPED_INTEGER_DIMENSION_EXTENSION: BuiltinExtensionDescriptor =
54    BuiltinExtensionDescriptor {
55        id: "join-typed-integer-dimension",
56        mode: BuiltinExtensionMode::RunMatOnly,
57        description: "join with a typed-integer dimension is a RunMat extension",
58        error_identifier: Some("RunMat:compatibility:JoinTypedIntegerDimensionExtension"),
59    };
60const JOIN_REVERSED_ARGUMENTS_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
61    id: "join-dimension-before-delimiter",
62    mode: BuiltinExtensionMode::RunMatOnly,
63    description: "join(str, dim, delimiter) is a RunMat extension",
64    error_identifier: Some("RunMat:compatibility:JoinDimensionBeforeDelimiterExtension"),
65};
66const JOIN_RESIDENT_INPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
67    id: "join-resident-input",
68    mode: BuiltinExtensionMode::RunMatOnly,
69    description: "join with an explicitly resident gpuArray argument is a RunMat extension",
70    error_identifier: Some("RunMat:compatibility:JoinResidentInputExtension"),
71};
72pub const JOIN_EXTENSIONS: [BuiltinExtensionDescriptor; 3] = [
73    JOIN_TYPED_INTEGER_DIMENSION_EXTENSION,
74    JOIN_REVERSED_ARGUMENTS_EXTENSION,
75    JOIN_RESIDENT_INPUT_EXTENSION,
76];
77
78const JOIN_INTEGER_DIMENSION_INPUT: [BuiltinIntegerInputCapability; 1] =
79    [BuiltinIntegerInputCapability {
80        name: "dim",
81        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
82        availability: BuiltinIntegerInputAvailability::RunMatOnly,
83        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
84        notes: "The documented dimension is a positive integer-valued double scalar. RunMat mode additionally accepts every native typed-integer scalar exactly.",
85    }];
86pub const JOIN_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
87    [BuiltinIntegerCapabilityDescriptor {
88        form: "out = join(str, delimiter?, integer_dim)",
89        inputs: &JOIN_INTEGER_DIMENSION_INPUT,
90        computation_domain: BuiltinIntegerComputationDomain::Structural,
91        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
92        overflow: BuiltinIntegerOverflowRule::Error,
93        backend: BuiltinIntegerBackendRule::GatherFallback,
94        overload: BuiltinIntegerOverloadKind::ScalarOnly,
95        notes: "Typed dimensions are compatibility-gated before resident access, parsed from authoritative integer storage, and never converted through floating point.",
96    }];
97
98const JOIN_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
99    name: "out",
100    ty: BuiltinParamType::Any,
101    arity: BuiltinParamArity::Required,
102    default: None,
103    description: "Joined text preserving join output container semantics.",
104}];
105
106const JOIN_INPUTS_BASE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
107    name: "str",
108    ty: BuiltinParamType::Any,
109    arity: BuiltinParamArity::Required,
110    default: None,
111    description: "Input text (string/char/cell).",
112}];
113
114const JOIN_INPUTS_DELIMITER: [BuiltinParamDescriptor; 2] = [
115    BuiltinParamDescriptor {
116        name: "str",
117        ty: BuiltinParamType::Any,
118        arity: BuiltinParamArity::Required,
119        default: None,
120        description: "Input text (string/char/cell).",
121    },
122    BuiltinParamDescriptor {
123        name: "delimiter",
124        ty: BuiltinParamType::Any,
125        arity: BuiltinParamArity::Required,
126        default: Some("\" \""),
127        description: "Delimiter scalar or delimiter array matching join shape constraints.",
128    },
129];
130
131const JOIN_INPUTS_DIM: [BuiltinParamDescriptor; 2] = [
132    BuiltinParamDescriptor {
133        name: "str",
134        ty: BuiltinParamType::Any,
135        arity: BuiltinParamArity::Required,
136        default: None,
137        description: "Input text (string/char/cell).",
138    },
139    BuiltinParamDescriptor {
140        name: "dim",
141        ty: BuiltinParamType::IntegerScalar,
142        arity: BuiltinParamArity::Required,
143        default: None,
144        description: "Positive dimension index to join along.",
145    },
146];
147
148const JOIN_INPUTS_DELIMITER_DIM: [BuiltinParamDescriptor; 3] = [
149    BuiltinParamDescriptor {
150        name: "str",
151        ty: BuiltinParamType::Any,
152        arity: BuiltinParamArity::Required,
153        default: None,
154        description: "Input text (string/char/cell).",
155    },
156    BuiltinParamDescriptor {
157        name: "delimiter",
158        ty: BuiltinParamType::Any,
159        arity: BuiltinParamArity::Required,
160        default: None,
161        description: "Delimiter scalar or delimiter array matching join shape constraints.",
162    },
163    BuiltinParamDescriptor {
164        name: "dim",
165        ty: BuiltinParamType::IntegerScalar,
166        arity: BuiltinParamArity::Required,
167        default: None,
168        description: "Positive dimension index to join along.",
169    },
170];
171
172const JOIN_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
173    BuiltinSignatureDescriptor {
174        label: "out = join(str)",
175        inputs: &JOIN_INPUTS_BASE,
176        outputs: &JOIN_OUTPUT,
177    },
178    BuiltinSignatureDescriptor {
179        label: "out = join(str, delimiter)",
180        inputs: &JOIN_INPUTS_DELIMITER,
181        outputs: &JOIN_OUTPUT,
182    },
183    BuiltinSignatureDescriptor {
184        label: "out = join(str, dim)",
185        inputs: &JOIN_INPUTS_DIM,
186        outputs: &JOIN_OUTPUT,
187    },
188    BuiltinSignatureDescriptor {
189        label: "out = join(str, delimiter, dim)",
190        inputs: &JOIN_INPUTS_DELIMITER_DIM,
191        outputs: &JOIN_OUTPUT,
192    },
193];
194
195const JOIN_ERROR_INPUT_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
196    code: "RM.JOIN.INPUT_TYPE",
197    identifier: Some("RunMat:join:InputType"),
198    when: "Input text is not a string array/scalar, char array, or cell array of text scalars.",
199    message:
200        "join: input must be a string array, string scalar, character array, or cell array of character vectors",
201};
202
203const JOIN_ERROR_DELIMITER_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
204    code: "RM.JOIN.DELIMITER_TYPE",
205    identifier: Some("RunMat:join:DelimiterType"),
206    when: "Delimiter is not a supported text scalar/array/cell value.",
207    message:
208        "join: delimiter must be a string, character vector, string array, or cell array of character vectors",
209};
210
211const JOIN_ERROR_DELIMITER_SIZE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
212    code: "RM.JOIN.DELIMITER_SIZE",
213    identifier: Some("RunMat:join:DelimiterSize"),
214    when: "Delimiter array shape does not match join shape constraints.",
215    message:
216        "join: size of delimiter array must match the size of str, with the join dimension reduced by one",
217};
218
219const JOIN_ERROR_DIMENSION_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
220    code: "RM.JOIN.DIMENSION_TYPE",
221    identifier: Some("RunMat:join:DimensionType"),
222    when: "Dimension argument is not a positive integer scalar.",
223    message: "join: dimension must be a positive integer scalar",
224};
225
226const JOIN_ERROR_ARG_COUNT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
227    code: "RM.JOIN.ARG_COUNT",
228    identifier: Some("RunMat:join:ArgCount"),
229    when: "More than three total arguments are supplied.",
230    message: "join: too many input arguments",
231};
232
233const JOIN_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
234    code: "RM.JOIN.INTERNAL",
235    identifier: Some("RunMat:join:InternalError"),
236    when: "Internal output container construction failed.",
237    message: "join: internal error",
238};
239
240const JOIN_ERRORS: [BuiltinErrorDescriptor; 6] = [
241    JOIN_ERROR_INPUT_TYPE,
242    JOIN_ERROR_DELIMITER_TYPE,
243    JOIN_ERROR_DELIMITER_SIZE,
244    JOIN_ERROR_DIMENSION_TYPE,
245    JOIN_ERROR_ARG_COUNT,
246    JOIN_ERROR_INTERNAL,
247];
248
249pub const JOIN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
250    signatures: &JOIN_SIGNATURES,
251    output_mode: BuiltinOutputMode::Fixed,
252    completion_policy: BuiltinCompletionPolicy::Public,
253    errors: &JOIN_ERRORS,
254};
255
256fn map_flow(err: RuntimeError) -> RuntimeError {
257    map_control_flow_with_builtin(err, BUILTIN_NAME)
258}
259
260fn join_error_with_message(
261    message: impl Into<String>,
262    error: &'static BuiltinErrorDescriptor,
263) -> RuntimeError {
264    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
265    if let Some(identifier) = error.identifier {
266        builder = builder.with_identifier(identifier);
267    }
268    builder.build()
269}
270
271fn join_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
272    join_error_with_message(error.message, error)
273}
274
275#[runtime_builtin(
276    name = "join",
277    category = "strings/transform",
278    summary = "Join text elements with delimiters along a dimension.",
279    keywords = "join,string join,concatenate strings,delimiters,cell array join",
280    accel = "none",
281    type_resolver(text_concat_type),
282    descriptor(crate::builtins::strings::transform::join::JOIN_DESCRIPTOR),
283    extensions(crate::builtins::strings::transform::join::JOIN_EXTENSIONS),
284    integer_capabilities(crate::builtins::strings::transform::join::JOIN_INTEGER_CAPABILITIES),
285    builtin_path = "crate::builtins::strings::transform::join"
286)]
287async fn join_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
288    preflight_join_extensions(&text, &rest)?;
289    let text = gather_if_needed_async(&text).await.map_err(map_flow)?;
290    let mut args = Vec::with_capacity(rest.len());
291    for arg in rest {
292        args.push(gather_if_needed_async(&arg).await.map_err(map_flow)?);
293    }
294
295    let mut input = JoinInput::from_value(text)?;
296    let (delimiter_arg, dimension_arg) = parse_arguments(&args)?;
297
298    let mut shape = input.shape.clone();
299    if shape.is_empty() {
300        shape = vec![1, 1];
301    }
302
303    let default_dim = default_dimension(&shape);
304    let dimension = match dimension_arg {
305        Some(dim) => dim,
306        None => default_dim,
307    };
308
309    if dimension == 0 {
310        return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
311    }
312
313    let ndims = input.ndims();
314    if dimension > ndims {
315        return input.into_value();
316    }
317
318    let axis_idx = dimension - 1;
319    input.ensure_shape_len(dimension);
320    let full_shape = input.shape.clone();
321
322    let delimiter = Delimiter::from_value(delimiter_arg, &full_shape, axis_idx)?;
323
324    let (output_data, output_shape) = perform_join(&input.data, &full_shape, axis_idx, &delimiter);
325
326    input.build_output(output_data, output_shape)
327}
328
329fn preflight_join_extensions(text: &Value, args: &[Value]) -> BuiltinResult<()> {
330    if args.len() > 2 {
331        return Err(join_error(&JOIN_ERROR_ARG_COUNT));
332    }
333    if !is_join_input(text) {
334        return Err(join_error(&JOIN_ERROR_INPUT_TYPE));
335    }
336
337    let dimension = match args {
338        [] => None,
339        [only] if is_dimension_candidate(only) => Some(only),
340        [delimiter] => {
341            if !is_join_delimiter(delimiter) {
342                return Err(join_error(&JOIN_ERROR_DELIMITER_TYPE));
343            }
344            None
345        }
346        [delimiter, dimension] if is_dimension_candidate(dimension) => {
347            if !is_join_delimiter(delimiter) {
348                return Err(join_error(&JOIN_ERROR_DELIMITER_TYPE));
349            }
350            Some(dimension)
351        }
352        [dimension, delimiter] if is_dimension_candidate(dimension) => {
353            if !is_join_delimiter(delimiter) {
354                return Err(join_error(&JOIN_ERROR_DELIMITER_TYPE));
355            }
356            crate::compatibility::ensure_builtin_extension_enabled(
357                &JOIN_REVERSED_ARGUMENTS_EXTENSION,
358                BUILTIN_NAME,
359            )?;
360            Some(dimension)
361        }
362        [_, _] => return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE)),
363        _ => unreachable!("join arity checked above"),
364    };
365
366    if dimension.is_some_and(is_typed_integer_dimension_candidate) {
367        crate::compatibility::ensure_builtin_extension_enabled(
368            &JOIN_TYPED_INTEGER_DIMENSION_EXTENSION,
369            BUILTIN_NAME,
370        )?;
371    }
372    if args.iter().any(value_contains_explicit_gpu) {
373        crate::compatibility::ensure_builtin_extension_enabled(
374            &JOIN_RESIDENT_INPUT_EXTENSION,
375            BUILTIN_NAME,
376        )?;
377    }
378    Ok(())
379}
380
381fn is_join_input(value: &Value) -> bool {
382    match value {
383        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => true,
384        Value::Cell(cell) => cell
385            .data
386            .iter()
387            .all(|value| cell_element_to_string(value).is_some()),
388        _ => false,
389    }
390}
391
392fn is_join_delimiter(value: &Value) -> bool {
393    match value {
394        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => true,
395        Value::Cell(cell) => cell
396            .data
397            .iter()
398            .all(|value| cell_element_to_string(value).is_some()),
399        _ => false,
400    }
401}
402
403fn value_contains_explicit_gpu(value: &Value) -> bool {
404    match value {
405        Value::GpuTensor(handle) => runmat_accelerate_api::handle_is_explicit(handle),
406        Value::Cell(cell) => cell.data.iter().any(value_contains_explicit_gpu),
407        Value::Struct(value) => value.fields.values().any(value_contains_explicit_gpu),
408        Value::Object(value) => value.properties.values().any(value_contains_explicit_gpu),
409        Value::Closure(value) => value.captures.iter().any(value_contains_explicit_gpu),
410        Value::OutputList(values) => values.iter().any(value_contains_explicit_gpu),
411        _ => false,
412    }
413}
414
415fn is_typed_integer_dimension_candidate(value: &Value) -> bool {
416    match value {
417        Value::Int(_) => true,
418        Value::Tensor(tensor) => {
419            tensor::is_scalar_tensor(tensor) && tensor.integer_storage().is_some()
420        }
421        Value::GpuTensor(handle) => {
422            handle.shape.iter().copied().product::<usize>() == 1
423                && runmat_accelerate_api::handle_integer_type(handle).is_some()
424        }
425        _ => false,
426    }
427}
428
429fn is_dimension_candidate(value: &Value) -> bool {
430    match value {
431        Value::Num(_) | Value::Int(_) => true,
432        Value::Tensor(tensor) => tensor::is_scalar_tensor(tensor),
433        Value::GpuTensor(handle) => handle.shape.iter().copied().product::<usize>() == 1,
434        _ => false,
435    }
436}
437
438fn parse_arguments(args: &[Value]) -> BuiltinResult<(Option<Value>, Option<usize>)> {
439    match args.len() {
440        0 => Ok((None, None)),
441        1 => {
442            if let Some(dim) = value_to_dimension(&args[0])? {
443                Ok((None, Some(dim)))
444            } else {
445                Ok((Some(args[0].clone()), None))
446            }
447        }
448        2 => {
449            if let Some(dim) = value_to_dimension(&args[1])? {
450                Ok((Some(args[0].clone()), Some(dim)))
451            } else if let Some(dim) = value_to_dimension(&args[0])? {
452                Ok((Some(args[1].clone()), Some(dim)))
453            } else {
454                Err(join_error(&JOIN_ERROR_DIMENSION_TYPE))
455            }
456        }
457        _ => Err(join_error(&JOIN_ERROR_ARG_COUNT)),
458    }
459}
460
461fn default_dimension(shape: &[usize]) -> usize {
462    for (index, size) in shape.iter().enumerate().rev() {
463        if *size != 1 {
464            return index + 1;
465        }
466    }
467    2
468}
469
470fn value_to_dimension(value: &Value) -> BuiltinResult<Option<usize>> {
471    match value {
472        Value::Int(i) => {
473            let v = i
474                .try_to_usize()
475                .filter(|value| *value > 0)
476                .ok_or_else(|| join_error(&JOIN_ERROR_DIMENSION_TYPE))?;
477            Ok(Some(v))
478        }
479        Value::Num(n) => {
480            if !n.is_finite() || *n <= 0.0 {
481                return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
482            }
483            let rounded = n.round();
484            if (rounded - n).abs() > f64::EPSILON {
485                return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
486            }
487            parse_dimension_float(rounded)
488        }
489        Value::Tensor(t) if tensor::is_scalar_tensor(t) => {
490            if let Some(int) = t.integer_storage().and_then(|storage| storage.value_at(0)) {
491                let dim = int
492                    .try_to_usize()
493                    .filter(|value| *value > 0)
494                    .ok_or_else(|| join_error(&JOIN_ERROR_DIMENSION_TYPE))?;
495                return Ok(Some(dim));
496            }
497            let val = tensor::tensor_value_f64(t, 0);
498            if !val.is_finite() || val <= 0.0 {
499                return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
500            }
501            let rounded = val.round();
502            if (rounded - val).abs() > f64::EPSILON {
503                return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
504            }
505            parse_dimension_float(rounded)
506        }
507        _ => Ok(None),
508    }
509}
510
511fn parse_dimension_float(rounded: f64) -> BuiltinResult<Option<usize>> {
512    if rounded > usize::MAX.saturating_sub(1) as f64 {
513        return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
514    }
515    let parsed = rounded as usize;
516    if parsed as f64 != rounded || parsed == usize::MAX {
517        return Err(join_error(&JOIN_ERROR_DIMENSION_TYPE));
518    }
519    Ok(Some(parsed))
520}
521
522struct JoinInput {
523    data: Vec<String>,
524    shape: Vec<usize>,
525    kind: OutputKind,
526}
527
528#[derive(Clone)]
529enum OutputKind {
530    StringScalar,
531    StringArray,
532    CellArray,
533}
534
535impl JoinInput {
536    fn from_value(value: Value) -> BuiltinResult<Self> {
537        match value {
538            Value::String(text) => Ok(Self {
539                data: vec![text],
540                shape: vec![1, 1],
541                kind: OutputKind::StringScalar,
542            }),
543            Value::StringArray(array) => Ok(Self {
544                data: array.data,
545                shape: array.shape,
546                kind: OutputKind::StringArray,
547            }),
548            Value::CharArray(array) => {
549                let strings = char_array_rows_to_strings(&array);
550                Ok(Self {
551                    data: strings,
552                    shape: vec![array.rows, 1],
553                    kind: OutputKind::StringArray,
554                })
555            }
556            Value::Cell(cell) => {
557                let (data, shape) = cell_array_to_strings(cell)?;
558                Ok(Self {
559                    data,
560                    shape,
561                    kind: OutputKind::CellArray,
562                })
563            }
564            _ => Err(join_error(&JOIN_ERROR_INPUT_TYPE)),
565        }
566    }
567
568    fn ndims(&self) -> usize {
569        if self.shape.is_empty() {
570            2
571        } else {
572            self.shape.len().max(2)
573        }
574    }
575
576    fn ensure_shape_len(&mut self, dimension: usize) {
577        if self.shape.len() < dimension {
578            self.shape.resize(dimension, 1);
579        }
580    }
581
582    fn into_value(self) -> BuiltinResult<Value> {
583        build_value(self.kind, self.data, self.shape)
584    }
585
586    fn build_output(&self, data: Vec<String>, shape: Vec<usize>) -> BuiltinResult<Value> {
587        build_value(self.kind.clone(), data, shape)
588    }
589}
590
591fn build_value(kind: OutputKind, data: Vec<String>, shape: Vec<usize>) -> BuiltinResult<Value> {
592    match kind {
593        OutputKind::StringScalar => Ok(Value::String(data.into_iter().next().unwrap_or_default())),
594        OutputKind::StringArray => {
595            let array = StringArray::new(data, shape).map_err(|e| {
596                join_error_with_message(format!("{BUILTIN_NAME}: {e}"), &JOIN_ERROR_INTERNAL)
597            })?;
598            Ok(Value::StringArray(array))
599        }
600        OutputKind::CellArray => {
601            let rows = shape.first().copied().unwrap_or(0);
602            let cols = shape.get(1).copied().unwrap_or(1);
603            if rows == 0 || cols == 0 || data.is_empty() {
604                return make_cell(Vec::new(), rows, cols).map_err(|e| {
605                    join_error_with_message(format!("{BUILTIN_NAME}: {e}"), &JOIN_ERROR_INTERNAL)
606                });
607            }
608            let mut values = Vec::with_capacity(rows * cols);
609            for row in 0..rows {
610                for col in 0..cols {
611                    let idx = row + col * rows;
612                    let text = data[idx].clone();
613                    let chars: Vec<char> = text.chars().collect();
614                    let cols_count = chars.len();
615                    let char_array = CharArray::new(chars, 1, cols_count).map_err(|e| {
616                        join_error_with_message(
617                            format!("{BUILTIN_NAME}: {e}"),
618                            &JOIN_ERROR_INTERNAL,
619                        )
620                    })?;
621                    values.push(Value::CharArray(char_array));
622                }
623            }
624            make_cell(values, rows, cols).map_err(|e| {
625                join_error_with_message(format!("{BUILTIN_NAME}: {e}"), &JOIN_ERROR_INTERNAL)
626            })
627        }
628    }
629}
630
631fn char_array_rows_to_strings(array: &CharArray) -> Vec<String> {
632    let mut strings = Vec::with_capacity(array.rows);
633    for row in 0..array.rows {
634        strings.push(char_row_to_string_slice(&array.data, array.cols, row));
635    }
636    strings
637}
638
639fn cell_array_to_strings(cell: CellArray) -> BuiltinResult<(Vec<String>, Vec<usize>)> {
640    let CellArray {
641        data, rows, cols, ..
642    } = cell;
643    let mut strings = Vec::with_capacity(rows * cols);
644    for col in 0..cols {
645        for row in 0..rows {
646            let idx = row * cols + col;
647            strings.push(
648                cell_element_to_string(&data[idx])
649                    .ok_or_else(|| join_error(&JOIN_ERROR_INPUT_TYPE))?,
650            );
651        }
652    }
653    Ok((strings, vec![rows, cols]))
654}
655
656fn cell_element_to_string(value: &Value) -> Option<String> {
657    match value {
658        Value::String(text) => Some(text.clone()),
659        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
660        Value::CharArray(array) if array.rows <= 1 => {
661            if array.rows == 0 {
662                Some(String::new())
663            } else {
664                Some(char_row_to_string_slice(&array.data, array.cols, 0))
665            }
666        }
667        _ => None,
668    }
669}
670
671#[derive(Clone)]
672enum Delimiter {
673    Scalar(String),
674    Array(DelimiterArray),
675}
676
677#[derive(Clone)]
678struct DelimiterArray {
679    data: Vec<String>,
680    shape: Vec<usize>,
681    strides: Vec<usize>,
682}
683
684impl Delimiter {
685    fn from_value(
686        value: Option<Value>,
687        full_shape: &[usize],
688        axis_idx: usize,
689    ) -> BuiltinResult<Self> {
690        match value {
691            None => Ok(Self::Scalar(" ".to_string())),
692            Some(v) => {
693                if let Some(text) = value_to_scalar_string(&v) {
694                    return Ok(Self::Scalar(text));
695                }
696                let (data, shape) = value_to_string_array(v)?;
697                let normalized = normalize_delimiter_shape(shape, full_shape, axis_idx)?;
698                let strides = compute_strides(&normalized);
699                Ok(Self::Array(DelimiterArray {
700                    data,
701                    shape: normalized,
702                    strides,
703                }))
704            }
705        }
706    }
707
708    fn value<'a>(&'a self, coords: &[usize], axis_idx: usize, axis_gap: usize) -> &'a str {
709        match self {
710            Delimiter::Scalar(text) => text.as_str(),
711            Delimiter::Array(array) => array.value(coords, axis_idx, axis_gap),
712        }
713    }
714}
715
716impl DelimiterArray {
717    fn value<'a>(&'a self, coords: &[usize], axis_idx: usize, axis_gap: usize) -> &'a str {
718        let mut offset = 0usize;
719        for (dim, stride) in self.strides.iter().enumerate() {
720            let size = self.shape[dim];
721            let coord = if dim == axis_idx {
722                axis_gap.min(size.saturating_sub(1))
723            } else if size == 1 {
724                0
725            } else {
726                coords[dim].min(size.saturating_sub(1))
727            };
728            offset += coord * stride;
729        }
730        &self.data[offset]
731    }
732}
733
734fn value_to_scalar_string(value: &Value) -> Option<String> {
735    match value {
736        Value::String(text) => Some(text.clone()),
737        Value::CharArray(array) if array.rows <= 1 => {
738            if array.rows == 0 {
739                Some(String::new())
740            } else {
741                Some(char_row_to_string_slice(&array.data, array.cols, 0))
742            }
743        }
744        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
745        Value::Cell(cell) if cell.data.len() == 1 => cell_element_to_string(&cell.data[0]),
746        _ => None,
747    }
748}
749
750fn value_to_string_array(value: Value) -> BuiltinResult<(Vec<String>, Vec<usize>)> {
751    match value {
752        Value::StringArray(array) => Ok((array.data, array.shape)),
753        Value::Cell(cell) => {
754            let (data, shape) = cell_array_to_strings(cell)?;
755            Ok((data, shape))
756        }
757        Value::CharArray(array) => {
758            let rows = array.rows;
759            let strings = char_array_rows_to_strings(&array);
760            Ok((strings, vec![rows, 1]))
761        }
762        _ => Err(join_error(&JOIN_ERROR_DELIMITER_TYPE)),
763    }
764}
765
766fn normalize_delimiter_shape(
767    mut shape: Vec<usize>,
768    full_shape: &[usize],
769    axis_idx: usize,
770) -> BuiltinResult<Vec<usize>> {
771    if shape.len() > full_shape.len() {
772        return Err(join_error(&JOIN_ERROR_DELIMITER_SIZE));
773    }
774    if shape.len() < full_shape.len() {
775        shape.resize(full_shape.len(), 1);
776    }
777
778    let axis_len = full_shape[axis_idx].saturating_sub(1);
779    if axis_len == 0 {
780        shape[axis_idx] = 1;
781    } else if shape[axis_idx] != axis_len {
782        return Err(join_error(&JOIN_ERROR_DELIMITER_SIZE));
783    }
784
785    for (dim, size) in shape.iter().enumerate() {
786        if dim == axis_idx {
787            continue;
788        }
789        let reference = full_shape[dim];
790        if *size != reference && *size != 1 {
791            return Err(join_error(&JOIN_ERROR_DELIMITER_SIZE));
792        }
793    }
794
795    Ok(shape)
796}
797
798fn perform_join(
799    data: &[String],
800    full_shape: &[usize],
801    axis_idx: usize,
802    delimiter: &Delimiter,
803) -> (Vec<String>, Vec<usize>) {
804    if full_shape.is_empty() {
805        return (vec![String::new()], vec![1, 1]);
806    }
807
808    let axis_len = full_shape[axis_idx];
809    let mut output_shape = full_shape.to_vec();
810
811    let rest_size = full_shape
812        .iter()
813        .enumerate()
814        .filter(|(idx, _)| *idx != axis_idx)
815        .fold(1usize, |acc, (_, size)| acc.saturating_mul(*size));
816
817    if rest_size == 0 {
818        output_shape[axis_idx] = 0;
819        return (Vec::new(), output_shape);
820    }
821
822    output_shape[axis_idx] = 1;
823
824    let total_output = rest_size;
825    let mut output = Vec::with_capacity(total_output);
826
827    let strides = compute_strides(full_shape);
828    let axis_stride = strides[axis_idx];
829    let dims = full_shape.len();
830    let mut coords = vec![0usize; dims];
831
832    for _ in 0..rest_size {
833        let mut base_offset = 0usize;
834        for dim in 0..dims {
835            base_offset += coords[dim] * strides[dim];
836        }
837
838        if axis_len == 0 {
839            output.push(String::new());
840        } else {
841            let mut result = String::new();
842            let mut missing = false;
843            for axis_pos in 0..axis_len {
844                let element_offset = base_offset + axis_pos * axis_stride;
845                let value = &data[element_offset];
846                if is_missing_string(value) {
847                    missing = true;
848                    break;
849                }
850                if axis_pos > 0 {
851                    let gap = axis_pos - 1;
852                    let delim = delimiter.value(&coords, axis_idx, gap);
853                    result.push_str(delim);
854                }
855                result.push_str(value);
856            }
857            if missing {
858                output.push("<missing>".to_string());
859            } else {
860                output.push(result);
861            }
862        }
863
864        increment_coords(&mut coords, full_shape, axis_idx);
865    }
866
867    (output, output_shape)
868}
869
870fn compute_strides(shape: &[usize]) -> Vec<usize> {
871    let mut strides = vec![1usize; shape.len()];
872    for dim in 1..shape.len() {
873        strides[dim] = strides[dim - 1].saturating_mul(shape[dim - 1]);
874    }
875    strides
876}
877
878fn increment_coords(coords: &mut [usize], shape: &[usize], axis_idx: usize) {
879    for dim in 0..shape.len() {
880        if dim == axis_idx {
881            continue;
882        }
883        coords[dim] += 1;
884        if coords[dim] < shape[dim] {
885            break;
886        }
887        coords[dim] = 0;
888    }
889}
890
891#[cfg(test)]
892pub(crate) mod tests {
893    use super::*;
894    #[cfg(feature = "wgpu")]
895    use runmat_accelerate::backend::wgpu::provider as wgpu_backend;
896    use runmat_builtins::{ResolveContext, Type};
897    use runmat_value::{IntValue, IntegerStorage, Tensor};
898
899    fn join_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
900        futures::executor::block_on(super::join_builtin(text, rest))
901    }
902
903    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
904    #[test]
905    fn join_string_array_default_dimension() {
906        let array = StringArray::new(
907            vec![
908                "Carlos".into(),
909                "Ella".into(),
910                "Diana".into(),
911                "Sada".into(),
912                "Olsen".into(),
913                "Lee".into(),
914            ],
915            vec![3, 2],
916        )
917        .unwrap();
918        let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
919        match result {
920            Value::StringArray(sa) => {
921                assert_eq!(sa.shape, vec![3, 1]);
922                assert_eq!(
923                    sa.data,
924                    vec![
925                        "Carlos Sada".to_string(),
926                        "Ella Olsen".to_string(),
927                        "Diana Lee".to_string()
928                    ]
929                );
930            }
931            other => panic!("expected string array, got {other:?}"),
932        }
933    }
934
935    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
936    #[test]
937    fn join_with_custom_scalar_delimiter() {
938        let array = StringArray::new(
939            vec![
940                "x".into(),
941                "a".into(),
942                "y".into(),
943                "b".into(),
944                "z".into(),
945                "c".into(),
946            ],
947            vec![2, 3],
948        )
949        .unwrap();
950        let result =
951            join_builtin(Value::StringArray(array), vec![Value::String("-".into())]).expect("join");
952        match result {
953            Value::StringArray(sa) => {
954                assert_eq!(sa.shape, vec![2, 1]);
955                assert_eq!(sa.data, vec![String::from("x-y-z"), String::from("a-b-c")]);
956            }
957            other => panic!("expected string array, got {other:?}"),
958        }
959    }
960
961    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
962    #[test]
963    fn join_with_delimiter_array_per_row() {
964        let array = StringArray::new(
965            vec![
966                "x".into(),
967                "a".into(),
968                "y".into(),
969                "b".into(),
970                "z".into(),
971                "c".into(),
972            ],
973            vec![2, 3],
974        )
975        .unwrap();
976        let delims = StringArray::new(
977            vec![" + ".into(), " - ".into(), " = ".into(), " = ".into()],
978            vec![2, 2],
979        )
980        .unwrap();
981        let result = join_builtin(Value::StringArray(array), vec![Value::StringArray(delims)])
982            .expect("join");
983        match result {
984            Value::StringArray(sa) => {
985                assert_eq!(sa.shape, vec![2, 1]);
986                assert_eq!(
987                    sa.data,
988                    vec![String::from("x + y = z"), String::from("a - b = c")]
989                );
990            }
991            other => panic!("expected string array, got {other:?}"),
992        }
993    }
994
995    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
996    #[test]
997    fn join_with_dimension_argument() {
998        let array = StringArray::new(
999            vec![
1000                "Carlos".into(),
1001                "Ella".into(),
1002                "Diana".into(),
1003                "Sada".into(),
1004                "Olsen".into(),
1005                "Lee".into(),
1006            ],
1007            vec![3, 2],
1008        )
1009        .unwrap();
1010        let result = join_builtin(Value::StringArray(array), vec![Value::Num(1.0)]).expect("join");
1011        match result {
1012            Value::StringArray(sa) => {
1013                assert_eq!(sa.shape, vec![1, 2]);
1014                assert_eq!(
1015                    sa.data,
1016                    vec![
1017                        String::from("Carlos Ella Diana"),
1018                        String::from("Sada Olsen Lee"),
1019                    ]
1020                );
1021            }
1022            other => panic!("expected string array, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn join_dimension_parser_preserves_typed_integer_tensor_bounds() {
1028        let dim = Tensor::new_integer(IntegerStorage::U64(vec![2]), vec![1, 1]).expect("dim");
1029        assert_eq!(value_to_dimension(&Value::Tensor(dim)).unwrap(), Some(2));
1030
1031        let zero = Tensor::new_integer(IntegerStorage::U64(vec![0]), vec![1, 1]).expect("dim");
1032        assert!(value_to_dimension(&Value::Tensor(zero)).is_err());
1033
1034        let negative =
1035            Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("negative dim");
1036        assert!(value_to_dimension(&Value::Tensor(negative)).is_err());
1037        assert!(value_to_dimension(&Value::Num(1.0e300)).is_err());
1038    }
1039
1040    #[test]
1041    fn join_typed_integer_dimension_is_gated_before_evaluation() {
1042        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1043        let input = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1044        let error = join_builtin(
1045            Value::StringArray(input),
1046            vec![Value::Int(IntValue::U64(u64::MAX))],
1047        )
1048        .expect_err("typed integer dimension extension");
1049        assert_eq!(
1050            error.identifier(),
1051            JOIN_TYPED_INTEGER_DIMENSION_EXTENSION.error_identifier
1052        );
1053    }
1054
1055    #[test]
1056    fn join_resident_typed_dimension_gate_precedes_provider_access() {
1057        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1058        let handle = runmat_accelerate_api::GpuTensorHandle {
1059            shape: vec![1, 1],
1060            device_id: u32::MAX,
1061            buffer_id: u64::MAX,
1062            descriptor: Default::default(),
1063        }
1064        .with_numeric_descriptor(
1065            runmat_accelerate_api::NumericElementType::U64,
1066            runmat_accelerate_api::GpuTensorStorage::Real,
1067        );
1068        let input = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1069        let error = join_builtin(
1070            Value::StringArray(input),
1071            vec![Value::GpuTensor(handle.clone())],
1072        )
1073        .expect_err("resident typed dimension extension");
1074        runmat_accelerate_api::clear_handle_metadata(&handle);
1075        assert_eq!(
1076            error.identifier(),
1077            JOIN_TYPED_INTEGER_DIMENSION_EXTENSION.error_identifier
1078        );
1079    }
1080
1081    #[test]
1082    fn join_only_explicit_residency_is_compatibility_gated() {
1083        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1084        let automatic = runmat_accelerate_api::GpuTensorHandle {
1085            shape: vec![1, 1],
1086            device_id: u32::MAX,
1087            buffer_id: u64::MAX - 1,
1088            descriptor: Default::default(),
1089        };
1090        let automatic =
1091            automatic.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Automatic);
1092        let input = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1093        let automatic_error = join_builtin(
1094            Value::StringArray(input.clone()),
1095            vec![Value::GpuTensor(automatic.clone())],
1096        )
1097        .expect_err("automatic residency may proceed to owner lookup");
1098        assert_ne!(
1099            automatic_error.identifier(),
1100            JOIN_RESIDENT_INPUT_EXTENSION.error_identifier
1101        );
1102
1103        let explicit = runmat_accelerate_api::GpuTensorHandle {
1104            shape: vec![1, 1],
1105            device_id: u32::MAX,
1106            buffer_id: u64::MAX - 2,
1107            descriptor: Default::default(),
1108        };
1109        let explicit =
1110            explicit.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1111        let explicit_error = join_builtin(
1112            Value::StringArray(input),
1113            vec![Value::GpuTensor(explicit.clone())],
1114        )
1115        .expect_err("explicit residency must be compatibility gated");
1116        assert_eq!(
1117            explicit_error.identifier(),
1118            JOIN_RESIDENT_INPUT_EXTENSION.error_identifier
1119        );
1120        runmat_accelerate_api::clear_handle_metadata(&automatic);
1121        runmat_accelerate_api::clear_handle_metadata(&explicit);
1122    }
1123
1124    #[test]
1125    fn join_delimiter_role_error_precedes_dimension_extension() {
1126        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1127        let input = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1128        let error = join_builtin(
1129            Value::StringArray(input),
1130            vec![Value::Int(IntValue::U8(1)), Value::Num(2.0)],
1131        )
1132        .expect_err("numeric delimiter must reject as a delimiter");
1133        assert_eq!(error.identifier(), JOIN_ERROR_DELIMITER_TYPE.identifier);
1134    }
1135
1136    #[test]
1137    fn join_dimension_before_delimiter_is_a_gated_extension() {
1138        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1139        let input = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1140        let error = join_builtin(
1141            Value::StringArray(input),
1142            vec![Value::Num(2.0), Value::from("-")],
1143        )
1144        .expect_err("reversed join syntax extension");
1145        assert_eq!(
1146            error.identifier(),
1147            JOIN_REVERSED_ARGUMENTS_EXTENSION.error_identifier
1148        );
1149    }
1150
1151    #[test]
1152    fn join_integer_metadata_records_exact_runmat_only_dimension() {
1153        assert_eq!(JOIN_INTEGER_CAPABILITIES.len(), 1);
1154        assert_eq!(
1155            JOIN_INTEGER_CAPABILITIES[0].inputs[0].availability,
1156            BuiltinIntegerInputAvailability::RunMatOnly
1157        );
1158        assert_eq!(JOIN_EXTENSIONS.len(), 3);
1159        assert_eq!(JOIN_DESCRIPTOR.signatures.len(), 4);
1160    }
1161
1162    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1163    #[test]
1164    fn join_dimension_greater_than_ndims_returns_input() {
1165        let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
1166        let result =
1167            join_builtin(Value::StringArray(array.clone()), vec![Value::Num(4.0)]).expect("join");
1168        match result {
1169            Value::StringArray(sa) => {
1170                assert_eq!(sa.shape, array.shape);
1171                assert_eq!(sa.data, array.data);
1172            }
1173            other => panic!("expected original array, got {other:?}"),
1174        }
1175    }
1176
1177    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1178    #[test]
1179    fn join_cell_array_of_char_vectors() {
1180        let gpu = CharArray::new_row("GPU");
1181        let accel = CharArray::new_row("Accelerate");
1182        let vm_label = CharArray::new_row("VM");
1183        let interpreter = CharArray::new_row("Interpreter");
1184        let values = vec![
1185            Value::CharArray(gpu),
1186            Value::CharArray(accel),
1187            Value::CharArray(vm_label),
1188            Value::CharArray(interpreter),
1189        ];
1190        let cell = make_cell(values, 2, 2).expect("cell");
1191        let result = join_builtin(cell, vec![Value::String(", ".into())]).expect("join cell");
1192        match result {
1193            Value::Cell(cell_out) => {
1194                assert_eq!(cell_out.rows, 2);
1195                assert_eq!(cell_out.cols, 1);
1196                let first = &cell_out.data[0];
1197                let second = &cell_out.data[1];
1198                match (first, second) {
1199                    (Value::CharArray(a), Value::CharArray(b)) => {
1200                        assert_eq!(
1201                            char_row_to_string_slice(&a.data, a.cols, 0),
1202                            "GPU, Accelerate"
1203                        );
1204                        assert_eq!(
1205                            char_row_to_string_slice(&b.data, b.cols, 0),
1206                            "VM, Interpreter"
1207                        );
1208                    }
1209                    other => panic!("expected char arrays, got {other:?}"),
1210                }
1211            }
1212            other => panic!("expected cell array, got {other:?}"),
1213        }
1214    }
1215
1216    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1217    #[test]
1218    fn join_with_numeric_second_argument_uses_default_delimiter() {
1219        let array = StringArray::new(
1220            vec!["RunMat".into(), "Accelerate".into(), "Planner".into()],
1221            vec![3, 1],
1222        )
1223        .unwrap();
1224        let result = join_builtin(Value::StringArray(array), vec![Value::Num(1.0)]).expect("join");
1225        match result {
1226            Value::StringArray(sa) => {
1227                assert_eq!(sa.shape, vec![1, 1]);
1228                assert_eq!(sa.data, vec![String::from("RunMat Accelerate Planner")]);
1229            }
1230            other => panic!("expected string array, got {other:?}"),
1231        }
1232    }
1233
1234    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1235    #[test]
1236    fn join_char_array_input_produces_string_array() {
1237        let data: Vec<char> = "RunMatGPUDev".chars().collect();
1238        let char_array = CharArray::new(data, 3, 4).unwrap();
1239        let result = join_builtin(Value::CharArray(char_array), Vec::new()).expect("join");
1240        match result {
1241            Value::StringArray(sa) => {
1242                assert_eq!(sa.shape, vec![1, 1]);
1243                assert_eq!(sa.data, vec![String::from("RunM atGP UDev")]);
1244            }
1245            other => panic!("expected string array, got {other:?}"),
1246        }
1247    }
1248
1249    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1250    #[test]
1251    fn join_with_cell_delimiter_array() {
1252        let array = StringArray::new(
1253            vec![
1254                "g".into(),
1255                "c".into(),
1256                "w".into(),
1257                "gpu".into(),
1258                "cuda".into(),
1259                "wgpu".into(),
1260            ],
1261            vec![3, 2],
1262        )
1263        .unwrap();
1264        let delimiters = make_cell(
1265            vec![
1266                Value::String(String::from(" -> ")),
1267                Value::String(String::from(" => ")),
1268                Value::String(String::from(" :: ")),
1269            ],
1270            3,
1271            1,
1272        )
1273        .expect("cell");
1274        let result = join_builtin(Value::StringArray(array), vec![delimiters, Value::Num(2.0)])
1275            .expect("join");
1276        match result {
1277            Value::StringArray(sa) => {
1278                assert_eq!(sa.shape, vec![3, 1]);
1279                assert_eq!(
1280                    sa.data,
1281                    vec![
1282                        String::from("g -> gpu"),
1283                        String::from("c => cuda"),
1284                        String::from("w :: wgpu")
1285                    ]
1286                );
1287            }
1288            other => panic!("expected string array, got {other:?}"),
1289        }
1290    }
1291
1292    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1293    #[test]
1294    fn join_3d_string_array_along_third_dimension() {
1295        let mut data = Vec::new();
1296        for page in 0..2 {
1297            for col in 0..2 {
1298                for row in 0..2 {
1299                    data.push(format!("r{row}c{col}p{page}"));
1300                }
1301            }
1302        }
1303        let array = StringArray::new(data, vec![2, 2, 2]).unwrap();
1304        let result = join_builtin(
1305            Value::StringArray(array),
1306            vec![Value::String(":".into()), Value::Num(3.0)],
1307        )
1308        .expect("join");
1309        match result {
1310            Value::StringArray(sa) => {
1311                assert_eq!(sa.shape, vec![2, 2, 1]);
1312                let expected = vec![
1313                    String::from("r0c0p0:r0c0p1"),
1314                    String::from("r1c0p0:r1c0p1"),
1315                    String::from("r0c1p0:r0c1p1"),
1316                    String::from("r1c1p0:r1c1p1"),
1317                ];
1318                assert_eq!(sa.data, expected);
1319            }
1320            other => panic!("expected string array, got {other:?}"),
1321        }
1322    }
1323
1324    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1325    #[test]
1326    fn join_errors_on_zero_dimension() {
1327        let array = StringArray::new(vec!["a".into()], vec![1, 1]).unwrap();
1328        let err = join_builtin(
1329            Value::StringArray(array),
1330            vec![Value::Int(IntValue::I32(0))],
1331        )
1332        .unwrap_err();
1333        let err_text = err.to_string();
1334        assert!(
1335            err_text.contains("dimension"),
1336            "expected dimension error, got {err_text}"
1337        );
1338    }
1339
1340    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1341    #[test]
1342    fn join_errors_on_mismatched_delimiter_shape() {
1343        let array = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![1, 3]).unwrap();
1344        let delims =
1345            StringArray::new(vec!["+".into(), "-".into(), "=".into()], vec![1, 3]).unwrap();
1346        let result = join_builtin(Value::StringArray(array), vec![Value::StringArray(delims)]);
1347        assert!(result.is_err());
1348    }
1349
1350    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1351    #[test]
1352    fn join_propagates_missing_strings() {
1353        let array = StringArray::new(vec!["GPU".into(), "<missing>".into()], vec![1, 2]).unwrap();
1354        let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1355        match result {
1356            Value::StringArray(sa) => {
1357                assert_eq!(sa.data, vec![String::from("<missing>")]);
1358            }
1359            other => panic!("expected string array, got {other:?}"),
1360        }
1361    }
1362
1363    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1364    #[test]
1365    fn join_accepts_char_delimiter_scalar() {
1366        let array = StringArray::new(vec!["A".into(), "B".into()], vec![1, 2]).unwrap();
1367        let delimiter_chars = CharArray::new("++".chars().collect::<Vec<char>>(), 1, 2).unwrap();
1368        let result = join_builtin(
1369            Value::StringArray(array),
1370            vec![Value::CharArray(delimiter_chars)],
1371        )
1372        .expect("join");
1373        match result {
1374            Value::StringArray(sa) => {
1375                assert_eq!(sa.data, vec![String::from("A++B")]);
1376            }
1377            other => panic!("expected string array, got {other:?}"),
1378        }
1379    }
1380
1381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1382    #[test]
1383    fn join_handles_empty_axis() {
1384        let array = StringArray::new(Vec::new(), vec![2, 0]).unwrap();
1385        let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1386        match result {
1387            Value::StringArray(sa) => {
1388                assert_eq!(sa.shape, vec![2, 1]);
1389                assert_eq!(sa.data, vec![String::from(""), String::from("")]);
1390            }
1391            other => panic!("expected string array, got {other:?}"),
1392        }
1393    }
1394
1395    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1396    #[test]
1397    fn join_missing_dimension_broadcast_delimiters() {
1398        let array = StringArray::new(
1399            vec!["aa".into(), "cc".into(), "bb".into(), "dd".into()],
1400            vec![2, 2],
1401        )
1402        .unwrap();
1403        let delims = StringArray::new(vec!["-".into()], vec![1, 1]).unwrap();
1404        let result = join_builtin(
1405            Value::StringArray(array),
1406            vec![Value::StringArray(delims), Value::Num(2.0)],
1407        )
1408        .expect("join");
1409        match result {
1410            Value::StringArray(sa) => {
1411                assert_eq!(sa.shape, vec![2, 1]);
1412                assert_eq!(sa.data, vec![String::from("aa-bb"), String::from("cc-dd")]);
1413            }
1414            other => panic!("expected string array, got {other:?}"),
1415        }
1416    }
1417
1418    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1419    #[test]
1420    #[cfg(feature = "wgpu")]
1421    fn join_executes_with_wgpu_provider_registered() {
1422        let _ = wgpu_backend::register_wgpu_provider(wgpu_backend::WgpuProviderOptions::default());
1423        let array = StringArray::new(vec!["GPU".into(), "Planner".into()], vec![2, 1]).unwrap();
1424        let result = join_builtin(Value::StringArray(array), Vec::new()).expect("join");
1425        match result {
1426            Value::StringArray(sa) => {
1427                assert_eq!(sa.data, vec![String::from("GPU Planner")]);
1428            }
1429            other => panic!("expected string array, got {other:?}"),
1430        }
1431    }
1432
1433    #[test]
1434    fn join_type_concatenates_text() {
1435        assert_eq!(
1436            text_concat_type(&[Type::String], &ResolveContext::new(Vec::new())),
1437            Type::String
1438        );
1439    }
1440}