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