runmat_runtime/builtins/structs/core/
rmfield.rs

1//! MATLAB-compatible `rmfield` builtin that removes fields from structs and struct arrays.
2
3use crate::builtins::common::spec::{
4    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
5    ReductionNaN, ResidencyPolicy, ShapeRequirements,
6};
7#[cfg(feature = "doc_export")]
8use crate::register_builtin_doc_text;
9use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
10use runmat_builtins::{CellArray, StringArray, StructValue, Value};
11use runmat_macros::runtime_builtin;
12use std::collections::HashSet;
13
14#[cfg(feature = "doc_export")]
15pub const DOC_MD: &str = r#"---
16title: "rmfield"
17category: "structs/core"
18keywords: ["rmfield", "remove field", "struct", "struct array", "metadata"]
19summary: "Remove one or more fields from scalar structs or struct arrays."
20references: []
21gpu_support:
22  elementwise: false
23  reduction: false
24  precisions: []
25  broadcasting: "none"
26  notes: "Runs entirely on the host; values that already live on the GPU remain device-resident."
27fusion:
28  elementwise: false
29  reduction: false
30  max_inputs: 1
31  constants: "inline"
32requires_feature: null
33tested:
34  unit: "builtins::structs::core::rmfield::tests"
35  integration: "builtins::structs::core::rmfield::tests::rmfield_struct_array_roundtrip"
36---
37
38# What does the `rmfield` function do in MATLAB / RunMat?
39`S2 = rmfield(S, name)` returns a copy of `S` with the field `name` removed. The builtin accepts
40additional field names, string arrays, or cell arrays of names to delete several fields in one call.
41
42## How does the `rmfield` function behave in MATLAB / RunMat?
43- Works with scalar structs and struct arrays created by `struct`, `load`, or other builtins.
44- Accepts character vectors, string scalars, string arrays, and cell arrays containing those types
45  to identify the fields that should be removed.
46- Every listed field must already exist. Attempting to remove a missing field raises the standard
47  MATLAB-style error `Reference to non-existent field '<name>'`.
48- Removing multiple fields applies to every element in a struct array; the operation fails if any
49  element is missing one of the requested fields.
50- The input `S` is not mutated in place. `rmfield` returns a new struct (or struct array) while the
51  original remains unchanged.
52
53## `rmfield` Function GPU Execution Behaviour
54`rmfield` performs metadata updates on the host. Values that already reside on the GPU—such as
55`gpuArray` tensors stored in other fields—stay on the device. Because this builtin only rewrites
56struct metadata it does not require or invoke acceleration provider hooks.
57
58## Examples of using the `rmfield` function in MATLAB / RunMat
59
60### Removing a single field from a scalar struct
61```matlab
62s = struct("name", "Ada", "score", 42);
63t = rmfield(s, "score");
64isfield(t, "score")
65```
66
67Expected output:
68```matlab
69ans =
70  logical
71   0
72```
73
74### Removing several fields with a cell array of names
75```matlab
76cfg = struct("mode", "fast", "rate", 60, "debug", true);
77cfg = rmfield(cfg, {"rate", "debug"});
78fieldnames(cfg)
79```
80
81Expected output:
82```matlab
83ans =
84  1×1 cell array
85    {'mode'}
86```
87
88### Removing a field from every element of a struct array
89```matlab
90people = struct("name", {"Ada", "Grace"}, "id", {101, 102}, "email", {"ada@example.com", "grace@example.com"});
91trimmed = rmfield(people, "email");
92fieldnames(trimmed)
93```
94
95Expected output:
96```matlab
97ans =
98  2×1 cell array
99    {'id'}
100    {'name'}
101```
102
103### Supplying a string array of field names to delete
104```matlab
105stats = struct("mean", 10, "median", 9, "stdev", 2);
106names = ["mean", "median"];
107reduced = rmfield(stats, names);
108fieldnames(reduced)
109```
110
111Expected output:
112```matlab
113ans =
114  1×1 cell array
115    {'stdev'}
116```
117
118### Conditionally removing optional fields
119```matlab
120record = struct("id", 7, "notes", "draft");
121if isfield(record, "notes")
122    record = rmfield(record, "notes");
123end
124fieldnames(record)
125```
126
127Expected output:
128```matlab
129ans =
130  1×1 cell array
131    {'id'}
132```
133
134## GPU residency in RunMat (Do I need `gpuArray`?)
135No additional residency management is required. `rmfield` leaves existing GPU tensors untouched and
136never gathers or uploads buffers. Subsequent GPU-aware builtins decide whether to keep values on the
137device.
138
139## FAQ
140
141### Does `rmfield` modify the input in place?
142No. The function returns a new struct (or struct array) with the specified fields removed. The input
143value remains unchanged, mirroring MATLAB's copy-on-write semantics.
144
145### What argument types can I use for the field names?
146You can pass character vectors, string scalars, string arrays, or cell arrays whose elements are
147strings or character vectors. Mixing these forms in a single call is supported—`rmfield`
148concatenates all supplied names into one list.
149
150### What happens if a field is missing?
151RunMat raises the MATLAB-compatible error `Reference to non-existent field '<name>'.` and leaves the
152struct unchanged.
153
154### Can I remove nested fields with `rmfield`?
155No. `rmfield` only removes top-level fields. Use `setfield` with nested assignments or restructure
156your data if you need to manipulate nested content.
157
158### Does `rmfield` work with MATLAB-style objects or handle classes?
159No. The builtin is restricted to structs and struct arrays. Use class-specific helpers (such as
160`rmprop`) for objects.
161
162### Does removing a field move GPU tensors back to the CPU?
163No. The builtin merely rewrites metadata. Any GPU-resident values stored in remaining fields stay on
164the device until another operation decides otherwise.
165
166## See Also
167[fieldnames](./fieldnames), [isfield](./isfield), [setfield](./setfield), [struct](./struct), [orderfields](./orderfields)
168"#;
169
170pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
171    name: "rmfield",
172    op_kind: GpuOpKind::Custom("rmfield"),
173    supported_precisions: &[],
174    broadcast: BroadcastSemantics::None,
175    provider_hooks: &[],
176    constant_strategy: ConstantStrategy::InlineLiteral,
177    residency: ResidencyPolicy::InheritInputs,
178    nan_mode: ReductionNaN::Include,
179    two_pass_threshold: None,
180    workgroup_size: None,
181    accepts_nan_mode: false,
182    notes: "Host-only struct metadata update; acceleration providers are not consulted.",
183};
184
185register_builtin_gpu_spec!(GPU_SPEC);
186
187pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
188    name: "rmfield",
189    shape: ShapeRequirements::Any,
190    constant_strategy: ConstantStrategy::InlineLiteral,
191    elementwise: None,
192    reduction: None,
193    emits_nan: false,
194    notes: "Metadata mutation forces fusion planners to flush pending groups on the host.",
195};
196
197register_builtin_fusion_spec!(FUSION_SPEC);
198
199#[cfg(feature = "doc_export")]
200register_builtin_doc_text!("rmfield", DOC_MD);
201
202#[runtime_builtin(
203    name = "rmfield",
204    category = "structs/core",
205    summary = "Remove one or more fields from scalar structs or struct arrays.",
206    keywords = "rmfield,struct,remove field,struct array"
207)]
208fn rmfield_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
209    let names = parse_field_names(&rest)?;
210    if names.is_empty() {
211        return Ok(value);
212    }
213
214    match value {
215        Value::Struct(st) => {
216            let updated = remove_fields_from_struct_owned(st, &names)?;
217            Ok(Value::Struct(updated))
218        }
219        Value::Cell(cell) if is_struct_array(&cell) => {
220            let updated = remove_fields_from_struct_array(&cell, &names)?;
221            Ok(Value::Cell(updated))
222        }
223        other => Err(format!(
224            "rmfield: expected struct or struct array, got {other:?}"
225        )),
226    }
227}
228
229fn parse_field_names(args: &[Value]) -> Result<Vec<String>, String> {
230    if args.is_empty() {
231        return Err("rmfield: not enough input arguments".to_string());
232    }
233    let mut names: Vec<String> = Vec::new();
234    for value in args {
235        names.extend(collect_field_names(value)?);
236    }
237    Ok(names)
238}
239
240fn collect_field_names(value: &Value) -> Result<Vec<String>, String> {
241    match value {
242        Value::String(_) | Value::CharArray(_) => expect_scalar_name(value)
243            .map(|name| vec![name])
244            .map_err(|err| format!("rmfield: {}", describe_field_name_error(err))),
245        Value::StringArray(sa) => {
246            if sa.data.len() == 1 {
247                expect_scalar_name(value)
248                    .map(|name| vec![name])
249                    .map_err(|err| format!("rmfield: {}", describe_field_name_error(err)))
250            } else {
251                string_array_to_names(sa)
252            }
253        }
254        Value::Cell(cell) => cell_to_names(cell),
255        other => Err(format!(
256            "rmfield: field names must be strings or character vectors (got {other:?})"
257        )),
258    }
259}
260
261fn string_array_to_names(array: &StringArray) -> Result<Vec<String>, String> {
262    let mut names = Vec::with_capacity(array.data.len());
263    for (index, name) in array.data.iter().enumerate() {
264        if name.is_empty() {
265            return Err(format!(
266                "rmfield: field names must be nonempty character vectors or strings (string array element {})",
267                index + 1
268            ));
269        }
270        names.push(name.clone());
271    }
272    Ok(names)
273}
274
275fn cell_to_names(cell: &CellArray) -> Result<Vec<String>, String> {
276    let mut output = Vec::with_capacity(cell.data.len());
277    for (index, handle) in cell.data.iter().enumerate() {
278        let value = unsafe { &*handle.as_raw() };
279        let name = expect_scalar_name(value).map_err(|err| {
280            format!(
281                "rmfield: {} (cell element {})",
282                describe_field_name_error(err),
283                index + 1
284            )
285        })?;
286        output.push(name);
287    }
288    Ok(output)
289}
290
291#[derive(Clone, Copy)]
292enum FieldNameError {
293    Type,
294    Empty,
295}
296
297fn describe_field_name_error(kind: FieldNameError) -> &'static str {
298    match kind {
299        FieldNameError::Type => {
300            "field names must be string scalars, character vectors, or single-element string arrays"
301        }
302        FieldNameError::Empty => "field names must be nonempty character vectors or strings",
303    }
304}
305
306fn expect_scalar_name(value: &Value) -> Result<String, FieldNameError> {
307    match value {
308        Value::String(s) => {
309            if s.is_empty() {
310                Err(FieldNameError::Empty)
311            } else {
312                Ok(s.clone())
313            }
314        }
315        Value::CharArray(ca) => {
316            if ca.rows != 1 {
317                return Err(FieldNameError::Type);
318            }
319            let text: String = ca.data.iter().collect();
320            if text.is_empty() {
321                Err(FieldNameError::Empty)
322            } else {
323                Ok(text)
324            }
325        }
326        Value::StringArray(sa) => {
327            if sa.data.len() != 1 {
328                return Err(FieldNameError::Type);
329            }
330            let text = sa.data[0].clone();
331            if text.is_empty() {
332                Err(FieldNameError::Empty)
333            } else {
334                Ok(text)
335            }
336        }
337        _ => Err(FieldNameError::Type),
338    }
339}
340
341fn remove_fields_from_struct_owned(
342    mut st: StructValue,
343    names: &[String],
344) -> Result<StructValue, String> {
345    let mut seen: HashSet<&str> = HashSet::new();
346    for name in names {
347        if !seen.insert(name.as_str()) {
348            continue;
349        }
350        if st.remove(name).is_none() {
351            return Err(missing_field_error(name));
352        }
353    }
354    Ok(st)
355}
356
357fn remove_fields_from_struct_array(
358    array: &CellArray,
359    names: &[String],
360) -> Result<CellArray, String> {
361    if array.data.is_empty() {
362        return Ok(array.clone());
363    }
364
365    let mut updated: Vec<Value> = Vec::with_capacity(array.data.len());
366    for handle in &array.data {
367        let value = unsafe { &*handle.as_raw() };
368        let Value::Struct(st) = value else {
369            return Err("rmfield: expected struct array contents to be structs".to_string());
370        };
371        let revised = remove_fields_from_struct_owned(st.clone(), names)?;
372        updated.push(Value::Struct(revised));
373    }
374    CellArray::new_with_shape(updated, array.shape.clone())
375        .map_err(|e| format!("rmfield: failed to rebuild struct array: {e}"))
376}
377
378fn missing_field_error(name: &str) -> String {
379    format!("Reference to non-existent field '{name}'.")
380}
381
382fn is_struct_array(cell: &CellArray) -> bool {
383    cell.data
384        .iter()
385        .all(|handle| matches!(unsafe { &*handle.as_raw() }, Value::Struct(_)))
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use runmat_builtins::{CellArray, CharArray, StringArray, StructValue, Value};
392
393    #[cfg(feature = "doc_export")]
394    use crate::builtins::common::test_support;
395    #[cfg(feature = "wgpu")]
396    use runmat_accelerate_api::HostTensorView;
397
398    #[test]
399    fn rmfield_removes_single_field_from_scalar_struct() {
400        let mut st = StructValue::new();
401        st.fields.insert("name".to_string(), Value::from("Ada"));
402        st.fields.insert("score".to_string(), Value::Num(42.0));
403        let result =
404            rmfield_builtin(Value::Struct(st), vec![Value::from("score")]).expect("rmfield");
405        let Value::Struct(updated) = result else {
406            panic!("expected struct result");
407        };
408        assert!(!updated.fields.contains_key("score"));
409        assert!(updated.fields.contains_key("name"));
410    }
411
412    #[test]
413    fn rmfield_accepts_cell_array_of_field_names() {
414        let mut st = StructValue::new();
415        st.fields.insert("left".to_string(), Value::Num(1.0));
416        st.fields.insert("right".to_string(), Value::Num(2.0));
417        st.fields.insert("top".to_string(), Value::Num(3.0));
418        let cell =
419            CellArray::new(vec![Value::from("left"), Value::from("top")], 1, 2).expect("cell");
420        let result = rmfield_builtin(Value::Struct(st), vec![Value::Cell(cell)]).expect("rmfield");
421        let Value::Struct(updated) = result else {
422            panic!("expected struct result");
423        };
424        assert!(!updated.fields.contains_key("left"));
425        assert!(!updated.fields.contains_key("top"));
426        assert!(updated.fields.contains_key("right"));
427    }
428
429    #[test]
430    fn rmfield_supports_string_array_names() {
431        let mut st = StructValue::new();
432        st.fields.insert("alpha".to_string(), Value::Num(1.0));
433        st.fields.insert("beta".to_string(), Value::Num(2.0));
434        st.fields.insert("gamma".to_string(), Value::Num(3.0));
435        let strings = StringArray::new(vec!["alpha".into(), "gamma".into()], vec![1, 2]).unwrap();
436        let result =
437            rmfield_builtin(Value::Struct(st), vec![Value::StringArray(strings)]).expect("rmfield");
438        let Value::Struct(updated) = result else {
439            panic!("expected struct result");
440        };
441        assert!(!updated.fields.contains_key("alpha"));
442        assert!(!updated.fields.contains_key("gamma"));
443        assert!(updated.fields.contains_key("beta"));
444    }
445
446    #[test]
447    fn rmfield_errors_when_field_missing() {
448        let mut st = StructValue::new();
449        st.fields.insert("name".to_string(), Value::from("Ada"));
450        let err = rmfield_builtin(Value::Struct(st), vec![Value::from("id")]).unwrap_err();
451        assert!(
452            err.contains("Reference to non-existent field 'id'."),
453            "unexpected error: {err}"
454        );
455    }
456
457    #[test]
458    fn rmfield_struct_array_roundtrip() {
459        let mut first = StructValue::new();
460        first.fields.insert("name".to_string(), Value::from("Ada"));
461        first.fields.insert("score".to_string(), Value::Num(90.0));
462
463        let mut second = StructValue::new();
464        second
465            .fields
466            .insert("name".to_string(), Value::from("Grace"));
467        second.fields.insert("score".to_string(), Value::Num(95.0));
468
469        let array = CellArray::new_with_shape(
470            vec![Value::Struct(first), Value::Struct(second)],
471            vec![1, 2],
472        )
473        .expect("struct array");
474
475        let result =
476            rmfield_builtin(Value::Cell(array), vec![Value::from("score")]).expect("rmfield");
477        let Value::Cell(updated) = result else {
478            panic!("expected struct array");
479        };
480        for handle in &updated.data {
481            let value = unsafe { &*handle.as_raw() };
482            let Value::Struct(st) = value else {
483                panic!("expected struct element");
484            };
485            assert!(!st.fields.contains_key("score"));
486            assert!(st.fields.contains_key("name"));
487        }
488    }
489
490    #[test]
491    fn rmfield_struct_array_missing_field_errors() {
492        let mut first = StructValue::new();
493        first.fields.insert("id".to_string(), Value::Num(1.0));
494        let mut second = StructValue::new();
495        second.fields.insert("id".to_string(), Value::Num(2.0));
496        second.fields.insert("extra".to_string(), Value::Num(3.0));
497
498        let array = CellArray::new_with_shape(
499            vec![Value::Struct(first), Value::Struct(second)],
500            vec![1, 2],
501        )
502        .expect("struct array");
503
504        let err = rmfield_builtin(Value::Cell(array), vec![Value::from("missing")]).unwrap_err();
505        assert!(
506            err.contains("Reference to non-existent field 'missing'."),
507            "unexpected error: {err}"
508        );
509    }
510
511    #[test]
512    fn rmfield_rejects_non_struct_inputs() {
513        let err = rmfield_builtin(Value::Num(1.0), vec![Value::from("field")]).unwrap_err();
514        assert!(
515            err.contains("expected struct or struct array"),
516            "unexpected error: {err}"
517        );
518    }
519
520    #[test]
521    fn rmfield_produces_error_for_empty_field_name() {
522        let mut st = StructValue::new();
523        st.fields.insert("data".to_string(), Value::Num(1.0));
524        let err = rmfield_builtin(Value::Struct(st), vec![Value::from("")]).unwrap_err();
525        assert!(
526            err.contains("field names must be nonempty"),
527            "unexpected error: {err}"
528        );
529    }
530
531    #[test]
532    fn rmfield_accepts_multiple_argument_forms() {
533        let mut st = StructValue::new();
534        st.fields.insert("alpha".to_string(), Value::Num(1.0));
535        st.fields.insert("beta".to_string(), Value::Num(2.0));
536        st.fields.insert("gamma".to_string(), Value::Num(3.0));
537        st.fields.insert("delta".to_string(), Value::Num(4.0));
538
539        let char_name = CharArray::new_row("beta");
540        let string_array =
541            StringArray::new(vec!["gamma".into()], vec![1, 1]).expect("string scalar array");
542        let cell = CellArray::new(vec![Value::from("delta")], 1, 1).expect("cell array of strings");
543
544        let result = rmfield_builtin(
545            Value::Struct(st),
546            vec![
547                Value::from("alpha"),
548                Value::CharArray(char_name),
549                Value::StringArray(string_array),
550                Value::Cell(cell),
551            ],
552        )
553        .expect("rmfield");
554
555        let Value::Struct(updated) = result else {
556            panic!("expected struct result");
557        };
558
559        assert!(updated.fields.is_empty());
560    }
561
562    #[test]
563    fn rmfield_ignores_duplicate_field_names() {
564        let mut st = StructValue::new();
565        st.fields.insert("keep".to_string(), Value::Num(1.0));
566        st.fields.insert("drop".to_string(), Value::Num(2.0));
567        let result = rmfield_builtin(
568            Value::Struct(st),
569            vec![Value::from("drop"), Value::from("drop")],
570        )
571        .expect("rmfield");
572        let Value::Struct(updated) = result else {
573            panic!("expected struct result");
574        };
575        assert!(!updated.fields.contains_key("drop"));
576        assert!(updated.fields.contains_key("keep"));
577    }
578
579    #[test]
580    fn rmfield_returns_original_when_no_names_supplied() {
581        let mut st = StructValue::new();
582        st.fields.insert("value".to_string(), Value::Num(10.0));
583        let empty = CellArray::new(Vec::new(), 0, 0).expect("empty cell array");
584        let original = st.clone();
585        let result =
586            rmfield_builtin(Value::Struct(st), vec![Value::Cell(empty)]).expect("rmfield empty");
587        assert_eq!(result, Value::Struct(original));
588    }
589
590    #[test]
591    fn rmfield_requires_field_names() {
592        let mut st = StructValue::new();
593        st.fields.insert("value".to_string(), Value::Num(10.0));
594        let err = rmfield_builtin(Value::Struct(st), Vec::new()).unwrap_err();
595        assert!(
596            err.contains("rmfield: not enough input arguments"),
597            "unexpected error: {err}"
598        );
599    }
600
601    #[test]
602    #[cfg(feature = "wgpu")]
603    fn rmfield_preserves_gpu_handles() {
604        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
605            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
606        );
607        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
608        let view = HostTensorView {
609            data: &[1.0, 2.0],
610            shape: &[2, 1],
611        };
612        let handle = provider.upload(&view).expect("upload");
613
614        let mut st = StructValue::new();
615        st.fields
616            .insert("gpu".to_string(), Value::GpuTensor(handle.clone()));
617        st.fields.insert("remove".to_string(), Value::Num(5.0));
618
619        let result =
620            rmfield_builtin(Value::Struct(st), vec![Value::from("remove")]).expect("rmfield");
621
622        let Value::Struct(updated) = result else {
623            panic!("expected struct result");
624        };
625
626        assert!(matches!(
627            updated.fields.get("gpu"),
628            Some(Value::GpuTensor(h)) if h == &handle
629        ));
630        assert!(!updated.fields.contains_key("remove"));
631    }
632
633    #[test]
634    #[cfg(feature = "doc_export")]
635    fn doc_examples_present() {
636        let blocks = test_support::doc_examples(DOC_MD);
637        assert!(!blocks.is_empty());
638    }
639}