use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SourceId(pub u32);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Span {
pub start: u32,
pub end: u32,
}
impl Span {
pub const fn new(start: usize, end: usize) -> Self {
Self {
start: start as u32,
end: end as u32,
}
}
pub const fn empty(at: usize) -> Self {
Self::new(at, at)
}
pub fn join(self, other: Self) -> Self {
Self {
start: self.start.min(other.start),
end: self.end.max(other.end),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Position {
pub line: u32,
pub column: u32,
pub offset: u32,
}
#[derive(Clone, Debug)]
pub struct SourceFile {
pub id: SourceId,
pub path: PathBuf,
text: String,
line_starts: Vec<u32>,
}
impl SourceFile {
pub fn new(id: SourceId, path: impl Into<PathBuf>, text: impl Into<String>) -> Self {
let text = text.into();
let mut line_starts = vec![0];
for (index, byte) in text.bytes().enumerate() {
if byte == b'\n' {
line_starts.push((index + 1) as u32);
}
}
Self {
id,
path: path.into(),
text,
line_starts,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn slice(&self, span: Span) -> &str {
self.text
.get(span.start as usize..span.end as usize)
.unwrap_or("")
}
pub fn position(&self, offset: u32) -> Position {
let line = self
.line_starts
.partition_point(|start| *start <= offset)
.saturating_sub(1);
Position {
line: line as u32 + 1,
column: offset - self.line_starts[line] + 1,
offset,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
Self::Info => "info",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Label {
pub span: Span,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiagnosticField {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
pub code: &'static str,
pub severity: Severity,
pub message: String,
pub span: Span,
pub path: Option<String>,
pub symbol: Option<String>,
pub labels: Vec<Label>,
pub notes: Vec<String>,
pub fields: Vec<DiagnosticField>,
}
impl Diagnostic {
pub fn error(code: &'static str, message: impl Into<String>, span: Span) -> Self {
Self {
code,
severity: Severity::Error,
message: message.into(),
span,
path: None,
symbol: None,
labels: vec![],
notes: vec![],
fields: vec![],
}
}
pub fn warning(code: &'static str, message: impl Into<String>, span: Span) -> Self {
Self {
code,
severity: Severity::Warning,
message: message.into(),
span,
path: None,
symbol: None,
labels: vec![],
notes: vec![],
fields: vec![],
}
}
pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
self.symbol = Some(symbol.into());
self
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push(DiagnosticField {
key: key.into(),
value: value.into(),
});
self
}
pub fn render(&self, source: &SourceFile) -> String {
let pos = source.position(self.span.start);
format!(
"{}:{}:{}: {}[{}]: {}",
source.path.display(),
pos.line,
pos.column,
self.severity.as_str(),
self.code,
self.message
)
}
pub fn to_json(&self) -> String {
let symbol = self
.symbol
.as_ref()
.map(|s| format!("\"{}\"", json_escape(s)))
.unwrap_or_else(|| "null".into());
let fields = self
.fields
.iter()
.map(|f| format!("\"{}\":\"{}\"", json_escape(&f.key), json_escape(&f.value)))
.collect::<Vec<_>>()
.join(",");
let fixes = self.fixes_json();
let path = self
.path
.as_ref()
.map(|path| format!("\"{}\"", json_escape(path)))
.unwrap_or_else(|| "null".into());
format!(
"{{\"code\":\"{}\",\"severity\":\"{}\",\"message\":\"{}\",\"span\":{{\"start\":{},\"end\":{}}},\"path\":{},\"symbol\":{},\"fields\":{{{}}},\"fixes\":{fixes}}}",
self.code,
self.severity.as_str(),
json_escape(&self.message),
self.span.start,
self.span.end,
path,
symbol,
fields
)
}
fn fixes_json(&self) -> String {
let operation = if self.code.contains("TRANSITION") {
"inspect_machine"
} else if self.code.contains("EXHAUSTIVE") || self.code.contains("MISSING_CASE") {
"add_missing_cases"
} else if self.code.starts_with("UNKNOWN_") || self.code.contains("UNRESOLVED") {
"find_symbol"
} else if self.code.contains("TYPE") {
"inspect_expected_type"
} else if self.code.starts_with("A11Y_") {
"apply_accessibility_semantics"
} else {
return "[]".into();
};
let symbol = self
.symbol
.as_ref()
.map(|value| format!(",\"symbol\":\"{}\"", json_escape(value)))
.unwrap_or_default();
let arguments = self
.fields
.iter()
.map(|field| {
format!(
"\"{}\":\"{}\"",
json_escape(&field.key),
json_escape(&field.value)
)
})
.collect::<Vec<_>>()
.join(",");
format!(
"[{{\"kind\":\"semantic-operation\",\"operation\":\"{operation}\",\"applicability\":\"compiler-validated\",\"targetSpan\":{{\"start\":{},\"end\":{}}}{symbol},\"arguments\":{{{arguments}}}}}]",
self.span.start, self.span.end
)
}
}
pub fn json_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
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"),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
pub fn js_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in json_escape(value).chars() {
match ch {
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
other => out.push(other),
}
}
out
}
pub fn html_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
other => out.push(other),
}
}
out
}
impl fmt::Display for Span {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}..{}", self.start, self.end)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn js_escape_covers_the_union_of_every_emitter_threat_model() {
assert_eq!(js_escape("say \"hi\""), "say \\\"hi\\\"");
assert_eq!(js_escape("back\\slash"), "back\\\\slash");
assert_eq!(js_escape("line\nfeed\ttab\r"), "line\\nfeed\\ttab\\r");
assert_eq!(js_escape("bell\u{7}"), "bell\\u0007");
assert_eq!(js_escape("</script>"), "\\u003c/script\\u003e");
assert_eq!(js_escape("a & b"), "a \\u0026 b");
assert_eq!(
js_escape("split\u{2028}here\u{2029}too"),
"split\\u2028here\\u2029too"
);
assert_eq!(js_escape("plain"), "plain");
}
#[test]
fn html_escape_covers_text_and_double_quoted_attribute_positions() {
assert_eq!(
html_escape("<img src=\"x\" onerror=y & z>"),
"<img src="x" onerror=y & z>"
);
assert_eq!(html_escape("<"), "&lt;");
assert_eq!(html_escape("plain"), "plain");
}
#[test]
fn maps_utf8_byte_offsets_to_lines_and_columns() {
let source = SourceFile::new(SourceId(0), "test.nox", "one\nthree");
assert_eq!(
source.position(4),
Position {
line: 2,
column: 1,
offset: 4
}
);
}
#[test]
fn structured_diagnostics_include_machine_actionable_fixes() {
let diagnostic = Diagnostic::error("TYPE_MISMATCH", "expected Int", Span::new(2, 7))
.with_symbol("state:Counter.count")
.with_field("expected", "Int")
.with_field("found", "String");
let json = diagnostic.to_json();
assert!(json.contains("\"operation\":\"inspect_expected_type\""));
assert!(json.contains("\"applicability\":\"compiler-validated\""));
assert!(json.contains("\"expected\":\"Int\""));
}
#[test]
fn warning_diagnostics_are_structured_without_becoming_errors() {
let diagnostic = Diagnostic::warning(
"SCENARIO_PROSE_UNEXECUTED",
"prose scenario steps are documentation, not executable proof",
Span::new(4, 12),
)
.with_symbol("scenario:Counter.Legacy");
assert_eq!(diagnostic.severity, Severity::Warning);
assert!(diagnostic.to_json().contains("\"severity\":\"warning\""));
}
}