555 lines
15 KiB
Rust
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
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|