1use miette::{Diagnostic, LabeledSpan, Severity};
2use owo_colors::OwoColorize;
3use std::fmt;
4use std::sync::Arc;
5
6use syntax::ParseError;
7use syntax::ast::Span;
8
9#[derive(Clone, Debug)]
11pub struct IndexedSource {
12 source: Arc<str>,
13 line_starts: Arc<[usize]>,
14}
15
16impl IndexedSource {
17 pub fn new(s: &str) -> Self {
18 let mut line_starts = vec![0usize];
19 for (i, byte) in s.bytes().enumerate() {
20 if byte == b'\n' {
21 line_starts.push(i + 1);
22 }
23 }
24 Self {
25 source: Arc::from(s),
26 line_starts: Arc::from(line_starts),
27 }
28 }
29
30 pub fn line_col(&self, offset: usize) -> (usize, usize) {
31 let line = match self.line_starts.binary_search(&offset) {
32 Ok(exact) => exact,
33 Err(idx) => idx.saturating_sub(1),
34 };
35 (line + 1, offset - self.line_starts[line] + 1)
36 }
37}
38
39impl miette::SourceCode for IndexedSource {
40 fn read_span<'a>(
41 &'a self,
42 span: &miette::SourceSpan,
43 context_lines_before: usize,
44 context_lines_after: usize,
45 ) -> Result<Box<dyn miette::SpanContents<'a> + 'a>, miette::MietteError> {
46 let src = self.source.as_ref();
47 let offset = span.offset();
48 let len = span.len();
49
50 if offset + len > src.len() {
51 return Err(miette::MietteError::OutOfBounds);
52 }
53
54 let span_line = match self.line_starts.binary_search(&offset) {
55 Ok(exact) => exact,
56 Err(idx) => idx.saturating_sub(1),
57 };
58
59 let start_line = span_line.saturating_sub(context_lines_before);
60 let start_offset = self.line_starts[start_line];
61 let start_column = if context_lines_before == 0 {
62 offset - self.line_starts[span_line]
63 } else {
64 0
65 };
66
67 let span_end = offset + len.saturating_sub(1);
68 let end_line = match self.line_starts.binary_search(&span_end) {
69 Ok(exact) => exact,
70 Err(idx) => idx.saturating_sub(1),
71 };
72
73 let last_line = (end_line + context_lines_after).min(self.line_starts.len() - 1);
74 let end_offset = if last_line + 1 < self.line_starts.len() {
75 self.line_starts[last_line + 1].min(src.len())
76 } else {
77 src.len()
78 };
79
80 Ok(Box::new(miette::MietteSpanContents::new(
81 &src.as_bytes()[start_offset..end_offset],
82 (start_offset, end_offset - start_offset).into(),
83 start_line,
84 start_column,
85 last_line + 1,
86 )))
87 }
88}
89
90fn strip_period(s: &str, strip: bool) -> &str {
91 if strip {
92 s.strip_suffix('.').unwrap_or(s)
93 } else {
94 s
95 }
96}
97
98fn span_to_labeled(span: &Span, text: String, primary: bool) -> LabeledSpan {
99 let source_span = miette::SourceSpan::new(
100 (span.byte_offset as usize).into(),
101 span.byte_length as usize,
102 );
103 if primary {
104 LabeledSpan::new_primary_with_span(Some(text), source_span)
105 } else {
106 LabeledSpan::new_with_span(Some(text), source_span)
107 }
108}
109
110pub use miette::Report;
111
112impl From<ParseError> for LisetteDiagnostic {
113 fn from(err: ParseError) -> Self {
114 let mut diagnostic = LisetteDiagnostic::error(&err.message);
115
116 for (span, label) in &err.labels {
117 diagnostic = diagnostic.with_span_label(span, label);
118 }
119
120 if let Some(help) = err.help {
121 diagnostic = diagnostic.with_help(help);
122 }
123
124 if let Some(note) = err.note {
125 diagnostic = diagnostic.with_note(note);
126 }
127
128 if !err.code.is_empty() {
129 diagnostic = diagnostic.with_code(err.code);
130 }
131
132 diagnostic
133 }
134}
135
136fn format_with_backticks<F>(text: &str, use_color: bool, base_style: F) -> String
137where
138 F: Fn(&str) -> String,
139{
140 if !use_color {
141 return text.to_string();
142 }
143
144 let mut result = String::new();
145 let mut chars = text.char_indices().peekable();
146 let mut segment_start = 0;
147
148 while let Some((i, ch)) = chars.next() {
149 if ch == '`' {
150 if i > segment_start {
151 result.push_str(&base_style(&text[segment_start..i]));
152 }
153
154 let mut found_closing = false;
155 for (j, inner_ch) in chars.by_ref() {
156 if inner_ch == '`' {
157 let quoted = &text[i + 1..j];
158 result.push_str(&format!("{}", quoted.bright_magenta()));
159 segment_start = j + 1;
160 found_closing = true;
161 break;
162 }
163 }
164
165 if !found_closing {
166 result.push_str(&base_style(&text[i..]));
167 segment_start = text.len();
168 }
169 }
170 }
171
172 if segment_start < text.len() {
173 result.push_str(&base_style(&text[segment_start..]));
174 }
175
176 result
177}
178
179#[derive(Debug, Clone)]
180#[must_use]
181pub struct LisetteDiagnostic {
182 message: String,
183 labels: Vec<LabeledSpan>,
184 help: Option<String>,
185 note: Option<String>,
186 severity: Severity,
187 code: Option<String>,
188 file_id: Option<u32>,
189 use_color: bool,
190}
191
192impl fmt::Display for LisetteDiagnostic {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 if self.use_color {
195 let styled_message = match self.severity {
196 Severity::Error => {
197 format_with_backticks(&self.message, true, |s| format!("{}", s.red().bold()))
198 }
199 Severity::Warning => {
200 format_with_backticks(&self.message, true, |s| format!("{}", s.yellow().bold()))
201 }
202 Severity::Advice => {
203 format_with_backticks(&self.message, true, |s| format!("{}", s.blue().bold()))
204 }
205 };
206 write!(f, "{}", styled_message)?;
207 } else {
208 self.message.fmt(f)?;
209 }
210 Ok(())
211 }
212}
213
214impl std::error::Error for LisetteDiagnostic {}
215
216struct HelpText<'a> {
217 help: Option<&'a str>,
218 note: Option<&'a str>,
219 diagnostic_code: Option<&'a str>,
220 use_color: bool,
221}
222
223impl fmt::Display for HelpText<'_> {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 let use_color = self.use_color;
226 let has_code = self.diagnostic_code.is_some();
227
228 let combined = match (self.help, self.note) {
229 (Some(h), Some(n)) => format!("{} {}", h, strip_period(n, has_code)),
230 (Some(h), None) => strip_period(h, has_code).to_string(),
231 (None, Some(n)) => strip_period(n, has_code).to_string(),
232 (None, None) => String::new(),
233 };
234
235 if !combined.is_empty() {
236 if use_color {
237 let styled = format_with_backticks(&combined, true, |s| format!("{}", s.dimmed()));
238 write!(f, "{}", styled)?;
239 } else {
240 write!(f, "{}", combined)?;
241 }
242 }
243
244 if let Some(code) = self.diagnostic_code {
245 let is_listing = self
246 .help
247 .is_some_and(|h| h.lines().skip(1).any(|line| line.starts_with(" ")));
248 let prefix = if is_listing { "\ncode: " } else { " ยท code: " };
249 if use_color {
250 write!(f, "{}{}", prefix.dimmed(), format!("[{}]", code).dimmed())?;
251 } else {
252 write!(f, "{}[{}]", prefix, code)?;
253 }
254 }
255
256 Ok(())
257 }
258}
259
260impl Diagnostic for LisetteDiagnostic {
261 fn severity(&self) -> Option<Severity> {
262 Some(self.severity)
263 }
264
265 fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
266 let diagnostic_code = self.code.as_deref();
267
268 if self.help.is_none() && self.note.is_none() && diagnostic_code.is_none() {
269 return None;
270 }
271 Some(Box::new(HelpText {
272 help: self.help.as_deref(),
273 note: self.note.as_deref(),
274 diagnostic_code,
275 use_color: self.use_color,
276 }))
277 }
278
279 fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
280 let use_color = self.use_color;
281 let severity = self.severity;
282
283 let formatted_labels = self.labels.iter().map(move |span| {
284 if let Some(label) = span.label() {
285 let formatted = if use_color {
286 let base_style = match severity {
287 Severity::Error => |s: &str| format!("{}", s.red()),
288 Severity::Warning => |s: &str| format!("{}", s.yellow()),
289 Severity::Advice => |s: &str| format!("{}", s.blue()),
290 };
291 format_with_backticks(label, true, base_style)
292 } else {
293 label.to_string()
294 };
295 if span.primary() {
296 LabeledSpan::new_primary_with_span(Some(formatted), *span.inner())
297 } else {
298 LabeledSpan::new_with_span(Some(formatted), *span.inner())
299 }
300 } else {
301 span.clone()
302 }
303 });
304
305 Some(Box::new(formatted_labels))
306 }
307
308 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
309 None }
311}
312
313impl LisetteDiagnostic {
314 pub fn plain_message(&self) -> &str {
315 &self.message
316 }
317
318 pub fn plain_help(&self) -> Option<&str> {
319 self.help.as_deref()
320 }
321
322 pub fn plain_note(&self) -> Option<&str> {
323 self.note.as_deref()
324 }
325
326 fn new(message: impl Into<String>, severity: Severity) -> Self {
327 Self {
328 message: message.into(),
329 labels: Vec::new(),
330 help: None,
331 note: None,
332 severity,
333 code: None,
334 file_id: None,
335 use_color: false,
336 }
337 }
338
339 pub fn error(message: impl Into<String>) -> Self {
340 Self::new(message, Severity::Error)
341 }
342
343 pub fn warn(message: impl Into<String>) -> Self {
344 Self::new(message, Severity::Warning)
345 }
346
347 pub fn info(message: impl Into<String>) -> Self {
348 Self::new(message, Severity::Advice)
349 }
350
351 pub fn with_color(mut self, use_color: bool) -> Self {
352 self.use_color = use_color;
353 self
354 }
355
356 pub fn with_span_label(mut self, span: &Span, text: impl Into<String>) -> Self {
357 if self.file_id.is_none() {
358 self.file_id = Some(span.file_id);
359 }
360 self.labels.push(span_to_labeled(span, text.into(), false));
361 self
362 }
363
364 pub fn with_span_primary_label(mut self, span: &Span, text: impl Into<String>) -> Self {
365 if self.file_id.is_none() {
366 self.file_id = Some(span.file_id);
367 }
368 self.labels.push(span_to_labeled(span, text.into(), true));
369 self
370 }
371
372 pub fn with_labels(mut self, labels: Vec<LabeledSpan>) -> Self {
373 self.labels.extend(labels);
374 self
375 }
376
377 pub fn with_help(mut self, help: impl Into<String>) -> Self {
378 self.help = Some(help.into());
379 self
380 }
381
382 pub fn with_note(mut self, note: impl Into<String>) -> Self {
383 self.note = Some(note.into());
384 self
385 }
386
387 pub fn with_lex_code(mut self, code: &str) -> Self {
388 self.code = Some(format!("lex.{}", code));
389 self
390 }
391
392 pub fn with_parse_code(mut self, code: &str) -> Self {
393 self.code = Some(format!("parse.{}", code));
394 self
395 }
396
397 pub fn with_resolve_code(mut self, code: &str) -> Self {
398 self.code = Some(format!("resolve.{}", code));
399 self
400 }
401
402 pub fn with_infer_code(mut self, code: &str) -> Self {
403 self.code = Some(format!("infer.{}", code));
404 self
405 }
406
407 pub fn with_lint_code(mut self, code: &str) -> Self {
408 debug_assert!(
409 matches!(self.severity, Severity::Warning | Severity::Advice),
410 "with_lint_code requires Warning or Advice severity (got {:?}); \
411 use a phase-specific code constructor for errors",
412 self.severity,
413 );
414 self.code = Some(format!("lint.{}", code));
415 self
416 }
417
418 pub fn with_attribute_code(mut self, code: &str) -> Self {
419 self.code = Some(format!("attribute.{}", code));
420 self
421 }
422
423 pub fn with_emit_code(mut self, code: &str) -> Self {
424 self.code = Some(format!("emit.{}", code));
425 self
426 }
427
428 pub fn with_code(mut self, code: impl Into<String>) -> Self {
429 self.code = Some(code.into());
430 self
431 }
432
433 pub fn with_source_code(self, source: IndexedSource, filename: String) -> miette::Report {
434 miette::Report::new(self).with_source_code(miette::NamedSource::new(filename, source))
435 }
436
437 pub fn code_str(&self) -> Option<&str> {
438 self.code.as_deref()
439 }
440
441 pub fn primary_offset(&self) -> usize {
442 self.labels.first().map(|l| l.offset()).unwrap_or(0)
443 }
444
445 pub fn location_offset(&self) -> Option<usize> {
446 self.labels
447 .iter()
448 .find(|l| l.primary())
449 .or_else(|| self.labels.first())
450 .map(|l| l.offset())
451 }
452
453 pub fn severity_word(&self) -> &'static str {
454 match self.severity {
455 Severity::Error => "error",
456 Severity::Warning => "warning",
457 Severity::Advice => "info",
458 }
459 }
460
461 pub fn file_id(&self) -> Option<u32> {
462 self.file_id
463 }
464
465 pub fn is_error(&self) -> bool {
466 self.severity == Severity::Error
467 }
468
469 pub fn is_warning(&self) -> bool {
470 self.severity == Severity::Warning
471 }
472
473 pub fn is_info(&self) -> bool {
474 self.severity == Severity::Advice
475 }
476
477 pub fn sort_key(a: &Self, b: &Self) -> std::cmp::Ordering {
478 a.file_id()
479 .cmp(&b.file_id())
480 .then_with(|| a.primary_offset().cmp(&b.primary_offset()))
481 .then_with(|| a.code_str().cmp(&b.code_str()))
482 .then_with(|| a.plain_message().cmp(b.plain_message()))
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn info_constructor_is_advice_severity() {
492 let diagnostic = LisetteDiagnostic::info("advisory");
493 assert!(diagnostic.is_info());
494 assert!(!diagnostic.is_error());
495 assert!(!diagnostic.is_warning());
496 assert_eq!(diagnostic.severity_word(), "info");
497 }
498}