use crate::token::Span; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Severity { Error, } impl Severity { fn as_str(self) -> &'static str { match self { Self::Error => "error", } } } #[derive(Debug, Clone)] pub struct Diagnostic { pub severity: Severity, pub code: &'static str, pub message: String, pub span: Option, pub expected: Option, pub found: Option, pub hint: Option, pub related: Vec, pub file: String, } #[derive(Debug, Clone)] pub struct RelatedSpan { pub file: String, pub label: String, pub span: Span, } impl Diagnostic { pub fn new(file: impl Into, code: &'static str, message: impl Into) -> Self { Self { severity: Severity::Error, file: file.into(), code, message: message.into(), span: None, expected: None, found: None, hint: None, related: Vec::new(), } } pub fn with_span(mut self, span: Span) -> Self { self.span = Some(span); self } pub fn expected(mut self, expected: impl Into) -> Self { self.expected = Some(expected.into()); self } pub fn found(mut self, found: impl Into) -> Self { self.found = Some(found.into()); self } pub fn hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self } pub fn related(mut self, label: impl Into, span: Span) -> Self { self.related.push(RelatedSpan { file: self.file.clone(), label: label.into(), span, }); self } pub fn related_in_file( mut self, file: impl Into, label: impl Into, span: Span, ) -> Self { self.related.push(RelatedSpan { file: file.into(), label: label.into(), span, }); self } pub fn render_human(&self, source: &str) -> String { self.render_human_with_sources(|file| { if file == self.file { Some(source) } else { None } }) } pub fn render_human_with_sources<'a>( &self, source_for: impl Fn(&str) -> Option<&'a str>, ) -> String { let mut out = String::new(); let source = source_for(&self.file).unwrap_or(""); out.push_str(&format!( "{}[{}]: {}\n", self.severity.as_str(), self.code, self.message )); if let Some(span) = self.span { if let Some(range) = SourceRange::new(source, span) { out.push_str(&format!( "--> {}:{}:{}-{}:{}\n", self.file, range.start.line, range.start.column, range.end.line, range.end.column )); } let excerpt = source_line_at(source, span.start) .or_else(|| source.get(span.start..span.end)) .unwrap_or(""); out.push_str(&format!("\nAt:\n {}\n", excerpt)); } if let Some(expected) = &self.expected { out.push_str(&format!("\nExpected:\n {}\n", expected)); } if let Some(found) = &self.found { out.push_str(&format!("\nFound:\n {}\n", found)); } if let Some(hint) = &self.hint { out.push_str(&format!("\nHint:\n {}\n", hint)); } for related in &self.related { out.push_str(&format!("\nRelated:\n {}\n", related.label)); let related_source = source_for(&related.file).unwrap_or(""); if let Some(range) = SourceRange::new(related_source, related.span) { out.push_str(&format!( " --> {}:{}:{}-{}:{}\n", related.file, range.start.line, range.start.column, range.end.line, range.end.column )); } let excerpt = source_line_at(related_source, related.span.start) .or_else(|| related_source.get(related.span.start..related.span.end)) .unwrap_or(""); out.push_str(&format!(" {}\n", excerpt)); } out } pub fn render_machine(&self, source: &str) -> String { self.render_machine_with_sources(|file| { if file == self.file { Some(source) } else { None } }) } pub fn render_machine_with_sources<'a>( &self, source_for: impl Fn(&str) -> Option<&'a str>, ) -> String { let source = source_for(&self.file).unwrap_or(""); let mut parts = vec![ " (schema slovo.diagnostic)".to_string(), " (version 1)".to_string(), format!(" (severity {})", self.severity.as_str()), format!(" (code {})", self.code), format!(" (message {})", render_string(&self.message)), format!(" (file {})", render_string(&self.file)), ]; if let Some(span) = self.span { parts.push(render_machine_span( " ", span, SourceRange::new(source, span), )); } if let Some(expected) = &self.expected { parts.push(format!(" (expected {})", render_string(expected))); } if let Some(found) = &self.found { parts.push(format!(" (found {})", render_string(found))); } if let Some(hint) = &self.hint { parts.push(format!(" (hint {})", render_string(hint))); } for related in &self.related { let related_source = source_for(&related.file).unwrap_or(""); parts.push(render_machine_related( " ", &related.file, related, SourceRange::new(related_source, related.span), )); } format!("(diagnostic\n{}\n)", parts.join("\n")) } pub fn render_json(&self, source: &str) -> String { self.render_json_with_sources(|file| { if file == self.file { Some(source) } else { None } }) } pub fn render_json_with_sources<'a>( &self, source_for: impl Fn(&str) -> Option<&'a str>, ) -> String { let source = source_for(&self.file).unwrap_or(""); let file = Some(self.file.as_str()); render_json_diagnostic( self.severity.as_str(), self.code, &self.message, file, self.span.map(|span| (span, SourceRange::new(source, span))), self.expected.as_deref(), self.found.as_deref(), self.hint.as_deref(), self.related.iter().map(|related| JsonRelated { file: &related.file, span: related.span, range: SourceRange::new(source_for(&related.file).unwrap_or(""), related.span), message: Some(&related.label), }), ) } } pub fn render_json_message( severity: &str, code: &str, message: &str, hint: Option<&str>, ) -> String { render_json_diagnostic( severity, code, message, None, None, None, None, hint, std::iter::empty::>(), ) } pub fn render_string(value: &str) -> String { let mut out = String::from("\""); for ch in value.chars() { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), ch if ch.is_control() => out.push_str(&format!("\\u{{{:x}}}", ch as u32)), ch => out.push(ch), } } out.push('"'); out } fn render_json_diagnostic<'a>( severity: &str, code: &str, message: &str, file: Option<&str>, span: Option<(Span, Option)>, expected: Option<&str>, found: Option<&str>, hint: Option<&str>, related: impl Iterator>, ) -> String { let mut fields = vec![ "\"schema\":\"slovo.diagnostic\"".to_string(), "\"version\":1".to_string(), format!("\"severity\":{}", render_json_string(severity)), format!("\"code\":{}", render_json_string(code)), format!("\"message\":{}", render_json_string(message)), format!("\"file\":{}", render_json_nullable_string(file)), format!("\"span\":{}", render_json_span(span)), ]; if let Some(expected) = expected { fields.push(format!("\"expected\":{}", render_json_string(expected))); } if let Some(found) = found { fields.push(format!("\"found\":{}", render_json_string(found))); } if let Some(hint) = hint { fields.push(format!("\"hint\":{}", render_json_string(hint))); } let related = related.map(render_json_related).collect::>(); if !related.is_empty() { fields.push(format!("\"related\":[{}]", related.join(","))); } format!("{{{}}}", fields.join(",")) } struct JsonRelated<'a> { file: &'a str, span: Span, range: Option, message: Option<&'a str>, } fn render_json_related(related: JsonRelated<'_>) -> String { let mut fields = vec![ format!("\"file\":{}", render_json_string(related.file)), format!( "\"span\":{}", render_json_span(Some((related.span, related.range))) ), ]; if let Some(message) = related.message { fields.push(format!("\"message\":{}", render_json_string(message))); } format!("{{{}}}", fields.join(",")) } fn render_json_span(span: Option<(Span, Option)>) -> String { let Some((span, range)) = span else { return "null".to_string(); }; let mut fields = vec![ format!("\"byte_start\":{}", span.start), format!("\"byte_end\":{}", span.end), ]; if let Some(range) = range { fields.push(format!("\"line_start\":{}", range.start.line)); fields.push(format!("\"column_start\":{}", range.start.column)); fields.push(format!("\"line_end\":{}", range.end.line)); fields.push(format!("\"column_end\":{}", range.end.column)); } format!("{{{}}}", fields.join(",")) } fn render_json_nullable_string(value: Option<&str>) -> String { value .map(render_json_string) .unwrap_or_else(|| "null".to_string()) } fn render_json_string(value: &str) -> String { let mut out = String::from("\""); for ch in value.chars() { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), '\u{08}' => out.push_str("\\b"), '\u{0c}' => out.push_str("\\f"), ch if ch <= '\u{1f}' => out.push_str(&format!("\\u{:04x}", ch as u32)), ch => out.push(ch), } } out.push('"'); out } fn render_machine_span(indent: &str, span: Span, range: Option) -> String { let mut out = format!( "{indent}(span\n{indent} (bytes {} {})", span.start, span.end ); if let Some(range) = range { out.push_str(&format!( "\n{indent} (range {} {} {} {})", range.start.line, range.start.column, range.end.line, range.end.column )); } out.push_str(&format!("\n{indent})")); out } fn render_machine_related( indent: &str, file: &str, related: &RelatedSpan, range: Option, ) -> String { let span_indent = format!("{indent} "); let mut span_parts = vec![ format!("{span_indent}(file {})", render_string(file)), format!( "{span_indent}(bytes {} {})", related.span.start, related.span.end ), ]; if let Some(range) = range { span_parts.push(format!( "{span_indent}(range {} {} {} {})", range.start.line, range.start.column, range.end.line, range.end.column )); } span_parts.push(format!( "{span_indent}(message {})", render_string(&related.label) )); format!( "{indent}(related\n{indent} (span\n{}\n{indent} )\n{indent})", span_parts.join("\n") ) } #[derive(Debug, Copy, Clone, PartialEq, Eq)] struct SourceRange { start: SourcePosition, end: SourcePosition, } #[derive(Debug, Copy, Clone, PartialEq, Eq)] struct SourcePosition { line: usize, column: usize, } impl SourceRange { fn new(source: &str, span: Span) -> Option { if span.start > span.end || span.end > source.len() { return None; } Some(Self { start: source_position(source, span.start), end: source_position(source, span.end), }) } } fn source_position(source: &str, offset: usize) -> SourcePosition { let mut line = 1; let mut column = 1; for byte in source.as_bytes().iter().take(offset) { if *byte == b'\n' { line += 1; column = 1; } else { column += 1; } } SourcePosition { line, column } } fn source_line_at(source: &str, offset: usize) -> Option<&str> { if offset > source.len() { return None; } let start = source[..offset].rfind('\n').map_or(0, |index| index + 1); let end = source[offset..] .find('\n') .map_or(source.len(), |index| offset + index); source.get(start..end) } #[cfg(test)] mod tests { use super::*; #[test] fn source_range_uses_one_based_line_and_column_numbers() { let source = "first\n second\nthird"; let range = SourceRange::new(source, Span::new(8, 14)).unwrap(); assert_eq!( range, SourceRange { start: SourcePosition { line: 2, column: 3 }, end: SourcePosition { line: 2, column: 9 }, } ); } #[test] fn source_range_columns_are_utf8_byte_columns() { let source = "pre ΕΎ value"; let start = source.find("value").unwrap(); let end = start + "value".len(); let range = SourceRange::new(source, Span::new(start, end)).unwrap(); assert_eq!( range, SourceRange { start: SourcePosition { line: 1, column: 8 }, end: SourcePosition { line: 1, column: 13 }, } ); } }