Skip to main content

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}