1use runmat_builtins::{
4 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5 BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6 BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7 BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8 BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::{
13 CharArray, ComplexTensor, IntValue, LogicalArray, NumericScalar, SparseTensor, StringArray,
14 Tensor, Value,
15};
16
17use crate::builtins::common::format::{complex_to_string, format_variadic, number_to_string};
18use crate::builtins::common::map_control_flow_with_builtin;
19use crate::builtins::common::spec::{
20 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21 ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::common::tensor;
24use crate::builtins::strings::type_resolvers::string_array_type;
25use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
26
27const STRING_OUTPUT_S: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
28 name: "S",
29 ty: BuiltinParamType::Any,
30 arity: BuiltinParamArity::Required,
31 default: None,
32 description: "String scalar/array result.",
33}];
34
35const STRING_INPUTS_VALUE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
36 name: "X",
37 ty: BuiltinParamType::Any,
38 arity: BuiltinParamArity::Required,
39 default: None,
40 description: "Input value to convert to string array.",
41}];
42
43const STRING_INPUTS_VALUE_ENCODING: [BuiltinParamDescriptor; 2] = [
44 BuiltinParamDescriptor {
45 name: "X",
46 ty: BuiltinParamType::Any,
47 arity: BuiltinParamArity::Required,
48 default: None,
49 description: "Input value to convert to string array.",
50 },
51 BuiltinParamDescriptor {
52 name: "encoding",
53 ty: BuiltinParamType::StringScalar,
54 arity: BuiltinParamArity::Optional,
55 default: Some("\"UTF-8\""),
56 description: "Character encoding (UTF-8 aliases supported).",
57 },
58];
59
60const STRING_INPUTS_FORMAT: [BuiltinParamDescriptor; 2] = [
61 BuiltinParamDescriptor {
62 name: "formatSpec",
63 ty: BuiltinParamType::Any,
64 arity: BuiltinParamArity::Required,
65 default: None,
66 description: "Format specification text/cell/string array.",
67 },
68 BuiltinParamDescriptor {
69 name: "A",
70 ty: BuiltinParamType::Any,
71 arity: BuiltinParamArity::Variadic,
72 default: None,
73 description: "Formatting data arguments.",
74 },
75];
76
77const STRING_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
78 BuiltinSignatureDescriptor {
79 label: "S = string(X)",
80 inputs: &STRING_INPUTS_VALUE,
81 outputs: &STRING_OUTPUT_S,
82 },
83 BuiltinSignatureDescriptor {
84 label: "S = string(X, encoding)",
85 inputs: &STRING_INPUTS_VALUE_ENCODING,
86 outputs: &STRING_OUTPUT_S,
87 },
88 BuiltinSignatureDescriptor {
89 label: "S = string(formatSpec, A...)",
90 inputs: &STRING_INPUTS_FORMAT,
91 outputs: &STRING_OUTPUT_S,
92 },
93];
94
95const STRING_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
96 code: "RM.STRING.INVALID_INPUT",
97 identifier: Some("RunMat:string:InvalidInput"),
98 when: "Input conversion/formatting/encoding constraints are violated.",
99 message: "string: invalid input",
100};
101
102const STRING_ERRORS: [BuiltinErrorDescriptor; 1] = [STRING_ERROR_INVALID_INPUT];
103const STRING_SPARSE_DENSE_ELEMENT_LIMIT: usize = 10_000_000;
104
105pub const STRING_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
106 signatures: &STRING_SIGNATURES,
107 output_mode: BuiltinOutputMode::Fixed,
108 completion_policy: BuiltinCompletionPolicy::Public,
109 errors: &STRING_ERRORS,
110};
111
112const STRING_FORMAT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
113 id: "string-format-spec",
114 mode: BuiltinExtensionMode::RunMatOnly,
115 description: "string(formatSpec, A...) printf-style formatting is a RunMat extension",
116 error_identifier: Some("RunMat:compatibility:StringFormatSpecExtension"),
117};
118
119const STRING_ENCODING_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
120 id: "string-encoding",
121 mode: BuiltinExtensionMode::RunMatOnly,
122 description: "string(X, encoding) character-encoding selection is a RunMat extension",
123 error_identifier: Some("RunMat:compatibility:StringEncodingExtension"),
124};
125
126const STRING_EXPLICIT_GPU_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
127 id: "string-explicit-gpu-input",
128 mode: BuiltinExtensionMode::RunMatOnly,
129 description: "string with explicit gpuArray numeric input is a RunMat extension",
130 error_identifier: Some("RunMat:compatibility:StringExplicitGpuInputExtension"),
131};
132
133pub const STRING_EXTENSIONS: [BuiltinExtensionDescriptor; 3] = [
134 STRING_FORMAT_EXTENSION,
135 STRING_ENCODING_EXTENSION,
136 STRING_EXPLICIT_GPU_EXTENSION,
137];
138
139const STRING_VALUE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
140 [BuiltinIntegerInputCapability {
141 name: "A",
142 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
143 availability: BuiltinIntegerInputAvailability::Documented,
144 scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
145 notes: "Every built-in integer class converts element by element to decimal text from authoritative storage, preserving input shape and exact wide values.",
146 }];
147
148const STRING_FORMAT_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
149 [BuiltinIntegerInputCapability {
150 name: "A",
151 classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
152 availability: BuiltinIntegerInputAvailability::RunMatOnly,
153 scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
154 notes: "RunMat printf-style format arguments accept every integer class and retain exact typed scalars until the selected formatter consumes them.",
155 }];
156
157pub const STRING_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
158 BuiltinIntegerCapabilityDescriptor {
159 form: "str = string(A)",
160 inputs: &STRING_VALUE_INTEGER_INPUTS,
161 computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific,
162 output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
163 overflow: BuiltinIntegerOverflowRule::NotApplicable,
164 backend: BuiltinIntegerBackendRule::GatherFallback,
165 overload: BuiltinIntegerOverloadKind::ElementwiseShapePreserving,
166 notes: "Host and automatically resident integer arrays convert exactly to host strings, including wide integer values. Explicit gpuArray input is independently mode-gated because the compatibility target does not document interactive GPU-array input for string.",
167 },
168 BuiltinIntegerCapabilityDescriptor {
169 form: "str = string(formatSpec, A...)",
170 inputs: &STRING_FORMAT_INTEGER_INPUTS,
171 computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific,
172 output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
173 overflow: BuiltinIntegerOverflowRule::NotApplicable,
174 backend: BuiltinIntegerBackendRule::GatherFallback,
175 overload: BuiltinIntegerOverloadKind::Multiple,
176 notes: "This printf-style formatting form is a mode-gated RunMat extension and does not redefine the documented string(A) conversion surface.",
177 },
178];
179
180#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::string")]
181pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
182 name: "string",
183 op_kind: GpuOpKind::Custom("conversion"),
184 supported_precisions: &[],
185 broadcast: BroadcastSemantics::None,
186 provider_hooks: &[],
187 constant_strategy: ConstantStrategy::InlineLiteral,
188 residency: ResidencyPolicy::GatherImmediately,
189 nan_mode: ReductionNaN::Include,
190 two_pass_threshold: None,
191 workgroup_size: None,
192 accepts_nan_mode: false,
193 notes: "Always converts on the CPU; GPU tensors are gathered to host memory before conversion.",
194};
195
196#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::string")]
197pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
198 name: "string",
199 shape: ShapeRequirements::Any,
200 constant_strategy: ConstantStrategy::InlineLiteral,
201 elementwise: None,
202 reduction: None,
203 emits_nan: false,
204 notes:
205 "Conversion builtin; not eligible for fusion and always materialises host string arrays.",
206};
207
208#[runtime_builtin(
209 name = "string",
210 category = "strings/core",
211 summary = "Convert numeric, logical, and text inputs into string arrays.",
212 keywords = "string,convert,text,char,gpu",
213 accel = "sink",
214 type_resolver(string_array_type),
215 descriptor(crate::builtins::strings::core::string::STRING_DESCRIPTOR),
216 extensions(crate::builtins::strings::core::string::STRING_EXTENSIONS),
217 integer_capabilities(crate::builtins::strings::core::string::STRING_INTEGER_CAPABILITIES),
218 builtin_path = "crate::builtins::strings::core::string"
219)]
220async fn string_builtin(value: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
221 if crate::builtins::common::validation::value_contains_explicit_gpu(&value)
222 || rest
223 .iter()
224 .any(crate::builtins::common::validation::value_contains_explicit_gpu)
225 {
226 crate::compatibility::ensure_builtin_extension_enabled(
227 &STRING_EXPLICIT_GPU_EXTENSION,
228 "string",
229 )?;
230 }
231 if !rest.is_empty() {
232 let extension = if rest.len() == 1 && is_encoding_call_shape(&value, &rest[0]) {
233 &STRING_ENCODING_EXTENSION
234 } else {
235 &STRING_FORMAT_EXTENSION
236 };
237 crate::compatibility::ensure_builtin_extension_enabled(extension, "string")?;
238 }
239 if rest.is_empty() {
240 let gathered = gather_if_needed_async(&value)
241 .await
242 .map_err(|flow| remap_string_flow(flow))?;
243 let array = convert_to_string_array(gathered, StringEncoding::Utf8).await?;
244 return Ok(Value::StringArray(array));
245 }
246
247 let mut args = rest;
248 let format_value = gather_if_needed_async(&value)
249 .await
250 .map_err(|flow| remap_string_flow(flow))?;
251
252 if args.len() == 1 {
253 let arg = args.pop().unwrap();
254 let gathered_arg = gather_if_needed_async(&arg)
255 .await
256 .map_err(|flow| remap_string_flow(flow))?;
257 if let Some(encoding) = try_encoding_argument(&format_value, &gathered_arg)? {
258 let array = convert_to_string_array(format_value, encoding).await?;
259 return Ok(Value::StringArray(array));
260 }
261 let formatted = format_from_spec(format_value, vec![gathered_arg]).await?;
262 return Ok(Value::StringArray(formatted));
263 }
264
265 let mut gathered_args = Vec::with_capacity(args.len());
266 for arg in args {
267 gathered_args.push(
268 gather_if_needed_async(&arg)
269 .await
270 .map_err(|flow| remap_string_flow(flow))?,
271 );
272 }
273 let formatted = format_from_spec(format_value, gathered_args).await?;
274 Ok(Value::StringArray(formatted))
275}
276
277fn is_encoding_call_shape(first: &Value, candidate: &Value) -> bool {
278 if !matches!(
279 first,
280 Value::CharArray(_) | Value::String(_) | Value::StringArray(_) | Value::Cell(_)
281 ) || has_format_placeholders(first)
282 {
283 return false;
284 }
285 if let Value::Cell(cell) = first {
286 if !cell_contains_only_text_scalars(cell) {
287 return false;
288 }
289 }
290 value_to_scalar_text(candidate).is_some()
291}
292
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294enum StringEncoding {
295 Utf8,
296}
297
298fn try_encoding_argument(
299 first: &Value,
300 candidate: &Value,
301) -> BuiltinResult<Option<StringEncoding>> {
302 if !matches!(
303 first,
304 Value::CharArray(_) | Value::String(_) | Value::StringArray(_) | Value::Cell(_)
305 ) {
306 return Ok(None);
307 }
308 if has_format_placeholders(first) {
309 return Ok(None);
310 }
311 if let Value::Cell(cell) = first {
312 if !cell_contains_only_text_scalars(cell) {
313 return Ok(None);
314 }
315 }
316 let Some(text) = value_to_scalar_text(candidate) else {
317 return Ok(None);
318 };
319 parse_encoding_text(&text).map(Some)
320}
321
322fn parse_encoding_text(raw: &str) -> BuiltinResult<StringEncoding> {
323 let trimmed = raw.trim();
324 let lowered = trimmed.to_ascii_lowercase();
325 match lowered.as_str() {
326 "utf-8" | "utf8" | "unicode" | "system" => Ok(StringEncoding::Utf8),
327 _ => Err(string_flow(format!(
328 "string: unsupported character encoding '{trimmed}'; only UTF-8 is available"
329 ))),
330 }
331}
332
333fn cell_contains_only_text_scalars(cell: &runmat_value::CellArray) -> bool {
334 cell.data.iter().all(|ptr| match &ptr {
335 Value::String(_) => true,
336 Value::StringArray(sa) => sa.data.len() <= 1,
337 Value::CharArray(ca) => ca.rows <= 1,
338 _ => false,
339 })
340}
341
342fn text_has_format_placeholder(text: &str) -> bool {
343 let mut chars = text.chars().peekable();
344 while let Some(ch) = chars.next() {
345 if ch != '%' {
346 continue;
347 }
348 if let Some('%') = chars.peek() {
349 chars.next();
350 continue;
351 }
352 while matches!(chars.peek(), Some(flag) if matches!(flag, '+' | '-' | '0' | '#')) {
353 chars.next();
354 }
355 while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
356 chars.next();
357 }
358 if let Some('.') = chars.peek() {
359 chars.next();
360 while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
361 chars.next();
362 }
363 }
364 if let Some(conv) = chars.peek() {
365 if conv.is_ascii_alphabetic() {
366 return true;
367 }
368 }
369 }
370 false
371}
372
373fn has_format_placeholders(value: &Value) -> bool {
374 match value {
375 Value::String(s) => text_has_format_placeholder(s),
376 Value::StringArray(sa) => sa.data.iter().any(|s| text_has_format_placeholder(s)),
377 Value::CharArray(ca) => {
378 for row in 0..ca.rows {
379 let mut row_str = String::with_capacity(ca.cols);
380 for col in 0..ca.cols {
381 row_str.push(ca.data[row * ca.cols + col]);
382 }
383 if text_has_format_placeholder(&row_str) {
384 return true;
385 }
386 }
387 false
388 }
389 Value::Cell(cell) => {
390 for ptr in &cell.data {
391 let element = (ptr).clone();
392 if has_format_placeholders(&element) {
393 return true;
394 }
395 }
396 false
397 }
398 _ => false,
399 }
400}
401
402pub(crate) struct FormatSpecData {
403 pub(crate) specs: Vec<String>,
404 pub(crate) shape: Vec<usize>,
405}
406
407struct ArgumentData {
408 values: Vec<Value>,
409 shape: Vec<usize>,
410}
411
412fn string_flow(message: impl Into<String>) -> RuntimeError {
413 string_error_with_detail(&STRING_ERROR_INVALID_INPUT, message)
414}
415
416fn string_error_with_detail(
417 error: &'static BuiltinErrorDescriptor,
418 detail: impl Into<String>,
419) -> RuntimeError {
420 let detail = detail.into();
421 let message = if detail.starts_with("string:") {
422 detail
423 } else {
424 format!("{}: {detail}", error.message)
425 };
426 let mut builder = build_runtime_error(message).with_builtin("string");
427 if let Some(identifier) = error.identifier {
428 builder = builder.with_identifier(identifier);
429 }
430 builder.build()
431}
432
433fn remap_string_flow(err: RuntimeError) -> RuntimeError {
434 map_control_flow_with_builtin(err, "string")
435}
436
437pub(crate) async fn format_from_spec(
438 format_value: Value,
439 args: Vec<Value>,
440) -> crate::BuiltinResult<StringArray> {
441 let spec = extract_format_spec(format_value).await?;
442 let mut arguments = Vec::with_capacity(args.len());
443 for arg in args {
444 arguments.push(extract_argument_data(arg).await?);
445 }
446
447 let (target_len, mut target_shape) = resolve_target_shape(&spec, &arguments)?;
448
449 if target_len == 0 {
450 let shape = if target_shape.is_empty() {
451 if spec.shape.is_empty() {
452 vec![0, 0]
453 } else {
454 spec.shape.clone()
455 }
456 } else {
457 target_shape
458 };
459 return StringArray::new(Vec::new(), shape)
460 .map_err(|e| string_flow(format!("string: {e}")));
461 }
462
463 let spec_len = spec.specs.len();
464 if spec_len == 0 {
465 return Err(string_flow(
466 "string: formatSpec must contain at least one element when formatting with data",
467 ));
468 }
469
470 for arg in &arguments {
471 if target_len > 0 && arg.values.is_empty() {
472 return Err(string_flow(
473 "string: format data arguments must be scalars or match formatSpec size",
474 ));
475 }
476 }
477
478 let mut output = Vec::with_capacity(target_len);
479 for idx in 0..target_len {
480 let spec_idx = if spec_len == 1 { 0 } else { idx };
481 let spec_str = &spec.specs[spec_idx];
482 let mut per_call = Vec::with_capacity(arguments.len());
483 for arg in &arguments {
484 let value =
485 match arg.values.len() {
486 0 => continue,
487 1 => arg.values[0].clone(),
488 len if len == target_len => arg.values[idx].clone(),
489 _ => return Err(string_flow(
490 "string: format data arguments must be scalars or match formatSpec size",
491 )),
492 };
493 per_call.push(value);
494 }
495 let formatted =
496 format_variadic(spec_str, &per_call).map_err(|flow| remap_string_flow(flow))?;
497 output.push(formatted);
498 }
499
500 if target_shape.is_empty() {
501 target_shape = if spec_len > 1 {
502 spec.shape.clone()
503 } else {
504 vec![target_len, 1]
505 };
506 }
507
508 if tensor::element_count(&target_shape) != target_len {
509 target_shape = vec![target_len, 1];
510 }
511
512 StringArray::new(output, target_shape).map_err(|e| string_flow(format!("string: {e}")))
513}
514
515fn resolve_target_shape(
516 spec: &FormatSpecData,
517 args: &[ArgumentData],
518) -> BuiltinResult<(usize, Vec<usize>)> {
519 let mut target_len = spec.specs.len();
520 let mut target_shape = if target_len > 1 || (target_len == 1 && !spec.shape.is_empty()) {
521 spec.shape.clone()
522 } else {
523 Vec::new()
524 };
525
526 for arg in args {
527 let len = arg.values.len();
528 if len == 0 {
529 continue;
530 }
531 if target_len == 0 {
532 target_len = len;
533 target_shape = arg.shape.clone();
534 continue;
535 }
536 if len == 1 {
537 continue;
538 }
539 if target_len == 1 {
540 target_len = len;
541 target_shape = arg.shape.clone();
542 continue;
543 }
544 if len != target_len {
545 return Err(string_flow(
546 "string: format data arguments must be scalars or match formatSpec size",
547 ));
548 }
549 if target_shape.is_empty() && len > 1 {
550 target_shape = arg.shape.clone();
551 }
552 }
553
554 if target_len == 0 {
555 let shape = if spec.shape.is_empty() {
556 vec![0, 0]
557 } else {
558 spec.shape.clone()
559 };
560 return Ok((0, shape));
561 }
562
563 if target_shape.is_empty() {
564 target_shape = if spec.shape.is_empty() {
565 vec![target_len, 1]
566 } else {
567 spec.shape.clone()
568 };
569 if spec.specs.len() == 1 && tensor::element_count(&target_shape) != target_len {
570 target_shape = vec![target_len, 1];
571 }
572 }
573
574 if tensor::element_count(&target_shape) != target_len {
575 target_shape = vec![target_len, 1];
576 }
577
578 Ok((target_len, target_shape))
579}
580
581pub(crate) async fn extract_format_spec(value: Value) -> BuiltinResult<FormatSpecData> {
582 match value {
583 Value::String(s) => Ok(FormatSpecData {
584 specs: vec![s],
585 shape: vec![1, 1],
586 }),
587 Value::StringArray(sa) => Ok(FormatSpecData {
588 specs: sa.data.clone(),
589 shape: sa.shape.clone(),
590 }),
591 Value::CharArray(ca) => {
592 let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
593 Ok(FormatSpecData {
594 specs: array.data,
595 shape: array.shape,
596 })
597 }
598 Value::Cell(cell) => {
599 let mut specs = Vec::with_capacity(cell.data.len());
600 for col in 0..cell.cols {
601 for row in 0..cell.rows {
602 let idx = row * cell.cols + col;
603 let element = &cell.data[idx];
604 let value = (element).clone();
605 let gathered = gather_if_needed_async(&value)
606 .await
607 .map_err(|flow| remap_string_flow(flow))?;
608 let text = value_to_scalar_text(&gathered).ok_or_else(|| {
609 string_flow("string: formatSpec cell elements must be text scalars")
610 })?;
611 specs.push(text);
612 }
613 }
614 Ok(FormatSpecData {
615 specs,
616 shape: vec![cell.rows, cell.cols],
617 })
618 }
619 _ => Err(string_flow(
620 "string: formatSpec must be text (string, char, or cellstr)",
621 )),
622 }
623}
624
625#[async_recursion::async_recursion(?Send)]
626async fn extract_argument_data(value: Value) -> BuiltinResult<ArgumentData> {
627 match value {
628 Value::String(s) => Ok(ArgumentData {
629 values: vec![Value::String(s)],
630 shape: vec![1, 1],
631 }),
632 Value::StringArray(sa) => Ok(ArgumentData {
633 values: sa.data.into_iter().map(Value::String).collect(),
634 shape: sa.shape,
635 }),
636 Value::CharArray(ca) => {
637 let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
638 Ok(ArgumentData {
639 values: array.data.into_iter().map(Value::String).collect(),
640 shape: array.shape,
641 })
642 }
643 Value::Symbolic(expr) => Ok(ArgumentData {
644 values: vec![Value::String(expr.to_string())],
645 shape: vec![1, 1],
646 }),
647 Value::SymbolicArray(array) => Ok(ArgumentData {
648 values: array
649 .data
650 .into_iter()
651 .map(|expr| Value::String(expr.to_string()))
652 .collect(),
653 shape: array.shape,
654 }),
655 Value::Num(n) => Ok(ArgumentData {
656 values: vec![Value::Num(n)],
657 shape: vec![1, 1],
658 }),
659 Value::Int(i) => Ok(ArgumentData {
660 values: vec![Value::Int(i)],
661 shape: vec![1, 1],
662 }),
663 Value::Bool(b) => Ok(ArgumentData {
664 values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
665 shape: vec![1, 1],
666 }),
667 Value::Tensor(tensor) => tensor_into_argument_data(tensor),
668 Value::SparseTensor(s) => {
669 ensure_sparse_dense_conversion(&s, "format argument")?;
670 if s.is_logical() {
671 let logical = s.to_dense_logical().map_err(string_flow)?;
672 Ok(ArgumentData {
673 values: logical
674 .data
675 .into_iter()
676 .map(|value| Value::Num(f64::from(value)))
677 .collect(),
678 shape: logical.shape,
679 })
680 } else {
681 tensor_into_argument_data(s.to_dense().map_err(string_flow)?)
682 }
683 }
684 Value::Complex(re, im) => Ok(ArgumentData {
685 values: vec![Value::String(complex_to_string(re, im))],
686 shape: vec![1, 1],
687 }),
688 Value::ComplexTensor(t) => Ok(ArgumentData {
689 values: t
690 .materialize_f64()
691 .into_iter()
692 .map(|(re, im)| Value::String(complex_to_string(re, im)))
693 .collect(),
694 shape: t.shape,
695 }),
696 Value::LogicalArray(la) => Ok(ArgumentData {
697 values: la
698 .data
699 .into_iter()
700 .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
701 .collect(),
702 shape: la.shape,
703 }),
704 Value::Cell(cell) => {
705 let mut values = Vec::with_capacity(cell.data.len());
706 for col in 0..cell.cols {
707 for row in 0..cell.rows {
708 let idx = row * cell.cols + col;
709 let element = &cell.data[idx];
710 let value = (element).clone();
711 let gathered = gather_if_needed_async(&value)
712 .await
713 .map_err(|flow| remap_string_flow(flow))?;
714 let value = match gathered {
715 Value::String(s) => Value::String(s),
716 Value::StringArray(sa) if sa.data.len() == 1 => {
717 Value::String(sa.data[0].clone())
718 }
719 Value::CharArray(ca) => {
720 if ca.rows != 1 {
721 return Err(string_flow(
722 "string: cell format arguments must contain char row vectors",
723 ));
724 }
725 let mut row_str = String::with_capacity(ca.cols);
726 for ch in ca.data {
727 row_str.push(ch);
728 }
729 Value::String(row_str)
730 }
731 Value::Num(n) => Value::Num(n),
732 Value::Int(i) => Value::Int(i),
733 Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
734 Value::Tensor(t) => {
735 if !tensor::is_scalar_tensor(&t) {
736 return Err(string_flow(
737 "string: cell format arguments must contain scalar values",
738 ));
739 }
740 Value::Num(tensor::tensor_value_f64(&t, 0))
741 }
742 Value::LogicalArray(la) => {
743 if la.data.len() != 1 {
744 return Err(string_flow(
745 "string: cell format arguments must contain scalar values",
746 ));
747 }
748 Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
749 }
750 Value::Complex(re, im) => Value::String(complex_to_string(re, im)),
751 Value::Symbolic(expr) => Value::String(expr.to_string()),
752 Value::ComplexTensor(t) => {
753 if !complex_tensor_is_scalar(&t) {
754 return Err(string_flow(
755 "string: cell format arguments must contain scalar values",
756 ));
757 }
758 Value::String(t.format_element(0))
759 }
760 other => {
761 return Err(string_flow(format!(
762 "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
763 )))
764 }
765 };
766 values.push(value);
767 }
768 }
769 Ok(ArgumentData {
770 values,
771 shape: vec![cell.rows, cell.cols],
772 })
773 }
774 Value::GpuTensor(handle) => {
775 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
776 .await
777 .map_err(|flow| remap_string_flow(flow))?;
778 extract_argument_data(gathered).await
779 }
780 Value::MException(_)
781 | Value::HandleObject(_)
782 | Value::ObjectArray(_)
783 | Value::Object(_)
784 | Value::Listener(_)
785 | Value::Struct(_)
786 | Value::OutputList(_)
787 | Value::Future(_)
788 | Value::Task(_)
789 | Value::Pool(_)
790 | Value::Job(_)
791 | Value::Foreign(_) => Err(string_flow("string: unsupported format argument type")),
792 Value::FunctionHandle(_)
793 | Value::ExternalFunctionHandle(_)
794 | Value::MethodFunctionHandle(_)
795 | Value::BoundFunctionHandle { .. }
796 | Value::Closure(_)
797 | Value::ClassRef(_) => Err(string_flow("string: unsupported format argument type")),
798 }
799}
800
801#[async_recursion::async_recursion(?Send)]
802async fn convert_to_string_array(
803 value: Value,
804 encoding: StringEncoding,
805) -> BuiltinResult<StringArray> {
806 if let Some(array) = crate::builtins::datetime::datetime_string_array(&value)
807 .map_err(|err| string_flow(err.message().to_string()))?
808 {
809 return Ok(array);
810 }
811 if let Some(array) = crate::builtins::duration::duration_string_array(&value)
812 .map_err(|err| string_flow(err.message().to_string()))?
813 {
814 return Ok(array);
815 }
816 match value {
817 Value::String(s) => string_scalar(s),
818 Value::StringArray(sa) => Ok(sa),
819 Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
820 Value::Symbolic(expr) => string_scalar(expr.to_string()),
821 Value::SymbolicArray(array) => StringArray::new(
822 array.data.into_iter().map(|expr| expr.to_string()).collect(),
823 array.shape,
824 )
825 .map_err(|e| string_flow(format!("string: {e}"))),
826 Value::Tensor(tensor) => tensor_to_string_array(tensor),
827 Value::SparseTensor(sparse) => {
828 ensure_sparse_dense_conversion(&sparse, "dense string array")?;
829 if sparse.is_logical() {
830 logical_array_to_string_array(sparse.to_dense_logical().map_err(string_flow)?)
831 } else {
832 tensor_to_string_array(sparse.to_dense().map_err(string_flow)?)
833 }
834 }
835 Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
836 Value::LogicalArray(logical) => logical_array_to_string_array(logical),
837 Value::Cell(cell) => cell_array_to_string_array(cell, encoding).await,
838 Value::Num(n) => string_scalar(number_to_string(n)),
839 Value::Int(i) => string_scalar(int_value_to_string(&i)),
840 Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
841 Value::Complex(re, im) => string_scalar(complex_to_string(re, im)),
842 Value::GpuTensor(handle) => {
843 let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
845 .await
846 .map_err(|flow| remap_string_flow(flow))?;
847 convert_to_string_array(gathered, encoding).await
848 }
849 Value::ObjectArray(_) | Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(string_flow(
850 "string: unsupported conversion from handle-based objects. Use class-specific formatters.",
851 )),
852 Value::Struct(_) => Err(string_flow(
853 "string: structs are not supported for automatic conversion",
854 )),
855 Value::FunctionHandle(_) | Value::ExternalFunctionHandle(_) | Value::MethodFunctionHandle(_) | Value::BoundFunctionHandle { .. }
856 | Value::Closure(_)
857 | Value::ClassRef(_)
858 | Value::MException(_)
859 | Value::Future(_)
860 | Value::Task(_)
861 | Value::Pool(_)
862 | Value::Job(_)
863 | Value::Foreign(_)
864 | Value::OutputList(_) => Err(
865 string_flow("string: unsupported conversion for function or exception handles"),
866 ),
867 }
868}
869
870fn string_scalar<S: Into<String>>(text: S) -> BuiltinResult<StringArray> {
871 StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| string_flow(format!("string: {e}")))
872}
873
874fn value_to_scalar_text(value: &Value) -> Option<String> {
875 match value {
876 Value::String(s) => Some(s.clone()),
877 Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
878 Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
879 _ => None,
880 }
881}
882
883fn char_array_to_string_array(
884 array: CharArray,
885 _encoding: StringEncoding,
886) -> BuiltinResult<StringArray> {
887 let mut rows: Vec<String> = Vec::with_capacity(array.rows);
888 for r in 0..array.rows {
889 let mut row = String::with_capacity(array.cols);
890 for c in 0..array.cols {
891 row.push(array.data[r * array.cols + c]);
892 }
893 rows.push(row);
894 }
895 let shape = if array.rows == 0 {
896 vec![0, 1]
897 } else {
898 vec![array.rows, 1]
899 };
900 StringArray::new(rows, shape).map_err(|e| string_flow(format!("string: {e}")))
901}
902
903fn tensor_to_string_array(tensor: Tensor) -> BuiltinResult<StringArray> {
904 let shape = tensor.shape.clone();
905 let storage = tensor.into_numeric_storage().map_err(string_flow)?;
906 let mut strings = Vec::with_capacity(storage.len());
907 for idx in 0..storage.len() {
908 let value = storage
909 .value_at(idx)
910 .ok_or_else(|| string_flow("string: numeric tensor storage is inconsistent"))?;
911 strings.push(numeric_scalar_to_string(value));
912 }
913 StringArray::new(strings, shape).map_err(|e| string_flow(format!("string: {e}")))
914}
915
916fn tensor_into_argument_data(tensor: Tensor) -> BuiltinResult<ArgumentData> {
917 let shape = tensor.shape.clone();
918 let storage = tensor.into_numeric_storage().map_err(string_flow)?;
919 let mut values = Vec::with_capacity(storage.len());
920 for idx in 0..storage.len() {
921 let value = storage
922 .value_at(idx)
923 .ok_or_else(|| string_flow("string: numeric tensor storage is inconsistent"))?;
924 values.push(numeric_scalar_to_value(value));
925 }
926 Ok(ArgumentData { values, shape })
927}
928
929fn numeric_scalar_to_value(value: NumericScalar) -> Value {
930 match value {
931 NumericScalar::F64(value) => Value::Num(value),
932 NumericScalar::F32(value) => Value::Num(f64::from(value)),
933 integer => Value::Int(
934 integer
935 .into_int_value()
936 .expect("non-floating numeric scalar is integer"),
937 ),
938 }
939}
940
941fn numeric_scalar_to_string(value: NumericScalar) -> String {
942 match value {
943 NumericScalar::F64(value) => number_to_string(value),
944 NumericScalar::F32(value) => number_to_string(f64::from(value)),
945 integer => int_value_to_string(
946 &integer
947 .into_int_value()
948 .expect("non-floating numeric scalar is integer"),
949 ),
950 }
951}
952
953fn complex_tensor_to_string_array(tensor: ComplexTensor) -> BuiltinResult<StringArray> {
954 let len = tensor::complex_tensor_element_len(&tensor);
955 let mut strings = Vec::with_capacity(len);
956 for idx in 0..len {
957 strings.push(tensor.format_element(idx));
958 }
959 StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
960}
961
962fn logical_array_to_string_array(logical: LogicalArray) -> BuiltinResult<StringArray> {
963 let mut strings = Vec::with_capacity(logical.data.len());
964 for &byte in &logical.data {
965 strings.push(bool_to_string(byte != 0).to_string());
966 }
967 StringArray::new(strings, logical.shape).map_err(|e| string_flow(format!("string: {e}")))
968}
969
970async fn cell_array_to_string_array(
971 cell: runmat_value::CellArray,
972 _encoding: StringEncoding,
973) -> BuiltinResult<StringArray> {
974 let mut strings = Vec::with_capacity(cell.data.len());
975 for col in 0..cell.cols {
976 for row in 0..cell.rows {
977 let idx = row * cell.cols + col;
978 let element = &cell.data[idx];
979 let value = (element).clone();
980 let gathered = gather_if_needed_async(&value)
981 .await
982 .map_err(|flow| remap_string_flow(flow))?;
983 strings.push(cell_element_to_string(&gathered)?);
984 }
985 }
986 StringArray::new(strings, vec![cell.rows, cell.cols])
987 .map_err(|e| string_flow(format!("string: {e}")))
988}
989
990fn cell_element_to_string(value: &Value) -> BuiltinResult<String> {
991 if let Some(array) = crate::builtins::datetime::datetime_string_array(value)
992 .map_err(|err| string_flow(err.message().to_string()))?
993 {
994 if array.data.len() == 1 {
995 return Ok(array.data[0].clone());
996 }
997 return Err(string_flow("string: cell datetime values must be scalar"));
998 }
999 if let Some(array) = crate::builtins::duration::duration_string_array(value)
1000 .map_err(|err| string_flow(err.message().to_string()))?
1001 {
1002 if array.data.len() == 1 {
1003 return Ok(array.data[0].clone());
1004 }
1005 return Err(string_flow("string: cell duration values must be scalar"));
1006 }
1007 match value {
1008 Value::String(s) => Ok(s.clone()),
1009 Value::StringArray(sa) => {
1010 if sa.data.len() == 1 {
1011 Ok(sa.data[0].clone())
1012 } else {
1013 Err(string_flow(
1014 "string: cell elements must contain string scalars, not string arrays",
1015 ))
1016 }
1017 }
1018 Value::CharArray(ca) => {
1019 if ca.rows == 1 {
1020 Ok(ca.data.iter().collect())
1021 } else {
1022 Err(string_flow(
1023 "string: cell character arrays must be row vectors",
1024 ))
1025 }
1026 }
1027 Value::Num(n) => Ok(number_to_string(*n)),
1028 Value::Int(i) => Ok(int_value_to_string(i)),
1029 Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
1030 Value::LogicalArray(array) => {
1031 if array.data.len() == 1 {
1032 Ok(bool_to_string(array.data[0] != 0).to_string())
1033 } else {
1034 Err(string_flow("string: cell logical values must be scalar"))
1035 }
1036 }
1037 Value::Tensor(t) => {
1038 if tensor::is_scalar_tensor(t) {
1039 if let Some(value) = t.integer_storage().and_then(|storage| storage.value_at(0)) {
1040 Ok(int_value_to_string(&value))
1041 } else {
1042 Ok(number_to_string(tensor::tensor_value_f64(t, 0)))
1043 }
1044 } else {
1045 Err(string_flow("string: cell numeric values must be scalar"))
1046 }
1047 }
1048 Value::Complex(re, im) => Ok(complex_to_string(*re, *im)),
1049 Value::ComplexTensor(t) => {
1050 if complex_tensor_is_scalar(t) {
1051 Ok(t.format_element(0))
1052 } else {
1053 Err(string_flow("string: cell complex values must be scalar"))
1054 }
1055 }
1056 other => Err(string_flow(format!(
1057 "string: unsupported cell element type {:?}; expected text or scalar values",
1058 other
1059 ))),
1060 }
1061}
1062
1063fn complex_tensor_is_scalar(tensor: &ComplexTensor) -> bool {
1064 tensor.shape.iter().product::<usize>() == 1
1065}
1066
1067fn ensure_sparse_dense_conversion(sparse: &SparseTensor, target: &str) -> BuiltinResult<()> {
1068 let total_elements = sparse
1069 .rows
1070 .checked_mul(sparse.cols)
1071 .ok_or_else(|| string_flow("string: sparse matrix dimensions overflow"))?;
1072 if total_elements > STRING_SPARSE_DENSE_ELEMENT_LIMIT {
1073 return Err(string_flow(format!(
1074 "string: cannot convert sparse tensor {}x{} with {} stored entries to {target} ({} elements exceeds safe threshold)",
1075 sparse.rows,
1076 sparse.cols,
1077 sparse.nnz(),
1078 total_elements
1079 )));
1080 }
1081 Ok(())
1082}
1083
1084fn bool_to_string(value: bool) -> &'static str {
1085 if value {
1086 "true"
1087 } else {
1088 "false"
1089 }
1090}
1091
1092fn int_value_to_string(value: &IntValue) -> String {
1093 match value {
1094 IntValue::I8(v) => v.to_string(),
1095 IntValue::I16(v) => v.to_string(),
1096 IntValue::I32(v) => v.to_string(),
1097 IntValue::I64(v) => v.to_string(),
1098 IntValue::U8(v) => v.to_string(),
1099 IntValue::U16(v) => v.to_string(),
1100 IntValue::U32(v) => v.to_string(),
1101 IntValue::U64(v) => v.to_string(),
1102 }
1103}
1104
1105#[cfg(test)]
1106pub(crate) mod tests {
1107 use super::*;
1108 use crate::builtins::common::test_support;
1109 use runmat_builtins::{ResolveContext, Type};
1110 use runmat_value::{
1111 CellArray, IntValue, IntegerComplexStorage, IntegerStorage, StringArray, StructValue,
1112 };
1113
1114 fn string_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1115 let _runmat = crate::compatibility::push_runmat_extensions_enabled(true);
1116 futures::executor::block_on(super::string_builtin(value, rest))
1117 }
1118
1119 fn error_message(err: crate::RuntimeError) -> String {
1120 err.message().to_string()
1121 }
1122
1123 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1124 #[test]
1125 fn string_from_numeric_scalar() {
1126 let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
1127 match out {
1128 Value::StringArray(sa) => {
1129 assert_eq!(sa.shape, vec![1, 1]);
1130 assert_eq!(sa.data, vec!["42".to_string()]);
1131 }
1132 other => panic!("expected string array, got {other:?}"),
1133 }
1134 }
1135
1136 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1137 #[test]
1138 fn string_from_numeric_tensor_preserves_shape() {
1139 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
1140 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
1141 match out {
1142 Value::StringArray(sa) => {
1143 assert_eq!(sa.shape, vec![2, 2]);
1144 assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
1145 }
1146 other => panic!("expected string array, got {other:?}"),
1147 }
1148 }
1149
1150 #[test]
1151 fn string_from_logical_sparse_uses_boolean_text() {
1152 let sparse =
1153 SparseTensor::new_logical(2, 2, vec![0, 1, 2], vec![1, 0]).expect("logical sparse");
1154 let out = string_builtin(Value::SparseTensor(sparse), Vec::new()).expect("string");
1155 let Value::StringArray(out) = out else {
1156 panic!("expected string array");
1157 };
1158 assert_eq!(out.shape, vec![2, 2]);
1159 assert_eq!(out.data, vec!["false", "true", "true", "false"]);
1160 }
1161
1162 #[test]
1163 fn string_from_native_single_tensor_uses_authoritative_values() {
1164 let tensor = Tensor::from_f32(vec![1.25, -2.5], vec![1, 2]).expect("native single tensor");
1165 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
1166 match out {
1167 Value::StringArray(array) => {
1168 assert_eq!(array.shape, vec![1, 2]);
1169 assert_eq!(array.data, vec!["1.25", "-2.5"]);
1170 }
1171 other => panic!("expected string array, got {other:?}"),
1172 }
1173 }
1174
1175 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1176 #[test]
1177 fn string_from_integer_tensor_preserves_exact_storage_values() {
1178 let tensor = Tensor::new_integer(
1179 IntegerStorage::U64(vec![u64::MAX, 9_007_199_254_740_993]),
1180 vec![1, 2],
1181 )
1182 .expect("integer tensor");
1183 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
1184 match out {
1185 Value::StringArray(sa) => {
1186 assert_eq!(sa.shape, vec![1, 2]);
1187 assert_eq!(sa.data, vec!["18446744073709551615", "9007199254740993"]);
1188 }
1189 other => panic!("expected string array, got {other:?}"),
1190 }
1191 }
1192
1193 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1194 #[test]
1195 fn string_from_signed_integer_tensor_preserves_exact_storage_values() {
1196 let tensor = Tensor::new_integer(
1197 IntegerStorage::I64(vec![i64::MIN, -9_007_199_254_740_993]),
1198 vec![1, 2],
1199 )
1200 .expect("integer tensor");
1201 let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
1202 match out {
1203 Value::StringArray(sa) => {
1204 assert_eq!(sa.shape, vec![1, 2]);
1205 assert_eq!(sa.data, vec!["-9223372036854775808", "-9007199254740993"]);
1206 }
1207 other => panic!("expected string array, got {other:?}"),
1208 }
1209 }
1210
1211 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1212 #[test]
1213 fn string_from_complex_integer_tensor_preserves_exact_storage_values() {
1214 let storage = IntegerComplexStorage::new(
1215 IntegerStorage::U64(vec![u64::MAX, 9_007_199_254_740_993]),
1216 IntegerStorage::U64(vec![7, 0]),
1217 )
1218 .expect("matching integer complex storage");
1219 let tensor = ComplexTensor::new_integer(storage, vec![1, 2]).expect("complex integer");
1220 let out = string_builtin(Value::ComplexTensor(tensor), Vec::new()).expect("string");
1221 match out {
1222 Value::StringArray(sa) => {
1223 assert_eq!(sa.shape, vec![1, 2]);
1224 assert_eq!(sa.data, vec!["18446744073709551615+7i", "9007199254740993"]);
1225 }
1226 other => panic!("expected string array, got {other:?}"),
1227 }
1228 }
1229
1230 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1231 #[test]
1232 fn string_from_logical_array_uses_boolean_text() {
1233 let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
1234 let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
1235 match out {
1236 Value::StringArray(sa) => {
1237 assert_eq!(sa.shape, vec![1, 3]);
1238 assert_eq!(sa.data, vec!["true", "false", "true"]);
1239 }
1240 other => panic!("expected string array, got {other:?}"),
1241 }
1242 }
1243
1244 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1245 #[test]
1246 fn string_from_char_array_produces_column_vector() {
1247 let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
1248 let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
1249 match out {
1250 Value::StringArray(sa) => {
1251 assert_eq!(sa.shape, vec![1, 1]);
1252 assert_eq!(sa.data, vec!["abc"]);
1253 }
1254 other => panic!("expected string array, got {other:?}"),
1255 }
1256 }
1257
1258 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1259 #[test]
1260 fn string_from_cell_array() {
1261 let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
1262 .expect("cell array");
1263 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1264 match out {
1265 Value::StringArray(sa) => {
1266 assert_eq!(sa.shape, vec![1, 2]);
1267 assert_eq!(sa.data, vec!["true", "7"]);
1268 }
1269 other => panic!("expected string array, got {other:?}"),
1270 }
1271 }
1272
1273 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1274 #[test]
1275 fn string_from_cell_array_column_major() {
1276 let cell = CellArray::new(
1277 vec![
1278 Value::Int(IntValue::I32(1)),
1279 Value::Int(IntValue::I32(2)),
1280 Value::Int(IntValue::I32(3)),
1281 Value::Int(IntValue::I32(4)),
1282 ],
1283 2,
1284 2,
1285 )
1286 .expect("cell array");
1287 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1288 match out {
1289 Value::StringArray(sa) => {
1290 assert_eq!(sa.shape, vec![2, 2]);
1291 assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
1292 }
1293 other => panic!("expected string array, got {other:?}"),
1294 }
1295 }
1296
1297 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1298 #[test]
1299 fn string_from_cell_scalar_integer_tensor_uses_exact_storage() {
1300 let tensor =
1301 Tensor::new_integer(IntegerStorage::U64(vec![9_007_199_254_740_993]), vec![1, 1])
1302 .expect("integer tensor");
1303 let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1)
1304 .expect("cell with scalar integer tensor");
1305 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1306 match out {
1307 Value::StringArray(sa) => {
1308 assert_eq!(sa.shape, vec![1, 1]);
1309 assert_eq!(sa.data, vec!["9007199254740993"]);
1310 }
1311 other => panic!("expected string array, got {other:?}"),
1312 }
1313 }
1314
1315 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1316 #[test]
1317 fn string_from_cell_scalar_complex_integer_tensor_uses_exact_storage() {
1318 let storage = IntegerComplexStorage::new(
1319 IntegerStorage::I64(vec![1]),
1320 IntegerStorage::I64(vec![i64::MIN]),
1321 )
1322 .expect("matching integer complex storage");
1323 let tensor = ComplexTensor::new_integer(storage, vec![1, 1]).expect("complex integer");
1324 let cell = CellArray::new(vec![Value::ComplexTensor(tensor)], 1, 1)
1325 .expect("cell with scalar complex integer tensor");
1326 let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1327 match out {
1328 Value::StringArray(sa) => {
1329 assert_eq!(sa.shape, vec![1, 1]);
1330 assert_eq!(sa.data, vec![format!("1-{}i", i64::MIN.unsigned_abs())]);
1331 }
1332 other => panic!("expected string array, got {other:?}"),
1333 }
1334 }
1335
1336 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1337 #[test]
1338 fn string_cell_element_requires_scalar_numeric() {
1339 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1340 let cell =
1341 CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
1342 let err = error_message(string_builtin(Value::Cell(cell), Vec::new()).unwrap_err());
1343 assert!(err.contains("cell numeric values must be scalar"));
1344 }
1345
1346 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1347 #[test]
1348 fn string_rejects_struct_input() {
1349 let err = error_message(
1350 string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string"),
1351 );
1352 assert!(err.contains("structs are not supported"));
1353 }
1354
1355 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1356 #[test]
1357 fn string_errors_on_unsupported_encoding() {
1358 let err = error_message(
1359 string_builtin(
1360 Value::CharArray(CharArray::new_row("abc")),
1361 vec![Value::from("UTF-16")],
1362 )
1363 .unwrap_err(),
1364 );
1365 assert!(
1366 err.contains("unsupported character encoding"),
1367 "unexpected error message: {err}"
1368 );
1369 }
1370
1371 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1372 #[test]
1373 fn string_accepts_system_encoding_alias() {
1374 let out = string_builtin(
1375 Value::CharArray(CharArray::new_row("hello")),
1376 vec![Value::from("system")],
1377 )
1378 .expect("string");
1379 match out {
1380 Value::StringArray(sa) => {
1381 assert_eq!(sa.shape, vec![1, 1]);
1382 assert_eq!(sa.data, vec!["hello"]);
1383 }
1384 other => panic!("expected string array, got {other:?}"),
1385 }
1386 }
1387
1388 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1389 #[test]
1390 fn string_encoding_allows_percent_literal() {
1391 let out = string_builtin(
1392 Value::CharArray(CharArray::new_row("100% Done")),
1393 vec![Value::from("utf8")],
1394 )
1395 .expect("string");
1396 match out {
1397 Value::StringArray(sa) => {
1398 assert_eq!(sa.shape, vec![1, 1]);
1399 assert_eq!(sa.data, vec!["100% Done"]);
1400 }
1401 other => panic!("expected string array, got {other:?}"),
1402 }
1403 }
1404
1405 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1406 #[test]
1407 fn string_format_spec_cell_requires_text_scalars() {
1408 let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1409 let err = error_message(
1410 string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string"),
1411 );
1412 assert!(
1413 err.contains("formatSpec cell elements must be text scalars"),
1414 "unexpected error: {err}"
1415 );
1416 }
1417
1418 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1419 #[test]
1420 fn string_format_cell_argument_requires_scalar_values() {
1421 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1422 let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1423 let err = error_message(
1424 string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string"),
1425 );
1426 assert!(err.contains("cell format arguments must contain scalar values"));
1427 }
1428
1429 #[test]
1430 fn string_rejects_oversized_sparse_tensor_before_densifying() {
1431 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1432 let err = string_builtin(Value::SparseTensor(sparse), Vec::new()).unwrap_err();
1433
1434 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1435 assert!(err.message().contains("exceeds safe threshold"));
1436 }
1437
1438 #[test]
1439 fn string_format_rejects_oversized_sparse_argument_before_densifying() {
1440 let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1441 let err = string_builtin(Value::from("%g"), vec![Value::SparseTensor(sparse)]).unwrap_err();
1442
1443 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1444 assert!(err.message().contains("format argument"));
1445 assert!(err.message().contains("exceeds safe threshold"));
1446 }
1447
1448 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1449 #[test]
1450 fn string_handles_large_unsigned_int() {
1451 let value = Value::Int(IntValue::U64(u64::MAX));
1452 let out = string_builtin(value, Vec::new()).expect("string");
1453 match out {
1454 Value::StringArray(sa) => {
1455 assert_eq!(sa.shape, vec![1, 1]);
1456 assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1457 }
1458 other => panic!("expected string array, got {other:?}"),
1459 }
1460 }
1461
1462 #[test]
1463 fn string_descriptor_signatures_cover_core_forms() {
1464 let labels: Vec<&str> = STRING_DESCRIPTOR
1465 .signatures
1466 .iter()
1467 .map(|signature| signature.label)
1468 .collect();
1469 assert_eq!(
1470 labels,
1471 vec![
1472 "S = string(X)",
1473 "S = string(X, encoding)",
1474 "S = string(formatSpec, A...)",
1475 ]
1476 );
1477
1478 let codes: Vec<&str> = STRING_DESCRIPTOR
1479 .errors
1480 .iter()
1481 .map(|error| error.code)
1482 .collect();
1483 assert_eq!(codes, vec!["RM.STRING.INVALID_INPUT"]);
1484 }
1485
1486 #[test]
1487 fn string_struct_input_uses_stable_identifier() {
1488 let err = string_builtin(Value::Struct(StructValue::new()), Vec::new()).unwrap_err();
1489 assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1490 }
1491
1492 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1493 #[test]
1494 fn string_format_numeric_scalar() {
1495 let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1496 match out {
1497 Value::StringArray(sa) => {
1498 assert_eq!(sa.shape, vec![1, 1]);
1499 assert_eq!(sa.data, vec!["7"]);
1500 }
1501 other => panic!("expected string array, got {other:?}"),
1502 }
1503 }
1504
1505 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1506 #[test]
1507 fn string_format_broadcast_over_tensor() {
1508 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1509 let out =
1510 string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1511 match out {
1512 Value::StringArray(sa) => {
1513 assert_eq!(sa.shape, vec![1, 3]);
1514 assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1515 }
1516 other => panic!("expected string array, got {other:?}"),
1517 }
1518 }
1519
1520 #[test]
1521 fn string_format_integer_tensor_preserves_wide_values() {
1522 let tensor = Tensor::new_integer(
1523 IntegerStorage::U64(vec![u64::MAX, 9_007_199_254_740_993]),
1524 vec![1, 2],
1525 )
1526 .expect("integer tensor");
1527 let out = string_builtin(Value::from("%u"), vec![Value::Tensor(tensor)]).expect("string");
1528 match out {
1529 Value::StringArray(array) => {
1530 assert_eq!(array.shape, vec![1, 2]);
1531 assert_eq!(
1532 array.data,
1533 vec![u64::MAX.to_string(), "9007199254740993".to_string()]
1534 );
1535 }
1536 other => panic!("expected string array, got {other:?}"),
1537 }
1538 }
1539
1540 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1541 #[test]
1542 fn string_format_string_array_spec_alignment() {
1543 let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1544 let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1545 let out =
1546 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1547 match out {
1548 Value::StringArray(sa) => {
1549 assert_eq!(sa.shape, vec![1, 2]);
1550 assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1551 }
1552 other => panic!("expected string array, got {other:?}"),
1553 }
1554 }
1555
1556 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1557 #[test]
1558 fn string_format_prefers_placeholders_over_encoding_hint() {
1559 let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1560 match out {
1561 Value::StringArray(sa) => {
1562 assert_eq!(sa.shape, vec![1, 1]);
1563 assert_eq!(sa.data, vec!["UTF-8"]);
1564 }
1565 other => panic!("expected string array, got {other:?}"),
1566 }
1567 }
1568
1569 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1570 #[test]
1571 fn string_format_mismatched_lengths_errors() {
1572 let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1573 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1574 let err = error_message(
1575 string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err(),
1576 );
1577 assert!(err.contains("must be scalars or match formatSpec size"));
1578 }
1579
1580 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1581 #[test]
1582 fn string_gpu_numeric_tensor() {
1583 test_support::with_test_provider(|provider| {
1584 let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1585 let data = tensor.as_f64_slice().expect("double tensor");
1586 let view = runmat_accelerate_api::HostTensorView {
1587 data,
1588 shape: &tensor.shape,
1589 };
1590 let handle = provider.upload(&view).expect("upload");
1591 let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1592 .expect("gpu string conversion");
1593 match result {
1594 Value::StringArray(sa) => {
1595 assert_eq!(sa.shape, vec![1, 2]);
1596 assert_eq!(sa.data, vec!["10", "20"]);
1597 }
1598 other => panic!("expected string array, got {other:?}"),
1599 }
1600 });
1601 }
1602
1603 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1604 #[test]
1605 fn string_strict_mode_gates_explicit_gpu_before_provider_access() {
1606 let handle = runmat_accelerate_api::GpuTensorHandle {
1607 shape: vec![1, 1],
1608 device_id: u32::MAX,
1609 buffer_id: u64::MAX - 452,
1610 descriptor: Default::default(),
1611 };
1612 let handle = handle.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
1613 let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
1614 let error = futures::executor::block_on(super::string_builtin(
1615 Value::GpuTensor(handle),
1616 Vec::new(),
1617 ))
1618 .expect_err("strict mode rejects explicit GPU input before gather");
1619 assert_eq!(
1620 error.identifier(),
1621 STRING_EXPLICIT_GPU_EXTENSION.error_identifier
1622 );
1623 }
1624
1625 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1626 #[test]
1627 #[cfg(feature = "wgpu")]
1628 fn string_wgpu_numeric_tensor_matches_cpu() {
1629 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1630 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1631 );
1632 let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1633 let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1634 .expect("cpu string conversion");
1635 let data = tensor.as_f64_slice().expect("double tensor");
1636 let view = runmat_accelerate_api::HostTensorView {
1637 data,
1638 shape: &tensor.shape,
1639 };
1640 let handle = runmat_accelerate_api::provider()
1641 .unwrap()
1642 .upload(&view)
1643 .expect("gpu upload");
1644 let gpu =
1645 string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1646 match (cpu, gpu) {
1647 (Value::StringArray(expect), Value::StringArray(actual)) => {
1648 assert_eq!(actual.shape, expect.shape);
1649 assert_eq!(actual.data, expect.data);
1650 }
1651 other => panic!("unexpected results {other:?}"),
1652 }
1653 }
1654
1655 #[test]
1656 fn string_type_is_string_array() {
1657 assert_eq!(
1658 string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1659 Type::cell_of(Type::String)
1660 );
1661 }
1662}