Skip to main content

gpui_rhai/
diagnostic.rs

1use std::collections::BTreeMap;
2
3use rhai::EvalAltResult;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    ComponentInstancePath, ExecutionOperation, ExecutionTiming, OPERATION_SEMANTICS_VERSION,
8    RuntimeError, StateInstanceSnapshot, UiContext, UiValue,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DiagnosticSeverity {
14    Error,
15    Warning,
16    Info,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum DiagnosticCode {
22    ScriptCompile,
23    ScriptEvaluate,
24    StaleCallback,
25    SlowExecution,
26    PrimitiveRender,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
30pub struct DiagnosticFrame {
31    pub kind: String,
32    pub name: String,
33    pub source: Option<String>,
34}
35
36#[derive(Clone, Debug, Default)]
37pub struct DiagnosticContext {
38    pub source: Option<String>,
39    pub component: Option<ComponentInstancePath>,
40    pub key: Option<String>,
41    pub execution_timing: Option<ExecutionTiming>,
42    pub component_state: Vec<DiagnosticStateSnapshot>,
43}
44
45impl DiagnosticContext {
46    /// Capture the runtime details associated with a failed script evaluation.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error when component state is currently mutably borrowed.
51    pub fn capture(
52        engine: &crate::RuntimeEngine,
53        context: &UiContext,
54        source: Option<String>,
55        key: Option<String>,
56    ) -> Result<Self, DiagnosticContextError> {
57        let component = context.component_path().clone();
58        let component_state = context
59            .runtime()
60            .try_borrow()
61            .map_err(|_| DiagnosticContextError::StateBorrowed)?
62            .component_state
63            .inspect_instance(&component);
64        Ok(Self {
65            source,
66            component: Some(component.clone()),
67            key,
68            execution_timing: engine.last_failed_timing(),
69            component_state: component_state
70                .map(redact_component_state)
71                .into_iter()
72                .collect(),
73        })
74    }
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
78pub enum DiagnosticContextError {
79    #[error("component state is already borrowed")]
80    StateBorrowed,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
84pub struct DiagnosticExecutionTiming {
85    pub operation: ExecutionOperation,
86    pub source: String,
87    pub duration_micros: u64,
88    pub succeeded: bool,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum DiagnosticErrorKind {
94    Terminated,
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
98pub struct DiagnosticOperationBudget {
99    pub consumed: u64,
100    pub maximum: u64,
101    pub semantics_version: u32,
102}
103
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub struct DiagnosticStateSnapshot {
106    pub path: String,
107    pub fields: BTreeMap<String, UiValue>,
108}
109
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct Diagnostic {
112    pub severity: DiagnosticSeverity,
113    pub code: DiagnosticCode,
114    pub message: String,
115    pub source: Option<String>,
116    pub line: Option<usize>,
117    pub column: Option<usize>,
118    pub component: Option<String>,
119    pub key: Option<String>,
120    pub stack: Vec<DiagnosticFrame>,
121    pub error_kind: Option<DiagnosticErrorKind>,
122    pub token: Option<UiValue>,
123    pub execution: Option<DiagnosticExecutionTiming>,
124    pub operation_budget: Option<DiagnosticOperationBudget>,
125    pub component_state: Vec<DiagnosticStateSnapshot>,
126}
127
128impl Diagnostic {
129    #[must_use]
130    #[allow(clippy::too_many_lines)]
131    pub fn from_runtime(error: &RuntimeError, context: &DiagnosticContext) -> Self {
132        match error {
133            RuntimeError::Compile(error) => {
134                from_eval(DiagnosticCode::ScriptCompile, error, context)
135            }
136            RuntimeError::Evaluate(error)
137            | RuntimeError::CallbackDefinition { source: error, .. } => {
138                from_eval(DiagnosticCode::ScriptEvaluate, error, context)
139            }
140            RuntimeError::StaleCallback {
141                name,
142                callback_generation,
143                current_generation,
144            } => Self {
145                severity: DiagnosticSeverity::Error,
146                code: DiagnosticCode::StaleCallback,
147                message: format!(
148                    "callback `{name}` belongs to generation {callback_generation}; current generation is {current_generation}"
149                ),
150                source: context.source.clone(),
151                line: None,
152                column: None,
153                component: context.component.as_ref().map(ToString::to_string),
154                key: context.key.clone(),
155                stack: Vec::new(),
156                ..runtime_context(context)
157            },
158            RuntimeError::StaleComponentCallback { name, component } => Self {
159                severity: DiagnosticSeverity::Error,
160                code: DiagnosticCode::StaleCallback,
161                message: format!(
162                    "callback `{name}` belongs to an unmounted incarnation of `{component}`"
163                ),
164                source: context.source.clone(),
165                line: None,
166                column: None,
167                component: Some(component.to_string()),
168                key: context.key.clone(),
169                stack: Vec::new(),
170                ..runtime_context(context)
171            },
172            RuntimeError::RetainedCallback { source, .. } => Self {
173                severity: DiagnosticSeverity::Error,
174                code: DiagnosticCode::ScriptEvaluate,
175                message: source.to_string(),
176                source: context.source.clone(),
177                line: None,
178                column: None,
179                component: context.component.as_ref().map(ToString::to_string),
180                key: context.key.clone(),
181                stack: Vec::new(),
182                ..runtime_context(context)
183            },
184            RuntimeError::ComponentRuntime(message) => Self {
185                severity: DiagnosticSeverity::Error,
186                code: DiagnosticCode::ScriptEvaluate,
187                message: message.clone(),
188                source: context.source.clone(),
189                line: None,
190                column: None,
191                component: context.component.as_ref().map(ToString::to_string),
192                key: context.key.clone(),
193                stack: Vec::new(),
194                ..runtime_context(context)
195            },
196            RuntimeError::MissingComponentInvocation(component) => Self {
197                severity: DiagnosticSeverity::Error,
198                code: DiagnosticCode::ScriptEvaluate,
199                message: format!("component invocation `{component}` is unavailable"),
200                source: context.source.clone(),
201                line: None,
202                column: None,
203                component: Some(component.to_string()),
204                key: context.key.clone(),
205                stack: Vec::new(),
206                ..runtime_context(context)
207            },
208            RuntimeError::Import(message) => Self {
209                severity: DiagnosticSeverity::Error,
210                code: DiagnosticCode::ScriptCompile,
211                message: message.clone(),
212                source: context.source.clone(),
213                line: None,
214                column: None,
215                component: context.component.as_ref().map(ToString::to_string),
216                key: context.key.clone(),
217                stack: Vec::new(),
218                ..runtime_context(context)
219            },
220            RuntimeError::InvalidAssignmentTarget(position) => Self {
221                severity: DiagnosticSeverity::Error,
222                code: DiagnosticCode::ScriptCompile,
223                message: error.to_string(),
224                source: context.source.clone(),
225                line: position.line(),
226                column: position.position(),
227                component: context.component.as_ref().map(ToString::to_string),
228                key: context.key.clone(),
229                stack: Vec::new(),
230                ..runtime_context(context)
231            },
232        }
233    }
234}
235
236fn from_eval(
237    code: DiagnosticCode,
238    error: &EvalAltResult,
239    context: &DiagnosticContext,
240) -> Diagnostic {
241    let leaf = leaf_error(error);
242    let position = leaf.position();
243    let mut stack = Vec::new();
244    collect_frames(error, &mut stack);
245    let (error_kind, token) = eval_details(leaf);
246    Diagnostic {
247        severity: DiagnosticSeverity::Error,
248        code,
249        message: error.to_string(),
250        source: source_for(error).or_else(|| context.source.clone()),
251        line: position.line(),
252        column: position.position(),
253        component: context.component.as_ref().map(ToString::to_string),
254        key: context.key.clone(),
255        stack,
256        error_kind,
257        token,
258        ..runtime_context(context)
259    }
260}
261
262fn eval_details(error: &EvalAltResult) -> (Option<DiagnosticErrorKind>, Option<UiValue>) {
263    match error {
264        EvalAltResult::ErrorTerminated(token, _) => (
265            Some(DiagnosticErrorKind::Terminated),
266            Some(UiValue::from_dynamic(token.clone()).unwrap_or_else(|_| {
267                UiValue::String(format!("<unsupported:{}>", token.type_name()))
268            })),
269        ),
270        _ => (None, None),
271    }
272}
273
274fn runtime_context(context: &DiagnosticContext) -> Diagnostic {
275    let execution = context
276        .execution_timing
277        .as_ref()
278        .map(DiagnosticExecutionTiming::from);
279    let operation_budget =
280        context
281            .execution_timing
282            .as_ref()
283            .map(|timing| DiagnosticOperationBudget {
284                consumed: timing.round_operations,
285                maximum: timing.operation_limit,
286                semantics_version: OPERATION_SEMANTICS_VERSION,
287            });
288    Diagnostic {
289        severity: DiagnosticSeverity::Error,
290        code: DiagnosticCode::ScriptEvaluate,
291        message: String::new(),
292        source: None,
293        line: None,
294        column: None,
295        component: None,
296        key: None,
297        stack: Vec::new(),
298        error_kind: None,
299        token: None,
300        execution,
301        operation_budget,
302        component_state: context.component_state.clone(),
303    }
304}
305
306impl From<&ExecutionTiming> for DiagnosticExecutionTiming {
307    fn from(timing: &ExecutionTiming) -> Self {
308        Self {
309            operation: timing.operation.clone(),
310            source: timing.source.clone(),
311            duration_micros: timing.duration.as_micros().try_into().unwrap_or(u64::MAX),
312            succeeded: timing.succeeded,
313        }
314    }
315}
316
317fn redact_component_state(snapshot: StateInstanceSnapshot) -> DiagnosticStateSnapshot {
318    DiagnosticStateSnapshot {
319        path: snapshot.path.to_string(),
320        fields: snapshot
321            .fields
322            .into_iter()
323            .map(|(name, field)| {
324                let value = if field.sensitive {
325                    UiValue::String("<redacted>".to_owned())
326                } else {
327                    field.value
328                };
329                (name, value)
330            })
331            .collect(),
332    }
333}
334
335fn leaf_error(mut error: &EvalAltResult) -> &EvalAltResult {
336    loop {
337        error = match error {
338            EvalAltResult::ErrorInFunctionCall(_, _, inner, _)
339            | EvalAltResult::ErrorInModule(_, inner, _) => inner,
340            _ => return error,
341        };
342    }
343}
344
345fn source_for(error: &EvalAltResult) -> Option<String> {
346    match error {
347        EvalAltResult::ErrorInFunctionCall(_, source, inner, _) => {
348            source_for(inner).or_else(|| (!source.is_empty()).then(|| source.clone()))
349        }
350        EvalAltResult::ErrorInModule(module, inner, _) => {
351            source_for(inner).or_else(|| (!module.is_empty()).then(|| module.clone()))
352        }
353        _ => None,
354    }
355}
356
357fn collect_frames(error: &EvalAltResult, frames: &mut Vec<DiagnosticFrame>) {
358    match error {
359        EvalAltResult::ErrorInFunctionCall(function, source, inner, _) => {
360            frames.push(DiagnosticFrame {
361                kind: "function".to_owned(),
362                name: function.clone(),
363                source: (!source.is_empty()).then(|| source.clone()),
364            });
365            collect_frames(inner, frames);
366        }
367        EvalAltResult::ErrorInModule(module, inner, _) => {
368            frames.push(DiagnosticFrame {
369                kind: "module".to_owned(),
370                name: module.clone(),
371                source: Some(module.clone()),
372            });
373            collect_frames(inner, frames);
374        }
375        _ => {}
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::collections::BTreeMap;
383    use std::time::Duration;
384
385    use rhai::{Dynamic, Position};
386
387    use crate::{
388        DEFAULT_SCRIPT_OPERATION_LIMIT, ExecutionOperation, ExecutionTiming, RuntimeEngine,
389        StateInstanceSnapshot, StateValueSnapshot, UiValue,
390    };
391
392    #[test]
393    fn runtime_diagnostic_contains_location_component_and_stack() {
394        let mut runtime = RuntimeEngine::new();
395        let compiled = runtime
396            .compile_named(
397                "ui/main.rhai",
398                r#"
399                    fn fail() { throw "boom"; }
400                    fn view() { fail(); }
401                "#,
402            )
403            .unwrap();
404        let error = runtime.render(&compiled).unwrap_err();
405        let diagnostic = Diagnostic::from_runtime(
406            &error,
407            &DiagnosticContext {
408                source: Some("ui/main.rhai".to_owned()),
409                component: Some(ComponentInstancePath::root("App", "root")),
410                key: Some("root".to_owned()),
411                ..DiagnosticContext::default()
412            },
413        );
414        assert_eq!(diagnostic.code, DiagnosticCode::ScriptEvaluate);
415        assert_eq!(diagnostic.source.as_deref(), Some("ui/main.rhai"));
416        assert!(diagnostic.line.is_some());
417        assert_eq!(diagnostic.component.as_deref(), Some("/App[root]"));
418        assert!(diagnostic.stack.iter().any(|frame| frame.name == "fail"));
419    }
420
421    #[test]
422    fn terminated_evaluation_preserves_structured_runtime_context() {
423        let component = ComponentInstancePath::root("App", "root").child("LoginForm", "primary");
424        let error = RuntimeError::Evaluate(Box::new(EvalAltResult::ErrorInFunctionCall(
425            "submit".to_owned(),
426            "ui/login.rhai".to_owned(),
427            Box::new(EvalAltResult::ErrorTerminated(
428                Dynamic::from("operation-limit"),
429                Position::new(17, 9),
430            )),
431            Position::new(20, 5),
432        )));
433        let timing = ExecutionTiming {
434            operation: ExecutionOperation::Callback("submit".to_owned()),
435            source: "ui/login.rhai".to_owned(),
436            duration: Duration::from_micros(2_500),
437            operations: 1_000_001,
438            operation_limit: DEFAULT_SCRIPT_OPERATION_LIMIT,
439            round_operations: 1_000_001,
440            operation_semantics: crate::OPERATION_SEMANTICS_VERSION,
441            slow: false,
442            succeeded: false,
443        };
444        let state = StateInstanceSnapshot {
445            path: component.clone(),
446            fields: BTreeMap::from([
447                (
448                    "attempts".to_owned(),
449                    StateValueSnapshot {
450                        value: UiValue::Integer(3),
451                        sensitive: false,
452                    },
453                ),
454                (
455                    "password".to_owned(),
456                    StateValueSnapshot {
457                        value: UiValue::String("hunter2".to_owned()),
458                        sensitive: true,
459                    },
460                ),
461            ]),
462        };
463
464        let component_state = vec![redact_component_state(state)];
465        let diagnostic = Diagnostic::from_runtime(
466            &error,
467            &DiagnosticContext {
468                source: Some("ui/login.rhai".to_owned()),
469                component: Some(component),
470                key: Some("primary".to_owned()),
471                execution_timing: Some(timing),
472                component_state,
473            },
474        );
475
476        assert_eq!(diagnostic.error_kind, Some(DiagnosticErrorKind::Terminated));
477        assert_eq!(
478            diagnostic.token,
479            Some(UiValue::String("operation-limit".to_owned()))
480        );
481        assert_eq!(diagnostic.line, Some(17));
482        assert_eq!(diagnostic.column, Some(9));
483        assert_eq!(
484            diagnostic.operation_budget,
485            Some(DiagnosticOperationBudget {
486                consumed: 1_000_001,
487                maximum: DEFAULT_SCRIPT_OPERATION_LIMIT,
488                semantics_version: crate::OPERATION_SEMANTICS_VERSION,
489            })
490        );
491        assert_eq!(
492            diagnostic.component.as_deref(),
493            Some("/App[root]/LoginForm[primary]")
494        );
495        let execution = diagnostic.execution.as_ref().unwrap();
496        assert_eq!(
497            execution.operation,
498            ExecutionOperation::Callback("submit".to_owned())
499        );
500        assert_eq!(execution.source, "ui/login.rhai");
501        assert_eq!(execution.duration_micros, 2_500);
502        assert!(!execution.succeeded);
503        assert_eq!(diagnostic.component_state.len(), 1);
504        assert_eq!(
505            diagnostic.component_state[0].fields["attempts"],
506            UiValue::Integer(3)
507        );
508        assert_eq!(
509            diagnostic.component_state[0].fields["password"],
510            UiValue::String("<redacted>".to_owned())
511        );
512        let serialized = serde_json::to_value(&diagnostic).unwrap();
513        assert_eq!(serialized["error_kind"], serde_json::json!("terminated"));
514        assert_eq!(
515            serialized["execution"]["operation"],
516            serde_json::json!({ "callback": "submit" })
517        );
518        assert_eq!(
519            serialized["operation_budget"]["semantics_version"],
520            serde_json::json!(crate::OPERATION_SEMANTICS_VERSION)
521        );
522        assert!(!serialized.to_string().contains("hunter2"));
523    }
524
525    #[test]
526    fn diagnostic_keeps_the_failed_round_quota_after_host_policy_changes() {
527        let mut engine = RuntimeEngine::new();
528        engine.set_operation_limit(100);
529        let compiled = engine
530            .compile("fn view() { let n=0; for i in 0..1000 { n+=1; } text(`${n}`) }")
531            .unwrap();
532        let error = engine.render(&compiled).unwrap_err();
533        let failed = engine.last_failed_timing().unwrap();
534        assert_eq!(failed.operation_limit, 100);
535        engine.set_operation_limit(20_000);
536        let diagnostic = Diagnostic::from_runtime(
537            &error,
538            &DiagnosticContext {
539                execution_timing: Some(failed),
540                ..DiagnosticContext::default()
541            },
542        );
543        let budget = diagnostic.operation_budget.unwrap();
544        assert_eq!(budget.maximum, 100);
545        assert!(budget.consumed > 100);
546    }
547}