diag_lang/json.rs
1//! A dependency-free, deterministic JSON form of diagnostics, for language
2//! servers, MCP tools, AI clients, and anything else that reads diagnostics
3//! rather than displays them.
4//!
5//! The schema is versioned by [`JSON_SCHEMA_VERSION`] and documented in
6//! `docs/API.md`; within one version it only ever gains keys.
7
8use alloc::string::String;
9use core::fmt;
10
11use source_lang::{SourceFile, SourceMap};
12use span_lang::Span;
13
14use crate::render::{floor_boundary, line_col_at, resolve};
15use crate::{Diagnostic, Diagnostics, Edit, Label, Suggestion};
16
17/// The version of the JSON schema this crate writes, emitted as the `"version"`
18/// key of every top-level object.
19///
20/// It increases only when a key is removed or changes meaning. Adding a key is
21/// not a version change, so a reader should ignore keys it does not know.
22///
23/// *Since 1.1.0.*
24///
25/// # Examples
26///
27/// ```
28/// use diag_lang::{Diagnostic, JSON_SCHEMA_VERSION, Label, Severity, SourceMap, Span};
29///
30/// let diag = Diagnostic::new(Severity::Error, "boom", Label::unlabelled(Span::empty(0)));
31/// let json = diag.to_json(&SourceMap::new());
32/// assert!(json.starts_with(&format!("{{\"version\":{JSON_SCHEMA_VERSION},")));
33/// ```
34pub const JSON_SCHEMA_VERSION: u32 = 1;
35
36impl Diagnostic {
37 /// Returns the diagnostic as one line of JSON, its spans resolved against
38 /// `map`.
39 ///
40 /// The output is a single object with no whitespace and no trailing
41 /// newline, so diagnostics can be streamed one per line (JSON Lines). Key
42 /// order is fixed and the same diagnostic and map always produce the same
43 /// bytes. Every string is escaped per RFC 8259, with U+2028 and U+2029
44 /// escaped as well so the output is also a valid JavaScript literal.
45 ///
46 /// ```text
47 /// {"version":1,"severity":"error","code":"E0425","message":"…",
48 /// "labels":[{"primary":true,"message":"…","span":{"start":24,"end":27},
49 /// "location":{"file":"main.rs","start":{"offset":24,"line":2,"column":13},
50 /// "end":{"offset":27,"line":2,"column":16}}}],
51 /// "notes":["…"],"help":["…"],
52 /// "suggestions":[{"message":"…","applicability":"machine_applicable",
53 /// "edits":[{"span":{…},"location":{…},"replacement":"…"}]}]}
54 /// ```
55 ///
56 /// `code` is `null` when no code is set. `labels` lists the primary label
57 /// first, then the secondary labels in the order they were added. `span`
58 /// is the label's global byte span as given; `location` resolves it: the
59 /// file name, and for each end the byte `offset` into that file, the 1-based
60 /// `line`, and the 1-based `column` counted in Unicode scalar values (the
61 /// numbers the text renderer prints after `-->`). An end past the file is
62 /// clamped to it and an offset inside a character is moved to that
63 /// character's start. `location` is `null` for a span outside every loaded
64 /// source. The full schema is in `docs/API.md`.
65 ///
66 /// *Since 1.1.0.*
67 ///
68 /// # Examples
69 ///
70 /// ```
71 /// use diag_lang::{Code, Diagnostic, Label, Severity, SourceMap, Span};
72 ///
73 /// let mut map = SourceMap::new();
74 /// map.add("a.txt", "let x = y;").expect("fits");
75 /// let diag = Diagnostic::new(Severity::Error, "unknown `y`", Label::new(Span::new(8, 9), "here"))
76 /// .with_code(Code::new("E", 2001).unwrap());
77 ///
78 /// assert_eq!(
79 /// diag.to_json(&map),
80 /// concat!(
81 /// r#"{"version":1,"severity":"error","code":"E2001","message":"unknown `y`","#,
82 /// r#""labels":[{"primary":true,"message":"here","span":{"start":8,"end":9},"#,
83 /// r#""location":{"file":"a.txt","start":{"offset":8,"line":1,"column":9},"#,
84 /// r#""end":{"offset":9,"line":1,"column":10}}}],"#,
85 /// r#""notes":[],"help":[],"suggestions":[]}"#,
86 /// ),
87 /// );
88 /// ```
89 #[must_use]
90 pub fn to_json(&self, map: &SourceMap) -> String {
91 let mut out = String::new();
92 // Writing into a `String` cannot fail.
93 let _ = self.write_json(&mut out, map);
94 out
95 }
96
97 /// Writes the diagnostic as JSON into any [`fmt::Write`] sink; see
98 /// [`to_json`](Diagnostic::to_json) for the format.
99 ///
100 /// *Since 1.1.0.*
101 ///
102 /// # Errors
103 ///
104 /// Returns the first [`fmt::Error`] the sink reports; writing into a
105 /// `String` never fails.
106 ///
107 /// # Examples
108 ///
109 /// ```
110 /// use diag_lang::{Diagnostic, Label, Severity, SourceMap, Span};
111 ///
112 /// let diag = Diagnostic::new(Severity::Warning, "w", Label::unlabelled(Span::empty(0)));
113 /// let mut line = String::new();
114 /// diag.write_json(&mut line, &SourceMap::new()).expect("string write");
115 /// line.push('\n'); // one diagnostic per line
116 /// assert!(line.contains(r#""location":null"#));
117 /// ```
118 pub fn write_json(&self, out: &mut impl fmt::Write, map: &SourceMap) -> fmt::Result {
119 write!(
120 out,
121 "{{\"version\":{JSON_SCHEMA_VERSION},\"severity\":\"{}\",\"code\":",
122 self.severity().as_str()
123 )?;
124 match self.code() {
125 Some(code) => write!(out, "\"{code}\"")?,
126 None => out.write_str("null")?,
127 }
128 out.write_str(",\"message\":")?;
129 write_string(out, self.message())?;
130
131 out.write_str(",\"labels\":[")?;
132 write_label(out, map, self.primary(), true)?;
133 for label in self.secondary() {
134 out.write_char(',')?;
135 write_label(out, map, label, false)?;
136 }
137
138 out.write_str("],\"notes\":")?;
139 write_strings(out, self.notes())?;
140 out.write_str(",\"help\":")?;
141 write_strings(out, self.help())?;
142
143 out.write_str(",\"suggestions\":[")?;
144 for (i, suggestion) in self.suggestions().iter().enumerate() {
145 if i > 0 {
146 out.write_char(',')?;
147 }
148 write_suggestion(out, map, suggestion)?;
149 }
150 out.write_str("]}")
151 }
152}
153
154impl Diagnostics {
155 /// Returns the whole collection as one JSON document, spans resolved
156 /// against `map`.
157 ///
158 /// ```text
159 /// {"version":1,"errors":2,"warnings":1,"omitted":0,"diagnostics":[{…},{…},{…}]}
160 /// ```
161 ///
162 /// `errors` and `warnings` count every distinct diagnostic pushed,
163 /// including ones the cap omitted; `omitted` is
164 /// [`Diagnostics::omitted`]; `diagnostics` holds the stored diagnostics in
165 /// their current order, each exactly as [`Diagnostic::to_json`] writes it.
166 /// No whitespace, no trailing newline, deterministic.
167 ///
168 /// *Since 1.1.0.*
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// use diag_lang::{Diagnostic, Diagnostics, Label, Severity, SourceMap, Span};
174 ///
175 /// let mut diags = Diagnostics::new();
176 /// diags.push(Diagnostic::new(Severity::Error, "e", Label::unlabelled(Span::empty(0))));
177 /// let json = diags.to_json(&SourceMap::new());
178 /// assert!(json.starts_with(r#"{"version":1,"errors":1,"warnings":0,"omitted":0,"diagnostics":[{"#));
179 /// ```
180 #[must_use]
181 pub fn to_json(&self, map: &SourceMap) -> String {
182 let mut out = String::new();
183 // Writing into a `String` cannot fail.
184 let _ = self.write_json(&mut out, map);
185 out
186 }
187
188 /// Writes the collection as one JSON document into any [`fmt::Write`]
189 /// sink; see [`to_json`](Diagnostics::to_json) for the format.
190 ///
191 /// *Since 1.1.0.*
192 ///
193 /// # Errors
194 ///
195 /// Returns the first [`fmt::Error`] the sink reports; writing into a
196 /// `String` never fails.
197 ///
198 /// # Examples
199 ///
200 /// ```
201 /// use diag_lang::{Diagnostics, SourceMap};
202 ///
203 /// let mut out = String::new();
204 /// Diagnostics::new().write_json(&mut out, &SourceMap::new()).expect("string write");
205 /// assert_eq!(out, r#"{"version":1,"errors":0,"warnings":0,"omitted":0,"diagnostics":[]}"#);
206 /// ```
207 pub fn write_json(&self, out: &mut impl fmt::Write, map: &SourceMap) -> fmt::Result {
208 write!(
209 out,
210 "{{\"version\":{JSON_SCHEMA_VERSION},\"errors\":{},\"warnings\":{},\"omitted\":{},\"diagnostics\":[",
211 self.error_count(),
212 self.warning_count(),
213 self.omitted()
214 )?;
215 for (i, diag) in self.iter().enumerate() {
216 if i > 0 {
217 out.write_char(',')?;
218 }
219 diag.write_json(out, map)?;
220 }
221 out.write_str("]}")
222 }
223}
224
225/// `{"primary":…,"message":…,"span":…,"location":…}`
226fn write_label(
227 out: &mut impl fmt::Write,
228 map: &SourceMap,
229 label: &Label,
230 primary: bool,
231) -> fmt::Result {
232 write!(out, "{{\"primary\":{primary},\"message\":")?;
233 write_string(out, label.message())?;
234 out.write_char(',')?;
235 write_span_and_location(out, map, label.span())?;
236 out.write_char('}')
237}
238
239/// `{"message":…,"applicability":…,"edits":[…]}`
240fn write_suggestion(
241 out: &mut impl fmt::Write,
242 map: &SourceMap,
243 suggestion: &Suggestion,
244) -> fmt::Result {
245 out.write_str("{\"message\":")?;
246 write_string(out, suggestion.message())?;
247 write!(
248 out,
249 ",\"applicability\":\"{}\",\"edits\":[",
250 suggestion.applicability().as_str()
251 )?;
252 for (i, edit) in suggestion.edits().iter().enumerate() {
253 if i > 0 {
254 out.write_char(',')?;
255 }
256 write_edit(out, map, edit)?;
257 }
258 out.write_str("]}")
259}
260
261/// `{"span":…,"location":…,"replacement":…}`
262fn write_edit(out: &mut impl fmt::Write, map: &SourceMap, edit: &Edit) -> fmt::Result {
263 out.write_char('{')?;
264 write_span_and_location(out, map, edit.span())?;
265 out.write_str(",\"replacement\":")?;
266 write_string(out, edit.replacement())?;
267 out.write_char('}')
268}
269
270/// `"span":{"start":…,"end":…},"location":{…}|null`
271fn write_span_and_location(out: &mut impl fmt::Write, map: &SourceMap, span: Span) -> fmt::Result {
272 write!(
273 out,
274 "\"span\":{{\"start\":{},\"end\":{}}},\"location\":",
275 span.start().to_u32(),
276 span.end().to_u32()
277 )?;
278 let Some((id, start, end)) = resolve(map, span) else {
279 return out.write_str("null");
280 };
281 let Some(file) = map.source(id) else {
282 return out.write_str("null");
283 };
284 out.write_str("{\"file\":")?;
285 write_string(out, file.name())?;
286 out.write_str(",\"start\":")?;
287 write_point(out, map, file, start)?;
288 out.write_str(",\"end\":")?;
289 write_point(out, map, file, end.max(start))?;
290 out.write_char('}')
291}
292
293/// `{"offset":…,"line":…,"column":…}` for local offset `at` in `file`.
294fn write_point(
295 out: &mut impl fmt::Write,
296 map: &SourceMap,
297 file: &SourceFile,
298 at: usize,
299) -> fmt::Result {
300 let text = file.text();
301 let at = floor_boundary(text, at);
302 let (line, column) = if at == text.len() && at > 0 {
303 // The end of the text, one past its last character. Computed from that
304 // character so it is answered by this file's line table even when the
305 // map would attribute the position to the next source.
306 let prev = floor_boundary(text, at - 1);
307 let lc = line_col_at(map, file, prev);
308 if text.as_bytes().get(prev) == Some(&b'\n') {
309 (lc.line.saturating_add(1), 1)
310 } else {
311 (lc.line, lc.col.saturating_add(1))
312 }
313 } else {
314 let lc = line_col_at(map, file, at);
315 (lc.line, lc.col)
316 };
317 write!(
318 out,
319 "{{\"offset\":{at},\"line\":{line},\"column\":{column}}}"
320 )
321}
322
323/// `["…","…"]`
324fn write_strings<'a>(
325 out: &mut impl fmt::Write,
326 items: impl Iterator<Item = &'a str>,
327) -> fmt::Result {
328 out.write_char('[')?;
329 for (i, item) in items.enumerate() {
330 if i > 0 {
331 out.write_char(',')?;
332 }
333 write_string(out, item)?;
334 }
335 out.write_char(']')
336}
337
338/// Writes `s` as a JSON string literal, quotes included.
339///
340/// Escapes `"` and `\`, every control character below U+0020 (with the short
341/// forms `\b \f \n \r \t` where JSON has them, `\u00XX` otherwise), and U+2028
342/// and U+2029, which JSON allows raw but JavaScript does not. Everything else
343/// is written as UTF-8, in runs, so plain text costs one `write_str`.
344pub(crate) fn write_string(out: &mut impl fmt::Write, s: &str) -> fmt::Result {
345 out.write_char('"')?;
346 let mut run = 0;
347 for (i, ch) in s.char_indices() {
348 let escape: Option<&str> = match ch {
349 '"' => Some("\\\""),
350 '\\' => Some("\\\\"),
351 '\n' => Some("\\n"),
352 '\r' => Some("\\r"),
353 '\t' => Some("\\t"),
354 '\u{8}' => Some("\\b"),
355 '\u{c}' => Some("\\f"),
356 '\u{0}'..='\u{1f}' | '\u{2028}' | '\u{2029}' => None,
357 _ => continue,
358 };
359 out.write_str(s.get(run..i).unwrap_or(""))?;
360 match escape {
361 Some(text) => out.write_str(text)?,
362 None => write!(out, "\\u{:04x}", u32::from(ch))?,
363 }
364 run = i + ch.len_utf8();
365 }
366 out.write_str(s.get(run..).unwrap_or(""))?;
367 out.write_char('"')
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn quoted(s: &str) -> String {
375 let mut out = String::new();
376 write_string(&mut out, s).unwrap();
377 out
378 }
379
380 #[test]
381 fn test_strings_are_escaped() {
382 assert_eq!(quoted(""), r#""""#);
383 assert_eq!(quoted("plain"), r#""plain""#);
384 assert_eq!(quoted("a\"b\\c"), r#""a\"b\\c""#);
385 assert_eq!(quoted("\n\r\t\u{8}\u{c}"), r#""\n\r\t\b\f""#);
386 assert_eq!(quoted("\u{0}\u{1b}\u{1f}"), r#""\u0000\u001b\u001f""#);
387 assert_eq!(quoted("\u{2028}\u{2029}"), r#""\u2028\u2029""#);
388 // Non-ASCII and DEL are valid raw in JSON.
389 assert_eq!(quoted("é世😀\u{7f}"), "\"é世😀\u{7f}\"");
390 }
391}