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