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 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}