Skip to main content

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