Skip to content

Commit

Permalink
feat: pretty formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
charislam committed Nov 27, 2024
1 parent 302a804 commit 04bdcce
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 1 deletion.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ edition = "2021"
[dependencies]
anyhow = "1.0.89"
clap = { version = "4.5.20", features = ["derive"] }
crop = "0.4.2"
crop = { version = "0.4.2", features = ["graphemes"] }
exitcode = "1.1.2"
glob = "0.3.1"
itertools = "0.13.0"
Expand Down
4 changes: 4 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use anyhow::Result;

use crate::{app_error, errors::LintError};

pub mod pretty;
pub mod rdf;
pub mod simple;

Expand Down Expand Up @@ -32,13 +33,15 @@ impl LintOutput {

#[derive(Debug, Clone)]
pub enum OutputFormatter {
Pretty(pretty::PrettyFormatter),
Simple(simple::SimpleFormatter),
Rdf(rdf::RdfFormatter),
}

impl OutputFormatter {
pub fn format<Writer: Write>(&self, output: &[LintOutput], io: &mut Writer) -> Result<()> {
match self {
Self::Pretty(formatter) => formatter.format(output, io),
Self::Simple(formatter) => formatter.format(output, io),
Self::Rdf(formatter) => formatter.format(output, io),
}
Expand All @@ -50,6 +53,7 @@ impl FromStr for OutputFormatter {

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pretty" => Ok(Self::Pretty(pretty::PrettyFormatter)),
"simple" => Ok(Self::Simple(simple::SimpleFormatter)),
"rdf" => Ok(Self::Rdf(rdf::RdfFormatter)),
_ => Err(app_error::ParseError::VariantNotFound),
Expand Down
288 changes: 288 additions & 0 deletions src/output/pretty.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
use std::{collections::HashSet, fs, io::Write};

use anyhow::Result;

use crate::{errors::LintLevel, rope::Rope};

use super::LintOutput;

/// Outputs linter diagnostics in the pretty format, for CLI display, which has
/// the structure:
///
/// ```text
/// <file Path>
/// ===========
/// [<severity>: <rule>] <msg>
/// <line> The line number containing the error
/// ^^^^^
///
/// [<severity>: <rule>] <msg>
/// ...
/// ```
///
/// The diagnostics are followed by a summary of the number of linted files,
/// total errors, and total warnings.
#[derive(Debug, Clone)]
pub struct PrettyFormatter;

impl PrettyFormatter {
pub(super) fn format<Writer: Write>(
&self,
output: &[LintOutput],
io: &mut Writer,
) -> Result<()> {
// Whether anything has been written to the output, used to determine
// whether to write a newline before each section.
let mut written = false;

for curr in output.iter() {
if curr.errors.is_empty() {
continue;
}

let content = fs::read_to_string(&curr.file_path)?;
let rope = Rope::from(content);

if written {
writeln!(io)?;
}
written |= true;

writeln!(io, "{}", curr.file_path)?;
writeln!(io, "{}", "=".repeat(curr.file_path.len()))?;

for (idx, error) in curr.errors.iter().enumerate() {
if idx > 0 {
writeln!(io)?;
}

writeln!(io, "[{}: {}] {}", error.level, error.rule, error.message)?;

let start_line = rope.line_of_byte(error.location.offset_range.start.into());
let end_line = rope.line_of_byte(error.location.offset_range.end.into());

for line_no in start_line..=end_line {
let line = rope.line(line_no);
log::debug!("line: {}", line);
let number_graphemes = line.graphemes().count();
writeln!(io, "{}", line)?;
if line_no == start_line {
let (_line, col) =
rope.line_column_of_byte(error.location.offset_range.start.into());
let graphemes_before = line.byte_slice(..col).graphemes().count();
writeln!(
io,
"{}{}",
" ".repeat(graphemes_before),
"^".repeat(number_graphemes - graphemes_before)
)?;
} else if line_no == end_line {
let (_line, col) =
rope.line_column_of_byte(error.location.offset_range.end.into());
let graphemes_before = line.byte_slice(..col).graphemes().count();
writeln!(
io,
"{}{}",
"^".repeat(graphemes_before),
" ".repeat(number_graphemes - graphemes_before)
)?;
} else {
writeln!(io, "{}", "^".repeat(number_graphemes))?;
}
}
}
}

if written {
writeln!(io)?;
}
PrettyFormatter::write_summary(output, io)?;

Ok(())
}
}

impl PrettyFormatter {
fn write_summary(output: &[LintOutput], io: &mut impl Write) -> Result<()> {
let mut seen_files = HashSet::<&str>::new();
let mut num_errors = 0;
let mut num_warnings = 0;

for o in output {
seen_files.insert(&o.file_path);
for error in &o.errors {
match error.level {
LintLevel::Error => num_errors += 1,
LintLevel::Warning => num_warnings += 1,
}
}
}

let diagnostic_message = match (num_errors, num_warnings) {
(0, 0) => "🟢 No errors or warnings found",
(0, num_warnings) => &format!(
"🟡 Found {} warning{}",
num_warnings,
if num_warnings != 1 { "s" } else { "" }
),
(num_errors, 0) => &format!(
"🔴 Found {} error{}",
num_errors,
if num_errors != 1 { "s" } else { "" }
),
(num_errors, num_warnings) => &format!(
"🔴 Found {} error{} and {} warning{}",
num_errors,
if num_errors != 1 { "s" } else { "" },
num_warnings,
if num_warnings != 1 { "s" } else { "" }
),
};

writeln!(
io,
"🔍 {} source{} linted",
seen_files.len(),
if seen_files.len() != 1 { "s" } else { "" }
)?;
writeln!(io, "{}", diagnostic_message)?;
Ok(())
}
}

#[cfg(test)]
mod tests {
use tempfile::TempDir;

use super::*;
use crate::{
errors::{LintError, LintLevel},
geometry::DenormalizedLocation,
};

#[test]
fn test_pretty_formatter() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
fs::write(&file_path, "# Hello World").unwrap();

let error = LintError {
rule: "MockRule".to_string(),
level: LintLevel::Error,
message: "This is an error".to_string(),
location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
fix: None,
};

let file_path = file_path.to_string_lossy().to_string();
let output = LintOutput {
file_path: file_path.clone(),
errors: vec![error],
};
let output = vec![output];

let formatter = PrettyFormatter;
let mut result = Vec::new();
formatter.format(&output, &mut result).unwrap();
assert_eq!(
String::from_utf8(result).unwrap(),
format!("{file_path}\n{}\n[ERROR: MockRule] This is an error\n# Hello World\n ^^^^^\n\n🔍 1 source linted\n🔴 Found 1 error\n",
"=".repeat(file_path.len()))
);
}

#[test]
fn test_pretty_formatter_no_errors() {
let file_path = "test.md".to_string();
let output = LintOutput {
file_path,
errors: vec![],
};
let output = vec![output];

let formatter = PrettyFormatter;
let mut result = Vec::new();
formatter.format(&output, &mut result).unwrap();
assert_eq!(
String::from_utf8(result).unwrap(),
"🔍 1 source linted\n🟢 No errors or warnings found\n"
);
}

#[test]
fn test_pretty_formatter_multiple_errors() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
fs::write(&file_path, "# Hello World\n\n# Hello World").unwrap();

let error_1 = LintError {
rule: "MockRule".to_string(),
level: LintLevel::Error,
message: "This is an error".to_string(),
location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
fix: None,
};
let error_2 = LintError {
rule: "MockRule".to_string(),
level: LintLevel::Error,
message: "This is another error".to_string(),
location: DenormalizedLocation::dummy(23, 28, 3, 8, 3, 13),
fix: None,
};

let file_path = file_path.to_string_lossy().to_string();
let output = LintOutput {
file_path: file_path.clone(),
errors: vec![error_1, error_2],
};
let output = vec![output];

let formatter = PrettyFormatter;
let mut result = Vec::new();
formatter.format(&output, &mut result).unwrap();
assert_eq!(
String::from_utf8(result).unwrap(),
format!("{file_path}\n{}\n[ERROR: MockRule] This is an error\n# Hello World\n ^^^^^\n\n[ERROR: MockRule] This is another error\n# Hello World\n ^^^^^\n\n🔍 1 source linted\n🔴 Found 2 errors\n",
"=".repeat(file_path.len()))
);
}

#[test]
fn test_pretty_formatter_multiple_files() {
let temp_dir = TempDir::new().unwrap();
let file_path_1 = temp_dir.path().join("test.md");
fs::write(&file_path_1, "# Hello World").unwrap();
let file_path_2 = temp_dir.path().join("test2.md");
fs::write(&file_path_2, "# Hello World").unwrap();

let error_1 = LintError {
rule: "MockRule".to_string(),
level: LintLevel::Error,
message: "This is an error".to_string(),
location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
fix: None,
};

let file_path_1 = file_path_1.to_string_lossy().to_string();
let file_path_2 = file_path_2.to_string_lossy().to_string();
let output_1 = LintOutput {
file_path: file_path_1.clone(),
errors: vec![error_1.clone()],
};
let output_2 = LintOutput {
file_path: file_path_2.clone(),
errors: vec![error_1],
};

let output = vec![output_1, output_2];

let formatter = PrettyFormatter;
let mut result = Vec::new();
formatter.format(&output, &mut result).unwrap();
assert_eq!(
String::from_utf8(result).unwrap(),
format!("{file_path_1}\n{}\n[ERROR: MockRule] This is an error\n# Hello World\n ^^^^^\n\n{file_path_2}\n{}\n[ERROR: MockRule] This is an error\n# Hello World\n ^^^^^\n\n🔍 2 sources linted\n🔴 Found 2 errors\n",
"=".repeat(file_path_1.len()), "=".repeat(file_path_2.len()))
);
}
}
6 changes: 6 additions & 0 deletions src/rope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ impl From<&str> for Rope {
}
}

impl From<String> for Rope {
fn from(s: String) -> Self {
Self(crop::Rope::from(s))
}
}

impl Rope {
pub(crate) fn line_column_of_byte(&self, byte_offset: usize) -> (usize, usize) {
let line = self.line_of_byte(byte_offset);
Expand Down

0 comments on commit 04bdcce

Please sign in to comment.