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::Num(n) => Ok(ArgumentData {
537 values: vec![Value::Num(n)],
538 shape: vec![1, 1],
539 }),
540 Value::Int(i) => Ok(ArgumentData {
541 values: vec![Value::Int(i)],
542 shape: vec![1, 1],
543 }),
544 Value::Bool(b) => Ok(ArgumentData {
545 values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
546 shape: vec![1, 1],
547 }),
548 Value::Tensor(t) => Ok(ArgumentData {
549 values: t.data.into_iter().map(Value::Num).collect(),
550 shape: t.shape,
551 }),
552 Value::SparseTensor(s) => {
553 ensure_sparse_dense_conversion(&s, "format argument")?;
554 let dense = s.to_dense().map_err(string_flow)?;
555 Ok(ArgumentData {
556 values: dense.data.into_iter().map(Value::Num).collect(),
557 shape: dense.shape,
558 })
559 }
560 Value::Complex(re, im) => Ok(ArgumentData {
561 values: vec![Value::String(complex_to_string(re, im))],
562 shape: vec![1, 1],
563 }),
564 Value::ComplexTensor(t) => Ok(ArgumentData {
565 values: t
566 .data
567 .into_iter()
568 .map(|(re, im)| Value::String(complex_to_string(re, im)))
569 .collect(),
570 shape: t.shape,
571 }),
572 Value::LogicalArray(la) => Ok(ArgumentData {
573 values: la
574 .data
575 .into_iter()
576 .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
577 .collect(),
578 shape: la.shape,
579 }),
580 Value::Cell(cell) => {
581 let mut values = Vec::with_capacity(cell.data.len());
582 for col in 0..cell.cols {
583 for row in 0..cell.rows {
584 let idx = row * cell.cols + col;
585 let element = &cell.data[idx];
586 let value = (**element).clone();
587 let gathered = gather_if_needed_async(&value)
588 .await
589 .map_err(|flow| remap_string_flow(flow))?;
590 let value = match gathered {
591 Value::String(s) => Value::String(s),
592 Value::StringArray(sa) if sa.data.len() == 1 => {
593 Value::String(sa.data[0].clone())
594 }
595 Value::CharArray(ca) => {
596 if ca.rows != 1 {
597 return Err(string_flow(
598 "string: cell format arguments must contain char row vectors",
599 ));
600 }
601 let mut row_str = String::with_capacity(ca.cols);
602 for ch in ca.data {
603 row_str.push(ch);
604 }
605 Value::String(row_str)
606 }
607 Value::Num(n) => Value::Num(n),
608 Value::Int(i) => Value::Int(i),
609 Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
610 Value::Tensor(t) => {
611 if t.data.len() != 1 {
612 return Err(string_flow(
613 "string: cell format arguments must contain scalar values",
614 ));
615 }
616 Value::Num(t.data[0])
617 }
618 Value::LogicalArray(la) => {
619 if la.data.len() != 1 {
620 return Err(string_flow(
621 "string: cell format arguments must contain scalar values",
622 ));
623 }
624 Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
625 }
626 Value::Complex(re, im) => Value::String(complex_to_string(re, im)),
627 Value::Symbolic(expr) => Value::String(expr.to_string()),
628 Value::ComplexTensor(t) => {
629 if t.data.len() != 1 {
630 return Err(string_flow(
631 "string: cell format arguments must contain scalar values",
632 ));
633 }
634 let (re, im) = t.data[0];
635 Value::String(complex_to_string(re, im))
636 }
637 other => {
638 return Err(string_flow(format!(
639 "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
640 )))
641 }
642 };
643 values.push(value);
644 }
645 }
646 Ok(ArgumentData {
647 values,
648 shape: vec![cell.rows, cell.cols],
649 })
650 }
651 Value::GpuTensor(handle) => {
652 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
653 .await
654 .map_err(|flow| remap_string_flow(flow))?;
655 extract_argument_data(gathered).await
656 }
657 Value::MException(_)
658 | Value::HandleObject(_)
659 | Value::Object(_)
660 | Value::Listener(_)
661 | Value::Struct(_)
662 | Value::OutputList(_) => Err(string_flow("string: unsupported format argument type")),
663 Value::FunctionHandle(_)
664 | Value::ExternalFunctionHandle(_)
665 | Value::MethodFunctionHandle(_)
666 | Value::BoundFunctionHandle { .. }
667 | Value::Closure(_)
668 | Value::ClassRef(_) => Err(string_flow("string: unsupported format argument type")),
669 }
670}
671
672#[async_recursion::async_recursion(?Send)]
673async fn convert_to_string_array(
674 value: Value,
675 encoding: StringEncoding,
676) -> BuiltinResult<StringArray> {
677 if let Some(array) = crate::builtins::datetime::datetime_string_array(&value)
678 .map_err(|err| string_flow(err.message().to_string()))?
679 {
680 return Ok(array);
681 }
682 if let Some(array) = crate::builtins::duration::duration_string_array(&value)
683 .map_err(|err| string_flow(err.message().to_string()))?
684 {
685 return Ok(array);
686 }
687 match value {
688 Value::String(s) => string_scalar(s),
689 Value::StringArray(sa) => Ok(sa),
690 Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
691 Value::Symbolic(expr) => string_scalar(expr.to_string()),
692 Value::Tensor(tensor) => tensor_to_string_array(tensor),
693 Value::SparseTensor(sparse) => {
694 ensure_sparse_dense_conversion(&sparse, "dense string array")?;
695 tensor_to_string_array(sparse.to_dense().map_err(string_flow)?)
696 }
697 Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
698 Value::LogicalArray(logical) => logical_array_to_string_array(logical),
699 Value::Cell(cell) => cell_array_to_string_array(cell, encoding).await,
700 Value::Num(n) => string_scalar(number_to_string(n)),
701 Value::Int(i) => string_scalar(int_value_to_string(&i)),
702 Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
703 Value::Complex(re, im) => string_scalar(complex_to_string(re, im)),
704 Value::GpuTensor(handle) => {
705 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
707 .await
708 .map_err(|flow| remap_string_flow(flow))?;
709 convert_to_string_array(gathered, encoding).await
710 }
711 Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(string_flow(
712 "string: unsupported conversion from handle-based objects. Use class-specific formatters.",
713 )),
714 Value::Struct(_) => Err(string_flow(
715 "string: structs are not supported for automatic conversion",
716 )),
717 Value::FunctionHandle(_) | Value::ExternalFunctionHandle(_) | Value::MethodFunctionHandle(_) | Value::BoundFunctionHandle { .. }
718 | Value::Closure(_)
719 | Value::ClassRef(_)
720 | Value::MException(_)
721 | Value::OutputList(_) => Err(
722 string_flow("string: unsupported conversion for function or exception handles"),
723 ),
724 }
725}
726
727fn string_scalar<S: Into<String>>(text: S) -> BuiltinResult<StringArray> {
728 StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| string_flow(format!("string: {e}")))
729}
730
731fn value_to_scalar_text(value: &Value) -> Option<String> {
732 match value {
733 Value::String(s) => Some(s.clone()),
734 Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
735 Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
736 _ => None,
737 }
738}
739
740fn char_array_to_string_array(
741 array: CharArray,
742 _encoding: StringEncoding,
743) -> BuiltinResult<StringArray> {
744 let mut rows: Vec<String> = Vec::with_capacity(array.rows);
745 for r in 0..array.rows {
746 let mut row = String::with_capacity(array.cols);
747 for c in 0..array.cols {
748 row.push(array.data[r * array.cols + c]);
749 }
750 rows.push(row);
751 }
752 let shape = if array.rows == 0 {
753 vec![0, 1]
754 } else {
755 vec![array.rows, 1]
756 };
757 StringArray::new(rows, shape).map_err(|e| string_flow(format!("string: {e}")))
758}
759
760fn tensor_to_string_array(tensor: Tensor) -> BuiltinResult<StringArray> {
761 let mut strings = Vec::with_capacity(tensor.data.len());
762 for &value in &tensor.data {
763 strings.push(number_to_string(value));
764 }
765 StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
766}
767
768fn complex_tensor_to_string_array(tensor: ComplexTensor) -> BuiltinResult<StringArray> {
769 let mut strings = Vec::with_capacity(tensor.data.len());
770 for &(re, im) in &tensor.data {
771 strings.push(complex_to_string(re, im));
772 }
773 StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
774}
775
776fn logical_array_to_string_array(logical: LogicalArray) -> BuiltinResult<StringArray> {
777 let mut strings = Vec::with_capacity(logical.data.len());
778 for &byte in &logical.data {
779 strings.push(bool_to_string(byte != 0).to_string());
780 }
781 StringArray::new(strings, logical.shape).map_err(|e| string_flow(format!("string: {e}")))
782}
783
784async fn cell_array_to_string_array(
785 cell: runmat_builtins::CellArray,
786 _encoding: StringEncoding,
787) -> BuiltinResult<StringArray> {
788 let mut strings = Vec::with_capacity(cell.data.len());
789 for col in 0..cell.cols {
790 for row in 0..cell.rows {
791 let idx = row * cell.cols + col;
792 let element = &cell.data[idx];
793 let value = (**element).clone();
794 let gathered = gather_if_needed_async(&value)
795 .await
796 .map_err(|flow| remap_string_flow(flow))?;
797 strings.push(cell_element_to_string(&gathered)?);
798 }
799 }
800 StringArray::new(strings, vec![cell.rows, cell.cols])
801 .map_err(|e| string_flow(format!("string: {e}")))
802}
803
804fn cell_element_to_string(value: &Value) -> BuiltinResult<String> {
805 if let Some(array) = crate::builtins::datetime::datetime_string_array(value)
806 .map_err(|err| string_flow(err.message().to_string()))?
807 {
808 if array.data.len() == 1 {
809 return Ok(array.data[0].clone());
810 }
811 return Err(string_flow("string: cell datetime values must be scalar"));
812 }
813 if let Some(array) = crate::builtins::duration::duration_string_array(value)
814 .map_err(|err| string_flow(err.message().to_string()))?
815 {
816 if array.data.len() == 1 {
817 return Ok(array.data[0].clone());
818 }
819 return Err(string_flow("string: cell duration values must be scalar"));
820 }
821 match value {
822 Value::String(s) => Ok(s.clone()),
823 Value::StringArray(sa) => {
824 if sa.data.len() == 1 {
825 Ok(sa.data[0].clone())
826 } else {
827 Err(string_flow(
828 "string: cell elements must contain string scalars, not string arrays",
829 ))
830 }
831 }
832 Value::CharArray(ca) => {
833 if ca.rows == 1 {
834 Ok(ca.data.iter().collect())
835 } else {
836 Err(string_flow(
837 "string: cell character arrays must be row vectors",
838 ))
839 }
840 }
841 Value::Num(n) => Ok(number_to_string(*n)),
842 Value::Int(i) => Ok(int_value_to_string(i)),
843 Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
844 Value::LogicalArray(array) => {
845 if array.data.len() == 1 {
846 Ok(bool_to_string(array.data[0] != 0).to_string())
847 } else {
848 Err(string_flow("string: cell logical values must be scalar"))
849 }
850 }
851 Value::Tensor(t) => {
852 if t.data.len() == 1 {
853 Ok(number_to_string(t.data[0]))
854 } else {
855 Err(string_flow("string: cell numeric values must be scalar"))
856 }
857 }
858 Value::Complex(re, im) => Ok(complex_to_string(*re, *im)),
859 Value::ComplexTensor(t) => {
860 if t.data.len() == 1 {
861 let (re, im) = t.data[0];
862 Ok(complex_to_string(re, im))
863 } else {
864 Err(string_flow("string: cell complex values must be scalar"))
865 }
866 }
867 other => Err(string_flow(format!(
868 "string: unsupported cell element type {:?}; expected text or scalar values",
869 other
870 ))),
871 }
872}
873
874fn ensure_sparse_dense_conversion(sparse: &SparseTensor, target: &str) -> BuiltinResult<()> {
875 let total_elements = sparse
876 .rows
877 .checked_mul(sparse.cols)
878 .ok_or_else(|| string_flow("string: sparse matrix dimensions overflow"))?;
879 if total_elements > STRING_SPARSE_DENSE_ELEMENT_LIMIT {
880 return Err(string_flow(format!(
881 "string: cannot convert sparse tensor {}x{} with {} stored entries to {target} ({} elements exceeds safe threshold)",
882 sparse.rows,
883 sparse.cols,
884 sparse.nnz(),
885 total_elements
886 )));
887 }
888 Ok(())
889}
890
891fn bool_to_string(value: bool) -> &'static str {
892 if value {
893 "true"
894 } else {
895 "false"
896 }
897}
898
899fn int_value_to_string(value: &IntValue) -> String {
900 match value {
901 IntValue::I8(v) => v.to_string(),
902 IntValue::I16(v) => v.to_string(),
903 IntValue::I32(v) => v.to_string(),
904 IntValue::I64(v) => v.to_string(),
905 IntValue::U8(v) => v.to_string(),
906 IntValue::U16(v) => v.to_string(),
907 IntValue::U32(v) => v.to_string(),
908 IntValue::U64(v) => v.to_string(),
909 }
910}
911
912#[cfg(test)]
913pub(crate) mod tests {
914 use super::*;
915 use crate::builtins::common::test_support;
916 use runmat_builtins::{CellArray, IntValue, ResolveContext, StringArray, StructValue, Type};
917
918 fn string_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
919 futures::executor::block_on(super::string_builtin(value, rest))
920 }
921
922 fn error_message(err: crate::RuntimeError) -> String {
923 err.message().to_string()
924 }
925
926 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
927 #[test]
928 fn string_from_numeric_scalar() {
929 let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
930 match out {
931 Value::StringArray(sa) => {
932 assert_eq!(sa.shape, vec![1, 1]);
933 assert_eq!(sa.data, vec!["42".to_string()]);
934 }
935 other => panic!("expected string array, got {other:?}"),
936 }
937 }
938
939 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
940 #[test]
941 fn string_from_numeric_tensor_preserves_shape() {
942 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
943 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
944 match out {
945 Value::StringArray(sa) => {
946 assert_eq!(sa.shape, vec![2, 2]);
947 assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
948 }
949 other => panic!("expected string array, got {other:?}"),
950 }
951 }
952
953 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
954 #[test]
955 fn string_from_logical_array_uses_boolean_text() {
956 let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
957 let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
958 match out {
959 Value::StringArray(sa) => {
960 assert_eq!(sa.shape, vec![1, 3]);
961 assert_eq!(sa.data, vec!["true", "false", "true"]);
962 }
963 other => panic!("expected string array, got {other:?}"),
964 }
965 }
966
967 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
968 #[test]
969 fn string_from_char_array_produces_column_vector() {
970 let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
971 let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
972 match out {
973 Value::StringArray(sa) => {
974 assert_eq!(sa.shape, vec![1, 1]);
975 assert_eq!(sa.data, vec!["abc"]);
976 }
977 other => panic!("expected string array, got {other:?}"),
978 }
979 }
980
981 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
982 #[test]
983 fn string_from_cell_array() {
984 let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
985 .expect("cell array");
986 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
987 match out {
988 Value::StringArray(sa) => {
989 assert_eq!(sa.shape, vec![1, 2]);
990 assert_eq!(sa.data, vec!["true", "7"]);
991 }
992 other => panic!("expected string array, got {other:?}"),
993 }
994 }
995
996 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
997 #[test]
998 fn string_from_cell_array_column_major() {
999 let cell = CellArray::new(
1000 vec![
1001 Value::Int(IntValue::I32(1)),
1002 Value::Int(IntValue::I32(2)),
1003 Value::Int(IntValue::I32(3)),
1004 Value::Int(IntValue::I32(4)),
1005 ],
1006 2,
1007 2,
1008 )
1009 .expect("cell array");
1010 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1011 match out {
1012 Value::StringArray(sa) => {
1013 assert_eq!(sa.shape, vec![2, 2]);
1014 assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
1015 }
1016 other => panic!("expected string array, got {other:?}"),
1017 }
1018 }
1019
1020 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1021 #[test]
1022 fn string_cell_element_requires_scalar_numeric() {
1023 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1024 let cell =
1025 CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
1026 let err = error_message(string_builtin(Value::Cell(cell), Vec::new()).unwrap_err());
1027 assert!(err.contains("cell numeric values must be scalar"));
1028 }
1029
1030 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1031 #[test]
1032 fn string_rejects_struct_input() {
1033 let err = error_message(
1034 string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string"),
1035 );
1036 assert!(err.contains("structs are not supported"));
1037 }
1038
1039 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1040 #[test]
1041 fn string_errors_on_unsupported_encoding() {
1042 let err = error_message(
1043 string_builtin(
1044 Value::CharArray(CharArray::new_row("abc")),
1045 vec![Value::from("UTF-16")],
1046 )
1047 .unwrap_err(),
1048 );
1049 assert!(
1050 err.contains("unsupported character encoding"),
1051 "unexpected error message: {err}"
1052 );
1053 }
1054
1055 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1056 #[test]
1057 fn string_accepts_system_encoding_alias() {
1058 let out = string_builtin(
1059 Value::CharArray(CharArray::new_row("hello")),
1060 vec![Value::from("system")],
1061 )
1062 .expect("string");
1063 match out {
1064 Value::StringArray(sa) => {
1065 assert_eq!(sa.shape, vec![1, 1]);
1066 assert_eq!(sa.data, vec!["hello"]);
1067 }
1068 other => panic!("expected string array, got {other:?}"),
1069 }
1070 }
1071
1072 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1073 #[test]
1074 fn string_encoding_allows_percent_literal() {
1075 let out = string_builtin(
1076 Value::CharArray(CharArray::new_row("100% Done")),
1077 vec![Value::from("utf8")],
1078 )
1079 .expect("string");
1080 match out {
1081 Value::StringArray(sa) => {
1082 assert_eq!(sa.shape, vec![1, 1]);
1083 assert_eq!(sa.data, vec!["100% Done"]);
1084 }
1085 other => panic!("expected string array, got {other:?}"),
1086 }
1087 }
1088
1089 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1090 #[test]
1091 fn string_format_spec_cell_requires_text_scalars() {
1092 let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1093 let err = error_message(
1094 string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string"),
1095 );
1096 assert!(
1097 err.contains("formatSpec cell elements must be text scalars"),
1098 "unexpected error: {err}"
1099 );
1100 }
1101
1102 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1103 #[test]
1104 fn string_format_cell_argument_requires_scalar_values() {
1105 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1106 let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1107 let err = error_message(
1108 string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string"),
1109 );
1110 assert!(err.contains("cell format arguments must contain scalar values"));
1111 }
1112
1113 #[test]
1114 fn string_rejects_oversized_sparse_tensor_before_densifying() {
1115 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1116 let err = string_builtin(Value::SparseTensor(sparse), Vec::new()).unwrap_err();
1117
1118 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1119 assert!(err.message().contains("exceeds safe threshold"));
1120 }
1121
1122 #[test]
1123 fn string_format_rejects_oversized_sparse_argument_before_densifying() {
1124 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1125 let err = string_builtin(Value::from("%g"), vec![Value::SparseTensor(sparse)]).unwrap_err();
1126
1127 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1128 assert!(err.message().contains("format argument"));
1129 assert!(err.message().contains("exceeds safe threshold"));
1130 }
1131
1132 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1133 #[test]
1134 fn string_handles_large_unsigned_int() {
1135 let value = Value::Int(IntValue::U64(u64::MAX));
1136 let out = string_builtin(value, Vec::new()).expect("string");
1137 match out {
1138 Value::StringArray(sa) => {
1139 assert_eq!(sa.shape, vec![1, 1]);
1140 assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1141 }
1142 other => panic!("expected string array, got {other:?}"),
1143 }
1144 }
1145
1146 #[test]
1147 fn string_descriptor_signatures_cover_core_forms() {
1148 let labels: Vec<&str> = STRING_DESCRIPTOR
1149 .signatures
1150 .iter()
1151 .map(|signature| signature.label)
1152 .collect();
1153 assert_eq!(
1154 labels,
1155 vec![
1156 "S = string(X)",
1157 "S = string(X, encoding)",
1158 "S = string(formatSpec, A...)",
1159 ]
1160 );
1161
1162 let codes: Vec<&str> = STRING_DESCRIPTOR
1163 .errors
1164 .iter()
1165 .map(|error| error.code)
1166 .collect();
1167 assert_eq!(codes, vec!["RM.STRING.INVALID_INPUT"]);
1168 }
1169
1170 #[test]
1171 fn string_struct_input_uses_stable_identifier() {
1172 let err = string_builtin(Value::Struct(StructValue::new()), Vec::new()).unwrap_err();
1173 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1174 }
1175
1176 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1177 #[test]
1178 fn string_format_numeric_scalar() {
1179 let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1180 match out {
1181 Value::StringArray(sa) => {
1182 assert_eq!(sa.shape, vec![1, 1]);
1183 assert_eq!(sa.data, vec!["7"]);
1184 }
1185 other => panic!("expected string array, got {other:?}"),
1186 }
1187 }
1188
1189 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1190 #[test]
1191 fn string_format_broadcast_over_tensor() {
1192 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1193 let out =
1194 string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1195 match out {
1196 Value::StringArray(sa) => {
1197 assert_eq!(sa.shape, vec![1, 3]);
1198 assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1199 }
1200 other => panic!("expected string array, got {other:?}"),
1201 }
1202 }
1203
1204 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1205 #[test]
1206 fn string_format_string_array_spec_alignment() {
1207 let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1208 let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1209 let out =
1210 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1211 match out {
1212 Value::StringArray(sa) => {
1213 assert_eq!(sa.shape, vec![1, 2]);
1214 assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1215 }
1216 other => panic!("expected string array, got {other:?}"),
1217 }
1218 }
1219
1220 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1221 #[test]
1222 fn string_format_prefers_placeholders_over_encoding_hint() {
1223 let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1224 match out {
1225 Value::StringArray(sa) => {
1226 assert_eq!(sa.shape, vec![1, 1]);
1227 assert_eq!(sa.data, vec!["UTF-8"]);
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_mismatched_lengths_errors() {
1236 let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1237 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1238 let err = error_message(
1239 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err(),
1240 );
1241 assert!(err.contains("must be scalars or match formatSpec size"));
1242 }
1243
1244 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1245 #[test]
1246 fn string_gpu_numeric_tensor() {
1247 test_support::with_test_provider(|provider| {
1248 let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1249 let view = runmat_accelerate_api::HostTensorView {
1250 data: &tensor.data,
1251 shape: &tensor.shape,
1252 };
1253 let handle = provider.upload(&view).expect("upload");
1254 let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1255 .expect("gpu string conversion");
1256 match result {
1257 Value::StringArray(sa) => {
1258 assert_eq!(sa.shape, vec![1, 2]);
1259 assert_eq!(sa.data, vec!["10", "20"]);
1260 }
1261 other => panic!("expected string array, got {other:?}"),
1262 }
1263 });
1264 }
1265
1266 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1267 #[test]
1268 #[cfg(feature = "wgpu")]
1269 fn string_wgpu_numeric_tensor_matches_cpu() {
1270 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1271 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1272 );
1273 let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1274 let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1275 .expect("cpu string conversion");
1276 let view = runmat_accelerate_api::HostTensorView {
1277 data: &tensor.data,
1278 shape: &tensor.shape,
1279 };
1280 let handle = runmat_accelerate_api::provider()
1281 .unwrap()
1282 .upload(&view)
1283 .expect("gpu upload");
1284 let gpu =
1285 string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1286 match (cpu, gpu) {
1287 (Value::StringArray(expect), Value::StringArray(actual)) => {
1288 assert_eq!(actual.shape, expect.shape);
1289 assert_eq!(actual.data, expect.data);
1290 }
1291 other => panic!("unexpected results {other:?}"),
1292 }
1293 }
1294
1295 #[test]
1296 fn string_type_is_string_array() {
1297 assert_eq!(
1298 string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1299 Type::cell_of(Type::String)
1300 );
1301 }
1302}