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