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