runmat_runtime/builtins/diagnostics/
error.rs

1//! MATLAB-compatible `error` builtin with structured exception handling semantics.
2
3use std::convert::TryFrom;
4
5use runmat_builtins::{StructValue, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::format::format_variadic;
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ShapeRequirements,
12};
13#[cfg(feature = "doc_export")]
14use crate::register_builtin_doc_text;
15use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
16
17const DEFAULT_IDENTIFIER: &str = "MATLAB:error";
18
19#[cfg(feature = "doc_export")]
20pub const DOC_MD: &str = r#"---
21title: "error"
22category: "diagnostics"
23keywords: ["error", "exception", "throw", "diagnostics", "message"]
24summary: "Throw an exception with an identifier and a formatted diagnostic message."
25references: []
26gpu_support:
27  elementwise: false
28  reduction: false
29  precisions: []
30  broadcasting: "none"
31  notes: "Always executed on the host; GPU tensors are gathered only when they appear in formatted arguments."
32fusion:
33  elementwise: false
34  reduction: false
35  max_inputs: 0
36  constants: "inline"
37requires_feature: null
38tested:
39  unit: "builtins::diagnostics::error::tests"
40  integration: null
41---
42
43# What does the `error` function do in MATLAB / RunMat?
44`error` throws an exception immediately, unwinding the current execution frame and transferring control to the nearest `catch` block (or aborting the program if none exists). RunMat mirrors MATLAB's behaviour, including support for message identifiers, formatted messages, `MException` objects, and legacy message structs.
45
46## How does the `error` function behave in MATLAB / RunMat?
47- `error(message)` throws using the default identifier `MATLAB:error`.
48- `error(id, message)` uses a custom identifier. Identifiers are normalised to `MATLAB:*` when they do not already contain a namespace.
49- `error(fmt, arg1, ...)` formats the message with MATLAB's `sprintf` rules before throwing.
50- `error(id, fmt, arg1, ...)` combines both a custom identifier and formatted message text.
51- `error(MException_obj)` rethrows an existing exception without altering its identifier or message.
52- `error(struct('identifier', id, 'message', msg))` honours the legacy structure form.
53- Invalid invocations (missing message, extra arguments after an `MException`, malformed structs, etc.) themselves raise `MATLAB:error` diagnostics so the caller can correct usage.
54
55The thrown exception is observed in MATLAB-compatible `try`/`catch` constructs or by the embedding runtime, which converts the string back into an `MException` object.
56
57## GPU execution and residency
58`error` is a control-flow builtin and never executes on the GPU. When formatting messages that include GPU-resident arrays (for example, via `%g` or `%s` specifiers), RunMat first gathers those values back to host memory so that the final diagnostic message accurately reflects the data the user passed.
59
60## Examples of using the `error` function in MATLAB / RunMat
61
62### Throwing an error with a simple message
63```matlab
64try
65    error("Computation failed.");
66catch err
67    fprintf("%s -> %s\n", err.identifier, err.message);
68end
69```
70
71### Throwing an error with a custom identifier
72```matlab
73try
74    error("runmat:examples:invalidState", "State vector is empty.");
75catch err
76    fprintf("%s\n", err.identifier);
77end
78```
79
80### Formatting values inside the error message
81```matlab
82value = 42;
83try
84    error("MATLAB:demo:badValue", "Value %d is outside [%d, %d].", value, 0, 10);
85catch err
86    disp(err.message);
87end
88```
89
90### Rethrowing an existing MException
91```matlab
92try
93    try
94        error("MATLAB:inner:failure", "Inner failure.");
95    catch inner
96        error(inner); % propagate with original identifier/message
97    end
98catch err
99    fprintf("%s\n", err.identifier);
100end
101```
102
103### Using a legacy message struct
104```matlab
105S.identifier = "toolbox:demo:badInput";
106S.message = "Inputs must be positive integers.";
107try
108    error(S);
109catch err
110    fprintf("%s\n", err.identifier);
111end
112```
113
114## FAQ
115
1161. **How do I choose a custom identifier?** Use `component:mnemonic` style strings such as `"MATLAB:io:fileNotFound"` or `"runmat:tools:badInput"`. If you omit a namespace (`:`), RunMat prefixes the identifier with `MATLAB:` automatically.
1172. **Can I rethrow an existing `MException`?** Yes. Pass the object returned by `catch err` directly to `error(err)` to propagate it unchanged.
1183. **What happens if I pass extra arguments after an `MException` or struct?** RunMat treats that as invalid usage and raises `MATLAB:error` explaining that no additional arguments are allowed in those forms.
1194. **Does `error` run on the GPU?** No. The builtin executes on the host. If the message references GPU data, RunMat gathers the values before formatting the diagnostic string.
1205. **What if I call `error` without arguments?** RunMat raises `MATLAB:error` indicating that a message is required, matching MATLAB's behaviour.
1216. **Why was my identifier normalised to `MATLAB:...`?** MATLAB requires message identifiers to contain at least one namespace separator (`:`). RunMat enforces this rule so diagnostics integrate cleanly with tooling that expects fully-qualified identifiers.
1227. **Can the message span multiple lines?** Yes. Any newline characters in the formatted message are preserved exactly in the thrown exception.
1238. **Does formatting follow MATLAB rules?** Yes. `error` uses the same formatter as `sprintf`, including width/precision specifiers and numeric conversions, and will raise `MATLAB:error` if the format string is invalid or under-specified.
124#"#;
125
126pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
127    name: "error",
128    op_kind: GpuOpKind::Custom("control"),
129    supported_precisions: &[],
130    broadcast: BroadcastSemantics::None,
131    provider_hooks: &[],
132    constant_strategy: ConstantStrategy::InlineLiteral,
133    residency: ResidencyPolicy::GatherImmediately,
134    nan_mode: ReductionNaN::Include,
135    two_pass_threshold: None,
136    workgroup_size: None,
137    accepts_nan_mode: false,
138    notes: "Control-flow builtin; never dispatched to GPU backends.",
139};
140
141register_builtin_gpu_spec!(GPU_SPEC);
142
143pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
144    name: "error",
145    shape: ShapeRequirements::Any,
146    constant_strategy: ConstantStrategy::InlineLiteral,
147    elementwise: None,
148    reduction: None,
149    emits_nan: false,
150    notes: "Control-flow builtin; excluded from fusion planning.",
151};
152
153register_builtin_fusion_spec!(FUSION_SPEC);
154
155#[cfg(feature = "doc_export")]
156register_builtin_doc_text!("error", DOC_MD);
157
158#[runtime_builtin(
159    name = "error",
160    category = "diagnostics",
161    summary = "Throw an exception with an identifier and a formatted diagnostic message.",
162    keywords = "error,exception,diagnostics,throw",
163    accel = "metadata"
164)]
165fn error_builtin(args: Vec<Value>) -> Result<Value, String> {
166    if args.is_empty() {
167        return Err(build_error(
168            DEFAULT_IDENTIFIER,
169            "error: missing message argument",
170        ));
171    }
172
173    let mut iter = args.into_iter();
174    let first = iter.next().expect("checked above");
175    let rest: Vec<Value> = iter.collect();
176
177    match first {
178        Value::MException(mex) => {
179            if !rest.is_empty() {
180                return Err(build_error(
181                    DEFAULT_IDENTIFIER,
182                    "error: additional arguments are not allowed when passing an MException",
183                ));
184            }
185            Err(build_error(&mex.identifier, &mex.message))
186        }
187        Value::Struct(ref st) => {
188            if !rest.is_empty() {
189                return Err(build_error(
190                    DEFAULT_IDENTIFIER,
191                    "error: additional arguments are not allowed when passing a message struct",
192                ));
193            }
194            let (identifier, message) = extract_struct_error_fields(st)?;
195            Err(build_error(&identifier, &message))
196        }
197        other => handle_message_arguments(other, rest),
198    }
199}
200
201fn handle_message_arguments(first: Value, rest: Vec<Value>) -> Result<Value, String> {
202    let first_string = value_to_string("error", &first)?;
203
204    if rest.is_empty() {
205        return Err(build_error(DEFAULT_IDENTIFIER, &first_string));
206    }
207
208    let mut identifier = DEFAULT_IDENTIFIER.to_string();
209    let mut format_string = first_string;
210    let mut format_args: &[Value] = &rest;
211
212    if !rest.is_empty()
213        && (is_message_identifier(&format_string)
214            || looks_like_unqualified_identifier(&format_string))
215    {
216        identifier = normalize_identifier(&format_string);
217        let (message_value, extra_args) = rest.split_first().expect("rest not empty");
218        format_string = value_to_string("error", message_value)?;
219        format_args = extra_args;
220    }
221
222    let message = if format_args.is_empty() {
223        format_string
224    } else {
225        format_variadic(&format_string, format_args)
226            .map_err(|e| build_error(DEFAULT_IDENTIFIER, &e))?
227    };
228
229    Err(build_error(&identifier, &message))
230}
231
232fn extract_struct_error_fields(struct_value: &StructValue) -> Result<(String, String), String> {
233    let identifier_value = struct_value
234        .fields
235        .get("identifier")
236        .or_else(|| struct_value.fields.get("messageid"))
237        .ok_or_else(|| {
238            build_error(
239                DEFAULT_IDENTIFIER,
240                "error: message struct must contain an 'identifier' field",
241            )
242        })?;
243    let message_value = struct_value
244        .fields
245        .get("message")
246        .or_else(|| struct_value.fields.get("msg"))
247        .ok_or_else(|| {
248            build_error(
249                DEFAULT_IDENTIFIER,
250                "error: message struct must contain a 'message' field",
251            )
252        })?;
253
254    let identifier = value_to_string("error", identifier_value)?;
255    let message = value_to_string("error", message_value)?;
256    Ok((identifier, message))
257}
258
259fn value_to_string(context: &str, value: &Value) -> Result<String, String> {
260    String::try_from(value).map_err(|e| build_error(DEFAULT_IDENTIFIER, &format!("{context}: {e}")))
261}
262
263fn build_error(identifier: &str, message: &str) -> String {
264    let ident = normalize_identifier(identifier);
265    format!("{ident}: {message}")
266}
267
268fn normalize_identifier(raw: &str) -> String {
269    let trimmed = raw.trim();
270    if trimmed.is_empty() {
271        DEFAULT_IDENTIFIER.to_string()
272    } else if trimmed.contains(':') {
273        trimmed.to_string()
274    } else {
275        format!("MATLAB:{trimmed}")
276    }
277}
278
279fn is_message_identifier(text: &str) -> bool {
280    let trimmed = text.trim();
281    if trimmed.is_empty() || !trimmed.contains(':') {
282        return false;
283    }
284    trimmed
285        .chars()
286        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '.'))
287}
288
289fn looks_like_unqualified_identifier(text: &str) -> bool {
290    let trimmed = text.trim();
291    if trimmed.is_empty() || trimmed.contains(char::is_whitespace) {
292        return false;
293    }
294    trimmed
295        .chars()
296        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.'))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use runmat_builtins::{IntValue, MException};
303
304    #[test]
305    fn error_requires_message() {
306        let err = error_builtin(Vec::new()).expect_err("should error");
307        assert!(err.contains("missing message"));
308    }
309
310    #[test]
311    fn default_identifier_is_applied() {
312        let err = error_builtin(vec![Value::from("Failure!")]).expect_err("should error");
313        assert_eq!(err, "MATLAB:error: Failure!");
314    }
315
316    #[test]
317    fn custom_identifier_is_preserved() {
318        let err = error_builtin(vec![
319            Value::from("runmat:tests:badValue"),
320            Value::from("Value %d is not allowed."),
321            Value::from(5.0),
322        ])
323        .expect_err("should error");
324        assert_eq!(err, "runmat:tests:badValue: Value 5 is not allowed.");
325    }
326
327    #[test]
328    fn identifier_is_normalised_when_namespace_missing() {
329        let err = error_builtin(vec![
330            Value::from("missingNamespace"),
331            Value::from("Message"),
332        ])
333        .expect_err("should error");
334        assert_eq!(err, "MATLAB:missingNamespace: Message");
335    }
336
337    #[test]
338    fn format_string_with_colon_not_treated_as_identifier() {
339        let err = error_builtin(vec![
340            Value::from("Value: %d."),
341            Value::Int(IntValue::I32(7)),
342        ])
343        .expect_err("should error");
344        assert_eq!(err, "MATLAB:error: Value: 7.");
345    }
346
347    #[test]
348    fn error_accepts_mexception() {
349        let mex = MException::new("MATLAB:demo:test".to_string(), "broken".to_string());
350        let err = error_builtin(vec![Value::MException(mex)]).expect_err("should error");
351        assert_eq!(err, "MATLAB:demo:test: broken");
352    }
353
354    #[test]
355    fn error_rejects_extra_args_after_mexception() {
356        let mex = MException::new("MATLAB:demo:test".to_string(), "broken".to_string());
357        let err = error_builtin(vec![Value::MException(mex), Value::from(1.0)])
358            .expect_err("should error");
359        assert!(err.contains("additional arguments"));
360    }
361
362    #[test]
363    fn error_accepts_message_struct() {
364        let mut st = StructValue::new();
365        st.fields
366            .insert("identifier".to_string(), Value::from("pkg:demo:failure"));
367        st.fields
368            .insert("message".to_string(), Value::from("Struct message."));
369        let err = error_builtin(vec![Value::Struct(st)]).expect_err("should error");
370        assert_eq!(err, "pkg:demo:failure: Struct message.");
371    }
372
373    #[test]
374    fn error_struct_requires_message_field() {
375        let mut st = StructValue::new();
376        st.fields
377            .insert("identifier".to_string(), Value::from("pkg:demo:oops"));
378        let err = error_builtin(vec![Value::Struct(st)]).expect_err("should error");
379        assert!(err.contains("message struct must contain a 'message' field"));
380    }
381
382    #[test]
383    #[cfg(feature = "doc_export")]
384    fn doc_examples_present() {
385        use crate::builtins::common::test_support;
386        let blocks = test_support::doc_examples(DOC_MD);
387        assert!(!blocks.is_empty());
388    }
389}