Skip to main content

brief/
diag.rs

1use crate::span::{SourceMap, Span};
2use std::fmt::Write;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum Severity {
6    Error,
7    Warning,
8}
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Code {
12    InvalidUtf8 = 101,
13    TabCharacter = 102,
14    BomNotAtStart = 103,
15    UnexpectedChar = 104,
16
17    EmphasisSameMarker = 204,
18    EmphasisCrossLine = 205,
19    DoubledEmphasis = 206,
20    UnterminatedEmph = 207,
21    UnterminatedCode = 208,
22
23    HeadingTooDeep = 301,
24    HeadingNoSpace = 302,
25    BadIndent = 303,
26    BadHorizontalRule = 304,
27    UnterminatedFence = 305,
28    UnterminatedBlock = 306,
29    InlineBlockComment = 307,
30    BadListMarker = 308,
31    EmptyDocument = 309,
32    BadBlockquote = 310,
33    StrayEnd = 311,
34    StrayContent = 312,
35    UnterminatedFrontmatter = 313,
36    FrontmatterToml = 314,
37    UnknownCodeAttribute = 315,
38    ConflictingCodeAttributes = 316,
39    BadHeadingAnchor = 317,
40    NestingTooDeep = 318,
41
42    MinifyFailed = 701,
43    CodeBlockLineCount = 702,
44    LineCommentConverted = 703,
45    RefusedLanguage = 704,
46
47    UnknownShortcode = 401,
48    ArgTypeMismatch = 402,
49    MissingArg = 403,
50    BadEnumValue = 404,
51    FormMismatch = 405,
52    BadArgSyntax = 406,
53    DuplicateKwarg = 407,
54    DeprecatedCalloutKind = 408,
55    UnknownArg = 409,
56
57    OrderedListSequence = 501,
58    TableColumnMismatch = 502,
59    HeadingMonotonic = 503,
60    AlignArrayLength = 504,
61    BadDefinitionList = 505,
62    DuplicateHeadingAnchor = 506,
63
64    RefMissingFile = 601,
65    RefMissingAnchor = 602,
66    RefBadTarget = 603,
67    RefNoProject = 604,
68}
69
70impl Code {
71    /// Every diagnostic code the compiler can emit. `brief explain` coverage
72    /// is tested against this list — extend it when adding a variant.
73    pub const ALL: &'static [Code] = &[
74        Code::InvalidUtf8,
75        Code::TabCharacter,
76        Code::BomNotAtStart,
77        Code::UnexpectedChar,
78        Code::EmphasisSameMarker,
79        Code::EmphasisCrossLine,
80        Code::DoubledEmphasis,
81        Code::UnterminatedEmph,
82        Code::UnterminatedCode,
83        Code::HeadingTooDeep,
84        Code::HeadingNoSpace,
85        Code::BadIndent,
86        Code::BadHorizontalRule,
87        Code::UnterminatedFence,
88        Code::UnterminatedBlock,
89        Code::InlineBlockComment,
90        Code::BadListMarker,
91        Code::EmptyDocument,
92        Code::BadBlockquote,
93        Code::StrayEnd,
94        Code::StrayContent,
95        Code::UnterminatedFrontmatter,
96        Code::FrontmatterToml,
97        Code::UnknownCodeAttribute,
98        Code::ConflictingCodeAttributes,
99        Code::BadHeadingAnchor,
100        Code::NestingTooDeep,
101        Code::UnknownShortcode,
102        Code::ArgTypeMismatch,
103        Code::MissingArg,
104        Code::BadEnumValue,
105        Code::FormMismatch,
106        Code::BadArgSyntax,
107        Code::DuplicateKwarg,
108        Code::DeprecatedCalloutKind,
109        Code::UnknownArg,
110        Code::OrderedListSequence,
111        Code::TableColumnMismatch,
112        Code::HeadingMonotonic,
113        Code::AlignArrayLength,
114        Code::BadDefinitionList,
115        Code::DuplicateHeadingAnchor,
116        Code::RefMissingFile,
117        Code::RefMissingAnchor,
118        Code::RefBadTarget,
119        Code::RefNoProject,
120        Code::MinifyFailed,
121        Code::CodeBlockLineCount,
122        Code::LineCommentConverted,
123        Code::RefusedLanguage,
124    ];
125
126    pub fn as_str(self) -> String {
127        format!("B{:04}", self as u32)
128    }
129
130    pub fn message(self) -> &'static str {
131        use Code::*;
132        match self {
133            InvalidUtf8 => "invalid UTF-8 in source",
134            TabCharacter => "tab character is not allowed; use two spaces",
135            BomNotAtStart => "byte-order mark must only appear at start of file",
136            UnexpectedChar => "unexpected character",
137            EmphasisSameMarker => "emphasis cannot nest with the same marker",
138            EmphasisCrossLine => "emphasis must open and close on the same line",
139            DoubledEmphasis => "doubled emphasis markers are not valid; use a single marker",
140            UnterminatedEmph => "unterminated emphasis",
141            UnterminatedCode => "unterminated inline code span",
142            HeadingTooDeep => "heading level exceeds maximum of 6",
143            HeadingNoSpace => "heading marker must be followed by exactly one space",
144            BadIndent => "indentation must be in multiples of two spaces",
145            BadHorizontalRule => "horizontal rule must be exactly three dashes",
146            UnterminatedFence => "unterminated code fence",
147            UnterminatedBlock => "unterminated block shortcode",
148            InlineBlockComment => "block comments must start at the beginning of a line",
149            BadListMarker => "invalid list marker",
150            EmptyDocument => "document is empty",
151            BadBlockquote => "blockquote marker must be followed by a space",
152            StrayEnd => "`@end` without a matching block shortcode",
153            StrayContent => "unexpected content after directive",
154            UnterminatedFrontmatter => "frontmatter `+++` block is never closed",
155            FrontmatterToml => "frontmatter is not valid TOML",
156            UnknownCodeAttribute => "unknown code-fence attribute",
157            ConflictingCodeAttributes => "conflicting code-fence attributes",
158            BadHeadingAnchor => "invalid heading anchor",
159            NestingTooDeep => "blocks are nested too deeply",
160            BadDefinitionList => "malformed definition list",
161            MinifyFailed => "code block did not parse in its tagged language; emitted verbatim",
162            CodeBlockLineCount => {
163                "minified code block was originally many lines; LLM consumers cannot reference specific lines"
164            }
165            LineCommentConverted => "line comment converted to block-comment form for minification",
166            RefusedLanguage => "language uses significant whitespace and cannot be safely minified",
167            UnknownShortcode => "shortcode is not registered",
168            ArgTypeMismatch => "shortcode argument has wrong type",
169            MissingArg => "missing required shortcode argument",
170            BadEnumValue => "argument value is not in the allowed set",
171            FormMismatch => "shortcode used in the wrong form (block vs. inline)",
172            BadArgSyntax => "malformed shortcode argument syntax",
173            DuplicateKwarg => "keyword argument given more than once",
174            DeprecatedCalloutKind => "callout kind is deprecated; use the GFM equivalent",
175            UnknownArg => "shortcode does not declare this argument",
176            OrderedListSequence => "ordered list numbering must be sequential starting from 1",
177            TableColumnMismatch => "table row column count does not match header",
178            HeadingMonotonic => "heading levels must increase by at most one",
179            AlignArrayLength => "alignment array length must equal the column count",
180            DuplicateHeadingAnchor => "heading anchor must be unique within a document",
181            RefMissingFile => "cross-document reference target file does not exist in project",
182            RefMissingAnchor => {
183                "cross-document reference target anchor does not exist in target file"
184            }
185            RefBadTarget => "malformed cross-document reference target",
186            RefNoProject => "`@ref` requires a `brief.toml`-rooted project; none found",
187        }
188    }
189}
190
191#[derive(Clone, Debug)]
192pub struct Diagnostic {
193    pub code: Code,
194    pub span: Span,
195    pub label: Option<String>,
196    pub help: Option<String>,
197    pub severity: Severity,
198}
199
200impl Diagnostic {
201    pub fn new(code: Code, span: Span) -> Self {
202        Diagnostic {
203            code,
204            span,
205            label: None,
206            help: None,
207            severity: Severity::Error,
208        }
209    }
210    pub fn warning(code: Code, span: Span) -> Self {
211        Diagnostic {
212            code,
213            span,
214            label: None,
215            help: None,
216            severity: Severity::Warning,
217        }
218    }
219    pub fn label(mut self, s: impl Into<String>) -> Self {
220        self.label = Some(s.into());
221        self
222    }
223    pub fn help(mut self, s: impl Into<String>) -> Self {
224        self.help = Some(s.into());
225        self
226    }
227}
228
229pub fn render(diag: &Diagnostic, src: &SourceMap) -> String {
230    let mut out = String::new();
231    let (line, col) = src.line_col(diag.span.start);
232    let prefix = match diag.severity {
233        Severity::Error => "error",
234        Severity::Warning => "warning",
235    };
236    let _ = writeln!(
237        out,
238        "{}[{}]: {}",
239        prefix,
240        diag.code.as_str(),
241        diag.code.message()
242    );
243    let _ = writeln!(out, "  --> {}:{}:{}", src.path, line, col);
244    let _ = writeln!(out, "   |");
245    let line_text = src.line_text(line);
246    let _ = writeln!(out, "{:>3} | {}", line, line_text);
247    let pad: String = std::iter::repeat(' ').take(col.saturating_sub(1)).collect();
248    let line_remaining = line_text
249        .chars()
250        .count()
251        .saturating_sub(col.saturating_sub(1));
252    let caret_len = (diag.span.len as usize).max(1).min(line_remaining.max(1));
253    let carets: String = std::iter::repeat('^').take(caret_len).collect();
254    let label = diag.label.as_deref().unwrap_or("");
255    let _ = writeln!(out, "   | {}{} {}", pad, carets, label);
256    if let Some(help) = &diag.help {
257        let _ = writeln!(out, "   |");
258        let _ = writeln!(out, "   = help: {}", help);
259    }
260    out
261}
262
263pub fn render_all(diags: &[Diagnostic], src: &SourceMap) -> String {
264    diags
265        .iter()
266        .map(|d| render(d, src))
267        .collect::<Vec<_>>()
268        .join("\n")
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn renders_with_caret() {
277        let src = SourceMap::new("doc.brf", "abc\nhello world\n");
278        let span = Span::new(4, 5);
279        let d = Diagnostic::new(Code::UnexpectedChar, span).label("here");
280        let out = render(&d, &src);
281        assert!(out.contains("error[B0104]"));
282        assert!(out.contains("doc.brf:2:1"));
283        assert!(out.contains("hello world"));
284        assert!(out.contains("^^^^^"));
285    }
286
287    #[test]
288    fn ref_codes_render_with_correct_prefix() {
289        use Code::*;
290        assert_eq!(RefMissingFile.as_str(), "B0601");
291        assert_eq!(RefMissingAnchor.as_str(), "B0602");
292        assert_eq!(RefBadTarget.as_str(), "B0603");
293        assert_eq!(RefNoProject.as_str(), "B0604");
294        assert!(RefMissingFile.message().contains("file"));
295        assert!(RefMissingAnchor.message().contains("anchor"));
296        assert!(RefBadTarget.message().contains("target"));
297        assert!(RefNoProject.message().contains("brief.toml"));
298    }
299
300    #[test]
301    fn bad_definition_list_code_renders() {
302        use Code::*;
303        assert_eq!(BadDefinitionList.as_str(), "B0505");
304        assert!(BadDefinitionList.message().contains("definition list"));
305    }
306}