nu_lint/
output.rs

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
12/// Format violations as human-readable text
13pub 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
32/// Format a single violation as text
33fn 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
73/// Format violations as JSON
74pub 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
86/// Calculate line and column number from byte offset in source
87/// Returns (line, column) as 1-indexed values
88fn 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
101/// Format fix information for text output
102fn 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
127/// Convert a violation to JSON format
128fn 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
154/// Convert a fix to JSON format
155fn 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    /// Format summary showing only non-zero severity counts
298    #[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}