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
41    CodeBlockLineCount = 702,
42    LineCommentConverted = 703,
43    RefusedLanguage = 704,
44
45    UnknownShortcode = 401,
46    ArgTypeMismatch = 402,
47    MissingArg = 403,
48    BadEnumValue = 404,
49    FormMismatch = 405,
50    BadArgSyntax = 406,
51    DuplicateKwarg = 407,
52    DeprecatedCalloutKind = 408,
53
54    OrderedListSequence = 501,
55    TableColumnMismatch = 502,
56    HeadingMonotonic = 503,
57    AlignArrayLength = 504,
58    BadDefinitionList = 505,
59    DuplicateHeadingAnchor = 506,
60
61    RefMissingFile = 601,
62    RefMissingAnchor = 602,
63    RefBadTarget = 603,
64    RefNoProject = 604,
65}
66
67impl Code {
68    pub fn as_str(self) -> String {
69        format!("B{:04}", self as u32)
70    }
71
72    pub fn message(self) -> &'static str {
73        use Code::*;
74        match self {
75            InvalidUtf8 => "invalid UTF-8 in source",
76            TabCharacter => "tab character is not allowed; use two spaces",
77            BomNotAtStart => "byte-order mark must only appear at start of file",
78            UnexpectedChar => "unexpected character",
79            EmphasisSameMarker => "emphasis cannot nest with the same marker",
80            EmphasisCrossLine => "emphasis must open and close on the same line",
81            DoubledEmphasis => "doubled emphasis markers are not valid; use a single marker",
82            UnterminatedEmph => "unterminated emphasis",
83            UnterminatedCode => "unterminated inline code span",
84            HeadingTooDeep => "heading level exceeds maximum of 6",
85            HeadingNoSpace => "heading marker must be followed by exactly one space",
86            BadIndent => "indentation must be in multiples of two spaces",
87            BadHorizontalRule => "horizontal rule must be exactly three dashes",
88            UnterminatedFence => "unterminated code fence",
89            UnterminatedBlock => "unterminated block shortcode",
90            InlineBlockComment => "block comments must start at the beginning of a line",
91            BadListMarker => "invalid list marker",
92            EmptyDocument => "document is empty",
93            BadBlockquote => "blockquote marker must be followed by a space",
94            StrayEnd => "`@end` without a matching block shortcode",
95            StrayContent => "unexpected content after directive",
96            UnterminatedFrontmatter => "frontmatter `+++` block is never closed",
97            FrontmatterToml => "frontmatter is not valid TOML",
98            UnknownCodeAttribute => "unknown code-fence attribute",
99            ConflictingCodeAttributes => "conflicting code-fence attributes",
100            BadHeadingAnchor => "invalid heading anchor",
101            BadDefinitionList => "malformed definition list",
102            CodeBlockLineCount => {
103                "minified code block was originally many lines; LLM consumers cannot reference specific lines"
104            }
105            LineCommentConverted => "line comment converted to block-comment form for minification",
106            RefusedLanguage => "language uses significant whitespace and cannot be safely minified",
107            UnknownShortcode => "shortcode is not registered",
108            ArgTypeMismatch => "shortcode argument has wrong type",
109            MissingArg => "missing required shortcode argument",
110            BadEnumValue => "argument value is not in the allowed set",
111            FormMismatch => "shortcode used in the wrong form (block vs. inline)",
112            BadArgSyntax => "malformed shortcode argument syntax",
113            DuplicateKwarg => "keyword argument given more than once",
114            DeprecatedCalloutKind => "callout kind is deprecated; use the GFM equivalent",
115            OrderedListSequence => "ordered list numbering must be sequential starting from 1",
116            TableColumnMismatch => "table row column count does not match header",
117            HeadingMonotonic => "heading levels must increase by at most one",
118            AlignArrayLength => "alignment array length must equal the column count",
119            DuplicateHeadingAnchor => "heading anchor must be unique within a document",
120            RefMissingFile => "cross-document reference target file does not exist in project",
121            RefMissingAnchor => {
122                "cross-document reference target anchor does not exist in target file"
123            }
124            RefBadTarget => "malformed cross-document reference target",
125            RefNoProject => "`@ref` requires a `brief.toml`-rooted project; none found",
126        }
127    }
128}
129
130#[derive(Clone, Debug)]
131pub struct Diagnostic {
132    pub code: Code,
133    pub span: Span,
134    pub label: Option<String>,
135    pub help: Option<String>,
136    pub severity: Severity,
137}
138
139impl Diagnostic {
140    pub fn new(code: Code, span: Span) -> Self {
141        Diagnostic {
142            code,
143            span,
144            label: None,
145            help: None,
146            severity: Severity::Error,
147        }
148    }
149    pub fn warning(code: Code, span: Span) -> Self {
150        Diagnostic {
151            code,
152            span,
153            label: None,
154            help: None,
155            severity: Severity::Warning,
156        }
157    }
158    pub fn label(mut self, s: impl Into<String>) -> Self {
159        self.label = Some(s.into());
160        self
161    }
162    pub fn help(mut self, s: impl Into<String>) -> Self {
163        self.help = Some(s.into());
164        self
165    }
166}
167
168pub fn render(diag: &Diagnostic, src: &SourceMap) -> String {
169    let mut out = String::new();
170    let (line, col) = src.line_col(diag.span.start);
171    let prefix = match diag.severity {
172        Severity::Error => "error",
173        Severity::Warning => "warning",
174    };
175    let _ = writeln!(
176        out,
177        "{}[{}]: {}",
178        prefix,
179        diag.code.as_str(),
180        diag.code.message()
181    );
182    let _ = writeln!(out, "  --> {}:{}:{}", src.path, line, col);
183    let _ = writeln!(out, "   |");
184    let line_text = src.line_text(line);
185    let _ = writeln!(out, "{:>3} | {}", line, line_text);
186    let pad: String = std::iter::repeat(' ').take(col.saturating_sub(1)).collect();
187    let line_remaining = line_text
188        .chars()
189        .count()
190        .saturating_sub(col.saturating_sub(1));
191    let caret_len = (diag.span.len as usize).max(1).min(line_remaining.max(1));
192    let carets: String = std::iter::repeat('^').take(caret_len).collect();
193    let label = diag.label.as_deref().unwrap_or("");
194    let _ = writeln!(out, "   | {}{} {}", pad, carets, label);
195    if let Some(help) = &diag.help {
196        let _ = writeln!(out, "   |");
197        let _ = writeln!(out, "   = help: {}", help);
198    }
199    out
200}
201
202pub fn render_all(diags: &[Diagnostic], src: &SourceMap) -> String {
203    diags
204        .iter()
205        .map(|d| render(d, src))
206        .collect::<Vec<_>>()
207        .join("\n")
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn renders_with_caret() {
216        let src = SourceMap::new("doc.brf", "abc\nhello world\n");
217        let span = Span::new(4, 5);
218        let d = Diagnostic::new(Code::UnexpectedChar, span).label("here");
219        let out = render(&d, &src);
220        assert!(out.contains("error[B0104]"));
221        assert!(out.contains("doc.brf:2:1"));
222        assert!(out.contains("hello world"));
223        assert!(out.contains("^^^^^"));
224    }
225
226    #[test]
227    fn ref_codes_render_with_correct_prefix() {
228        use Code::*;
229        assert_eq!(RefMissingFile.as_str(), "B0601");
230        assert_eq!(RefMissingAnchor.as_str(), "B0602");
231        assert_eq!(RefBadTarget.as_str(), "B0603");
232        assert_eq!(RefNoProject.as_str(), "B0604");
233        assert!(RefMissingFile.message().contains("file"));
234        assert!(RefMissingAnchor.message().contains("anchor"));
235        assert!(RefBadTarget.message().contains("target"));
236        assert!(RefNoProject.message().contains("brief.toml"));
237    }
238
239    #[test]
240    fn bad_definition_list_code_renders() {
241        use Code::*;
242        assert_eq!(BadDefinitionList.as_str(), "B0505");
243        assert!(BadDefinitionList.message().contains("definition list"));
244    }
245}