nu_lint/
output.rs

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        // Show summary at the beginning
30        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
111/// Calculate line and column number from byte offset in source
112/// Returns (line, column) as 1-indexed values
113fn 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    /// Format summary showing only non-zero severity counts
245    #[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}