doge_compiler/diagnostics.rs
1/// A single compile error. The front end stops at the first one (docs/ERRORS.md:
2/// "one issue at a time"), so a failed compile yields exactly one `Diagnostic`.
3#[derive(Debug, Clone, PartialEq)]
4pub struct Diagnostic {
5 /// The meme framing line, e.g. `very error. much confuse.`. A more specific
6 /// headline is used when one fits (`very tab. much confuse.`).
7 pub headline: String,
8 /// Path of the source file, shown above the offending line.
9 pub path: String,
10 /// 1-based line the error points at.
11 pub line: u32,
12 /// 1-based column the caret sits under.
13 pub col: u32,
14 /// The offending source line, verbatim (no trailing newline).
15 pub source_line: String,
16 /// The precise, plain-language explanation printed after the caret.
17 pub message: String,
18 /// An optional concrete fix, rendered as `such fix: …`.
19 pub hint: Option<String>,
20}
21
22/// The default meme framing, used unless a more specific headline fits.
23pub const DEFAULT_HEADLINE: &str = "very error. much confuse.";
24
25/// Split source into diagnostic lines: one `String` per line, each stripped of a
26/// trailing `\r` so caret columns line up on both `\n` and `\r\n` files. Every
27/// pass that anchors diagnostics splits its source through this.
28pub(crate) fn split_source_lines(source: &str) -> Vec<String> {
29 source
30 .split('\n')
31 .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
32 .collect()
33}
34
35/// The 1-based source line a diagnostic points at, pulled from a file's
36/// already-split lines. A line number past the end falls back to an empty line.
37/// The lexer, parser, and checker all anchor diagnostics through this.
38pub(crate) fn source_line(lines: &[String], line: u32) -> String {
39 lines
40 .get((line as usize).saturating_sub(1))
41 .cloned()
42 .unwrap_or_default()
43}
44
45impl Diagnostic {
46 /// Build a diagnostic with the default headline.
47 pub fn new(
48 path: impl Into<String>,
49 line: u32,
50 col: u32,
51 source_line: impl Into<String>,
52 message: impl Into<String>,
53 ) -> Diagnostic {
54 Diagnostic {
55 headline: DEFAULT_HEADLINE.to_string(),
56 path: path.into(),
57 line,
58 col,
59 source_line: source_line.into(),
60 message: message.into(),
61 hint: None,
62 }
63 }
64
65 /// Replace the default headline with a specific meme framing.
66 pub fn with_headline(mut self, headline: impl Into<String>) -> Diagnostic {
67 self.headline = headline.into();
68 self
69 }
70
71 /// Attach a `such fix: …` hint.
72 pub fn with_hint(mut self, hint: impl Into<String>) -> Diagnostic {
73 self.hint = Some(hint.into());
74 self
75 }
76
77 /// Render the diagnostic in the exact docs/ERRORS.md shape:
78 ///
79 /// ```text
80 /// very error. much confuse.
81 ///
82 /// examples/hello.doge:4
83 /// bark "hello" + 5
84 /// ^ cannot + a Str and an Int
85 ///
86 /// such fix: turn the Int into a Str first, e.g. str(5)
87 /// ```
88 ///
89 /// The code line is indented four spaces; the caret sits under `col`
90 /// (1-based), so its leading padding is `4 + (col - 1)` spaces.
91 pub fn render(&self) -> String {
92 let mut out = String::new();
93 out.push_str(&self.headline);
94 out.push_str("\n\n");
95
96 // Location line, indented two spaces.
97 out.push_str(&format!(" {}:{}\n", self.path, self.line));
98
99 // The offending source line, indented four spaces.
100 out.push_str(&format!(" {}\n", self.source_line));
101
102 // Caret line: four spaces of code indent, then (col - 1) more to reach
103 // the offending column, then the caret and the message.
104 let caret_pad = 4 + self.col.saturating_sub(1) as usize;
105 out.push_str(&" ".repeat(caret_pad));
106 out.push_str(&format!("^ {}\n", self.message));
107
108 if let Some(hint) = &self.hint {
109 out.push_str(&format!("\nsuch fix: {hint}\n"));
110 }
111
112 out
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn render_matches_design_section_7() {
122 // The source line is passed verbatim (top-level, no indentation); render
123 // adds the 4-space code indent and places the caret under `+` at col 14.
124 let diag = Diagnostic::new(
125 "examples/hello.doge",
126 4,
127 14,
128 "bark \"hello\" + 5",
129 "cannot + a Str and an Int",
130 )
131 .with_hint("turn the Int into a Str first, e.g. str(5)");
132
133 let expected = "\
134very error. much confuse.
135
136 examples/hello.doge:4
137 bark \"hello\" + 5
138 ^ cannot + a Str and an Int
139
140such fix: turn the Int into a Str first, e.g. str(5)
141";
142 assert_eq!(diag.render(), expected);
143 }
144
145 #[test]
146 fn render_without_hint_omits_fix_block() {
147 let diag = Diagnostic::new("f.doge", 1, 1, "wut", "unexpected");
148 let rendered = diag.render();
149 assert!(!rendered.contains("such fix:"));
150 assert!(rendered.ends_with("^ unexpected\n"));
151 }
152}