Skip to main content

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}