1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6 CharArray, ComplexTensor, IntValue, LogicalArray, SparseTensor, StringArray, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::format::{complex_to_string, format_variadic, number_to_string};
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::common::tensor;
17use crate::builtins::strings::type_resolvers::string_array_type;
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19
20const STRING_OUTPUT_S: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
21 name: "S",
22 ty: BuiltinParamType::Any,
23 arity: BuiltinParamArity::Required,
24 default: None,
25 description: "String scalar/array result.",
26}];
27
28const STRING_INPUTS_VALUE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
29 name: "X",
30 ty: BuiltinParamType::Any,
31 arity: BuiltinParamArity::Required,
32 default: None,
33 description: "Input value to convert to string array.",
34}];
35
36const STRING_INPUTS_VALUE_ENCODING: [BuiltinParamDescriptor; 2] = [
37 BuiltinParamDescriptor {
38 name: "X",
39 ty: BuiltinParamType::Any,
40 arity: BuiltinParamArity::Required,
41 default: None,
42 description: "Input value to convert to string array.",
43 },
44 BuiltinParamDescriptor {
45 name: "encoding",
46 ty: BuiltinParamType::StringScalar,
47 arity: BuiltinParamArity::Optional,
48 default: Some("\"UTF-8\""),
49 description: "Character encoding (UTF-8 aliases supported).",
50 },
51];
52
53const STRING_INPUTS_FORMAT: [BuiltinParamDescriptor; 2] = [
54 BuiltinParamDescriptor {
55 name: "formatSpec",
56 ty: BuiltinParamType::Any,
57 arity: BuiltinParamArity::Required,
58 default: None,
59 description: "Format specification text/cell/string array.",
60 },
61 BuiltinParamDescriptor {
62 name: "A",
63 ty: BuiltinParamType::Any,
64 arity: BuiltinParamArity::Variadic,
65 default: None,
66 description: "Formatting data arguments.",
67 },
68];
69
70const STRING_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
71 BuiltinSignatureDescriptor {
72 label: "S = string(X)",
73 inputs: &STRING_INPUTS_VALUE,
74 outputs: &STRING_OUTPUT_S,
75 },
76 BuiltinSignatureDescriptor {
77 label: "S = string(X, encoding)",
78 inputs: &STRING_INPUTS_VALUE_ENCODING,
79 outputs: &STRING_OUTPUT_S,
80 },
81 BuiltinSignatureDescriptor {
82 label: "S = string(formatSpec, A...)",
83 inputs: &STRING_INPUTS_FORMAT,
84 outputs: &STRING_OUTPUT_S,
85 },
86];
87
88const STRING_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
89 code: "RM.STRING.INVALID_INPUT",
90 identifier: Some("RunMat:string:InvalidInput"),
91 when: "Input conversion/formatting/encoding constraints are violated.",
92 message: "string: invalid input",
93};
94
95const STRING_ERRORS: [BuiltinErrorDescriptor; 1] = [STRING_ERROR_INVALID_INPUT];
96const STRING_SPARSE_DENSE_ELEMENT_LIMIT: usize = 10_000_000;
97
98pub const STRING_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
99 signatures: &STRING_SIGNATURES,
100 output_mode: BuiltinOutputMode::Fixed,
101 completion_policy: BuiltinCompletionPolicy::Public,
102 errors: &STRING_ERRORS,
103};
104
105#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::string")]
106pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
107 name: "string",
108 op_kind: GpuOpKind::Custom("conversion"),
109 supported_precisions: &[],
110 broadcast: BroadcastSemantics::None,
111 provider_hooks: &[],
112 constant_strategy: ConstantStrategy::InlineLiteral,
113 residency: ResidencyPolicy::GatherImmediately,
114 nan_mode: ReductionNaN::Include,
115 two_pass_threshold: None,
116 workgroup_size: None,
117 accepts_nan_mode: false,
118 notes: "Always converts on the CPU; GPU tensors are gathered to host memory before conversion.",
119};
120
121#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::string")]
122pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
123 name: "string",
124 shape: ShapeRequirements::Any,
125 constant_strategy: ConstantStrategy::InlineLiteral,
126 elementwise: None,
127 reduction: None,
128 emits_nan: false,
129 notes:
130 "Conversion builtin; not eligible for fusion and always materialises host string arrays.",
131};
132
133#[runtime_builtin(
134 name = "string",
135 category = "strings/core",
136 summary = "Convert numeric, logical, and text inputs into string arrays.",
137 keywords = "string,convert,text,char,gpu",
138 accel = "sink",
139 type_resolver(string_array_type),
140 descriptor(crate::builtins::strings::core::string::STRING_DESCRIPTOR),
141 builtin_path = "crate::builtins::strings::core::string"
142)]
143async fn string_builtin(value: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
144 if rest.is_empty() {
145 let gathered = gather_if_needed_async(&value)
146 .await
147 .map_err(|flow| remap_string_flow(flow))?;
148 let array = convert_to_string_array(gathered, StringEncoding::Utf8).await?;
149 return Ok(Value::StringArray(array));
150 }
151
152 let mut args = rest;
153 let format_value = gather_if_needed_async(&value)
154 .await
155 .map_err(|flow| remap_string_flow(flow))?;
156
157 if args.len() == 1 {
158 let arg = args.pop().unwrap();
159 let gathered_arg = gather_if_needed_async(&arg)
160 .await
161 .map_err(|flow| remap_string_flow(flow))?;
162 if let Some(encoding) = try_encoding_argument(&format_value, &gathered_arg)? {
163 let array = convert_to_string_array(format_value, encoding).await?;
164 return Ok(Value::StringArray(array));
165 }
166 let formatted = format_from_spec(format_value, vec![gathered_arg]).await?;
167 return Ok(Value::StringArray(formatted));
168 }
169
170 let mut gathered_args = Vec::with_capacity(args.len());
171 for arg in args {
172 gathered_args.push(
173 gather_if_needed_async(&arg)
174 .await
175 .map_err(|flow| remap_string_flow(flow))?,
176 );
177 }
178 let formatted = format_from_spec(format_value, gathered_args).await?;
179 Ok(Value::StringArray(formatted))
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
183enum StringEncoding {
184 Utf8,
185}
186
187fn try_encoding_argument(
188 first: &Value,
189 candidate: &Value,
190) -> BuiltinResult<Option<StringEncoding>> {
191 if !matches!(
192 first,
193 Value::CharArray(_) | Value::String(_) | Value::StringArray(_) | Value::Cell(_)
194 ) {
195 return Ok(None);
196 }
197 if has_format_placeholders(first) {
198 return Ok(None);
199 }
200 if let Value::Cell(cell) = first {
201 if !cell_contains_only_text_scalars(cell) {
202 return Ok(None);
203 }
204 }
205 let Some(text) = value_to_scalar_text(candidate) else {
206 return Ok(None);
207 };
208 parse_encoding_text(&text).map(Some)
209}
210
211fn parse_encoding_text(raw: &str) -> BuiltinResult<StringEncoding> {
212 let trimmed = raw.trim();
213 let lowered = trimmed.to_ascii_lowercase();
214 match lowered.as_str() {
215 "utf-8" | "utf8" | "unicode" | "system" => Ok(StringEncoding::Utf8),
216 _ => Err(string_flow(format!(
217 "string: unsupported character encoding '{trimmed}'; only UTF-8 is available"
218 ))),
219 }
220}
221
222fn cell_contains_only_text_scalars(cell: &runmat_builtins::CellArray) -> bool {
223 cell.data.iter().all(|ptr| match &ptr {
224 Value::String(_) => true,
225 Value::StringArray(sa) => sa.data.len() <= 1,
226 Value::CharArray(ca) => ca.rows <= 1,
227 _ => false,
228 })
229}
230
231fn text_has_format_placeholder(text: &str) -> bool {
232 let mut chars = text.chars().peekable();
233 while let Some(ch) = chars.next() {
234 if ch != '%' {
235 continue;
236 }
237 if let Some('%') = chars.peek() {
238 chars.next();
239 continue;
240 }
241 while matches!(chars.peek(), Some(flag) if matches!(flag, '+' | '-' | '0' | '#')) {
242 chars.next();
243 }
244 while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
245 chars.next();
246 }
247 if let Some('.') = chars.peek() {
248 chars.next();
249 while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
250 chars.next();
251 }
252 }
253 if let Some(conv) = chars.peek() {
254 if conv.is_ascii_alphabetic() {
255 return true;
256 }
257 }
258 }
259 false
260}
261
262fn has_format_placeholders(value: &Value) -> bool {
263 match value {
264 Value::String(s) => text_has_format_placeholder(s),
265 Value::StringArray(sa) => sa.data.iter().any(|s| text_has_format_placeholder(s)),
266 Value::CharArray(ca) => {
267 for row in 0..ca.rows {
268 let mut row_str = String::with_capacity(ca.cols);
269 for col in 0..ca.cols {
270 row_str.push(ca.data[row * ca.cols + col]);
271 }
272 if text_has_format_placeholder(&row_str) {
273 return true;
274 }
275 }
276 false
277 }
278 Value::Cell(cell) => {
279 for ptr in &cell.data {
280 let element = (ptr).clone();
281 if has_format_placeholders(&element) {
282 return true;
283 }
284 }
285 false
286 }
287 _ => false,
288 }
289}
290
291pub(crate) struct FormatSpecData {
292 pub(crate) specs: Vec<String>,
293 pub(crate) shape: Vec<usize>,
294}
295
296struct ArgumentData {
297 values: Vec<Value>,
298 shape: Vec<usize>,
299}
300
301fn string_flow(message: impl Into<String>) -> RuntimeError {
302 string_error_with_detail(&STRING_ERROR_INVALID_INPUT, message)
303}
304
305fn string_error_with_detail(
306 error: &'static BuiltinErrorDescriptor,
307 detail: impl Into<String>,
308) -> RuntimeError {
309 let detail = detail.into();
310 let message = if detail.starts_with("string:") {
311 detail
312 } else {
313 format!("{}: {detail}", error.message)
314 };
315 let mut builder = build_runtime_error(message).with_builtin("string");
316 if let Some(identifier) = error.identifier {
317 builder = builder.with_identifier(identifier);
318 }
319 builder.build()
320}
321
322fn remap_string_flow(err: RuntimeError) -> RuntimeError {
323 map_control_flow_with_builtin(err, "string")
324}
325
326pub(crate) async fn format_from_spec(
327 format_value: Value,
328 args: Vec<Value>,
329) -> crate::BuiltinResult<StringArray> {
330 let spec = extract_format_spec(format_value).await?;
331 let mut arguments = Vec::with_capacity(args.len());
332 for arg in args {
333 arguments.push(extract_argument_data(arg).await?);
334 }
335
336 let (target_len, mut target_shape) = resolve_target_shape(&spec, &arguments)?;
337
338 if target_len == 0 {
339 let shape = if target_shape.is_empty() {
340 if spec.shape.is_empty() {
341 vec![0, 0]
342 } else {
343 spec.shape.clone()
344 }
345 } else {
346 target_shape
347 };
348 return StringArray::new(Vec::new(), shape)
349 .map_err(|e| string_flow(format!("string: {e}")));
350 }
351
352 let spec_len = spec.specs.len();
353 if spec_len == 0 {
354 return Err(string_flow(
355 "string: formatSpec must contain at least one element when formatting with data",
356 ));
357 }
358
359 for arg in &arguments {
360 if target_len > 0 && arg.values.is_empty() {
361 return Err(string_flow(
362 "string: format data arguments must be scalars or match formatSpec size",
363 ));
364 }
365 }
366
367 let mut output = Vec::with_capacity(target_len);
368 for idx in 0..target_len {
369 let spec_idx = if spec_len == 1 { 0 } else { idx };
370 let spec_str = &spec.specs[spec_idx];
371 let mut per_call = Vec::with_capacity(arguments.len());
372 for arg in &arguments {
373 let value =
374 match arg.values.len() {
375 0 => continue,
376 1 => arg.values[0].clone(),
377 len if len == target_len => arg.values[idx].clone(),
378 _ => return Err(string_flow(
379 "string: format data arguments must be scalars or match formatSpec size",
380 )),
381 };
382 per_call.push(value);
383 }
384 let formatted =
385 format_variadic(spec_str, &per_call).map_err(|flow| remap_string_flow(flow))?;
386 output.push(formatted);
387 }
388
389 if target_shape.is_empty() {
390 target_shape = if spec_len > 1 {
391 spec.shape.clone()
392 } else {
393 vec![target_len, 1]
394 };
395 }
396
397 if tensor::element_count(&target_shape) != target_len {
398 target_shape = vec![target_len, 1];
399 }
400
401 StringArray::new(output, target_shape).map_err(|e| string_flow(format!("string: {e}")))
402}
403
404fn resolve_target_shape(
405 spec: &FormatSpecData,
406 args: &[ArgumentData],
407) -> BuiltinResult<(usize, Vec<usize>)> {
408 let mut target_len = spec.specs.len();
409 let mut target_shape = if target_len > 1 || (target_len == 1 && !spec.shape.is_empty()) {
410 spec.shape.clone()
411 } else {
412 Vec::new()
413 };
414
415 for arg in args {
416 let len = arg.values.len();
417 if len == 0 {
418 continue;
419 }
420 if target_len == 0 {
421 target_len = len;
422 target_shape = arg.shape.clone();
423 continue;
424 }
425 if len == 1 {
426 continue;
427 }
428 if target_len == 1 {
429 target_len = len;
430 target_shape = arg.shape.clone();
431 continue;
432 }
433 if len != target_len {
434 return Err(string_flow(
435 "string: format data arguments must be scalars or match formatSpec size",
436 ));
437 }
438 if target_shape.is_empty() && len > 1 {
439 target_shape = arg.shape.clone();
440 }
441 }
442
443 if target_len == 0 {
444 let shape = if spec.shape.is_empty() {
445 vec![0, 0]
446 } else {
447 spec.shape.clone()
448 };
449 return Ok((0, shape));
450 }
451
452 if target_shape.is_empty() {
453 target_shape = if spec.shape.is_empty() {
454 vec![target_len, 1]
455 } else {
456 spec.shape.clone()
457 };
458 if spec.specs.len() == 1 && tensor::element_count(&target_shape) != target_len {
459 target_shape = vec![target_len, 1];
460 }
461 }
462
463 if tensor::element_count(&target_shape) != target_len {
464 target_shape = vec![target_len, 1];
465 }
466
467 Ok((target_len, target_shape))
468}
469
470pub(crate) async fn extract_format_spec(value: Value) -> BuiltinResult<FormatSpecData> {
471 match value {
472 Value::String(s) => Ok(FormatSpecData {
473 specs: vec![s],
474 shape: vec![1, 1],
475 }),
476 Value::StringArray(sa) => Ok(FormatSpecData {
477 specs: sa.data.clone(),
478 shape: sa.shape.clone(),
479 }),
480 Value::CharArray(ca) => {
481 let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
482 Ok(FormatSpecData {
483 specs: array.data,
484 shape: array.shape,
485 })
486 }
487 Value::Cell(cell) => {
488 let mut specs = Vec::with_capacity(cell.data.len());
489 for col in 0..cell.cols {
490 for row in 0..cell.rows {
491 let idx = row * cell.cols + col;
492 let element = &cell.data[idx];
493 let value = (element).clone();
494 let gathered = gather_if_needed_async(&value)
495 .await
496 .map_err(|flow| remap_string_flow(flow))?;
497 let text = value_to_scalar_text(&gathered).ok_or_else(|| {
498 string_flow("string: formatSpec cell elements must be text scalars")
499 })?;
500 specs.push(text);
501 }
502 }
503 Ok(FormatSpecData {
504 specs,
505 shape: vec![cell.rows, cell.cols],
506 })
507 }
508 _ => Err(string_flow(
509 "string: formatSpec must be text (string, char, or cellstr)",
510 )),
511 }
512}
513
514#[async_recursion::async_recursion(?Send)]
515async fn extract_argument_data(value: Value) -> BuiltinResult<ArgumentData> {
516 match value {
517 Value::String(s) => Ok(ArgumentData {
518 values: vec![Value::String(s)],
519 shape: vec![1, 1],
520 }),
521 Value::StringArray(sa) => Ok(ArgumentData {
522 values: sa.data.into_iter().map(Value::String).collect(),
523 shape: sa.shape,
524 }),
525 Value::CharArray(ca) => {
526 let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
527 Ok(ArgumentData {
528 values: array.data.into_iter().map(Value::String).collect(),
529 shape: array.shape,
530 })
531 }
532 Value::Symbolic(expr) => Ok(ArgumentData {
533 values: vec![Value::String(expr.to_string())],
534 shape: vec![1, 1],
535 }),
536 Value::SymbolicArray(array) => Ok(ArgumentData {
537 values: array
538 .data
539 .into_iter()
540 .map(|expr| Value::String(expr.to_string()))
541 .collect(),
542 shape: array.shape,
543 }),
544 Value::Num(n) => Ok(ArgumentData {
545 values: vec![Value::Num(n)],
546 shape: vec![1, 1],
547 }),
548 Value::Int(i) => Ok(ArgumentData {
549 values: vec![Value::Int(i)],
550 shape: vec![1, 1],
551 }),
552 Value::Bool(b) => Ok(ArgumentData {
553 values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
554 shape: vec![1, 1],
555 }),
556 Value::Tensor(t) => Ok(ArgumentData {
557 values: t.data.into_iter().map(Value::Num).collect(),
558 shape: t.shape,
559 }),
560 Value::SparseTensor(s) => {
561 ensure_sparse_dense_conversion(&s, "format argument")?;
562 let dense = s.to_dense().map_err(string_flow)?;
563 Ok(ArgumentData {
564 values: dense.data.into_iter().map(Value::Num).collect(),
565 shape: dense.shape,
566 })
567 }
568 Value::Complex(re, im) => Ok(ArgumentData {
569 values: vec![Value::String(complex_to_string(re, im))],
570 shape: vec![1, 1],
571 }),
572 Value::ComplexTensor(t) => Ok(ArgumentData {
573 values: t
574 .data
575 .into_iter()
576 .map(|(re, im)| Value::String(complex_to_string(re, im)))
577 .collect(),
578 shape: t.shape,
579 }),
580 Value::LogicalArray(la) => Ok(ArgumentData {
581 values: la
582 .data
583 .into_iter()
584 .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
585 .collect(),
586 shape: la.shape,
587 }),
588 Value::Cell(cell) => {
589 let mut values = Vec::with_capacity(cell.data.len());
590 for col in 0..cell.cols {
591 for row in 0..cell.rows {
592 let idx = row * cell.cols + col;
593 let element = &cell.data[idx];
594 let value = (element).clone();
595 let gathered = gather_if_needed_async(&value)
596 .await
597 .map_err(|flow| remap_string_flow(flow))?;
598 let value = match gathered {
599 Value::String(s) => Value::String(s),
600 Value::StringArray(sa) if sa.data.len() == 1 => {
601 Value::String(sa.data[0].clone())
602 }
603 Value::CharArray(ca) => {
604 if ca.rows != 1 {
605 return Err(string_flow(
606 "string: cell format arguments must contain char row vectors",
607 ));
608 }
609 let mut row_str = String::with_capacity(ca.cols);
610 for ch in ca.data {
611 row_str.push(ch);
612 }
613 Value::String(row_str)
614 }
615 Value::Num(n) => Value::Num(n),
616 Value::Int(i) => Value::Int(i),
617 Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
618 Value::Tensor(t) => {
619 if t.data.len() != 1 {
620 return Err(string_flow(
621 "string: cell format arguments must contain scalar values",
622 ));
623 }
624 Value::Num(t.data[0])
625 }
626 Value::LogicalArray(la) => {
627 if la.data.len() != 1 {
628 return Err(string_flow(
629 "string: cell format arguments must contain scalar values",
630 ));
631 }
632 Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
633 }
634 Value::Complex(re, im) => Value::String(complex_to_string(re, im)),
635 Value::Symbolic(expr) => Value::String(expr.to_string()),
636 Value::ComplexTensor(t) => {
637 if t.data.len() != 1 {
638 return Err(string_flow(
639 "string: cell format arguments must contain scalar values",
640 ));
641 }
642 let (re, im) = t.data[0];
643 Value::String(complex_to_string(re, im))
644 }
645 other => {
646 return Err(string_flow(format!(
647 "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
648 )))
649 }
650 };
651 values.push(value);
652 }
653 }
654 Ok(ArgumentData {
655 values,
656 shape: vec![cell.rows, cell.cols],
657 })
658 }
659 Value::GpuTensor(handle) => {
660 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
661 .await
662 .map_err(|flow| remap_string_flow(flow))?;
663 extract_argument_data(gathered).await
664 }
665 Value::MException(_)
666 | Value::HandleObject(_)
667 | Value::Object(_)
668 | Value::Listener(_)
669 | Value::Struct(_)
670 | Value::OutputList(_) => Err(string_flow("string: unsupported format argument type")),
671 Value::FunctionHandle(_)
672 | Value::ExternalFunctionHandle(_)
673 | Value::MethodFunctionHandle(_)
674 | Value::BoundFunctionHandle { .. }
675 | Value::Closure(_)
676 | Value::ClassRef(_) => Err(string_flow("string: unsupported format argument type")),
677 }
678}
679
680#[async_recursion::async_recursion(?Send)]
681async fn convert_to_string_array(
682 value: Value,
683 encoding: StringEncoding,
684) -> BuiltinResult<StringArray> {
685 if let Some(array) = crate::builtins::datetime::datetime_string_array(&value)
686 .map_err(|err| string_flow(err.message().to_string()))?
687 {
688 return Ok(array);
689 }
690 if let Some(array) = crate::builtins::duration::duration_string_array(&value)
691 .map_err(|err| string_flow(err.message().to_string()))?
692 {
693 return Ok(array);
694 }
695 match value {
696 Value::String(s) => string_scalar(s),
697 Value::StringArray(sa) => Ok(sa),
698 Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
699 Value::Symbolic(expr) => string_scalar(expr.to_string()),
700 Value::SymbolicArray(array) => StringArray::new(
701 array.data.into_iter().map(|expr| expr.to_string()).collect(),
702 array.shape,
703 )
704 .map_err(|e| string_flow(format!("string: {e}"))),
705 Value::Tensor(tensor) => tensor_to_string_array(tensor),
706 Value::SparseTensor(sparse) => {
707 ensure_sparse_dense_conversion(&sparse, "dense string array")?;
708 tensor_to_string_array(sparse.to_dense().map_err(string_flow)?)
709 }
710 Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
711 Value::LogicalArray(logical) => logical_array_to_string_array(logical),
712 Value::Cell(cell) => cell_array_to_string_array(cell, encoding).await,
713 Value::Num(n) => string_scalar(number_to_string(n)),
714 Value::Int(i) => string_scalar(int_value_to_string(&i)),
715 Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
716 Value::Complex(re, im) => string_scalar(complex_to_string(re, im)),
717 Value::GpuTensor(handle) => {
718 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
720 .await
721 .map_err(|flow| remap_string_flow(flow))?;
722 convert_to_string_array(gathered, encoding).await
723 }
724 Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(string_flow(
725 "string: unsupported conversion from handle-based objects. Use class-specific formatters.",
726 )),
727 Value::Struct(_) => Err(string_flow(
728 "string: structs are not supported for automatic conversion",
729 )),
730 Value::FunctionHandle(_) | Value::ExternalFunctionHandle(_) | Value::MethodFunctionHandle(_) | Value::BoundFunctionHandle { .. }
731 | Value::Closure(_)
732 | Value::ClassRef(_)
733 | Value::MException(_)
734 | Value::OutputList(_) => Err(
735 string_flow("string: unsupported conversion for function or exception handles"),
736 ),
737 }
738}
739
740fn string_scalar<S: Into<String>>(text: S) -> BuiltinResult<StringArray> {
741 StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| string_flow(format!("string: {e}")))
742}
743
744fn value_to_scalar_text(value: &Value) -> Option<String> {
745 match value {
746 Value::String(s) => Some(s.clone()),
747 Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
748 Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
749 _ => None,
750 }
751}
752
753fn char_array_to_string_array(
754 array: CharArray,
755 _encoding: StringEncoding,
756) -> BuiltinResult<StringArray> {
757 let mut rows: Vec<String> = Vec::with_capacity(array.rows);
758 for r in 0..array.rows {
759 let mut row = String::with_capacity(array.cols);
760 for c in 0..array.cols {
761 row.push(array.data[r * array.cols + c]);
762 }
763 rows.push(row);
764 }
765 let shape = if array.rows == 0 {
766 vec![0, 1]
767 } else {
768 vec![array.rows, 1]
769 };
770 StringArray::new(rows, shape).map_err(|e| string_flow(format!("string: {e}")))
771}
772
773fn tensor_to_string_array(tensor: Tensor) -> BuiltinResult<StringArray> {
774 let mut strings = Vec::with_capacity(tensor.data.len());
775 for &value in &tensor.data {
776 strings.push(number_to_string(value));
777 }
778 StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
779}
780
781fn complex_tensor_to_string_array(tensor: ComplexTensor) -> BuiltinResult<StringArray> {
782 let mut strings = Vec::with_capacity(tensor.data.len());
783 for &(re, im) in &tensor.data {
784 strings.push(complex_to_string(re, im));
785 }
786 StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
787}
788
789fn logical_array_to_string_array(logical: LogicalArray) -> BuiltinResult<StringArray> {
790 let mut strings = Vec::with_capacity(logical.data.len());
791 for &byte in &logical.data {
792 strings.push(bool_to_string(byte != 0).to_string());
793 }
794 StringArray::new(strings, logical.shape).map_err(|e| string_flow(format!("string: {e}")))
795}
796
797async fn cell_array_to_string_array(
798 cell: runmat_builtins::CellArray,
799 _encoding: StringEncoding,
800) -> BuiltinResult<StringArray> {
801 let mut strings = Vec::with_capacity(cell.data.len());
802 for col in 0..cell.cols {
803 for row in 0..cell.rows {
804 let idx = row * cell.cols + col;
805 let element = &cell.data[idx];
806 let value = (element).clone();
807 let gathered = gather_if_needed_async(&value)
808 .await
809 .map_err(|flow| remap_string_flow(flow))?;
810 strings.push(cell_element_to_string(&gathered)?);
811 }
812 }
813 StringArray::new(strings, vec![cell.rows, cell.cols])
814 .map_err(|e| string_flow(format!("string: {e}")))
815}
816
817fn cell_element_to_string(value: &Value) -> BuiltinResult<String> {
818 if let Some(array) = crate::builtins::datetime::datetime_string_array(value)
819 .map_err(|err| string_flow(err.message().to_string()))?
820 {
821 if array.data.len() == 1 {
822 return Ok(array.data[0].clone());
823 }
824 return Err(string_flow("string: cell datetime values must be scalar"));
825 }
826 if let Some(array) = crate::builtins::duration::duration_string_array(value)
827 .map_err(|err| string_flow(err.message().to_string()))?
828 {
829 if array.data.len() == 1 {
830 return Ok(array.data[0].clone());
831 }
832 return Err(string_flow("string: cell duration values must be scalar"));
833 }
834 match value {
835 Value::String(s) => Ok(s.clone()),
836 Value::StringArray(sa) => {
837 if sa.data.len() == 1 {
838 Ok(sa.data[0].clone())
839 } else {
840 Err(string_flow(
841 "string: cell elements must contain string scalars, not string arrays",
842 ))
843 }
844 }
845 Value::CharArray(ca) => {
846 if ca.rows == 1 {
847 Ok(ca.data.iter().collect())
848 } else {
849 Err(string_flow(
850 "string: cell character arrays must be row vectors",
851 ))
852 }
853 }
854 Value::Num(n) => Ok(number_to_string(*n)),
855 Value::Int(i) => Ok(int_value_to_string(i)),
856 Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
857 Value::LogicalArray(array) => {
858 if array.data.len() == 1 {
859 Ok(bool_to_string(array.data[0] != 0).to_string())
860 } else {
861 Err(string_flow("string: cell logical values must be scalar"))
862 }
863 }
864 Value::Tensor(t) => {
865 if t.data.len() == 1 {
866 Ok(number_to_string(t.data[0]))
867 } else {
868 Err(string_flow("string: cell numeric values must be scalar"))
869 }
870 }
871 Value::Complex(re, im) => Ok(complex_to_string(*re, *im)),
872 Value::ComplexTensor(t) => {
873 if t.data.len() == 1 {
874 let (re, im) = t.data[0];
875 Ok(complex_to_string(re, im))
876 } else {
877 Err(string_flow("string: cell complex values must be scalar"))
878 }
879 }
880 other => Err(string_flow(format!(
881 "string: unsupported cell element type {:?}; expected text or scalar values",
882 other
883 ))),
884 }
885}
886
887fn ensure_sparse_dense_conversion(sparse: &SparseTensor, target: &str) -> BuiltinResult<()> {
888 let total_elements = sparse
889 .rows
890 .checked_mul(sparse.cols)
891 .ok_or_else(|| string_flow("string: sparse matrix dimensions overflow"))?;
892 if total_elements > STRING_SPARSE_DENSE_ELEMENT_LIMIT {
893 return Err(string_flow(format!(
894 "string: cannot convert sparse tensor {}x{} with {} stored entries to {target} ({} elements exceeds safe threshold)",
895 sparse.rows,
896 sparse.cols,
897 sparse.nnz(),
898 total_elements
899 )));
900 }
901 Ok(())
902}
903
904fn bool_to_string(value: bool) -> &'static str {
905 if value {
906 "true"
907 } else {
908 "false"
909 }
910}
911
912fn int_value_to_string(value: &IntValue) -> String {
913 match value {
914 IntValue::I8(v) => v.to_string(),
915 IntValue::I16(v) => v.to_string(),
916 IntValue::I32(v) => v.to_string(),
917 IntValue::I64(v) => v.to_string(),
918 IntValue::U8(v) => v.to_string(),
919 IntValue::U16(v) => v.to_string(),
920 IntValue::U32(v) => v.to_string(),
921 IntValue::U64(v) => v.to_string(),
922 }
923}
924
925#[cfg(test)]
926pub(crate) mod tests {
927 use super::*;
928 use crate::builtins::common::test_support;
929 use runmat_builtins::{CellArray, IntValue, ResolveContext, StringArray, StructValue, Type};
930
931 fn string_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
932 futures::executor::block_on(super::string_builtin(value, rest))
933 }
934
935 fn error_message(err: crate::RuntimeError) -> String {
936 err.message().to_string()
937 }
938
939 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
940 #[test]
941 fn string_from_numeric_scalar() {
942 let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
943 match out {
944 Value::StringArray(sa) => {
945 assert_eq!(sa.shape, vec![1, 1]);
946 assert_eq!(sa.data, vec!["42".to_string()]);
947 }
948 other => panic!("expected string array, got {other:?}"),
949 }
950 }
951
952 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
953 #[test]
954 fn string_from_numeric_tensor_preserves_shape() {
955 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
956 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
957 match out {
958 Value::StringArray(sa) => {
959 assert_eq!(sa.shape, vec![2, 2]);
960 assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
961 }
962 other => panic!("expected string array, got {other:?}"),
963 }
964 }
965
966 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
967 #[test]
968 fn string_from_logical_array_uses_boolean_text() {
969 let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
970 let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
971 match out {
972 Value::StringArray(sa) => {
973 assert_eq!(sa.shape, vec![1, 3]);
974 assert_eq!(sa.data, vec!["true", "false", "true"]);
975 }
976 other => panic!("expected string array, got {other:?}"),
977 }
978 }
979
980 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
981 #[test]
982 fn string_from_char_array_produces_column_vector() {
983 let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
984 let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
985 match out {
986 Value::StringArray(sa) => {
987 assert_eq!(sa.shape, vec![1, 1]);
988 assert_eq!(sa.data, vec!["abc"]);
989 }
990 other => panic!("expected string array, got {other:?}"),
991 }
992 }
993
994 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
995 #[test]
996 fn string_from_cell_array() {
997 let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
998 .expect("cell array");
999 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1000 match out {
1001 Value::StringArray(sa) => {
1002 assert_eq!(sa.shape, vec![1, 2]);
1003 assert_eq!(sa.data, vec!["true", "7"]);
1004 }
1005 other => panic!("expected string array, got {other:?}"),
1006 }
1007 }
1008
1009 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1010 #[test]
1011 fn string_from_cell_array_column_major() {
1012 let cell = CellArray::new(
1013 vec![
1014 Value::Int(IntValue::I32(1)),
1015 Value::Int(IntValue::I32(2)),
1016 Value::Int(IntValue::I32(3)),
1017 Value::Int(IntValue::I32(4)),
1018 ],
1019 2,
1020 2,
1021 )
1022 .expect("cell array");
1023 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1024 match out {
1025 Value::StringArray(sa) => {
1026 assert_eq!(sa.shape, vec![2, 2]);
1027 assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
1028 }
1029 other => panic!("expected string array, got {other:?}"),
1030 }
1031 }
1032
1033 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1034 #[test]
1035 fn string_cell_element_requires_scalar_numeric() {
1036 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1037 let cell =
1038 CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
1039 let err = error_message(string_builtin(Value::Cell(cell), Vec::new()).unwrap_err());
1040 assert!(err.contains("cell numeric values must be scalar"));
1041 }
1042
1043 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1044 #[test]
1045 fn string_rejects_struct_input() {
1046 let err = error_message(
1047 string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string"),
1048 );
1049 assert!(err.contains("structs are not supported"));
1050 }
1051
1052 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1053 #[test]
1054 fn string_errors_on_unsupported_encoding() {
1055 let err = error_message(
1056 string_builtin(
1057 Value::CharArray(CharArray::new_row("abc")),
1058 vec![Value::from("UTF-16")],
1059 )
1060 .unwrap_err(),
1061 );
1062 assert!(
1063 err.contains("unsupported character encoding"),
1064 "unexpected error message: {err}"
1065 );
1066 }
1067
1068 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1069 #[test]
1070 fn string_accepts_system_encoding_alias() {
1071 let out = string_builtin(
1072 Value::CharArray(CharArray::new_row("hello")),
1073 vec![Value::from("system")],
1074 )
1075 .expect("string");
1076 match out {
1077 Value::StringArray(sa) => {
1078 assert_eq!(sa.shape, vec![1, 1]);
1079 assert_eq!(sa.data, vec!["hello"]);
1080 }
1081 other => panic!("expected string array, got {other:?}"),
1082 }
1083 }
1084
1085 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1086 #[test]
1087 fn string_encoding_allows_percent_literal() {
1088 let out = string_builtin(
1089 Value::CharArray(CharArray::new_row("100% Done")),
1090 vec![Value::from("utf8")],
1091 )
1092 .expect("string");
1093 match out {
1094 Value::StringArray(sa) => {
1095 assert_eq!(sa.shape, vec![1, 1]);
1096 assert_eq!(sa.data, vec!["100% Done"]);
1097 }
1098 other => panic!("expected string array, got {other:?}"),
1099 }
1100 }
1101
1102 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1103 #[test]
1104 fn string_format_spec_cell_requires_text_scalars() {
1105 let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1106 let err = error_message(
1107 string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string"),
1108 );
1109 assert!(
1110 err.contains("formatSpec cell elements must be text scalars"),
1111 "unexpected error: {err}"
1112 );
1113 }
1114
1115 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1116 #[test]
1117 fn string_format_cell_argument_requires_scalar_values() {
1118 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1119 let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1120 let err = error_message(
1121 string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string"),
1122 );
1123 assert!(err.contains("cell format arguments must contain scalar values"));
1124 }
1125
1126 #[test]
1127 fn string_rejects_oversized_sparse_tensor_before_densifying() {
1128 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1129 let err = string_builtin(Value::SparseTensor(sparse), Vec::new()).unwrap_err();
1130
1131 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1132 assert!(err.message().contains("exceeds safe threshold"));
1133 }
1134
1135 #[test]
1136 fn string_format_rejects_oversized_sparse_argument_before_densifying() {
1137 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1138 let err = string_builtin(Value::from("%g"), vec![Value::SparseTensor(sparse)]).unwrap_err();
1139
1140 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1141 assert!(err.message().contains("format argument"));
1142 assert!(err.message().contains("exceeds safe threshold"));
1143 }
1144
1145 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1146 #[test]
1147 fn string_handles_large_unsigned_int() {
1148 let value = Value::Int(IntValue::U64(u64::MAX));
1149 let out = string_builtin(value, Vec::new()).expect("string");
1150 match out {
1151 Value::StringArray(sa) => {
1152 assert_eq!(sa.shape, vec![1, 1]);
1153 assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1154 }
1155 other => panic!("expected string array, got {other:?}"),
1156 }
1157 }
1158
1159 #[test]
1160 fn string_descriptor_signatures_cover_core_forms() {
1161 let labels: Vec<&str> = STRING_DESCRIPTOR
1162 .signatures
1163 .iter()
1164 .map(|signature| signature.label)
1165 .collect();
1166 assert_eq!(
1167 labels,
1168 vec![
1169 "S = string(X)",
1170 "S = string(X, encoding)",
1171 "S = string(formatSpec, A...)",
1172 ]
1173 );
1174
1175 let codes: Vec<&str> = STRING_DESCRIPTOR
1176 .errors
1177 .iter()
1178 .map(|error| error.code)
1179 .collect();
1180 assert_eq!(codes, vec!["RM.STRING.INVALID_INPUT"]);
1181 }
1182
1183 #[test]
1184 fn string_struct_input_uses_stable_identifier() {
1185 let err = string_builtin(Value::Struct(StructValue::new()), Vec::new()).unwrap_err();
1186 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1187 }
1188
1189 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1190 #[test]
1191 fn string_format_numeric_scalar() {
1192 let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1193 match out {
1194 Value::StringArray(sa) => {
1195 assert_eq!(sa.shape, vec![1, 1]);
1196 assert_eq!(sa.data, vec!["7"]);
1197 }
1198 other => panic!("expected string array, got {other:?}"),
1199 }
1200 }
1201
1202 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1203 #[test]
1204 fn string_format_broadcast_over_tensor() {
1205 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1206 let out =
1207 string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1208 match out {
1209 Value::StringArray(sa) => {
1210 assert_eq!(sa.shape, vec![1, 3]);
1211 assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1212 }
1213 other => panic!("expected string array, got {other:?}"),
1214 }
1215 }
1216
1217 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1218 #[test]
1219 fn string_format_string_array_spec_alignment() {
1220 let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1221 let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1222 let out =
1223 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1224 match out {
1225 Value::StringArray(sa) => {
1226 assert_eq!(sa.shape, vec![1, 2]);
1227 assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1228 }
1229 other => panic!("expected string array, got {other:?}"),
1230 }
1231 }
1232
1233 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1234 #[test]
1235 fn string_format_prefers_placeholders_over_encoding_hint() {
1236 let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1237 match out {
1238 Value::StringArray(sa) => {
1239 assert_eq!(sa.shape, vec![1, 1]);
1240 assert_eq!(sa.data, vec!["UTF-8"]);
1241 }
1242 other => panic!("expected string array, got {other:?}"),
1243 }
1244 }
1245
1246 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1247 #[test]
1248 fn string_format_mismatched_lengths_errors() {
1249 let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1250 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1251 let err = error_message(
1252 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err(),
1253 );
1254 assert!(err.contains("must be scalars or match formatSpec size"));
1255 }
1256
1257 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1258 #[test]
1259 fn string_gpu_numeric_tensor() {
1260 test_support::with_test_provider(|provider| {
1261 let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1262 let view = runmat_accelerate_api::HostTensorView {
1263 data: &tensor.data,
1264 shape: &tensor.shape,
1265 };
1266 let handle = provider.upload(&view).expect("upload");
1267 let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1268 .expect("gpu string conversion");
1269 match result {
1270 Value::StringArray(sa) => {
1271 assert_eq!(sa.shape, vec![1, 2]);
1272 assert_eq!(sa.data, vec!["10", "20"]);
1273 }
1274 other => panic!("expected string array, got {other:?}"),
1275 }
1276 });
1277 }
1278
1279 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1280 #[test]
1281 #[cfg(feature = "wgpu")]
1282 fn string_wgpu_numeric_tensor_matches_cpu() {
1283 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1284 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1285 );
1286 let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1287 let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1288 .expect("cpu string conversion");
1289 let view = runmat_accelerate_api::HostTensorView {
1290 data: &tensor.data,
1291 shape: &tensor.shape,
1292 };
1293 let handle = runmat_accelerate_api::provider()
1294 .unwrap()
1295 .upload(&view)
1296 .expect("gpu upload");
1297 let gpu =
1298 string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1299 match (cpu, gpu) {
1300 (Value::StringArray(expect), Value::StringArray(actual)) => {
1301 assert_eq!(actual.shape, expect.shape);
1302 assert_eq!(actual.data, expect.data);
1303 }
1304 other => panic!("unexpected results {other:?}"),
1305 }
1306 }
1307
1308 #[test]
1309 fn string_type_is_string_array() {
1310 assert_eq!(
1311 string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1312 Type::cell_of(Type::String)
1313 );
1314 }
1315}