use crate::blocks::BlockSeverity;
use crate::repo_path::RepoPath;
use crate::validators::{SimpleDiagnostic, Violation};
use crate::violation_address::ViolationAddress;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
const SARIF_VERSION: &str = "2.1.0";
const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
const TOOL_NAME: &str = "blockwatch";
const ADDRESS_FINGERPRINT_KEY: &str = "blockwatchAddress/v1";
const _: () = assert!(
!env!("CARGO_PKG_REPOSITORY").is_empty(),
"the package needs a repository, which the documentation links are built from"
);
const COLUMN_KIND: &str = "unicodeCodePoints";
const PATH_UNSAFE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
const VALIDATOR_DESCRIPTIONS: &[(&str, &str)] = &[
(
"affects",
"Requires co-dependent blocks to be updated together.",
),
("check-ai", "Checks a block with AI."),
("check-lua", "Checks a block with a custom Lua script."),
(
"keep-sorted",
"Requires the lines of a block to stay in order.",
),
(
"keep-unique",
"Requires the lines of a block to stay free of duplicates.",
),
("line-count", "Constrains how many lines a block has."),
(
"line-pattern",
"Requires every line of a block to match a pattern.",
),
(
"same-as",
"Requires two places to keep holding the same value.",
),
];
#[derive(Serialize, Debug)]
pub struct SarifLog<'a> {
#[serde(rename = "$schema")]
schema: &'static str,
version: &'static str,
runs: [Run<'a>; 1],
}
impl<'a> SarifLog<'a> {
pub fn new(violations: &'a HashMap<RepoPath, Vec<Violation>>) -> Self {
let diagnostics = diagnostics_in_stable_order(violations);
let rules = rules_that_fired(&diagnostics);
let results = sarif_results(&diagnostics, &rules);
Self {
schema: SARIF_SCHEMA,
version: SARIF_VERSION,
runs: [Run {
tool: Tool {
driver: ToolComponent::new(rules),
},
column_kind: COLUMN_KIND,
results,
}],
}
}
}
fn diagnostics_in_stable_order(
violations: &HashMap<RepoPath, Vec<Violation>>,
) -> Vec<(&RepoPath, SimpleDiagnostic<'_>)> {
violations
.iter()
.collect::<BTreeMap<_, _>>()
.into_iter()
.flat_map(|(file, file_violations)| {
file_violations
.iter()
.map(move |violation| (file, violation.as_simple_diagnostic()))
})
.collect()
}
fn rules_that_fired(diagnostics: &[(&RepoPath, SimpleDiagnostic<'_>)]) -> Vec<ReportingDescriptor> {
diagnostics
.iter()
.map(|(_, diagnostic)| diagnostic.code())
.collect::<BTreeSet<_>>()
.into_iter()
.map(ReportingDescriptor::new)
.collect()
}
fn sarif_results<'a>(
diagnostics: &[(&RepoPath, SimpleDiagnostic<'a>)],
rules: &[ReportingDescriptor],
) -> Vec<SarifResult<'a>> {
diagnostics
.iter()
.map(|(file, diagnostic)| {
let rule_index = rules
.iter()
.position(|rule| rule.id == diagnostic.code())
.expect("every rule that reported a diagnostic is described");
SarifResult::new(file, diagnostic, rule_index)
})
.collect()
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Run<'a> {
tool: Tool,
column_kind: &'static str,
results: Vec<SarifResult<'a>>,
}
#[derive(Serialize, Debug)]
struct Tool {
driver: ToolComponent,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ToolComponent {
name: &'static str,
version: &'static str,
semantic_version: &'static str,
information_uri: &'static str,
rules: Vec<ReportingDescriptor>,
}
impl ToolComponent {
fn new(rules: Vec<ReportingDescriptor>) -> Self {
Self {
name: TOOL_NAME,
version: env!("CARGO_PKG_VERSION"),
semantic_version: env!("CARGO_PKG_VERSION"),
information_uri: env!("CARGO_PKG_REPOSITORY"),
rules,
}
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ReportingDescriptor {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
short_description: Option<Message>,
help_uri: String,
}
impl ReportingDescriptor {
fn new(validator: &str) -> Self {
Self {
id: validator.to_string(),
name: validator.to_string(),
short_description: validator_description(validator).map(Message::new),
help_uri: help_uri(validator),
}
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct SarifResult<'a> {
rule_id: &'a str,
rule_index: usize,
level: &'static str,
message: Message,
locations: [Location; 1],
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
partial_fingerprints: BTreeMap<&'static str, String>,
#[serde(skip_serializing_if = "Option::is_none")]
suppressions: Option<[Suppression; 1]>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<Properties<'a>>,
}
impl<'a> SarifResult<'a> {
fn new(file: &RepoPath, diagnostic: &SimpleDiagnostic<'a>, rule_index: usize) -> Self {
Self {
rule_id: diagnostic.code(),
rule_index,
level: sarif_level(diagnostic.severity()),
message: Message::new(diagnostic.message()),
locations: [Location::new(file, diagnostic)],
partial_fingerprints: diagnostic
.address()
.map(|address| BTreeMap::from([(ADDRESS_FINGERPRINT_KEY, address.to_string())]))
.unwrap_or_default(),
suppressions: diagnostic
.is_suppressed()
.then_some([Suppression { kind: "external" }]),
properties: Properties::new(diagnostic),
}
}
}
#[derive(Serialize, Debug)]
struct Properties<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<&'a ViolationAddress>,
#[serde(skip_serializing_if = "Option::is_none")]
data: &'a Option<serde_json::Value>,
}
impl<'a> Properties<'a> {
fn new(diagnostic: &SimpleDiagnostic<'a>) -> Option<Self> {
let (address, data) = (diagnostic.address(), diagnostic.data());
(address.is_some() || data.is_some()).then_some(Self { address, data })
}
}
#[derive(Serialize, Debug)]
struct Suppression {
kind: &'static str,
}
#[derive(Serialize, Debug)]
struct Message {
text: String,
}
impl Message {
fn new(text: &str) -> Self {
Self {
text: text.to_string(),
}
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Location {
physical_location: PhysicalLocation,
}
impl Location {
fn new(file: &RepoPath, diagnostic: &SimpleDiagnostic<'_>) -> Self {
let range = diagnostic.range();
Self {
physical_location: PhysicalLocation {
artifact_location: ArtifactLocation {
uri: utf8_percent_encode(file.as_str(), PATH_UNSAFE).to_string(),
},
region: Region {
start_line: range.start().line,
start_column: range.start().character,
end_line: range.end().line,
end_column: range.end().character,
},
},
}
}
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct PhysicalLocation {
artifact_location: ArtifactLocation,
region: Region,
}
#[derive(Serialize, Debug)]
struct ArtifactLocation {
uri: String,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Region {
start_line: usize,
start_column: usize,
end_line: usize,
end_column: usize,
}
fn sarif_level(severity: BlockSeverity) -> &'static str {
match severity {
BlockSeverity::Error => "error",
BlockSeverity::Warning => "warning",
BlockSeverity::Info | BlockSeverity::Hint => "note",
}
}
fn validator_description(validator: &str) -> Option<&'static str> {
VALIDATOR_DESCRIPTIONS
.iter()
.find(|(name, _)| *name == validator)
.map(|(_, description)| *description)
}
fn help_uri(validator: &str) -> String {
format!(
"{}/blob/v{}/docs/validators/{validator}.md",
env!("CARGO_PKG_REPOSITORY"),
env!("CARGO_PKG_VERSION"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Position;
use crate::blocks::Block;
use crate::validators::ViolationRange;
use assert_json_diff::assert_json_include;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::Path;
fn repo_path(path: &str) -> RepoPath {
RepoPath::from_reference(path).expect("a repository-relative path")
}
fn block(name: Option<&str>, severity: &str) -> Block {
let mut attributes = HashMap::from([("severity".to_string(), severity.to_string())]);
if let Some(name) = name {
attributes.insert("name".to_string(), name.to_string());
}
Block::new(
attributes,
Position::new(1, 1)..=Position::new(1, 1),
0..0,
Position::new(1, 1)..Position::new(1, 1),
)
}
fn violation(
file: &RepoPath,
name: Option<&str>,
severity: &str,
code: &str,
violation_text: Option<&str>,
) -> Violation {
Violation::new(
ViolationRange::new(Position::new(3, 1), Position::new(3, 4)),
file,
&block(name, severity),
code.to_string(),
format!("{code} is unhappy"),
violation_text,
None,
)
.expect("the block declares a known severity")
}
fn violations(entries: Vec<(RepoPath, Vec<Violation>)>) -> HashMap<RepoPath, Vec<Violation>> {
entries.into_iter().collect()
}
fn log_value(violations: &HashMap<RepoPath, Vec<Violation>>) -> Value {
serde_json::to_value(SarifLog::new(violations)).expect("the log serializes")
}
fn documentation_link(validator: &str) -> String {
format!(
"{}/blob/v{}/docs/validators/{validator}.md",
env!("CARGO_PKG_REPOSITORY"),
env!("CARGO_PKG_VERSION")
)
}
#[test]
fn violation_is_reported_as_a_result_at_the_place_it_was_found() {
let file = repo_path("src/lib.rs");
let violations = violations(vec![(
file.clone(),
vec![violation(
&file,
Some("languages"),
"error",
"keep-sorted",
Some("rust"),
)],
)]);
let result = log_value(&violations)["runs"][0]["results"][0].clone();
let address = "src/lib.rs:languages:keep-sorted:6f66c727";
assert_eq!(
result,
json!({
"ruleId": "keep-sorted",
"ruleIndex": 0,
"level": "error",
"message": {"text": "keep-sorted is unhappy"},
"locations": [{
"physicalLocation": {
"artifactLocation": {"uri": "src/lib.rs"},
"region": {"startLine": 3, "startColumn": 1, "endLine": 3, "endColumn": 4},
},
}],
"partialFingerprints": {ADDRESS_FINGERPRINT_KEY: address},
"properties": {"address": address},
})
);
}
#[test]
fn no_violations_produce_a_log_with_no_results() {
let log = log_value(&HashMap::new());
assert_eq!(log["runs"][0]["results"], json!([]), "{log}");
assert_eq!(
log["runs"][0]["tool"]["driver"]["rules"],
json!([]),
"{log}"
);
}
#[test]
fn log_contains_correct_metadata_fields() {
let log = log_value(&HashMap::new());
assert_json_include!(
actual: log,
expected: json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "blockwatch",
"version": env!("CARGO_PKG_VERSION"),
"semanticVersion": env!("CARGO_PKG_VERSION"),
"informationUri": env!("CARGO_PKG_REPOSITORY"),
},
},
"columnKind": "unicodeCodePoints",
}],
})
);
}
#[test]
fn severity_below_a_warning_is_reported_as_a_note() {
for severity in [
BlockSeverity::Error,
BlockSeverity::Warning,
BlockSeverity::Info,
BlockSeverity::Hint,
] {
match severity {
BlockSeverity::Error => {
assert_eq!(sarif_level(severity), "error");
}
BlockSeverity::Warning => {
assert_eq!(sarif_level(severity), "warning");
}
BlockSeverity::Info => {
assert_eq!(sarif_level(severity), "note");
}
BlockSeverity::Hint => {
assert_eq!(sarif_level(severity), "note");
}
}
}
}
#[test]
fn only_the_validators_that_reported_something_are_described_as_rules() {
let file = repo_path("src/lib.rs");
let violations = violations(vec![(
file.clone(),
vec![
violation(&file, Some("languages"), "warning", "line-count", None),
violation(&file, Some("languages"), "error", "keep-sorted", Some("a")),
],
)]);
let log = log_value(&violations);
let rules = log["runs"][0]["tool"]["driver"]["rules"].clone();
assert_eq!(
rules,
json!([
{
"id": "keep-sorted",
"name": "keep-sorted",
"shortDescription": {"text": "Requires the lines of a block to stay in order."},
"helpUri": documentation_link("keep-sorted"),
},
{
"id": "line-count",
"name": "line-count",
"shortDescription": {"text": "Constrains how many lines a block has."},
"helpUri": documentation_link("line-count"),
},
])
);
for result in log["runs"][0]["results"].as_array().expect("results") {
let index = result["ruleIndex"].as_u64().expect("a rule index") as usize;
assert_eq!(rules[index]["id"], result["ruleId"]);
}
}
#[test]
fn suppressed_violation_is_reported_with_an_external_suppression() {
let file = repo_path("src/lib.rs");
let mut suppressed = violation(&file, Some("languages"), "error", "line-count", None);
suppressed.suppress();
let violations = violations(vec![(file, vec![suppressed])]);
let result = log_value(&violations)["runs"][0]["results"][0].clone();
assert_json_include!(
actual: result,
expected: json!({"level": "error", "suppressions": [{"kind": "external"}]})
);
}
#[test]
fn violation_on_an_unnamed_block_carries_no_fingerprint() {
let file = repo_path("src/lib.rs");
let violations = violations(vec![(
file.clone(),
vec![violation(&file, None, "error", "keep-sorted", Some("rust"))],
)]);
let result = log_value(&violations)["runs"][0]["results"][0].clone();
assert_eq!(
json!({
"partialFingerprints": result.get("partialFingerprints"),
"properties": result.get("properties"),
}),
json!({"partialFingerprints": null, "properties": null}),
"{result}"
);
}
#[test]
fn special_characters_in_path_are_encoded() {
let file = repo_path("src/is#1 note.py");
let violations = violations(vec![(
file.clone(),
vec![violation(
&file,
Some("a"),
"error",
"keep-sorted",
Some("x"),
)],
)]);
let result = log_value(&violations)["runs"][0]["results"][0].clone();
assert_json_include!(
actual: result,
expected: json!({
"locations": [{
"physicalLocation": {
"artifactLocation": {"uri": "src/is%231%20note.py"},
},
}],
})
);
}
#[test]
fn files_found_in_any_order_are_reported_in_path_order() {
let (first, second) = (repo_path("a.py"), repo_path("b.py"));
let ordered = violations(vec![
(
first.clone(),
vec![violation(
&first,
Some("a"),
"error",
"keep-sorted",
Some("x"),
)],
),
(
second.clone(),
vec![violation(
&second,
Some("b"),
"error",
"keep-sorted",
Some("y"),
)],
),
]);
let uris: Vec<String> = log_value(&ordered)["runs"][0]["results"]
.as_array()
.expect("results")
.iter()
.map(|result| {
result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"].to_string()
})
.collect();
assert_eq!(uris, vec!["\"a.py\"".to_string(), "\"b.py\"".to_string()]);
}
#[test]
fn every_registered_validator_is_described() {
let described: Vec<&str> = VALIDATOR_DESCRIPTIONS
.iter()
.map(|(name, _)| *name)
.collect();
let mut registered = crate::validators::validator_names();
registered.sort_unstable();
let mut described_sorted = described.clone();
described_sorted.sort_unstable();
assert_eq!(
described_sorted, registered,
"every validator needs a description, and no description may outlive its validator"
);
}
#[test]
fn every_described_validator_has_the_documentation_page_its_help_uri_points_at() {
for (validator, _) in VALIDATOR_DESCRIPTIONS {
let page = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("docs/validators")
.join(format!("{validator}.md"));
assert!(
page.exists(),
"{validator} has no documentation page at {}, so its helpUri would be a dead link",
page.display()
);
}
}
}