Skip to content

Commit cb99815

Browse files
authored
Feature: Add SARIF output support (#9078)
## Summary Adds support for sarif v2.1.0 output to cli, usable via the output-format paramter. `ruff . --output-format=sarif` Includes a few changes I wasn't sure of, namely: * Adds a few derives for Clone & Copy, which I think could be removed with a little extra work as well. ## Test Plan I built and ran this against several large open source projects and verified that the output sarif was valid, using [Microsoft's SARIF validator tool](https://sarifweb.azurewebsites.net/Validation) I've also attached an output of the sarif generated by this version of ruff on the main branch of django at commit: b287af5dc9 [django_main_b287af5dc9_sarif.json](https://github.com/astral-sh/ruff/files/13626222/django_main_b287af5dc9_sarif.json) Note: this needs to be regenerated with the latest changes and confirmed. ## Open Points [ ] Convert to just using all Rules all the time [ ] Fix the issue with getting the file URI when compiling for web assembly
1 parent 45f6030 commit cb99815

File tree

8 files changed

+224
-3
lines changed

8 files changed

+224
-3
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ruff_cli/src/printer.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use ruff_linter::fs::relativize_path;
1313
use ruff_linter::logging::LogLevel;
1414
use ruff_linter::message::{
1515
AzureEmitter, Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter,
16-
JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, TextEmitter,
16+
JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, SarifEmitter, TextEmitter,
1717
};
1818
use ruff_linter::notify_user;
1919
use ruff_linter::registry::{AsRule, Rule};
@@ -291,6 +291,9 @@ impl Printer {
291291
SerializationFormat::Azure => {
292292
AzureEmitter.emit(writer, &diagnostics.messages, &context)?;
293293
}
294+
SerializationFormat::Sarif => {
295+
SarifEmitter.emit(writer, &diagnostics.messages, &context)?;
296+
}
294297
}
295298

296299
writer.flush()?;

crates/ruff_linter/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ toml = { workspace = true }
7171
typed-arena = { version = "2.0.2" }
7272
unicode-width = { workspace = true }
7373
unicode_names2 = { workspace = true }
74+
url = { version = "2.2.2" }
7475
wsl = { version = "0.1.0" }
7576

7677
[dev-dependencies]

crates/ruff_linter/src/message/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
1717
use ruff_notebook::NotebookIndex;
1818
use ruff_source_file::{SourceFile, SourceLocation};
1919
use ruff_text_size::{Ranged, TextRange, TextSize};
20+
pub use sarif::SarifEmitter;
2021
pub use text::TextEmitter;
2122

2223
mod azure;
@@ -28,6 +29,7 @@ mod json;
2829
mod json_lines;
2930
mod junit;
3031
mod pylint;
32+
mod sarif;
3133
mod text;
3234

3335
#[derive(Debug, PartialEq, Eq)]
+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
use std::io::Write;
2+
3+
use anyhow::Result;
4+
use serde::{Serialize, Serializer};
5+
use serde_json::json;
6+
7+
use ruff_source_file::OneIndexed;
8+
9+
use crate::codes::Rule;
10+
use crate::fs::normalize_path;
11+
use crate::message::{Emitter, EmitterContext, Message};
12+
use crate::registry::{AsRule, Linter, RuleNamespace};
13+
use crate::VERSION;
14+
15+
use strum::IntoEnumIterator;
16+
17+
pub struct SarifEmitter;
18+
19+
impl Emitter for SarifEmitter {
20+
fn emit(
21+
&mut self,
22+
writer: &mut dyn Write,
23+
messages: &[Message],
24+
_context: &EmitterContext,
25+
) -> Result<()> {
26+
let results = messages
27+
.iter()
28+
.map(SarifResult::from_message)
29+
.collect::<Result<Vec<_>>>()?;
30+
31+
let output = json!({
32+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
33+
"version": "2.1.0",
34+
"runs": [{
35+
"tool": {
36+
"driver": {
37+
"name": "ruff",
38+
"informationUri": "https://github.com/astral-sh/ruff",
39+
"rules": Rule::iter().map(SarifRule::from).collect::<Vec<_>>(),
40+
"version": VERSION.to_string(),
41+
}
42+
},
43+
"results": results,
44+
}],
45+
});
46+
serde_json::to_writer_pretty(writer, &output)?;
47+
Ok(())
48+
}
49+
}
50+
51+
#[derive(Debug, Clone)]
52+
struct SarifRule<'a> {
53+
name: &'a str,
54+
code: String,
55+
linter: &'a str,
56+
summary: &'a str,
57+
explanation: Option<&'a str>,
58+
url: Option<String>,
59+
}
60+
61+
impl From<Rule> for SarifRule<'_> {
62+
fn from(rule: Rule) -> Self {
63+
let code = rule.noqa_code().to_string();
64+
let (linter, _) = Linter::parse_code(&code).unwrap();
65+
Self {
66+
name: rule.into(),
67+
code,
68+
linter: linter.name(),
69+
summary: rule.message_formats()[0],
70+
explanation: rule.explanation(),
71+
url: rule.url(),
72+
}
73+
}
74+
}
75+
76+
impl Serialize for SarifRule<'_> {
77+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
78+
where
79+
S: Serializer,
80+
{
81+
json!({
82+
"id": self.code,
83+
"shortDescription": {
84+
"text": self.summary,
85+
},
86+
"fullDescription": {
87+
"text": self.explanation,
88+
},
89+
"help": {
90+
"text": self.summary,
91+
},
92+
"helpUri": self.url,
93+
"properties": {
94+
"id": self.code,
95+
"kind": self.linter,
96+
"name": self.name,
97+
"problem.severity": "error".to_string(),
98+
},
99+
})
100+
.serialize(serializer)
101+
}
102+
}
103+
104+
#[derive(Debug)]
105+
struct SarifResult {
106+
rule: Rule,
107+
level: String,
108+
message: String,
109+
uri: String,
110+
start_line: OneIndexed,
111+
start_column: OneIndexed,
112+
end_line: OneIndexed,
113+
end_column: OneIndexed,
114+
}
115+
116+
impl SarifResult {
117+
#[cfg(not(target_arch = "wasm32"))]
118+
fn from_message(message: &Message) -> Result<Self> {
119+
let start_location = message.compute_start_location();
120+
let end_location = message.compute_end_location();
121+
let path = normalize_path(message.filename());
122+
Ok(Self {
123+
rule: message.kind.rule(),
124+
level: "error".to_string(),
125+
message: message.kind.name.clone(),
126+
uri: url::Url::from_file_path(&path)
127+
.map_err(|()| anyhow::anyhow!("Failed to convert path to URL: {}", path.display()))?
128+
.to_string(),
129+
start_line: start_location.row,
130+
start_column: start_location.column,
131+
end_line: end_location.row,
132+
end_column: end_location.column,
133+
})
134+
}
135+
136+
#[cfg(target_arch = "wasm32")]
137+
#[allow(clippy::unnecessary_wraps)]
138+
fn from_message(message: &Message) -> Result<Self> {
139+
let start_location = message.compute_start_location();
140+
let end_location = message.compute_end_location();
141+
let path = normalize_path(message.filename());
142+
Ok(Self {
143+
rule: message.kind.rule(),
144+
level: "error".to_string(),
145+
message: message.kind.name.clone(),
146+
uri: path.display().to_string(),
147+
start_line: start_location.row,
148+
start_column: start_location.column,
149+
end_line: end_location.row,
150+
end_column: end_location.column,
151+
})
152+
}
153+
}
154+
155+
impl Serialize for SarifResult {
156+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157+
where
158+
S: Serializer,
159+
{
160+
json!({
161+
"level": self.level,
162+
"message": {
163+
"text": self.message,
164+
},
165+
"locations": [{
166+
"physicalLocation": {
167+
"artifactLocation": {
168+
"uri": self.uri,
169+
},
170+
"region": {
171+
"startLine": self.start_line,
172+
"startColumn": self.start_column,
173+
"endLine": self.end_line,
174+
"endColumn": self.end_column,
175+
}
176+
}
177+
}],
178+
"ruleId": self.rule.noqa_code().to_string(),
179+
})
180+
.serialize(serializer)
181+
}
182+
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
187+
use crate::message::tests::{capture_emitter_output, create_messages};
188+
use crate::message::SarifEmitter;
189+
190+
fn get_output() -> String {
191+
let mut emitter = SarifEmitter {};
192+
capture_emitter_output(&mut emitter, &create_messages())
193+
}
194+
195+
#[test]
196+
fn valid_json() {
197+
let content = get_output();
198+
serde_json::from_str::<serde_json::Value>(&content).unwrap();
199+
}
200+
201+
#[test]
202+
fn test_results() {
203+
let content = get_output();
204+
let sarif = serde_json::from_str::<serde_json::Value>(content.as_str()).unwrap();
205+
let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
206+
.as_array()
207+
.unwrap();
208+
let results = sarif["runs"][0]["results"].as_array().unwrap();
209+
assert_eq!(results.len(), 3);
210+
assert!(rules.len() > 3);
211+
}
212+
}

crates/ruff_linter/src/settings/types.rs

+1
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ pub enum SerializationFormat {
423423
Gitlab,
424424
Pylint,
425425
Azure,
426+
Sarif,
426427
}
427428

428429
impl Default for SerializationFormat {

docs/configuration.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ Options:
481481
--ignore-noqa
482482
Ignore any `# noqa` comments
483483
--output-format <OUTPUT_FORMAT>
484-
Output serialization format for violations [env: RUFF_OUTPUT_FORMAT=] [possible values: text, json, json-lines, junit, grouped, github, gitlab, pylint, azure]
484+
Output serialization format for violations [env: RUFF_OUTPUT_FORMAT=] [possible values: text, json, json-lines, junit, grouped, github, gitlab, pylint, azure, sarif]
485485
-o, --output-file <OUTPUT_FILE>
486486
Specify file to write the linter output to (default: stdout)
487487
--target-version <TARGET_VERSION>

ruff.schema.json

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)