diff --git a/crates/ruff/src/printer.rs b/crates/ruff/src/printer.rs index c44150602bdce..3931a5a13df3a 100644 --- a/crates/ruff/src/printer.rs +++ b/crates/ruff/src/printer.rs @@ -13,7 +13,8 @@ use ruff_linter::fs::relativize_path; use ruff_linter::logging::LogLevel; use ruff_linter::message::{ AzureEmitter, Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, - JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, SarifEmitter, TextEmitter, + JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, RdjsonEmitter, SarifEmitter, + TextEmitter, }; use ruff_linter::notify_user; use ruff_linter::registry::{AsRule, Rule}; @@ -242,6 +243,9 @@ impl Printer { SerializationFormat::Json => { JsonEmitter.emit(writer, &diagnostics.messages, &context)?; } + SerializationFormat::Rdjson => { + RdjsonEmitter.emit(writer, &diagnostics.messages, &context)?; + } SerializationFormat::JsonLines => { JsonLinesEmitter.emit(writer, &diagnostics.messages, &context)?; } diff --git a/crates/ruff_linter/src/message/mod.rs b/crates/ruff_linter/src/message/mod.rs index 2f44de44eda71..7e95fc9d14cba 100644 --- a/crates/ruff_linter/src/message/mod.rs +++ b/crates/ruff_linter/src/message/mod.rs @@ -13,6 +13,7 @@ pub use json::JsonEmitter; pub use json_lines::JsonLinesEmitter; pub use junit::JunitEmitter; pub use pylint::PylintEmitter; +pub use rdjson::RdjsonEmitter; use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix}; use ruff_notebook::NotebookIndex; use ruff_source_file::{SourceFile, SourceLocation}; @@ -29,6 +30,7 @@ mod json; mod json_lines; mod junit; mod pylint; +mod rdjson; mod sarif; mod text; diff --git a/crates/ruff_linter/src/message/rdjson.rs b/crates/ruff_linter/src/message/rdjson.rs new file mode 100644 index 0000000000000..9d3ff50411f2a --- /dev/null +++ b/crates/ruff_linter/src/message/rdjson.rs @@ -0,0 +1,138 @@ +use std::io::Write; + +use serde::ser::SerializeSeq; +use serde::{Serialize, Serializer}; +use serde_json::{json, Value}; + +use ruff_diagnostics::Edit; +use ruff_source_file::SourceCode; +use ruff_text_size::Ranged; + +use crate::message::{Emitter, EmitterContext, Message, SourceLocation}; +use crate::registry::AsRule; + +#[derive(Default)] +pub struct RdjsonEmitter; + +impl Emitter for RdjsonEmitter { + fn emit( + &mut self, + writer: &mut dyn Write, + messages: &[Message], + _context: &EmitterContext, + ) -> anyhow::Result<()> { + serde_json::to_writer_pretty( + writer, + &json!({ + "source": { + "name": "ruff", + "url": "https://docs.astral.sh/ruff", + }, + "severity": "warning", + "diagnostics": &ExpandedMessages{ messages } + }), + )?; + + Ok(()) + } +} + +struct ExpandedMessages<'a> { + messages: &'a [Message], +} + +impl Serialize for ExpandedMessages<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut s = serializer.serialize_seq(Some(self.messages.len()))?; + + for message in self.messages { + let value = message_to_rdjson_value(message); + s.serialize_element(&value)?; + } + + s.end() + } +} + +fn message_to_rdjson_value(message: &Message) -> Value { + let source_code = message.file.to_source_code(); + + let start_location = source_code.source_location(message.start()); + let end_location = source_code.source_location(message.end()); + + if let Some(fix) = message.fix.as_ref() { + json!({ + "message": message.kind.body, + "location": { + "path": message.filename(), + "range": rdjson_range(&start_location, &end_location), + }, + "code": { + "value": message.kind.rule().noqa_code().to_string(), + "url": message.kind.rule().url(), + }, + "suggestions": rdjson_suggestions(fix.edits(), &source_code), + }) + } else { + json!({ + "message": message.kind.body, + "location": { + "path": message.filename(), + "range": rdjson_range(&start_location, &end_location), + }, + "code": { + "value": message.kind.rule().noqa_code().to_string(), + "url": message.kind.rule().url(), + }, + }) + } +} + +fn rdjson_suggestions(edits: &[Edit], source_code: &SourceCode) -> Value { + Value::Array( + edits + .iter() + .map(|edit| { + let location = source_code.source_location(edit.start()); + let end_location = source_code.source_location(edit.end()); + + json!({ + "range": rdjson_range(&location, &end_location), + "text": edit.content().unwrap_or_default(), + }) + }) + .collect(), + ) +} + +fn rdjson_range(start: &SourceLocation, end: &SourceLocation) -> Value { + json!({ + "start": { + "line": start.row, + "column": start.column, + }, + "end": { + "line": end.row, + "column": end.column, + }, + }) +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + + use crate::message::tests::{capture_emitter_output, create_messages}; + use crate::message::RdjsonEmitter; + + #[test] + fn output() { + let mut emitter = RdjsonEmitter; + let content = capture_emitter_output(&mut emitter, &create_messages()); + + assert_snapshot!(content); + } +} diff --git a/crates/ruff_linter/src/message/snapshots/ruff_linter__message__rdjson__tests__output.snap b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__rdjson__tests__output.snap new file mode 100644 index 0000000000000..cbb8d6c632796 --- /dev/null +++ b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__rdjson__tests__output.snap @@ -0,0 +1,103 @@ +--- +source: crates/ruff_linter/src/message/rdjson.rs +expression: content +--- +{ + "diagnostics": [ + { + "code": { + "url": "https://docs.astral.sh/ruff/rules/unused-import", + "value": "F401" + }, + "location": { + "path": "fib.py", + "range": { + "end": { + "column": 10, + "line": 1 + }, + "start": { + "column": 8, + "line": 1 + } + } + }, + "message": "`os` imported but unused", + "suggestions": [ + { + "range": { + "end": { + "column": 1, + "line": 2 + }, + "start": { + "column": 1, + "line": 1 + } + }, + "text": "" + } + ] + }, + { + "code": { + "url": "https://docs.astral.sh/ruff/rules/unused-variable", + "value": "F841" + }, + "location": { + "path": "fib.py", + "range": { + "end": { + "column": 6, + "line": 6 + }, + "start": { + "column": 5, + "line": 6 + } + } + }, + "message": "Local variable `x` is assigned to but never used", + "suggestions": [ + { + "range": { + "end": { + "column": 10, + "line": 6 + }, + "start": { + "column": 5, + "line": 6 + } + }, + "text": "" + } + ] + }, + { + "code": { + "url": "https://docs.astral.sh/ruff/rules/undefined-name", + "value": "F821" + }, + "location": { + "path": "undef.py", + "range": { + "end": { + "column": 5, + "line": 1 + }, + "start": { + "column": 4, + "line": 1 + } + } + }, + "message": "Undefined name `a`" + } + ], + "severity": "warning", + "source": { + "name": "ruff", + "url": "https://docs.astral.sh/ruff" + } +} diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index 877d705bb935e..25f7223b76aea 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -515,6 +515,7 @@ pub enum SerializationFormat { Github, Gitlab, Pylint, + Rdjson, Azure, Sarif, } @@ -532,6 +533,7 @@ impl Display for SerializationFormat { Self::Github => write!(f, "github"), Self::Gitlab => write!(f, "gitlab"), Self::Pylint => write!(f, "pylint"), + Self::Rdjson => write!(f, "rdjson"), Self::Azure => write!(f, "azure"), Self::Sarif => write!(f, "sarif"), } diff --git a/docs/configuration.md b/docs/configuration.md index 8f38279e25d27..d675a36da1261 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -599,7 +599,7 @@ Options: format is "concise". In preview mode, the default serialization format is "full" [env: RUFF_OUTPUT_FORMAT=] [possible values: text, concise, full, json, json-lines, junit, grouped, github, gitlab, - pylint, azure, sarif] + pylint, rdjson, azure, sarif] -o, --output-file Specify file to write the linter output to (default: stdout) [env: RUFF_OUTPUT_FILE=] diff --git a/ruff.schema.json b/ruff.schema.json index b146d9b74bcc2..936eeb575d63b 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3947,6 +3947,7 @@ "github", "gitlab", "pylint", + "rdjson", "azure", "sarif" ]