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::random_args::{extract_dims, keyword_of};
7use crate::builtins::common::spec::{
8    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
9    ReductionNaN, ResidencyPolicy, ShapeRequirements,
10};
11#[cfg(feature = "doc_export")]
12use crate::register_builtin_doc_text;
13use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
14
15const LABEL: &str = "string.empty";
16
17#[cfg(feature = "doc_export")]
18pub const DOC_MD: &str = r#"---
19title: "string.empty"
20category: "strings/core"
21keywords: ["string.empty", "empty string array", "preallocate text", "size vector", "0-by-N", "'like'"]
22summary: "Construct empty string arrays with MATLAB-compatible dimension semantics and 'like' prototypes."
23references:
24  - https://www.mathworks.com/help/matlab/ref/string.empty.html
25gpu_support:
26  elementwise: false
27  reduction: false
28  precisions: []
29  broadcasting: "none"
30  notes: "Creates host string arrays; GPU tensors are neither read nor written."
31fusion:
32  elementwise: false
33  reduction: false
34  max_inputs: 0
35  constants: "inline"
36requires_feature: null
37tested:
38  unit: "builtins::strings::core::string_empty::tests"
39  integration: "builtins::strings::core::string_empty::tests::doc_examples_present"
40---
41
42# What does the `string.empty` function do in MATLAB / RunMat?
43`string.empty` constructs an empty string array. By default it returns a `0×0` array, and when you
44specify additional dimensions they define the trailing extents while the leading dimension remains
45zero, ensuring the total element count is zero. This mirrors MATLAB's static `string.empty` method.
46
47## How does the `string.empty` function behave in MATLAB / RunMat?
48- `string.empty` with no arguments yields a `0×0` string array.
49- `string.empty(n)` produces a `0×n` array. The leading dimension is fixed at `0`, so the result is
50  still empty even if `n > 0`.
51- `string.empty(m, n, p, ...)` returns a `0×n×p×…` array. All trailing dimensions are honoured while the leading dimension remains zero.
52- You can provide a single size vector such as `string.empty([0 5 3])`; the first entry is ignored
53  beyond confirming it is non-negative, and the remaining entries set the trailing dimensions.
54- `string.empty(___, 'like', prototype)` copies the trailing dimensions from `prototype` when you do
55  not supply explicit sizes. Any dimensions you pass explicitly take precedence. GPU-resident
56  prototypes are automatically gathered so their shape can be inspected.
57- Size inputs must be finite, real, non-negative integers. Fractional or negative values produce a
58  MATLAB-compatible error.
59- The result always resides on the host; there is no GPU counterpart for string arrays.
60
61## `string.empty` GPU Execution Behaviour
62`string.empty` does not allocate or interact with GPU memory. It is a pure host constructor that
63instantly returns the requested shape metadata and an empty data buffer. When the runtime is
64executing under RunMat Accelerate, no provider hooks are invoked. `'like'` prototypes that happen to
65live on the GPU are gathered to the host before their shape is examined.
66
67## Examples of using the `string.empty` function in MATLAB / RunMat
68
69### Creating a 0x0 string array
70```matlab
71S = string.empty;
72```
73Expected output:
74```matlab
75S =
76  0x0 string array
77```
78
79### Building a 0xN string row vector
80```matlab
81row = string.empty(5);
82```
83Expected output:
84```matlab
85row =
86  0x5 string array
87```
88
89### Creating a 0xN string array with extra dimensions
90```matlab
91cube = string.empty(0, 4, 3);
92```
93Expected output:
94```matlab
95cube =
96  0x4x3 string array
97```
98
99### Using a size vector with string.empty
100```matlab
101sz = [0 2 5];
102grid = string.empty(sz);
103```
104Expected output:
105```matlab
106grid =
107  0x2x5 string array
108```
109
110### Resetting a preallocated string array to empty
111```matlab
112A = strings(3, 2);  % Some application-specific strings
113A = string.empty(size(A));
114```
115Expected output:
116```matlab
117A =
118  0x2 string array
119```
120
121### Preserving higher-dimensional layout while empty
122```matlab
123layout = string.empty([2 0 4 6]);
124```
125Expected output:
126```matlab
127layout =
128  0x0x4x6 string array
129```
130
131### Reusing the shape of an existing array with `'like'`
132```matlab
133proto = strings(3, 2);
134sameCols = string.empty('like', proto);
135```
136Expected output:
137```matlab
138sameCols =
139  0x2 string array
140```
141
142## GPU residency in RunMat (Do I need `gpuArray`?)
143No. `string.empty` allocates metadata for an empty string array entirely on the host. Because the
144result contains no elements and string scalars are host-only, there is nothing to transfer to or from
145the GPU. Using `gpuArray` with `string.empty` has no effect and is unnecessary.
146
147## FAQ
148
149### Why is the first dimension always zero?
150MATLAB defines `classname.empty` so that the leading dimension is zero, guaranteeing the result
151contains no elements. RunMat mirrors this rule for perfect compatibility.
152
153### Can I request negative or fractional dimensions?
154No. Dimensions must be finite, non-negative integers. Any other input raises a descriptive error.
155
156### Does `string.empty(n)` create space for `n` elements?
157No. It returns a `0×n` array, which still has zero elements. Use `strings(n)` if you want an array of
158string scalars that you can fill later.
159
160### Can I combine scalars and size vectors?
161Yes. Calls like `string.empty([0 3], 5)` flatten to `string.empty(0, 3, 5)` internally.
162
163### What does the `'like'` option do?
164`'like', prototype` copies the trailing dimensions from `prototype` when you omit explicit sizes.
165The first dimension is still forced to `0`, so the result remains empty. The prototype is gathered
166automatically if it resides on the GPU.
167
168### Does the result share storage with existing arrays?
169No. Every call returns a new handle. Because the array is empty, the data buffer is an empty vector
170and consumes negligible memory.
171
172### Is there a GPU-accelerated variant?
173No. String arrays live on the host in RunMat, and this builtin never touches GPU memory.
174
175### How do I obtain a 0x0 string array quickly?
176Call `string.empty` with no arguments. It is equivalent to `strings(0)` but makes the intention
177explicit.
178
179### Can I use `size` output directly?
180Yes. Expressions like `string.empty(size(existingArray))` are supported. The first element of the
181size vector is ignored when constructing the new array so that the first dimension is zero.
182
183### What happens if I pass an empty array as the size vector?
184`string.empty([])` returns the canonical `0×0` string array, just like calling `string.empty` with no
185arguments.
186
187### Does `string.empty` ever throw away extra arguments?
188Only when they cannot be interpreted as dimensions. In that case RunMat throws an error rather than
189guessing.
190
191## See Also
192`string`, `strings`, `char`, `zeros`, `ones`
193"#;
194
195pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
196    name: "string.empty",
197    op_kind: GpuOpKind::Custom("constructor"),
198    supported_precisions: &[],
199    broadcast: BroadcastSemantics::None,
200    provider_hooks: &[],
201    constant_strategy: ConstantStrategy::InlineLiteral,
202    residency: ResidencyPolicy::NewHandle,
203    nan_mode: ReductionNaN::Include,
204    two_pass_threshold: None,
205    workgroup_size: None,
206    accepts_nan_mode: false,
207    notes: "Host-only constructor that returns a new empty string array without contacting GPU providers.",
208};
209
210register_builtin_gpu_spec!(GPU_SPEC);
211
212pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
213    name: "string.empty",
214    shape: ShapeRequirements::Any,
215    constant_strategy: ConstantStrategy::InlineLiteral,
216    elementwise: None,
217    reduction: None,
218    emits_nan: false,
219    notes: "Pure constructor; fusion planner treats calls as non-fusable sinks.",
220};
221
222register_builtin_fusion_spec!(FUSION_SPEC);
223
224#[cfg(feature = "doc_export")]
225register_builtin_doc_text!("string.empty", DOC_MD);
226
227#[runtime_builtin(
228    name = "string.empty",
229    category = "strings/core",
230    summary = "Construct an empty string array with MATLAB-compatible dimensions.",
231    keywords = "string.empty,empty,string array,preallocate",
232    accel = "none"
233)]
234fn string_empty_builtin(rest: Vec<Value>) -> Result<Value, String> {
235    let shape = parse_shape(&rest)?;
236    let total: usize = shape.iter().product();
237    debug_assert_eq!(total, 0, "string.empty must produce an empty array");
238    let data = Vec::<String>::new();
239    let array = StringArray::new(data, shape).map_err(|e| format!("{LABEL}: {e}"))?;
240    Ok(Value::StringArray(array))
241}
242
243fn parse_shape(args: &[Value]) -> Result<Vec<usize>, String> {
244    if args.is_empty() {
245        return Ok(vec![0, 0]);
246    }
247
248    let mut explicit_dims: Vec<usize> = Vec::new();
249    let mut like_shape: Option<Vec<usize>> = None;
250    let mut idx = 0;
251
252    while idx < args.len() {
253        let arg_host = gather_if_needed(&args[idx]).map_err(|e| format!("{LABEL}: {e}"))?;
254
255        if let Some(keyword) = keyword_of(&arg_host) {
256            if keyword.as_str() == "like" {
257                if like_shape.is_some() {
258                    return Err(format!(
259                        "{LABEL}: multiple 'like' prototypes are not supported"
260                    ));
261                }
262                let Some(proto_raw) = args.get(idx + 1) else {
263                    return Err(format!("{LABEL}: expected prototype after 'like'"));
264                };
265                let proto = gather_if_needed(proto_raw).map_err(|e| format!("{LABEL}: {e}"))?;
266                like_shape = Some(prototype_dims(&proto));
267                idx += 2;
268                continue;
269            }
270            // Unrecognized keywords are treated as non-keyword inputs and will
271            // be validated under numeric size parsing below.
272        }
273
274        if let Some(parsed) = extract_dims(&arg_host, LABEL)? {
275            if explicit_dims.is_empty() {
276                explicit_dims = parsed;
277            } else {
278                explicit_dims.extend(parsed);
279            }
280            idx += 1;
281            continue;
282        }
283
284        return Err(format!(
285            "{LABEL}: size inputs must be numeric scalars or size vectors"
286        ));
287    }
288
289    let shape = if !explicit_dims.is_empty() {
290        shape_from_explicit_dims(&explicit_dims)
291    } else if let Some(proto_shape) = like_shape {
292        shape_from_like(&proto_shape)
293    } else {
294        vec![0, 0]
295    };
296    ensure_empty_shape(&shape)?;
297    Ok(shape)
298}
299
300fn shape_from_explicit_dims(dims: &[usize]) -> Vec<usize> {
301    match dims.len() {
302        0 => vec![0, 0],
303        1 => vec![0, dims[0]],
304        _ => {
305            let mut shape = Vec::with_capacity(dims.len());
306            shape.push(0);
307            shape.extend_from_slice(&dims[1..]);
308            shape
309        }
310    }
311}
312
313fn shape_from_like(proto: &[usize]) -> Vec<usize> {
314    if proto.is_empty() {
315        return vec![0, 0];
316    }
317    if proto.len() == 1 {
318        return vec![0, proto[0]];
319    }
320    let mut shape = Vec::with_capacity(proto.len());
321    shape.push(0);
322    shape.extend_from_slice(&proto[1..]);
323    shape
324}
325
326fn ensure_empty_shape(shape: &[usize]) -> Result<(), String> {
327    if shape.iter().product::<usize>() != 0 {
328        return Err(format!(
329            "{LABEL}: at least one dimension must be zero to construct an empty string array"
330        ));
331    }
332    Ok(())
333}
334
335fn prototype_dims(proto: &Value) -> Vec<usize> {
336    match proto {
337        Value::StringArray(sa) => sa.shape.clone(),
338        Value::CharArray(ca) => vec![ca.rows, ca.cols],
339        Value::Tensor(t) => t.shape.clone(),
340        Value::ComplexTensor(t) => t.shape.clone(),
341        Value::LogicalArray(l) => l.shape.clone(),
342        Value::Cell(cell) => cell.shape.clone(),
343        Value::GpuTensor(handle) => handle.shape.clone(),
344        Value::Num(_) | Value::Int(_) | Value::Bool(_) | Value::Complex(_, _) => vec![1, 1],
345        Value::String(_) => vec![1, 1],
346        _ => vec![1, 1],
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::builtins::common::test_support;
354    use runmat_accelerate_api::HostTensorView;
355    use runmat_builtins::{StringArray, Tensor, Value};
356
357    #[test]
358    fn default_is_zero_by_zero() {
359        let result = string_empty_builtin(Vec::new()).expect("string.empty");
360        match result {
361            Value::StringArray(sa) => {
362                assert_eq!(sa.shape, vec![0, 0]);
363                assert_eq!(sa.data.len(), 0);
364            }
365            other => panic!("expected string array, got {other:?}"),
366        }
367    }
368
369    #[test]
370    fn single_dimension_creates_zero_by_n() {
371        let result = string_empty_builtin(vec![Value::from(5)]).expect("string.empty");
372        match result {
373            Value::StringArray(sa) => {
374                assert_eq!(sa.shape, vec![0, 5]);
375                assert_eq!(sa.data.len(), 0);
376            }
377            other => panic!("expected string array, got {other:?}"),
378        }
379    }
380
381    #[test]
382    fn multiple_dimensions_respect_trailing_sizes() {
383        let args = vec![Value::from(3), Value::from(4), Value::from(2)];
384        let result = string_empty_builtin(args).expect("string.empty");
385        match result {
386            Value::StringArray(sa) => {
387                assert_eq!(sa.shape, vec![0, 4, 2]);
388                assert_eq!(sa.data.len(), 0);
389            }
390            other => panic!("expected string array, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn size_vector_argument_supported() {
396        let tensor = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
397        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
398        match result {
399            Value::StringArray(sa) => {
400                assert_eq!(sa.shape, vec![0, 5, 3]);
401                assert_eq!(sa.data.len(), 0);
402            }
403            other => panic!("expected string array, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn size_vector_from_nonempty_array_drops_leading_extent() {
409        let tensor = Tensor::new(vec![3.0, 2.0], vec![1, 2]).unwrap();
410        let result = string_empty_builtin(vec![Value::Tensor(tensor)]).expect("string.empty");
411        match result {
412            Value::StringArray(sa) => {
413                assert_eq!(sa.shape, vec![0, 2]);
414                assert_eq!(sa.data.len(), 0);
415            }
416            other => panic!("expected string array, got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn accepts_zero_in_any_position() {
422        let args = vec![Value::from(3), Value::from(4), Value::from(0)];
423        let result = string_empty_builtin(args).expect("string.empty");
424        match result {
425            Value::StringArray(sa) => assert_eq!(sa.shape, vec![0, 4, 0]),
426            other => panic!("expected string array, got {other:?}"),
427        }
428    }
429
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    #[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    #[test]
459    fn like_with_numeric_prototype() {
460        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
461        let result = string_empty_builtin(vec![Value::from("like"), Value::Tensor(tensor)])
462            .expect("string.empty");
463        match result {
464            Value::StringArray(sa) => {
465                assert_eq!(sa.shape, vec![0, 1]);
466                assert_eq!(sa.data.len(), 0);
467            }
468            other => panic!("expected string array, got {other:?}"),
469        }
470    }
471
472    #[test]
473    fn like_with_explicit_dims_prefers_dimensions() {
474        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
475        let args = vec![
476            Value::from(0),
477            Value::from(7),
478            Value::from("like"),
479            Value::StringArray(proto),
480        ];
481        let result = string_empty_builtin(args).expect("string.empty");
482        match result {
483            Value::StringArray(sa) => {
484                assert_eq!(sa.shape, vec![0, 7]);
485                assert_eq!(sa.data.len(), 0);
486            }
487            other => panic!("expected string array, got {other:?}"),
488        }
489    }
490
491    #[test]
492    fn missing_like_prototype_errors() {
493        let err = string_empty_builtin(vec![Value::from("like")]).expect_err("expected error");
494        assert!(
495            err.contains("expected prototype"),
496            "unexpected error: {err}"
497        );
498    }
499
500    #[test]
501    fn duplicate_like_errors() {
502        let proto = StringArray::new(Vec::new(), vec![0, 2]).unwrap();
503        let err = string_empty_builtin(vec![
504            Value::from("like"),
505            Value::StringArray(proto.clone()),
506            Value::from("like"),
507            Value::StringArray(proto),
508        ])
509        .expect_err("expected error");
510        assert!(err.contains("multiple 'like'"), "unexpected error: {err}");
511    }
512
513    #[test]
514    fn rejects_non_dimension_inputs() {
515        let err =
516            string_empty_builtin(vec![Value::String("oops".into())]).expect_err("expected error");
517        assert!(
518            err.contains("size inputs must be numeric"),
519            "unexpected error: {err}"
520        );
521    }
522
523    #[test]
524    fn like_gathers_gpu_prototype() {
525        test_support::with_test_provider(|provider| {
526            let tensor =
527                Tensor::new((1..=6).map(|v| v as f64).collect::<Vec<_>>(), vec![2, 3]).unwrap();
528            let view = HostTensorView {
529                data: &tensor.data,
530                shape: &tensor.shape,
531            };
532            let handle = provider.upload(&view).expect("upload");
533            let result =
534                string_empty_builtin(vec![Value::from("like"), Value::GpuTensor(handle.clone())])
535                    .expect("string.empty");
536            match result {
537                Value::StringArray(sa) => {
538                    assert_eq!(sa.shape, vec![0, 3]);
539                    assert_eq!(sa.data.len(), 0);
540                }
541                other => panic!("expected string array, got {other:?}"),
542            }
543            let _ = provider.free(&handle);
544        });
545    }
546
547    #[test]
548    fn gpu_dimension_arguments_are_gathered() {
549        test_support::with_test_provider(|provider| {
550            let dims = Tensor::new(vec![0.0, 5.0, 3.0], vec![1, 3]).unwrap();
551            let view = HostTensorView {
552                data: &dims.data,
553                shape: &dims.shape,
554            };
555            let handle = provider.upload(&view).expect("upload");
556            let result =
557                string_empty_builtin(vec![Value::GpuTensor(handle.clone())]).expect("string.empty");
558            match result {
559                Value::StringArray(sa) => {
560                    assert_eq!(sa.shape, vec![0, 5, 3]);
561                    assert_eq!(sa.data.len(), 0);
562                }
563                other => panic!("expected string array, got {other:?}"),
564            }
565            let _ = provider.free(&handle);
566        });
567    }
568
569    #[test]
570    fn rejects_negative_dimension() {
571        let err = string_empty_builtin(vec![Value::from(-1.0)]).expect_err("expected error");
572        assert!(
573            err.contains("matrix dimensions must be non-negative"),
574            "unexpected error: {err}"
575        );
576    }
577
578    #[test]
579    #[cfg(feature = "doc_export")]
580    fn doc_examples_present() {
581        let blocks = test_support::doc_examples(DOC_MD);
582        assert!(!blocks.is_empty());
583    }
584}