Skip to main content

kcl_error/
error.rs

1use schemars::JsonSchema;
2use serde::Deserialize;
3use serde::Serialize;
4use thiserror::Error;
5
6use crate::CompilationIssue;
7use crate::SourceRange;
8
9const RETRYABLE_ENGINE_MESSAGE_MARKER_SETS: &[&[&str]] = &[
10    &["modeling connection", "interrupted", "please reconnect"],
11    &["modeling connection", "heartbeats", "please reconnect"],
12];
13
14pub trait IsRetryable {
15    /// Returns true if the error is transient and the operation that caused it
16    /// should be retried.
17    fn is_retryable(&self) -> bool;
18}
19
20#[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq, JsonSchema)]
21#[ts(export)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum KclError {
24    #[error("lexical: {details:?}")]
25    Lexical { details: KclErrorDetails },
26    #[error("syntax: {details:?}")]
27    Syntax { details: KclErrorDetails },
28    #[error("semantic: {details:?}")]
29    Semantic { details: KclErrorDetails },
30    #[error("import cycle: {details:?}")]
31    ImportCycle { details: KclErrorDetails },
32    #[error("argument: {details:?}")]
33    Argument { details: KclErrorDetails },
34    #[error("type: {details:?}")]
35    Type { details: KclErrorDetails },
36    #[error("user-defined: {details:?}")]
37    UserDefined { details: KclErrorDetails },
38    #[error("i/o: {details:?}")]
39    Io { details: KclErrorDetails },
40    #[error("unexpected: {details:?}")]
41    Unexpected { details: KclErrorDetails },
42    #[error("value already defined: {details:?}")]
43    ValueAlreadyDefined { details: KclErrorDetails },
44    #[error("undefined value: {details:?}")]
45    UndefinedValue {
46        details: KclErrorDetails,
47        name: Option<String>,
48    },
49    #[error("invalid expression: {details:?}")]
50    InvalidExpression { details: KclErrorDetails },
51    #[error("max call stack size exceeded: {details:?}")]
52    MaxCallStack { details: KclErrorDetails },
53    #[error("refactor: {details:?}")]
54    Refactor { details: KclErrorDetails },
55    #[error("engine: {details:?}")]
56    Engine { details: KclErrorDetails },
57    #[error("engine hangup: {details:?}")]
58    EngineHangup {
59        details: KclErrorDetails,
60        api_call_id: Option<String>,
61    },
62    #[error("engine internal: {details:?}")]
63    EngineInternal { details: KclErrorDetails },
64    #[error("internal error, please report to KittyCAD team: {details:?}")]
65    Internal { details: KclErrorDetails },
66}
67
68impl IsRetryable for KclError {
69    fn is_retryable(&self) -> bool {
70        matches!(self, KclError::EngineHangup { .. } | KclError::EngineInternal { .. })
71    }
72}
73#[derive(
74    Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic, JsonSchema,
75)]
76#[serde(rename_all = "camelCase")]
77#[error("{message}")]
78#[ts(export)]
79pub struct KclErrorDetails {
80    #[label(collection, "Errors")]
81    pub source_ranges: Vec<SourceRange>,
82    pub backtrace: Vec<BacktraceItem>,
83    #[serde(rename = "msg")]
84    pub message: String,
85}
86
87impl KclErrorDetails {
88    pub fn new(message: String, source_ranges: Vec<SourceRange>) -> KclErrorDetails {
89        let backtrace = source_ranges
90            .iter()
91            .map(|s| BacktraceItem {
92                source_range: *s,
93                fn_name: None,
94                kind: BacktraceItemKind::Call,
95            })
96            .collect();
97        KclErrorDetails {
98            source_ranges,
99            backtrace,
100            message,
101        }
102    }
103}
104
105impl KclError {
106    pub fn internal(message: String) -> KclError {
107        KclError::Internal {
108            details: KclErrorDetails {
109                source_ranges: Default::default(),
110                backtrace: Default::default(),
111                message,
112            },
113        }
114    }
115
116    pub fn new_internal(details: KclErrorDetails) -> KclError {
117        KclError::Internal { details }
118    }
119
120    pub fn new_import_cycle(details: KclErrorDetails) -> KclError {
121        KclError::ImportCycle { details }
122    }
123
124    pub fn new_argument(details: KclErrorDetails) -> KclError {
125        KclError::Argument { details }
126    }
127
128    pub fn new_semantic(details: KclErrorDetails) -> KclError {
129        KclError::Semantic { details }
130    }
131
132    pub fn new_value_already_defined(details: KclErrorDetails) -> KclError {
133        KclError::ValueAlreadyDefined { details }
134    }
135
136    pub fn new_syntax(details: KclErrorDetails) -> KclError {
137        KclError::Syntax { details }
138    }
139
140    pub fn new_io(details: KclErrorDetails) -> KclError {
141        KclError::Io { details }
142    }
143
144    pub fn new_invalid_expression(details: KclErrorDetails) -> KclError {
145        KclError::InvalidExpression { details }
146    }
147
148    pub fn new_max_call_stack(details: KclErrorDetails) -> KclError {
149        KclError::MaxCallStack { details }
150    }
151
152    pub fn refactor(message: String) -> KclError {
153        KclError::Refactor {
154            details: KclErrorDetails {
155                source_ranges: Default::default(),
156                backtrace: Default::default(),
157                message,
158            },
159        }
160    }
161
162    pub fn new_engine(details: KclErrorDetails) -> KclError {
163        if details.message.eq_ignore_ascii_case("internal error") {
164            KclError::EngineInternal { details }
165        } else if is_retryable_engine_message(&details.message) {
166            KclError::EngineHangup {
167                details,
168                api_call_id: None,
169            }
170        } else {
171            KclError::Engine { details }
172        }
173    }
174
175    pub fn new_engine_hangup(details: KclErrorDetails, api_call_id: Option<String>) -> KclError {
176        KclError::EngineHangup { details, api_call_id }
177    }
178
179    pub fn new_lexical(details: KclErrorDetails) -> KclError {
180        KclError::Lexical { details }
181    }
182
183    pub fn new_undefined_value(details: KclErrorDetails, name: Option<String>) -> KclError {
184        KclError::UndefinedValue { details, name }
185    }
186
187    pub fn new_type(details: KclErrorDetails) -> KclError {
188        KclError::Type { details }
189    }
190
191    pub fn new_user_defined(details: KclErrorDetails) -> KclError {
192        KclError::UserDefined { details }
193    }
194
195    pub fn is_undefined_value(&self) -> bool {
196        matches!(self, KclError::UndefinedValue { .. })
197    }
198
199    /// Get the error message.
200    pub fn get_message(&self) -> String {
201        format!("{}: {}", self.error_type(), self.message())
202    }
203
204    pub fn error_type(&self) -> &'static str {
205        match self {
206            KclError::Lexical { .. } => "lexical",
207            KclError::Syntax { .. } => "syntax",
208            KclError::Semantic { .. } => "semantic",
209            KclError::ImportCycle { .. } => "import cycle",
210            KclError::Argument { .. } => "argument",
211            KclError::Type { .. } => "type",
212            KclError::UserDefined { .. } => "user-defined",
213            KclError::Io { .. } => "i/o",
214            KclError::Unexpected { .. } => "unexpected",
215            KclError::ValueAlreadyDefined { .. } => "value already defined",
216            KclError::UndefinedValue { .. } => "undefined value",
217            KclError::InvalidExpression { .. } => "invalid expression",
218            KclError::MaxCallStack { .. } => "max call stack",
219            KclError::Refactor { .. } => "refactor",
220            KclError::Engine { .. } => "engine",
221            KclError::EngineHangup { .. } => "engine hangup",
222            KclError::EngineInternal { .. } => "engine internal",
223            KclError::Internal { .. } => "internal",
224        }
225    }
226
227    /// The error details shared by every variant.
228    pub fn details(&self) -> &KclErrorDetails {
229        match self {
230            KclError::Lexical { details: e }
231            | KclError::Syntax { details: e }
232            | KclError::Semantic { details: e }
233            | KclError::ImportCycle { details: e }
234            | KclError::Argument { details: e }
235            | KclError::Type { details: e }
236            | KclError::UserDefined { details: e }
237            | KclError::Io { details: e }
238            | KclError::Unexpected { details: e }
239            | KclError::ValueAlreadyDefined { details: e }
240            | KclError::UndefinedValue { details: e, .. }
241            | KclError::InvalidExpression { details: e }
242            | KclError::MaxCallStack { details: e }
243            | KclError::Refactor { details: e }
244            | KclError::Engine { details: e }
245            | KclError::EngineHangup { details: e, .. }
246            | KclError::EngineInternal { details: e }
247            | KclError::Internal { details: e } => e,
248        }
249    }
250
251    /// Mutable access to the error details shared by every variant.
252    pub fn details_mut(&mut self) -> &mut KclErrorDetails {
253        match self {
254            KclError::Lexical { details: e }
255            | KclError::Syntax { details: e }
256            | KclError::Semantic { details: e }
257            | KclError::ImportCycle { details: e }
258            | KclError::Argument { details: e }
259            | KclError::Type { details: e }
260            | KclError::UserDefined { details: e }
261            | KclError::Io { details: e }
262            | KclError::Unexpected { details: e }
263            | KclError::ValueAlreadyDefined { details: e }
264            | KclError::UndefinedValue { details: e, .. }
265            | KclError::InvalidExpression { details: e }
266            | KclError::MaxCallStack { details: e }
267            | KclError::Refactor { details: e }
268            | KclError::Engine { details: e }
269            | KclError::EngineHangup { details: e, .. }
270            | KclError::EngineInternal { details: e }
271            | KclError::Internal { details: e } => e,
272        }
273    }
274
275    pub fn source_ranges(&self) -> Vec<SourceRange> {
276        self.details().source_ranges.clone()
277    }
278
279    /// Get the inner error message.
280    pub fn message(&self) -> &str {
281        &self.details().message
282    }
283
284    pub fn backtrace(&self) -> Vec<BacktraceItem> {
285        self.details().backtrace.clone()
286    }
287
288    pub fn override_source_ranges(&self, source_ranges: Vec<SourceRange>) -> Self {
289        let mut new = self.clone();
290        let e = new.details_mut();
291        e.backtrace = source_ranges
292            .iter()
293            .map(|s| BacktraceItem {
294                source_range: *s,
295                fn_name: None,
296                kind: BacktraceItemKind::Call,
297            })
298            .collect();
299        e.source_ranges = source_ranges;
300
301        new
302    }
303
304    pub fn add_unwind_location(&self, last_fn_name: Option<String>, source_range: SourceRange) -> Self {
305        let mut new = self.clone();
306        let e = new.details_mut();
307        if let Some(item) = e.backtrace.last_mut() {
308            item.fn_name = last_fn_name;
309        }
310        e.backtrace.push(BacktraceItem {
311            source_range,
312            fn_name: None,
313            kind: BacktraceItemKind::Call,
314        });
315        e.source_ranges.push(source_range);
316
317        new
318    }
319
320    /// Add the statement that imported the module containing this error.
321    ///
322    /// `import_path` is the path as written in the import statement.
323    ///
324    /// This mirrors how [`KclError::add_unwind_location`] records function
325    /// calls, keeping source ranges ordered innermost first: the current last
326    /// frame (the imported module's code) is labeled `import <path>`, and the
327    /// import statement's own location is appended as the new outermost frame.
328    pub fn add_import_location(&self, import_path: &str, source_range: SourceRange) -> Self {
329        let mut new = self.clone();
330        let e = new.details_mut();
331        if let Some(item) = e.backtrace.last_mut() {
332            item.fn_name = Some(format!("import {import_path}"));
333            item.kind = BacktraceItemKind::Import;
334        }
335        e.backtrace.push(BacktraceItem {
336            source_range,
337            fn_name: None,
338            kind: BacktraceItemKind::Call,
339        });
340        e.source_ranges.push(source_range);
341
342        new
343    }
344}
345
346#[derive(
347    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS, thiserror::Error, miette::Diagnostic, JsonSchema,
348)]
349#[serde(rename_all = "camelCase")]
350#[ts(export)]
351pub struct BacktraceItem {
352    pub source_range: SourceRange,
353    pub fn_name: Option<String>,
354    #[serde(default)]
355    pub kind: BacktraceItemKind,
356}
357
358/// What kind of execution step a backtrace frame records.
359#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS, JsonSchema)]
360#[serde(rename_all = "camelCase")]
361#[ts(export)]
362pub enum BacktraceItemKind {
363    /// A function call.
364    #[default]
365    Call,
366    /// An import of a module whose execution failed.
367    Import,
368}
369
370impl std::fmt::Display for BacktraceItem {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        if let Some(fn_name) = &self.fn_name {
373            write!(f, "{fn_name}: {:?}", self.source_range)
374        } else {
375            write!(f, "(fn): {:?}", self.source_range)
376        }
377    }
378}
379
380fn is_retryable_engine_message(message: &str) -> bool {
381    // TODO: Replace string matching with structured engine/API retry metadata once it is available.
382    let message = message.to_ascii_lowercase();
383    RETRYABLE_ENGINE_MESSAGE_MARKER_SETS
384        .iter()
385        .any(|markers| markers.iter().all(|marker| message.contains(marker)))
386}
387
388/// This is different than to_string() in that it will serialize the Error
389/// the struct as JSON so we can deserialize it on the js side.
390impl From<KclError> for String {
391    fn from(error: KclError) -> Self {
392        serde_json::to_string(&error).unwrap()
393    }
394}
395
396impl From<CompilationIssue> for KclErrorDetails {
397    fn from(err: CompilationIssue) -> Self {
398        let backtrace = vec![BacktraceItem {
399            source_range: err.source_range,
400            fn_name: None,
401            kind: BacktraceItemKind::Call,
402        }];
403        KclErrorDetails {
404            source_ranges: vec![err.source_range],
405            backtrace,
406            message: err.message,
407        }
408    }
409}
410
411#[cfg(feature = "pyo3")]
412impl From<pyo3::PyErr> for KclError {
413    fn from(error: pyo3::PyErr) -> Self {
414        KclError::new_internal(KclErrorDetails {
415            source_ranges: vec![],
416            backtrace: Default::default(),
417            message: error.to_string(),
418        })
419    }
420}
421
422#[cfg(feature = "pyo3")]
423impl From<KclError> for pyo3::PyErr {
424    fn from(error: KclError) -> Self {
425        pyo3::exceptions::PyException::new_err(error.to_string())
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::ModuleId;
433
434    #[test]
435    fn add_import_location_marks_the_imported_frame() {
436        let inner = SourceRange::new(0, 1, ModuleId::from_usize(1));
437        let import_site = SourceRange::new(5, 9, ModuleId::default());
438        let error = KclError::new_semantic(KclErrorDetails::new("boom".to_owned(), vec![inner]))
439            .add_import_location("part.kcl", import_site);
440
441        let backtrace = error.backtrace();
442        assert_eq!(backtrace.len(), 2);
443        assert_eq!(backtrace[0].fn_name.as_deref(), Some("import part.kcl"));
444        assert_eq!(backtrace[0].kind, BacktraceItemKind::Import);
445        assert_eq!(backtrace[0].source_range, inner);
446        assert_eq!(backtrace[1].fn_name, None);
447        assert_eq!(backtrace[1].kind, BacktraceItemKind::Call);
448        assert_eq!(backtrace[1].source_range, import_site);
449        assert_eq!(error.source_ranges(), vec![inner, import_site]);
450    }
451
452    #[test]
453    fn add_unwind_location_keeps_call_kind() {
454        let inner = SourceRange::new(0, 1, ModuleId::default());
455        let call_site = SourceRange::new(5, 9, ModuleId::default());
456        let error = KclError::new_semantic(KclErrorDetails::new("boom".to_owned(), vec![inner]))
457            .add_unwind_location(Some("f".to_owned()), call_site);
458
459        let backtrace = error.backtrace();
460        assert_eq!(backtrace.len(), 2);
461        assert_eq!(backtrace[0].fn_name.as_deref(), Some("f"));
462        assert_eq!(backtrace[0].kind, BacktraceItemKind::Call);
463        assert_eq!(backtrace[1].kind, BacktraceItemKind::Call);
464    }
465
466    #[test]
467    fn backtrace_item_kind_defaults_to_call_in_serde() {
468        // Payloads serialized before the kind field existed must still parse.
469        let item: BacktraceItem = serde_json::from_str(r#"{"sourceRange":[0,1,0],"fnName":null}"#).unwrap();
470        assert_eq!(item.kind, BacktraceItemKind::Call);
471    }
472}