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::{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};
13use crate::builtins::diagnostics::type_resolvers::error_type;
14use crate::{build_runtime_error, RuntimeError};
15
16const DEFAULT_IDENTIFIER: &str = "RunMat:error";
17
18#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::diagnostics::error")]
19pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
20    name: "error",
21    op_kind: GpuOpKind::Custom("control"),
22    supported_precisions: &[],
23    broadcast: BroadcastSemantics::None,
24    provider_hooks: &[],
25    constant_strategy: ConstantStrategy::InlineLiteral,
26    residency: ResidencyPolicy::GatherImmediately,
27    nan_mode: ReductionNaN::Include,
28    two_pass_threshold: None,
29    workgroup_size: None,
30    accepts_nan_mode: false,
31    notes: "Control-flow builtin; never dispatched to GPU backends.",
32};
33
34#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::diagnostics::error")]
35pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
36    name: "error",
37    shape: ShapeRequirements::Any,
38    constant_strategy: ConstantStrategy::InlineLiteral,
39    elementwise: None,
40    reduction: None,
41    emits_nan: false,
42    notes: "Control-flow builtin; excluded from fusion planning.",
43};
44
45fn error_flow(identifier: &str, message: impl Into<String>) -> RuntimeError {
46    build_runtime_error(message)
47        .with_builtin("error")
48        .with_identifier(normalize_identifier(identifier))
49        .build()
50}
51
52fn remap_error_flow(err: RuntimeError, identifier: &str) -> RuntimeError {
53    build_runtime_error(err.message().to_string())
54        .with_builtin("error")
55        .with_identifier(normalize_identifier(identifier))
56        .with_source(err)
57        .build()
58}
59
60#[runtime_builtin(
61    name = "error",
62    category = "diagnostics",
63    summary = "Throw an exception with an identifier and a formatted diagnostic message.",
64    keywords = "error,exception,diagnostics,throw",
65    accel = "metadata",
66    type_resolver(error_type),
67    builtin_path = "crate::builtins::diagnostics::error"
68)]
69fn error_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
70    if args.is_empty() {
71        return Err(error_flow(
72            DEFAULT_IDENTIFIER,
73            "error: missing message argument",
74        ));
75    }
76
77    let mut iter = args.into_iter();
78    let first = iter.next().expect("checked above");
79    let rest: Vec<Value> = iter.collect();
80
81    match first {
82        Value::MException(mex) => {
83            if !rest.is_empty() {
84                return Err(error_flow(
85                    DEFAULT_IDENTIFIER,
86                    "error: additional arguments are not allowed when passing an MException",
87                ));
88            }
89            Err(error_flow(&mex.identifier, &mex.message))
90        }
91        Value::Struct(ref st) => {
92            if !rest.is_empty() {
93                return Err(error_flow(
94                    DEFAULT_IDENTIFIER,
95                    "error: additional arguments are not allowed when passing a message struct",
96                ));
97            }
98            let (identifier, message) = extract_struct_error_fields(st)?;
99            Err(error_flow(&identifier, &message))
100        }
101        other => handle_message_arguments(other, rest),
102    }
103}
104
105fn handle_message_arguments(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
106    let first_string = value_to_string("error", &first)?;
107
108    if rest.is_empty() {
109        return Err(error_flow(DEFAULT_IDENTIFIER, first_string));
110    }
111
112    let mut identifier = DEFAULT_IDENTIFIER.to_string();
113    let mut format_string = first_string;
114    let mut format_args: &[Value] = &rest;
115
116    if !rest.is_empty()
117        && (is_message_identifier(&format_string)
118            || looks_like_unqualified_identifier(&format_string))
119    {
120        identifier = normalize_identifier(&format_string);
121        let (message_value, extra_args) = rest.split_first().expect("rest not empty");
122        format_string = value_to_string("error", message_value)?;
123        format_args = extra_args;
124    }
125
126    let message = if format_args.is_empty() {
127        format_string
128    } else {
129        format_variadic(&format_string, format_args)
130            .map_err(|flow| remap_error_flow(flow, DEFAULT_IDENTIFIER))?
131    };
132
133    Err(error_flow(&identifier, message))
134}
135
136fn extract_struct_error_fields(
137    struct_value: &StructValue,
138) -> crate::BuiltinResult<(String, String)> {
139    let identifier_value = struct_value
140        .fields
141        .get("identifier")
142        .or_else(|| struct_value.fields.get("messageid"))
143        .ok_or_else(|| {
144            error_flow(
145                DEFAULT_IDENTIFIER,
146                "error: message struct must contain an 'identifier' field",
147            )
148        })?;
149    let message_value = struct_value
150        .fields
151        .get("message")
152        .or_else(|| struct_value.fields.get("msg"))
153        .ok_or_else(|| {
154            error_flow(
155                DEFAULT_IDENTIFIER,
156                "error: message struct must contain a 'message' field",
157            )
158        })?;
159
160    let identifier = value_to_string("error", identifier_value)?;
161    let message = value_to_string("error", message_value)?;
162    Ok((identifier, message))
163}
164
165fn value_to_string(context: &str, value: &Value) -> crate::BuiltinResult<String> {
166    String::try_from(value).map_err(|e| error_flow(DEFAULT_IDENTIFIER, format!("{context}: {e}")))
167}
168
169fn normalize_identifier(raw: &str) -> String {
170    let trimmed = raw.trim();
171    if trimmed.is_empty() {
172        DEFAULT_IDENTIFIER.to_string()
173    } else if trimmed.contains(':') {
174        trimmed.to_string()
175    } else {
176        format!("RunMat:{trimmed}")
177    }
178}
179
180fn is_message_identifier(text: &str) -> bool {
181    let trimmed = text.trim();
182    if trimmed.is_empty() || !trimmed.contains(':') {
183        return false;
184    }
185    trimmed
186        .chars()
187        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '.'))
188}
189
190fn looks_like_unqualified_identifier(text: &str) -> bool {
191    let trimmed = text.trim();
192    if trimmed.is_empty() || trimmed.contains(char::is_whitespace) {
193        return false;
194    }
195    trimmed
196        .chars()
197        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.'))
198}
199
200#[cfg(test)]
201pub(crate) mod tests {
202    use super::*;
203    use runmat_builtins::{IntValue, MException, ResolveContext, Type};
204
205    fn unwrap_error(err: crate::RuntimeError) -> crate::RuntimeError {
206        err
207    }
208
209    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
210    #[test]
211    fn error_requires_message() {
212        let err = unwrap_error(error_builtin(Vec::new()).expect_err("should error"));
213        assert_eq!(err.identifier(), Some(DEFAULT_IDENTIFIER));
214        assert!(err.message().contains("missing message"));
215    }
216
217    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
218    #[test]
219    fn default_identifier_is_applied() {
220        let err =
221            unwrap_error(error_builtin(vec![Value::from("Failure!")]).expect_err("should error"));
222        assert_eq!(err.identifier(), Some(DEFAULT_IDENTIFIER));
223        assert_eq!(err.message(), "Failure!");
224    }
225
226    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
227    #[test]
228    fn custom_identifier_is_preserved() {
229        let err = unwrap_error(
230            error_builtin(vec![
231                Value::from("runmat:tests:badValue"),
232                Value::from("Value %d is not allowed."),
233                Value::from(5.0),
234            ])
235            .expect_err("should error"),
236        );
237        assert_eq!(err.identifier(), Some("runmat:tests:badValue"));
238        assert_eq!(err.message(), "Value 5 is not allowed.");
239    }
240
241    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
242    #[test]
243    fn identifier_is_normalised_when_namespace_missing() {
244        let err = unwrap_error(
245            error_builtin(vec![
246                Value::from("missingNamespace"),
247                Value::from("Message"),
248            ])
249            .expect_err("should error"),
250        );
251        assert_eq!(err.identifier(), Some("RunMat:missingNamespace"));
252        assert_eq!(err.message(), "Message");
253    }
254
255    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
256    #[test]
257    fn format_string_with_colon_not_treated_as_identifier() {
258        let err = unwrap_error(
259            error_builtin(vec![
260                Value::from("Value: %d."),
261                Value::Int(IntValue::I32(7)),
262            ])
263            .expect_err("should error"),
264        );
265        assert_eq!(err.identifier(), Some(DEFAULT_IDENTIFIER));
266        assert_eq!(err.message(), "Value: 7.");
267    }
268
269    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
270    #[test]
271    fn error_accepts_mexception() {
272        let mex = MException::new("RunMat:demo:test".to_string(), "broken".to_string());
273        let err =
274            unwrap_error(error_builtin(vec![Value::MException(mex)]).expect_err("should error"));
275        assert_eq!(err.identifier(), Some("RunMat:demo:test"));
276        assert_eq!(err.message(), "broken");
277    }
278
279    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
280    #[test]
281    fn error_rejects_extra_args_after_mexception() {
282        let mex = MException::new("RunMat:demo:test".to_string(), "broken".to_string());
283        let err = unwrap_error(
284            error_builtin(vec![Value::MException(mex), Value::from(1.0)])
285                .expect_err("should error"),
286        );
287        assert_eq!(err.identifier(), Some(DEFAULT_IDENTIFIER));
288        assert!(err.message().contains("additional arguments"));
289    }
290
291    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
292    #[test]
293    fn error_accepts_message_struct() {
294        let mut st = StructValue::new();
295        st.fields
296            .insert("identifier".to_string(), Value::from("pkg:demo:failure"));
297        st.fields
298            .insert("message".to_string(), Value::from("Struct message."));
299        let err = unwrap_error(error_builtin(vec![Value::Struct(st)]).expect_err("should error"));
300        assert_eq!(err.identifier(), Some("pkg:demo:failure"));
301        assert_eq!(err.message(), "Struct message.");
302    }
303
304    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
305    #[test]
306    fn error_struct_requires_message_field() {
307        let mut st = StructValue::new();
308        st.fields
309            .insert("identifier".to_string(), Value::from("pkg:demo:oops"));
310        let err = unwrap_error(error_builtin(vec![Value::Struct(st)]).expect_err("should error"));
311        assert_eq!(err.identifier(), Some(DEFAULT_IDENTIFIER));
312        assert!(err
313            .message()
314            .contains("message struct must contain a 'message' field"));
315    }
316
317    #[test]
318    fn error_type_is_unknown() {
319        assert_eq!(
320            error_type(&[Type::String], &ResolveContext::new(Vec::new())),
321            Type::Unknown
322        );
323    }
324}