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