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#[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}