diff --git a/README.md b/README.md index 93473d6a6f..52a2ee79d4 100644 --- a/README.md +++ b/README.md @@ -236,10 +236,10 @@ See `ruff --help` for more: ```shell Ruff: An extremely fast Python linter. -Usage: ruff [OPTIONS] ... +Usage: ruff [OPTIONS] [FILES]... Arguments: - ... + [FILES]... Options: --config @@ -277,7 +277,7 @@ Options: --per-file-ignores List of mappings from file pattern to code to exclude --format - Output serialization format for error messages [default: text] [possible values: text, json] + Output serialization format for error messages [default: text] [possible values: text, json, grouped] --show-source Show violations with source code --show-files @@ -296,6 +296,8 @@ Options: Max McCabe complexity allowed for a function --stdin-filename The name of the file when passing it through stdin + --explain + Explain a rule -h, --help Print help information -V, --version diff --git a/src/commands.rs b/src/commands.rs index d2ccd6c091..3b37012bec 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -43,7 +43,7 @@ struct Explanation<'a> { /// Explain a `CheckCode` to the user. pub fn explain(code: &CheckCode, format: SerializationFormat) -> Result<()> { match format { - SerializationFormat::Text => { + SerializationFormat::Text | SerializationFormat::Grouped => { println!( "{} ({}): {}", code.as_ref(), diff --git a/src/message.rs b/src/message.rs index e2f6275559..43755060a5 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,16 +1,10 @@ use std::cmp::Ordering; -use std::fmt; -use std::path::Path; -use annotate_snippets::display_list::{DisplayList, FormatOptions}; -use annotate_snippets::snippet::{Annotation, AnnotationType, Slice, Snippet, SourceAnnotation}; -use colored::Colorize; use rustpython_parser::ast::Location; use serde::{Deserialize, Serialize}; use crate::ast::types::Range; use crate::checks::{Check, CheckKind}; -use crate::fs::relativize_path; use crate::source_code_locator::SourceCodeLocator; #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -50,57 +44,6 @@ impl PartialOrd for Message { } } -impl fmt::Display for Message { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let label = format!( - "{}{}{}{}{}{} {} {}", - relativize_path(Path::new(&self.filename)).bold(), - ":".cyan(), - self.location.row(), - ":".cyan(), - self.location.column(), - ":".cyan(), - self.kind.code().as_ref().red().bold(), - self.kind.body(), - ); - match &self.source { - None => write!(f, "{label}"), - Some(source) => { - let snippet = Snippet { - title: Some(Annotation { - label: Some(&label), - annotation_type: AnnotationType::Error, - // The ID (error number) is already encoded in the `label`. - id: None, - }), - footer: vec![], - slices: vec![Slice { - source: &source.contents, - line_start: self.location.row(), - annotations: vec![SourceAnnotation { - label: self.kind.code().as_ref(), - annotation_type: AnnotationType::Error, - range: source.range, - }], - // The origin (file name, line number, and column number) is already encoded - // in the `label`. - origin: None, - fold: false, - }], - opt: FormatOptions { - color: true, - ..FormatOptions::default() - }, - }; - // `split_once(' ')` strips "error: " from `message`. - let message = DisplayList::from(snippet).to_string(); - let (_, message) = message.split_once(' ').unwrap(); - write!(f, "{message}") - } - } - } -} - #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Source { pub contents: String, diff --git a/src/printer.rs b/src/printer.rs index ea80bc3a8d..8623a77b1c 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -1,18 +1,27 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use annotate_snippets::display_list::{DisplayList, FormatOptions}; +use annotate_snippets::snippet::{Annotation, AnnotationType, Slice, Snippet, SourceAnnotation}; use anyhow::Result; use clap::ValueEnum; use colored::Colorize; +use itertools::iterate; use rustpython_parser::ast::Location; use serde::Serialize; use crate::checks::{CheckCode, CheckKind}; +use crate::fs::relativize_path; use crate::linter::Diagnostics; use crate::logging::LogLevel; +use crate::message::Message; use crate::tell_user; #[derive(Clone, Copy, ValueEnum, PartialEq, Eq, Debug)] pub enum SerializationFormat { Text, Json, + Grouped, } #[derive(Serialize)] @@ -41,6 +50,28 @@ impl<'a> Printer<'a> { } } + fn pre_text(&self, diagnostics: &Diagnostics) { + if self.log_level >= &LogLevel::Default { + if diagnostics.fixed > 0 { + println!( + "Found {} error(s) ({} fixed).", + diagnostics.messages.len(), + diagnostics.fixed, + ); + } else if !diagnostics.messages.is_empty() { + println!("Found {} error(s).", diagnostics.messages.len()); + } + } + } + + fn post_text(&self, num_fixable: usize) { + if self.log_level >= &LogLevel::Default { + if num_fixable > 0 { + println!("{num_fixable} potentially fixable with the --fix option."); + } + } + } + pub fn write_once(&self, diagnostics: &Diagnostics) -> Result<()> { if matches!(self.log_level, LogLevel::Silent) { return Ok(()); @@ -73,27 +104,56 @@ impl<'a> Printer<'a> { ); } SerializationFormat::Text => { - if self.log_level >= &LogLevel::Default { - if diagnostics.fixed > 0 { - println!( - "Found {} error(s) ({} fixed).", - diagnostics.messages.len(), - diagnostics.fixed, - ); - } else if !diagnostics.messages.is_empty() { - println!("Found {} error(s).", diagnostics.messages.len()); - } - } + self.pre_text(diagnostics); for message in &diagnostics.messages { - println!("{message}"); + print_message(message); } - if self.log_level >= &LogLevel::Default { - if num_fixable > 0 { - println!("{num_fixable} potentially fixable with the --fix option."); - } + self.post_text(num_fixable); + } + SerializationFormat::Grouped => { + self.pre_text(diagnostics); + println!(); + + // Group by filename. + let mut grouped_messages = BTreeMap::default(); + for message in &diagnostics.messages { + grouped_messages + .entry(&message.filename) + .or_insert_with(Vec::new) + .push(message); } + + for (filename, messages) in grouped_messages { + // Compute the maximum number of digits in the row and column, for messages in + // this file. + let row_length = num_digits( + messages + .iter() + .map(|message| message.location.row()) + .max() + .unwrap(), + ); + let column_length = num_digits( + messages + .iter() + .map(|message| message.location.column()) + .max() + .unwrap(), + ); + + // Print the filename. + println!("{}:", relativize_path(Path::new(&filename)).underline()); + + // Print each message. + for message in messages { + print_grouped_message(message, row_length, column_length); + } + println!(); + } + + self.post_text(num_fixable); } } @@ -117,7 +177,7 @@ impl<'a> Printer<'a> { println!(); } for message in &diagnostics.messages { - println!("{message}"); + print_message(message); } } @@ -130,3 +190,107 @@ impl<'a> Printer<'a> { Ok(()) } } + +fn num_digits(n: usize) -> usize { + iterate(n, |&n| n / 10) + .take_while(|&n| n > 0) + .count() + .max(1) +} + +/// Print a single `Message` with full details. +fn print_message(message: &Message) { + let label = format!( + "{}{}{}{}{}{} {} {}", + relativize_path(Path::new(&message.filename)).bold(), + ":".cyan(), + message.location.row(), + ":".cyan(), + message.location.column(), + ":".cyan(), + message.kind.code().as_ref().red().bold(), + message.kind.body(), + ); + println!("{label}"); + if let Some(source) = &message.source { + let snippet = Snippet { + title: Some(Annotation { + label: None, + annotation_type: AnnotationType::Error, + // The ID (error number) is already encoded in the `label`. + id: None, + }), + footer: vec![], + slices: vec![Slice { + source: &source.contents, + line_start: message.location.row(), + annotations: vec![SourceAnnotation { + label: message.kind.code().as_ref(), + annotation_type: AnnotationType::Error, + range: source.range, + }], + // The origin (file name, line number, and column number) is already encoded + // in the `label`. + origin: None, + fold: false, + }], + opt: FormatOptions { + color: true, + ..FormatOptions::default() + }, + }; + // Skip the first line, since we format the `label` ourselves. + let message = DisplayList::from(snippet).to_string(); + let (_, message) = message.split_once('\n').unwrap(); + println!("{message}"); + } +} + +/// Print a grouped `Message`, assumed to be printed in a group with others from +/// the same file. +fn print_grouped_message(message: &Message, row_length: usize, column_length: usize) { + let label = format!( + " {}{}{}{}{} {} {}", + " ".repeat(row_length - num_digits(message.location.row())), + message.location.row(), + ":".cyan(), + message.location.column(), + " ".repeat(column_length - num_digits(message.location.column())), + message.kind.code().as_ref().red().bold(), + message.kind.body(), + ); + println!("{label}"); + if let Some(source) = &message.source { + let snippet = Snippet { + title: Some(Annotation { + label: None, + annotation_type: AnnotationType::Error, + // The ID (error number) is already encoded in the `label`. + id: None, + }), + footer: vec![], + slices: vec![Slice { + source: &source.contents, + line_start: message.location.row(), + annotations: vec![SourceAnnotation { + label: message.kind.code().as_ref(), + annotation_type: AnnotationType::Error, + range: source.range, + }], + // The origin (file name, line number, and column number) is already encoded + // in the `label`. + origin: None, + fold: false, + }], + opt: FormatOptions { + color: true, + ..FormatOptions::default() + }, + }; + // Skip the first line, since we format the `label` ourselves. + let message = DisplayList::from(snippet).to_string(); + let (_, message) = message.split_once('\n').unwrap(); + let message = textwrap::indent(message, " "); + println!("{message}"); + } +}