Skip to content

Commit

Permalink
refactor(linter): move DiagnosticsReporters to oxlint
Browse files Browse the repository at this point in the history
  • Loading branch information
Sysix committed Jan 12, 2025
1 parent f6ccdcd commit 1e74eb0
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 356 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions apps/oxlint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"]
ignore = { workspace = true, features = ["simd-accel"] }
miette = { workspace = true }
rayon = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
Expand Down
110 changes: 108 additions & 2 deletions apps/oxlint/src/output_formatter/checkstyle.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
use std::io::{BufWriter, Stdout, Write};
use std::{
borrow::Cow,
io::{BufWriter, Stdout, Write},
};

use oxc_diagnostics::reporter::{CheckstyleReporter, DiagnosticReporter};
use rustc_hash::FxHashMap;

use oxc_diagnostics::{
reporter::{DiagnosticReporter, Info},
Error, Severity,
};

use crate::output_formatter::InternalFormatter;

Expand All @@ -16,3 +24,101 @@ impl InternalFormatter for CheckStyleOutputFormatter {
Box::new(CheckstyleReporter::default())
}
}

#[derive(Default)]
pub struct CheckstyleReporter {
diagnostics: Vec<Error>,
}

impl DiagnosticReporter for CheckstyleReporter {
fn finish(&mut self, writer: &mut BufWriter<Stdout>) {
writer.write_all(format_checkstyle(&self.diagnostics).as_bytes()).unwrap();
}

fn render_diagnostics(&mut self, _writer: &mut BufWriter<Stdout>, _s: &[u8]) {}

fn render_error(&mut self, error: Error) -> Option<String> {
self.diagnostics.push(error);
None
}
}

