Skip to main content

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::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    StructValue, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::format::format_variadic;
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::builtins::diagnostics::type_resolvers::error_type;
18use crate::{build_runtime_error, RuntimeError};
19
20const BUILTIN_NAME: &str = "error";
21
22const ERROR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "out",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Never returned because error always throws.",
28}];
29
30const ERROR_INPUTS_MESSAGE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "message",
32    ty: BuiltinParamType::StringScalar,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Error message text.",
36}];
37
38const ERROR_INPUTS_MESSAGE_VARIADIC: [BuiltinParamDescriptor; 2] = [
39    BuiltinParamDescriptor {
40        name: "message",
41        ty: BuiltinParamType::StringScalar,
42        arity: BuiltinParamArity::Required,
43        default: None,
44        description: "Error message template text.",
45    },
46    BuiltinParamDescriptor {
47        name: "A",
48        ty: BuiltinParamType::Any,
49        arity: BuiltinParamArity::Variadic,
50        default: None,
51        description: "Formatting values for the message template.",
52    },
53];
54
55const ERROR_INPUTS_IDENTIFIER_MESSAGE: [BuiltinParamDescriptor; 2] = [
56    BuiltinParamDescriptor {
57        name: "message_id",
58        ty: BuiltinParamType::StringScalar,
59        arity: BuiltinParamArity::Required,
60        default: Some("\"RunMat:error\""),
61        description: "Message identifier.",
62    },
63    BuiltinParamDescriptor {
64        name: "message",
65        ty: BuiltinParamType::StringScalar,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "Error message text.",
69    },
70];
71
72const ERROR_INPUTS_IDENTIFIER_MESSAGE_VARIADIC: [BuiltinParamDescriptor; 3] = [
73    BuiltinParamDescriptor {
74        name: "message_id",
75        ty: BuiltinParamType::StringScalar,
76        arity: BuiltinParamArity::Required,
77        default: Some("\"RunMat:error\""),
78        description: "Message identifier.",
79    },
80    BuiltinParamDescriptor {
81        name: "message",
82        ty: BuiltinParamType::StringScalar,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Error message template text.",
86    },
87    BuiltinParamDescriptor {
88        name: "A",
89        ty: BuiltinParamType::Any,
90        arity: BuiltinParamArity::Variadic,
91        default: None,
92        description: "Formatting values for the message template.",
93    },
94];
95
96const ERROR_INPUTS_MEXCEPTION: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
97    name: "mex",
98    ty: BuiltinParamType::Any,
99    arity: BuiltinParamArity::Required,
100    default: None,
101    description: "MException value to rethrow.",
102}];
103
104const ERROR_INPUTS_STRUCT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
105    name: "msg_struct",
106    ty: BuiltinParamType::Any,
107    arity: BuiltinParamArity::Required,
108    default: None,
109    description: "Struct containing identifier/message fields.",
110}];
111
112const ERROR_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
113    BuiltinSignatureDescriptor {
114        label: "out = error(message)",
115        inputs: &ERROR_INPUTS_MESSAGE,
116        outputs: &ERROR_OUTPUT,
117    },
118    BuiltinSignatureDescriptor {
119        label: "out = error(message, A...)",
120        inputs: &ERROR_INPUTS_MESSAGE_VARIADIC,
121        outputs: &ERROR_OUTPUT,
122    },
123    BuiltinSignatureDescriptor {
124        label: "out = error(message_id, message)",
125        inputs: &ERROR_INPUTS_IDENTIFIER_MESSAGE,
126        outputs: &ERROR_OUTPUT,
127    },
128    BuiltinSignatureDescriptor {
129        label: "out = error(message_id, message, A...)",
130        inputs: &ERROR_INPUTS_IDENTIFIER_MESSAGE_VARIADIC,
131        outputs: &ERROR_OUTPUT,
132    },
133    BuiltinSignatureDescriptor {
134        label: "out = error(mex)",
135        inputs: &ERROR_INPUTS_MEXCEPTION,
136        outputs: &ERROR_OUTPUT,
137    },
138    BuiltinSignatureDescriptor {
139        label: "out = error(msg_struct)",
140        inputs: &ERROR_INPUTS_STRUCT,
141        outputs: &ERROR_OUTPUT,
142    },
143];
144
145const ERROR_ERROR_MISSING_MESSAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
146    code: "RM.ERROR.MISSING_MESSAGE",
147    identifier: Some("RunMat:error"),
148    when: "No arguments are supplied.",
149    message: "error: missing message argument",
150};
151
152const ERROR_ERROR_EXTRA_ARGS_MEXCEPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
153    code: "RM.ERROR.MEXCEPTION_EXTRA_ARGS",
154    identifier: Some("RunMat:error"),
155    when: "Additional arguments are supplied after an MException input.",
156    message: "error: additional arguments are not allowed when passing an MException",
157};
158
159const ERROR_ERROR_EXTRA_ARGS_STRUCT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
160    code: "RM.ERROR.STRUCT_EXTRA_ARGS",
161    identifier: Some("RunMat:error"),
162    when: "Additional arguments are supplied after a message-struct input.",
163    message: "error: additional arguments are not allowed when passing a message struct",
164};
165
166const ERROR_ERROR_STRUCT_MISSING_IDENTIFIER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
167    code: "RM.ERROR.STRUCT_MISSING_IDENTIFIER",
168    identifier: Some("RunMat:error"),
169    when: "Message struct does not contain an identifier field.",
170    message: "error: message struct must contain an 'identifier' field",
171};
172
173const ERROR_ERROR_STRUCT_MISSING_MESSAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
174    code: "RM.ERROR.STRUCT_MISSING_MESSAGE",
175    identifier: Some("RunMat:error"),
176    when: "Message struct does not contain a message field.",
177    message: "error: message struct must contain a 'message' field",
178};
179
180const ERROR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
181    code: "RM.ERROR.INVALID_INPUT",
182    identifier: Some("RunMat:error"),
183    when: "Identifier/message inputs or format arguments are not string-compatible.",
184    message: "error: invalid input argument",
185};
186
187const ERROR_ERRORS: [BuiltinErrorDescriptor; 6] = [
188    ERROR_ERROR_MISSING_MESSAGE,
189    ERROR_ERROR_EXTRA_ARGS_MEXCEPTION,
190    ERROR_ERROR_EXTRA_ARGS_STRUCT,
191    ERROR_ERROR_STRUCT_MISSING_IDENTIFIER,
192    ERROR_ERROR_STRUCT_MISSING_MESSAGE,
193    ERROR_ERROR_INVALID_INPUT,
194];
195
196pub const ERROR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
197    signatures: &ERROR_SIGNATURES,
198    output_mode: BuiltinOutputMode::Fixed,
199    completion_policy: BuiltinCompletionPolicy::Public,
200    errors: &ERROR_ERRORS,
201};
202
203#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::diagnostics::error")]
204pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
205    name: "error",
206    op_kind: GpuOpKind::Custom("control"),
207    supported_precisions: &[],
208    broadcast: BroadcastSemantics::None,
209    provider_hooks: &[],
210    constant_strategy: ConstantStrategy::InlineLiteral,
211    residency: ResidencyPolicy::GatherImmediately,
212    nan_mode: ReductionNaN::Include,
213    two_pass_threshold: None,
214    workgroup_size: None,
215    accepts_nan_mode: false,
216    notes: "Control-flow builtin; never dispatched to GPU backends.",
217};
218
219#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::diagnostics::error")]
220pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
221    name: "error",
222    shape: ShapeRequirements::Any,
223    constant_strategy: ConstantStrategy::InlineLiteral,
224    elementwise: None,
225    reduction: None,
226    emits_nan: false,
227    notes: "Control-flow builtin; excluded from fusion planning.",
228};
229
230fn error_flow(identifier: &str, message: impl Into<String>) -> RuntimeError {
231    build_runtime_error(message)
232        .with_builtin(BUILTIN_NAME)
233        .with_identifier(normalize_identifier(identifier))
234        .build()
235}
236
237fn error_default_identifier() -> &'static str {
238    ERROR_ERROR_MISSING_MESSAGE
239        .identifier
240        .expect("error default identifier must be defined")
241}
242
243fn error_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
244    error_error_with_message(error.message, error)
245}
246
247fn error_error_with_message(
248    message: impl Into<String>,
249    error: &'static BuiltinErrorDescriptor,
250) -> RuntimeError {
251    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
252    if let Some(identifier) = error.identifier {
253        builder = builder.with_identifier(normalize_identifier(identifier));
254    }
255    builder.build()
256}
257
258fn remap_error_flow(err: RuntimeError, error: &'static BuiltinErrorDescriptor) -> RuntimeError {
259    let mut builder = build_runtime_error(err.message().to_string())
260        .with_builtin(BUILTIN_NAME)
261        .with_source(err);
262    if let Some(identifier) = error.identifier {
263        builder = builder.with_identifier(normalize_identifier(identifier));
264    }
265    builder.build()
266}
267
268#[runtime_builtin(
269    name = "error",
270    category = "diagnostics",
271    summary = "Throw exceptions with identifiers and formatted messages.",
272    keywords = "error,exception,diagnostics,throw",
273    accel = "metadata",
274    type_resolver(error_type),
275    descriptor(crate::builtins::diagnostics::error::ERROR_DESCRIPTOR),
276    builtin_path = "crate::builtins::diagnostics::error"
277)]
278fn error_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
279    if args.is_empty() {
280        return Err(error_error(&ERROR_ERROR_MISSING_MESSAGE));
281    }
282
283    let mut iter = args.into_iter();
284    let first = iter.next().expect("checked above");
285    let rest: Vec<Value> = iter.collect();
286
287    match first {
288        Value::MException(mex) => {
289            if !rest.is_empty() {
290                return Err(error_error(&ERROR_ERROR_EXTRA_ARGS_MEXCEPTION));
291            }
292            Err(error_flow(&mex.identifier, &mex.message))
293        }
294        Value::Struct(ref st) => {
295            if !rest.is_empty() {
296                return Err(error_error(&ERROR_ERROR_EXTRA_ARGS_STRUCT));
297            }
298            let (identifier, message) = extract_struct_error_fields(st)?;
299            Err(error_flow(&identifier, &message))
300        }
301        other => handle_message_arguments(other, rest),
302    }
303}
304
305fn handle_message_arguments(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
306    let first_string = value_to_string("error", &first)?;
307
308    if rest.is_empty() {
309        return Err(error_flow(error_default_identifier(), first_string));
310    }
311
312    let mut identifier = error_default_identifier().to_string();
313    let mut format_string = first_string;
314    let mut format_args: &[Value] = &rest;
315
316    if !rest.is_empty()
317        && (is_message_identifier(&format_string)
318            || looks_like_unqualified_identifier(&format_string))
319    {
320        identifier = normalize_identifier(&format_string);
321        let (message_value, extra_args) = rest.split_first().expect("rest not empty");
322        format_string = value_to_string("error", message_value)?;
323        format_args = extra_args;
324    }
325
326    let message = if format_args.is_empty() {
327        format_string
328    } else {
329        format_variadic(&format_string, format_args)
330            .map_err(|flow| remap_error_flow(flow, &ERROR_ERROR_INVALID_INPUT))?
331    };
332
333    Err(error_flow(&identifier, message))
334}
335
336fn extract_struct_error_fields(
337    struct_value: &StructValue,
338) -> crate::BuiltinResult<(String, String)> {
339    let identifier_value = struct_value
340        .fields
341        .get("identifier")
342        .or_else(|| struct_value.fields.get("messageid"))
343        .ok_or_else(|| error_error(&ERROR_ERROR_STRUCT_MISSING_IDENTIFIER))?;
344    let message_value = struct_value
345        .fields
346        .get("message")
347        .or_else(|| struct_value.fields.get("msg"))
348        .ok_or_else(|| error_error(&ERROR_ERROR_STRUCT_MISSING_MESSAGE))?;
349
350    let identifier = value_to_string("error", identifier_value)?;
351    let message = value_to_string("error", message_value)?;
352    Ok((identifier, message))
353}
354
355fn value_to_string(context: &str, value: &Value) -> crate::BuiltinResult<String> {
356    String::try_from(value).map_err(|e| {
357        error_error_with_message(format!("{context}: {e}"), &ERROR_ERROR_INVALID_INPUT)
358    })
359}
360
361fn normalize_identifier(raw: &str) -> String {
362    let trimmed = raw.trim();
363    if trimmed.is_empty() {
364        error_default_identifier().to_string()
365    } else if trimmed.contains(':') {
366        trimmed.to_string()
367    } else {
368        format!("RunMat:{trimmed}")
369    }
370}
371
372fn is_message_identifier(text: &str) -> bool {
373    let trimmed = text.trim();
374    if trimmed.is_empty() || !trimmed.contains(':') {
375        return false;
376    }
377    trimmed
378        .chars()
379        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '.'))
380}
381
382fn looks_like_unqualified_identifier(text: &str) -> bool {
383    let trimmed = text.trim();
384    if trimmed.is_empty() || trimmed.contains(char::is_whitespace) {
385        return false;
386    }
387    trimmed
388        .chars()
389        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.'))
390}
391
392#[cfg(test)]
393pub(crate) mod tests {
394    use super::*;
395    use runmat_builtins::{IntValue, MException, ResolveContext, Type};
396
397    fn unwrap_error(err: crate::RuntimeError) -> crate::RuntimeError {
398        err
399    }
400
401    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
402    #[test]
403    fn error_requires_message() {
404        let err = unwrap_error(error_builtin(Vec::new()).expect_err("should error"));
405        assert_eq!(err.identifier(), Some(error_default_identifier()));
406        assert!(err.message().contains("missing message"));
407    }
408
409    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
410    #[test]
411    fn default_identifier_is_applied() {
412        let err =
413            unwrap_error(error_builtin(vec![Value::from("Failure!")]).expect_err("should error"));
414        assert_eq!(err.identifier(), Some(error_default_identifier()));
415        assert_eq!(err.message(), "Failure!");
416    }
417
418    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
419    #[test]
420    fn custom_identifier_is_preserved() {
421        let err = unwrap_error(
422            error_builtin(vec![
423                Value::from("runmat:tests:badValue"),
424                Value::from("Value %d is not allowed."),
425                Value::from(5.0),
426            ])
427            .expect_err("should error"),
428        );
429        assert_eq!(err.identifier(), Some("runmat:tests:badValue"));
430        assert_eq!(err.message(), "Value 5 is not allowed.");
431    }
432
433    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
434    #[test]
435    fn identifier_is_normalised_when_namespace_missing() {
436        let err = unwrap_error(
437            error_builtin(vec![
438                Value::from("missingNamespace"),
439                Value::from("Message"),
440            ])
441            .expect_err("should error"),
442        );
443        assert_eq!(err.identifier(), Some("RunMat:missingNamespace"));
444        assert_eq!(err.message(), "Message");
445    }
446
447    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
448    #[test]
449    fn format_string_with_colon_not_treated_as_identifier() {
450        let err = unwrap_error(
451            error_builtin(vec![
452                Value::from("Value: %d."),
453                Value::Int(IntValue::I32(7)),
454            ])
455            .expect_err("should error"),
456        );
457        assert_eq!(err.identifier(), Some(error_default_identifier()));
458        assert_eq!(err.message(), "Value: 7.");
459    }
460
461    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
462    #[test]
463    fn error_accepts_mexception() {
464        let mex = MException::new("RunMat:demo:test".to_string(), "broken".to_string());
465        let err =
466            unwrap_error(error_builtin(vec![Value::MException(mex)]).expect_err("should error"));
467        assert_eq!(err.identifier(), Some("RunMat:demo:test"));
468        assert_eq!(err.message(), "broken");
469    }
470
471    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
472    #[test]
473    fn error_rejects_extra_args_after_mexception() {
474        let mex = MException::new("RunMat:demo:test".to_string(), "broken".to_string());
475        let err = unwrap_error(
476            error_builtin(vec![Value::MException(mex), Value::from(1.0)])
477                .expect_err("should error"),
478        );
479        assert_eq!(err.identifier(), Some(error_default_identifier()));
480        assert!(err.message().contains("additional arguments"));
481    }
482
483    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
484    #[test]
485    fn error_accepts_message_struct() {
486        let mut st = StructValue::new();
487        st.fields
488            .insert("identifier".to_string(), Value::from("pkg:demo:failure"));
489        st.fields
490            .insert("message".to_string(), Value::from("Struct message."));
491        let err = unwrap_error(error_builtin(vec![Value::Struct(st)]).expect_err("should error"));
492        assert_eq!(err.identifier(), Some("pkg:demo:failure"));
493        assert_eq!(err.message(), "Struct message.");
494    }
495
496    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
497    #[test]
498    fn error_struct_requires_message_field() {
499        let mut st = StructValue::new();
500        st.fields
501            .insert("identifier".to_string(), Value::from("pkg:demo:oops"));
502        let err = unwrap_error(error_builtin(vec![Value::Struct(st)]).expect_err("should error"));
503        assert_eq!(err.identifier(), Some(error_default_identifier()));
504        assert!(err
505            .message()
506            .contains("message struct must contain a 'message' field"));
507    }
508
509    #[test]
510    fn error_type_is_unknown() {
511        assert_eq!(
512            error_type(&[Type::String], &ResolveContext::new(Vec::new())),
513            Type::Unknown
514        );
515    }
516}