Skip to content

Commit 11e9e49

Browse files
psteinroeclaude
andcommitted
feat(cli): implement json, json-pretty, and summary reporters
The CLI advertised these as valid --reporter values but they were not implemented, causing an error when used. Adds the three missing reporter variants with snapshot tests. JSON output shape aligned with Biome: summary object with numeric nanos duration, per-diagnostic message/category/location, command field, and zero-span fallback when source location is unavailable. Summary reporter reuses ConsoleTraversalSummary from terminal reporter and groups diagnostics by file with per-file error/warning counts. Closes #694 Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 77366ec commit 11e9e49

File tree

9 files changed

+478
-4
lines changed

9 files changed

+478
-4
lines changed

crates/pgls_cli/src/cli_options.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ pub enum CliReporter {
129129
Junit,
130130
/// Reports linter diagnostics using the [GitLab Code Quality report](https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool).
131131
GitLab,
132+
/// Diagnostics are printed as JSON
133+
Json,
134+
/// Diagnostics are printed as pretty-printed JSON
135+
JsonPretty,
136+
/// Only a summary of diagnostics is printed (counts, no individual diagnostics)
137+
Summary,
132138
}
133139

134140
impl CliReporter {
@@ -145,6 +151,9 @@ impl FromStr for CliReporter {
145151
"github" => Ok(Self::GitHub),
146152
"junit" => Ok(Self::Junit),
147153
"gitlab" => Ok(Self::GitLab),
154+
"json" => Ok(Self::Json),
155+
"json-pretty" => Ok(Self::JsonPretty),
156+
"summary" => Ok(Self::Summary),
148157
_ => Err(format!(
149158
"value {s:?} is not valid for the --reporter argument"
150159
)),
@@ -159,6 +168,9 @@ impl Display for CliReporter {
159168
CliReporter::GitHub => f.write_str("github"),
160169
CliReporter::Junit => f.write_str("junit"),
161170
CliReporter::GitLab => f.write_str("gitlab"),
171+
CliReporter::Json => f.write_str("json"),
172+
CliReporter::JsonPretty => f.write_str("json-pretty"),
173+
CliReporter::Summary => f.write_str("summary"),
162174
}
163175
}
164176
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
use crate::diagnostics::CliDiagnostic;
2+
use crate::reporter::{Report, ReportConfig, ReportWriter};
3+
use pgls_console::{Console, ConsoleExt, markup};
4+
use pgls_diagnostics::display::SourceFile;
5+
use pgls_diagnostics::{Error, PrintDescription, Resource, Severity};
6+
use serde::Serialize;
7+
8+
pub(crate) struct JsonReportWriter {
9+
pub pretty: bool,
10+
}
11+
12+
impl ReportWriter for JsonReportWriter {
13+
fn write(
14+
&mut self,
15+
console: &mut dyn Console,
16+
command_name: &str,
17+
report: &Report,
18+
config: &ReportConfig,
19+
) -> Result<(), CliDiagnostic> {
20+
let diagnostics: Vec<_> = report
21+
.diagnostics
22+
.iter()
23+
.filter(|d| d.severity() >= config.diagnostic_level)
24+
.filter(|d| {
25+
if d.tags().is_verbose() {
26+
config.verbose
27+
} else {
28+
true
29+
}
30+
})
31+
.map(to_json_report)
32+
.collect();
33+
34+
let summary = JsonSummary::from_report(report);
35+
36+
let output = JsonOutput {
37+
summary,
38+
diagnostics,
39+
command: command_name.to_string(),
40+
};
41+
42+
let serialized = if self.pretty {
43+
serde_json::to_string_pretty(&output)
44+
} else {
45+
serde_json::to_string(&output)
46+
}
47+
.map_err(|e| CliDiagnostic::io_error(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
48+
49+
console.log(markup!({ serialized }));
50+
Ok(())
51+
}
52+
}
53+
54+
#[derive(Serialize)]
55+
struct JsonOutput {
56+
summary: JsonSummary,
57+
diagnostics: Vec<JsonDiagnostic>,
58+
command: String,
59+
}
60+
61+
#[derive(Serialize)]
62+
#[serde(rename_all = "camelCase")]
63+
struct JsonSummary {
64+
changed: usize,
65+
unchanged: usize,
66+
duration: u128,
67+
errors: u32,
68+
warnings: u32,
69+
skipped: usize,
70+
skipped_diagnostics: u32,
71+
}
72+
73+
impl JsonSummary {
74+
fn from_report(report: &Report) -> Self {
75+
let (changed, unchanged, skipped) = report
76+
.traversal
77+
.as_ref()
78+
.map(|t| (t.changed, t.unchanged, t.skipped))
79+
.unwrap_or_default();
80+
Self {
81+
changed,
82+
unchanged,
83+
duration: report.duration.as_nanos(),
84+
errors: report.errors,
85+
warnings: report.warnings,
86+
skipped,
87+
skipped_diagnostics: report.skipped_diagnostics,
88+
}
89+
}
90+
}
91+
92+
#[derive(Serialize)]
93+
struct JsonDiagnostic {
94+
severity: &'static str,
95+
message: String,
96+
#[serde(skip_serializing_if = "Option::is_none")]
97+
category: Option<String>,
98+
#[serde(skip_serializing_if = "Option::is_none")]
99+
location: Option<JsonLocation>,
100+
}
101+
102+
#[derive(Serialize)]
103+
struct JsonLocation {
104+
path: String,
105+
start: JsonPosition,
106+
end: JsonPosition,
107+
}
108+
109+
#[derive(Serialize)]
110+
struct JsonPosition {
111+
line: usize,
112+
column: usize,
113+
}
114+
115+
fn to_json_report(diagnostic: &Error) -> JsonDiagnostic {
116+
let message = PrintDescription(diagnostic).to_string();
117+
let category = diagnostic.category().map(|c| c.name().to_string());
118+
let severity = match diagnostic.severity() {
119+
Severity::Hint => "hint",
120+
Severity::Information => "info",
121+
Severity::Warning => "warning",
122+
Severity::Error => "error",
123+
Severity::Fatal => "fatal",
124+
};
125+
126+
let location = to_location(diagnostic);
127+
128+
JsonDiagnostic {
129+
severity,
130+
message,
131+
category,
132+
location,
133+
}
134+
}
135+
136+
fn to_location(diagnostic: &Error) -> Option<JsonLocation> {
137+
let loc = diagnostic.location();
138+
let path = match loc.resource {
139+
Some(Resource::File(file)) => file.to_string(),
140+
_ => return None,
141+
};
142+
143+
match (loc.span, loc.source_code) {
144+
(Some(span), Some(source_code)) => {
145+
let source = SourceFile::new(source_code);
146+
let start = source.location(span.start()).ok()?;
147+
let end = source.location(span.end()).ok()?;
148+
Some(JsonLocation {
149+
path,
150+
start: JsonPosition {
151+
line: start.line_number.get(),
152+
column: start.column_number.get(),
153+
},
154+
end: JsonPosition {
155+
line: end.line_number.get(),
156+
column: end.column_number.get(),
157+
},
158+
})
159+
}
160+
_ => Some(JsonLocation {
161+
path,
162+
start: JsonPosition { line: 0, column: 0 },
163+
end: JsonPosition { line: 0, column: 0 },
164+
}),
165+
}
166+
}

crates/pgls_cli/src/reporter/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
pub(crate) mod github;
22
pub(crate) mod gitlab;
3+
pub(crate) mod json;
34
pub(crate) mod junit;
5+
pub(crate) mod summary;
46
pub(crate) mod terminal;
57

68
use crate::cli_options::{CliOptions, CliReporter};
@@ -39,6 +41,9 @@ pub enum ReportMode {
3941
GitHub,
4042
GitLab,
4143
Junit,
44+
Json,
45+
JsonPretty,
46+
Summary,
4247
}
4348

4449
impl From<CliReporter> for ReportMode {
@@ -48,6 +53,9 @@ impl From<CliReporter> for ReportMode {
4853
CliReporter::GitHub => Self::GitHub,
4954
CliReporter::Junit => Self::Junit,
5055
CliReporter::GitLab => Self::GitLab,
56+
CliReporter::Json => Self::Json,
57+
CliReporter::JsonPretty => Self::JsonPretty,
58+
CliReporter::Summary => Self::Summary,
5159
}
5260
}
5361
}
@@ -129,6 +137,9 @@ impl Reporter {
129137
ReportMode::GitHub => Box::new(github::GithubReportWriter),
130138
ReportMode::GitLab => Box::new(gitlab::GitLabReportWriter),
131139
ReportMode::Junit => Box::new(junit::JunitReportWriter),
140+
ReportMode::Json => Box::new(json::JsonReportWriter { pretty: false }),
141+
ReportMode::JsonPretty => Box::new(json::JsonReportWriter { pretty: true }),
142+
ReportMode::Summary => Box::new(summary::SummaryReportWriter),
132143
};
133144

134145
writer.write(console, command_name, payload, &self.config)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use crate::diagnostics::CliDiagnostic;
2+
use crate::reporter::terminal::{ConsoleDiagnosticSummary, ConsoleTraversalSummary};
3+
use crate::reporter::{Report, ReportConfig, ReportWriter};
4+
use pgls_console::fmt::{Display, Formatter};
5+
use pgls_console::{Console, ConsoleExt, markup};
6+
use pgls_diagnostics::{Error, Resource, Severity};
7+
use std::collections::BTreeMap;
8+
9+
pub(crate) struct SummaryReportWriter;
10+
11+
impl ReportWriter for SummaryReportWriter {
12+
fn write(
13+
&mut self,
14+
console: &mut dyn Console,
15+
command_name: &str,
16+
report: &Report,
17+
config: &ReportConfig,
18+
) -> Result<(), CliDiagnostic> {
19+
let file_diagnostics = collect_file_diagnostics(report, config);
20+
if !file_diagnostics.0.is_empty() {
21+
console.log(markup! {{ file_diagnostics }});
22+
}
23+
24+
if let Some(traversal) = &report.traversal {
25+
console.log(markup! {
26+
{ConsoleTraversalSummary(command_name, report, traversal)}
27+
});
28+
} else {
29+
console.log(markup! {
30+
{ConsoleDiagnosticSummary(command_name, report)}
31+
});
32+
}
33+
34+
Ok(())
35+
}
36+
}
37+
38+
#[derive(Debug, Default)]
39+
struct DiagnosticCounts {
40+
errors: usize,
41+
warnings: usize,
42+
}
43+
44+
impl DiagnosticCounts {
45+
fn track(&mut self, severity: Severity) {
46+
match severity {
47+
Severity::Error | Severity::Fatal => self.errors += 1,
48+
Severity::Warning => self.warnings += 1,
49+
_ => {}
50+
}
51+
}
52+
}
53+
54+
struct FileDiagnostics(BTreeMap<String, DiagnosticCounts>);
55+
56+
fn collect_file_diagnostics(report: &Report, config: &ReportConfig) -> FileDiagnostics {
57+
let mut files: BTreeMap<String, DiagnosticCounts> = BTreeMap::new();
58+
59+
for diagnostic in &report.diagnostics {
60+
if !should_emit(config, diagnostic) {
61+
continue;
62+
}
63+
64+
let path = match diagnostic.location().resource {
65+
Some(Resource::File(p)) => p.to_string(),
66+
_ => continue,
67+
};
68+
69+
files.entry(path).or_default().track(diagnostic.severity());
70+
}
71+
72+
FileDiagnostics(files)
73+
}
74+
75+
fn should_emit(config: &ReportConfig, diagnostic: &Error) -> bool {
76+
if diagnostic.severity() < config.diagnostic_level {
77+
return false;
78+
}
79+
80+
if diagnostic.tags().is_verbose() {
81+
config.verbose
82+
} else {
83+
true
84+
}
85+
}
86+
87+
impl Display for FileDiagnostics {
88+
fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> {
89+
for (path, counts) in &self.0 {
90+
fmt.write_str(path)?;
91+
fmt.write_str(": ")?;
92+
93+
let mut parts = Vec::new();
94+
if counts.errors > 0 {
95+
parts.push(format!(
96+
"{} {}",
97+
counts.errors,
98+
if counts.errors == 1 { "error" } else { "errors" }
99+
));
100+
}
101+
if counts.warnings > 0 {
102+
parts.push(format!(
103+
"{} {}",
104+
counts.warnings,
105+
if counts.warnings == 1 {
106+
"warning"
107+
} else {
108+
"warnings"
109+
}
110+
));
111+
}
112+
fmt.write_str(&parts.join(", "))?;
113+
fmt.write_str("\n")?;
114+
}
115+
Ok(())
116+
}
117+
}

crates/pgls_cli/src/reporter/terminal.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ impl fmt::Display for SummaryDetail {
130130
}
131131
}
132132

133-
struct ConsoleTraversalSummary<'a>(&'a str, &'a Report, &'a TraversalData);
133+
pub(crate) struct ConsoleTraversalSummary<'a>(pub &'a str, pub &'a Report, pub &'a TraversalData);
134134

135135
impl fmt::Display for ConsoleTraversalSummary<'_> {
136136
fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> {
@@ -168,7 +168,7 @@ impl fmt::Display for ConsoleTraversalSummary<'_> {
168168
}
169169
}
170170

171-
struct ConsoleDiagnosticSummary<'a>(&'a str, &'a Report);
171+
pub(crate) struct ConsoleDiagnosticSummary<'a>(pub &'a str, pub &'a Report);
172172

173173
impl fmt::Display for ConsoleDiagnosticSummary<'_> {
174174
fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> {

0 commit comments

Comments
 (0)