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::BacktraceItemKind;
7pub use kcl_error::CompilationIssue;
8pub use kcl_error::IsRetryable;
9pub use kcl_error::KclError;
10pub use kcl_error::KclErrorDetails;
11pub use kcl_error::Severity;
12pub use kcl_error::Suggestion;
13pub use kcl_error::Tag;
14use serde::Serialize;
15use thiserror::Error;
16use tower_lsp::lsp_types::Diagnostic;
17use tower_lsp::lsp_types::DiagnosticSeverity;
18use uuid::Uuid;
19
20use crate::ExecOutcome;
21use crate::ModuleId;
22use crate::SourceRange;
23use crate::exec::KclValue;
24use crate::execution::ArtifactCommand;
25use crate::execution::ArtifactGraph;
26use crate::execution::DefaultPlanes;
27use crate::execution::KclValueView;
28use crate::execution::OperationsByModule;
29use crate::execution::RefactorMetadata;
30use crate::front::Number;
31use crate::front::Object;
32use crate::front::ObjectId;
33use crate::lsp_types::IntoDiagnostic;
34use crate::lsp_types::ToLspRange;
35use crate::modules::ModulePath;
36use crate::modules::ModuleSource;
37
38/// How did the KCL execution fail
39#[derive(thiserror::Error, Debug)]
40pub enum ExecError {
41    #[error("{0}")]
42    Kcl(#[from] Box<crate::KclErrorWithOutputs>),
43    #[error("Could not connect to engine: {0}")]
44    Connection(#[from] ConnectionError),
45    #[error("PNG snapshot could not be decoded: {0}")]
46    BadPng(String),
47    #[error("Bad export: {0}")]
48    BadExport(String),
49}
50
51impl From<KclErrorWithOutputs> for ExecError {
52    fn from(error: KclErrorWithOutputs) -> Self {
53        ExecError::Kcl(Box::new(error))
54    }
55}
56
57/// How did the KCL execution fail, with extra state.
58#[derive(Debug, thiserror::Error)]
59#[error("{error}")]
60pub struct ExecErrorWithState {
61    pub error: ExecError,
62    pub exec_state: Option<crate::execution::ExecState>,
63    #[cfg(feature = "snapshot-engine-responses")]
64    pub responses: Option<IndexMap<Uuid, kittycad_modeling_cmds::websocket::WebSocketResponse>>,
65}
66
67impl ExecErrorWithState {
68    #[cfg_attr(target_arch = "wasm32", expect(dead_code))]
69    pub fn new(
70        error: ExecError,
71        exec_state: crate::execution::ExecState,
72        #[cfg_attr(not(feature = "snapshot-engine-responses"), expect(unused_variables))] responses: Option<
73            IndexMap<Uuid, kittycad_modeling_cmds::websocket::WebSocketResponse>,
74        >,
75    ) -> Self {
76        Self {
77            error,
78            exec_state: Some(exec_state),
79            #[cfg(feature = "snapshot-engine-responses")]
80            responses,
81        }
82    }
83}
84
85impl IsRetryable for ExecErrorWithState {
86    fn is_retryable(&self) -> bool {
87        self.error.is_retryable()
88    }
89}
90
91impl ExecError {
92    pub fn as_kcl_error(&self) -> Option<&crate::KclError> {
93        let ExecError::Kcl(k) = &self else {
94            return None;
95        };
96        Some(&k.error)
97    }
98}
99
100impl IsRetryable for ExecError {
101    fn is_retryable(&self) -> bool {
102        matches!(self, ExecError::Kcl(kcl_error) if kcl_error.is_retryable())
103    }
104}
105
106impl From<ExecError> for ExecErrorWithState {
107    fn from(error: ExecError) -> Self {
108        Self {
109            error,
110            exec_state: None,
111            #[cfg(feature = "snapshot-engine-responses")]
112            responses: None,
113        }
114    }
115}
116
117impl From<ConnectionError> for ExecErrorWithState {
118    fn from(error: ConnectionError) -> Self {
119        Self {
120            error: error.into(),
121            exec_state: None,
122            #[cfg(feature = "snapshot-engine-responses")]
123            responses: None,
124        }
125    }
126}
127
128/// How did KCL client fail to connect to the engine
129#[derive(thiserror::Error, Debug)]
130pub enum ConnectionError {
131    #[error("Could not create a Zoo client: {0}")]
132    CouldNotMakeClient(anyhow::Error),
133    #[error("Could not establish connection to engine: {0}")]
134    Establishing(anyhow::Error),
135}
136
137impl From<KclErrorWithOutputs> for KclError {
138    fn from(error: KclErrorWithOutputs) -> Self {
139        error.error
140    }
141}
142
143#[derive(Error, Debug, Serialize, ts_rs::TS, Clone, PartialEq)]
144#[error("{error}")]
145#[ts(export)]
146#[serde(rename_all = "camelCase")]
147pub struct KclErrorWithOutputs {
148    pub error: KclError,
149    pub non_fatal: Vec<CompilationIssue>,
150    /// Variables in the top-level of the root module. Note that functions will
151    /// have an invalid env ref.
152    pub variables: IndexMap<String, KclValueView>,
153    pub operations: OperationsByModule,
154    // TODO: Remove this field.  Doing so breaks the ts-rs output for some
155    // reason.
156    pub _artifact_commands: Vec<ArtifactCommand>,
157    pub artifact_graph: ArtifactGraph,
158    #[serde(skip)]
159    pub scene_objects: Vec<Object>,
160    #[serde(skip)]
161    pub source_range_to_object: BTreeMap<SourceRange, ObjectId>,
162    #[serde(skip)]
163    pub var_solutions: Vec<(SourceRange, Option<NodePath>, Number)>,
164    pub refactor_metadata: Vec<RefactorMetadata>,
165    pub scene_graph: Option<crate::front::SceneGraph>,
166    pub filenames: IndexMap<ModuleId, ModulePath>,
167    pub source_files: IndexMap<ModuleId, ModuleSource>,
168    pub default_planes: Option<DefaultPlanes>,
169}
170
171impl KclErrorWithOutputs {
172    #[allow(clippy::too_many_arguments)]
173    pub fn new(
174        error: KclError,
175        non_fatal: Vec<CompilationIssue>,
176        variables: IndexMap<String, KclValue>,
177        operations: OperationsByModule,
178        artifact_commands: Vec<ArtifactCommand>,
179        artifact_graph: ArtifactGraph,
180        scene_objects: Vec<Object>,
181        source_range_to_object: BTreeMap<SourceRange, ObjectId>,
182        var_solutions: Vec<(SourceRange, Option<NodePath>, Number)>,
183        refactor_metadata: Vec<RefactorMetadata>,
184        filenames: IndexMap<ModuleId, ModulePath>,
185        source_files: IndexMap<ModuleId, ModuleSource>,
186        default_planes: Option<DefaultPlanes>,
187    ) -> Self {
188        let variables_view = variables.into_iter().map(|(k, v)| (k, v.into())).collect();
189        Self {
190            error,
191            non_fatal,
192            variables: variables_view,
193            operations,
194            _artifact_commands: artifact_commands,
195            artifact_graph,
196            scene_objects,
197            source_range_to_object,
198            var_solutions,
199            refactor_metadata,
200            scene_graph: Default::default(),
201            filenames,
202            source_files,
203            default_planes,
204        }
205    }
206
207    pub fn no_outputs(error: KclError) -> Self {
208        Self {
209            error,
210            non_fatal: Default::default(),
211            variables: Default::default(),
212            operations: Default::default(),
213            _artifact_commands: Default::default(),
214            artifact_graph: Default::default(),
215            scene_objects: Default::default(),
216            source_range_to_object: Default::default(),
217            var_solutions: Default::default(),
218            refactor_metadata: Default::default(),
219            scene_graph: Default::default(),
220            filenames: Default::default(),
221            source_files: Default::default(),
222            default_planes: Default::default(),
223        }
224    }
225
226    /// This is for when the error is generated after a successful execution.
227    pub fn from_error_outcome(error: KclError, outcome: ExecOutcome) -> Self {
228        KclErrorWithOutputs {
229            error,
230            non_fatal: outcome.issues,
231            variables: outcome.variables,
232            operations: outcome.operations,
233            _artifact_commands: Default::default(),
234            artifact_graph: outcome.artifact_graph,
235            scene_objects: outcome.scene_objects,
236            source_range_to_object: outcome.source_range_to_object,
237            var_solutions: outcome.var_solutions,
238            refactor_metadata: outcome.refactor_metadata,
239            scene_graph: Default::default(),
240            filenames: outcome.filenames,
241            source_files: outcome.source_files,
242            default_planes: outcome.default_planes,
243        }
244    }
245
246    pub fn sketch_constraint_report(&self) -> crate::SketchConstraintReport {
247        crate::execution::sketch_constraint_report_from_scene_objects(&self.scene_objects)
248    }
249
250    /// Get a debug visualization of the named sketch.
251    /// If there's multiple sketches with the same name, disambiguate them
252    /// with `instance_index`.
253    pub fn render_sketch_png_instance(
254        &self,
255        sketch_name: &str,
256        instance_index: Option<usize>,
257    ) -> std::result::Result<Vec<u8>, crate::tooling::sketch_visualizer::SketchVisualizationError> {
258        crate::execution::render_sketch_png_from_scene_objects(&self.scene_objects, sketch_name, instance_index)
259    }
260
261    pub fn into_miette_report_with_outputs(self, code: &str) -> anyhow::Result<ReportWithOutputs> {
262        let source_ranges = self.error.source_ranges();
263
264        // Source ranges are ordered innermost first, so the first one is where
265        // the error actually occurred; it anchors the primary report. Each
266        // outer frame either becomes another label on the primary (same file
267        // and not overlapping any label already kept; miette merges
268        // overlapping labels into hard-to-read shared rows) or its own
269        // related report below.
270        let first_source_range = *source_ranges
271            .first()
272            .ok_or_else(|| anyhow::anyhow!("No source ranges found"))?;
273        let primary_module_id = first_source_range.module_id();
274
275        let module_source = |module_id: ModuleId| {
276            self.source_files.get(&module_id).cloned().unwrap_or(ModuleSource {
277                source: code.to_string(),
278                path: self.filenames.get(&module_id).cloned().unwrap_or(ModulePath::Main),
279            })
280        };
281        let source = module_source(primary_module_id);
282        let filename = source.path.to_string();
283        let kcl_source = source.source;
284
285        // Label outer frames with their backtrace names so the chain reads
286        // like a backtrace; fall back to the filename.
287        let backtrace = self.error.backtrace();
288
289        let mut primary_labels = vec![miette::LabeledSpan::new_with_span(
290            Some(filename.clone()),
291            miette::SourceSpan::from(first_source_range),
292        )];
293        let mut kept_ranges = vec![first_source_range];
294        let mut related = Vec::new();
295        for (index, source_range) in source_ranges.iter().copied().enumerate().skip(1) {
296            let keep = source_range.module_id() == primary_module_id
297                && !kept_ranges.iter().any(|kept| ranges_overlap(*kept, source_range));
298            let source = module_source(source_range.module_id());
299            let label = frame_label(&backtrace, source_ranges.len(), index).unwrap_or_else(|| source.path.to_string());
300            if keep {
301                primary_labels.push(miette::LabeledSpan::new_with_span(
302                    Some(label),
303                    miette::SourceSpan::from(source_range),
304                ));
305                kept_ranges.push(source_range);
306            } else {
307                let error = self.error.override_source_ranges(vec![source_range]);
308                related.push(Report {
309                    error,
310                    kcl_source: source.source,
311                    filename: source.path.to_string(),
312                    label,
313                });
314            }
315        }
316
317        Ok(ReportWithOutputs {
318            error: self,
319            kcl_source,
320            filename,
321            primary_labels,
322            related,
323        })
324    }
325}
326
327/// The display label for backtrace frame `index`, derived from the frame's
328/// name: `in someFunction()` for calls, the `import <path>` label for
329/// imports. Some errors carry source ranges without matching frames (e.g.
330/// hand-built details), so the backtrace is only trusted when it lines up
331/// with the source ranges.
332fn frame_label(backtrace: &[BacktraceItem], ranges_len: usize, index: usize) -> Option<String> {
333    if backtrace.len() != ranges_len {
334        return None;
335    }
336    let frame = &backtrace[index];
337    let name = frame.fn_name.as_ref()?;
338    match frame.kind {
339        BacktraceItemKind::Import => Some(name.clone()),
340        BacktraceItemKind::Call => Some(format!("in {name}()")),
341    }
342}
343
344/// Whether two source ranges cover any common source text.
345///
346/// Equal ranges count as overlapping even when empty so that repeated frames
347/// (e.g. recursion) do not stack duplicate labels on the primary report.
348fn ranges_overlap(a: SourceRange, b: SourceRange) -> bool {
349    if a.module_id() != b.module_id() {
350        return false;
351    }
352    if a.start() == b.start() && a.end() == b.end() {
353        return true;
354    }
355    a.start() < b.end() && b.start() < a.end()
356}
357
358impl IsRetryable for KclErrorWithOutputs {
359    fn is_retryable(&self) -> bool {
360        matches!(
361            self.error,
362            KclError::EngineHangup { .. } | KclError::EngineInternal { .. }
363        )
364    }
365}
366
367impl IntoDiagnostic for KclErrorWithOutputs {
368    fn to_lsp_diagnostics(&self, code: &str, uri: &tower_lsp::lsp_types::Url) -> Vec<Diagnostic> {
369        let message = self.error.get_message();
370        let source_ranges = self.error.source_ranges();
371        if source_ranges.is_empty() {
372            return Vec::new();
373        }
374
375        // The caller publishes these diagnostics under the top-level
376        // document's URI, so the diagnostic range must be a top-level range
377        // converted against the top-level source; imported offsets would
378        // point at unrelated text. Source ranges are ordered innermost
379        // first: anchor at the innermost top-level range and attach every
380        // other frame as related information located in its own module.
381        let primary_index = source_ranges.iter().position(|range| range.module_id().is_top_level());
382        let primary_range = primary_index.map(|index| source_ranges[index]).unwrap_or_default();
383
384        let backtrace = self.error.backtrace();
385        let related_information: Vec<tower_lsp::lsp_types::DiagnosticRelatedInformation> = source_ranges
386            .iter()
387            .enumerate()
388            .filter(|(index, _)| Some(*index) != primary_index)
389            .filter_map(|(index, source_range)| {
390                // Top-level frames belong to the document these diagnostics
391                // are published under; its path in `filenames` is the virtual
392                // main module, so only the caller knows the real URI.
393                let location = if source_range.module_id().is_top_level() {
394                    tower_lsp::lsp_types::Location {
395                        uri: uri.clone(),
396                        range: source_range.to_lsp_range(code),
397                    }
398                } else {
399                    let source = self.source_files.get(&source_range.module_id()).cloned().or_else(|| {
400                        self.filenames
401                            .get(&source_range.module_id())
402                            .cloned()
403                            .map(|path| ModuleSource {
404                                source: code.to_string(),
405                                path,
406                            })
407                    })?;
408                    let mut filename = source.path.to_string();
409                    if !filename.starts_with("file://") {
410                        filename = format!("file:///{}", filename.trim_start_matches("/"));
411                    }
412                    tower_lsp::lsp_types::Location {
413                        uri: url::Url::parse(&filename).ok()?,
414                        range: source_range.to_lsp_range(&source.source),
415                    }
416                };
417                Some(tower_lsp::lsp_types::DiagnosticRelatedInformation {
418                    location,
419                    message: frame_label(&backtrace, source_ranges.len(), index).unwrap_or_else(|| message.clone()),
420                })
421            })
422            .collect();
423
424        vec![Diagnostic {
425            range: primary_range.to_lsp_range(code),
426            severity: Some(self.severity()),
427            code: None,
428            // TODO: this is neat we can pass a URL to a help page here for this specific error.
429            code_description: None,
430            source: Some("kcl".to_string()),
431            related_information: (!related_information.is_empty()).then_some(related_information),
432            message,
433            tags: None,
434            data: None,
435        }]
436    }
437
438    fn severity(&self) -> DiagnosticSeverity {
439        DiagnosticSeverity::ERROR
440    }
441}
442
443#[derive(thiserror::Error, Debug)]
444#[error("{}", self.error.error.get_message())]
445pub struct ReportWithOutputs {
446    pub error: KclErrorWithOutputs,
447    pub kcl_source: String,
448    pub filename: String,
449    /// Labels to render on the primary report, precomputed so they cannot
450    /// disagree with which frames were split out into `related`.
451    pub primary_labels: Vec<miette::LabeledSpan>,
452    pub related: Vec<Report>,
453}
454
455impl miette::Diagnostic for ReportWithOutputs {
456    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
457        let family = match self.error.error {
458            KclError::Lexical { .. } => "Lexical",
459            KclError::Syntax { .. } => "Syntax",
460            KclError::Semantic { .. } => "Semantic",
461            KclError::ImportCycle { .. } => "ImportCycle",
462            KclError::Argument { .. } => "Argument",
463            KclError::Type { .. } => "Type",
464            KclError::UserDefined { .. } => "UserDefined",
465            KclError::Io { .. } => "I/O",
466            KclError::Unexpected { .. } => "Unexpected",
467            KclError::ValueAlreadyDefined { .. } => "ValueAlreadyDefined",
468            KclError::UndefinedValue { .. } => "UndefinedValue",
469            KclError::InvalidExpression { .. } => "InvalidExpression",
470            KclError::MaxCallStack { .. } => "MaxCallStack",
471            KclError::Refactor { .. } => "Refactor",
472            KclError::Engine { .. } => "Engine",
473            KclError::EngineHangup { .. } => "EngineHangup",
474            KclError::EngineInternal { .. } => "EngineInternal",
475            KclError::Internal { .. } => "Internal",
476        };
477        let error_string = format!("KCL {family} error");
478        Some(Box::new(error_string))
479    }
480
481    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
482        Some(&self.kcl_source)
483    }
484
485    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
486        Some(Box::new(self.primary_labels.iter().cloned()))
487    }
488
489    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn miette::Diagnostic> + 'a>> {
490        let iter = self.related.iter().map(|r| r as &dyn miette::Diagnostic);
491        Some(Box::new(iter))
492    }
493}
494
495#[derive(thiserror::Error, Debug)]
496#[error("{}", self.error.get_message())]
497pub struct Report {
498    pub error: KclError,
499    pub kcl_source: String,
500    pub filename: String,
501    /// Text for this report's span label: the backtrace frame name when one
502    /// exists, otherwise the filename.
503    pub label: String,
504}
505
506impl miette::Diagnostic for Report {
507    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
508        let family = match self.error {
509            KclError::Lexical { .. } => "Lexical",
510            KclError::Syntax { .. } => "Syntax",
511            KclError::Semantic { .. } => "Semantic",
512            KclError::ImportCycle { .. } => "ImportCycle",
513            KclError::Argument { .. } => "Argument",
514            KclError::Type { .. } => "Type",
515            KclError::UserDefined { .. } => "UserDefined",
516            KclError::Io { .. } => "I/O",
517            KclError::Unexpected { .. } => "Unexpected",
518            KclError::ValueAlreadyDefined { .. } => "ValueAlreadyDefined",
519            KclError::UndefinedValue { .. } => "UndefinedValue",
520            KclError::InvalidExpression { .. } => "InvalidExpression",
521            KclError::MaxCallStack { .. } => "MaxCallStack",
522            KclError::Refactor { .. } => "Refactor",
523            KclError::Engine { .. } => "Engine",
524            KclError::EngineHangup { .. } => "EngineHangup",
525            KclError::EngineInternal { .. } => "EngineInternal",
526            KclError::Internal { .. } => "Internal",
527        };
528        let error_string = format!("KCL {family} error");
529        Some(Box::new(error_string))
530    }
531
532    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
533        Some(&self.kcl_source)
534    }
535
536    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
537        let iter = self
538            .error
539            .source_ranges()
540            .into_iter()
541            .map(miette::SourceSpan::from)
542            .map(|span| miette::LabeledSpan::new_with_span(Some(self.label.clone()), span));
543        Some(Box::new(iter))
544    }
545}
546
547#[derive(thiserror::Error, Debug)]
548#[error("{}", self.issue.message)]
549pub struct CompilationIssueReport {
550    pub issue: CompilationIssue,
551    pub kcl_source: String,
552    pub filename: String,
553}
554
555impl miette::Diagnostic for CompilationIssueReport {
556    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
557        let tag = match self.issue.tag {
558            Tag::Deprecated => "deprecated",
559            Tag::Unnecessary => "unnecessary",
560            Tag::UnknownNumericUnits => "unknown-numeric-units",
561            Tag::None => return None,
562        };
563        Some(Box::new(format!("KCL {tag}")))
564    }
565
566    fn severity(&self) -> Option<miette::Severity> {
567        Some(match self.issue.severity {
568            Severity::Warning => miette::Severity::Warning,
569            Severity::Error | Severity::Fatal => miette::Severity::Error,
570        })
571    }
572
573    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
574        self.issue
575            .suggestion
576            .as_ref()
577            .map(|s| Box::new(s.title.clone()) as Box<dyn std::fmt::Display>)
578    }
579
580    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
581        Some(&self.kcl_source)
582    }
583
584    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
585        let span = miette::SourceSpan::from(self.issue.source_range);
586        let label = miette::LabeledSpan::new_with_span(Some(self.filename.to_string()), span);
587        Some(Box::new(std::iter::once(label)))
588    }
589}
590
591/// Render a [`CompilationIssue`] as a miette report string, mirroring the
592/// formatting used for [`Report`]. The issue is rendered against the module
593/// its source range points into: issues from imported modules use their
594/// entry in `source_files`, while top-level issues use `top_level_filename`
595/// and `top_level_source`, since callers know the real top-level filename
596/// (e.g. an absolute path) while `source_files` only records the module
597/// path. The top-level pair is also the fallback when the module is missing
598/// from `source_files`, so callers without a source map can pass an empty
599/// one.
600pub fn render_compilation_issue_miette(
601    top_level_filename: &str,
602    top_level_source: &str,
603    source_files: &IndexMap<ModuleId, ModuleSource>,
604    issue: CompilationIssue,
605) -> String {
606    let module_id = issue.source_range.module_id();
607    let module_source = (!module_id.is_top_level())
608        .then(|| source_files.get(&module_id))
609        .flatten();
610    let (filename, kcl_source) = match module_source {
611        Some(module_source) => (module_source.path.to_string(), module_source.source.clone()),
612        None => (top_level_filename.to_owned(), top_level_source.to_owned()),
613    };
614    let report = CompilationIssueReport {
615        issue,
616        kcl_source,
617        filename,
618    };
619    let report = miette::Report::new(report);
620    format!("{report:?}")
621}
622
623impl IntoDiagnostic for KclError {
624    fn to_lsp_diagnostics(&self, code: &str, _uri: &tower_lsp::lsp_types::Url) -> Vec<Diagnostic> {
625        let message = self.get_message();
626        let source_ranges = self.source_ranges();
627
628        // Limit to only errors in the top-level file.
629        let module_id = ModuleId::default();
630        let source_ranges = source_ranges
631            .iter()
632            .filter(|r| r.module_id() == module_id)
633            .collect::<Vec<_>>();
634
635        let mut diagnostics = Vec::new();
636        for source_range in &source_ranges {
637            diagnostics.push(Diagnostic {
638                range: source_range.to_lsp_range(code),
639                severity: Some(self.severity()),
640                code: None,
641                // TODO: this is neat we can pass a URL to a help page here for this specific error.
642                code_description: None,
643                source: Some("kcl".to_string()),
644                related_information: None,
645                message: message.clone(),
646                tags: None,
647                data: None,
648            });
649        }
650
651        diagnostics
652    }
653
654    fn severity(&self) -> DiagnosticSeverity {
655        DiagnosticSeverity::ERROR
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn missing_filename_mapping_does_not_panic_when_building_diagnostics() {
665        let error = KclErrorWithOutputs::no_outputs(KclError::new_semantic(KclErrorDetails::new(
666            "boom".to_owned(),
667            vec![SourceRange::new(0, 1, ModuleId::from_usize(9))],
668        )));
669
670        let diagnostics = error.to_lsp_diagnostics("x", &"file:///test.kcl".try_into().unwrap());
671
672        assert_eq!(diagnostics.len(), 1);
673        assert_eq!(diagnostics[0].message, "semantic: boom");
674        assert_eq!(diagnostics[0].related_information, None);
675    }
676
677    #[test]
678    fn lsp_diagnostics_anchor_at_top_level_and_relate_imported_frames() {
679        let main_code = "import assemblyValue from \"assembly.kcl\"\n\nassemblyValue\n";
680        // The failing expression is on line 2 of the imported file, so a
681        // range converted against the wrong source lands on the wrong line.
682        let imported_code = "// comment\nexport brokenValue = missingName + 1\n";
683        let imported_module = ModuleId::from_usize(1);
684        let missing_name_start = imported_code.find("missingName").unwrap();
685        let imported_range = SourceRange::new(missing_name_start, missing_name_start + 11, imported_module);
686        let import_stmt_range = SourceRange::new(0, 41, ModuleId::default());
687
688        let error = KclError::new_semantic(KclErrorDetails::new(
689            "`missingName` is not defined".to_owned(),
690            vec![imported_range],
691        ))
692        .add_import_location("assembly.kcl", import_stmt_range);
693        let mut error = KclErrorWithOutputs::no_outputs(error);
694        error.source_files.insert(
695            imported_module,
696            ModuleSource {
697                source: imported_code.to_owned(),
698                path: ModulePath::Local {
699                    value: "/project/assembly.kcl".into(),
700                    original_import_path: None,
701                },
702            },
703        );
704
705        let diagnostics = error.to_lsp_diagnostics(main_code, &"file:///project/main.kcl".try_into().unwrap());
706
707        // One diagnostic, anchored at the import statement in the top-level
708        // file (line 0), not at imported offsets.
709        assert_eq!(diagnostics.len(), 1);
710        assert_eq!(diagnostics[0].range.start.line, 0);
711        assert_eq!(diagnostics[0].range.end.line, 0);
712
713        // The imported frame is related information located in its own file,
714        // with the range computed against that file's source.
715        let related = diagnostics[0].related_information.as_ref().unwrap();
716        assert_eq!(related.len(), 1);
717        assert!(related[0].location.uri.as_str().ends_with("assembly.kcl"));
718        assert_eq!(related[0].location.range.start.line, 1);
719        assert_eq!(related[0].message, "import assembly.kcl");
720    }
721
722    fn report_for(ranges: Vec<SourceRange>) -> ReportWithOutputs {
723        let error = KclError::new_semantic(KclErrorDetails::new("boom".to_owned(), ranges));
724        KclErrorWithOutputs::no_outputs(error)
725            .into_miette_report_with_outputs("code")
726            .unwrap()
727    }
728
729    #[test]
730    fn overlapping_same_file_ranges_become_related_reports() {
731        let module = ModuleId::default();
732        let narrow = SourceRange::new(10, 16, module);
733        let wide = SourceRange::new(0, 20, module);
734        let disjoint = SourceRange::new(30, 40, module);
735
736        let report = report_for(vec![narrow, wide, disjoint]);
737
738        // The wide range overlaps the primary label, so it is split out; the
739        // disjoint one stays as a second label.
740        assert_eq!(report.primary_labels.len(), 2);
741        assert_eq!(report.related.len(), 1);
742        assert_eq!(report.related[0].error.source_ranges(), vec![wide]);
743    }
744
745    #[test]
746    fn other_module_ranges_become_related_reports() {
747        let inner = SourceRange::new(0, 5, ModuleId::from_usize(7));
748        let outer = SourceRange::new(10, 20, ModuleId::default());
749
750        let report = report_for(vec![inner, outer]);
751
752        assert_eq!(report.primary_labels.len(), 1);
753        assert_eq!(report.related.len(), 1);
754        assert_eq!(report.related[0].error.source_ranges(), vec![outer]);
755    }
756
757    #[test]
758    fn labels_use_frame_names_when_available() {
759        let module = ModuleId::default();
760        let inner = SourceRange::new(10, 16, module);
761        let mid_call = SourceRange::new(30, 40, module);
762        let outer_call = SourceRange::new(0, 20, module);
763        let import_site = SourceRange::new(0, 5, ModuleId::from_usize(2));
764        let error = KclError::new_semantic(KclErrorDetails::new("boom".to_owned(), vec![inner]))
765            .add_unwind_location(Some("f".to_owned()), mid_call)
766            .add_unwind_location(Some("g".to_owned()), outer_call)
767            .add_import_location("part.kcl", import_site);
768
769        let report = KclErrorWithOutputs::no_outputs(error)
770            .into_miette_report_with_outputs("code")
771            .unwrap();
772
773        // mid_call is disjoint, so it stays as a label named for the function
774        // containing it; outer_call overlaps the anchor and was labeled by the
775        // import unwind; import_site has no frame name, so it falls back to
776        // its filename.
777        assert_eq!(report.primary_labels.len(), 2);
778        assert_eq!(report.primary_labels[1].label(), Some("in g()"));
779        assert_eq!(
780            report.related.iter().map(|r| r.label.as_str()).collect::<Vec<_>>(),
781            ["import part.kcl", report.related[1].filename.as_str()]
782        );
783    }
784
785    #[test]
786    fn repeated_frames_do_not_stack_duplicate_labels() {
787        // Recursion repeats the same range; only the first occurrence stays
788        // on the primary report.
789        let module = ModuleId::default();
790        let range = SourceRange::new(10, 16, module);
791
792        let report = report_for(vec![range, range, range]);
793
794        assert_eq!(report.primary_labels.len(), 1);
795        assert_eq!(report.related.len(), 2);
796    }
797
798    #[test]
799    fn compilation_issues_render_against_the_module_their_range_points_into() {
800        let imported_module = ModuleId::from_usize(1);
801        // Long enough that ranges into it lie past the end of the top-level
802        // source below.
803        let imported_code = "// enough leading padding to push the range out of the top level\nexport value = 1 * 2\n";
804        let mul_start = imported_code.find("1 * 2").unwrap();
805        let mut source_files = IndexMap::new();
806        source_files.insert(
807            imported_module,
808            ModuleSource {
809                source: imported_code.to_owned(),
810                path: ModulePath::Local {
811                    value: "/project/derived.kcl".into(),
812                    original_import_path: None,
813                },
814            },
815        );
816        let issue_at = |source_range| CompilationIssue {
817            source_range,
818            message: "unknown units".to_owned(),
819            suggestion: None,
820            severity: Severity::Warning,
821            tag: Tag::UnknownNumericUnits,
822        };
823
824        // Imported ranges render the imported module's filename and source.
825        let report = render_compilation_issue_miette(
826            "/project/main.kcl",
827            "top",
828            &source_files,
829            issue_at(SourceRange::new(mul_start, mul_start + 5, imported_module)),
830        );
831        assert!(report.contains("derived.kcl"), "{report}");
832        assert!(report.contains("1 * 2"), "{report}");
833        assert!(!report.contains("OutOfBounds"), "{report}");
834
835        // Top-level ranges use the caller's filename and source, not the
836        // module path recorded in `source_files`.
837        let report = render_compilation_issue_miette(
838            "/project/main.kcl",
839            "top",
840            &source_files,
841            issue_at(SourceRange::new(0, 3, ModuleId::default())),
842        );
843        assert!(report.contains("main.kcl"), "{report}");
844
845        // Modules missing from the map fall back to the top-level pair, for
846        // callers that have no source map.
847        let report = render_compilation_issue_miette(
848            "/project/main.kcl",
849            "top",
850            &source_files,
851            issue_at(SourceRange::new(0, 3, ModuleId::from_usize(9))),
852        );
853        assert!(report.contains("main.kcl"), "{report}");
854    }
855}