use std::path::Path;
use serde_json::json;
use crate::{
config::{Config, RuleLevel},
rules::Violation,
scanner::create_all_rules,
};
pub fn generate_sarif(violations: &[Violation], config: &Config, project_root: &Path) -> String {
let rules = generate_rules_metadata(config);
let results = violations
.iter()
.map(|v| generate_result(v, config, project_root))
.collect::<Vec<_>>();
let sarif = json!({
"version": "2.1.0",
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"runs": [{
"tool": {
"driver": {
"name": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
"informationUri": env!("CARGO_PKG_REPOSITORY"),
"rules": rules,
}
},
"results": results,
}]
});
serde_json::to_string_pretty(&sarif).unwrap()
}
fn generate_rules_metadata(config: &Config) -> Vec<serde_json::Value> {
let rules = create_all_rules(config);
rules
.iter()
.filter(|entry| entry.level.is_enabled())
.map(|entry| {
json!({
"id": entry.rule.name(),
"shortDescription": {
"text": entry.rule.description(),
},
"fullDescription": {
"text": entry.rule.description(),
},
"defaultConfiguration": {
"level": rule_level_to_sarif_level(entry.level),
},
"properties": {
"category": entry.rule.category().as_str(),
"tags": [entry.rule.category().as_str()],
},
})
})
.collect()
}
fn generate_result(
violation: &Violation,
config: &Config,
project_root: &Path,
) -> serde_json::Value {
let relative_path = violation
.file
.strip_prefix(project_root)
.unwrap_or(&violation.file)
.display()
.to_string();
let mut result = json!({
"ruleId": violation.rule,
"level": rule_level_to_sarif_level(violation.level),
"message": {
"text": violation.message,
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": relative_path,
},
"region": {
"startLine": violation.line,
"startColumn": violation.column,
}
}
}]
});
if !violation.fixes.is_empty() {
if let Ok(content) = std::fs::read(&violation.file) {
let mut replacements = Vec::new();
for fix in &violation.fixes {
if let (Some(start_pos), Some(end_pos)) = (
byte_offset_to_position(&content, fix.start_byte),
byte_offset_to_position(&content, fix.end_byte),
) {
let deleted_region = json!({
"startLine": start_pos.0,
"startColumn": start_pos.1,
"endLine": end_pos.0,
"endColumn": end_pos.1,
});
let mut replacement = json!({
"deletedRegion": deleted_region,
});
if let Some(ref text) = fix.replacement {
replacement["insertedContent"] = json!({"text": text});
}
replacements.push(replacement);
}
}
if !replacements.is_empty() {
result["fixes"] = json!([{
"description": {
"text": format!("Apply {}", violation.rule),
},
"artifactChanges": [{
"artifactLocation": {
"uri": relative_path,
},
"replacements": replacements,
}]
}]);
}
}
}
if let Some(ref editor_url) = config.editor_url {
let url = editor_url
.replace("{path}", &violation.file.display().to_string())
.replace("{line}", &violation.line.to_string())
.replace("{column}", &violation.column.to_string());
result["hovers"] = json!([{
"text": format!("Open in editor: {}", url),
}]);
}
result
}
fn rule_level_to_sarif_level(level: RuleLevel) -> &'static str {
match level {
RuleLevel::Error => "error",
RuleLevel::Warn => "warning",
RuleLevel::Ignore => "none", }
}
fn byte_offset_to_position(content: &[u8], offset: usize) -> Option<(usize, usize)> {
if offset > content.len() {
return None;
}
let mut line = 1;
let mut column = 1;
for (i, &byte) in content.iter().enumerate() {
if i == offset {
return Some((line, column));
}
if byte == b'\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
if offset == content.len() {
Some((line, column))
} else {
None
}
}