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 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}