nu_lint/
output.rs

1use std::fmt;
2
3use miette::{Diagnostic, LabeledSpan, Report, SourceCode};
4use serde::Serialize;
5
6use crate::violation::{Severity, Violation};
7
8/// Format violations as human-readable text
9pub(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
28/// Format a single violation as text
29fn 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
69/// Format violations as JSON
70pub 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
82/// Calculate line and column number from byte offset in source
83/// Returns (line, column) as 1-indexed values
84fn 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
97/// Format fix information for text output
98fn 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
123/// Convert a violation to JSON format
124fn 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
156/// Convert a fix to JSON format
157fn 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    /// Format summary showing only non-zero severity counts
300    #[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}