Skip to main content

rubyfast/
output.rs

1use std::collections::BTreeMap;
2
3use colored::Colorize;
4
5use crate::analyzer::ParseError;
6use crate::cli::OutputFormat;
7use crate::file_traverser::TraversalResult;
8use crate::offense::OffenseKind;
9
10/// Print analysis results using the selected output format.
11pub fn print_results(result: &TraversalResult, format: &OutputFormat) {
12    match format {
13        OutputFormat::File => print_results_by_file(result),
14        OutputFormat::Rule => print_results_by_rule(result),
15        OutputFormat::Plain => print_results_plain(result),
16    }
17
18    if !result.parse_errors.is_empty() {
19        print_parse_errors(&result.parse_errors);
20    }
21
22    print_statistics(result);
23}
24
25/// `--format file` — group offenses by file path.
26///
27/// ```text
28/// app/controllers/concerns/lottery_common.rb
29///   L13  fetch_with_argument_vs_block
30///   L94  fetch_with_argument_vs_block
31/// ```
32fn print_results_by_file(result: &TraversalResult) {
33    for analysis in &result.results {
34        if analysis.offenses.is_empty() {
35            continue;
36        }
37        println!("{}", analysis.path.bold());
38        for offense in &analysis.offenses {
39            println!(
40                "  {}  {}",
41                format!("L{}", offense.line).cyan(),
42                offense.kind.config_key()
43            );
44        }
45        println!();
46    }
47}
48
49/// `--format rule` — group offenses by rule kind.
50///
51/// ```text
52/// Hash#fetch with second argument is slower than Hash#fetch with block. (5 offenses)
53///   app/controllers/api/v1/health_articles_controller.rb:11
54///   app/controllers/concerns/lottery_common.rb:13
55/// ```
56fn print_results_by_rule(result: &TraversalResult) {
57    let mut grouped: BTreeMap<OffenseKind, Vec<(String, usize)>> = BTreeMap::new();
58
59    for analysis in &result.results {
60        for offense in &analysis.offenses {
61            grouped
62                .entry(offense.kind)
63                .or_default()
64                .push((analysis.path.clone(), offense.line));
65        }
66    }
67
68    for (kind, locations) in &grouped {
69        let count = locations.len();
70        println!(
71            "{} ({} {})",
72            kind.explanation().yellow(),
73            count,
74            pluralize("offense", count)
75        );
76        for (path, line) in locations {
77            println!("  {}:{}", path, line);
78        }
79        println!();
80    }
81}
82
83/// `--format plain` — one offense per line (original format, for grep/reviewdog).
84///
85/// ```text
86/// app/controllers/api/v1/health_articles_controller.rb:11 Hash#fetch with second argument ...
87/// ```
88fn print_results_plain(result: &TraversalResult) {
89    for analysis in &result.results {
90        if analysis.offenses.is_empty() {
91            continue;
92        }
93        for offense in &analysis.offenses {
94            let location = format!("{}:{}", analysis.path, offense.line);
95            println!("{} {}.", location.red(), offense.kind.explanation());
96        }
97        println!();
98    }
99}
100
101fn print_parse_errors(errors: &[ParseError]) {
102    println!(
103        "rubyfast was unable to process some files because the\n\
104         internal parser is not able to read some characters or\n\
105         has timed out. Unprocessable files were:"
106    );
107    println!("-----------------------------------------------------");
108    for err in errors {
109        println!("{} - {}", err.path, err.message);
110    }
111    println!();
112}
113
114fn print_statistics(result: &TraversalResult) {
115    let files = result.files_inspected;
116    let offenses = result.total_offenses();
117    let parse_errors = result.parse_errors.len();
118
119    let files_str = format!("{} {} inspected", files, pluralize("file", files));
120
121    let offenses_str = format!("{} {} detected", offenses, pluralize("offense", offenses));
122
123    let colored_offenses = if offenses == 0 {
124        offenses_str.green().to_string()
125    } else {
126        offenses_str.red().to_string()
127    };
128
129    if parse_errors > 0 {
130        let errors_str = format!(
131            "{} unparsable {} found",
132            parse_errors,
133            pluralize("file", parse_errors)
134        );
135        println!(
136            "{}, {}, {}",
137            files_str.green(),
138            colored_offenses,
139            errors_str.red()
140        );
141    } else {
142        println!("{}, {}", files_str.green(), colored_offenses);
143    }
144}
145
146/// Print results when --fix mode is active.
147pub fn print_fix_results(
148    result: &TraversalResult,
149    total_fixed: usize,
150    total_errors: usize,
151    format: &OutputFormat,
152) {
153    // Print unfixable offenses using the selected format
154    let unfixable_result = filter_unfixable(result);
155    match format {
156        OutputFormat::File => print_results_by_file(&unfixable_result),
157        OutputFormat::Rule => print_results_by_rule(&unfixable_result),
158        OutputFormat::Plain => print_results_plain(&unfixable_result),
159    }
160
161    if !result.parse_errors.is_empty() {
162        print_parse_errors(&result.parse_errors);
163    }
164
165    print_fix_statistics(result, total_fixed, total_errors);
166}
167
168/// Build a TraversalResult containing only unfixable offenses.
169fn filter_unfixable(result: &TraversalResult) -> TraversalResult {
170    use crate::analyzer::AnalysisResult;
171
172    let results = result
173        .results
174        .iter()
175        .map(|analysis| {
176            let offenses = analysis
177                .offenses
178                .iter()
179                .filter(|o| o.fix.is_none())
180                .cloned()
181                .collect();
182            AnalysisResult {
183                path: analysis.path.clone(),
184                offenses,
185            }
186        })
187        .collect();
188
189    TraversalResult {
190        results,
191        parse_errors: vec![],
192        files_inspected: result.files_inspected,
193    }
194}
195
196fn print_fix_statistics(result: &TraversalResult, total_fixed: usize, total_errors: usize) {
197    let files = result.files_inspected;
198    let offenses = result.total_offenses();
199    let fixable: usize = result
200        .results
201        .iter()
202        .flat_map(|r| &r.offenses)
203        .filter(|o| o.fix.is_some())
204        .count();
205
206    let files_str = format!("{} {} inspected", files, pluralize("file", files));
207    let offenses_str = format!("{} {} detected", offenses, pluralize("offense", offenses));
208    let fixed_str = format!(
209        "{} {} fixed",
210        total_fixed,
211        pluralize("offense", total_fixed)
212    );
213
214    let colored_offenses = if offenses == 0 {
215        offenses_str.green().to_string()
216    } else {
217        offenses_str.red().to_string()
218    };
219
220    let colored_fixed = if total_fixed > 0 {
221        fixed_str.green().to_string()
222    } else {
223        fixed_str.to_string()
224    };
225
226    let unfixable = offenses.saturating_sub(fixable);
227    if total_errors > 0 {
228        let err_str = format!(
229            "{} {} skipped (syntax error after fix)",
230            total_errors,
231            pluralize("file", total_errors)
232        );
233        println!(
234            "{}, {}, {}, {}",
235            files_str.green(),
236            colored_offenses,
237            colored_fixed,
238            err_str.yellow()
239        );
240    } else if unfixable > 0 {
241        let unfixable_str = format!(
242            "{} {} cannot be auto-fixed",
243            unfixable,
244            pluralize("offense", unfixable)
245        );
246        println!(
247            "{}, {}, {}, {}",
248            files_str.green(),
249            colored_offenses,
250            colored_fixed,
251            unfixable_str.yellow()
252        );
253    } else {
254        println!(
255            "{}, {}, {}",
256            files_str.green(),
257            colored_offenses,
258            colored_fixed
259        );
260    }
261}
262
263fn pluralize(word: &str, count: usize) -> String {
264    if count == 1 {
265        word.to_string()
266    } else {
267        format!("{}s", word)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::analyzer::AnalysisResult;
275    use crate::fix::Fix;
276    use crate::offense::{Offense, OffenseKind};
277
278    #[test]
279    fn pluralize_singular() {
280        assert_eq!(pluralize("file", 1), "file");
281    }
282
283    #[test]
284    fn pluralize_plural() {
285        assert_eq!(pluralize("file", 0), "files");
286        assert_eq!(pluralize("offense", 2), "offenses");
287    }
288
289    fn make_result(offenses: Vec<Offense>) -> TraversalResult {
290        TraversalResult {
291            results: vec![AnalysisResult {
292                path: "test.rb".to_string(),
293                offenses,
294            }],
295            parse_errors: vec![],
296            files_inspected: 1,
297        }
298    }
299
300    #[test]
301    fn filter_unfixable_keeps_only_no_fix() {
302        let offenses = vec![
303            Offense::new(OffenseKind::GsubVsTr, 1),
304            Offense::with_fix(OffenseKind::ForLoopVsEach, 2, Fix::single(0, 3, "x")),
305            Offense::new(OffenseKind::SortVsSortBy, 3),
306        ];
307        let result = make_result(offenses);
308        let filtered = filter_unfixable(&result);
309        assert_eq!(filtered.results[0].offenses.len(), 2);
310        assert!(filtered.results[0].offenses.iter().all(|o| o.fix.is_none()));
311    }
312
313    #[test]
314    fn filter_unfixable_empty_when_all_fixable() {
315        let offenses = vec![Offense::with_fix(
316            OffenseKind::ForLoopVsEach,
317            1,
318            Fix::single(0, 3, "x"),
319        )];
320        let result = make_result(offenses);
321        let filtered = filter_unfixable(&result);
322        assert_eq!(filtered.results[0].offenses.len(), 0);
323    }
324
325    #[test]
326    fn print_results_by_file_no_panic() {
327        let result = make_result(vec![Offense::new(OffenseKind::GsubVsTr, 5)]);
328        print_results_by_file(&result);
329    }
330
331    #[test]
332    fn print_results_by_file_empty_no_panic() {
333        let result = make_result(vec![]);
334        print_results_by_file(&result);
335    }
336
337    #[test]
338    fn print_results_by_rule_no_panic() {
339        let result = make_result(vec![
340            Offense::new(OffenseKind::GsubVsTr, 5),
341            Offense::new(OffenseKind::GsubVsTr, 10),
342        ]);
343        print_results_by_rule(&result);
344    }
345
346    #[test]
347    fn print_results_plain_no_panic() {
348        let result = make_result(vec![Offense::new(OffenseKind::GsubVsTr, 5)]);
349        print_results_plain(&result);
350    }
351
352    #[test]
353    fn print_results_plain_empty_no_panic() {
354        let result = make_result(vec![]);
355        print_results_plain(&result);
356    }
357
358    #[test]
359    fn print_statistics_no_offenses() {
360        let result = make_result(vec![]);
361        print_statistics(&result);
362    }
363
364    #[test]
365    fn print_statistics_with_offenses() {
366        let result = make_result(vec![Offense::new(OffenseKind::GsubVsTr, 5)]);
367        print_statistics(&result);
368    }
369
370    #[test]
371    fn print_statistics_with_parse_errors() {
372        let result = TraversalResult {
373            results: vec![],
374            parse_errors: vec![ParseError {
375                path: "bad.rb".to_string(),
376                message: "syntax error".to_string(),
377            }],
378            files_inspected: 1,
379        };
380        print_statistics(&result);
381    }
382
383    #[test]
384    fn print_parse_errors_no_panic() {
385        let errors = vec![ParseError {
386            path: "bad.rb".to_string(),
387            message: "oops".to_string(),
388        }];
389        print_parse_errors(&errors);
390    }
391
392    #[test]
393    fn print_results_dispatches_all_formats() {
394        let result = make_result(vec![Offense::new(OffenseKind::GsubVsTr, 1)]);
395        print_results(&result, &OutputFormat::File);
396        print_results(&result, &OutputFormat::Rule);
397        print_results(&result, &OutputFormat::Plain);
398    }
399
400    #[test]
401    fn print_fix_results_no_panic() {
402        let offenses = vec![
403            Offense::new(OffenseKind::GsubVsTr, 1),
404            Offense::with_fix(OffenseKind::ForLoopVsEach, 2, Fix::single(0, 3, "x")),
405        ];
406        let result = make_result(offenses);
407        print_fix_results(&result, 1, 0, &OutputFormat::File);
408    }
409
410    #[test]
411    fn print_fix_results_with_errors() {
412        let offenses = vec![Offense::with_fix(
413            OffenseKind::ForLoopVsEach,
414            1,
415            Fix::single(0, 3, "x"),
416        )];
417        let result = make_result(offenses);
418        print_fix_results(&result, 0, 1, &OutputFormat::File);
419    }
420
421    #[test]
422    fn print_fix_results_all_fixed() {
423        let offenses = vec![Offense::with_fix(
424            OffenseKind::ForLoopVsEach,
425            1,
426            Fix::single(0, 3, "x"),
427        )];
428        let result = make_result(offenses);
429        print_fix_results(&result, 1, 0, &OutputFormat::File);
430    }
431
432    #[test]
433    fn print_fix_results_unfixable_remaining() {
434        let offenses = vec![
435            Offense::new(OffenseKind::GsubVsTr, 1),
436            Offense::with_fix(OffenseKind::ForLoopVsEach, 2, Fix::single(0, 3, "x")),
437        ];
438        let result = make_result(offenses);
439        print_fix_results(&result, 1, 0, &OutputFormat::Rule);
440        print_fix_results(&result, 1, 0, &OutputFormat::Plain);
441    }
442}