fn format_checkstyle(diagnostics: &[Error]) -> String {
let infos = diagnostics.iter().map(Info::new).collect::<Vec<_>>();
let mut grouped: FxHashMap<String, Vec<Info>> = FxHashMap::default();
for info in infos {
grouped.entry(info.filename.clone()).or_default().push(info);
}
let messages = grouped.into_values().map(|infos| {
let messages = infos
.iter()
.fold(String::new(), |mut acc, info| {
let Info { line, column, message, severity, rule_id, .. } = info;
let severity = match severity {
Severity::Error => "error",
_ => "warning",
};
let message = rule_id.as_ref().map_or_else(|| xml_escape(message), |rule_id| Cow::Owned(format!("{} ({rule_id})", xml_escape(message))));
let source = rule_id.as_ref().map_or_else(|| Cow::Borrowed(""), |rule_id| Cow::Owned(format!("eslint.rules.{rule_id}")));
let line = format!(r#"<error line="{line}" column="{column}" severity="{severity}" message="{message}" source="{source}" />"#);
acc.push_str(&line);
acc
});
let filename = &infos[0].filename;
format!(r#"<file name="{filename}">{messages}</file>"#)
}).collect::<Vec<_>>().join(" ");
format!(
r#"<?xml version="1.0" encoding="utf-8"?><checkstyle version="4.3">{messages}</checkstyle>"#
)
}

/// <https://github.com/tafia/quick-xml/blob/6e34a730853fe295d68dc28460153f08a5a12955/src/escapei.rs#L84-L86>
fn xml_escape(raw: &str) -> Cow<str> {
xml_escape_impl(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"'))
}

fn xml_escape_impl<F: Fn(u8) -> bool>(raw: &str, escape_chars: F) -> Cow<str> {
let bytes = raw.as_bytes();
let mut escaped = None;
let mut iter = bytes.iter();
let mut pos = 0;
while let Some(i) = iter.position(|&b| escape_chars(b)) {
if escaped.is_none() {
escaped = Some(Vec::with_capacity(raw.len()));
}
let escaped = escaped.as_mut().expect("initialized");
let new_pos = pos + i;
escaped.extend_from_slice(&bytes[pos..new_pos]);
match bytes[new_pos] {
b'<' => escaped.extend_from_slice(b"&lt;"),
b'>' => escaped.extend_from_slice(b"&gt;"),
b'\'' => escaped.extend_from_slice(b"&apos;"),
b'&' => escaped.extend_from_slice(b"&amp;"),
b'"' => escaped.extend_from_slice(b"&quot;"),

// This set of escapes handles characters that should be escaped
// in elements of xs:lists, because those characters works as
// delimiters of list elements
b'\t' => escaped.extend_from_slice(b"&#9;"),
b'\n' => escaped.extend_from_slice(b"&#10;"),
b'\r' => escaped.extend_from_slice(b"&#13;"),
b' ' => escaped.extend_from_slice(b"&#32;"),
_ => unreachable!(
"Only '<', '>','\', '&', '\"', '\\t', '\\r', '\\n', and ' ' are escaped"
),
}
pos = new_pos + 1;
}

if let Some(mut escaped) = escaped {
if let Some(raw) = bytes.get(pos..) {
escaped.extend_from_slice(raw);
}

// SAFETY: we operate on UTF-8 input and search for an one byte chars only,
// so all slices that was put to the `escaped` is a valid UTF-8 encoded strings
Cow::Owned(unsafe { String::from_utf8_unchecked(escaped) })
} else {
Cow::Borrowed(raw)
}
}
53 changes: 51 additions & 2 deletions apps/oxlint/src/output_formatter/default.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::io::{BufWriter, Stdout, Write};
use std::io::{BufWriter, ErrorKind, Stdout, Write};

use oxc_diagnostics::reporter::{DiagnosticReporter, GraphicalReporter};
use oxc_diagnostics::{reporter::DiagnosticReporter, Error, GraphicalReportHandler};
use oxc_linter::table::RuleTable;

use crate::output_formatter::InternalFormatter;
Expand All @@ -23,6 +23,55 @@ impl InternalFormatter for DefaultOutputFormatter {
}
}

/// Pretty-prints diagnostics. Primarily meant for human-readable output in a terminal.
///
/// See [`GraphicalReportHandler`] for how to configure colors, context lines, etc.
pub struct GraphicalReporter {
handler: GraphicalReportHandler,
}

impl Default for GraphicalReporter {
fn default() -> Self {
Self { handler: GraphicalReportHandler::new() }
}
}

impl DiagnosticReporter for GraphicalReporter {
fn finish(&mut self, writer: &mut BufWriter<Stdout>) {
writer
.flush()
.or_else(|e| {
// Do not panic when the process is skill (e.g. piping into `less`).
if matches!(e.kind(), ErrorKind::Interrupted | ErrorKind::BrokenPipe) {
Ok(())
} else {
Err(e)
}
})
.unwrap();
}

fn render_diagnostics(&mut self, writer: &mut BufWriter<Stdout>, s: &[u8]) {
writer
.write_all(s)
.or_else(|e| {
// Do not panic when the process is skill (e.g. piping into `less`).
if matches!(e.kind(), ErrorKind::Interrupted | ErrorKind::BrokenPipe) {
Ok(())
} else {
Err(e)
}
})
.unwrap();
}

fn render_error(&mut self, error: Error) -> Option<String> {
let mut output = String::new();
self.handler.render_report(&mut output, error.as_ref()).unwrap();
Some(output)
}
}

#[cfg(test)]
mod test {
use crate::output_formatter::{default::DefaultOutputFormatter, InternalFormatter};
Expand Down
76 changes: 74 additions & 2 deletions apps/oxlint/src/output_formatter/github.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
use std::io::{BufWriter, Stdout, Write};
use std::{
borrow::Cow,
io::{BufWriter, Stdout, Write},
};

use oxc_diagnostics::reporter::{DiagnosticReporter, GithubReporter};
use oxc_diagnostics::{
reporter::{DiagnosticReporter, Info},
Error, Severity,
};

use crate::output_formatter::InternalFormatter;

Expand All @@ -16,3 +22,69 @@ impl InternalFormatter for GithubOutputFormatter {
Box::new(GithubReporter)
}
}

/// Formats reports using [GitHub Actions
/// annotations](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message). Useful for reporting in CI.
struct GithubReporter;

impl DiagnosticReporter for GithubReporter {
fn finish(&mut self, writer: &mut BufWriter<Stdout>) {
writer.flush().unwrap();
}

fn render_diagnostics(&mut self, writer: &mut BufWriter<Stdout>, s: &[u8]) {
writer.write_all(s).unwrap();
}

fn render_error(&mut self, error: Error) -> Option<String> {
Some(format_github(&error))
}
}

fn format_github(diagnostic: &Error) -> String {
let Info { line, column, filename, message, severity, rule_id } = Info::new(diagnostic);
let severity = match severity {
Severity::Error => "error",
Severity::Warning | miette::Severity::Advice => "warning",
};
let title = rule_id.map_or(Cow::Borrowed("oxlint"), Cow::Owned);
let filename = escape_property(&filename);
let message = escape_data(&message);
format!(
"::{severity} file={filename},line={line},endLine={line},col={column},endColumn={column},title={title}::{message}\n"
)
}

fn escape_data(value: &str) -> String {
// Refs:
// - https://github.com/actions/runner/blob/a4c57f27477077e57545af79851551ff7f5632bd/src/Runner.Common/ActionCommand.cs#L18-L22
// - https://github.com/actions/toolkit/blob/fe3e7ce9a7f995d29d1fcfd226a32bca407f9dc8/packages/core/src/command.ts#L80-L94
let mut result = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\r' => result.push_str("%0D"),
'\n' => result.push_str("%0A"),
'%' => result.push_str("%25"),
_ => result.push(c),
}
}
result
}

