sigil_parser/
diagnostic.rs

1//! Rich diagnostic reporting for Sigil.
2//!
3//! Provides Rust-quality error messages with:
4//! - Colored output with source context
5//! - "Did you mean?" suggestions
6//! - Fix suggestions
7//! - Multi-span support for related information
8//! - JSON output for AI agent consumption
9
10use ariadne::{Color, ColorGenerator, Config, Fmt, Label, Report, ReportKind, Source};
11use serde::{Deserialize, Serialize};
12use std::ops::Range;
13use strsim::jaro_winkler;
14
15use crate::lexer::Token;
16use crate::span::Span;
17
18/// Severity level for diagnostics.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Severity {
22    Error,
23    Warning,
24    Info,
25    Hint,
26}
27
28impl Severity {
29    fn to_report_kind(self) -> ReportKind<'static> {
30        match self {
31            Severity::Error => ReportKind::Error,
32            Severity::Warning => ReportKind::Warning,
33            Severity::Info => ReportKind::Advice,
34            Severity::Hint => ReportKind::Advice,
35        }
36    }
37
38    fn color(self) -> Color {
39        match self {
40            Severity::Error => Color::Red,
41            Severity::Warning => Color::Yellow,
42            Severity::Info => Color::Blue,
43            Severity::Hint => Color::Cyan,
44        }
45    }
46}
47
48/// A fix suggestion that can be applied automatically.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct FixSuggestion {
51    pub message: String,
52    pub span: Span,
53    pub replacement: String,
54}
55
56/// A related location with additional context.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RelatedInfo {
59    pub message: String,
60    pub span: Span,
61}
62
63/// A label at a specific location.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DiagnosticLabel {
66    pub span: Span,
67    pub message: String,
68}
69
70/// A rich diagnostic with all context needed for beautiful error reporting.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Diagnostic {
73    pub severity: Severity,
74    pub code: Option<String>,
75    pub message: String,
76    pub span: Span,
77    #[serde(skip)]
78    pub labels: Vec<(Span, String)>,
79    pub notes: Vec<String>,
80    pub suggestions: Vec<FixSuggestion>,
81    pub related: Vec<RelatedInfo>,
82}
83
84/// JSON-serializable diagnostic output for AI agents.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct JsonDiagnostic {
87    pub severity: Severity,
88    pub code: Option<String>,
89    pub message: String,
90    pub file: String,
91    pub span: Span,
92    pub line: u32,
93    pub column: u32,
94    pub end_line: u32,
95    pub end_column: u32,
96    pub labels: Vec<DiagnosticLabel>,
97    pub notes: Vec<String>,
98    pub suggestions: Vec<FixSuggestion>,
99    pub related: Vec<RelatedInfo>,
100}
101
102/// JSON output wrapper for multiple diagnostics.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct JsonDiagnosticsOutput {
105    pub file: String,
106    pub diagnostics: Vec<JsonDiagnostic>,
107    pub error_count: usize,
108    pub warning_count: usize,
109    pub success: bool,
110}
111
112impl Diagnostic {
113    /// Create a new error diagnostic.
114    pub fn error(message: impl Into<String>, span: Span) -> Self {
115        Self {
116            severity: Severity::Error,
117            code: None,
118            message: message.into(),
119            span,
120            labels: Vec::new(),
121            notes: Vec::new(),
122            suggestions: Vec::new(),
123            related: Vec::new(),
124        }
125    }
126
127    /// Create a new warning diagnostic.
128    pub fn warning(message: impl Into<String>, span: Span) -> Self {
129        Self {
130            severity: Severity::Warning,
131            code: None,
132            message: message.into(),
133            span,
134            labels: Vec::new(),
135            notes: Vec::new(),
136            suggestions: Vec::new(),
137            related: Vec::new(),
138        }
139    }
140
141    /// Add an error code (e.g., "E0001").
142    pub fn with_code(mut self, code: impl Into<String>) -> Self {
143        self.code = Some(code.into());
144        self
145    }
146
147    /// Add a label at a specific span.
148    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
149        self.labels.push((span, message.into()));
150        self
151    }
152
153    /// Add a note (shown at the bottom).
154    pub fn with_note(mut self, note: impl Into<String>) -> Self {
155        self.notes.push(note.into());
156        self
157    }
158
159    /// Add a fix suggestion.
160    pub fn with_suggestion(
161        mut self,
162        message: impl Into<String>,
163        span: Span,
164        replacement: impl Into<String>,
165    ) -> Self {
166        self.suggestions.push(FixSuggestion {
167            message: message.into(),
168            span,
169            replacement: replacement.into(),
170        });
171        self
172    }
173
174    /// Add related information.
175    pub fn with_related(mut self, message: impl Into<String>, span: Span) -> Self {
176        self.related.push(RelatedInfo {
177            message: message.into(),
178            span,
179        });
180        self
181    }
182
183    /// Render this diagnostic to a string with colors.
184    pub fn render(&self, filename: &str, source: &str) -> String {
185        let mut output = Vec::new();
186        self.write_to(&mut output, filename, source);
187        String::from_utf8(output).unwrap_or_else(|_| self.message.clone())
188    }
189
190    /// Write this diagnostic to a writer.
191    pub fn write_to<W: std::io::Write>(&self, writer: W, filename: &str, source: &str) {
192        let span_range: Range<usize> = self.span.start..self.span.end;
193
194        let mut colors = ColorGenerator::new();
195        let primary_color = self.severity.color();
196
197        let mut builder = Report::build(self.severity.to_report_kind(), filename, self.span.start)
198            .with_config(Config::default().with_cross_gap(true))
199            .with_message(&self.message);
200
201        // Add error code if present
202        if let Some(ref code) = self.code {
203            builder = builder.with_code(code);
204        }
205
206        // Primary label
207        builder = builder.with_label(
208            Label::new((filename, span_range.clone()))
209                .with_message(&self.message)
210                .with_color(primary_color),
211        );
212
213        // Additional labels
214        for (span, msg) in &self.labels {
215            let color = colors.next();
216            builder = builder.with_label(
217                Label::new((filename, span.start..span.end))
218                    .with_message(msg)
219                    .with_color(color),
220            );
221        }
222
223        // Notes
224        for note in &self.notes {
225            builder = builder.with_note(note);
226        }
227
228        // Suggestions as notes
229        for suggestion in &self.suggestions {
230            let help_msg = format!(
231                "help: {}: `{}`",
232                suggestion.message,
233                suggestion.replacement.clone().fg(Color::Green)
234            );
235            builder = builder.with_help(help_msg);
236        }
237
238        builder
239            .finish()
240            .write((filename, Source::from(source)), writer)
241            .unwrap();
242    }
243
244    /// Print this diagnostic to stderr.
245    pub fn eprint(&self, filename: &str, source: &str) {
246        self.write_to(std::io::stderr(), filename, source);
247    }
248
249    /// Convert to JSON-serializable format with computed line/column positions.
250    pub fn to_json(&self, filename: &str, source: &str) -> JsonDiagnostic {
251        let (line, column) = offset_to_line_col(source, self.span.start);
252        let (end_line, end_column) = offset_to_line_col(source, self.span.end);
253
254        JsonDiagnostic {
255            severity: self.severity,
256            code: self.code.clone(),
257            message: self.message.clone(),
258            file: filename.to_string(),
259            span: self.span,
260            line,
261            column,
262            end_line,
263            end_column,
264            labels: self
265                .labels
266                .iter()
267                .map(|(span, msg)| DiagnosticLabel {
268                    span: *span,
269                    message: msg.clone(),
270                })
271                .collect(),
272            notes: self.notes.clone(),
273            suggestions: self.suggestions.clone(),
274            related: self.related.clone(),
275        }
276    }
277}
278
279/// Convert byte offset to line/column (1-indexed).
280fn offset_to_line_col(source: &str, offset: usize) -> (u32, u32) {
281    let mut line = 1u32;
282    let mut col = 1u32;
283
284    for (i, ch) in source.char_indices() {
285        if i >= offset {
286            break;
287        }
288        if ch == '\n' {
289            line += 1;
290            col = 1;
291        } else {
292            col += 1;
293        }
294    }
295
296    (line, col)
297}
298
299/// Diagnostic builder for common error patterns.
300pub struct DiagnosticBuilder;
301
302impl DiagnosticBuilder {
303    /// Create an "unexpected token" error with suggestions.
304    pub fn unexpected_token(expected: &str, found: &Token, span: Span, source: &str) -> Diagnostic {
305        let found_str = format!("{:?}", found);
306        let message = format!("expected {}, found {}", expected, found_str);
307
308        let mut diag = Diagnostic::error(message, span).with_code("E0001");
309
310        // Add context about what was expected
311        diag = diag.with_label(span, format!("expected {} here", expected));
312
313        // Add suggestions for common mistakes
314        if let Some(suggestion) = Self::suggest_token_fix(expected, found, source, span) {
315            diag = diag.with_suggestion(suggestion.0, span, suggestion.1);
316        }
317
318        diag
319    }
320
321    /// Create an "undefined variable" error with "did you mean?" suggestions.
322    pub fn undefined_variable(name: &str, span: Span, known_names: &[&str]) -> Diagnostic {
323        let message = format!("cannot find value `{}` in this scope", name);
324        let mut diag = Diagnostic::error(message, span)
325            .with_code("E0425")
326            .with_label(span, "not found in this scope");
327
328        // Find similar names
329        if let Some(suggestion) = Self::find_similar(name, known_names) {
330            diag = diag.with_suggestion(
331                format!("a local variable with a similar name exists"),
332                span,
333                suggestion.to_string(),
334            );
335        }
336
337        diag
338    }
339
340    /// Create a "type mismatch" error.
341    pub fn type_mismatch(
342        expected: &str,
343        found: &str,
344        span: Span,
345        expected_span: Option<Span>,
346    ) -> Diagnostic {
347        let message = format!(
348            "mismatched types: expected `{}`, found `{}`",
349            expected, found
350        );
351        let mut diag = Diagnostic::error(message, span)
352            .with_code("E0308")
353            .with_label(span, format!("expected `{}`", expected));
354
355        if let Some(exp_span) = expected_span {
356            diag = diag.with_related("expected due to this", exp_span);
357        }
358
359        diag
360    }
361
362    /// Create an "evidentiality mismatch" error.
363    pub fn evidentiality_mismatch(expected: &str, found: &str, span: Span) -> Diagnostic {
364        let message = format!(
365            "evidentiality mismatch: expected `{}`, found `{}`",
366            expected, found
367        );
368
369        Diagnostic::error(message, span)
370            .with_code("E0600")
371            .with_label(span, format!("has evidentiality `{}`", found))
372            .with_note(format!(
373                "values with `{}` evidentiality cannot be used where `{}` is required",
374                found, expected
375            ))
376            .with_note(Self::evidentiality_help(expected, found))
377    }
378
379    /// Create an "untrusted data" error for evidentiality violations.
380    pub fn untrusted_data_used(span: Span, source_span: Option<Span>) -> Diagnostic {
381        let mut diag = Diagnostic::error("cannot use reported (~) data without validation", span)
382            .with_code("E0601")
383            .with_label(span, "untrusted data used here")
384            .with_note("data from external sources must be validated before use")
385            .with_suggestion("validate the data first", span, "value|validate!{...}");
386
387        if let Some(src) = source_span {
388            diag = diag.with_related("data originates from external source here", src);
389        }
390
391        diag
392    }
393
394    /// Create a "missing morpheme" error with ASCII alternatives.
395    pub fn unknown_morpheme(found: &str, span: Span) -> Diagnostic {
396        let message = format!("unknown morpheme `{}`", found);
397        let mut diag = Diagnostic::error(message, span).with_code("E0100");
398
399        // Suggest similar morphemes
400        let morphemes = [
401            ("τ", "tau", "transform/map"),
402            ("φ", "phi", "filter"),
403            ("σ", "sigma", "sort"),
404            ("ρ", "rho", "reduce"),
405            ("λ", "lambda", "anonymous function"),
406            ("Σ", "sum", "sum all"),
407            ("Π", "pi", "product"),
408            ("α", "alpha", "first element"),
409            ("ω", "omega", "last element"),
410            ("μ", "mu", "middle element"),
411            ("χ", "chi", "random choice"),
412            ("ν", "nu", "nth element"),
413            ("ξ", "xi", "next in sequence"),
414        ];
415
416        if let Some((greek, _ascii, desc)) = morphemes
417            .iter()
418            .find(|(g, a, _)| jaro_winkler(found, g) > 0.8 || jaro_winkler(found, a) > 0.8)
419        {
420            diag = diag.with_suggestion(
421                format!("did you mean the {} morpheme?", desc),
422                span,
423                greek.to_string(),
424            );
425        }
426
427        diag = diag.with_note(
428            "transform morphemes: τ (map), φ (filter), σ (sort), ρ (reduce), Σ (sum), Π (product)",
429        );
430        diag = diag.with_note(
431            "access morphemes: α (first), ω (last), μ (middle), χ (choice), ν (nth), ξ (next)",
432        );
433
434        diag
435    }
436
437    /// Suggest a Unicode symbol for an ASCII operator or name.
438    /// Returns (unicode_symbol, description) if a suggestion exists.
439    pub fn suggest_unicode_symbol(ascii: &str) -> Option<(&'static str, &'static str)> {
440        match ascii {
441            // Logic operators
442            "&&" => Some(("∧", "logical AND")),
443            "||" => Some(("∨", "logical OR")),
444            "^^" => Some(("⊻", "logical XOR")),
445
446            // Bitwise operators
447            "&" => Some(("⋏", "bitwise AND")),
448            "|" => Some(("⋎", "bitwise OR")),
449
450            // Set operations
451            "union" => Some(("∪", "set union")),
452            "intersect" | "intersection" => Some(("∩", "set intersection")),
453            "subset" => Some(("⊂", "proper subset")),
454            "superset" => Some(("⊃", "proper superset")),
455            "in" | "element_of" => Some(("∈", "element of")),
456            "not_in" => Some(("∉", "not element of")),
457
458            // Math symbols
459            "sqrt" => Some(("√", "square root")),
460            "cbrt" => Some(("∛", "cube root")),
461            "infinity" | "inf" => Some(("∞", "infinity")),
462            "pi" => Some(("π", "pi constant")),
463            "sum" => Some(("Σ", "summation")),
464            "product" => Some(("Π", "product")),
465            "integral" => Some(("∫", "integral/cumulative sum")),
466            "partial" | "derivative" => Some(("∂", "partial/derivative")),
467
468            // Morphemes
469            "tau" | "map" | "transform" => Some(("τ", "transform morpheme")),
470            "phi" | "filter" => Some(("φ", "filter morpheme")),
471            "sigma" | "sort" => Some(("σ", "sort morpheme")),
472            "rho" | "reduce" | "fold" => Some(("ρ", "reduce morpheme")),
473            "lambda" => Some(("λ", "lambda")),
474            "alpha" | "first" => Some(("α", "first element")),
475            "omega" | "last" => Some(("ω", "last element")),
476            "mu" | "middle" | "median" => Some(("μ", "middle element")),
477            "chi" | "choice" | "random" => Some(("χ", "random choice")),
478            "nu" | "nth" => Some(("ν", "nth element")),
479            "xi" | "next" => Some(("ξ", "next in sequence")),
480            "delta" | "diff" | "change" => Some(("δ", "delta/change")),
481            "epsilon" | "empty" => Some(("ε", "epsilon/empty")),
482            "zeta" | "zip" => Some(("ζ", "zeta/zip")),
483
484            // Category theory
485            "compose" => Some(("∘", "function composition")),
486            "tensor" => Some(("⊗", "tensor product")),
487            "direct_sum" | "xor" => Some(("⊕", "direct sum/XOR")),
488
489            // Special values
490            "null" | "void" | "nothing" => Some(("∅", "empty set")),
491            "true" | "top" | "any" => Some(("⊤", "top/true")),
492            "false" | "bottom" | "never" => Some(("⊥", "bottom/false")),
493
494            // Quantifiers
495            "forall" | "for_all" => Some(("∀", "universal quantifier")),
496            "exists" => Some(("∃", "existential quantifier")),
497
498            // Data operations
499            "join" | "zip_with" => Some(("⋈", "join/zip with")),
500            "flatten" => Some(("⋳", "flatten")),
501            "max" | "supremum" => Some(("⊔", "supremum/max")),
502            "min" | "infimum" => Some(("⊓", "infimum/min")),
503
504            _ => None,
505        }
506    }
507
508    /// Create a hint diagnostic suggesting a Unicode symbol.
509    pub fn suggest_symbol_upgrade(ascii: &str, span: Span) -> Option<Diagnostic> {
510        Self::suggest_unicode_symbol(ascii).map(|(unicode, desc)| {
511            Diagnostic::warning(
512                format!("consider using Unicode symbol `{}` for {}", unicode, desc),
513                span,
514            )
515            .with_code("W0200")
516            .with_suggestion(
517                format!("use `{}` for clearer, more idiomatic Sigil", unicode),
518                span,
519                unicode.to_string(),
520            )
521            .with_note(format!(
522                "Sigil supports Unicode symbols. `{}` → `{}` ({})",
523                ascii, unicode, desc
524            ))
525        })
526    }
527
528    /// Get all available symbol mappings for documentation/completion.
529    pub fn all_symbol_mappings() -> Vec<(&'static str, &'static str, &'static str)> {
530        vec![
531            // (ascii, unicode, description)
532            ("&&", "∧", "logical AND"),
533            ("||", "∨", "logical OR"),
534            ("^^", "⊻", "logical XOR"),
535            ("&", "⋏", "bitwise AND"),
536            ("|", "⋎", "bitwise OR"),
537            ("union", "∪", "set union"),
538            ("intersect", "∩", "set intersection"),
539            ("subset", "⊂", "proper subset"),
540            ("superset", "⊃", "proper superset"),
541            ("in", "∈", "element of"),
542            ("not_in", "∉", "not element of"),
543            ("sqrt", "√", "square root"),
544            ("cbrt", "∛", "cube root"),
545            ("infinity", "∞", "infinity"),
546            ("tau", "τ", "transform"),
547            ("phi", "φ", "filter"),
548            ("sigma", "σ", "sort"),
549            ("rho", "ρ", "reduce"),
550            ("lambda", "λ", "lambda"),
551            ("alpha", "α", "first"),
552            ("omega", "ω", "last"),
553            ("mu", "μ", "middle"),
554            ("chi", "χ", "choice"),
555            ("nu", "ν", "nth"),
556            ("xi", "ξ", "next"),
557            ("sum", "Σ", "sum"),
558            ("product", "Π", "product"),
559            ("compose", "∘", "compose"),
560            ("tensor", "⊗", "tensor"),
561            ("xor", "⊕", "direct sum"),
562            ("forall", "∀", "for all"),
563            ("exists", "∃", "exists"),
564            ("null", "∅", "empty"),
565            ("true", "⊤", "top/true"),
566            ("false", "⊥", "bottom/false"),
567        ]
568    }
569
570    /// Find a similar name using Jaro-Winkler distance.
571    fn find_similar<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
572        candidates
573            .iter()
574            .filter(|c| jaro_winkler(name, c) > 0.8)
575            .max_by(|a, b| {
576                jaro_winkler(name, a)
577                    .partial_cmp(&jaro_winkler(name, b))
578                    .unwrap_or(std::cmp::Ordering::Equal)
579            })
580            .copied()
581    }
582
583    /// Suggest fixes for common token mistakes.
584    fn suggest_token_fix(
585        expected: &str,
586        found: &Token,
587        _source: &str,
588        _span: Span,
589    ) -> Option<(String, String)> {
590        // Common mistake patterns
591        match (expected, found) {
592            ("`;`", Token::RBrace) => Some((
593                "you might be missing a semicolon".to_string(),
594                ";".to_string(),
595            )),
596            ("`{`", Token::Arrow) => Some((
597                "you might want a block here".to_string(),
598                "{ ... }".to_string(),
599            )),
600            ("`)`", Token::Comma) => Some((
601                "unexpected comma, maybe close the parenthesis first".to_string(),
602                ")".to_string(),
603            )),
604            _ => None,
605        }
606    }
607
608    /// Generate help text for evidentiality issues.
609    fn evidentiality_help(expected: &str, found: &str) -> String {
610        match (expected, found) {
611            ("!", "~") => {
612                "use `value|validate!{...}` to promote reported data to known".to_string()
613            }
614            ("!", "?") => {
615                "handle the uncertain case with `match` or unwrap with `value!`".to_string()
616            }
617            ("?", "~") => "reported data is already uncertain, no conversion needed".to_string(),
618            _ => format!(
619                "evidentiality flows: ! (known) < ? (uncertain) < ~ (reported) < ‽ (paradox)"
620            ),
621        }
622    }
623}
624
625/// Collection of diagnostics for a compilation unit.
626#[derive(Debug, Default)]
627pub struct Diagnostics {
628    items: Vec<Diagnostic>,
629    has_errors: bool,
630}
631
632impl Diagnostics {
633    pub fn new() -> Self {
634        Self::default()
635    }
636
637    pub fn add(&mut self, diagnostic: Diagnostic) {
638        if diagnostic.severity == Severity::Error {
639            self.has_errors = true;
640        }
641        self.items.push(diagnostic);
642    }
643
644    pub fn has_errors(&self) -> bool {
645        self.has_errors
646    }
647
648    pub fn is_empty(&self) -> bool {
649        self.items.is_empty()
650    }
651
652    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
653        self.items.iter()
654    }
655
656    /// Render all diagnostics.
657    pub fn render_all(&self, filename: &str, source: &str) -> String {
658        let mut output = String::new();
659        for diag in &self.items {
660            output.push_str(&diag.render(filename, source));
661            output.push('\n');
662        }
663        output
664    }
665
666    /// Print all diagnostics to stderr.
667    pub fn eprint_all(&self, filename: &str, source: &str) {
668        for diag in &self.items {
669            diag.eprint(filename, source);
670        }
671    }
672
673    /// Get error count.
674    pub fn error_count(&self) -> usize {
675        self.items
676            .iter()
677            .filter(|d| d.severity == Severity::Error)
678            .count()
679    }
680
681    /// Get warning count.
682    pub fn warning_count(&self) -> usize {
683        self.items
684            .iter()
685            .filter(|d| d.severity == Severity::Warning)
686            .count()
687    }
688
689    /// Print summary.
690    pub fn print_summary(&self) {
691        let errors = self.error_count();
692        let warnings = self.warning_count();
693
694        if errors > 0 || warnings > 0 {
695            eprint!("\n");
696            if errors > 0 {
697                eprintln!(
698                    "{}: aborting due to {} previous error{}",
699                    "error".fg(Color::Red),
700                    errors,
701                    if errors == 1 { "" } else { "s" }
702                );
703            }
704            if warnings > 0 {
705                eprintln!(
706                    "{}: {} warning{} emitted",
707                    "warning".fg(Color::Yellow),
708                    warnings,
709                    if warnings == 1 { "" } else { "s" }
710                );
711            }
712        }
713    }
714
715    /// Convert all diagnostics to JSON output format.
716    ///
717    /// Returns a structured JSON object suitable for machine consumption,
718    /// with line/column positions, fix suggestions, and metadata.
719    pub fn to_json_output(&self, filename: &str, source: &str) -> JsonDiagnosticsOutput {
720        let diagnostics: Vec<JsonDiagnostic> = self
721            .items
722            .iter()
723            .map(|d| d.to_json(filename, source))
724            .collect();
725
726        let error_count = self.error_count();
727        let warning_count = self.warning_count();
728
729        JsonDiagnosticsOutput {
730            file: filename.to_string(),
731            diagnostics,
732            error_count,
733            warning_count,
734            success: error_count == 0,
735        }
736    }
737
738    /// Render diagnostics as JSON string.
739    ///
740    /// For AI agent consumption - provides structured, machine-readable output.
741    pub fn to_json_string(&self, filename: &str, source: &str) -> String {
742        let output = self.to_json_output(filename, source);
743        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
744    }
745
746    /// Render diagnostics as compact JSON (single line).
747    ///
748    /// For piping to other tools or streaming output.
749    pub fn to_json_compact(&self, filename: &str, source: &str) -> String {
750        let output = self.to_json_output(filename, source);
751        serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string())
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn test_undefined_variable_suggestion() {
761        let known = vec!["counter", "count", "total", "sum"];
762        let diag = DiagnosticBuilder::undefined_variable("countr", Span::new(10, 16), &known);
763
764        assert!(diag.suggestions.iter().any(|s| s.replacement == "counter"));
765    }
766
767    #[test]
768    fn test_evidentiality_mismatch() {
769        let diag = DiagnosticBuilder::evidentiality_mismatch("!", "~", Span::new(0, 5));
770
771        assert!(diag.notes.iter().any(|n| n.contains("validate")));
772    }
773
774    #[test]
775    fn test_unicode_symbol_suggestions() {
776        // Logic operators
777        assert_eq!(
778            DiagnosticBuilder::suggest_unicode_symbol("&&"),
779            Some(("∧", "logical AND"))
780        );
781        assert_eq!(
782            DiagnosticBuilder::suggest_unicode_symbol("||"),
783            Some(("∨", "logical OR"))
784        );
785
786        // Bitwise operators
787        assert_eq!(
788            DiagnosticBuilder::suggest_unicode_symbol("&"),
789            Some(("⋏", "bitwise AND"))
790        );
791        assert_eq!(
792            DiagnosticBuilder::suggest_unicode_symbol("|"),
793            Some(("⋎", "bitwise OR"))
794        );
795
796        // Morphemes
797        assert_eq!(
798            DiagnosticBuilder::suggest_unicode_symbol("tau"),
799            Some(("τ", "transform morpheme"))
800        );
801        assert_eq!(
802            DiagnosticBuilder::suggest_unicode_symbol("filter"),
803            Some(("φ", "filter morpheme"))
804        );
805        assert_eq!(
806            DiagnosticBuilder::suggest_unicode_symbol("alpha"),
807            Some(("α", "first element"))
808        );
809
810        // Math
811        assert_eq!(
812            DiagnosticBuilder::suggest_unicode_symbol("sqrt"),
813            Some(("√", "square root"))
814        );
815        assert_eq!(
816            DiagnosticBuilder::suggest_unicode_symbol("infinity"),
817            Some(("∞", "infinity"))
818        );
819
820        // Unknown
821        assert_eq!(DiagnosticBuilder::suggest_unicode_symbol("foobar"), None);
822    }
823
824    #[test]
825    fn test_symbol_upgrade_diagnostic() {
826        let diag = DiagnosticBuilder::suggest_symbol_upgrade("&&", Span::new(0, 2));
827        assert!(diag.is_some());
828
829        let d = diag.unwrap();
830        assert!(d.suggestions.iter().any(|s| s.replacement == "∧"));
831        assert!(d.notes.iter().any(|n| n.contains("logical AND")));
832    }
833
834    #[test]
835    fn test_unknown_morpheme_with_access_morphemes() {
836        let diag = DiagnosticBuilder::unknown_morpheme("alph", Span::new(0, 4));
837
838        // Should suggest alpha
839        assert!(diag.suggestions.iter().any(|s| s.replacement == "α"));
840        // Should have notes about both transform and access morphemes
841        assert!(diag.notes.iter().any(|n| n.contains("transform morphemes")));
842        assert!(diag.notes.iter().any(|n| n.contains("access morphemes")));
843    }
844
845    #[test]
846    fn test_all_symbol_mappings() {
847        let mappings = DiagnosticBuilder::all_symbol_mappings();
848        assert!(!mappings.is_empty());
849
850        // Check that essential mappings exist
851        assert!(mappings.iter().any(|(a, u, _)| *a == "&&" && *u == "∧"));
852        assert!(mappings.iter().any(|(a, u, _)| *a == "tau" && *u == "τ"));
853        assert!(mappings.iter().any(|(a, u, _)| *a == "sqrt" && *u == "√"));
854    }
855}