Skip to main content

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

1//! MATLAB-compatible `string.empty` builtin for RunMat.
2
3use runmat_builtins::{StringArray, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::map_control_flow_with_builtin;
7use crate::builtins::common::random_args::{extract_dims, keyword_of};
8use crate::builtins::common::spec::{
9    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10    ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12use crate::builtins::strings::type_resolvers::string_array_type;
13use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
14
15const LABEL: &str = "string.empty";
16
17fn string_empty_flow(message: impl Into<String>) -> RuntimeError {
18    build_runtime_error(message).with_builtin(LABEL).build()
19}
20
21fn remap_string_empty_flow(err: RuntimeError) -> RuntimeError {
22    map_control_flow_with_builtin(err, LABEL)
23}
24
25#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::string_empty")]
26pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
27    name: "string.empty",
28    op_kind: GpuOpKind::Custom("constructor"),
29    supported_precisions: &[],
30    broadcast: BroadcastSemantics::None,
31    provider_hooks: &[],
32    constant_strategy: ConstantStrategy::InlineLiteral,
33    residency: ResidencyPolicy::NewHandle,
34    nan_mode: ReductionNaN::Include,
35    two_pass_threshold: None,
36    workgroup_size: None,
37    accepts_nan_mode: false,
38    notes: "Host-only constructor that returns a new empty string array without contacting GPU providers.",
39};
40
41#[runmat_macros::register_fusion_spec(
42    builtin_path = "crate::builtins::strings::core::string_empty"
43)]
44pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
45    name: "string.empty",
46    shape: ShapeRequirements::Any,
47    constant_strategy: ConstantStrategy::InlineLiteral,
48    elementwise: None,
49    reduction: None,
50    emits_nan: false,
51    notes: "Pure constructor; fusion planner treats calls as non-fusable sinks.",
52};
53
54#[runtime_builtin(
55    name = "string.empty",
56    category = "strings/core",
57    summary = "Construct an empty string array with MATLAB-compatible dimensions.",
58    keywords = "string.empty,empty,string array,preallocate",
59    accel = "none",
60    type_resolver(string_array_type),
61    builtin_path = "crate::builtins::strings::core::string_empty"
62)]
63async fn string_empty_builtin(rest: Vec<Value>) -> crate::BuiltinResult<Value> {
64    let shape = parse_shape(&rest).await?;
65    let total: usize = shape.iter().product();
66    debug_assert_eq!(total, 0, "string.empty must produce an empty array");
67    let data = Vec::<String>::new();
68    let array =
69        StringArray::new(data, shape).map_err(|e| string_empty_flow(format!("{LABEL}: {e}")))?;
70    Ok(Value::StringArray(array))
71}
72
73async fn parse_shape(args: &[Value]) -> BuiltinResult<Vec<usize>> {
74    if args.is_empty() {
75        return Ok(vec![0, 0]);
76    }
77
78    let mut explicit_dims: Vec<usize> = Vec::new();
79    let mut like_shape: Option<Vec<usize>> = None;
80    let mut idx = 0;
81
82    while idx < args.len() {
83        let arg_host = gather_if_needed_async(&args[idx])
84            .await
85            .map_err(remap_string_empty_flow)?;
86
87        if let Some(keyword) = keyword_of(&arg_host) {
88            if keyword.as_str() == "like" {
89                if like_shape.is_some() {
90                    return Err(string_empty_flow(format!(
91                        "{LABEL}: multiple 'like' prototypes are not supported"
92                    )));
93                }
94                let Some(proto_raw) = args.get(idx + 1) else {
95                    return Err(string_empty_flow(format!(
96                        "{LABEL}: expected prototype after 'like'"
97                    )));
98                };
99                let proto = gather_if_needed_async(proto_raw)
100                    .await
101                    .map_err(remap_string_empty_flow)?;
102                like_shape = Some(prototype_dims(&proto));
103                idx += 2;
104                continue;
105            }
106            // Unrecognized keywords are treated as non-keyword inputs and will
107            // be validated under numeric size parsing below.
108        }
109
110        if let Some(parsed) = extract_dims(&arg_host, LABEL)
111            .await
112            .map_err(string_empty_flow)?
113        {
114            if explicit_dims.is_empty() {
115                explicit_dims = parsed;
116            } else {
117                explicit_dims.extend(parsed);
118            }
119            idx += 1;
120            continue;
121        }
122
123        return Err(string_empty_flow(format!(
124            "{LABEL}: size inputs must be numeric scalars or size vectors"
125        )));
126    }
127
128    let shape = if !explicit_dims.is_empty() {
129        shape_from_explicit_dims(&explicit_dims)
130    } else if let Some(proto_shape) = like_shape {
131        shape_from_like(&proto_shape)
132    } else {
133        vec![0, 0]
134    };
135    ensure_empty_shape(&shape)?;
136    Ok(shape)
137}
138
139fn shape_from_explicit_dims(dims: &[usize]) -> Vec<usize> {
140    match dims.len() {
141        0 => vec![0, 0],
142        1 => vec![0, dims[0]],
143        _ => {
144            let mut shape = Vec::with_capacity(dims.len());
145            shape.push(0);
146            shape.extend_from_slice(&dims[1..]);
147            shape
148        }
149    }
150}
151
152fn shape_from_like(proto: &[usize]) -> Vec<usize> {
153    if proto.is_empty() {
154        return vec![0, 0];
155    }
156    if proto.len() == 1 {
157        return vec![0, proto[0]];
158    }
159    let mut shape = Vec::with_capacity(proto.len());
160    shape.push(0);
161    shape.extend_from_slice(&proto[1..]);
162    shape
163}
164
165fn ensure_empty_shape(shape: &[usize]) -> BuiltinResult<()> {
166    if shape.iter().product::<usize>() != 0 {
167        return Err(string_empty_flow(format!(
168            "{LABEL}: at least one dimension must be zero to construct an empty string array"
169        )));
170    }
171    Ok(())
172}
173
174fn prototype_dims(proto: &Value) -> Vec<usize> {
175    match proto {
176        Value::StringArray(sa) => sa.shape.clone(),
177        Value::CharArray(ca) => vec![ca.rows, ca.cols],
178        Value::Tensor(t) => t.shape.clone(),
179        Value::ComplexTensor(t) => t.shape.clone(),
180        Value::LogicalArray(l) => l.shape.clone(),
181        Value::Cell(cell) => cell.shape.clone(),
182        Value::GpuTensor(handle) => handle.shape.clone(),
183        Value::Num(_) | Value::Int(_) | Value::Bool(_) | Value::Complex(_, _) => vec![1, 1],
184        Value::String(_) => vec![1, 1],
185        _ => vec![1, 1],
186    }
187}
188
189#[cfg(test)]
190pub(crate) mod tests {
191    use super::*;
192    use crate::builtins::common::test_support;
193    use runmat_accelerate_api::HostTensorView;
194    use runmat_builtins::{ResolveContext, StringArray, Tensor, Type, Value};
195
196    fn string_empty_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
197        futures::executor::block_on(super::string_empty_builtin(rest))
198    }
199
200    fn error_message(err: crate::RuntimeError) -> String {
201        err.message().to_string()
202    }
203
204    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
205    #[test]
206    fn default_is_zero_by_zero() {
207        let result = string_empty_builtin(Vec::new()).expect("string.empty");
208        match result {
209            Value::StringArray(sa) => {
210                assert_eq!(sa.shape, vec![0, 0]);
211                assert_eq!(sa.data.len(), 0);
212            }
213            other => panic!("expected string array, got {other:?}"),
214        }
215    }
216
217    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
218    #[test]
219    fn single_dimension_creates_zero_by_n() {
220        let result = string_empty_builtin(vec![Value::from(5)]).expect("string.empty");
221        match result {
222            Value::StringArray(sa) => {
223                assert_eq!(sa.shape, vec![0, 5]);
224                assert_eq!(sa.data.len(), 0);
225            }
226            other => panic!("expected string array, got {other:?}"),
227        }
228    }
229
230    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
231    #[test]
232    fn multiple_dimensions_respect_trailing_sizes() {
233        let args = vec![Value::from(3), Value::from(4), Value::from(2)];
234        let result = string_empty_builtin(args).expect("string.empty");
235        match result {
236            Value::StringArray(sa) => {
237                assert_eq!(sa.shape, vec![0, 4, 2]);
238                assert_eq!(sa.data.len(), 0);
239            }
240            other => panic!("expected string array, got {other:?}"),
241        }
242    }
243
244    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
245    #[test]
246    fn size_vector_argument_supported() {
247        let tensor = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
248        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
249        match result {
250            Value::StringArray(sa) => {
251                assert_eq!(sa.shape, vec![0, 5, 3]);
252                assert_eq!(sa.data.len(), 0);
253            }
254            other => panic!("expected string array, got {other:?}"),
255        }
256    }
257
258    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
259    #[test]
260    fn size_vector_from_nonempty_array_drops_leading_extent() {
261        let tensor = Tensor::new(vec![3.0, 2.0], vec![1, 2]).unwrap();
262        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
263        match result {
264            Value::StringArray(sa) => {
265                assert_eq!(sa.shape, vec![0, 2]);
266                assert_eq!(sa.data.len(), 0);
267            }
268            other => panic!("expected string array, got {other:?}"),
269        }
270    }
271
272    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
273    #[test]
274    fn accepts_zero_in_any_position() {
275        let args = vec![Value::from(3), Value::from(4), Value::from(0)];
276        let result = string_empty_builtin(args).expect("string.empty");
277        match result {
278            Value::StringArray(sa) => assert_eq!(sa.shape, vec![0, 4, 0]),
279            other => panic!("expected string array, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn string_empty_type_is_string_array() {
285        assert_eq!(
286            string_array_type(&[], &ResolveContext::new(Vec::new())),
287            Type::cell_of(Type::String)
288        );
289    }
290
291    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
292    #[test]
293    fn like_prototype_without_explicit_dims() {
294        let proto = StringArray::new(vec!["alpha".to_string(); 6], vec![2, 3]).unwrap();
295        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
296            .expect("string.empty");
297        match result {
298            Value::StringArray(sa) => {
299                assert_eq!(sa.shape, vec![0, 3]);
300                assert_eq!(sa.data.len(), 0);
301            }
302            other => panic!("expected string array, got {other:?}"),
303        }
304    }
305
306    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
307    #[test]
308    fn like_prototype_with_scalar_shape() {
309        let proto = StringArray::new(vec!["foo".to_string()], vec![1, 1]).unwrap();
310        let result = string_empty_builtin(vec![Value::from("like"), Value::StringArray(proto)])
311            .expect("string.empty");
312        match result {
313            Value::StringArray(sa) => {
314                assert_eq!(sa.shape, vec![0, 1]);
315                assert_eq!(sa.data.len(), 0);
316            }
317            other => panic!("expected string array, got {other:?}"),
318        }
319    }
320
321    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
322    #[test]
323    fn like_with_numeric_prototype() {
324        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
325        let result = string_empty_builtin(vec![Value::from("like"), Value::Tensor(tensor)])
326            .expect("string.empty");
327        match result {
328            Value::StringArray(sa) => {
329                assert_eq!(sa.shape, vec![0, 1]);
330                assert_eq!(sa.data.len(), 0);
331            }
332            other => panic!("expected string array, got {other:?}"),
333        }
334    }
335
336    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
337    #[test]
338    fn like_with_explicit_dims_prefers_dimensions() {
339        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
340        let args = vec![
341            Value::from(0),
342            Value::from(7),
343            Value::from("like"),
344            Value::StringArray(proto),
345        ];
346        let result = string_empty_builtin(args).expect("string.empty");
347        match result {
348            Value::StringArray(sa) => {
349                assert_eq!(sa.shape, vec![0, 7]);
350                assert_eq!(sa.data.len(), 0);
351            }
352            other => panic!("expected string array, got {other:?}"),
353        }
354    }
355
356    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
357    #[test]
358    fn missing_like_prototype_errors() {
359        let err = error_message(
360            string_empty_builtin(vec![Value::from("like")]).expect_err("expected error"),
361        );
362        assert!(
363            err.contains("expected prototype"),
364            "unexpected error: {err}"
365        );
366    }
367
368    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
369    #[test]
370    fn duplicate_like_errors() {
371        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
372        let err = error_message(
373            string_empty_builtin(vec![
374                Value::from("like"),
375                Value::StringArray(proto.clone()),
376                Value::from("like"),
377                Value::StringArray(proto),
378            ])
379            .expect_err("expected error"),
380        );
381        assert!(err.contains("multiple 'like'"), "unexpected error: {err}");
382    }
383
384    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
385    #[test]
386    fn rejects_non_dimension_inputs() {
387        let err = error_message(
388            string_empty_builtin(vec![Value::String("oops".into())]).expect_err("expected error"),
389        );
390        assert!(
391            err.contains("size inputs must be numeric"),
392            "unexpected error: {err}"
393        );
394    }
395
396    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
397    #[test]
398    fn like_gathers_gpu_prototype() {
399        test_support::with_test_provider(|provider| {
400            let tensor =
401                Tensor::new((1..=6).map(|v| v as f64).collect::<Vec<_>>(), vec![2, 3]).unwrap();
402            let view = HostTensorView {
403                data: &tensor.data,
404                shape: &tensor.shape,
405            };
406            let handle = provider.upload(&view).expect("upload");
407            let result =
408                string_empty_builtin(vec![Value::from("like"), Value::GpuTensor(handle.clone())])
409                    .expect("string.empty");
410            match result {
411                Value::StringArray(sa) => {
412                    assert_eq!(sa.shape, vec![0, 3]);
413                    assert_eq!(sa.data.len(), 0);
414                }
415                other => panic!("expected string array, got {other:?}"),
416            }
417            let _ = provider.free(&handle);
418        });
419    }
420
421    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
422    #[test]
423    fn gpu_dimension_arguments_are_gathered() {
424        test_support::with_test_provider(|provider| {
425            let dims = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
426            let view = HostTensorView {
427                data: &dims.data,
428                shape: &dims.shape,
429            };
430            let handle = provider.upload(&view).expect("upload");
431            let result =
432                string_empty_builtin(vec![Value::GpuTensor(handle.clone())]).expect("string.empty");
433            match result {
434                Value::StringArray(sa) => {
435                    assert_eq!(sa.shape, vec![0, 5, 3]);
436                    assert_eq!(sa.data.len(), 0);
437                }
438                other => panic!("expected string array, got {other:?}"),
439            }
440            let _ = provider.free(&handle);
441        });
442    }
443
444    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
445    #[test]
446    fn rejects_negative_dimension() {
447        let err = error_message(
448            string_empty_builtin(vec![Value::from(-1.0)]).expect_err("expected error"),
449        );
450        assert!(
451            err.contains("matrix dimensions must be non-negative"),
452            "unexpected error: {err}"
453        );
454    }
455}