Skip to main content

runmat_runtime/builtins/strings/core/
string.rs

1//! MATLAB-compatible `string` builtin with GPU-aware conversion semantics for RunMat.
2
3use 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            // Defensive fallback: gather and retry.
844            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}