Skip to main content

runmat_runtime/builtins/strings/core/
string.empty.rs

1//! MATLAB-compatible `string.empty` builtin for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    StringArray, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::map_control_flow_with_builtin;
11use crate::builtins::common::random_args::{extract_dims, keyword_of};
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 LABEL: &str = "string.empty";
20
21const STRING_EMPTY_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
22    name: "S",
23    ty: BuiltinParamType::Any,
24    arity: BuiltinParamArity::Required,
25    default: None,
26    description: "Empty string array with at least one zero dimension.",
27}];
28
29const STRING_EMPTY_INPUT_SZ: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
30    name: "sz",
31    ty: BuiltinParamType::SizeArg,
32    arity: BuiltinParamArity::Required,
33    default: None,
34    description: "Size vector or scalar.",
35}];
36
37const STRING_EMPTY_INPUT_DIMS: [BuiltinParamDescriptor; 2] = [
38    BuiltinParamDescriptor {
39        name: "m",
40        ty: BuiltinParamType::SizeArg,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "First dimension.",
44    },
45    BuiltinParamDescriptor {
46        name: "n...",
47        ty: BuiltinParamType::SizeArg,
48        arity: BuiltinParamArity::Variadic,
49        default: None,
50        description: "Additional dimensions.",
51    },
52];
53
54const STRING_EMPTY_INPUT_LIKE: [BuiltinParamDescriptor; 3] = [
55    BuiltinParamDescriptor {
56        name: "dims...",
57        ty: BuiltinParamType::SizeArg,
58        arity: BuiltinParamArity::Variadic,
59        default: None,
60        description: "Optional explicit dimensions.",
61    },
62    BuiltinParamDescriptor {
63        name: "like",
64        ty: BuiltinParamType::StringScalar,
65        arity: BuiltinParamArity::Required,
66        default: Some("\"like\""),
67        description: "Literal option keyword \"like\".",
68    },
69    BuiltinParamDescriptor {
70        name: "p",
71        ty: BuiltinParamType::LikePrototype,
72        arity: BuiltinParamArity::Required,
73        default: None,
74        description: "Prototype supplying trailing dimensions.",
75    },
76];
77
78const STRING_EMPTY_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
79    BuiltinSignatureDescriptor {
80        label: "S = string.empty()",
81        inputs: &[],
82        outputs: &STRING_EMPTY_OUTPUT,
83    },
84    BuiltinSignatureDescriptor {
85        label: "S = string.empty(sz)",
86        inputs: &STRING_EMPTY_INPUT_SZ,
87        outputs: &STRING_EMPTY_OUTPUT,
88    },
89    BuiltinSignatureDescriptor {
90        label: "S = string.empty(m, n...)",
91        inputs: &STRING_EMPTY_INPUT_DIMS,
92        outputs: &STRING_EMPTY_OUTPUT,
93    },
94    BuiltinSignatureDescriptor {
95        label: "S = string.empty(___, \"like\", p)",
96        inputs: &STRING_EMPTY_INPUT_LIKE,
97        outputs: &STRING_EMPTY_OUTPUT,
98    },
99];
100
101const STRING_EMPTY_ERROR_INVALID_SIZE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
102    code: "RM.STRING_EMPTY.INVALID_SIZE",
103    identifier: Some("RunMat:string.empty:InvalidSize"),
104    when: "Size inputs are not valid numeric dimensions or vectors.",
105    message: "string.empty: size inputs must be numeric scalars or size vectors",
106};
107
108const STRING_EMPTY_ERROR_LIKE_MISSING: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109    code: "RM.STRING_EMPTY.LIKE_MISSING_PROTOTYPE",
110    identifier: Some("RunMat:string.empty:LikeMissingPrototype"),
111    when: "\"like\" keyword is present without a prototype.",
112    message: "string.empty: expected prototype after 'like'",
113};
114
115const STRING_EMPTY_ERROR_LIKE_DUPLICATE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
116    code: "RM.STRING_EMPTY.LIKE_DUPLICATE",
117    identifier: Some("RunMat:string.empty:LikeDuplicate"),
118    when: "Multiple \"like\" specifications are supplied.",
119    message: "string.empty: multiple 'like' prototypes are not supported",
120};
121
122const STRING_EMPTY_ERROR_NOT_EMPTY_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
123    code: "RM.STRING_EMPTY.NONEMPTY_SHAPE",
124    identifier: Some("RunMat:string.empty:NonEmptyShape"),
125    when: "Parsed dimensions do not produce an empty array shape.",
126    message: "string.empty: at least one dimension must be zero",
127};
128
129const STRING_EMPTY_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
130    code: "RM.STRING_EMPTY.INTERNAL",
131    identifier: Some("RunMat:string.empty:InternalError"),
132    when: "Internal empty string-array construction failed.",
133    message: "string.empty: internal error",
134};
135
136const STRING_EMPTY_ERRORS: [BuiltinErrorDescriptor; 5] = [
137    STRING_EMPTY_ERROR_INVALID_SIZE,
138    STRING_EMPTY_ERROR_LIKE_MISSING,
139    STRING_EMPTY_ERROR_LIKE_DUPLICATE,
140    STRING_EMPTY_ERROR_NOT_EMPTY_SHAPE,
141    STRING_EMPTY_ERROR_INTERNAL,
142];
143
144pub const STRING_EMPTY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
145    signatures: &STRING_EMPTY_SIGNATURES,
146    output_mode: BuiltinOutputMode::Fixed,
147    completion_policy: BuiltinCompletionPolicy::MethodOnly,
148    errors: &STRING_EMPTY_ERRORS,
149};
150
151fn string_empty_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
152    string_empty_error_with_message(error.message, error)
153}
154
155fn string_empty_error_with_message(
156    message: impl Into<String>,
157    error: &'static BuiltinErrorDescriptor,
158) -> RuntimeError {
159    let mut builder = build_runtime_error(message).with_builtin(LABEL);
160    if let Some(identifier) = error.identifier {
161        builder = builder.with_identifier(identifier);
162    }
163    builder.build()
164}
165
166fn remap_string_empty_flow(err: RuntimeError) -> RuntimeError {
167    map_control_flow_with_builtin(err, LABEL)
168}
169
170#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::string_empty")]
171pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
172    name: "string.empty",
173    op_kind: GpuOpKind::Custom("constructor"),
174    supported_precisions: &[],
175    broadcast: BroadcastSemantics::None,
176    provider_hooks: &[],
177    constant_strategy: ConstantStrategy::InlineLiteral,
178    residency: ResidencyPolicy::NewHandle,
179    nan_mode: ReductionNaN::Include,
180    two_pass_threshold: None,
181    workgroup_size: None,
182    accepts_nan_mode: false,
183    notes: "Host-only constructor that returns a new empty string array without contacting GPU providers.",
184};
185
186#[runmat_macros::register_fusion_spec(
187    builtin_path = "crate::builtins::strings::core::string_empty"
188)]
189pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
190    name: "string.empty",
191    shape: ShapeRequirements::Any,
192    constant_strategy: ConstantStrategy::InlineLiteral,
193    elementwise: None,
194    reduction: None,
195    emits_nan: false,
196    notes: "Pure constructor; fusion planner treats calls as non-fusable sinks.",
197};
198
199#[runtime_builtin(
200    name = "string.empty",
201    category = "strings/core",
202    summary = "Construct empty string arrays with MATLAB-compatible dimension semantics.",
203    keywords = "string.empty,empty,string array,preallocate",
204    accel = "none",
205    type_resolver(string_array_type),
206    descriptor(crate::builtins::strings::core::string_empty::STRING_EMPTY_DESCRIPTOR),
207    builtin_path = "crate::builtins::strings::core::string_empty"
208)]
209async fn string_empty_builtin(rest: Vec<Value>) -> crate::BuiltinResult<Value> {
210    let shape = parse_shape(&rest).await?;
211    let total: usize = shape.iter().product();
212    debug_assert_eq!(total, 0, "string.empty must produce an empty array");
213    let data = Vec::<String>::new();
214    let array = StringArray::new(data, shape)
215        .map_err(|_| string_empty_error(&STRING_EMPTY_ERROR_INTERNAL))?;
216    Ok(Value::StringArray(array))
217}
218
219async fn parse_shape(args: &[Value]) -> BuiltinResult<Vec<usize>> {
220    if args.is_empty() {
221        return Ok(vec![0, 0]);
222    }
223
224    let mut explicit_dims: Vec<usize> = Vec::new();
225    let mut like_shape: Option<Vec<usize>> = None;
226    let mut idx = 0;
227
228    while idx < args.len() {
229        let arg_host = gather_if_needed_async(&args[idx])
230            .await
231            .map_err(remap_string_empty_flow)?;
232
233        if let Some(keyword) = keyword_of(&arg_host) {
234            if keyword.as_str() == "like" {
235                if like_shape.is_some() {
236                    return Err(string_empty_error(&STRING_EMPTY_ERROR_LIKE_DUPLICATE));
237                }
238                let Some(proto_raw) = args.get(idx + 1) else {
239                    return Err(string_empty_error(&STRING_EMPTY_ERROR_LIKE_MISSING));
240                };
241                let proto = gather_if_needed_async(proto_raw)
242                    .await
243                    .map_err(remap_string_empty_flow)?;
244                like_shape = Some(prototype_dims(&proto));
245                idx += 2;
246                continue;
247            }
248            // Unrecognized keywords are treated as non-keyword inputs and will
249            // be validated under numeric size parsing below.
250        }
251
252        if let Some(parsed) = extract_dims(&arg_host, LABEL).await.map_err(|message| {
253            string_empty_error_with_message(message, &STRING_EMPTY_ERROR_INVALID_SIZE)
254        })? {
255            if explicit_dims.is_empty() {
256                explicit_dims = parsed;
257            } else {
258                explicit_dims.extend(parsed);
259            }
260            idx += 1;
261            continue;
262        }
263
264        return Err(string_empty_error(&STRING_EMPTY_ERROR_INVALID_SIZE));
265    }
266
267    let shape = if !explicit_dims.is_empty() {
268        shape_from_explicit_dims(&explicit_dims)
269    } else if let Some(proto_shape) = like_shape {
270        shape_from_like(&proto_shape)
271    } else {
272        vec![0, 0]
273    };
274    ensure_empty_shape(&shape)?;
275    Ok(shape)
276}
277
278fn shape_from_explicit_dims(dims: &[usize]) -> Vec<usize> {
279    match dims.len() {
280        0 => vec![0, 0],
281        1 => vec![0, dims[0]],
282        _ => {
283            let mut shape = Vec::with_capacity(dims.len());
284            shape.push(0);
285            shape.extend_from_slice(&dims[1..]);
286            shape
287        }
288    }
289}
290
291fn shape_from_like(proto: &[usize]) -> Vec<usize> {
292    if proto.is_empty() {
293        return vec![0, 0];
294    }
295    if proto.len() == 1 {
296        return vec![0, proto[0]];
297    }
298    let mut shape = Vec::with_capacity(proto.len());
299    shape.push(0);
300    shape.extend_from_slice(&proto[1..]);
301    shape
302}
303
304fn ensure_empty_shape(shape: &[usize]) -> BuiltinResult<()> {
305    if shape.iter().product::<usize>() != 0 {
306        return Err(string_empty_error(&STRING_EMPTY_ERROR_NOT_EMPTY_SHAPE));
307    }
308    Ok(())
309}
310
311fn prototype_dims(proto: &Value) -> Vec<usize> {
312    match proto {
313        Value::StringArray(sa) => sa.shape.clone(),
314        Value::CharArray(ca) => vec![ca.rows, ca.cols],
315        Value::Tensor(t) => t.shape.clone(),
316        Value::ComplexTensor(t) => t.shape.clone(),
317        Value::LogicalArray(l) => l.shape.clone(),
318        Value::Cell(cell) => cell.shape.clone(),
319        Value::GpuTensor(handle) => handle.shape.clone(),
320        Value::Num(_) | Value::Int(_) | Value::Bool(_) | Value::Complex(_, _) => vec![1, 1],
321        Value::String(_) => vec![1, 1],
322        _ => vec![1, 1],
323    }
324}
325
326#[cfg(test)]
327pub(crate) mod tests {
328    use super::*;
329    use crate::builtins::common::test_support;
330    use runmat_accelerate_api::HostTensorView;
331    use runmat_builtins::{ResolveContext, StringArray, Tensor, Type, Value};
332
333    fn string_empty_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
334        futures::executor::block_on(super::string_empty_builtin(rest))
335    }
336
337    fn error_message(err: crate::RuntimeError) -> String {
338        err.message().to_string()
339    }
340
341    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
342    #[test]
343    fn default_is_zero_by_zero() {
344        let result = string_empty_builtin(Vec::new()).expect("string.empty");
345        match result {
346            Value::StringArray(sa) => {
347                assert_eq!(sa.shape, vec![0, 0]);
348                assert_eq!(sa.data.len(), 0);
349            }
350            other => panic!("expected string array, got {other:?}"),
351        }
352    }
353
354    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
355    #[test]
356    fn single_dimension_creates_zero_by_n() {
357        let result = string_empty_builtin(vec![Value::from(5)]).expect("string.empty");
358        match result {
359            Value::StringArray(sa) => {
360                assert_eq!(sa.shape, vec![0, 5]);
361                assert_eq!(sa.data.len(), 0);
362            }
363            other => panic!("expected string array, got {other:?}"),
364        }
365    }
366
367    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
368    #[test]
369    fn multiple_dimensions_respect_trailing_sizes() {
370        let args = vec![Value::from(3), Value::from(4), Value::from(2)];
371        let result = string_empty_builtin(args).expect("string.empty");
372        match result {
373            Value::StringArray(sa) => {
374                assert_eq!(sa.shape, vec![0, 4, 2]);
375                assert_eq!(sa.data.len(), 0);
376            }
377            other => panic!("expected string array, got {other:?}"),
378        }
379    }
380
381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
382    #[test]
383    fn size_vector_argument_supported() {
384        let tensor = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
385        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
386        match result {
387            Value::StringArray(sa) => {
388                assert_eq!(sa.shape, vec![0, 5, 3]);
389                assert_eq!(sa.data.len(), 0);
390            }
391            other => panic!("expected string array, got {other:?}"),
392        }
393    }
394
395    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
396    #[test]
397    fn size_vector_from_nonempty_array_drops_leading_extent() {
398        let tensor = Tensor::new(vec![3.0, 2.0], vec![1, 2]).unwrap();
399        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
400        match result {
401            Value::StringArray(sa) => {
402                assert_eq!(sa.shape, vec![0, 2]);
403                assert_eq!(sa.data.len(), 0);
404            }
405            other => panic!("expected string array, got {other:?}"),
406        }
407    }
408
409    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
410    #[test]
411    fn accepts_zero_in_any_position() {
412        let args = vec![Value::from(3), Value::from(4), Value::from(0)];
413        let result = string_empty_builtin(args).expect("string.empty");
414        match result {
415            Value::StringArray(sa) => assert_eq!(sa.shape, vec![0, 4, 0]),
416            other => panic!("expected string array, got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn string_empty_type_is_string_array() {
422        assert_eq!(
423            string_array_type(&[], &ResolveContext::new(Vec::new())),
424            Type::cell_of(Type::String)
425        );
426    }
427
428    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
429    #[test]
430    fn like_prototype_without_explicit_dims() {
431        let proto = StringArray::new(vec!["alpha".to_string(); 6], vec![2, 3]).unwrap();
432        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
433            .expect("string.empty");
434        match result {
435            Value::StringArray(sa) => {
436                assert_eq!(sa.shape, vec![0, 3]);
437                assert_eq!(sa.data.len(), 0);
438            }
439            other => panic!("expected string array, got {other:?}"),
440        }
441    }
442
443    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
444    #[test]
445    fn like_prototype_with_scalar_shape() {
446        let proto = StringArray::new(vec!["foo".to_string()], vec![1, 1]).unwrap();
447        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
448            .expect("string.empty");
449        match result {
450            Value::StringArray(sa) => {
451                assert_eq!(sa.shape, vec![0, 1]);
452                assert_eq!(sa.data.len(), 0);
453            }
454            other => panic!("expected string array, got {other:?}"),
455        }
456    }
457
458    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
459    #[test]
460    fn like_with_numeric_prototype() {
461        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
462        let result = string_empty_builtin(vec![Value::from("like"), Value::Tensor(tensor)])
463            .expect("string.empty");
464        match result {
465            Value::StringArray(sa) => {
466                assert_eq!(sa.shape, vec![0, 1]);
467                assert_eq!(sa.data.len(), 0);
468            }
469            other => panic!("expected string array, got {other:?}"),
470        }
471    }
472
473    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
474    #[test]
475    fn like_with_explicit_dims_prefers_dimensions() {
476        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
477        let args = vec![
478            Value::from(0),
479            Value::from(7),
480            Value::from("like"),
481            Value::StringArray(proto),
482        ];
483        let result = string_empty_builtin(args).expect("string.empty");
484        match result {
485            Value::StringArray(sa) => {
486                assert_eq!(sa.shape, vec![0, 7]);
487                assert_eq!(sa.data.len(), 0);
488            }
489            other => panic!("expected string array, got {other:?}"),
490        }
491    }
492
493    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
494    #[test]
495    fn missing_like_prototype_errors() {
496        let err = error_message(
497            string_empty_builtin(vec![Value::from("like")]).expect_err("expected error"),
498        );
499        assert!(
500            err.contains("expected prototype"),
501            "unexpected error: {err}"
502        );
503    }
504
505    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
506    #[test]
507    fn duplicate_like_errors() {
508        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
509        let err = error_message(
510            string_empty_builtin(vec![
511                Value::from("like"),
512                Value::StringArray(proto.clone()),
513                Value::from("like"),
514                Value::StringArray(proto),
515            ])
516            .expect_err("expected error"),
517        );
518        assert!(err.contains("multiple 'like'"), "unexpected error: {err}");
519    }
520
521    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
522    #[test]
523    fn rejects_non_dimension_inputs() {
524        let err = error_message(
525            string_empty_builtin(vec![Value::String("oops".into())]).expect_err("expected error"),
526        );
527        assert!(
528            err.contains("size inputs must be numeric"),
529            "unexpected error: {err}"
530        );
531    }
532
533    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
534    #[test]
535    fn like_gathers_gpu_prototype() {
536        test_support::with_test_provider(|provider| {
537            let tensor =
538                Tensor::new((1..=6).map(|v| v as f64).collect::<Vec<_>>(), vec![2, 3]).unwrap();
539            let view = HostTensorView {
540                data: &tensor.data,
541                shape: &tensor.shape,
542            };
543            let handle = provider.upload(&view).expect("upload");
544            let result =
545                string_empty_builtin(vec![Value::from("like"), Value::GpuTensor(handle.clone())])
546                    .expect("string.empty");
547            match result {
548                Value::StringArray(sa) => {
549                    assert_eq!(sa.shape, vec![0, 3]);
550                    assert_eq!(sa.data.len(), 0);
551                }
552                other => panic!("expected string array, got {other:?}"),
553            }
554            let _ = provider.free(&handle);
555        });
556    }
557
558    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
559    #[test]
560    fn gpu_dimension_arguments_are_gathered() {
561        test_support::with_test_provider(|provider| {
562            let dims = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
563            let view = HostTensorView {
564                data: &dims.data,
565                shape: &dims.shape,
566            };
567            let handle = provider.upload(&view).expect("upload");
568            let result =
569                string_empty_builtin(vec![Value::GpuTensor(handle.clone())]).expect("string.empty");
570            match result {
571                Value::StringArray(sa) => {
572                    assert_eq!(sa.shape, vec![0, 5, 3]);
573                    assert_eq!(sa.data.len(), 0);
574                }
575                other => panic!("expected string array, got {other:?}"),
576            }
577            let _ = provider.free(&handle);
578        });
579    }
580
581    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
582    #[test]
583    fn rejects_negative_dimension() {
584        let err = error_message(
585            string_empty_builtin(vec![Value::from(-1.0)]).expect_err("expected error"),
586        );
587        assert!(
588            err.contains("matrix dimensions must be non-negative"),
589            "unexpected error: {err}"
590        );
591    }
592}