gruel_compiler/
diagnostic.rs

1//! Diagnostic formatting for compiler errors and warnings.
2//!
3//! This module provides utilities for formatting compiler diagnostics into
4//! human-readable output using annotate-snippets for source annotations.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use gruel_compiler::{MultiFileFormatter, SourceInfo, FileId};
10//!
11//! let sources = vec![
12//!     (FileId::new(1), SourceInfo::new(&source, "example.gruel")),
13//! ];
14//! let formatter = MultiFileFormatter::new(sources);
15//!
16//! // Format an error
17//! let error_output = formatter.format_error(&error);
18//! eprintln!("{}", error_output);
19//!
20//! // Format warnings
21//! let warning_output = formatter.format_warnings(&warnings);
22//! eprintln!("{}", warning_output);
23//! ```
24
25use std::collections::HashMap;
26use std::io::IsTerminal;
27
28use annotate_snippets::{Level, Renderer, Snippet};
29
30use crate::{CompileError, CompileErrors, CompileWarning, Diagnostic, ErrorCode, FileId, Span};
31
32/// Spans grouped by file, with optional label text and severity level.
33type FileSpanMap<'a> = HashMap<FileId, Vec<(Span, Option<&'a str>, Level)>>;
34
35/// Source code information for diagnostic rendering.
36///
37/// Contains the source text and file path needed for rendering annotated
38/// error and warning messages.
39#[derive(Debug, Clone)]
40pub struct SourceInfo<'a> {
41    /// The source code text.
42    pub source: &'a str,
43    /// The path to the source file (for display in diagnostics).
44    pub path: &'a str,
45}
46
47impl<'a> SourceInfo<'a> {
48    /// Create a new SourceInfo with the given source and file path.
49    pub fn new(source: &'a str, path: &'a str) -> Self {
50        Self { source, path }
51    }
52}
53
54/// Color choice for diagnostic output.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum ColorChoice {
57    /// Automatically detect whether to use colors based on terminal capabilities.
58    #[default]
59    Auto,
60    /// Always use colors.
61    Always,
62    /// Never use colors.
63    Never,
64}
65
66// ============================================================================
67// Text Diagnostic Formatter
68// ============================================================================
69
70/// A diagnostic formatter that supports diagnostics spanning one or more source files.
71///
72/// Renders errors and warnings as human-readable text with annotated source
73/// snippets. For multi-file compilation, errors that reference multiple files
74/// will show each file's snippet separately.
75///
76/// # Example
77///
78/// ```ignore
79/// use gruel_compiler::{MultiFileFormatter, SourceInfo, FileId};
80///
81/// // Create source infos for each file
82/// let sources = vec![
83///     (FileId::new(1), SourceInfo::new(&source1, "main.gruel")),
84///     (FileId::new(2), SourceInfo::new(&source2, "utils.gruel")),
85/// ];
86///
87/// let formatter = MultiFileFormatter::new(sources);
88/// let error_output = formatter.format_error(&error);
89/// eprintln!("{}", error_output);
90/// ```
91pub struct MultiFileFormatter<'a> {
92    /// Mapping from FileId to source information.
93    sources: HashMap<FileId, SourceInfo<'a>>,
94    /// The renderer for formatting diagnostics.
95    renderer: Renderer,
96}
97
98impl<'a> MultiFileFormatter<'a> {
99    /// Create a new multi-file formatter from an iterator of (FileId, SourceInfo) pairs.
100    ///
101    /// By default, uses automatic color detection based on whether stderr is a terminal.
102    pub fn new(sources: impl IntoIterator<Item = (FileId, SourceInfo<'a>)>) -> Self {
103        Self::with_color_choice(sources, ColorChoice::Auto)
104    }
105
106    /// Create a new multi-file formatter with explicit color choice.
107    pub fn with_color_choice(
108        sources: impl IntoIterator<Item = (FileId, SourceInfo<'a>)>,
109        color_choice: ColorChoice,
110    ) -> Self {
111        let use_color = match color_choice {
112            ColorChoice::Auto => std::io::stderr().is_terminal(),
113            ColorChoice::Always => true,
114            ColorChoice::Never => false,
115        };
116        let renderer = if use_color {
117            Renderer::styled()
118        } else {
119            Renderer::plain()
120        };
121        Self {
122            sources: sources.into_iter().collect(),
123            renderer,
124        }
125    }
126
127    /// Get the source info for a file ID, if it exists.
128    fn get_source(&self, file_id: FileId) -> Option<&SourceInfo<'a>> {
129        self.sources.get(&file_id)
130    }
131
132    /// Get a fallback source info (the first one in the map).
133    ///
134    /// This is used when a span has no file ID (FileId::DEFAULT) and we need
135    /// to show something. Returns None if no sources are registered.
136    fn fallback_source(&self) -> Option<&SourceInfo<'a>> {
137        // Try FileId::DEFAULT first, then any source
138        self.sources
139            .get(&FileId::DEFAULT)
140            .or_else(|| self.sources.values().next())
141    }
142
143    /// Format a compilation error into a string.
144    ///
145    /// The error is formatted with its error code, e.g.:
146    /// `error[E0206]: type mismatch: expected i32, found bool`
147    ///
148    /// If the error or its labels reference multiple files, each file's
149    /// snippet is shown separately.
150    ///
151    /// Internal compiler errors (ICEs) receive special formatting with
152    /// bug report instructions.
153    pub fn format_error(&self, error: &CompileError) -> String {
154        let message_with_code = format!("[{}]: {}", error.kind.code(), error.kind);
155
156        // Check if this is an internal compiler error
157        let is_ice = matches!(
158            error.kind.code(),
159            ErrorCode::INTERNAL_ERROR | ErrorCode::INTERNAL_CODEGEN_ERROR
160        );
161
162        let mut output = self.format_diagnostic_impl(
163            Level::Error,
164            &message_with_code,
165            error.span(),
166            error.diagnostic(),
167        );
168
169        // Add ICE-specific notes and helps
170        if is_ice {
171            output.push_str("\nnote: this is a bug in the Gruel compiler\n");
172            output.push_str(
173                "help: please report this issue at https://github.com/ysimonson/gruel/issues\n",
174            );
175        }
176
177        output
178    }
179
180    /// Format multiple compilation errors into a string.
181    ///
182    /// Each error is formatted on its own line(s). A summary line is added at
183    /// the end if there are multiple errors showing the total count.
184    pub fn format_errors(&self, errors: &CompileErrors) -> String {
185        if errors.is_empty() {
186            return String::new();
187        }
188
189        let mut output = String::new();
190        for error in errors.iter() {
191            if !output.is_empty() {
192                output.push('\n');
193            }
194            output.push_str(&self.format_error(error));
195        }
196
197        if errors.len() > 1 {
198            output.push_str(&format!(
199                "\nerror: aborting due to {} previous errors\n",
200                errors.len()
201            ));
202        }
203
204        output
205    }
206
207    /// Format all warnings, adding line numbers when multiple variables share the same name.
208    pub fn format_warnings(&self, warnings: &[CompileWarning]) -> String {
209        if warnings.is_empty() {
210            return String::new();
211        }
212
213        // Count occurrences of each unused variable name
214        let mut var_name_counts: HashMap<&str, usize> = HashMap::new();
215        for warning in warnings {
216            if let Some(name) = warning.kind.unused_variable_name() {
217                *var_name_counts.entry(name).or_insert(0) += 1;
218            }
219        }
220
221        // Format each warning
222        let mut output = String::new();
223        for warning in warnings {
224            let needs_line_number = warning
225                .kind
226                .unused_variable_name()
227                .is_some_and(|name| var_name_counts.get(name).copied().unwrap_or(0) > 1);
228
229            if !output.is_empty() {
230                output.push('\n');
231            }
232            output.push_str(&self.format_warning_internal(warning, needs_line_number));
233        }
234        output
235    }
236
237    /// Format a single warning into a string.
238    pub fn format_warning(&self, warning: &CompileWarning) -> String {
239        self.format_warning_internal(warning, false)
240    }
241
242    fn format_warning_internal(
243        &self,
244        warning: &CompileWarning,
245        include_line_number: bool,
246    ) -> String {
247        // Get the message, optionally with line number for disambiguation
248        let message = if include_line_number {
249            if let Some(span) = warning.span() {
250                if let Some(source_info) = self
251                    .get_source(span.file_id)
252                    .or_else(|| self.fallback_source())
253                {
254                    let line = span.line_number(source_info.source);
255                    warning.kind.format_with_line(Some(line))
256                } else {
257                    warning.to_string()
258                }
259            } else {
260                warning.to_string()
261            }
262        } else {
263            warning.to_string()
264        };
265
266        self.format_diagnostic_impl(
267            Level::Warning,
268            &message,
269            warning.span(),
270            warning.diagnostic(),
271        )
272    }
273
274    /// Internal implementation for formatting diagnostics with multi-file support.
275    fn format_diagnostic_impl(
276        &self,
277        level: Level,
278        message: &str,
279        span: Option<Span>,
280        diagnostic: &Diagnostic,
281    ) -> String {
282        // For diagnostics without a span, just format the message with any footers
283        let Some(primary_span) = span else {
284            let mut report = level.title(message);
285            for note in &diagnostic.notes {
286                report = report.footer(Level::Note.title(note.0.as_str()));
287            }
288            for help in &diagnostic.helps {
289                report = report.footer(Level::Help.title(help.0.as_str()));
290            }
291            return format!("{}", self.renderer.render(report));
292        };
293
294        // Collect all file IDs we need to show
295        let mut file_spans = FileSpanMap::new();
296
297        // Add primary span
298        file_spans
299            .entry(primary_span.file_id)
300            .or_default()
301            .push((primary_span, None, level));
302
303        // Add secondary labels
304        for label in &diagnostic.labels {
305            file_spans.entry(label.span.file_id).or_default().push((
306                label.span,
307                Some(label.message.as_str()),
308                Level::Info,
309            ));
310        }
311
312        // Build report with snippets for each file
313        let mut report = level.title(message);
314
315        // Process files in a deterministic order (by FileId)
316        let mut file_ids: Vec<_> = file_spans.keys().copied().collect();
317        file_ids.sort_by_key(|id| id.0);
318
319        for file_id in file_ids {
320            let spans = &file_spans[&file_id];
321
322            // Get source info for this file
323            let source_info = self.get_source(file_id).or_else(|| self.fallback_source());
324
325            let Some(source_info) = source_info else {
326                // No source available for this file - skip it
327                continue;
328            };
329
330            // Build snippet with all annotations for this file
331            let mut snippet = Snippet::source(source_info.source)
332                .origin(source_info.path)
333                .fold(true);
334
335            let source_len = source_info.source.len();
336            for (span, label, span_level) in spans {
337                // Validate and clamp the span to prevent annotate-snippets panics
338                let start = (span.start as usize).min(source_len);
339                let end = (span.end as usize).min(source_len).max(start);
340
341                let annotation = span_level.span(start..end);
342                let annotation = if let Some(label_text) = label {
343                    annotation.label(label_text)
344                } else {
345                    annotation
346                };
347                snippet = snippet.annotation(annotation);
348            }
349
350            report = report.snippet(snippet);
351        }
352
353        // Add notes and helps as footers
354        for note in &diagnostic.notes {
355            report = report.footer(Level::Note.title(note.0.as_str()));
356        }
357        for help in &diagnostic.helps {
358            report = report.footer(Level::Help.title(help.0.as_str()));
359        }
360
361        format!("{}", self.renderer.render(report))
362    }
363}
364
365// ============================================================================
366// JSON Diagnostic Output
367// ============================================================================
368
369/// A diagnostic formatted for JSON output.
370///
371/// This structure is designed to be compatible with common editor protocols
372/// (LSP, cargo's JSON format) while containing all information needed for
373/// rich diagnostic display.
374#[derive(Debug)]
375pub struct JsonDiagnostic {
376    /// Error or warning code (e.g., "E0206").
377    pub code: String,
378    /// The diagnostic message.
379    pub message: String,
380    /// Severity level: "error" or "warning".
381    pub severity: &'static str,
382    /// Primary and secondary spans with labels.
383    pub spans: Vec<JsonSpan>,
384    /// Suggested fixes that can be applied.
385    pub suggestions: Vec<JsonSuggestion>,
386    /// Additional notes providing context.
387    pub notes: Vec<String>,
388    /// Additional help messages.
389    pub helps: Vec<String>,
390}
391
392/// A span in JSON format with file location and labels.
393#[derive(Debug)]
394pub struct JsonSpan {
395    /// Source file path.
396    pub file: String,
397    /// Start byte offset (0-indexed).
398    pub start: u32,
399    /// End byte offset (exclusive).
400    pub end: u32,
401    /// Line number (1-indexed).
402    pub line: u32,
403    /// Column number (1-indexed).
404    pub column: u32,
405    /// Label text for this span.
406    pub label: Option<String>,
407    /// Whether this is the primary span.
408    pub primary: bool,
409}
410
411/// A suggested fix in JSON format.
412#[derive(Debug)]
413pub struct JsonSuggestion {
414    /// Human-readable description.
415    pub message: String,
416    /// File containing the span.
417    pub file: String,
418    /// Start byte offset.
419    pub start: u32,
420    /// End byte offset.
421    pub end: u32,
422    /// Replacement text.
423    pub replacement: String,
424    /// Applicability level.
425    pub applicability: String,
426}
427
428impl JsonDiagnostic {
429    /// Serialize this diagnostic to a JSON string.
430    pub fn to_json(&self) -> String {
431        let mut obj = serde_json::Map::new();
432        obj.insert(
433            "code".to_string(),
434            serde_json::Value::String(self.code.clone()),
435        );
436        obj.insert(
437            "message".to_string(),
438            serde_json::Value::String(self.message.clone()),
439        );
440        obj.insert(
441            "severity".to_string(),
442            serde_json::Value::String(self.severity.to_string()),
443        );
444
445        // Spans
446        let spans: Vec<serde_json::Value> = self
447            .spans
448            .iter()
449            .map(|s| {
450                let mut span = serde_json::Map::new();
451                span.insert(
452                    "file".to_string(),
453                    serde_json::Value::String(s.file.clone()),
454                );
455                span.insert(
456                    "start".to_string(),
457                    serde_json::Value::Number(s.start.into()),
458                );
459                span.insert("end".to_string(), serde_json::Value::Number(s.end.into()));
460                span.insert("line".to_string(), serde_json::Value::Number(s.line.into()));
461                span.insert(
462                    "column".to_string(),
463                    serde_json::Value::Number(s.column.into()),
464                );
465                if let Some(label) = &s.label {
466                    span.insert(
467                        "label".to_string(),
468                        serde_json::Value::String(label.clone()),
469                    );
470                } else {
471                    span.insert("label".to_string(), serde_json::Value::Null);
472                }
473                span.insert("primary".to_string(), serde_json::Value::Bool(s.primary));
474                serde_json::Value::Object(span)
475            })
476            .collect();
477        obj.insert("spans".to_string(), serde_json::Value::Array(spans));
478
479        // Suggestions
480        let suggestions: Vec<serde_json::Value> = self
481            .suggestions
482            .iter()
483            .map(|s| {
484                let mut sugg = serde_json::Map::new();
485                sugg.insert(
486                    "message".to_string(),
487                    serde_json::Value::String(s.message.clone()),
488                );
489                sugg.insert(
490                    "file".to_string(),
491                    serde_json::Value::String(s.file.clone()),
492                );
493                sugg.insert(
494                    "start".to_string(),
495                    serde_json::Value::Number(s.start.into()),
496                );
497                sugg.insert("end".to_string(), serde_json::Value::Number(s.end.into()));
498                sugg.insert(
499                    "replacement".to_string(),
500                    serde_json::Value::String(s.replacement.clone()),
501                );
502                sugg.insert(
503                    "applicability".to_string(),
504                    serde_json::Value::String(s.applicability.clone()),
505                );
506                serde_json::Value::Object(sugg)
507            })
508            .collect();
509        obj.insert(
510            "suggestions".to_string(),
511            serde_json::Value::Array(suggestions),
512        );
513
514        // Notes and helps
515        let notes: Vec<serde_json::Value> = self
516            .notes
517            .iter()
518            .map(|n| serde_json::Value::String(n.clone()))
519            .collect();
520        obj.insert("notes".to_string(), serde_json::Value::Array(notes));
521
522        let helps: Vec<serde_json::Value> = self
523            .helps
524            .iter()
525            .map(|h| serde_json::Value::String(h.clone()))
526            .collect();
527        obj.insert("helps".to_string(), serde_json::Value::Array(helps));
528
529        serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
530    }
531}
532
533// ============================================================================
534// JSON Diagnostic Formatter
535// ============================================================================
536
537/// Formats diagnostics as JSON for machine consumption.
538///
539/// Maps FileIds to their source information, enabling correct file path and
540/// line/column output for diagnostics spanning one or more files.
541///
542/// # Example
543///
544/// ```ignore
545/// use gruel_compiler::{MultiFileJsonFormatter, SourceInfo, FileId};
546///
547/// let sources = vec![
548///     (FileId::new(1), SourceInfo::new(&source1, "main.gruel")),
549///     (FileId::new(2), SourceInfo::new(&source2, "utils.gruel")),
550/// ];
551///
552/// let formatter = MultiFileJsonFormatter::new(sources);
553/// let json = formatter.format_error(&error).to_json();
554/// ```
555pub struct MultiFileJsonFormatter<'a> {
556    /// Mapping from FileId to source information.
557    sources: HashMap<FileId, SourceInfo<'a>>,
558}
559
560impl<'a> MultiFileJsonFormatter<'a> {
561    /// Create a new multi-file JSON diagnostic formatter.
562    pub fn new(sources: impl IntoIterator<Item = (FileId, SourceInfo<'a>)>) -> Self {
563        Self {
564            sources: sources.into_iter().collect(),
565        }
566    }
567
568    /// Get the source info for a file ID, if it exists.
569    fn get_source(&self, file_id: FileId) -> Option<&SourceInfo<'a>> {
570        self.sources.get(&file_id)
571    }
572
573    /// Get a fallback source info (for FileId::DEFAULT or when no specific source is found).
574    fn fallback_source(&self) -> Option<&SourceInfo<'a>> {
575        self.sources
576            .get(&FileId::DEFAULT)
577            .or_else(|| self.sources.values().next())
578    }
579
580    /// Calculate line and column for a byte offset in a specific source.
581    fn offset_to_line_col(&self, source: &str, offset: u32) -> (u32, u32) {
582        let offset = offset as usize;
583        let mut line = 1u32;
584        let mut col = 1u32;
585        for (i, ch) in source.char_indices() {
586            if i >= offset {
587                break;
588            }
589            if ch == '\n' {
590                line += 1;
591                col = 1;
592            } else {
593                col += 1;
594            }
595        }
596        (line, col)
597    }
598
599    /// Get the path and source for a span, using the span's file ID.
600    fn source_for_span(&self, span: Span) -> Option<(&str, &str)> {
601        self.get_source(span.file_id)
602            .or_else(|| self.fallback_source())
603            .map(|info| (info.path, info.source))
604    }
605
606    /// Format a compile error as JSON.
607    pub fn format_error(&self, error: &CompileError) -> JsonDiagnostic {
608        let diag = error.diagnostic();
609
610        let primary_span = error.span().and_then(|span| {
611            self.source_for_span(span).map(|(path, source)| {
612                let (line, col) = self.offset_to_line_col(source, span.start);
613                JsonSpan {
614                    file: path.to_string(),
615                    start: span.start,
616                    end: span.end,
617                    line,
618                    column: col,
619                    label: None,
620                    primary: true,
621                }
622            })
623        });
624
625        let secondary_spans: Vec<JsonSpan> = diag
626            .labels
627            .iter()
628            .filter_map(|label| {
629                self.source_for_span(label.span).map(|(path, source)| {
630                    let (line, col) = self.offset_to_line_col(source, label.span.start);
631                    JsonSpan {
632                        file: path.to_string(),
633                        start: label.span.start,
634                        end: label.span.end,
635                        line,
636                        column: col,
637                        label: Some(label.message.clone()),
638                        primary: false,
639                    }
640                })
641            })
642            .collect();
643
644        let mut spans: Vec<JsonSpan> = primary_span.into_iter().collect();
645        spans.extend(secondary_spans);
646
647        let suggestions: Vec<JsonSuggestion> = diag
648            .suggestions
649            .iter()
650            .filter_map(|s| {
651                self.source_for_span(s.span)
652                    .map(|(path, _)| JsonSuggestion {
653                        message: s.message.clone(),
654                        file: path.to_string(),
655                        start: s.span.start,
656                        end: s.span.end,
657                        replacement: s.replacement.clone(),
658                        applicability: s.applicability.to_string(),
659                    })
660            })
661            .collect();
662
663        JsonDiagnostic {
664            code: format!("{}", error.kind.code()),
665            message: format!("{}", error.kind),
666            severity: "error",
667            spans,
668            suggestions,
669            notes: diag.notes.iter().map(|n| n.0.clone()).collect(),
670            helps: diag.helps.iter().map(|h| h.0.clone()).collect(),
671        }
672    }
673
674    /// Format a compile warning as JSON.
675    pub fn format_warning(&self, warning: &CompileWarning) -> JsonDiagnostic {
676        let diag = warning.diagnostic();
677
678        let primary_span = warning.span().and_then(|span| {
679            self.source_for_span(span).map(|(path, source)| {
680                let (line, col) = self.offset_to_line_col(source, span.start);
681                JsonSpan {
682                    file: path.to_string(),
683                    start: span.start,
684                    end: span.end,
685                    line,
686                    column: col,
687                    label: None,
688                    primary: true,
689                }
690            })
691        });
692
693        let secondary_spans: Vec<JsonSpan> = diag
694            .labels
695            .iter()
696            .filter_map(|label| {
697                self.source_for_span(label.span).map(|(path, source)| {
698                    let (line, col) = self.offset_to_line_col(source, label.span.start);
699                    JsonSpan {
700                        file: path.to_string(),
701                        start: label.span.start,
702                        end: label.span.end,
703                        line,
704                        column: col,
705                        label: Some(label.message.clone()),
706                        primary: false,
707                    }
708                })
709            })
710            .collect();
711
712        let mut spans: Vec<JsonSpan> = primary_span.into_iter().collect();
713        spans.extend(secondary_spans);
714
715        let suggestions: Vec<JsonSuggestion> = diag
716            .suggestions
717            .iter()
718            .filter_map(|s| {
719                self.source_for_span(s.span)
720                    .map(|(path, _)| JsonSuggestion {
721                        message: s.message.clone(),
722                        file: path.to_string(),
723                        start: s.span.start,
724                        end: s.span.end,
725                        replacement: s.replacement.clone(),
726                        applicability: s.applicability.to_string(),
727                    })
728            })
729            .collect();
730
731        JsonDiagnostic {
732            code: String::new(), // Warnings don't have codes yet
733            message: format!("{}", warning.kind),
734            severity: "warning",
735            spans,
736            suggestions,
737            notes: diag.notes.iter().map(|n| n.0.clone()).collect(),
738            helps: diag.helps.iter().map(|h| h.0.clone()).collect(),
739        }
740    }
741
742    /// Format multiple errors as a JSON array string.
743    pub fn format_errors(&self, errors: &CompileErrors) -> String {
744        let diagnostics: Vec<String> = errors
745            .iter()
746            .map(|e| self.format_error(e).to_json())
747            .collect();
748        format!("[{}]", diagnostics.join(","))
749    }
750
751    /// Format multiple warnings as a JSON array string.
752    pub fn format_warnings(&self, warnings: &[CompileWarning]) -> String {
753        let diagnostics: Vec<String> = warnings
754            .iter()
755            .map(|w| self.format_warning(w).to_json())
756            .collect();
757        format!("[{}]", diagnostics.join(","))
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::{ErrorKind, Suggestion, WarningKind};
765
766    #[test]
767    fn test_format_error_with_span() {
768        let source = "fn main() -> i32 { 1 + true }";
769        let source_info = SourceInfo::new(source, "test.gruel");
770        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
771
772        let error = CompileError::new(
773            ErrorKind::TypeMismatch {
774                expected: "i32".to_string(),
775                found: "bool".to_string(),
776            },
777            Span::new(23, 27),
778        );
779
780        let output = formatter.format_error(&error);
781        // Should include error code
782        assert!(output.contains("[E0206]"));
783        assert!(output.contains("type mismatch"));
784        assert!(output.contains("expected i32"));
785        assert!(output.contains("found bool"));
786        assert!(output.contains("test.gruel"));
787    }
788
789    #[test]
790    fn test_format_error_without_span() {
791        let source = "fn foo() -> i32 { 42 }";
792        let source_info = SourceInfo::new(source, "test.gruel");
793        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
794
795        let error = CompileError::without_span(ErrorKind::NoMainFunction);
796
797        let output = formatter.format_error(&error);
798        // Should include error code
799        assert!(output.contains("[E0200]"));
800        assert!(output.contains("no main function"));
801    }
802
803    #[test]
804    fn test_format_warning() {
805        let source = "fn main() -> i32 { let x = 42; 0 }";
806        let source_info = SourceInfo::new(source, "test.gruel");
807        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
808
809        let warning = CompileWarning::new(
810            WarningKind::UnusedVariable("x".to_string()),
811            Span::new(23, 24),
812        );
813
814        let output = formatter.format_warning(&warning);
815        assert!(output.contains("unused variable"));
816        assert!(output.contains("'x'"));
817    }
818
819    #[test]
820    fn test_format_warnings_with_duplicates() {
821        let source = "fn main() -> i32 {\n    let x = 1;\n    let x = 2;\n    0\n}";
822        let source_info = SourceInfo::new(source, "test.gruel");
823        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
824
825        let warnings = vec![
826            CompileWarning::new(
827                WarningKind::UnusedVariable("x".to_string()),
828                Span::new(23, 24),
829            ),
830            CompileWarning::new(
831                WarningKind::UnusedVariable("x".to_string()),
832                Span::new(36, 37),
833            ),
834        ];
835
836        let output = formatter.format_warnings(&warnings);
837        // Should include line numbers for disambiguation
838        assert!(output.contains("line"));
839    }
840
841    #[test]
842    fn test_format_warnings_empty() {
843        let source = "fn main() -> i32 { 42 }";
844        let source_info = SourceInfo::new(source, "test.gruel");
845        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
846
847        let output = formatter.format_warnings(&[]);
848        assert!(output.is_empty());
849    }
850
851    #[test]
852    fn test_format_error_with_help() {
853        let source = "fn main() -> i32 { x = 1; 0 }";
854        let source_info = SourceInfo::new(source, "test.gruel");
855        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
856
857        let error = CompileError::new(
858            ErrorKind::AssignToImmutable("x".to_string()),
859            Span::new(19, 20),
860        )
861        .with_help("consider making `x` mutable: `let mut x`");
862
863        let output = formatter.format_error(&error);
864        assert!(output.contains("help"));
865        assert!(output.contains("mutable"));
866    }
867
868    #[test]
869    fn test_format_error_with_label() {
870        let source = "fn main() -> i32 { if true { 1 } else { false } }";
871        let source_info = SourceInfo::new(source, "test.gruel");
872        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
873
874        let error = CompileError::new(
875            ErrorKind::TypeMismatch {
876                expected: "i32".to_string(),
877                found: "bool".to_string(),
878            },
879            Span::new(40, 45),
880        )
881        .with_label("then branch is here", Span::new(29, 30));
882
883        let output = formatter.format_error(&error);
884        assert!(output.contains("then branch"));
885    }
886
887    #[test]
888    fn test_format_errors_empty() {
889        let source = "fn main() -> i32 { 42 }";
890        let source_info = SourceInfo::new(source, "test.gruel");
891        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
892
893        let errors = CompileErrors::new();
894        let output = formatter.format_errors(&errors);
895        assert!(output.is_empty());
896    }
897
898    #[test]
899    fn test_format_errors_single() {
900        let source = "fn main() -> i32 { 1 + true }";
901        let source_info = SourceInfo::new(source, "test.gruel");
902        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
903
904        let mut errors = CompileErrors::new();
905        errors.push(CompileError::new(
906            ErrorKind::TypeMismatch {
907                expected: "i32".to_string(),
908                found: "bool".to_string(),
909            },
910            Span::new(23, 27),
911        ));
912
913        let output = formatter.format_errors(&errors);
914        assert!(output.contains("type mismatch"));
915        // Single error should not have summary line
916        assert!(!output.contains("aborting"));
917    }
918
919    #[test]
920    fn test_format_errors_multiple() {
921        let source = "fn main() -> i32 {\n    let x = 1 + true;\n    let y = false - 1;\n    0\n}";
922        let source_info = SourceInfo::new(source, "test.gruel");
923        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
924
925        let mut errors = CompileErrors::new();
926        errors.push(CompileError::new(
927            ErrorKind::TypeMismatch {
928                expected: "i32".to_string(),
929                found: "bool".to_string(),
930            },
931            Span::new(32, 36),
932        ));
933        errors.push(CompileError::new(
934            ErrorKind::TypeMismatch {
935                expected: "bool".to_string(),
936                found: "i32".to_string(),
937            },
938            Span::new(58, 59),
939        ));
940
941        let output = formatter.format_errors(&errors);
942        // Should contain both errors
943        assert!(output.contains("expected i32, found bool"));
944        assert!(output.contains("expected bool, found i32"));
945        // Should have summary line
946        assert!(output.contains("aborting due to 2 previous errors"));
947    }
948
949    #[test]
950    fn test_color_choice_never() {
951        let source = "fn main() -> i32 { 1 + true }";
952        let source_info = SourceInfo::new(source, "test.gruel");
953        let formatter = MultiFileFormatter::with_color_choice(
954            [(FileId::DEFAULT, source_info)],
955            ColorChoice::Never,
956        );
957
958        let error = CompileError::new(
959            ErrorKind::TypeMismatch {
960                expected: "i32".to_string(),
961                found: "bool".to_string(),
962            },
963            Span::new(23, 27),
964        );
965
966        let output = formatter.format_error(&error);
967        // Output should not contain ANSI escape codes
968        assert!(!output.contains("\x1b["));
969        assert!(output.contains("type mismatch"));
970    }
971
972    #[test]
973    fn test_format_error_with_invalid_span() {
974        let source = "fn main() -> i32 { 42 }";
975        let source_info = SourceInfo::new(source, "test.gruel");
976        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
977
978        // Span that extends beyond source length
979        let error = CompileError::new(
980            ErrorKind::TypeMismatch {
981                expected: "i32".to_string(),
982                found: "bool".to_string(),
983            },
984            Span::new(20, 1000), // end is way beyond source length
985        );
986
987        // Should not panic, should clamp to valid range
988        let output = formatter.format_error(&error);
989        assert!(output.contains("[E0206]"));
990        assert!(output.contains("type mismatch"));
991    }
992
993    #[test]
994    fn test_format_error_with_reversed_span() {
995        let source = "fn main() -> i32 { 42 }";
996        let source_info = SourceInfo::new(source, "test.gruel");
997        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
998
999        // Span with start > end (should be clamped so start == end)
1000        let error = CompileError::new(
1001            ErrorKind::TypeMismatch {
1002                expected: "i32".to_string(),
1003                found: "bool".to_string(),
1004            },
1005            Span::new(20, 10), // start > end
1006        );
1007
1008        // Should not panic
1009        let output = formatter.format_error(&error);
1010        assert!(output.contains("[E0206]"));
1011        assert!(output.contains("type mismatch"));
1012    }
1013
1014    #[test]
1015    fn test_color_choice_always() {
1016        let source = "fn main() -> i32 { 1 + true }";
1017        let source_info = SourceInfo::new(source, "test.gruel");
1018        let formatter = MultiFileFormatter::with_color_choice(
1019            [(FileId::DEFAULT, source_info)],
1020            ColorChoice::Always,
1021        );
1022
1023        let error = CompileError::new(
1024            ErrorKind::TypeMismatch {
1025                expected: "i32".to_string(),
1026                found: "bool".to_string(),
1027            },
1028            Span::new(23, 27),
1029        );
1030
1031        let output = formatter.format_error(&error);
1032        // Output should contain ANSI escape codes
1033        assert!(output.contains("\x1b["));
1034        assert!(output.contains("type mismatch"));
1035    }
1036
1037    #[test]
1038    fn test_ice_formatting() {
1039        let source = "fn main() -> i32 { 42 }";
1040        let source_info = SourceInfo::new(source, "test.gruel");
1041        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
1042
1043        let error = CompileError::without_span(ErrorKind::InternalError(
1044            "unexpected type in codegen".to_string(),
1045        ));
1046
1047        let output = formatter.format_error(&error);
1048        // Should contain ICE error code
1049        assert!(output.contains("[E9000]"));
1050        assert!(output.contains("internal compiler error"));
1051        // Should contain ICE-specific note and help
1052        assert!(output.contains("note: this is a bug in the Gruel compiler"));
1053        assert!(output.contains("help: please report this issue"));
1054        assert!(output.contains("github.com/ysimonson/gruel/issues"));
1055    }
1056
1057    #[test]
1058    fn test_ice_codegen_formatting() {
1059        let source = "fn main() -> i32 { 42 }";
1060        let source_info = SourceInfo::new(source, "test.gruel");
1061        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
1062
1063        let error = CompileError::without_span(ErrorKind::InternalCodegenError(
1064            "failed to emit instruction".to_string(),
1065        ));
1066
1067        let output = formatter.format_error(&error);
1068        // Should contain ICE codegen error code
1069        assert!(output.contains("[E9001]"));
1070        assert!(output.contains("internal codegen error"));
1071        // Should contain ICE-specific note and help
1072        assert!(output.contains("note: this is a bug in the Gruel compiler"));
1073        assert!(output.contains("help: please report this issue"));
1074    }
1075
1076    #[test]
1077    fn test_non_ice_no_extra_formatting() {
1078        let source = "fn main() -> i32 { 1 + true }";
1079        let source_info = SourceInfo::new(source, "test.gruel");
1080        let formatter = MultiFileFormatter::new([(FileId::DEFAULT, source_info)]);
1081
1082        let error = CompileError::new(
1083            ErrorKind::TypeMismatch {
1084                expected: "i32".to_string(),
1085                found: "bool".to_string(),
1086            },
1087            Span::new(23, 27),
1088        );
1089
1090        let output = formatter.format_error(&error);
1091        // Non-ICE errors should not have ICE-specific messages
1092        assert!(!output.contains("note: this is a bug in the Gruel compiler"));
1093        assert!(!output.contains("please report this issue"));
1094    }
1095
1096    // ========================================================================
1097    // JSON Formatting Tests
1098    // ========================================================================
1099
1100    #[test]
1101    fn test_json_format_error() {
1102        let source = "fn main() -> i32 { 1 + true }";
1103        let source_info = SourceInfo::new(source, "test.gruel");
1104        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1105
1106        let error = CompileError::new(
1107            ErrorKind::TypeMismatch {
1108                expected: "i32".to_string(),
1109                found: "bool".to_string(),
1110            },
1111            Span::new(23, 27),
1112        );
1113
1114        let json_diag = formatter.format_error(&error);
1115        assert_eq!(json_diag.severity, "error");
1116        assert_eq!(json_diag.code, "E0206");
1117        assert!(json_diag.message.contains("type mismatch"));
1118        assert_eq!(json_diag.spans.len(), 1);
1119        assert_eq!(json_diag.spans[0].file, "test.gruel");
1120        assert_eq!(json_diag.spans[0].start, 23);
1121        assert_eq!(json_diag.spans[0].end, 27);
1122        assert!(json_diag.spans[0].primary);
1123    }
1124
1125    #[test]
1126    fn test_json_format_error_line_col() {
1127        let source = "fn main() -> i32 {\n    1 + true\n}";
1128        //                            ^--- line 2, col 9 (0-indexed: 23)
1129        let source_info = SourceInfo::new(source, "test.gruel");
1130        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1131
1132        let error = CompileError::new(
1133            ErrorKind::TypeMismatch {
1134                expected: "i32".to_string(),
1135                found: "bool".to_string(),
1136            },
1137            Span::new(27, 31), // "true" on line 2
1138        );
1139
1140        let json_diag = formatter.format_error(&error);
1141        assert_eq!(json_diag.spans[0].line, 2);
1142        assert!(json_diag.spans[0].column > 1); // Column should be > 1 (indented)
1143    }
1144
1145    #[test]
1146    fn test_json_format_error_with_suggestion() {
1147        let source = "fn main() -> i32 { x = 1; 0 }";
1148        let source_info = SourceInfo::new(source, "test.gruel");
1149        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1150
1151        let error = CompileError::new(
1152            ErrorKind::AssignToImmutable("x".to_string()),
1153            Span::new(19, 20),
1154        )
1155        .with_suggestion(Suggestion::machine_applicable(
1156            "add mut",
1157            Span::new(4, 5),
1158            "mut x",
1159        ));
1160
1161        let json_diag = formatter.format_error(&error);
1162        assert_eq!(json_diag.suggestions.len(), 1);
1163        assert_eq!(json_diag.suggestions[0].message, "add mut");
1164        assert_eq!(json_diag.suggestions[0].replacement, "mut x");
1165        assert_eq!(json_diag.suggestions[0].applicability, "MachineApplicable");
1166    }
1167
1168    #[test]
1169    fn test_json_format_warning() {
1170        let source = "fn main() -> i32 { let x = 42; 0 }";
1171        let source_info = SourceInfo::new(source, "test.gruel");
1172        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1173
1174        let warning = CompileWarning::new(
1175            WarningKind::UnusedVariable("x".to_string()),
1176            Span::new(23, 24),
1177        );
1178
1179        let json_diag = formatter.format_warning(&warning);
1180        assert_eq!(json_diag.severity, "warning");
1181        assert!(json_diag.message.contains("unused variable"));
1182    }
1183
1184    #[test]
1185    fn test_json_to_string() {
1186        let source = "fn main() -> i32 { 1 + true }";
1187        let source_info = SourceInfo::new(source, "test.gruel");
1188        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1189
1190        let error = CompileError::new(
1191            ErrorKind::TypeMismatch {
1192                expected: "i32".to_string(),
1193                found: "bool".to_string(),
1194            },
1195            Span::new(23, 27),
1196        );
1197
1198        let json_diag = formatter.format_error(&error);
1199        let json_str = json_diag.to_json();
1200
1201        // Should be valid JSON
1202        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1203        assert_eq!(parsed["severity"], "error");
1204        assert_eq!(parsed["code"], "E0206");
1205        assert!(parsed["spans"].is_array());
1206        assert_eq!(parsed["spans"][0]["primary"], true);
1207    }
1208
1209    #[test]
1210    fn test_json_format_errors_array() {
1211        let source = "fn main() -> i32 {\n    1 + true\n}";
1212        let source_info = SourceInfo::new(source, "test.gruel");
1213        let formatter = MultiFileJsonFormatter::new([(FileId::DEFAULT, source_info)]);
1214
1215        let mut errors = CompileErrors::new();
1216        errors.push(CompileError::new(
1217            ErrorKind::TypeMismatch {
1218                expected: "i32".to_string(),
1219                found: "bool".to_string(),
1220            },
1221            Span::new(27, 31),
1222        ));
1223
1224        let json_str = formatter.format_errors(&errors);
1225        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1226        assert!(parsed.is_array());
1227        assert_eq!(parsed.as_array().unwrap().len(), 1);
1228    }
1229
1230    // ========================================================================
1231    // MultiFileFormatter tests
1232    // ========================================================================
1233
1234    #[test]
1235    fn test_multi_file_formatter_single_file() {
1236        let source = "fn main() -> i32 { 1 + true }";
1237        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1238        let formatter = MultiFileFormatter::new(sources);
1239
1240        let error = CompileError::new(
1241            ErrorKind::TypeMismatch {
1242                expected: "i32".to_string(),
1243                found: "bool".to_string(),
1244            },
1245            Span::with_file(FileId::new(1), 23, 27),
1246        );
1247
1248        let output = formatter.format_error(&error);
1249        assert!(output.contains("[E0206]"));
1250        assert!(output.contains("type mismatch"));
1251        assert!(output.contains("test.gruel"));
1252    }
1253
1254    #[test]
1255    fn test_multi_file_formatter_multiple_files() {
1256        let source1 = "fn main() -> i32 { helper() }";
1257        let source2 = "fn helper() -> bool { true }";
1258        let sources = vec![
1259            (FileId::new(1), SourceInfo::new(source1, "main.gruel")),
1260            (FileId::new(2), SourceInfo::new(source2, "helper.gruel")),
1261        ];
1262        let formatter = MultiFileFormatter::new(sources);
1263
1264        // Error in file 1 with label pointing to file 2
1265        let error = CompileError::new(
1266            ErrorKind::TypeMismatch {
1267                expected: "i32".to_string(),
1268                found: "bool".to_string(),
1269            },
1270            Span::with_file(FileId::new(1), 19, 27),
1271        )
1272        .with_label(
1273            "function returns bool here",
1274            Span::with_file(FileId::new(2), 15, 19),
1275        );
1276
1277        let output = formatter.format_error(&error);
1278        // Should contain both file names
1279        assert!(output.contains("main.gruel"));
1280        assert!(output.contains("helper.gruel"));
1281        assert!(output.contains("function returns bool here"));
1282    }
1283
1284    #[test]
1285    fn test_multi_file_formatter_without_span() {
1286        let source = "fn foo() -> i32 { 42 }";
1287        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1288        let formatter = MultiFileFormatter::new(sources);
1289
1290        let error = CompileError::without_span(ErrorKind::NoMainFunction);
1291
1292        let output = formatter.format_error(&error);
1293        assert!(output.contains("[E0200]"));
1294        assert!(output.contains("no main function"));
1295    }
1296
1297    #[test]
1298    fn test_multi_file_formatter_format_errors() {
1299        let source = "fn main() -> i32 { 1 + true }";
1300        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1301        let formatter = MultiFileFormatter::new(sources);
1302
1303        let mut errors = CompileErrors::new();
1304        errors.push(CompileError::new(
1305            ErrorKind::TypeMismatch {
1306                expected: "i32".to_string(),
1307                found: "bool".to_string(),
1308            },
1309            Span::with_file(FileId::new(1), 23, 27),
1310        ));
1311        errors.push(CompileError::without_span(ErrorKind::NoMainFunction));
1312
1313        let output = formatter.format_errors(&errors);
1314        assert!(output.contains("type mismatch"));
1315        assert!(output.contains("no main function"));
1316        assert!(output.contains("aborting due to 2 previous errors"));
1317    }
1318
1319    #[test]
1320    fn test_multi_file_formatter_format_warnings() {
1321        let source = "fn main() -> i32 { let x = 42; 0 }";
1322        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1323        let formatter = MultiFileFormatter::new(sources);
1324
1325        let warnings = vec![CompileWarning::new(
1326            WarningKind::UnusedVariable("x".to_string()),
1327            Span::with_file(FileId::new(1), 23, 24),
1328        )];
1329
1330        let output = formatter.format_warnings(&warnings);
1331        assert!(output.contains("unused variable"));
1332        assert!(output.contains("'x'"));
1333    }
1334
1335    #[test]
1336    fn test_multi_file_formatter_color_choice() {
1337        let source = "fn main() -> i32 { 1 + true }";
1338        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1339        let formatter = MultiFileFormatter::with_color_choice(sources, ColorChoice::Never);
1340
1341        let error = CompileError::new(
1342            ErrorKind::TypeMismatch {
1343                expected: "i32".to_string(),
1344                found: "bool".to_string(),
1345            },
1346            Span::with_file(FileId::new(1), 23, 27),
1347        );
1348
1349        let output = formatter.format_error(&error);
1350        // Output should not contain ANSI escape codes
1351        assert!(!output.contains("\x1b["));
1352    }
1353
1354    #[test]
1355    fn test_multi_file_formatter_invalid_span() {
1356        let source = "fn main() -> i32 { 42 }";
1357        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1358        let formatter = MultiFileFormatter::new(sources);
1359
1360        // Span that extends beyond source length
1361        let error = CompileError::new(
1362            ErrorKind::TypeMismatch {
1363                expected: "i32".to_string(),
1364                found: "bool".to_string(),
1365            },
1366            Span::with_file(FileId::new(1), 20, 1000),
1367        );
1368
1369        // Should not panic, should clamp to valid range
1370        let output = formatter.format_error(&error);
1371        assert!(output.contains("[E0206]"));
1372        assert!(output.contains("type mismatch"));
1373    }
1374
1375    #[test]
1376    fn test_multi_file_formatter_reversed_span() {
1377        let source = "fn main() -> i32 { 42 }";
1378        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1379        let formatter = MultiFileFormatter::new(sources);
1380
1381        // Span with start > end
1382        let error = CompileError::new(
1383            ErrorKind::TypeMismatch {
1384                expected: "i32".to_string(),
1385                found: "bool".to_string(),
1386            },
1387            Span::with_file(FileId::new(1), 20, 10),
1388        );
1389
1390        // Should not panic
1391        let output = formatter.format_error(&error);
1392        assert!(output.contains("[E0206]"));
1393        assert!(output.contains("type mismatch"));
1394    }
1395
1396    // ========================================================================
1397    // MultiFileJsonFormatter tests
1398    // ========================================================================
1399
1400    #[test]
1401    fn test_multi_file_json_formatter_single_file() {
1402        let source = "fn main() -> i32 { 1 + true }";
1403        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1404        let formatter = MultiFileJsonFormatter::new(sources);
1405
1406        let error = CompileError::new(
1407            ErrorKind::TypeMismatch {
1408                expected: "i32".to_string(),
1409                found: "bool".to_string(),
1410            },
1411            Span::with_file(FileId::new(1), 23, 27),
1412        );
1413
1414        let json_diag = formatter.format_error(&error);
1415        assert_eq!(json_diag.severity, "error");
1416        assert_eq!(json_diag.code, "E0206");
1417        assert_eq!(json_diag.spans.len(), 1);
1418        assert_eq!(json_diag.spans[0].file, "test.gruel");
1419        assert!(json_diag.spans[0].primary);
1420    }
1421
1422    #[test]
1423    fn test_multi_file_json_formatter_multiple_files() {
1424        let source1 = "fn main() -> i32 { helper() }";
1425        let source2 = "fn helper() -> bool { true }";
1426        let sources = vec![
1427            (FileId::new(1), SourceInfo::new(source1, "main.gruel")),
1428            (FileId::new(2), SourceInfo::new(source2, "helper.gruel")),
1429        ];
1430        let formatter = MultiFileJsonFormatter::new(sources);
1431
1432        // Error in file 1 with label pointing to file 2
1433        let error = CompileError::new(
1434            ErrorKind::TypeMismatch {
1435                expected: "i32".to_string(),
1436                found: "bool".to_string(),
1437            },
1438            Span::with_file(FileId::new(1), 19, 27),
1439        )
1440        .with_label(
1441            "function returns bool here",
1442            Span::with_file(FileId::new(2), 15, 19),
1443        );
1444
1445        let json_diag = formatter.format_error(&error);
1446        assert_eq!(json_diag.spans.len(), 2);
1447
1448        // Primary span should be from main.gruel
1449        let primary = json_diag.spans.iter().find(|s| s.primary).unwrap();
1450        assert_eq!(primary.file, "main.gruel");
1451
1452        // Secondary span should be from helper.gruel
1453        let secondary = json_diag.spans.iter().find(|s| !s.primary).unwrap();
1454        assert_eq!(secondary.file, "helper.gruel");
1455        assert_eq!(
1456            secondary.label,
1457            Some("function returns bool here".to_string())
1458        );
1459    }
1460
1461    #[test]
1462    fn test_multi_file_json_formatter_format_errors() {
1463        let source = "fn main() -> i32 { 1 + true }";
1464        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1465        let formatter = MultiFileJsonFormatter::new(sources);
1466
1467        let mut errors = CompileErrors::new();
1468        errors.push(CompileError::new(
1469            ErrorKind::TypeMismatch {
1470                expected: "i32".to_string(),
1471                found: "bool".to_string(),
1472            },
1473            Span::with_file(FileId::new(1), 23, 27),
1474        ));
1475
1476        let json_str = formatter.format_errors(&errors);
1477        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1478        assert!(parsed.is_array());
1479        assert_eq!(parsed.as_array().unwrap().len(), 1);
1480    }
1481
1482    #[test]
1483    fn test_multi_file_json_formatter_format_warning() {
1484        let source = "fn main() -> i32 { let x = 42; 0 }";
1485        let sources = vec![(FileId::new(1), SourceInfo::new(source, "test.gruel"))];
1486        let formatter = MultiFileJsonFormatter::new(sources);
1487
1488        let warning = CompileWarning::new(
1489            WarningKind::UnusedVariable("x".to_string()),
1490            Span::with_file(FileId::new(1), 23, 24),
1491        );
1492
1493        let json_diag = formatter.format_warning(&warning);
1494        assert_eq!(json_diag.severity, "warning");
1495        assert!(json_diag.message.contains("unused variable"));
1496        assert_eq!(json_diag.spans.len(), 1);
1497        assert_eq!(json_diag.spans[0].file, "test.gruel");
1498    }
1499}