1use std::fmt;
2use std::path::{Path, PathBuf};
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
5pub struct SourceId(pub u32);
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
8pub struct Span {
9 pub start: u32,
10 pub end: u32,
11}
12
13impl Span {
14 pub const fn new(start: usize, end: usize) -> Self {
15 Self {
16 start: start as u32,
17 end: end as u32,
18 }
19 }
20
21 pub const fn empty(at: usize) -> Self {
22 Self::new(at, at)
23 }
24
25 pub fn join(self, other: Self) -> Self {
26 Self {
27 start: self.start.min(other.start),
28 end: self.end.max(other.end),
29 }
30 }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct Position {
35 pub line: u32,
36 pub column: u32,
37 pub offset: u32,
38}
39
40#[derive(Clone, Debug)]
41pub struct SourceFile {
42 pub id: SourceId,
43 pub path: PathBuf,
44 text: String,
45 line_starts: Vec<u32>,
46}
47
48impl SourceFile {
49 pub fn new(id: SourceId, path: impl Into<PathBuf>, text: impl Into<String>) -> Self {
50 let text = text.into();
51 let mut line_starts = vec![0];
52 for (index, byte) in text.bytes().enumerate() {
53 if byte == b'\n' {
54 line_starts.push((index + 1) as u32);
55 }
56 }
57 Self {
58 id,
59 path: path.into(),
60 text,
61 line_starts,
62 }
63 }
64
65 pub fn text(&self) -> &str {
66 &self.text
67 }
68
69 pub fn path(&self) -> &Path {
70 &self.path
71 }
72
73 pub fn slice(&self, span: Span) -> &str {
74 self.text
75 .get(span.start as usize..span.end as usize)
76 .unwrap_or("")
77 }
78
79 pub fn position(&self, offset: u32) -> Position {
80 let line = self
81 .line_starts
82 .partition_point(|start| *start <= offset)
83 .saturating_sub(1);
84 Position {
85 line: line as u32 + 1,
86 column: offset - self.line_starts[line] + 1,
87 offset,
88 }
89 }
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum Severity {
94 Error,
95 Warning,
96 Info,
97}
98
99impl Severity {
100 pub fn as_str(self) -> &'static str {
101 match self {
102 Self::Error => "error",
103 Self::Warning => "warning",
104 Self::Info => "info",
105 }
106 }
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct Label {
111 pub span: Span,
112 pub message: String,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct DiagnosticField {
117 pub key: String,
118 pub value: String,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct Diagnostic {
123 pub code: &'static str,
124 pub severity: Severity,
125 pub message: String,
126 pub span: Span,
127 pub path: Option<String>,
129 pub symbol: Option<String>,
130 pub labels: Vec<Label>,
131 pub notes: Vec<String>,
132 pub fields: Vec<DiagnosticField>,
133}
134
135impl Diagnostic {
136 pub fn error(code: &'static str, message: impl Into<String>, span: Span) -> Self {
137 Self {
138 code,
139 severity: Severity::Error,
140 message: message.into(),
141 span,
142 path: None,
143 symbol: None,
144 labels: vec![],
145 notes: vec![],
146 fields: vec![],
147 }
148 }
149
150 pub fn warning(code: &'static str, message: impl Into<String>, span: Span) -> Self {
151 Self {
152 code,
153 severity: Severity::Warning,
154 message: message.into(),
155 span,
156 path: None,
157 symbol: None,
158 labels: vec![],
159 notes: vec![],
160 fields: vec![],
161 }
162 }
163
164 pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
165 self.symbol = Some(symbol.into());
166 self
167 }
168
169 pub fn with_path(mut self, path: impl Into<String>) -> Self {
170 self.path = Some(path.into());
171 self
172 }
173
174 pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
175 self.fields.push(DiagnosticField {
176 key: key.into(),
177 value: value.into(),
178 });
179 self
180 }
181
182 pub fn render(&self, source: &SourceFile) -> String {
183 let pos = source.position(self.span.start);
184 format!(
185 "{}:{}:{}: {}[{}]: {}",
186 source.path.display(),
187 pos.line,
188 pos.column,
189 self.severity.as_str(),
190 self.code,
191 self.message
192 )
193 }
194
195 pub fn to_json(&self) -> String {
196 let symbol = self
197 .symbol
198 .as_ref()
199 .map(|s| format!("\"{}\"", json_escape(s)))
200 .unwrap_or_else(|| "null".into());
201 let fields = self
202 .fields
203 .iter()
204 .map(|f| format!("\"{}\":\"{}\"", json_escape(&f.key), json_escape(&f.value)))
205 .collect::<Vec<_>>()
206 .join(",");
207 let fixes = self.fixes_json();
208 let path = self
209 .path
210 .as_ref()
211 .map(|path| format!("\"{}\"", json_escape(path)))
212 .unwrap_or_else(|| "null".into());
213 format!(
214 "{{\"code\":\"{}\",\"severity\":\"{}\",\"message\":\"{}\",\"span\":{{\"start\":{},\"end\":{}}},\"path\":{},\"symbol\":{},\"fields\":{{{}}},\"fixes\":{fixes}}}",
215 self.code,
216 self.severity.as_str(),
217 json_escape(&self.message),
218 self.span.start,
219 self.span.end,
220 path,
221 symbol,
222 fields
223 )
224 }
225
226 fn fixes_json(&self) -> String {
227 let operation = if self.code.contains("TRANSITION") {
228 "inspect_machine"
229 } else if self.code.contains("EXHAUSTIVE") || self.code.contains("MISSING_CASE") {
230 "add_missing_cases"
231 } else if self.code.starts_with("UNKNOWN_") || self.code.contains("UNRESOLVED") {
232 "find_symbol"
233 } else if self.code.contains("TYPE") {
234 "inspect_expected_type"
235 } else if self.code.starts_with("A11Y_") {
236 "apply_accessibility_semantics"
237 } else {
238 return "[]".into();
239 };
240 let symbol = self
241 .symbol
242 .as_ref()
243 .map(|value| format!(",\"symbol\":\"{}\"", json_escape(value)))
244 .unwrap_or_default();
245 let arguments = self
246 .fields
247 .iter()
248 .map(|field| {
249 format!(
250 "\"{}\":\"{}\"",
251 json_escape(&field.key),
252 json_escape(&field.value)
253 )
254 })
255 .collect::<Vec<_>>()
256 .join(",");
257 format!(
258 "[{{\"kind\":\"semantic-operation\",\"operation\":\"{operation}\",\"applicability\":\"compiler-validated\",\"targetSpan\":{{\"start\":{},\"end\":{}}}{symbol},\"arguments\":{{{arguments}}}}}]",
259 self.span.start, self.span.end
260 )
261 }
262}
263
264pub fn json_escape(value: &str) -> String {
265 let mut out = String::with_capacity(value.len());
266 for ch in value.chars() {
267 match ch {
268 '"' => out.push_str("\\\""),
269 '\\' => out.push_str("\\\\"),
270 '\n' => out.push_str("\\n"),
271 '\r' => out.push_str("\\r"),
272 '\t' => out.push_str("\\t"),
273 c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
274 c => out.push(c),
275 }
276 }
277 out
278}
279
280pub fn js_escape(value: &str) -> String {
289 let mut out = String::with_capacity(value.len());
290 for ch in json_escape(value).chars() {
291 match ch {
292 '<' => out.push_str("\\u003c"),
293 '>' => out.push_str("\\u003e"),
294 '&' => out.push_str("\\u0026"),
295 '\u{2028}' => out.push_str("\\u2028"),
296 '\u{2029}' => out.push_str("\\u2029"),
297 other => out.push(other),
298 }
299 }
300 out
301}
302
303pub fn html_escape(value: &str) -> String {
306 let mut out = String::with_capacity(value.len());
307 for ch in value.chars() {
308 match ch {
309 '&' => out.push_str("&"),
310 '<' => out.push_str("<"),
311 '>' => out.push_str(">"),
312 '"' => out.push_str("""),
313 other => out.push(other),
314 }
315 }
316 out
317}
318
319impl fmt::Display for Span {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 write!(f, "{}..{}", self.start, self.end)
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
334 fn js_escape_covers_the_union_of_every_emitter_threat_model() {
335 assert_eq!(js_escape("say \"hi\""), "say \\\"hi\\\"");
336 assert_eq!(js_escape("back\\slash"), "back\\\\slash");
337 assert_eq!(js_escape("line\nfeed\ttab\r"), "line\\nfeed\\ttab\\r");
338 assert_eq!(js_escape("bell\u{7}"), "bell\\u0007");
339 assert_eq!(js_escape("</script>"), "\\u003c/script\\u003e");
340 assert_eq!(js_escape("a & b"), "a \\u0026 b");
341 assert_eq!(
342 js_escape("split\u{2028}here\u{2029}too"),
343 "split\\u2028here\\u2029too"
344 );
345 assert_eq!(js_escape("plain"), "plain");
346 }
347
348 #[test]
349 fn html_escape_covers_text_and_double_quoted_attribute_positions() {
350 assert_eq!(
351 html_escape("<img src=\"x\" onerror=y & z>"),
352 "<img src="x" onerror=y & z>"
353 );
354 assert_eq!(html_escape("<"), "&lt;");
356 assert_eq!(html_escape("plain"), "plain");
357 }
358
359 #[test]
360 fn maps_utf8_byte_offsets_to_lines_and_columns() {
361 let source = SourceFile::new(SourceId(0), "test.nox", "one\nthree");
362 assert_eq!(
363 source.position(4),
364 Position {
365 line: 2,
366 column: 1,
367 offset: 4
368 }
369 );
370 }
371
372 #[test]
373 fn structured_diagnostics_include_machine_actionable_fixes() {
374 let diagnostic = Diagnostic::error("TYPE_MISMATCH", "expected Int", Span::new(2, 7))
375 .with_symbol("state:Counter.count")
376 .with_field("expected", "Int")
377 .with_field("found", "String");
378 let json = diagnostic.to_json();
379 assert!(json.contains("\"operation\":\"inspect_expected_type\""));
380 assert!(json.contains("\"applicability\":\"compiler-validated\""));
381 assert!(json.contains("\"expected\":\"Int\""));
382 }
383
384 #[test]
385 fn warning_diagnostics_are_structured_without_becoming_errors() {
386 let diagnostic = Diagnostic::warning(
387 "SCENARIO_PROSE_UNEXECUTED",
388 "prose scenario steps are documentation, not executable proof",
389 Span::new(4, 12),
390 )
391 .with_symbol("scenario:Counter.Legacy");
392
393 assert_eq!(diagnostic.severity, Severity::Warning);
394 assert!(diagnostic.to_json().contains("\"severity\":\"warning\""));
395 }
396}