1use std::{fmt, fmt::Write};
2
3use miette::{Diagnostic, LabeledSpan, Report, SourceCode};
4use serde::Serialize;
5
6use crate::lint::{Severity, Violation};
7#[derive(Debug, Clone, Copy)]
8pub enum OutputFormat {
9 Text,
10 Json,
11 Github,
12}
13
14pub trait OutputFormatter {
15 fn format(&self, violations: &[Violation], source: &str) -> String;
16}
17
18#[derive(Debug, Default)]
19pub struct TextFormatter;
20
21impl OutputFormatter for TextFormatter {
22 fn format(&self, violations: &[Violation], _source: &str) -> String {
23 if violations.is_empty() {
24 return String::from("No violations found!");
25 }
26
27 let mut output = String::new();
28
29 let summary = Summary::from_violations(violations);
31 let _ = writeln!(output, "Found {}\n", summary.format_compact());
32
33 for violation in violations {
34 let source_code = violation
35 .file
36 .as_ref()
37 .and_then(|path| std::fs::read_to_string(path.as_ref()).ok())
38 .unwrap_or_default();
39
40 let (line, column) = calculate_line_column(&source_code, violation.span.start);
41 let (end_line, end_column) = calculate_line_column(&source_code, violation.span.end);
42
43 if let Some(file_path) = &violation.file {
44 let _ = writeln!(output, "\x1b[1m{file_path}:{line}:{column}\x1b[0m");
45 }
46
47 let diagnostic = ViolationDiagnostic {
48 violation: violation.clone(),
49 source_code,
50 line,
51 column,
52 end_line,
53 end_column,
54 };
55
56 let report = Report::new(diagnostic);
57 let _ = writeln!(output, "{report:?}");
58 }
59
60 let summary = Summary::from_violations(violations);
61 let _ = writeln!(output, "\n{}", summary.format_compact());
62
63 output
64 }
65}
66
67#[derive(Debug, Default)]
68pub struct JsonFormatter;
69
70impl OutputFormatter for JsonFormatter {
71 fn format(&self, violations: &[Violation], _source: &str) -> String {
72 let json_violations: Vec<JsonViolation> = violations
73 .iter()
74 .map(|violation| {
75 let source_code = violation
76 .file
77 .as_ref()
78 .and_then(|path| std::fs::read_to_string(path.as_ref()).ok())
79 .unwrap_or_default();
80
81 let (line, column) = calculate_line_column(&source_code, violation.span.start);
82
83 JsonViolation {
84 rule_id: violation.rule_id.to_string(),
85 severity: violation.severity.to_string(),
86 message: violation.message.to_string(),
87 file: violation
88 .file
89 .as_ref()
90 .map(std::string::ToString::to_string),
91 line,
92 column,
93 suggestion: violation
94 .suggestion
95 .as_ref()
96 .map(std::string::ToString::to_string),
97 }
98 })
99 .collect();
100
101 let summary = Summary::from_violations(violations);
102 let output = JsonOutput {
103 violations: json_violations,
104 summary,
105 };
106
107 serde_json::to_string_pretty(&output).unwrap_or_default()
108 }
109}
110
111fn calculate_line_column(source: &str, offset: usize) -> (usize, usize) {
114 let mut line = 1;
115 let mut column = 1;
116
117 for (pos, ch) in source.char_indices() {
118 if pos >= offset {
119 break;
120 }
121 if ch == '\n' {
122 line += 1;
123 column = 1;
124 } else {
125 column += 1;
126 }
127 }
128
129 (line, column)
130}
131
132#[derive(Debug, Clone)]
133struct ViolationDiagnostic {
134 violation: Violation,
135 source_code: String,
136 line: usize,
137 column: usize,
138 end_line: usize,
139 end_column: usize,
140}
141
142impl fmt::Display for ViolationDiagnostic {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 write!(f, "{}", self.violation.message)
145 }
146}
147
148impl std::error::Error for ViolationDiagnostic {}
149
150impl Diagnostic for ViolationDiagnostic {
151 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
152 Some(Box::new(format!(
153 "{}({})",
154 self.violation.severity, self.violation.rule_id
155 )))
156 }
157
158 fn severity(&self) -> Option<miette::Severity> {
159 Some(match self.violation.severity {
160 Severity::Error => miette::Severity::Error,
161 Severity::Warning => miette::Severity::Warning,
162 Severity::Info => miette::Severity::Advice,
163 })
164 }
165
166 fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
167 self.violation
168 .suggestion
169 .as_deref()
170 .map(|s| Box::new(s) as Box<dyn fmt::Display>)
171 }
172
173 fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
174 let span = self.violation.to_source_span();
175 let label_text = if self.line == self.end_line {
176 format!("{} [{}:{}]", self.violation.message, self.line, self.column)
177 } else {
178 format!(
179 "{} [{}:{} - {}:{}]",
180 self.violation.message, self.line, self.column, self.end_line, self.end_column
181 )
182 };
183
184 Some(Box::new(std::iter::once(LabeledSpan::new(
185 Some(label_text),
186 span.offset(),
187 span.len(),
188 ))))
189 }
190
191 fn source_code(&self) -> Option<&dyn SourceCode> {
192 Some(&self.source_code as &dyn SourceCode)
193 }
194}
195
196#[derive(Serialize)]
197pub struct JsonOutput {
198 pub violations: Vec<JsonViolation>,
199 pub summary: Summary,
200}
201
202#[derive(Serialize)]
203pub struct JsonViolation {
204 pub rule_id: String,
205 pub severity: String,
206 pub message: String,
207 pub file: Option<String>,
208 pub line: usize,
209 pub column: usize,
210 pub suggestion: Option<String>,
211}
212
213#[derive(Serialize)]
214pub struct Summary {
215 pub errors: usize,
216 pub warnings: usize,
217 pub info: usize,
218 pub files_checked: usize,
219}
220
221impl Summary {
222 #[must_use]
223 pub fn from_violations(violations: &[Violation]) -> Self {
224 let mut errors = 0;
225 let mut warnings = 0;
226 let mut info = 0;
227
228 for violation in violations {
229 match violation.severity {
230 Severity::Error => errors += 1,
231 Severity::Warning => warnings += 1,
232 Severity::Info => info += 1,
233 }
234 }
235
236 Self {
237 errors,
238 warnings,
239 info,
240 files_checked: 1,
241 }
242 }
243
244 #[must_use]
246 pub fn format_compact(&self) -> String {
247 let mut parts = Vec::new();
248
249 if self.errors > 0 {
250 parts.push(format!("{} error(s)", self.errors));
251 }
252 if self.warnings > 0 {
253 parts.push(format!("{} warning(s)", self.warnings));
254 }
255 if self.info > 0 {
256 parts.push(format!("{} info", self.info));
257 }
258
259 if parts.is_empty() {
260 String::from("0 violations")
261 } else {
262 parts.join(", ")
263 }
264 }
265}