Skip to main content

sva_core/
output.rs

1// Concern: builds the JSON envelope every answer is written in, success or failure | Non-concern: the data objects it carries (sva-cli's output.rs) | IO: (data or a code, diagnostics) -> JSON
2
3use crate::json::{NONE, escape, list, meta};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum Severity {
7    Error,
8    Warning,
9}
10
11impl Severity {
12    fn as_str(self) -> &'static str {
13        match self {
14            Severity::Error => "error",
15            Severity::Warning => "warning",
16        }
17    }
18}
19
20/// One located finding. No `fix` field: an edit needs a span, and no written literal carries one.
21pub struct Diagnostic {
22    pub code: String,
23    pub severity: Severity,
24    pub message: String,
25    pub file: Option<String>,
26    /// The line and column, from 1, it starts and ends at, where the check knows them.
27    pub start: Option<(usize, usize)>,
28    pub end: Option<(usize, usize)>,
29    pub span: Option<(usize, usize)>,
30    pub help: Option<String>,
31}
32
33impl Diagnostic {
34    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Diagnostic {
35        Diagnostic {
36            code: code.into(),
37            severity: Severity::Error,
38            message: message.into(),
39            file: None,
40            start: None,
41            end: None,
42            span: None,
43            help: None,
44        }
45    }
46
47    pub fn at(mut self, file: Option<String>, span: Option<(usize, usize)>) -> Diagnostic {
48        self.file = file;
49        self.span = span;
50        self
51    }
52
53    pub fn helped(mut self, help: impl Into<String>) -> Diagnostic {
54        self.help = Some(help.into());
55        self
56    }
57
58    /// A check knowing only the line points at its first column, no guess.
59    pub fn on_line(mut self, line: Option<usize>) -> Diagnostic {
60        self.start = line.map(|l| (l, 1));
61        self
62    }
63
64    pub fn placed(mut self, at: Option<(usize, usize)>) -> Diagnostic {
65        self.start = at;
66        self.end = at;
67        self
68    }
69
70    pub fn with_severity(mut self, severity: Severity) -> Diagnostic {
71        self.severity = severity;
72        self
73    }
74}
75
76pub fn diagnostics_json(diagnostics: &[Diagnostic]) -> String {
77    list(diagnostics, diagnostic_json)
78}
79
80fn diagnostic_json(d: &Diagnostic) -> String {
81    let span = d.span.map_or_else(
82        || NONE.to_string(),
83        |(o, l)| format!("{{ \"offset\": {o}, \"length\": {l} }}"),
84    );
85    let place = |at: Option<(usize, usize)>| {
86        at.map_or_else(
87            || NONE.to_string(),
88            |(l, c)| format!("{{ \"line\": {l}, \"column\": {c} }}"),
89        )
90    };
91    let (start, end) = (place(d.start), place(d.end));
92    let location = d.file.as_ref().map_or_else(
93        || NONE.to_string(),
94        |f| {
95            format!(
96                "{{ \"file\": \"{}\", \"span\": {span}, \"start\": {start}, \"end\": {end} }}",
97                escape(f)
98            )
99        },
100    );
101    let help = d
102        .help
103        .as_ref()
104        .map_or_else(|| NONE.to_string(), |h| format!("\"{}\"", escape(h)));
105    format!(
106        "{{ \"code\": \"{}\", \"severity\": \"{}\", \"message\": \"{}\", \"location\": {location}, \"help\": {help} }}",
107        escape(&d.code),
108        d.severity.as_str(),
109        escape(&d.message)
110    )
111}
112
113/// `details` is free-form in the standard: the count and codes an agent branches on, so the
114/// located array is read once, at `data.diagnostics`.
115fn details_json(diagnostics: &[Diagnostic]) -> String {
116    let mut codes: Vec<&str> = Vec::new();
117    for d in diagnostics {
118        if !codes.contains(&d.code.as_str()) {
119            codes.push(&d.code);
120        }
121    }
122    format!(
123        "{{ \"count\": {}, \"codes\": {} }}",
124        diagnostics.len(),
125        crate::json::strings(&codes)
126    )
127}
128
129pub fn success_envelope(data: &str, diagnostics: &[Diagnostic]) -> String {
130    let fields = data
131        .strip_suffix('}')
132        .expect("every data builder answers one JSON object");
133    format!(
134        "{{\n  \"status\": \"success\",\n  \"data\": {fields},\n  \"diagnostics\": {}\n  }},\n  \"meta\": {}\n}}",
135        diagnostics_json(diagnostics),
136        meta()
137    )
138}
139
140/// `data.diagnostics` is always present, empty where the failure has no subject to locate.
141pub fn error_envelope(code: &str, message: &str, diagnostics: &[Diagnostic]) -> String {
142    format!(
143        "{{\n  \"status\": \"error\",\n  \"error\": {{ \"code\": \"{}\", \"message\": \"{}\", \"details\": {} }},\n  \"data\": {{ \"diagnostics\": {} }},\n  \"meta\": {}\n}}",
144        escape(code),
145        escape(message),
146        details_json(diagnostics),
147        diagnostics_json(diagnostics),
148        meta()
149    )
150}