1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6 CellArray, CharArray, StringArray, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::broadcast::{broadcast_index, broadcast_shapes, compute_strides};
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::common::spec::{
13 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14 ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
17use crate::builtins::strings::type_resolvers::text_concat_type;
18use crate::{
19 build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult, RuntimeError,
20};
21
22#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24 name: "strcat",
25 op_kind: GpuOpKind::Custom("string-transform"),
26 supported_precisions: &[],
27 broadcast: BroadcastSemantics::Matlab,
28 provider_hooks: &[],
29 constant_strategy: ConstantStrategy::InlineLiteral,
30 residency: ResidencyPolicy::GatherImmediately,
31 nan_mode: ReductionNaN::Include,
32 two_pass_threshold: None,
33 workgroup_size: None,
34 accepts_nan_mode: false,
35 notes: "Executes on the CPU with trailing-space trimming; GPU inputs are gathered before concatenation.",
36};
37
38#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::strcat")]
39pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
40 name: "strcat",
41 shape: ShapeRequirements::BroadcastCompatible,
42 constant_strategy: ConstantStrategy::InlineLiteral,
43 elementwise: None,
44 reduction: None,
45 emits_nan: false,
46 notes: "String concatenation runs on the host and is not eligible for fusion.",
47};
48
49const BUILTIN_NAME: &str = "strcat";
50
51const STRCAT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
52 name: "out",
53 ty: BuiltinParamType::Any,
54 arity: BuiltinParamArity::Required,
55 default: None,
56 description: "Concatenated text preserving strcat output container semantics.",
57}];
58
59const STRCAT_INPUTS: [BuiltinParamDescriptor; 2] = [
60 BuiltinParamDescriptor {
61 name: "str1",
62 ty: BuiltinParamType::Any,
63 arity: BuiltinParamArity::Required,
64 default: None,
65 description: "First text input (string/char/cell).",
66 },
67 BuiltinParamDescriptor {
68 name: "str2",
69 ty: BuiltinParamType::Any,
70 arity: BuiltinParamArity::Variadic,
71 default: None,
72 description: "Additional text inputs to concatenate element-wise.",
73 },
74];
75
76const STRCAT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
77 label: "out = strcat(str1, str2, ...)",
78 inputs: &STRCAT_INPUTS,
79 outputs: &STRCAT_OUTPUT,
80}];
81
82const STRCAT_ERROR_NOT_ENOUGH_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
83 code: "RM.STRCAT.NOT_ENOUGH_INPUTS",
84 identifier: Some("RunMat:strcat:NotEnoughInputs"),
85 when: "No arguments are supplied.",
86 message: "strcat: not enough input arguments",
87};
88
89const STRCAT_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
90 code: "RM.STRCAT.INVALID_INPUT",
91 identifier: Some("RunMat:strcat:InvalidInput"),
92 when: "An input is not a string, character array, or cell array of text scalars.",
93 message:
94 "strcat: inputs must be strings, character arrays, or cell arrays of character vectors",
95};
96
97const STRCAT_ERROR_INVALID_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
98 code: "RM.STRCAT.CELL_ELEMENT",
99 identifier: Some("RunMat:strcat:CellElement"),
100 when: "A cell array contains a non-text element or non-row char array element.",
101 message: "strcat: cell array elements must be character vectors or string scalars",
102};
103
104const STRCAT_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
105 code: "RM.STRCAT.SIZE_MISMATCH",
106 identifier: Some("RunMat:strcat:SizeMismatch"),
107 when: "Input shapes are not broadcast-compatible.",
108 message: "strcat: array sizes are not compatible for broadcasting",
109};
110
111const STRCAT_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
112 code: "RM.STRCAT.INTERNAL",
113 identifier: Some("RunMat:strcat:InternalError"),
114 when: "Internal output container construction failed.",
115 message: "strcat: internal error",
116};
117
118const STRCAT_ERRORS: [BuiltinErrorDescriptor; 5] = [
119 STRCAT_ERROR_NOT_ENOUGH_INPUTS,
120 STRCAT_ERROR_INVALID_INPUT,
121 STRCAT_ERROR_INVALID_CELL_ELEMENT,
122 STRCAT_ERROR_SIZE_MISMATCH,
123 STRCAT_ERROR_INTERNAL,
124];
125
126pub const STRCAT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
127 signatures: &STRCAT_SIGNATURES,
128 output_mode: BuiltinOutputMode::Fixed,
129 completion_policy: BuiltinCompletionPolicy::Public,
130 errors: &STRCAT_ERRORS,
131};
132
133fn map_flow(err: RuntimeError) -> RuntimeError {
134 map_control_flow_with_builtin(err, BUILTIN_NAME)
135}
136
137fn strcat_error_with_message(
138 message: impl Into<String>,
139 error: &'static BuiltinErrorDescriptor,
140) -> RuntimeError {
141 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
142 if let Some(identifier) = error.identifier {
143 builder = builder.with_identifier(identifier);
144 }
145 builder.build()
146}
147
148fn strcat_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
149 strcat_error_with_message(error.message, error)
150}
151
152#[derive(Clone, Copy, PartialEq, Eq)]
153enum OperandKind {
154 String,
155 Cell,
156 Char,
157}
158
159#[derive(Clone)]
160struct TextElement {
161 text: String,
162 missing: bool,
163}
164
165#[derive(Clone)]
166struct TextOperand {
167 data: Vec<TextElement>,
168 shape: Vec<usize>,
169 strides: Vec<usize>,
170 kind: OperandKind,
171}
172
173impl TextOperand {
174 fn from_value(value: Value) -> BuiltinResult<Self> {
175 match value {
176 Value::String(s) => Ok(Self::from_string_scalar(s)),
177 Value::StringArray(sa) => Ok(Self::from_string_array(sa)),
178 Value::CharArray(ca) => Self::from_char_array(&ca),
179 Value::Cell(ca) => Self::from_cell_array(&ca),
180 _ => Err(strcat_error(&STRCAT_ERROR_INVALID_INPUT)),
181 }
182 }
183
184 fn from_string_scalar(text: String) -> Self {
185 let missing = is_missing_string(&text);
186 Self {
187 data: vec![TextElement { text, missing }],
188 shape: vec![1, 1],
189 strides: vec![1, 1],
190 kind: OperandKind::String,
191 }
192 }
193
194 fn from_string_array(array: StringArray) -> Self {
195 let missing_flags: Vec<bool> = array.data.iter().map(|s| is_missing_string(s)).collect();
196 let data = array
197 .data
198 .into_iter()
199 .zip(missing_flags)
200 .map(|(text, missing)| TextElement { text, missing })
201 .collect();
202 let shape = array.shape.clone();
203 let strides = compute_strides(&shape);
204 Self {
205 data,
206 shape,
207 strides,
208 kind: OperandKind::String,
209 }
210 }
211
212 fn from_char_array(array: &CharArray) -> BuiltinResult<Self> {
213 let rows = array.rows;
214 let cols = array.cols;
215 let mut elements = Vec::with_capacity(rows);
216 for row in 0..rows {
217 let text = char_row_to_string_slice(&array.data, cols, row);
218 let trimmed = trim_trailing_spaces(&text);
219 elements.push(TextElement {
220 text: trimmed,
221 missing: false,
222 });
223 }
224 let shape = vec![rows, 1];
225 let strides = compute_row_major_strides(&shape);
226 Ok(Self {
227 data: elements,
228 shape,
229 strides,
230 kind: OperandKind::Char,
231 })
232 }
233
234 fn from_cell_array(array: &CellArray) -> BuiltinResult<Self> {
235 let total = array.data.len();
236 let mut elements = Vec::with_capacity(total);
237 for handle in &array.data {
238 let text_element = cell_element_to_text(handle)?;
239 elements.push(text_element);
240 }
241 let shape = array.shape.clone();
242 let strides = compute_row_major_strides(&shape);
243 Ok(Self {
244 data: elements,
245 shape,
246 strides,
247 kind: OperandKind::Cell,
248 })
249 }
250}
251
252#[derive(Clone, Copy, PartialEq, Eq)]
253enum OutputKind {
254 Char,
255 Cell,
256 String,
257}
258
259impl OutputKind {
260 fn update(self, operand_kind: OperandKind) -> Self {
261 match (self, operand_kind) {
262 (_, OperandKind::String) => OutputKind::String,
263 (OutputKind::String, _) => OutputKind::String,
264 (OutputKind::Cell, _) => OutputKind::Cell,
265 (_, OperandKind::Cell) => OutputKind::Cell,
266 _ => self,
267 }
268 }
269}
270
271fn trim_trailing_spaces(text: &str) -> String {
272 text.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
273 .to_string()
274}
275
276fn compute_row_major_strides(shape: &[usize]) -> Vec<usize> {
277 if shape.is_empty() {
278 return Vec::new();
279 }
280 let mut strides = vec![0usize; shape.len()];
281 let mut stride = 1usize;
282 for dim in (0..shape.len()).rev() {
283 strides[dim] = stride;
284 let extent = shape[dim].max(1);
285 stride = stride.saturating_mul(extent);
286 }
287 strides
288}
289
290fn column_major_coords(mut index: usize, shape: &[usize]) -> Vec<usize> {
291 if shape.is_empty() {
292 return Vec::new();
293 }
294 let mut coords = Vec::with_capacity(shape.len());
295 for &extent in shape {
296 if extent == 0 {
297 coords.push(0);
298 } else {
299 coords.push(index % extent);
300 index /= extent;
301 }
302 }
303 coords
304}
305
306fn row_major_index(coords: &[usize], shape: &[usize]) -> usize {
307 if coords.is_empty() {
308 return 0;
309 }
310 let mut index = 0usize;
311 let mut stride = 1usize;
312 for dim in (0..coords.len()).rev() {
313 let extent = shape[dim].max(1);
314 index += coords[dim] * stride;
315 stride = stride.saturating_mul(extent);
316 }
317 index
318}
319
320fn cell_element_to_text(value: &Value) -> BuiltinResult<TextElement> {
321 match value {
322 Value::String(s) => Ok(TextElement {
323 text: s.clone(),
324 missing: is_missing_string(s),
325 }),
326 Value::StringArray(sa) if sa.data.len() == 1 => {
327 let text = sa.data[0].clone();
328 Ok(TextElement {
329 missing: is_missing_string(&text),
330 text,
331 })
332 }
333 Value::CharArray(ca) if ca.rows <= 1 => {
334 let text = if ca.rows == 0 {
335 String::new()
336 } else {
337 char_row_to_string_slice(&ca.data, ca.cols, 0)
338 };
339 Ok(TextElement {
340 text: trim_trailing_spaces(&text),
341 missing: false,
342 })
343 }
344 Value::CharArray(_) => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
345 _ => Err(strcat_error(&STRCAT_ERROR_INVALID_CELL_ELEMENT)),
346 }
347}
348
349#[runtime_builtin(
350 name = "strcat",
351 category = "strings/transform",
352 summary = "Concatenate text inputs element-wise across compatible array sizes.",
353 keywords = "strcat,string concatenation,character arrays,cell arrays",
354 accel = "sink",
355 type_resolver(text_concat_type),
356 descriptor(crate::builtins::strings::transform::strcat::STRCAT_DESCRIPTOR),
357 builtin_path = "crate::builtins::strings::transform::strcat"
358)]
359async fn strcat_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
360 if rest.is_empty() {
361 return Err(strcat_error(&STRCAT_ERROR_NOT_ENOUGH_INPUTS));
362 }
363
364 let mut operands = Vec::with_capacity(rest.len());
365 let mut output_kind = OutputKind::Char;
366
367 for value in rest {
368 let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
369 let operand = TextOperand::from_value(gathered)?;
370 output_kind = output_kind.update(operand.kind);
371 operands.push(operand);
372 }
373
374 let mut output_shape = operands
375 .first()
376 .map(|op| op.shape.clone())
377 .unwrap_or_else(|| vec![1, 1]);
378 for operand in operands.iter().skip(1) {
379 output_shape =
380 broadcast_shapes(BUILTIN_NAME, &output_shape, &operand.shape).map_err(|e| {
381 strcat_error_with_message(
382 format!("{}: {e}", STRCAT_ERROR_SIZE_MISMATCH.message),
383 &STRCAT_ERROR_SIZE_MISMATCH,
384 )
385 })?;
386 }
387
388 let total_len: usize = output_shape.iter().product();
389 let mut concatenated = Vec::with_capacity(total_len);
390
391 for linear in 0..total_len {
392 let mut buffer = String::new();
393 let mut any_missing = false;
394 for operand in &operands {
395 let idx = broadcast_index(linear, &output_shape, &operand.shape, &operand.strides);
396 let element = &operand.data[idx];
397 if output_kind == OutputKind::String && element.missing {
398 any_missing = true;
399 continue;
400 }
401 buffer.push_str(&element.text);
402 }
403 if matches!(output_kind, OutputKind::String) && any_missing {
404 concatenated.push(String::from("<missing>"));
405 } else {
406 concatenated.push(buffer);
407 }
408 }
409
410 match output_kind {
411 OutputKind::String => build_string_output(concatenated, &output_shape),
412 OutputKind::Cell => build_cell_output(concatenated, &output_shape),
413 OutputKind::Char => build_char_output(concatenated),
414 }
415}
416
417fn build_string_output(data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
418 if data.is_empty() {
419 let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
420 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
421 })?;
422 return Ok(Value::StringArray(array));
423 }
424
425 let is_scalar = shape.is_empty() || shape.iter().all(|&dim| dim == 1);
426 if is_scalar {
427 return Ok(Value::String(data[0].clone()));
428 }
429
430 let array = StringArray::new(data, shape.to_vec()).map_err(|e| {
431 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
432 })?;
433 Ok(Value::StringArray(array))
434}
435
436fn build_cell_output(mut data: Vec<String>, shape: &[usize]) -> BuiltinResult<Value> {
437 if data.is_empty() {
438 return make_cell_with_shape(Vec::new(), shape.to_vec()).map_err(|e| {
439 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
440 });
441 }
442 if shape.len() > 1 {
443 let mut reordered = vec![String::new(); data.len()];
444 for (cm_index, text) in data.into_iter().enumerate() {
445 let coords = column_major_coords(cm_index, shape);
446 let rm_index = row_major_index(&coords, shape);
447 reordered[rm_index] = text;
448 }
449 data = reordered;
450 }
451 let mut values = Vec::with_capacity(data.len());
452 for text in data {
453 let char_array = CharArray::new_row(&text);
454 values.push(Value::CharArray(char_array));
455 }
456 make_cell_with_shape(values, shape.to_vec()).map_err(|e| {
457 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
458 })
459}
460
461fn build_char_output(data: Vec<String>) -> BuiltinResult<Value> {
462 let rows = data.len();
463 if rows == 0 {
464 let array = CharArray::new(Vec::new(), 0, 0).map_err(|e| {
465 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
466 })?;
467 return Ok(Value::CharArray(array));
468 }
469
470 let max_cols = data.iter().map(|s| s.chars().count()).max().unwrap_or(0);
471 let mut chars = Vec::with_capacity(rows * max_cols);
472 for text in data {
473 let mut row_chars: Vec<char> = text.chars().collect();
474 if row_chars.len() < max_cols {
475 row_chars.resize(max_cols, ' ');
476 }
477 chars.extend(row_chars.into_iter());
478 }
479 let array = CharArray::new(chars, rows, max_cols).map_err(|e| {
480 strcat_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRCAT_ERROR_INTERNAL)
481 })?;
482 Ok(Value::CharArray(array))
483}
484
485#[cfg(test)]
486pub(crate) mod tests {
487 use super::*;
488 #[cfg(feature = "wgpu")]
489 use crate::builtins::common::test_support;
490 #[cfg(feature = "wgpu")]
491 use runmat_builtins::Tensor;
492 use runmat_builtins::{CellArray, CharArray, IntValue, ResolveContext, StringArray, Type};
493
494 fn run_strcat(rest: Vec<Value>) -> BuiltinResult<Value> {
495 futures::executor::block_on(strcat_builtin(rest))
496 }
497
498 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
499 #[test]
500 fn strcat_string_scalar_concatenation() {
501 let result = run_strcat(vec![
502 Value::String("Run".into()),
503 Value::String("Mat".into()),
504 ])
505 .expect("strcat");
506 assert_eq!(result, Value::String("RunMat".into()));
507 }
508
509 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
510 #[test]
511 fn strcat_string_array_broadcasts_scalar() {
512 let array = StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).unwrap();
513 let result = run_strcat(vec![
514 Value::String("runmat-".into()),
515 Value::StringArray(array),
516 ])
517 .expect("strcat");
518 match result {
519 Value::StringArray(sa) => {
520 assert_eq!(sa.shape, vec![1, 2]);
521 assert_eq!(
522 sa.data,
523 vec![String::from("runmat-core"), String::from("runmat-runtime")]
524 );
525 }
526 other => panic!("expected string array, got {other:?}"),
527 }
528 }
529
530 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
531 #[test]
532 fn strcat_char_array_multiple_rows_concatenates_per_row() {
533 let first = CharArray::new(vec!['A', ' ', 'B', 'C'], 2, 2).expect("char");
534 let second = CharArray::new(vec!['X', 'Y', 'Z', ' '], 2, 2).expect("char");
535 let result =
536 run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
537 match result {
538 Value::CharArray(ca) => {
539 assert_eq!(ca.rows, 2);
540 assert_eq!(ca.cols, 3);
541 let expected: Vec<char> = vec!['A', 'X', 'Y', 'B', 'C', 'Z'];
542 assert_eq!(ca.data, expected);
543 }
544 other => panic!("expected char array, got {other:?}"),
545 }
546 }
547
548 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
549 #[test]
550 fn strcat_char_array_trims_trailing_spaces() {
551 let first = CharArray::new_row("GPU ");
552 let second = CharArray::new_row(" Accel ");
553 let result =
554 run_strcat(vec![Value::CharArray(first), Value::CharArray(second)]).expect("strcat");
555 match result {
556 Value::CharArray(ca) => {
557 assert_eq!(ca.rows, 1);
558 assert_eq!(ca.cols, 9);
559 let expected: Vec<char> = "GPU Accel".chars().collect();
560 assert_eq!(ca.data, expected);
561 }
562 other => panic!("expected char array, got {other:?}"),
563 }
564 }
565
566 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
567 #[test]
568 fn strcat_mixed_char_and_string_returns_string_array() {
569 let prefixes = CharArray::new(vec!['A', ' ', 'B', ' '], 2, 2).expect("char");
570 let suffixes =
571 StringArray::new(vec!["core".into(), "runtime".into()], vec![1, 2]).expect("strings");
572 let result = run_strcat(vec![
573 Value::CharArray(prefixes),
574 Value::StringArray(suffixes),
575 ])
576 .expect("strcat");
577 match result {
578 Value::StringArray(sa) => {
579 assert_eq!(sa.shape, vec![2, 2]);
580 assert_eq!(
581 sa.data,
582 vec![
583 "Acore".to_string(),
584 "Bcore".to_string(),
585 "Aruntime".to_string(),
586 "Bruntime".to_string()
587 ]
588 );
589 }
590 other => panic!("expected string array, got {other:?}"),
591 }
592 }
593
594 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
595 #[test]
596 fn strcat_cell_array_trims_trailing_spaces() {
597 let cell = make_cell_with_shape(
598 vec![
599 Value::CharArray(CharArray::new_row("Run ")),
600 Value::CharArray(CharArray::new_row("Mat ")),
601 ],
602 vec![1, 2],
603 )
604 .expect("cell");
605 let suffix = Value::CharArray(CharArray::new_row("Core "));
606 let result = run_strcat(vec![cell, suffix]).expect("strcat");
607 match result {
608 Value::Cell(ca) => {
609 assert_eq!(ca.shape, vec![1, 2]);
610 let first: &Value = &ca.data[0];
611 let second: &Value = &ca.data[1];
612 match (first, second) {
613 (Value::CharArray(a), Value::CharArray(b)) => {
614 assert_eq!(a.data, "RunCore".chars().collect::<Vec<char>>());
615 assert_eq!(b.data, "MatCore".chars().collect::<Vec<char>>());
616 }
617 other => panic!("unexpected cell contents {other:?}"),
618 }
619 }
620 other => panic!("expected cell array, got {other:?}"),
621 }
622 }
623
624 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
625 #[test]
626 fn strcat_cell_array_two_by_two_preserves_row_major_order() {
627 let cell = make_cell_with_shape(
628 vec![
629 Value::CharArray(CharArray::new_row("Top ")),
630 Value::CharArray(CharArray::new_row("Right ")),
631 Value::CharArray(CharArray::new_row("Bottom ")),
632 Value::CharArray(CharArray::new_row("Last ")),
633 ],
634 vec![2, 2],
635 )
636 .expect("cell");
637 let suffix = Value::CharArray(CharArray::new_row("X"));
638 let result = run_strcat(vec![cell, suffix]).expect("strcat");
639 match result {
640 Value::Cell(ca) => {
641 assert_eq!(ca.shape, vec![2, 2]);
642 let v00 = ca.get(0, 0).expect("cell (0,0)");
643 let v01 = ca.get(0, 1).expect("cell (0,1)");
644 let v10 = ca.get(1, 0).expect("cell (1,0)");
645 let v11 = ca.get(1, 1).expect("cell (1,1)");
646 match (v00, v01, v10, v11) {
647 (
648 Value::CharArray(a),
649 Value::CharArray(b),
650 Value::CharArray(c),
651 Value::CharArray(d),
652 ) => {
653 assert_eq!(a.data, "TopX".chars().collect::<Vec<char>>());
654 assert_eq!(b.data, "RightX".chars().collect::<Vec<char>>());
655 assert_eq!(c.data, "BottomX".chars().collect::<Vec<char>>());
656 assert_eq!(d.data, "LastX".chars().collect::<Vec<char>>());
657 }
658 other => panic!("unexpected cell contents {other:?}"),
659 }
660 }
661 other => panic!("expected cell array, got {other:?}"),
662 }
663 }
664
665 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
666 #[test]
667 fn strcat_missing_strings_propagate() {
668 let array = StringArray::new(
669 vec![String::from("<missing>"), String::from("ready")],
670 vec![1, 2],
671 )
672 .unwrap();
673 let result = run_strcat(vec![
674 Value::String("job-".into()),
675 Value::StringArray(array),
676 ])
677 .expect("strcat");
678 match result {
679 Value::StringArray(sa) => {
680 assert_eq!(sa.data[0], "<missing>");
681 assert_eq!(sa.data[1], "job-ready");
682 }
683 other => panic!("expected string array, got {other:?}"),
684 }
685 }
686
687 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
688 #[test]
689 fn strcat_empty_dimension_returns_empty_array() {
690 let empty = StringArray::new(Vec::<String>::new(), vec![0, 2]).expect("string array");
691 let result = run_strcat(vec![
692 Value::StringArray(empty),
693 Value::String("prefix".into()),
694 ])
695 .expect("strcat");
696 match result {
697 Value::StringArray(sa) => {
698 assert_eq!(sa.shape, vec![0, 2]);
699 assert!(sa.data.is_empty());
700 }
701 other => panic!("expected empty string array, got {other:?}"),
702 }
703 }
704
705 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
706 #[test]
707 fn strcat_errors_on_invalid_input_type() {
708 let err = run_strcat(vec![Value::Int(IntValue::I32(4))]).expect_err("expected error");
709 assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
710 assert_eq!(
711 err.identifier.as_deref(),
712 STRCAT_ERROR_INVALID_INPUT.identifier
713 );
714 }
715
716 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
717 #[test]
718 fn strcat_errors_on_mismatched_sizes() {
719 let left = CharArray::new(vec!['A', 'B'], 2, 1).expect("char");
720 let right = CharArray::new(vec!['C', 'D', 'E'], 3, 1).expect("char");
721 let err = run_strcat(vec![Value::CharArray(left), Value::CharArray(right)])
722 .expect_err("expected broadcast error");
723 assert!(err
724 .to_string()
725 .starts_with(STRCAT_ERROR_SIZE_MISMATCH.message));
726 assert_eq!(
727 err.identifier.as_deref(),
728 STRCAT_ERROR_SIZE_MISMATCH.identifier
729 );
730 }
731
732 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
733 #[test]
734 fn strcat_errors_on_invalid_cell_element() {
735 let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
736 let err = run_strcat(vec![Value::Cell(cell)]).expect_err("expected error");
737 assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_CELL_ELEMENT.message);
738 assert_eq!(
739 err.identifier.as_deref(),
740 STRCAT_ERROR_INVALID_CELL_ELEMENT.identifier
741 );
742 }
743
744 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
745 #[test]
746 fn strcat_errors_on_empty_argument_list() {
747 let err = run_strcat(Vec::new()).expect_err("expected error");
748 assert_eq!(err.to_string(), STRCAT_ERROR_NOT_ENOUGH_INPUTS.message);
749 assert_eq!(
750 err.identifier.as_deref(),
751 STRCAT_ERROR_NOT_ENOUGH_INPUTS.identifier
752 );
753 }
754
755 #[cfg(feature = "wgpu")]
756 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
757 #[test]
758 fn strcat_gpu_operand_still_errors_on_type() {
759 test_support::with_test_provider(|provider| {
760 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor");
761 let view = runmat_accelerate_api::HostTensorView {
762 data: &tensor.data,
763 shape: &tensor.shape,
764 };
765 let handle = provider.upload(&view).expect("upload");
766 let err = run_strcat(vec![Value::GpuTensor(handle)]).expect_err("expected error");
767 assert_eq!(err.to_string(), STRCAT_ERROR_INVALID_INPUT.message);
768 });
769 }
770
771 #[test]
772 fn strcat_type_concatenates_text() {
773 assert_eq!(
774 text_concat_type(&[Type::String], &ResolveContext::new(Vec::new())),
775 Type::String
776 );
777 }
778}