Skip to main content

runmat_runtime/builtins/strings/core/
strings.rs

1//! MATLAB-compatible `strings` builtin that preallocates string arrays filled with empty scalars.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    LogicalArray, StringArray, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::map_control_flow_with_builtin;
11use crate::builtins::common::random_args::{keyword_of, shape_from_value};
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::strings::type_resolvers::string_array_type;
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19const FN_NAME: &str = "strings";
20const SIZE_INTEGER_ERR: &str = "size inputs must be integers";
21const SIZE_NONNEGATIVE_ERR: &str = "size inputs must be nonnegative integers";
22const SIZE_FINITE_ERR: &str = "size inputs must be finite";
23const SIZE_SCALAR_ERR: &str = "size inputs must be scalar";
24
25const STRINGS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
26    name: "S",
27    ty: BuiltinParamType::Any,
28    arity: BuiltinParamArity::Required,
29    default: None,
30    description: "Preallocated string array.",
31}];
32
33const STRINGS_INPUT_SZ: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34    name: "sz",
35    ty: BuiltinParamType::SizeArg,
36    arity: BuiltinParamArity::Required,
37    default: None,
38    description: "Size vector or scalar side length.",
39}];
40
41const STRINGS_INPUT_DIMS: [BuiltinParamDescriptor; 2] = [
42    BuiltinParamDescriptor {
43        name: "m",
44        ty: BuiltinParamType::SizeArg,
45        arity: BuiltinParamArity::Required,
46        default: None,
47        description: "First array dimension.",
48    },
49    BuiltinParamDescriptor {
50        name: "n...",
51        ty: BuiltinParamType::SizeArg,
52        arity: BuiltinParamArity::Variadic,
53        default: None,
54        description: "Additional array dimensions.",
55    },
56];
57
58const STRINGS_INPUT_LIKE: [BuiltinParamDescriptor; 3] = [
59    BuiltinParamDescriptor {
60        name: "dims...",
61        ty: BuiltinParamType::SizeArg,
62        arity: BuiltinParamArity::Variadic,
63        default: None,
64        description: "Optional explicit size dimensions.",
65    },
66    BuiltinParamDescriptor {
67        name: "like",
68        ty: BuiltinParamType::StringScalar,
69        arity: BuiltinParamArity::Required,
70        default: Some("\"like\""),
71        description: "Literal option keyword \"like\".",
72    },
73    BuiltinParamDescriptor {
74        name: "p",
75        ty: BuiltinParamType::LikePrototype,
76        arity: BuiltinParamArity::Required,
77        default: None,
78        description: "Prototype value supplying output shape when dims are omitted.",
79    },
80];
81
82const STRINGS_INPUT_FILL: [BuiltinParamDescriptor; 2] = [
83    BuiltinParamDescriptor {
84        name: "dims...",
85        ty: BuiltinParamType::SizeArg,
86        arity: BuiltinParamArity::Variadic,
87        default: None,
88        description: "Optional explicit size dimensions.",
89    },
90    BuiltinParamDescriptor {
91        name: "fill",
92        ty: BuiltinParamType::StringScalar,
93        arity: BuiltinParamArity::Required,
94        default: Some("\"empty\""),
95        description: "Fill mode keyword: \"empty\" or \"missing\".",
96    },
97];
98
99const STRINGS_SIGNATURES: [BuiltinSignatureDescriptor; 5] = [
100    BuiltinSignatureDescriptor {
101        label: "S = strings()",
102        inputs: &[],
103        outputs: &STRINGS_OUTPUT,
104    },
105    BuiltinSignatureDescriptor {
106        label: "S = strings(sz)",
107        inputs: &STRINGS_INPUT_SZ,
108        outputs: &STRINGS_OUTPUT,
109    },
110    BuiltinSignatureDescriptor {
111        label: "S = strings(m, n...)",
112        inputs: &STRINGS_INPUT_DIMS,
113        outputs: &STRINGS_OUTPUT,
114    },
115    BuiltinSignatureDescriptor {
116        label: "S = strings(___, \"like\", p)",
117        inputs: &STRINGS_INPUT_LIKE,
118        outputs: &STRINGS_OUTPUT,
119    },
120    BuiltinSignatureDescriptor {
121        label: "S = strings(___, fill)",
122        inputs: &STRINGS_INPUT_FILL,
123        outputs: &STRINGS_OUTPUT,
124    },
125];
126
127const STRINGS_ERROR_INVALID_SIZE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128    code: "RM.STRINGS.INVALID_SIZE",
129    identifier: Some("RunMat:strings:InvalidSize"),
130    when: "Size arguments are not valid numeric scalar/vector dimensions.",
131    message: "strings: size arguments must be numeric scalars or vectors",
132};
133
134const STRINGS_ERROR_LIKE_MISSING_PROTOTYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135    code: "RM.STRINGS.LIKE_MISSING_PROTOTYPE",
136    identifier: Some("RunMat:strings:LikeMissingPrototype"),
137    when: "\"like\" is provided without a following prototype.",
138    message: "strings: expected prototype after 'like'",
139};
140
141const STRINGS_ERROR_LIKE_DUPLICATE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
142    code: "RM.STRINGS.LIKE_DUPLICATE",
143    identifier: Some("RunMat:strings:LikeDuplicate"),
144    when: "Multiple \"like\" options are supplied in one call.",
145    message: "strings: multiple 'like' specifications are not supported",
146};
147
148const STRINGS_ERROR_SIZE_OVERFLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
149    code: "RM.STRINGS.SIZE_OVERFLOW",
150    identifier: Some("RunMat:strings:SizeOverflow"),
151    when: "Requested dimensions overflow platform limits.",
152    message: "strings: requested size exceeds platform limits",
153};
154
155const STRINGS_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
156    code: "RM.STRINGS.INTERNAL",
157    identifier: Some("RunMat:strings:InternalError"),
158    when: "Internal string array construction failed.",
159    message: "strings: internal error",
160};
161
162const STRINGS_ERRORS: [BuiltinErrorDescriptor; 5] = [
163    STRINGS_ERROR_INVALID_SIZE,
164    STRINGS_ERROR_LIKE_MISSING_PROTOTYPE,
165    STRINGS_ERROR_LIKE_DUPLICATE,
166    STRINGS_ERROR_SIZE_OVERFLOW,
167    STRINGS_ERROR_INTERNAL,
168];
169
170pub const STRINGS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
171    signatures: &STRINGS_SIGNATURES,
172    output_mode: BuiltinOutputMode::Fixed,
173    completion_policy: BuiltinCompletionPolicy::Public,
174    errors: &STRINGS_ERRORS,
175};
176
177fn strings_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
178    strings_error_with_message(error.message, error)
179}
180
181fn strings_error_with_message(
182    message: impl Into<String>,
183    error: &'static BuiltinErrorDescriptor,
184) -> RuntimeError {
185    let mut builder = build_runtime_error(message).with_builtin(FN_NAME);
186    if let Some(identifier) = error.identifier {
187        builder = builder.with_identifier(identifier);
188    }
189    builder.build()
190}
191
192fn remap_strings_flow(err: RuntimeError) -> RuntimeError {
193    map_control_flow_with_builtin(err, FN_NAME)
194}
195
196#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::strings")]
197pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
198    name: FN_NAME,
199    op_kind: GpuOpKind::Custom("array_creation"),
200    supported_precisions: &[],
201    broadcast: BroadcastSemantics::None,
202    provider_hooks: &[],
203    constant_strategy: ConstantStrategy::InlineLiteral,
204    residency: ResidencyPolicy::GatherImmediately,
205    nan_mode: ReductionNaN::Include,
206    two_pass_threshold: None,
207    workgroup_size: None,
208    accepts_nan_mode: false,
209    notes: "Runs entirely on the host; size arguments pulled from the GPU are gathered before allocation.",
210};
211
212#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::strings")]
213pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
214    name: FN_NAME,
215    shape: ShapeRequirements::Any,
216    constant_strategy: ConstantStrategy::InlineLiteral,
217    elementwise: None,
218    reduction: None,
219    emits_nan: false,
220    notes: "Preallocates host string arrays; no fusion-supported kernels are generated.",
221};
222
223struct ParsedStrings {
224    shape: Vec<usize>,
225    fill: FillKind,
226}
227
228#[derive(Clone, Copy, PartialEq, Eq)]
229enum FillKind {
230    Empty,
231    Missing,
232}
233
234#[runtime_builtin(
235    name = "strings",
236    category = "strings/core",
237    summary = "Preallocate string arrays filled with empty string scalars (`\"\"`).",
238    keywords = "strings,string array,empty,preallocate",
239    accel = "array_construct",
240    type_resolver(string_array_type),
241    descriptor(crate::builtins::strings::core::strings::STRINGS_DESCRIPTOR),
242    builtin_path = "crate::builtins::strings::core::strings"
243)]
244async fn strings_builtin(rest: Vec<Value>) -> crate::BuiltinResult<Value> {
245    let ParsedStrings { shape, fill } = parse_arguments(rest).await?;
246    let total = shape.iter().try_fold(1usize, |acc, &dim| {
247        acc.checked_mul(dim)
248            .ok_or_else(|| strings_error(&STRINGS_ERROR_SIZE_OVERFLOW))
249    })?;
250
251    let fill_text = match fill {
252        FillKind::Empty => String::new(),
253        FillKind::Missing => "<missing>".to_string(),
254    };
255
256    let mut data = Vec::with_capacity(total);
257    for _ in 0..total {
258        data.push(fill_text.clone());
259    }
260
261    let array =
262        StringArray::new(data, shape).map_err(|_| strings_error(&STRINGS_ERROR_INTERNAL))?;
263    Ok(Value::StringArray(array))
264}
265
266async fn parse_arguments(args: Vec<Value>) -> BuiltinResult<ParsedStrings> {
267    let mut size_values: Vec<Value> = Vec::new();
268    let mut like_proto: Option<Value> = None;
269    let mut fill = FillKind::Empty;
270
271    let mut idx = 0;
272    while idx < args.len() {
273        let host = gather_if_needed_async(&args[idx])
274            .await
275            .map_err(remap_strings_flow)?;
276        if let Some(keyword) = keyword_of(&host) {
277            match keyword.as_str() {
278                "like" => {
279                    if like_proto.is_some() {
280                        return Err(strings_error(&STRINGS_ERROR_LIKE_DUPLICATE));
281                    }
282                    let Some(proto_raw) = args.get(idx + 1) else {
283                        return Err(strings_error(&STRINGS_ERROR_LIKE_MISSING_PROTOTYPE));
284                    };
285                    let proto = gather_if_needed_async(proto_raw)
286                        .await
287                        .map_err(remap_strings_flow)?;
288                    like_proto = Some(proto);
289                    idx += 2;
290                    continue;
291                }
292                "missing" => {
293                    fill = FillKind::Missing;
294                    idx += 1;
295                    continue;
296                }
297                "empty" => {
298                    fill = FillKind::Empty;
299                    idx += 1;
300                    continue;
301                }
302                _ => {}
303            }
304        }
305        size_values.push(host);
306        idx += 1;
307    }
308
309    let dims = parse_size_values(size_values)?;
310    let mut shape = if let Some(dims) = dims {
311        normalize_dims(dims)
312    } else if let Some(proto) = like_proto.as_ref() {
313        prototype_shape(proto)?
314    } else {
315        vec![1, 1]
316    };
317
318    if shape.is_empty() {
319        shape = vec![0, 0];
320    }
321
322    Ok(ParsedStrings { shape, fill })
323}
324
325fn prototype_shape(value: &Value) -> BuiltinResult<Vec<usize>> {
326    match value {
327        Value::StringArray(sa) => Ok(sa.shape.clone()),
328        _ => {
329            shape_from_value(value, FN_NAME).map_err(|_| strings_error(&STRINGS_ERROR_INVALID_SIZE))
330        }
331    }
332}
333
334fn err_integer() -> RuntimeError {
335    strings_error_with_message(
336        format!("{FN_NAME}: {SIZE_INTEGER_ERR}"),
337        &STRINGS_ERROR_INVALID_SIZE,
338    )
339}
340
341fn err_nonnegative() -> RuntimeError {
342    strings_error_with_message(
343        format!("{FN_NAME}: {SIZE_NONNEGATIVE_ERR}"),
344        &STRINGS_ERROR_INVALID_SIZE,
345    )
346}
347
348fn err_finite() -> RuntimeError {
349    strings_error_with_message(
350        format!("{FN_NAME}: {SIZE_FINITE_ERR}"),
351        &STRINGS_ERROR_INVALID_SIZE,
352    )
353}
354
355fn parse_size_values(values: Vec<Value>) -> BuiltinResult<Option<Vec<usize>>> {
356    match values.len() {
357        0 => Ok(None),
358        1 => parse_single_argument(values.into_iter().next().unwrap()).map(Some),
359        _ => {
360            let mut dims = Vec::with_capacity(values.len());
361            for value in &values {
362                dims.push(parse_size_scalar(value)?);
363            }
364            Ok(Some(dims))
365        }
366    }
367}
368
369fn parse_single_argument(value: Value) -> BuiltinResult<Vec<usize>> {
370    match value {
371        Value::Int(iv) => Ok(vec![validate_i64_dimension(iv.to_i64())?]),
372        Value::Num(n) => Ok(vec![parse_numeric_dimension(n)?]),
373        Value::Bool(b) => Ok(vec![if b { 1 } else { 0 }]),
374        Value::Tensor(t) => parse_size_tensor(&t),
375        Value::LogicalArray(arr) => parse_size_logical_array(&arr),
376        _ => Err(strings_error(&STRINGS_ERROR_INVALID_SIZE)),
377    }
378}
379
380fn parse_size_scalar(value: &Value) -> BuiltinResult<usize> {
381    match value {
382        Value::Int(iv) => {
383            let raw = iv.to_i64();
384            validate_i64_dimension(raw)
385        }
386        Value::Num(n) => parse_numeric_dimension(*n),
387        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
388        Value::Tensor(t) => {
389            if t.data.len() != 1 {
390                return Err(strings_error_with_message(
391                    format!("{FN_NAME}: {SIZE_SCALAR_ERR}"),
392                    &STRINGS_ERROR_INVALID_SIZE,
393                ));
394            }
395            parse_numeric_dimension(t.data[0])
396        }
397        Value::LogicalArray(arr) => {
398            if arr.data.len() != 1 {
399                return Err(strings_error_with_message(
400                    format!("{FN_NAME}: {SIZE_SCALAR_ERR}"),
401                    &STRINGS_ERROR_INVALID_SIZE,
402                ));
403            }
404            Ok(if arr.data[0] != 0 { 1 } else { 0 })
405        }
406        _ => Err(strings_error(&STRINGS_ERROR_INVALID_SIZE)),
407    }
408}
409
410fn parse_size_tensor(tensor: &Tensor) -> BuiltinResult<Vec<usize>> {
411    if tensor.data.is_empty() {
412        return Ok(vec![0, 0]);
413    }
414    if !is_vector_shape(&tensor.shape) {
415        return Err(strings_error_with_message(
416            format!("{FN_NAME}: size vector must be a row or column vector"),
417            &STRINGS_ERROR_INVALID_SIZE,
418        ));
419    }
420    tensor
421        .data
422        .iter()
423        .map(|&value| parse_numeric_dimension(value))
424        .collect()
425}
426
427fn parse_size_logical_array(array: &LogicalArray) -> BuiltinResult<Vec<usize>> {
428    if array.data.is_empty() {
429        return Ok(vec![0, 0]);
430    }
431    if !is_vector_shape(&array.shape) {
432        return Err(strings_error_with_message(
433            format!("{FN_NAME}: size vector must be a row or column vector"),
434            &STRINGS_ERROR_INVALID_SIZE,
435        ));
436    }
437    array
438        .data
439        .iter()
440        .map(|&value| Ok(if value != 0 { 1 } else { 0 }))
441        .collect()
442}
443
444fn parse_numeric_dimension(value: f64) -> BuiltinResult<usize> {
445    if !value.is_finite() {
446        return Err(err_finite());
447    }
448    let rounded = value.round();
449    if (rounded - value).abs() > f64::EPSILON {
450        return Err(err_integer());
451    }
452    if rounded < 0.0 {
453        return Err(err_nonnegative());
454    }
455    if rounded > usize::MAX as f64 {
456        return Err(strings_error_with_message(
457            format!("{FN_NAME}: requested dimension exceeds platform limits"),
458            &STRINGS_ERROR_SIZE_OVERFLOW,
459        ));
460    }
461    Ok(rounded as usize)
462}
463
464fn normalize_dims(dims: Vec<usize>) -> Vec<usize> {
465    match dims.len() {
466        0 => vec![0, 0],
467        1 => {
468            let side = dims[0];
469            vec![side, side]
470        }
471        _ => dims,
472    }
473}
474
475fn is_vector_shape(shape: &[usize]) -> bool {
476    match shape.len() {
477        0 | 1 => true,
478        2 => shape[0] == 1 || shape[1] == 1,
479        _ => shape.iter().filter(|&&d| d > 1).count() <= 1,
480    }
481}
482
483fn validate_i64_dimension(raw: i64) -> BuiltinResult<usize> {
484    if raw < 0 {
485        return Err(err_nonnegative());
486    }
487    if (raw as u128) > (usize::MAX as u128) {
488        return Err(strings_error_with_message(
489            format!("{FN_NAME}: requested dimension exceeds platform limits"),
490            &STRINGS_ERROR_SIZE_OVERFLOW,
491        ));
492    }
493    Ok(raw as usize)
494}
495
496#[cfg(test)]
497pub(crate) mod tests {
498    use super::*;
499
500    use crate::builtins::common::test_support;
501    use runmat_accelerate_api::HostTensorView;
502    use runmat_builtins::{ResolveContext, Type};
503
504    fn strings_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
505        futures::executor::block_on(super::strings_builtin(rest))
506    }
507
508    fn error_message(err: crate::RuntimeError) -> String {
509        err.message().to_string()
510    }
511
512    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
513    #[test]
514    fn strings_default_scalar() {
515        let result = strings_builtin(Vec::new()).expect("strings");
516        match result {
517            Value::StringArray(array) => {
518                assert_eq!(array.shape, vec![1, 1]);
519                assert_eq!(array.data, vec![String::new()]);
520            }
521            other => panic!("expected string array, got {other:?}"),
522        }
523    }
524
525    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
526    #[test]
527    fn strings_square_from_single_dimension() {
528        let args = vec![Value::Num(4.0)];
529        let result = strings_builtin(args).expect("strings");
530        match result {
531            Value::StringArray(array) => {
532                assert_eq!(array.shape, vec![4, 4]);
533                assert!(array.data.iter().all(|s| s.is_empty()));
534            }
535            other => panic!("expected string array, got {other:?}"),
536        }
537    }
538
539    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
540    #[test]
541    fn strings_rectangular_multiple_args() {
542        let args = vec![
543            Value::Int(runmat_builtins::IntValue::I32(2)),
544            Value::Num(3.0),
545        ];
546        let result = strings_builtin(args).expect("strings");
547        match result {
548            Value::StringArray(array) => {
549                assert_eq!(array.shape, vec![2, 3]);
550                assert_eq!(array.data.len(), 6);
551            }
552            other => panic!("expected string array, got {other:?}"),
553        }
554    }
555
556    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
557    #[test]
558    fn strings_from_size_vector_tensor() {
559        let dims = Tensor::new(vec![2.0, 3.0, 1.0], vec![1, 3]).unwrap();
560        let result = strings_builtin(vec![Value::Tensor(dims)]).expect("strings");
561        match result {
562            Value::StringArray(array) => {
563                assert_eq!(array.shape, vec![2, 3, 1]);
564                assert_eq!(array.data.len(), 6);
565            }
566            other => panic!("expected string array, got {other:?}"),
567        }
568    }
569
570    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
571    #[test]
572    fn strings_preserves_trailing_singletons() {
573        let args = vec![
574            Value::Num(3.0),
575            Value::Int(runmat_builtins::IntValue::I32(1)),
576            Value::Num(1.0),
577            Value::Bool(true),
578        ];
579        let result = strings_builtin(args).expect("strings");
580        match result {
581            Value::StringArray(array) => {
582                assert_eq!(array.shape, vec![3, 1, 1, 1]);
583                assert_eq!(array.data.len(), 3);
584            }
585            other => panic!("expected string array, got {other:?}"),
586        }
587    }
588
589    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
590    #[test]
591    fn strings_bool_dimensions() {
592        let result = strings_builtin(vec![Value::Bool(true), Value::Bool(false)]).expect("strings");
593        match result {
594            Value::StringArray(array) => {
595                assert_eq!(array.shape, vec![1, 0]);
596                assert!(array.data.is_empty());
597            }
598            other => panic!("expected string array, got {other:?}"),
599        }
600    }
601
602    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
603    #[test]
604    fn strings_logical_vector_argument() {
605        let logical =
606            LogicalArray::new(vec![1u8, 0, 1], vec![1, 3]).expect("logical size construction");
607        let result = strings_builtin(vec![Value::LogicalArray(logical)]).expect("strings");
608        match result {
609            Value::StringArray(array) => {
610                assert_eq!(array.shape, vec![1, 0, 1]);
611                assert!(array.data.is_empty());
612            }
613            other => panic!("expected string array, got {other:?}"),
614        }
615    }
616
617    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
618    #[test]
619    fn strings_negative_dimension_errors() {
620        let err =
621            error_message(strings_builtin(vec![Value::Num(-5.0)]).expect_err("expected error"));
622        assert!(err.contains(super::SIZE_NONNEGATIVE_ERR));
623    }
624
625    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
626    #[test]
627    fn strings_rejects_non_integer_dimension() {
628        let err =
629            error_message(strings_builtin(vec![Value::Num(2.5)]).expect_err("expected error"));
630        assert!(err.contains(super::SIZE_INTEGER_ERR));
631    }
632
633    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
634    #[test]
635    fn strings_rejects_non_numeric_dimension() {
636        let err = error_message(
637            strings_builtin(vec![Value::String("size".into())]).expect_err("expected error"),
638        );
639        assert!(err.contains("size arguments must be numeric"));
640    }
641
642    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
643    #[test]
644    fn strings_empty_vector_returns_empty_array() {
645        let dims = Tensor::new(Vec::<f64>::new(), vec![0, 0]).unwrap();
646        let result = strings_builtin(vec![Value::Tensor(dims)]).expect("strings");
647        match result {
648            Value::StringArray(array) => {
649                assert_eq!(array.shape, vec![0, 0]);
650                assert!(array.data.is_empty());
651            }
652            other => panic!("expected string array, got {other:?}"),
653        }
654    }
655
656    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
657    #[test]
658    fn strings_missing_option_fills_with_missing() {
659        let result = strings_builtin(vec![
660            Value::Num(2.0),
661            Value::Num(3.0),
662            Value::String("missing".into()),
663        ])
664        .expect("strings");
665        match result {
666            Value::StringArray(array) => {
667                assert_eq!(array.shape, vec![2, 3]);
668                assert_eq!(array.data.len(), 6);
669                assert!(array.data.iter().all(|s| s == "<missing>"));
670            }
671            other => panic!("expected string array, got {other:?}"),
672        }
673    }
674
675    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
676    #[test]
677    fn strings_missing_without_dims_defaults_to_scalar() {
678        let result = strings_builtin(vec![Value::String("missing".into())]).expect("strings");
679        match result {
680            Value::StringArray(array) => {
681                assert_eq!(array.shape, vec![1, 1]);
682                assert_eq!(array.data, vec!["<missing>".to_string()]);
683            }
684            other => panic!("expected string array, got {other:?}"),
685        }
686    }
687
688    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
689    #[test]
690    fn strings_like_prototype_shape() {
691        let proto = StringArray::new(
692            vec!["alpha".into(), "beta".into(), "gamma".into()],
693            vec![3, 1],
694        )
695        .unwrap();
696        let result = strings_builtin(vec![
697            Value::String("like".into()),
698            Value::StringArray(proto.clone()),
699        ])
700        .expect("strings");
701        match result {
702            Value::StringArray(array) => {
703                assert_eq!(array.shape, proto.shape);
704                assert!(array.data.iter().all(|s| s.is_empty()));
705            }
706            other => panic!("expected string array, got {other:?}"),
707        }
708    }
709
710    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
711    #[test]
712    fn strings_like_numeric_prototype() {
713        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
714        let result = strings_builtin(vec![
715            Value::String("like".into()),
716            Value::Tensor(tensor.clone()),
717        ])
718        .expect("strings");
719        match result {
720            Value::StringArray(array) => {
721                assert_eq!(array.shape, tensor.shape);
722                assert_eq!(array.data.len(), tensor.data.len());
723            }
724            other => panic!("expected string array, got {other:?}"),
725        }
726    }
727
728    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
729    #[test]
730    fn strings_like_overrides_shape_when_dims_provided() {
731        let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
732        let result = strings_builtin(vec![
733            Value::String("like".into()),
734            Value::Tensor(tensor),
735            Value::Int(runmat_builtins::IntValue::I32(3)),
736        ])
737        .expect("strings");
738        match result {
739            Value::StringArray(array) => {
740                assert_eq!(array.shape, vec![3, 3]);
741            }
742            other => panic!("expected string array, got {other:?}"),
743        }
744    }
745
746    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
747    #[test]
748    fn strings_like_requires_prototype() {
749        let err = error_message(
750            strings_builtin(vec![Value::String("like".into())]).expect_err("expected error"),
751        );
752        assert!(err.contains("expected prototype"));
753    }
754
755    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
756    #[test]
757    fn strings_like_rejects_multiple_specs() {
758        let err = error_message(
759            strings_builtin(vec![
760                Value::String("like".into()),
761                Value::Num(1.0),
762                Value::String("like".into()),
763                Value::Num(2.0),
764            ])
765            .expect_err("expected error"),
766        );
767        assert!(err.contains("multiple 'like'"));
768    }
769
770    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
771    #[test]
772    fn strings_gpu_size_vector_argument() {
773        test_support::with_test_provider(|provider| {
774            let dims = Tensor::new(vec![2.0, 3.0], vec![1, 2]).unwrap();
775            let view = HostTensorView {
776                data: &dims.data,
777                shape: &dims.shape,
778            };
779            let handle = provider.upload(&view).expect("upload");
780            let result = strings_builtin(vec![Value::GpuTensor(handle)]).expect("strings");
781            match result {
782                Value::StringArray(array) => {
783                    assert_eq!(array.shape, vec![2, 3]);
784                    assert_eq!(array.data.len(), 6);
785                }
786                other => panic!("expected string array, got {other:?}"),
787            }
788        });
789    }
790
791    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
792    #[test]
793    fn strings_like_accepts_gpu_prototype() {
794        test_support::with_test_provider(|provider| {
795            let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
796            let view = HostTensorView {
797                data: &tensor.data,
798                shape: &tensor.shape,
799            };
800            let handle = provider.upload(&view).expect("upload");
801            let result =
802                strings_builtin(vec![Value::String("like".into()), Value::GpuTensor(handle)])
803                    .expect("strings");
804            match result {
805                Value::StringArray(array) => {
806                    assert_eq!(array.shape, vec![2, 2]);
807                }
808                other => panic!("expected string array, got {other:?}"),
809            }
810        });
811    }
812
813    #[test]
814    fn strings_type_is_string_array() {
815        assert_eq!(
816            string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
817            Type::cell_of(Type::String)
818        );
819    }
820
821    #[cfg(feature = "wgpu")]
822    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
823    #[test]
824    fn strings_handles_wgpu_size_vectors() {
825        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
826            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
827        );
828        let dims = Tensor::new(vec![1.0, 4.0], vec![1, 2]).unwrap();
829        let view = HostTensorView {
830            data: &dims.data,
831            shape: &dims.shape,
832        };
833        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
834        let handle = provider.upload(&view).expect("upload");
835        let result = strings_builtin(vec![Value::GpuTensor(handle)]).expect("strings");
836        match result {
837            Value::StringArray(array) => {
838                assert_eq!(array.shape, vec![1, 4]);
839            }
840            other => panic!("expected string array, got {other:?}"),
841        }
842    }
843}