Skip to main content

kcl_lib/
errors.rs

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