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};
7use runmat_macros::runtime_builtin;
8use runmat_value::{StringArray, Value};
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) => ca.shape.clone(),
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, Type};
332    use runmat_value::{StringArray, Tensor, Value};
333
334    fn string_empty_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
335        futures::executor::block_on(super::string_empty_builtin(rest))
336    }
337
338    fn error_message(err: crate::RuntimeError) -> String {
339        err.message().to_string()
340    }
341
342    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
343    #[test]
344    fn default_is_zero_by_zero() {
345        let result = string_empty_builtin(Vec::new()).expect("string.empty");
346        match result {
347            Value::StringArray(sa) => {
348                assert_eq!(sa.shape, vec![0, 0]);
349                assert_eq!(sa.data.len(), 0);
350            }
351            other => panic!("expected string array, got {other:?}"),
352        }
353    }
354
355    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
356    #[test]
357    fn single_dimension_creates_zero_by_n() {
358        let result = string_empty_builtin(vec![Value::from(5)]).expect("string.empty");
359        match result {
360            Value::StringArray(sa) => {
361                assert_eq!(sa.shape, vec![0, 5]);
362                assert_eq!(sa.data.len(), 0);
363            }
364            other => panic!("expected string array, got {other:?}"),
365        }
366    }
367
368    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
369    #[test]
370    fn multiple_dimensions_respect_trailing_sizes() {
371        let args = vec![Value::from(3), Value::from(4), Value::from(2)];
372        let result = string_empty_builtin(args).expect("string.empty");
373        match result {
374            Value::StringArray(sa) => {
375                assert_eq!(sa.shape, vec![0, 4, 2]);
376                assert_eq!(sa.data.len(), 0);
377            }
378            other => panic!("expected string array, got {other:?}"),
379        }
380    }
381
382    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
383    #[test]
384    fn size_vector_argument_supported() {
385        let tensor = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
386        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
387        match result {
388            Value::StringArray(sa) => {
389                assert_eq!(sa.shape, vec![0, 5, 3]);
390                assert_eq!(sa.data.len(), 0);
391            }
392            other => panic!("expected string array, got {other:?}"),
393        }
394    }
395
396    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
397    #[test]
398    fn size_vector_from_nonempty_array_drops_leading_extent() {
399        let tensor = Tensor::new(vec![3.0, 2.0], vec![1, 2]).unwrap();
400        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
401        match result {
402            Value::StringArray(sa) => {
403                assert_eq!(sa.shape, vec![0, 2]);
404                assert_eq!(sa.data.len(), 0);
405            }
406            other => panic!("expected string array, got {other:?}"),
407        }
408    }
409
410    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
411    #[test]
412    fn accepts_zero_in_any_position() {
413        let args = vec![Value::from(3), Value::from(4), Value::from(0)];
414        let result = string_empty_builtin(args).expect("string.empty");
415        match result {
416            Value::StringArray(sa) => assert_eq!(sa.shape, vec![0, 4, 0]),
417            other => panic!("expected string array, got {other:?}"),
418        }
419    }
420
421    #[test]
422    fn string_empty_type_is_string_array() {
423        assert_eq!(
424            string_array_type(&[], &ResolveContext::new(Vec::new())),
425            Type::cell_of(Type::String)
426        );
427    }
428
429    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
430    #[test]
431    fn like_prototype_without_explicit_dims() {
432        let proto = StringArray::new(vec!["alpha".to_string(); 6], vec![2, 3]).unwrap();
433        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
434            .expect("string.empty");
435        match result {
436            Value::StringArray(sa) => {
437                assert_eq!(sa.shape, vec![0, 3]);
438                assert_eq!(sa.data.len(), 0);
439            }
440            other => panic!("expected string array, got {other:?}"),
441        }
442    }
443
444    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
445    #[test]
446    fn like_prototype_with_scalar_shape() {
447        let proto = StringArray::new(vec!["foo".to_string()], vec![1, 1]).unwrap();
448        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
449            .expect("string.empty");
450        match result {
451            Value::StringArray(sa) => {
452                assert_eq!(sa.shape, vec![0, 1]);
453                assert_eq!(sa.data.len(), 0);
454            }
455            other => panic!("expected string array, got {other:?}"),
456        }
457    }
458
459    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
460    #[test]
461    fn like_with_numeric_prototype() {
462        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
463        let result = string_empty_builtin(vec![Value::from("like"), Value::Tensor(tensor)])
464            .expect("string.empty");
465        match result {
466            Value::StringArray(sa) => {
467                assert_eq!(sa.shape, vec![0, 1]);
468                assert_eq!(sa.data.len(), 0);
469            }
470            other => panic!("expected string array, got {other:?}"),
471        }
472    }
473
474    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
475    #[test]
476    fn like_with_explicit_dims_prefers_dimensions() {
477        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
478        let args = vec![
479            Value::from(0),
480            Value::from(7),
481            Value::from("like"),
482            Value::StringArray(proto),
483        ];
484        let result = string_empty_builtin(args).expect("string.empty");
485        match result {
486            Value::StringArray(sa) => {
487                assert_eq!(sa.shape, vec![0, 7]);
488                assert_eq!(sa.data.len(), 0);
489            }
490            other => panic!("expected string array, got {other:?}"),
491        }
492    }
493
494    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
495    #[test]
496    fn missing_like_prototype_errors() {
497        let err = error_message(
498            string_empty_builtin(vec![Value::from("like")]).expect_err("expected error"),
499        );
500        assert!(
501            err.contains("expected prototype"),
502            "unexpected error: {err}"
503        );
504    }
505
506    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
507    #[test]
508    fn duplicate_like_errors() {
509        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
510        let err = error_message(
511            string_empty_builtin(vec![
512                Value::from("like"),
513                Value::StringArray(proto.clone()),
514                Value::from("like"),
515                Value::StringArray(proto),
516            ])
517            .expect_err("expected error"),
518        );
519        assert!(err.contains("multiple 'like'"), "unexpected error: {err}");
520    }
521
522    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
523    #[test]
524    fn rejects_non_dimension_inputs() {
525        let err = error_message(
526            string_empty_builtin(vec![Value::String("oops".into())]).expect_err("expected error"),
527        );
528        assert!(
529            err.contains("size inputs must be numeric"),
530            "unexpected error: {err}"
531        );
532    }
533
534    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
535    #[test]
536    fn like_gathers_gpu_prototype() {
537        test_support::with_test_provider(|provider| {
538            let tensor =
539                Tensor::new((1..=6).map(|v| v as f64).collect::<Vec<_>>(), vec![2, 3]).unwrap();
540            let view = HostTensorView {
541                data: &tensor.materialize_f64(),
542                shape: &tensor.shape,
543            };
544            let handle = provider.upload(&view).expect("upload");
545            let result =
546                string_empty_builtin(vec![Value::from("like"), Value::GpuTensor(handle.clone())])
547                    .expect("string.empty");
548            match result {
549                Value::StringArray(sa) => {
550                    assert_eq!(sa.shape, vec![0, 3]);
551                    assert_eq!(sa.data.len(), 0);
552                }
553                other => panic!("expected string array, got {other:?}"),
554            }
555            let _ = provider.free(&handle);
556        });
557    }
558
559    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
560    #[test]
561    fn gpu_dimension_arguments_are_gathered() {
562        test_support::with_test_provider(|provider| {
563            let dims = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
564            let view = HostTensorView {
565                data: &dims.materialize_f64(),
566                shape: &dims.shape,
567            };
568            let handle = provider.upload(&view).expect("upload");
569            let result =
570                string_empty_builtin(vec![Value::GpuTensor(handle.clone())]).expect("string.empty");
571            match result {
572                Value::StringArray(sa) => {
573                    assert_eq!(sa.shape, vec![0, 5, 3]);
574                    assert_eq!(sa.data.len(), 0);
575                }
576                other => panic!("expected string array, got {other:?}"),
577            }
578            let _ = provider.free(&handle);
579        });
580    }
581
582    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
583    #[test]
584    fn rejects_negative_dimension() {
585        let err = error_message(
586            string_empty_builtin(vec![Value::from(-1.0)]).expect_err("expected error"),
587        );
588        assert!(
589            err.contains("matrix dimensions must be non-negative"),
590            "unexpected error: {err}"
591        );
592    }
593}