fn escape_property(value: &str) -> String {
// Refs:
// - https://github.com/actions/runner/blob/a4c57f27477077e57545af79851551ff7f5632bd/src/Runner.Common/ActionCommand.cs#L25-L32
// - https://github.com/actions/toolkit/blob/fe3e7ce9a7f995d29d1fcfd226a32bca407f9dc8/packages/core/src/command.ts#L80-L94
let mut result = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\r' => result.push_str("%0D"),
'\n' => result.push_str("%0A"),
':' => result.push_str("%3A"),
',' => result.push_str("%2C"),
'%' => result.push_str("%25"),
_ => result.push(c),
}
}
result
}
44 changes: 43 additions & 1 deletion apps/oxlint/src/output_formatter/json.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use std::io::{BufWriter, Stdout, Write};

use oxc_diagnostics::reporter::{DiagnosticReporter, JsonReporter};
use oxc_diagnostics::{reporter::DiagnosticReporter, Error};
use oxc_linter::rules::RULES;
use oxc_linter::RuleCategory;

use miette::JSONReportHandler;

use crate::output_formatter::InternalFormatter;

#[derive(Debug, Default)]
Expand Down Expand Up @@ -37,3 +39,43 @@ impl InternalFormatter for JsonOutputFormatter {
Box::new(JsonReporter::default())
}
}

/// Renders reports as a JSON array of objects.
///
/// Note that, due to syntactic restrictions of JSON arrays, this reporter waits until all
/// diagnostics have been reported before writing them to the output stream.
#[derive(Default)]
struct JsonReporter {
diagnostics: Vec<Error>,
}

impl DiagnosticReporter for JsonReporter {
// NOTE: this output does not conform to eslint json format yet
// https://eslint.org/docs/latest/use/formatters/#json
fn finish(&mut self, writer: &mut BufWriter<Stdout>) {
writer.write_all(format_json(&mut self.diagnostics).as_bytes()).unwrap();
}

fn render_diagnostics(&mut self, _writer: &mut BufWriter<Stdout>, _s: &[u8]) {}

fn render_error(&mut self, error: Error) -> Option<String> {
self.diagnostics.push(error);
None
}
}

/// <https://github.com/fregante/eslint-formatters/tree/ae1fd9748596447d1fd09625c33d9e7ba9a3d06d/packages/eslint-formatter-json>
#[allow(clippy::print_stdout)]
fn format_json(diagnostics: &mut Vec<Error>) -> String {
let handler = JSONReportHandler::new();
let messages = diagnostics
.drain(..)
.map(|error| {
let mut output = String::from("\t");
handler.render_report(&mut output, error.as_ref()).unwrap();
output
})
.collect::<Vec<_>>()
.join(",\n");
format!("[\n{messages}\n]")
}
47 changes: 45 additions & 2 deletions apps/oxlint/src/output_formatter/unix.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
use std::io::{BufWriter, Stdout, Write};
use std::{
borrow::Cow,
io::{BufWriter, Stdout, Write},
};

use oxc_diagnostics::reporter::{DiagnosticReporter, UnixReporter};
use oxc_diagnostics::{
reporter::{DiagnosticReporter, Info},
Error, Severity,
};

use crate::output_formatter::InternalFormatter;

Expand All @@ -16,3 +22,40 @@ impl InternalFormatter for UnixOutputFormatter {
Box::new(UnixReporter::default())
}
}

#[derive(Default)]
struct UnixReporter {
total: usize,
}

impl DiagnosticReporter for UnixReporter {
fn finish(&mut self, writer: &mut BufWriter<Stdout>) {
let total = self.total;
if total > 0 {
let line = format!("\n{total} problem{}\n", if total > 1 { "s" } else { "" });
writer.write_all(line.as_bytes()).unwrap();
}
writer.flush().unwrap();
}

fn render_diagnostics(&mut self, writer: &mut BufWriter<Stdout>, s: &[u8]) {
writer.write_all(s).unwrap();
}

fn render_error(&mut self, error: Error) -> Option<String> {
self.total += 1;
Some(format_unix(&error))
}
}

/// <https://github.com/fregante/eslint-formatters/tree/ae1fd9748596447d1fd09625c33d9e7ba9a3d06d/packages/eslint-formatter-unix>
fn format_unix(diagnostic: &Error) -> String {
let Info { line, column, filename, message, severity, rule_id } = Info::new(diagnostic);
let severity = match severity {
Severity::Error => "Error",
_ => "Warning",
};
let rule_id =
rule_id.map_or_else(|| Cow::Borrowed(""), |rule_id| Cow::Owned(format!("/{rule_id}")));
format!("{filename}:{line}:{column}: {message} [{severity}{rule_id}]\n")
}
Loading

0 comments on commit 1e74eb0

Please sign in to comment.