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