Skip to main content

kcl_lib/
errors.rs

1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use kcl_api::NodePath;
5pub use kcl_error::BacktraceItem;
6pub use kcl_error::CompilationIssue;
7pub use kcl_error::IsRetryable;
8pub use kcl_error::KclError;
9pub use kcl_error::KclErrorDetails;
10pub use kcl_error::Severity;
11pub use kcl_error::Suggestion;
12pub use kcl_error::Tag;
13use serde::Serialize;
14use thiserror::Error;
15use tower_lsp::lsp_types::Diagnostic;
16use tower_lsp::lsp_types::DiagnosticSeverity;
17use uuid::Uuid;
18
19use crate::ExecOutcome;
20use crate::ModuleId;
21use crate::SourceRange;
22use crate::exec::KclValue;
23use crate::execution::ArtifactCommand;
24use crate::execution::ArtifactGraph;
25use crate::execution::DefaultPlanes;
26use crate::execution::KclValueView;
27use crate::execution::OperationsByModule;
28use crate::front::Number;
29use crate::front::Object;
30use crate::front::ObjectId;
31use crate::lsp::IntoDiagnostic;
32use crate::lsp::ToLspRange;
33use crate::modules::ModulePath;
34use crate::modules::ModuleSource;
35
36/// How did the KCL execution fail
37#[derive(thiserror::Error, Debug)]
38pub enum ExecError {
39    #[error("{0}")]
40    Kcl(#[from] Box<crate::KclErrorWithOutputs>),
41    #[error("Could not connect to engine: {0}")]
42    Connection(#[from] ConnectionError),
43    #[error("PNG snapshot could not be decoded: {0}")]
44    BadPng(String),
45    #[error("Bad export: {0}")]
46    BadExport(String),
47}
48
49impl From<KclErrorWithOutputs> for ExecError {
50    fn from(error: KclErrorWithOutputs) -> Self {
51        ExecError::Kcl(Box::new(error))
52    }
53}
54
55/// How did the KCL execution fail, with extra state.
56#[derive(Debug, thiserror::Error)]
57#[error("{error}")]
58pub struct ExecErrorWithState {
59    pub error: ExecError,
60    pub exec_state: Option<crate::execution::ExecState>,
61    #[cfg(feature = "snapshot-engine-responses")]
62    pub responses: Option<IndexMap<Uuid, kittycad_modeling_cmds::websocket::WebSocketResponse>>,
63}
64
65impl ExecErrorWithState {
66    #[cfg_attr(target_arch = "wasm32", expect(dead_code))]
67    pub fn new(
68        error: ExecError,
69        exec_state: crate::execution::ExecState,
70        #[cfg_attr(not(feature = "snapshot-engine-responses"), expect(unused_variables))] responses: Option<
71            IndexMap<Uuid, kittycad_modeling_cmds::websocket::WebSocketResponse>,
72        >,
73    ) -> Self {
74        Self {
75            error,
76            exec_state: Some(exec_state),
77            #[cfg(feature = "snapshot-engine-responses")]
78            responses,
79        }
80    }
81}
82
83impl IsRetryable for ExecErrorWithState {
84    fn is_retryable(&self) -> bool {
85        self.error.is_retryable()
86    }
87}
88
89impl ExecError {
90    pub fn as_kcl_error(&self) -> Option<&crate::KclError> {
91        let ExecError::Kcl(k) = &self else {
92            return None;
93        };
94        Some(&k.error)
95    }
96}
97
98impl IsRetryable for ExecError {
99    fn is_retryable(&self) -> bool {
100        matches!(self, ExecError::Kcl(kcl_error) if kcl_error.is_retryable())
101    }
102}
103
104impl From<ExecError> for ExecErrorWithState {
105    fn from(error: ExecError) -> Self {
106        Self {
107            error,
108            exec_state: None,
109            #[cfg(feature = "snapshot-engine-responses")]
110            responses: None,
111        }
112    }
113}
114
115impl From<ConnectionError> for ExecErrorWithState {
116    fn from(error: ConnectionError) -> Self {
117        Self {
118            error: error.into(),
119            exec_state: None,
120            #[cfg(feature = "snapshot-engine-responses")]
121            responses: None,
122        }
123    }
124}
125
126/// How did KCL client fail to connect to the engine
127#[derive(thiserror::Error, Debug)]
128pub enum ConnectionError {
129    #[error("Could not create a Zoo client: {0}")]
130    CouldNotMakeClient(anyhow::Error),
131    #[error("Could not establish connection to engine: {0}")]
132    Establishing(anyhow::Error),
133}
134
135impl From<KclErrorWithOutputs> for KclError {
136    fn from(error: KclErrorWithOutputs) -> Self {
137        error.error
138    }
139}
140
141#[derive(Error, Debug, Serialize, ts_rs::TS, Clone, PartialEq)]
142#[error("{error}")]
143#[ts(export)]
144#[serde(rename_all = "camelCase")]
145pub struct KclErrorWithOutputs {
146    pub error: KclError,
147    pub non_fatal: Vec<CompilationIssue>,
148    /// Variables in the top-level of the root module. Note that functions will
149    /// have an invalid env ref.
150    pub variables: IndexMap<String, KclValueView>,
151    pub operations: OperationsByModule,
152    // TODO: Remove this field.  Doing so breaks the ts-rs output for some
153    // reason.
154    pub _artifact_commands: Vec<ArtifactCommand>,
155    pub artifact_graph: ArtifactGraph,
156    #[serde(skip)]
157    pub scene_objects: Vec<Object>,
158    #[serde(skip)]
159    pub source_range_to_object: BTreeMap<SourceRange, ObjectId>,
160    #[serde(skip)]
161    pub var_solutions: Vec<(SourceRange, Option<NodePath>, Number)>,
162    pub scene_graph: Option<crate::front::SceneGraph>,
163    pub filenames: IndexMap<ModuleId, ModulePath>,
164    pub source_files: IndexMap<ModuleId, ModuleSource>,
165    pub default_planes: Option<DefaultPlanes>,
166}
167
168impl KclErrorWithOutputs {
169    #[allow(clippy::too_many_arguments)]
170    pub fn new(
171        error: KclError,
172        non_fatal: Vec<CompilationIssue>,
173        variables: IndexMap<String, KclValue>,
174        operations: OperationsByModule,
175        artifact_commands: Vec<ArtifactCommand>,
176        artifact_graph: ArtifactGraph,
177        scene_objects: Vec<Object>,
178        source_range_to_object: BTreeMap<SourceRange, ObjectId>,
179        var_solutions: Vec<(SourceRange, Option<NodePath>, Number)>,
180        filenames: IndexMap<ModuleId, ModulePath>,
181        source_files: IndexMap<ModuleId, ModuleSource>,
182        default_planes: Option<DefaultPlanes>,
183    ) -> Self {
184        let variables_view = variables.into_iter().map(|(k, v)| (k, v.into())).collect();
185        Self {
186            error,
187            non_fatal,
188            variables: variables_view,
189            operations,
190            _artifact_commands: artifact_commands,
191            artifact_graph,
192            scene_objects,
193            source_range_to_object,
194            var_solutions,
195            scene_graph: Default::default(),
196            filenames,
197            source_files,
198            default_planes,
199        }
200    }
201
202    pub fn no_outputs(error: KclError) -> Self {
203        Self {
204            error,
205            non_fatal: Default::default(),
206            variables: Default::default(),
207            operations: Default::default(),
208            _artifact_commands: Default::default(),
209            artifact_graph: Default::default(),
210            scene_objects: Default::default(),
211            source_range_to_object: Default::default(),
212            var_solutions: Default::default(),
213            scene_graph: Default::default(),
214            filenames: Default::default(),
215            source_files: Default::default(),
216            default_planes: Default::default(),
217        }
218    }
219
220    /// This is for when the error is generated after a successful execution.
221    pub fn from_error_outcome(error: KclError, outcome: ExecOutcome) -> Self {
222        KclErrorWithOutputs {
223            error,
224            non_fatal: outcome.issues,
225            variables: outcome.variables,
226            operations: outcome.operations,
227            _artifact_commands: Default::default(),
228            artifact_graph: outcome.artifact_graph,
229            scene_objects: outcome.scene_objects,
230            source_range_to_object: outcome.source_range_to_object,
231            var_solutions: outcome.var_solutions,
232            scene_graph: Default::default(),
233            filenames: outcome.filenames,
234            source_files: Default::default(),
235            default_planes: outcome.default_planes,
236        }
237    }
238
239    pub fn sketch_constraint_report(&self) -> crate::SketchConstraintReport {
240        crate::execution::sketch_constraint_report_from_scene_objects(&self.scene_objects)
241    }
242
243    pub fn into_miette_report_with_outputs(self, code: &str) -> anyhow::Result<ReportWithOutputs> {
244        let mut source_ranges = self.error.source_ranges();
245
246        // Pop off the first source range to get the filename.
247        let first_source_range = source_ranges
248            .pop()
249            .ok_or_else(|| anyhow::anyhow!("No source ranges found"))?;
250
251        let source = self
252            .source_files
253            .get(&first_source_range.module_id())
254            .cloned()
255            .unwrap_or(ModuleSource {
256                source: code.to_string(),
257                path: self
258                    .filenames
259                    .get(&first_source_range.module_id())
260                    .cloned()
261                    .unwrap_or(ModulePath::Main),
262            });
263        let filename = source.path.to_string();
264        let kcl_source = source.source;
265
266        let mut related = Vec::new();
267        for source_range in source_ranges {
268            let module_id = source_range.module_id();
269            let source = self.source_files.get(&module_id).cloned().unwrap_or(ModuleSource {
270                source: code.to_string(),
271                path: self.filenames.get(&module_id).cloned().unwrap_or(ModulePath::Main),
272            });
273            let error = self.error.override_source_ranges(vec![source_range]);
274            let report = Report {
275                error,
276                kcl_source: source.source.to_string(),
277                filename: source.path.to_string(),
278            };
279            related.push(report);
280        }
281
282        Ok(ReportWithOutputs {
283            error: self,
284            kcl_source,
285            filename,
286            related,
287        })
288    }
289}
290
291impl IsRetryable for KclErrorWithOutputs {
292    fn is_retryable(&self) -> bool {
293        matches!(
294            self.error,
295            KclError::EngineHangup { .. } | KclError::EngineInternal { .. }
296        )
297    }
298}
299
300impl IntoDiagnostic for KclErrorWithOutputs {
301    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
302        let message = self.error.get_message();
303        let source_ranges = self.error.source_ranges();
304
305        source_ranges
306            .into_iter()
307            .map(|source_range| {
308                let source = self.source_files.get(&source_range.module_id()).cloned().or_else(|| {
309                    self.filenames
310                        .get(&source_range.module_id())
311                        .cloned()
312                        .map(|path| ModuleSource {
313                            source: code.to_string(),
314                            path,
315                        })
316                });
317
318                let related_information = source.and_then(|source| {
319                    let mut filename = source.path.to_string();
320                    if !filename.starts_with("file://") {
321                        filename = format!("file:///{}", filename.trim_start_matches("/"));
322                    }
323
324                    url::Url::parse(&filename).ok().map(|uri| {
325                        vec![tower_lsp::lsp_types::DiagnosticRelatedInformation {
326                            location: tower_lsp::lsp_types::Location {
327                                uri,
328                                range: source_range.to_lsp_range(&source.source),
329                            },
330                            message: message.to_string(),
331                        }]
332                    })
333                });
334
335                Diagnostic {
336                    range: source_range.to_lsp_range(code),
337                    severity: Some(self.severity()),
338                    code: None,
339                    // TODO: this is neat we can pass a URL to a help page here for this specific error.
340                    code_description: None,
341                    source: Some("kcl".to_string()),
342                    related_information,
343                    message: message.clone(),
344                    tags: None,
345                    data: None,
346                }
347            })
348            .collect()
349    }
350
351    fn severity(&self) -> DiagnosticSeverity {
352        DiagnosticSeverity::ERROR
353    }
354}
355
356#[derive(thiserror::Error, Debug)]
357#[error("{}", self.error.error.get_message())]
358pub struct ReportWithOutputs {
359    pub error: KclErrorWithOutputs,
360    pub kcl_source: String,
361    pub filename: String,
362    pub related: Vec<Report>,
363}
364
365impl miette::Diagnostic for ReportWithOutputs {
366    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
367        let family = match self.error.error {
368            KclError::Lexical { .. } => "Lexical",
369            KclError::Syntax { .. } => "Syntax",
370            KclError::Semantic { .. } => "Semantic",
371            KclError::ImportCycle { .. } => "ImportCycle",
372            KclError::Argument { .. } => "Argument",
373            KclError::Type { .. } => "Type",
374            KclError::Io { .. } => "I/O",
375            KclError::Unexpected { .. } => "Unexpected",
376            KclError::ValueAlreadyDefined { .. } => "ValueAlreadyDefined",
377            KclError::UndefinedValue { .. } => "UndefinedValue",
378            KclError::InvalidExpression { .. } => "InvalidExpression",
379            KclError::MaxCallStack { .. } => "MaxCallStack",
380            KclError::Refactor { .. } => "Refactor",
381            KclError::Engine { .. } => "Engine",
382            KclError::EngineHangup { .. } => "EngineHangup",
383            KclError::EngineInternal { .. } => "EngineInternal",
384            KclError::Internal { .. } => "Internal",
385        };
386        let error_string = format!("KCL {family} error");
387        Some(Box::new(error_string))
388    }
389
390    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
391        Some(&self.kcl_source)
392    }
393
394    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
395        let iter = self
396            .error
397            .error
398            .source_ranges()
399            .into_iter()
400            .map(miette::SourceSpan::from)
401            .map(|span| miette::LabeledSpan::new_with_span(Some(self.filename.to_string()), span));
402        Some(Box::new(iter))
403    }
404
405    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn miette::Diagnostic> + 'a>> {
406        let iter = self.related.iter().map(|r| r as &dyn miette::Diagnostic);
407        Some(Box::new(iter))
408    }
409}
410
411#[derive(thiserror::Error, Debug)]
412#[error("{}", self.error.get_message())]
413pub struct Report {
414    pub error: KclError,
415    pub kcl_source: String,
416    pub filename: String,
417}
418
419impl miette::Diagnostic for Report {
420    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
421        let family = match self.error {
422            KclError::Lexical { .. } => "Lexical",
423            KclError::Syntax { .. } => "Syntax",
424            KclError::Semantic { .. } => "Semantic",
425            KclError::ImportCycle { .. } => "ImportCycle",
426            KclError::Argument { .. } => "Argument",
427            KclError::Type { .. } => "Type",
428            KclError::Io { .. } => "I/O",
429            KclError::Unexpected { .. } => "Unexpected",
430            KclError::ValueAlreadyDefined { .. } => "ValueAlreadyDefined",
431            KclError::UndefinedValue { .. } => "UndefinedValue",
432            KclError::InvalidExpression { .. } => "InvalidExpression",
433            KclError::MaxCallStack { .. } => "MaxCallStack",
434            KclError::Refactor { .. } => "Refactor",
435            KclError::Engine { .. } => "Engine",
436            KclError::EngineHangup { .. } => "EngineHangup",
437            KclError::EngineInternal { .. } => "EngineInternal",
438            KclError::Internal { .. } => "Internal",
439        };
440        let error_string = format!("KCL {family} error");
441        Some(Box::new(error_string))
442    }
443
444    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
445        Some(&self.kcl_source)
446    }
447
448    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
449        let iter = self
450            .error
451            .source_ranges()
452            .into_iter()
453            .map(miette::SourceSpan::from)
454            .map(|span| miette::LabeledSpan::new_with_span(Some(self.filename.to_string()), span));
455        Some(Box::new(iter))
456    }
457}
458
459#[derive(thiserror::Error, Debug)]
460#[error("{}", self.issue.message)]
461pub struct CompilationIssueReport {
462    pub issue: CompilationIssue,
463    pub kcl_source: String,
464    pub filename: String,
465}
466
467impl miette::Diagnostic for CompilationIssueReport {
468    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
469        let tag = match self.issue.tag {
470            Tag::Deprecated => "deprecated",
471            Tag::Unnecessary => "unnecessary",
472            Tag::UnknownNumericUnits => "unknown-numeric-units",
473            Tag::None => return None,
474        };
475        Some(Box::new(format!("KCL {tag}")))
476    }
477
478    fn severity(&self) -> Option<miette::Severity> {
479        Some(match self.issue.severity {
480            Severity::Warning => miette::Severity::Warning,
481            Severity::Error | Severity::Fatal => miette::Severity::Error,
482        })
483    }
484
485    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
486        self.issue
487            .suggestion
488            .as_ref()
489            .map(|s| Box::new(s.title.clone()) as Box<dyn std::fmt::Display>)
490    }
491
492    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
493        Some(&self.kcl_source)
494    }
495
496    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
497        let span = miette::SourceSpan::from(self.issue.source_range);
498        let label = miette::LabeledSpan::new_with_span(Some(self.filename.to_string()), span);
499        Some(Box::new(std::iter::once(label)))
500    }
501}
502
503/// Render a [`CompilationIssue`] as a miette report string, mirroring the
504/// formatting used for [`Report`].
505pub fn render_compilation_issue_miette(filename: &str, source: &str, issue: CompilationIssue) -> String {
506    let report = CompilationIssueReport {
507        issue,
508        kcl_source: source.to_owned(),
509        filename: filename.to_owned(),
510    };
511    let report = miette::Report::new(report);
512    format!("{report:?}")
513}
514
515impl IntoDiagnostic for KclError {
516    fn to_lsp_diagnostics(&self, code: &str) -> Vec<Diagnostic> {
517        let message = self.get_message();
518        let source_ranges = self.source_ranges();
519
520        // Limit to only errors in the top-level file.
521        let module_id = ModuleId::default();
522        let source_ranges = source_ranges
523            .iter()
524            .filter(|r| r.module_id() == module_id)
525            .collect::<Vec<_>>();
526
527        let mut diagnostics = Vec::new();
528        for source_range in &source_ranges {
529            diagnostics.push(Diagnostic {
530                range: source_range.to_lsp_range(code),
531                severity: Some(self.severity()),
532                code: None,
533                // TODO: this is neat we can pass a URL to a help page here for this specific error.
534                code_description: None,
535                source: Some("kcl".to_string()),
536                related_information: None,
537                message: message.clone(),
538                tags: None,
539                data: None,
540            });
541        }
542
543        diagnostics
544    }
545
546    fn severity(&self) -> DiagnosticSeverity {
547        DiagnosticSeverity::ERROR
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn missing_filename_mapping_does_not_panic_when_building_diagnostics() {
557        let error = KclErrorWithOutputs::no_outputs(KclError::new_semantic(KclErrorDetails::new(
558            "boom".to_owned(),
559            vec![SourceRange::new(0, 1, ModuleId::from_usize(9))],
560        )));
561
562        let diagnostics = error.to_lsp_diagnostics("x");
563
564        assert_eq!(diagnostics.len(), 1);
565        assert_eq!(diagnostics[0].message, "semantic: boom");
566        assert_eq!(diagnostics[0].related_information, None);
567    }
568}