1use std::fmt;
2
3use miette::{Diagnostic, LabeledSpan, Report, SourceCode};
4use serde::Serialize;
5
6use crate::violation::{Severity, Violation};
7
8pub(crate) fn format_text(violations: &[Violation]) -> String {
10 if violations.is_empty() {
11 return String::from("No violations found!");
12 }
13
14 let summary = Summary::from_violations(violations);
15 let header = format!("Found {}\n", summary.format_compact());
16
17 let violations_output: String = violations
18 .iter()
19 .enumerate()
20 .map(|(idx, violation)| format_violation_text(violation, idx < violations.len() - 1))
21 .collect();
22
23 let footer = format!("\n{}", summary.format_compact());
24
25 format!("{header}{violations_output}{footer}")
26}
27
28fn format_violation_text(violation: &Violation, add_separator: bool) -> String {
30 let source_code = violation
31 .file
32 .as_ref()
33 .and_then(|path| std::fs::read_to_string(path.as_ref()).ok())
34 .unwrap_or_default();
35
36 let (line, column) = calculate_line_column(&source_code, violation.span.start);
37 let (end_line, end_column) = calculate_line_column(&source_code, violation.span.end);
38
39 let header = violation.file.as_ref().map_or(String::new(), |file_path| {
40 format!("\n\x1b[1;4m{file_path}:{line}:{column}\x1b[0m\n")
41 });
42
43 let diagnostic = ViolationDiagnostic {
44 violation: violation.clone(),
45 source_code: source_code.clone(),
46 line,
47 column,
48 end_line,
49 end_column,
50 };
51
52 let report = format!("{:?}", Report::new(diagnostic));
53
54 let fix_info = violation
55 .fix
56 .as_ref()
57 .map(|fix| format_fix_info(fix, &source_code))
58 .unwrap_or_default();
59
60 let separator = if add_separator {
61 format!("\n\n{}\n", "─".repeat(80))
62 } else {
63 String::new()
64 };
65
66 format!("{header}{report}\n{fix_info}{separator}")
67}
68
69pub fn format_json(violations: &[Violation]) -> String {
71 let json_violations: Vec<JsonViolation> = violations.iter().map(violation_to_json).collect();
72
73 let summary = Summary::from_violations(violations);
74 let output = JsonOutput {
75 violations: json_violations,
76 summary,
77 };
78
79 serde_json::to_string_pretty(&output).unwrap_or_default()
80}
81
82fn calculate_line_column(source: &str, offset: usize) -> (usize, usize) {
85 source
86 .char_indices()
87 .take_while(|(pos, _)| *pos < offset)
88 .fold((1, 1), |(line, column), (_, ch)| {
89 if ch == '\n' {
90 (line + 1, 1)
91 } else {
92 (line, column + 1)
93 }
94 })
95}
96
97fn format_fix_info(fix: &crate::violation::Fix, source_code: &str) -> String {
99 let header = format!("\n \x1b[36mℹ Available fix:\x1b[0m {}", fix.description);
100
101 if fix.replacements.is_empty() {
102 return header;
103 }
104
105 let replacements = fix
106 .replacements
107 .iter()
108 .map(|replacement| {
109 let (start_line, start_col) =
110 calculate_line_column(source_code, replacement.span.start);
111 let (end_line, end_col) = calculate_line_column(source_code, replacement.span.end);
112 format!(
113 " • {}:{}-{}:{} → {}",
114 start_line, start_col, end_line, end_col, replacement.new_text
115 )
116 })
117 .collect::<Vec<_>>()
118 .join("\n");
119
120 format!("{header}\n \x1b[2mReplacements:\x1b[0m\n{replacements}")
121}
122
123fn violation_to_json(violation: &Violation) -> JsonViolation {
125 let source_code = violation
126 .file
127 .as_ref()
128 .and_then(|path| std::fs::read_to_string(path.as_ref()).ok())
129 .unwrap_or_default();
130
131 let (line_start, column_start) = calculate_line_column(&source_code, violation.span.start);
132 let (line_end, column_end) = calculate_line_column(&source_code, violation.span.end);
133
134 JsonViolation {
135 rule_id: violation.rule_id.to_string(),
136 severity: violation.severity.to_string(),
137 message: violation.message.to_string(),
138 file: violation
139 .file
140 .as_ref()
141 .map(std::string::ToString::to_string),
142 line_start,
143 line_end,
144 column_start,
145 column_end,
146 offset_start: violation.span.start,
147 offset_end: violation.span.end,
148 suggestion: violation
149 .suggestion
150 .as_ref()
151 .map(std::string::ToString::to_string),
152 fix: violation.fix.as_ref().map(fix_to_json),
153 }
154}
155
156fn fix_to_json(fix: &crate::violation::Fix) -> JsonFix {
158 JsonFix {
159 description: fix.description.to_string(),
160 replacements: fix
161 .replacements
162 .iter()
163 .map(|r| JsonReplacement {
164 offset_start: r.span.start,
165 offset_end: r.span.end,
166 new_text: r.new_text.to_string(),
167 })
168 .collect(),
169 }
170}
171
172#[derive(Debug, Clone)]
173struct ViolationDiagnostic {
174 violation: Violation,
175 source_code: String,
176 line: usize,
177 column: usize,
178 end_line: usize,
179 end_column: usize,
180}
181
182impl fmt::Display for ViolationDiagnostic {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(f, "{}", self.violation.message)
185 }
186}
187
188impl std::error::Error for ViolationDiagnostic {}
189
190impl Diagnostic for ViolationDiagnostic {
191 fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
192 Some(Box::new(format!(
193 "{}({})",
194 self.violation.severity, self.violation.rule_id
195 )))
196 }
197
198 fn severity(&self) -> Option<miette::Severity> {
199 Some(match self.violation.severity {
200 Severity::Error => miette::Severity::Error,
201 Severity::Warning => miette::Severity::Warning,
202 Severity::Info => miette::Severity::Advice,
203 })
204 }
205
206 fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
207 self.violation
208 .suggestion
209 .as_deref()
210 .map(|s| Box::new(s) as Box<dyn fmt::Display>)
211 }
212
213 fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
214 let span = self.violation.to_source_span();
215 let label_text = if self.line == self.end_line {
216 format!("{} [{}:{}]", self.violation.message, self.line, self.column)
217 } else {
218 format!(
219 "{} [{}:{} - {}:{}]",
220 self.violation.message, self.line, self.column, self.end_line, self.end_column
221 )
222 };
223
224 Some(Box::new(std::iter::once(LabeledSpan::new(
225 Some(label_text),
226 span.offset(),
227 span.len(),
228 ))))
229 }
230
231 fn source_code(&self) -> Option<&dyn SourceCode> {
232 Some(&self.source_code as &dyn SourceCode)
233 }
234}
235
236#[derive(Serialize)]
237pub struct JsonOutput {
238 pub violations: Vec<JsonViolation>,
239 pub summary: Summary,
240}
241
242#[derive(Serialize)]
243pub struct JsonViolation {
244 pub rule_id: String,
245 pub severity: String,
246 pub message: String,
247 pub file: Option<String>,
248 pub line_start: usize,
249 pub line_end: usize,
250 pub column_start: usize,
251 pub column_end: usize,
252 pub offset_start: usize,
253 pub offset_end: usize,
254 pub suggestion: Option<String>,
255 pub fix: Option<JsonFix>,
256}
257
258#[derive(Serialize)]
259pub struct JsonFix {
260 pub description: String,
261 pub replacements: Vec<JsonReplacement>,
262}
263
264#[derive(Serialize)]
265pub struct JsonReplacement {
266 pub offset_start: usize,
267 pub offset_end: usize,
268 pub new_text: String,
269}
270
271#[derive(Serialize)]
272pub struct Summary {
273 pub errors: usize,
274 pub warnings: usize,
275 pub info: usize,
276 pub files_checked: usize,
277}
278
279impl Summary {
280 #[must_use]
281 pub fn from_violations(violations: &[Violation]) -> Self {
282 let (errors, warnings, info) = violations.iter().fold(
283 (0, 0, 0),
284 |(errors, warnings, info), violation| match violation.severity {
285 Severity::Error => (errors + 1, warnings, info),
286 Severity::Warning => (errors, warnings + 1, info),
287 Severity::Info => (errors, warnings, info + 1),
288 },
289 );
290
291 Self {
292 errors,
293 warnings,
294 info,
295 files_checked: 1,
296 }
297 }
298
299 #[must_use]
301 pub fn format_compact(&self) -> String {
302 let parts: Vec<String> = [
303 (self.errors > 0).then(|| format!("{} error(s)", self.errors)),
304 (self.warnings > 0).then(|| format!("{} warning(s)", self.warnings)),
305 (self.info > 0).then(|| format!("{} info", self.info)),
306 ]
307 .into_iter()
308 .flatten()
309 .collect();
310
311 if parts.is_empty() {
312 String::from("0 violations")
313 } else {
314 parts.join(", ")
315 }
316 }
317}