slovo/compiler/src/diag.rs
2026-05-22 08:38:43 +02:00

555 lines
15 KiB
Rust

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<Span>,
pub expected: Option<String>,
pub found: Option<String>,
pub hint: Option<String>,
pub related: Vec<RelatedSpan>,
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<String>, code: &'static str, message: impl Into<String>) -> 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<String>) -> Self {
self.expected = Some(expected.into());
self
}
pub fn found(mut self, found: impl Into<String>) -> Self {
self.found = Some(found.into());
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn related(mut self, label: impl Into<String>, 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<String>,
label: impl Into<String>,
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::<JsonRelated<'_>>(),
)
}
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<SourceRange>)>,
expected: Option<&str>,
found: Option<&str>,
hint: Option<&str>,
related: impl Iterator<Item = JsonRelated<'a>>,
) -> 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::<Vec<_>>();
if !related.is_empty() {
fields.push(format!("\"related\":[{}]", related.join(",")));
}
format!("{{{}}}", fields.join(","))
}
struct JsonRelated<'a> {
file: &'a str,
span: Span,
range: Option<SourceRange>,
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<SourceRange>)>) -> 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<SourceRange>) -> 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<SourceRange>,
) -> 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<Self> {
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
},
}
);
}
}