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