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}