Skip to main content

qala_compiler/
diagnostics.rs

1//! the diagnostics layer: one [`Diagnostic`] data model with two rendering
2//! paths -- [`Diagnostic::render`] (Task 2) produces a Rust-style
3//! ASCII-underlined source block for CLI output and snapshot tests;
4//! [`Diagnostic::to_monaco`] produces a serde-Serialize [`MonacoDiagnostic`]
5//! for the playground's inline editor underlines. errors and warnings share
6//! the model; [`Severity`] distinguishes them; warnings carry their
7//! snake_case category as `Diagnostic::category`.
8//!
9//! determinism: same source + same Diagnostic = byte-identical render
10//! output, verified by a render-twice unit test plus a snapshot file checked
11//! into `compiler/tests/snapshots/`. user-visible lists (missing variants,
12//! missing methods, cycle paths) are pre-sorted by the typechecker so this
13//! module never iterates a HashMap into a user-visible string.
14
15use crate::errors::QalaError;
16use crate::span::{LineIndex, Span};
17use crate::typechecker::QalaWarning;
18
19/// is this diagnostic an error (compilation-blocking) or a warning
20/// (informational; never blocks codegen)?
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Severity {
23    /// a hard fault: the typechecker produced a [`QalaError`] and codegen
24    /// will not run.
25    Error,
26    /// a soft fault: the typechecker produced a [`QalaWarning`]; codegen
27    /// still runs.
28    Warning,
29}
30
31/// one rendered-ready diagnostic, decoupled from the typechecker's
32/// internal error and warning enums.
33///
34/// not derived `PartialEq`: the contract is the output of
35/// [`Diagnostic::render`] (and [`Diagnostic::to_monaco`]), not the
36/// internal field layout. notes and hints are ordered lists and the order
37/// is part of the rendered output, but two Diagnostics with the same
38/// rendering may differ in non-rendered fields without breaking anything.
39#[derive(Debug, Clone)]
40pub struct Diagnostic {
41    /// error or warning -- selects the rendered header word.
42    pub severity: Severity,
43    /// the warning's snake_case category (`unused_var`, `shadowed_var`,
44    /// ...); `None` for errors. this is what `// qala: allow(<category>)`
45    /// matches against and what the playground colors yellow.
46    pub category: Option<String>,
47    /// the one-line description, taken verbatim from
48    /// [`QalaError::message`] or [`QalaWarning::message`].
49    pub message: String,
50    /// the source region the diagnostic points at -- used by the renderer
51    /// to find the line text, line:column, and the underline width.
52    pub primary_span: Span,
53    /// `   = note: {note}` lines appended to the rendered block, in this
54    /// order, after the source-line underline.
55    pub notes: Vec<String>,
56    /// `   = hint: {hint}` lines appended after every note, in this
57    /// order. rustc-style: notes describe facts about the program; hints
58    /// describe edits the user might make.
59    pub hints: Vec<String>,
60}
61
62/// the structured form of a diagnostic for the Monaco editor's marker
63/// API. Monaco wants 1-based line and column, an end position, a
64/// severity-as-integer (0=Hint, 1=Info, 2=Warning, 4=Error per
65/// `MarkerSeverity` -- we use the locked 0=Warning, 1=Error pair), a
66/// message, and an optional category (which the playground renders as
67/// the marker source string).
68///
69/// derives `serde::Serialize` so the Phase 6 WASM bridge can hand a
70/// `Vec<MonacoDiagnostic>` straight to JS via `serde-wasm-bindgen`.
71#[derive(Debug, Clone, serde::Serialize)]
72pub struct MonacoDiagnostic {
73    /// 1-based line of the diagnostic's start, from
74    /// [`LineIndex::location`].
75    pub line: u32,
76    /// 1-based column of the diagnostic's start, character-counted (a
77    /// tab is one column, matching Span::location).
78    pub column: u32,
79    /// 1-based line of one past the diagnostic's end.
80    pub end_line: u32,
81    /// 1-based column of one past the diagnostic's end (the end of the
82    /// underlined region; matches Monaco's exclusive-end convention).
83    pub end_column: u32,
84    /// 0 = warning, 1 = error. matches the locked Monaco
85    /// MarkerSeverity convention adopted by the playground.
86    pub severity: u8,
87    /// the diagnostic message, verbatim.
88    pub message: String,
89    /// the warning category (snake_case); `None` for errors.
90    pub category: Option<String>,
91}
92
93impl Diagnostic {
94    /// render this diagnostic as a Rust-style ASCII-underlined source
95    /// block.
96    ///
97    /// byte-identical for the same `(Diagnostic, src)` pair: same input
98    /// produces the same output, regardless of process, build, or
99    /// platform. that is the DIAG-04 contract; a snapshot test in
100    /// `compiler/tests/snapshots/diagnostics_basic.txt` proves it
101    /// cross-process.
102    ///
103    /// the block has five lines plus optional notes and hints:
104    ///
105    /// ```text
106    /// error: <message>           (or `warning: <message>`)
107    ///   --> <line>:<column>
108    ///    |
109    /// N  |   <source line>
110    ///    |   <pad><carets>
111    ///    |
112    ///    = note: spans multiple lines      (only when the span clips)
113    ///    = note: <each note>
114    ///    = hint: <each hint>
115    /// ```
116    ///
117    /// the underline is one `^` per byte of the span, clipped to the
118    /// source line so a multi-line span does not produce screen-fulls
119    /// of carets (Pitfall 4 of the research); when clipping occurs, a
120    /// `note: spans multiple lines` is appended before any user notes.
121    ///
122    /// no color codes, no emoji, no host paths -- the renderer is a
123    /// pure function over `(self, src)` and produces only ASCII text.
124    pub fn render(&self, src: &str) -> String {
125        let line_index = LineIndex::new(src);
126        let (line, column) = line_index.location(src, self.primary_span.start as usize);
127        let line_text = src.lines().nth(line - 1).unwrap_or("");
128
129        let header = match self.severity {
130            Severity::Error => "error",
131            Severity::Warning => "warning",
132        };
133
134        let mut out = String::new();
135
136        // header line: "error: <message>" / "warning: <message>".
137        out.push_str(&format!("{header}: {}\n", self.message));
138
139        // arrow line: file path is omitted; Phase 6's WASM bridge
140        // supplies the file name separately if needed.
141        out.push_str(&format!("  --> {line}:{column}\n"));
142
143        // top separator.
144        out.push_str("   |\n");
145
146        // source line: line number left-justified to 3 cols, then
147        // `|   `, then the source text. the line numbers in tests can
148        // be at most 3 digits in practice (a 999-line program is the
149        // outer end of what the playground asks the renderer to draw),
150        // and 3 cols of padding is the rustc convention.
151        out.push_str(&format!("{line:<3}|   {line_text}\n"));
152
153        // underline width: one `^` per byte of the span, clipped to
154        // the source line so a multi-line span does not run off the
155        // end (Pitfall 4). `avail` is the remaining bytes from the
156        // start column to the end of the source line.
157        let avail = line_text.len().saturating_sub(column.saturating_sub(1));
158        let span_bytes = self.primary_span.len as usize;
159        let underline_width = std::cmp::min(span_bytes, avail).max(1);
160        let pad = " ".repeat(column.saturating_sub(1));
161        let underline = "^".repeat(underline_width);
162        out.push_str(&format!("   |   {pad}{underline}\n"));
163
164        // bottom separator.
165        out.push_str("   |\n");
166
167        // multi-line clip note (prepended before any user notes).
168        if span_bytes > avail {
169            out.push_str("   = note: spans multiple lines\n");
170        }
171
172        // user notes, then hints.
173        for note in &self.notes {
174            out.push_str(&format!("   = note: {note}\n"));
175        }
176        for hint in &self.hints {
177            out.push_str(&format!("   = hint: {hint}\n"));
178        }
179
180        out
181    }
182
183    /// translate this Diagnostic into the structured Monaco form.
184    ///
185    /// the source string is required to translate byte offsets into
186    /// 1-based line and column via [`LineIndex::location`]; the line
187    /// index is built once per call and discarded (the typical playground
188    /// path renders a small batch of diagnostics from one source string,
189    /// so the build cost is paid per-call rather than cached).
190    pub fn to_monaco(&self, src: &str) -> MonacoDiagnostic {
191        let line_index = LineIndex::new(src);
192        let (line, column) = line_index.location(src, self.primary_span.start as usize);
193        let (end_line, end_column) = line_index.location(src, self.primary_span.end());
194        MonacoDiagnostic {
195            line: line as u32,
196            column: column as u32,
197            end_line: end_line as u32,
198            end_column: end_column as u32,
199            severity: match self.severity {
200                Severity::Error => 1,
201                Severity::Warning => 0,
202            },
203            message: self.message.clone(),
204            category: self.category.clone(),
205        }
206    }
207}
208
209impl From<QalaError> for Diagnostic {
210    /// build a Diagnostic from a [`QalaError`]. severity is always
211    /// `Error`; category is always `None` (errors have no category --
212    /// only warnings do). the message comes from `err.message()` and the
213    /// span from `err.span()`. five variants carry structured extra
214    /// data which becomes per-method `note:` lines or a single `hint:`
215    /// line; the rest leave notes and hints empty.
216    fn from(err: QalaError) -> Self {
217        let message = err.message();
218        let primary_span = err.span();
219        let mut notes: Vec<String> = Vec::new();
220        let mut hints: Vec<String> = Vec::new();
221        // match on a reference so any future variant addition forces a
222        // deliberate decision here -- not because the compiler enforces
223        // exhaustiveness on Diagnostic construction (a wildcard arm
224        // would also compile) but because reading this match is the
225        // only place a reader sees the five "structured" variants
226        // listed together.
227        match &err {
228            QalaError::EffectViolation {
229                caller_effect,
230                callee_effect,
231                ..
232            } => {
233                notes.push(format!(
234                    "pure functions cannot call functions with {callee_effect} effects"
235                ));
236                hints.push(format!(
237                    "remove the `is {caller_effect}` annotation, or refactor to remove the {callee_effect} call"
238                ));
239            }
240            QalaError::NonExhaustiveMatch { missing, .. } if !missing.is_empty() => {
241                notes.push(format!("add an arm for: {}", missing.join(", ")));
242            }
243            QalaError::InterfaceNotSatisfied {
244                missing,
245                mismatched,
246                ..
247            } => {
248                // typechecker pre-sorts both lists; render in that order.
249                for name in missing {
250                    notes.push(format!("missing method `{name}`"));
251                }
252                for (method, expected, found) in mismatched {
253                    notes.push(format!(
254                        "method `{method}` has signature {found}, expected {expected}"
255                    ));
256                }
257            }
258            QalaError::RecursiveStructByValue { path, .. } => {
259                notes.push(format!("cycle path: {}", path.join(" -> ")));
260            }
261            QalaError::RedundantQuestionOperator { .. } => {
262                hints.push(
263                    "change the function's return type to a compatible Result/Option, or handle the error explicitly"
264                        .to_string(),
265                );
266            }
267            // every other variant -- the lex/parse error family and the
268            // simpler type-error variants -- carries enough context in
269            // its one-line message. no notes, no hints.
270            _ => {}
271        }
272        Diagnostic {
273            severity: Severity::Error,
274            category: None,
275            message,
276            primary_span,
277            notes,
278            hints,
279        }
280    }
281}
282
283impl From<&QalaWarning> for Diagnostic {
284    /// build a Diagnostic from a [`QalaWarning`] reference. severity is
285    /// `Warning`; category is `Some(w.category)`. warnings carry their
286    /// snake_case category as `Diagnostic::category`; this is what
287    /// `// qala: allow(<category>)` matches against and what the
288    /// playground colors yellow. the optional `note` field becomes the
289    /// first note line.
290    fn from(w: &QalaWarning) -> Self {
291        let notes = w.note.as_ref().map_or_else(Vec::new, |n| vec![n.clone()]);
292        Diagnostic {
293            severity: Severity::Warning,
294            category: Some(w.category.clone()),
295            message: w.message.clone(),
296            primary_span: w.span,
297            notes,
298            hints: Vec::new(),
299        }
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::errors::QalaError;
307    use crate::lexer;
308    use crate::parser;
309    use crate::span::Span;
310    use crate::typechecker;
311    use crate::typechecker::QalaWarning;
312
313    /// a small default span; tests that want a specific position
314    /// construct `Span::new(...)` inline.
315    fn sp() -> Span {
316        Span::new(0, 1)
317    }
318
319    /// build a Diagnostic for a small bad program by running it
320    /// through lex/parse/check and converting the first error to a
321    /// Diagnostic. used by the renderer tests so they exercise the
322    /// real From<QalaError> conversion against real typechecker
323    /// output rather than hand-constructed errors.
324    fn diag_for(src: &str) -> Diagnostic {
325        let tokens = lexer::Lexer::tokenize(src).expect("lex");
326        let ast = parser::Parser::parse(&tokens).expect("parse");
327        let (_, errors, _) = typechecker::check_program(&ast, src);
328        assert!(!errors.is_empty(), "expected an error for: {src}");
329        Diagnostic::from(errors[0].clone())
330    }
331
332    /// build a Diagnostic for the first warning produced by a small
333    /// program. the unused_var snapshot case uses this.
334    fn warn_diag_for(src: &str) -> Diagnostic {
335        let tokens = lexer::Lexer::tokenize(src).expect("lex");
336        let ast = parser::Parser::parse(&tokens).expect("parse");
337        let (_, _errors, warnings) = typechecker::check_program(&ast, src);
338        assert!(!warnings.is_empty(), "expected a warning for: {src}");
339        Diagnostic::from(&warnings[0])
340    }
341
342    #[test]
343    fn diagnostic_builds_and_round_trips_through_debug_clone() {
344        let d = Diagnostic {
345            severity: Severity::Error,
346            category: None,
347            message: "x".to_string(),
348            primary_span: Span::new(0, 1),
349            notes: Vec::new(),
350            hints: Vec::new(),
351        };
352        let copy = d.clone();
353        // Debug works on every field; the format produces something non-empty.
354        let dbg = format!("{copy:?}");
355        assert!(!dbg.is_empty());
356        // shape stayed the same across the clone.
357        assert_eq!(copy.message, "x");
358        assert!(matches!(copy.severity, Severity::Error));
359        assert_eq!(copy.primary_span, Span::new(0, 1));
360        assert!(copy.notes.is_empty());
361        assert!(copy.hints.is_empty());
362        assert!(copy.category.is_none());
363    }
364
365    #[test]
366    fn from_qala_error_int_overflow_carries_message_and_span() {
367        let err = QalaError::IntOverflow {
368            span: Span::new(5, 4),
369        };
370        let d: Diagnostic = err.into();
371        assert!(matches!(d.severity, Severity::Error));
372        assert!(d.category.is_none());
373        assert_eq!(d.message, "integer literal is too large for i64");
374        assert_eq!(d.primary_span, Span::new(5, 4));
375        assert!(d.notes.is_empty());
376        assert!(d.hints.is_empty());
377    }
378
379    #[test]
380    fn from_qala_error_effect_violation_adds_locked_note_and_hint() {
381        let err = QalaError::EffectViolation {
382            span: sp(),
383            caller: "compute".to_string(),
384            caller_effect: "pure".to_string(),
385            callee: "println".to_string(),
386            callee_effect: "io".to_string(),
387        };
388        let d: Diagnostic = err.into();
389        assert_eq!(
390            d.notes,
391            vec!["pure functions cannot call functions with io effects".to_string()]
392        );
393        assert_eq!(
394            d.hints,
395            vec!["remove the `is pure` annotation, or refactor to remove the io call".to_string()]
396        );
397    }
398
399    #[test]
400    fn from_qala_error_non_exhaustive_match_adds_note() {
401        let err = QalaError::NonExhaustiveMatch {
402            span: sp(),
403            enum_name: "Shape".to_string(),
404            missing: vec!["Bar".to_string(), "Foo".to_string()],
405        };
406        let d: Diagnostic = err.into();
407        assert_eq!(d.notes, vec!["add an arm for: Bar, Foo".to_string()]);
408        assert!(d.hints.is_empty());
409    }
410
411    #[test]
412    fn from_qala_error_interface_not_satisfied_emits_per_method_notes() {
413        let err = QalaError::InterfaceNotSatisfied {
414            span: sp(),
415            ty: "Point".to_string(),
416            interface: "Printable".to_string(),
417            missing: vec!["a".to_string(), "b".to_string()],
418            mismatched: vec![(
419                "c".to_string(),
420                "fn(Self) -> str".to_string(),
421                "fn(Self) -> i64".to_string(),
422            )],
423        };
424        let d: Diagnostic = err.into();
425        assert_eq!(
426            d.notes,
427            vec![
428                "missing method `a`".to_string(),
429                "missing method `b`".to_string(),
430                "method `c` has signature fn(Self) -> i64, expected fn(Self) -> str".to_string(),
431            ]
432        );
433        assert!(d.hints.is_empty());
434    }
435
436    #[test]
437    fn from_qala_error_recursive_struct_adds_cycle_path_note() {
438        let err = QalaError::RecursiveStructByValue {
439            span: sp(),
440            path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
441        };
442        let d: Diagnostic = err.into();
443        assert_eq!(d.notes, vec!["cycle path: A -> B -> A".to_string()]);
444        assert!(d.hints.is_empty());
445    }
446
447    #[test]
448    fn from_qala_error_redundant_question_operator_adds_hint() {
449        let err = QalaError::RedundantQuestionOperator {
450            span: sp(),
451            message: "?".to_string(),
452        };
453        let d: Diagnostic = err.into();
454        assert_eq!(
455            d.hints,
456            vec![
457                "change the function's return type to a compatible Result/Option, or handle the error explicitly"
458                    .to_string()
459            ]
460        );
461        assert!(d.notes.is_empty());
462    }
463
464    #[test]
465    fn from_qala_warning_without_note_yields_empty_notes() {
466        let w = QalaWarning {
467            category: "unused_var".to_string(),
468            message: "unused variable `x`".to_string(),
469            span: Span::new(10, 1),
470            note: None,
471        };
472        let d: Diagnostic = (&w).into();
473        assert!(matches!(d.severity, Severity::Warning));
474        assert_eq!(d.category, Some("unused_var".to_string()));
475        assert_eq!(d.message, "unused variable `x`");
476        assert_eq!(d.primary_span, Span::new(10, 1));
477        assert!(d.notes.is_empty());
478        assert!(d.hints.is_empty());
479    }
480
481    #[test]
482    fn from_qala_warning_with_note_pushes_one_note_entry() {
483        let w = QalaWarning {
484            category: "shadowed_var".to_string(),
485            message: "shadowed `x`".to_string(),
486            span: Span::new(20, 1),
487            note: Some("the prior binding is at line 3:5".to_string()),
488        };
489        let d: Diagnostic = (&w).into();
490        assert_eq!(
491            d.notes,
492            vec!["the prior binding is at line 3:5".to_string()]
493        );
494        assert!(matches!(d.severity, Severity::Warning));
495    }
496
497    #[test]
498    fn to_monaco_translates_a_single_line_span_to_1_based_columns() {
499        // span Span::new(0, 5) over "hello" -> covers all 5 bytes
500        // start byte 0 -> line 1, column 1
501        // end   byte 5 -> line 1, column 6 (one past the last char)
502        let d = Diagnostic {
503            severity: Severity::Error,
504            category: None,
505            message: "m".to_string(),
506            primary_span: Span::new(0, 5),
507            notes: Vec::new(),
508            hints: Vec::new(),
509        };
510        let m = d.to_monaco("hello");
511        assert_eq!(m.line, 1);
512        assert_eq!(m.column, 1);
513        assert_eq!(m.end_line, 1);
514        assert_eq!(m.end_column, 6);
515        assert_eq!(m.severity, 1);
516        assert_eq!(m.message, "m");
517        assert!(m.category.is_none());
518    }
519
520    #[test]
521    fn to_monaco_translates_a_multi_line_span_to_two_line_numbers() {
522        // src "a\nb\nc": bytes a=0, \n=1, b=2, \n=3, c=4
523        // span Span::new(0, 5) starts at byte 0 (line 1, col 1) and
524        // ends at byte 5 (one past c, line 3, col 2).
525        let d = Diagnostic {
526            severity: Severity::Error,
527            category: None,
528            message: "m".to_string(),
529            primary_span: Span::new(0, 5),
530            notes: Vec::new(),
531            hints: Vec::new(),
532        };
533        let m = d.to_monaco("a\nb\nc");
534        assert_eq!(m.line, 1);
535        assert_eq!(m.column, 1);
536        assert_eq!(m.end_line, 3);
537        assert_eq!(m.end_column, 2);
538    }
539
540    #[test]
541    fn to_monaco_severity_maps_error_to_1_and_warning_to_0() {
542        let err_diag = Diagnostic {
543            severity: Severity::Error,
544            category: None,
545            message: String::new(),
546            primary_span: Span::new(0, 0),
547            notes: Vec::new(),
548            hints: Vec::new(),
549        };
550        assert_eq!(err_diag.to_monaco("").severity, 1);
551        let warn_diag = Diagnostic {
552            severity: Severity::Warning,
553            category: Some("unused_var".to_string()),
554            message: String::new(),
555            primary_span: Span::new(0, 0),
556            notes: Vec::new(),
557            hints: Vec::new(),
558        };
559        assert_eq!(warn_diag.to_monaco("").severity, 0);
560    }
561
562    #[test]
563    fn monaco_diagnostic_implements_serialize() {
564        // a compile-time check: if MonacoDiagnostic doesn't implement
565        // serde::Serialize, this generic call fails to typecheck and
566        // the test build breaks. Serialize has a generic method so it
567        // is not dyn-compatible; a generic asserting function is the
568        // standard way to spell "T: Serialize" as a witness.
569        fn assert_serialize<T: serde::Serialize>(_: &T) {}
570        let m = MonacoDiagnostic {
571            line: 1,
572            column: 1,
573            end_line: 1,
574            end_column: 1,
575            severity: 0,
576            message: String::new(),
577            category: None,
578        };
579        assert_serialize(&m);
580    }
581
582    #[test]
583    fn from_qala_error_is_deterministic_across_two_calls() {
584        // determinism property: From<QalaError> is a pure transformation
585        // -- the match arms are fixed-order and the structured arms
586        // preserve typechecker-sorted lists, so two calls produce
587        // notes/hints in the same order. the match-arm-fixed-order is
588        // the guarantee; this test makes it visible.
589        let err = QalaError::InterfaceNotSatisfied {
590            span: sp(),
591            ty: "Point".to_string(),
592            interface: "Printable".to_string(),
593            missing: vec!["a".to_string(), "b".to_string()],
594            mismatched: vec![(
595                "c".to_string(),
596                "fn(Self) -> str".to_string(),
597                "fn(Self) -> i64".to_string(),
598            )],
599        };
600        let a: Diagnostic = err.clone().into();
601        let b: Diagnostic = err.into();
602        assert_eq!(a.notes, b.notes);
603        assert_eq!(a.hints, b.hints);
604        assert_eq!(a.message, b.message);
605        assert_eq!(a.primary_span, b.primary_span);
606    }
607
608    // ---- Task 2: render tests ---------------------------------------------
609
610    #[test]
611    fn render_header_says_error_for_an_error_diagnostic() {
612        let d = Diagnostic {
613            severity: Severity::Error,
614            category: None,
615            message: "oops".to_string(),
616            primary_span: Span::new(0, 1),
617            notes: Vec::new(),
618            hints: Vec::new(),
619        };
620        let out = d.render("x");
621        assert!(out.starts_with("error: oops\n"), "wrong header: {out:?}");
622    }
623
624    #[test]
625    fn render_arrow_line_uses_1_based_line_and_column() {
626        // src "abcde\nfghij": bytes a=0,b=1,c=2,d=3,e=4,\n=5,f=6,g=7,...
627        // byte 6 (the `f`) is on line 2, column 1 -- the Plan's stated
628        // expected output "  --> 2:1" corresponds to byte 6, not byte 5
629        // (byte 5 is the `\n` on line 1 col 6). picking byte 6 here
630        // matches the Plan's intent: "show that a span on line 2 col 1
631        // renders the arrow line correctly".
632        let d = Diagnostic {
633            severity: Severity::Error,
634            category: None,
635            message: "m".to_string(),
636            primary_span: Span::new(6, 1),
637            notes: Vec::new(),
638            hints: Vec::new(),
639        };
640        let out = d.render("abcde\nfghij");
641        assert!(out.contains("  --> 2:1\n"), "{out:?}");
642    }
643
644    #[test]
645    fn render_source_line_includes_line_number_and_text() {
646        // src "abc\ndef\nghi", span on line 2 (byte 4 is `d`, length 3
647        // covers `def`). source line should render as "2  |   def\n".
648        let d = Diagnostic {
649            severity: Severity::Error,
650            category: None,
651            message: "m".to_string(),
652            primary_span: Span::new(4, 3),
653            notes: Vec::new(),
654            hints: Vec::new(),
655        };
656        let out = d.render("abc\ndef\nghi");
657        assert!(out.contains("2  |   def\n"), "{out:?}");
658    }
659
660    #[test]
661    fn render_underline_has_one_caret_per_byte_at_column_1() {
662        // src "hello", span 0..5 -> column 1, pad="" (0 chars), 5 carets.
663        let d = Diagnostic {
664            severity: Severity::Error,
665            category: None,
666            message: "m".to_string(),
667            primary_span: Span::new(0, 5),
668            notes: Vec::new(),
669            hints: Vec::new(),
670        };
671        let out = d.render("hello");
672        assert!(out.contains("   |   ^^^^^\n"), "{out:?}");
673    }
674
675    #[test]
676    fn render_underline_pads_for_non_first_column_spans() {
677        // src "hello", span 2..5 -> column 3, pad="  " (2 chars), 3 carets.
678        let d = Diagnostic {
679            severity: Severity::Error,
680            category: None,
681            message: "m".to_string(),
682            primary_span: Span::new(2, 3),
683            notes: Vec::new(),
684            hints: Vec::new(),
685        };
686        let out = d.render("hello");
687        assert!(out.contains("   |     ^^^\n"), "{out:?}");
688    }
689
690    #[test]
691    fn render_multi_line_span_clips_underline_and_appends_note() {
692        // src "line one\nline two" (line 1 = "line one" = 8 chars).
693        // span 0..20 starts at byte 0 (col 1) and runs 20 bytes past
694        // the end of line 1. underline clips to 8 carets and a
695        // "spans multiple lines" note is appended before any user notes.
696        let d = Diagnostic {
697            severity: Severity::Error,
698            category: None,
699            message: "m".to_string(),
700            primary_span: Span::new(0, 20),
701            notes: Vec::new(),
702            hints: Vec::new(),
703        };
704        let out = d.render("line one\nline two");
705        assert!(out.contains("   |   ^^^^^^^^\n"), "{out:?}");
706        assert!(out.contains("   = note: spans multiple lines\n"), "{out:?}");
707    }
708
709    #[test]
710    fn render_emits_bottom_separator_before_notes_and_hints() {
711        // a Diagnostic with one note and one hint should have the
712        // bottom "   |\n" separator BEFORE either of them, and the
713        // notes BEFORE the hints (rustc order).
714        let d = Diagnostic {
715            severity: Severity::Error,
716            category: None,
717            message: "m".to_string(),
718            primary_span: Span::new(0, 1),
719            notes: vec!["a fact".to_string()],
720            hints: vec!["a suggestion".to_string()],
721        };
722        let out = d.render("x");
723        let bottom = out.find("   |\n   = note: a fact\n");
724        assert!(
725            bottom.is_some(),
726            "bottom separator and note must be adjacent: {out:?}"
727        );
728        let note_pos = out.find("   = note: a fact\n").unwrap();
729        let hint_pos = out.find("   = hint: a suggestion\n").unwrap();
730        assert!(note_pos < hint_pos, "note must come before hint: {out:?}");
731    }
732
733    #[test]
734    fn render_emits_one_line_per_note_in_order() {
735        let d = Diagnostic {
736            severity: Severity::Error,
737            category: None,
738            message: "m".to_string(),
739            primary_span: Span::new(0, 1),
740            notes: vec!["first".to_string(), "second".to_string()],
741            hints: Vec::new(),
742        };
743        let out = d.render("x");
744        let first = out.find("   = note: first\n").unwrap();
745        let second = out.find("   = note: second\n").unwrap();
746        assert!(first < second, "notes must render in vec order: {out:?}");
747    }
748
749    #[test]
750    fn render_emits_one_line_per_hint_in_order() {
751        let d = Diagnostic {
752            severity: Severity::Error,
753            category: None,
754            message: "m".to_string(),
755            primary_span: Span::new(0, 1),
756            notes: Vec::new(),
757            hints: vec!["try this".to_string(), "or that".to_string()],
758        };
759        let out = d.render("x");
760        let first = out.find("   = hint: try this\n").unwrap();
761        let second = out.find("   = hint: or that\n").unwrap();
762        assert!(first < second, "hints must render in vec order: {out:?}");
763    }
764
765    #[test]
766    fn render_warning_uses_warning_header() {
767        let d = Diagnostic {
768            severity: Severity::Warning,
769            category: Some("unused_var".to_string()),
770            message: "unused variable `x`".to_string(),
771            primary_span: Span::new(0, 1),
772            notes: Vec::new(),
773            hints: Vec::new(),
774        };
775        let out = d.render("let x = 1");
776        assert!(
777            out.starts_with("warning: unused variable `x`\n"),
778            "wrong warning header: {out:?}"
779        );
780    }
781
782    #[test]
783    fn render_is_byte_identical_across_two_calls() {
784        // determinism property in-process: same Diagnostic + same src
785        // = byte-identical output across two calls. exercises a real
786        // typechecker-produced error via diag_for.
787        let src = "fn f() -> i64 { return \"x\" }";
788        let d = diag_for(src);
789        let a = d.render(src);
790        let b = d.render(src);
791        assert_eq!(a, b, "render output drifted between calls");
792    }
793
794    #[test]
795    fn six_bundled_examples_render_no_error_output() {
796        // the Plan 04 typechecker smoke test asserted errors is empty;
797        // this paired Plan 05 test asserts rendering those (empty)
798        // errors collects to the empty string -- proves the renderer
799        // is wired in correctly for the no-error case.
800        for name in [
801            "hello",
802            "fibonacci",
803            "effects",
804            "pattern-matching",
805            "pipeline",
806            "defer-demo",
807        ] {
808            let path = format!(
809                "{}/../../playground/public/examples/{}.qala",
810                env!("CARGO_MANIFEST_DIR"),
811                name
812            );
813            let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
814            let tokens = lexer::Lexer::tokenize(&src).expect("lex");
815            let ast = parser::Parser::parse(&tokens).expect("parse");
816            let (_, errors, _warnings) = typechecker::check_program(&ast, &src);
817            assert!(
818                errors.is_empty(),
819                "{name}.qala: unexpected errors: {errors:?}"
820            );
821            let rendered: String = errors
822                .iter()
823                .map(|e| Diagnostic::from(e.clone()).render(&src))
824                .collect::<Vec<_>>()
825                .join("");
826            assert!(
827                rendered.is_empty(),
828                "{name}.qala: errors-rendered-to-empty failed: {rendered:?}"
829            );
830        }
831    }
832
833    #[test]
834    fn warnings_never_block_typed_ast_construction() {
835        // a program that produces a warning still yields a non-empty
836        // TypedAst. this is a regression check that warnings are not
837        // errors: the typechecker emits the warning AND keeps going.
838        let src = "fn main() is io {\nlet x = 1\nprintln(\"hi\")\n}";
839        let tokens = lexer::Lexer::tokenize(src).expect("lex");
840        let ast = parser::Parser::parse(&tokens).expect("parse");
841        let (typed, errors, warnings) = typechecker::check_program(&ast, src);
842        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
843        assert!(!warnings.is_empty(), "expected at least one warning");
844        assert!(
845            !typed.is_empty(),
846            "warnings must not block typed-AST construction"
847        );
848    }
849
850    #[test]
851    fn render_with_no_notes_or_hints_ends_at_the_bottom_separator() {
852        // a clean Diagnostic (no notes, no hints) should produce
853        // exactly: header + arrow + top separator + source line +
854        // underline + bottom separator. no trailing `= note:` or
855        // `= hint:` lines.
856        let d = Diagnostic {
857            severity: Severity::Error,
858            category: None,
859            message: "boom".to_string(),
860            primary_span: Span::new(0, 1),
861            notes: Vec::new(),
862            hints: Vec::new(),
863        };
864        let out = d.render("x");
865        // the last non-empty line is the bottom separator.
866        assert!(out.ends_with("   |\n"), "unexpected tail: {out:?}");
867        assert!(!out.contains("= note:"), "no notes expected: {out:?}");
868        assert!(!out.contains("= hint:"), "no hints expected: {out:?}");
869    }
870
871    #[test]
872    fn renderer_is_byte_deterministic_against_snapshot() {
873        // cross-process determinism: this test re-runs the renderer
874        // over four fixed bad programs and asserts byte-equality
875        // against the frozen snapshot in
876        // `compiler/tests/snapshots/diagnostics_basic.txt`. if the
877        // renderer's format ever drifts -- a column-width change, an
878        // emoji, a stray colour code, a trailing space -- this test
879        // fails and the snapshot must be deliberately regenerated.
880        //
881        // the four cases lock the four diagnostic shapes the
882        // playground will hit hardest: a type-mismatch, a
883        // non-exhaustive match, an effect violation, and an
884        // unused_var warning. they cover error vs warning headers,
885        // single-line vs multi-token underlines, the `add an arm
886        // for:` note, and the `is pure` hint.
887        let cases: [String; 4] = [
888            diag_for("fn f() -> i64 { return \"x\" }").render("fn f() -> i64 { return \"x\" }"),
889            {
890                let src = "enum Shape { Circle(i64), Rect(i64), Triangle(i64) }\nfn f(s: Shape) -> i64 { match s { Circle(r) => r, Rect(w) => w } }";
891                diag_for(src).render(src)
892            },
893            diag_for("fn f() is pure { println(\"hi\") }")
894                .render("fn f() is pure { println(\"hi\") }"),
895            {
896                let src = "fn main() is io {\n  let x = 1\n  println(\"hi\")\n}";
897                warn_diag_for(src).render(src)
898            },
899        ];
900        let combined = cases.join("\n===\n");
901
902        let snapshot_path = format!(
903            "{}/tests/snapshots/diagnostics_basic.txt",
904            env!("CARGO_MANIFEST_DIR")
905        );
906        // normalize CRLF -> LF on read: on Windows with core.autocrlf
907        // true, a checked-out snapshot file has CRLF line endings even
908        // though the in-repo blob is LF; the renderer always emits LF;
909        // stripping carriage returns makes the comparison
910        // platform-stable. the snapshot blob itself stays LF.
911        let snapshot = std::fs::read_to_string(&snapshot_path)
912            .unwrap_or_else(|e| panic!("read {snapshot_path}: {e}"))
913            .replace("\r\n", "\n");
914
915        assert_eq!(
916            combined, snapshot,
917            "renderer output drifted from snapshot at {snapshot_path}"
918        );
919    }
920}