supa_mdx_lint/output/
rdf.rs

1use std::fmt::Write;
2
3use anyhow::Result;
4use log::{debug, warn};
5use serde::Serialize;
6
7use crate::{
8    errors::LintLevel,
9    fix::LintCorrection,
10    location::{AdjustedPoint, DenormalizedLocation},
11    output::OutputFormatter,
12    ConfigMetadata,
13};
14
15use super::LintOutput;
16
17/// Outputs linter diagnostics in the
18/// [Reviewdog Diagnostic Format](https://github.com/reviewdog/reviewdog/tree/master/proto/rdf).
19///
20/// Uses the `rdjsonl` form, which has the structure:
21///
22/// ```text
23/// {"message": "<msg>", "location": {"path": "<file path>", "range": {"start": {"line": 14, "column": 15}}}, "severity": "ERROR"}
24/// {"message": "<msg>", "location": {"path": "<file path>", "range": {"start": {"line": 14, "column": 15}, "end": {"line": 14, "column": 18}}}, "suggestions": [{"range": {"start": {"line": 14, "column": 15}, "end": {"line": 14, "column": 18}}, "text": "<replacement text>"}], "severity": "WARNING"}
25/// ```
26#[derive(Debug, Clone)]
27pub struct RdfFormatter;
28
29#[derive(Debug, PartialEq, Eq, Serialize)]
30struct RdfOutput<'output> {
31    message: &'output str,
32    location: RdfLocation<'output>,
33    severity: &'output LintLevel,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    suggestions: Option<Vec<RdfSuggestion<'output>>>,
36}
37
38#[derive(Debug, PartialEq, Eq, Serialize)]
39struct RdfLocation<'location> {
40    path: &'location str,
41    range: RdfRange,
42}
43
44#[derive(Debug, PartialEq, Eq, Serialize)]
45struct RdfRange {
46    start: RdfPosition,
47    end: RdfPosition,
48}
49
50impl From<DenormalizedLocation> for RdfRange {
51    fn from(location: DenormalizedLocation) -> Self {
52        Self::from(&location)
53    }
54}
55
56impl From<&DenormalizedLocation> for RdfRange {
57    fn from(location: &DenormalizedLocation) -> Self {
58        Self {
59            start: (&location.start).into(),
60            end: (&location.end).into(),
61        }
62    }
63}
64
65#[derive(Debug, PartialEq, Eq, Serialize)]
66struct RdfPosition {
67    line: usize,
68    column: usize,
69}
70
71impl From<&AdjustedPoint> for RdfPosition {
72    fn from(point: &AdjustedPoint) -> Self {
73        Self {
74            line: point.row + 1,
75            column: point.column + 1,
76        }
77    }
78}
79
80#[derive(Debug, PartialEq, Eq, Serialize)]
81struct RdfSuggestion<'suggestion> {
82    range: RdfRange,
83    text: &'suggestion str,
84}
85
86impl<'fix> RdfSuggestion<'fix> {
87    fn from_lint_fix(fix: &'fix LintCorrection) -> Self {
88        match fix {
89            LintCorrection::Insert(fix) => Self {
90                range: (&fix.location).into(),
91                text: &fix.text,
92            },
93            LintCorrection::Delete(fix) => Self {
94                range: (&fix.location).into(),
95                text: "",
96            },
97            LintCorrection::Replace(fix) => Self {
98                range: (&fix.location).into(),
99                text: &fix.text,
100            },
101        }
102    }
103}
104
105impl OutputFormatter for RdfFormatter {
106    fn id(&self) -> &'static str {
107        "rdf"
108    }
109
110    fn should_log_metadata(&self) -> bool {
111        false
112    }
113
114    fn format(&self, outputs: &[LintOutput], metadata: &ConfigMetadata) -> Result<String> {
115        let mut result = String::new();
116        for output in outputs.iter() {
117            for error in output.errors.iter() {
118                let suggestions = match (error.fix.as_ref(), error.suggestions.as_ref()) {
119                    (None, None) => None,
120                    (fix, suggestions) => {
121                        let mut combined = Vec::new();
122                        if let Some(f) = fix {
123                            combined.extend(f.iter());
124                        }
125                        if let Some(s) = suggestions {
126                            combined.extend(s.iter());
127                        }
128                        Some(combined)
129                    }
130                };
131
132                let mut message = String::new();
133                write!(
134                    message,
135                    "[{}] {}{}",
136                    error.rule,
137                    error.message,
138                    if let Some(location) = metadata
139                        .config_file_locations
140                        .as_ref()
141                        .and_then(|locations| locations.get(&error.rule))
142                    {
143                        format!(" (configure rule at {location})")
144                    } else {
145                        "".to_string()
146                    }
147                )?;
148
149                let rdf_output = RdfOutput {
150                    message: &message,
151                    location: RdfLocation {
152                        path: &output.file_path,
153                        range: (&error.location).into(),
154                    },
155                    severity: &error.level,
156                    suggestions: suggestions.map(|fix| {
157                        fix.iter()
158                            .map(|corr| RdfSuggestion::from_lint_fix(corr))
159                            .collect()
160                    }),
161                };
162                debug!("Writing to ReviewDog output format: {rdf_output:?}");
163
164                let json_string = match serde_json::to_string(&rdf_output) {
165                    Ok(json_string) => json_string,
166                    Err(err) => {
167                        warn!("Failed to serialize output: {}", err);
168                        return Err(err.into());
169                    }
170                };
171                result.push_str(&json_string);
172                result.push('\n');
173            }
174        }
175
176        Ok(result)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::{
184        errors::LintError,
185        fix::{LintCorrection, LintCorrectionDelete, LintCorrectionReplace},
186    };
187
188    #[test]
189    fn test_rdf_formatter() {
190        let file_path = "test.md".to_string();
191        let error = LintError::from_raw_location()
192            .rule("MockRule")
193            .level(LintLevel::Error)
194            .message("This is an error")
195            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
196            .call();
197
198        let output = LintOutput {
199            file_path,
200            errors: vec![error],
201        };
202        let output = vec![output];
203
204        let formatter = RdfFormatter;
205        let result = formatter
206            .format(&output, &ConfigMetadata::default())
207            .unwrap();
208        let result = result.trim();
209        let expected = r#"{"message":"[MockRule] This is an error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}"#;
210        assert_eq!(result, expected);
211    }
212
213    #[test]
214    fn test_rdf_formatter_with_fixes() {
215        let file_path = "test.md".to_string();
216        let error = LintError::from_raw_location()
217            .rule("MockRule")
218            .level(LintLevel::Error)
219            .message("This is an error")
220            .location(DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8))
221            .fix(vec![LintCorrection::Delete(LintCorrectionDelete {
222                location: DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8),
223            })])
224            .call();
225        let output = LintOutput {
226            file_path,
227            errors: vec![error],
228        };
229        let output = vec![output];
230
231        let formatter = RdfFormatter;
232        let result = formatter
233            .format(&output, &ConfigMetadata::default())
234            .unwrap();
235
236        let result = result.trim();
237        let expected = r#"{"message":"[MockRule] This is an error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}}},"severity":"ERROR","suggestions":[{"range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}},"text":""}]}"#;
238        assert_eq!(result, expected);
239    }
240
241    #[test]
242    fn test_rdf_formatter_multiple_errors() {
243        let file_path = "test.md".to_string();
244        let error_1 = LintError::from_raw_location()
245            .rule("MockRule")
246            .level(LintLevel::Error)
247            .message("This is an error")
248            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
249            .call();
250        let error_2 = LintError::from_raw_location()
251            .rule("MockRule")
252            .level(LintLevel::Error)
253            .message("This is another error")
254            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 4, 2))
255            .call();
256
257        let output = LintOutput {
258            file_path,
259            errors: vec![error_1, error_2],
260        };
261        let output = vec![output];
262
263        let formatter = RdfFormatter;
264        let result = formatter
265            .format(&output, &ConfigMetadata::default())
266            .unwrap();
267
268        let result = result.trim();
269        let expected = r#"{"message":"[MockRule] This is an error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}
270{"message":"[MockRule] This is another error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":5,"column":3}}},"severity":"ERROR"}"#;
271        assert_eq!(result, expected);
272    }
273
274    #[test]
275    fn test_rdf_formatter_multiple_files() {
276        let file_path_1 = "test.md".to_string();
277        let error_1 = LintError::from_raw_location()
278            .rule("MockRule")
279            .level(LintLevel::Error)
280            .message("This is an error")
281            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
282            .call();
283        let error_2 = LintError::from_raw_location()
284            .rule("MockRule")
285            .level(LintLevel::Error)
286            .message("This is another error")
287            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
288            .call();
289
290        let output_1 = LintOutput {
291            file_path: file_path_1,
292            errors: vec![error_1.clone(), error_2.clone()],
293        };
294
295        let file_path_2 = "test2.md".to_string();
296
297        let output_2 = LintOutput {
298            file_path: file_path_2,
299            errors: vec![error_1, error_2],
300        };
301
302        let output = vec![output_1, output_2];
303
304        let formatter = RdfFormatter;
305        let result = formatter
306            .format(&output, &ConfigMetadata::default())
307            .unwrap();
308
309        let result = result.trim();
310        let expected = r#"{"message":"[MockRule] This is an error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}
311{"message":"[MockRule] This is another error","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}
312{"message":"[MockRule] This is an error","location":{"path":"test2.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}
313{"message":"[MockRule] This is another error","location":{"path":"test2.md","range":{"start":{"line":1,"column":1},"end":{"line":2,"column":1}}},"severity":"ERROR"}"#;
314        assert_eq!(result, expected);
315    }
316
317    #[test]
318    fn test_rdf_formatter_with_fixes_and_suggestions() {
319        let file_path = "test.md".to_string();
320        let error = LintError::from_raw_location()
321            .rule("MockRule")
322            .level(LintLevel::Error)
323            .message("This is an error with fixes and suggestions")
324            .location(DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8))
325            .fix(vec![LintCorrection::Delete(LintCorrectionDelete {
326                location: DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8),
327            })])
328            .suggestions(vec![LintCorrection::Replace(LintCorrectionReplace {
329                location: DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8),
330                text: "replacement text".to_string(),
331            })])
332            .call();
333        let output = LintOutput {
334            file_path,
335            errors: vec![error],
336        };
337        let output = vec![output];
338
339        let formatter = RdfFormatter;
340        let result = formatter
341            .format(&output, &ConfigMetadata::default())
342            .unwrap();
343
344        let result = result.trim();
345        let expected = r#"{"message":"[MockRule] This is an error with fixes and suggestions","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}}},"severity":"ERROR","suggestions":[{"range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}},"text":""},{"range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}},"text":"replacement text"}]}"#;
346        assert_eq!(result, expected);
347    }
348
349    #[test]
350    fn test_rdf_formatter_with_only_suggestions() {
351        let file_path = "test.md".to_string();
352        let error = LintError::from_raw_location()
353            .rule("MockRule")
354            .level(LintLevel::Error)
355            .message("This is an error with only suggestions")
356            .location(DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8))
357            .suggestions(vec![LintCorrection::Replace(LintCorrectionReplace {
358                location: DenormalizedLocation::dummy(0, 8, 0, 0, 0, 8),
359                text: "replacement text".to_string(),
360            })])
361            .call();
362        let output = LintOutput {
363            file_path,
364            errors: vec![error],
365        };
366        let output = vec![output];
367
368        let formatter = RdfFormatter;
369        let result = formatter
370            .format(&output, &ConfigMetadata::default())
371            .unwrap();
372
373        let result = result.trim();
374        let expected = r#"{"message":"[MockRule] This is an error with only suggestions","location":{"path":"test.md","range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}}},"severity":"ERROR","suggestions":[{"range":{"start":{"line":1,"column":1},"end":{"line":1,"column":9}},"text":"replacement text"}]}"#;
375        assert_eq!(result, expected);
376    }
377}